chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { executeQuery } from "~/services/queryService.server";
|
||||
import {
|
||||
QueryWidget,
|
||||
type QueryWidgetConfig,
|
||||
type QueryWidgetData,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { useElementVisibility } from "~/hooks/useElementVisibility";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
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<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
stats: { elapsed_ns: string } | null;
|
||||
hiddenColumns: string[] | null;
|
||||
reachedMaxRows: boolean;
|
||||
periodClipped: number | null;
|
||||
maxQueryPeriod: number | undefined;
|
||||
timeRange: { from: string; to: string };
|
||||
};
|
||||
};
|
||||
|
||||
const MetricWidgetQuery = z.object({
|
||||
query: z.string(),
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
scope: Scope,
|
||||
period: z.string().nullable(),
|
||||
from: z.string().nullable(),
|
||||
to: z.string().nullable(),
|
||||
taskIdentifiers: z.array(z.string()).optional(),
|
||||
queues: z.array(z.string()).optional(),
|
||||
responseModels: z.array(z.string()).optional(),
|
||||
promptSlugs: z.array(z.string()).optional(),
|
||||
promptVersions: z.array(z.number()).optional(),
|
||||
operations: z.array(z.string()).optional(),
|
||||
providers: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const data = await request.json();
|
||||
const submission = MetricWidgetQuery.safeParse(data);
|
||||
|
||||
if (!submission.success) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: "Invalid input",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
scope,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
taskIdentifiers,
|
||||
queues,
|
||||
responseModels,
|
||||
promptSlugs,
|
||||
promptVersions,
|
||||
operations,
|
||||
providers,
|
||||
tags: _tags,
|
||||
} = submission.data;
|
||||
|
||||
// Check they should be able to access it
|
||||
const hasAccess = await hasAccessToEnvironment({
|
||||
environmentId,
|
||||
projectId,
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: "You don't have permission for this resource",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const queryResult = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
scope,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
taskIdentifiers,
|
||||
queues,
|
||||
responseModels,
|
||||
promptSlugs,
|
||||
promptVersions,
|
||||
operations,
|
||||
providers,
|
||||
// Set higher concurrency if many widgets are on screen at once
|
||||
customOrgConcurrencyLimit: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT,
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: queryResult.error.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true as const,
|
||||
data: {
|
||||
rows: queryResult.result.rows,
|
||||
columns: queryResult.result.columns,
|
||||
stats: queryResult.result.stats,
|
||||
hiddenColumns: queryResult.result.hiddenColumns ?? null,
|
||||
reachedMaxRows: queryResult.result.reachedMaxRows,
|
||||
periodClipped: queryResult.periodClipped,
|
||||
maxQueryPeriod: queryResult.maxQueryPeriod,
|
||||
timeRange: {
|
||||
from: queryResult.timeRange.from.toISOString(),
|
||||
to: queryResult.timeRange.to.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MetricWidgetProps = {
|
||||
/** Unique key for this widget - used to identify the fetcher */
|
||||
widgetKey: string;
|
||||
title: string;
|
||||
config: QueryWidgetConfig;
|
||||
refreshIntervalMs?: number;
|
||||
isResizing?: boolean;
|
||||
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 */
|
||||
onDuplicate?: (data: QueryWidgetData) => void;
|
||||
/** Column names to hide from table display but keep in row data (useful for linking) */
|
||||
hiddenColumns?: string[];
|
||||
} & z.infer<typeof MetricWidgetQuery>;
|
||||
|
||||
export function MetricWidget({
|
||||
widgetKey,
|
||||
title,
|
||||
config,
|
||||
refreshIntervalMs,
|
||||
isResizing,
|
||||
isDraggable,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
hiddenColumns,
|
||||
...props
|
||||
}: MetricWidgetProps) {
|
||||
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isDirtyRef = useRef(false);
|
||||
|
||||
// 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(() => {
|
||||
if (!isVisibleRef.current) {
|
||||
isDirtyRef.current = true;
|
||||
return;
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
|
||||
// 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(propsRef.current),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
try {
|
||||
return (await res.json()) as MetricWidgetActionResponse;
|
||||
} catch {
|
||||
throw new Error(`Request failed (${res.status})`);
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setResponse(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (!controller.signal.aborted) {
|
||||
// Only surface the error if there's no existing successful data to preserve
|
||||
setResponse((prev) =>
|
||||
prev?.success ? prev : { success: false, error: err.message || "Network error" }
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track visibility so we only fetch for on-screen widgets.
|
||||
// When a widget scrolls into view and has no data yet, trigger a load.
|
||||
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
|
||||
onVisibilityChange: (visible) => {
|
||||
if (visible && (!response || isDirtyRef.current)) {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
|
||||
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
|
||||
|
||||
// Reload on mount and when query, time period, or filters change
|
||||
useEffect(() => {
|
||||
submit();
|
||||
}, [
|
||||
submit,
|
||||
props.query,
|
||||
props.from,
|
||||
props.to,
|
||||
props.period,
|
||||
props.scope,
|
||||
JSON.stringify(props.taskIdentifiers),
|
||||
JSON.stringify(props.queues),
|
||||
JSON.stringify(props.responseModels),
|
||||
JSON.stringify(props.promptSlugs),
|
||||
JSON.stringify(props.promptVersions),
|
||||
JSON.stringify(props.operations),
|
||||
JSON.stringify(props.providers),
|
||||
]);
|
||||
|
||||
const data = response?.success
|
||||
? { rows: response.data.rows, columns: response.data.columns }
|
||||
: { rows: [], columns: [] };
|
||||
|
||||
const timeRange = response?.success ? response.data.timeRange : undefined;
|
||||
|
||||
return (
|
||||
<div ref={visibilityRef} className="h-full">
|
||||
<QueryWidget
|
||||
title={title}
|
||||
titleString={title}
|
||||
query={props.query}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
timeRange={timeRange}
|
||||
error={response?.success === false ? response.error : undefined}
|
||||
isResizing={isResizing}
|
||||
isDraggable={isDraggable}
|
||||
onEdit={onEdit}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
hiddenColumns={hiddenColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user