From 2b9948de5daa40baa1a4a6d689c59e7ff001002c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 08:52:18 +0000 Subject: [PATCH 1/5] feat: add HTTP endpoints overview page New /endpoints page that shows all HTTP server endpoints discovered from traces, with per-endpoint metrics (request count, P50/P95/P99 latency, error rate) grouped by service. - New Tinybird endpoint `http_endpoints_overview` filtering SpanKind=Server - Endpoints table with method badges, latency columns, and error rate - Sidebar nav item under Telemetry group - Links to traces filtered by span name + service https://claude.ai/code/session_01TvkFU6mqAgG7UFcFf6DMTk --- .../src/api/tinybird/endpoints-overview.ts | 84 +++++++ .../src/components/dashboard/app-sidebar.tsx | 6 + .../components/endpoints/endpoints-table.tsx | 222 ++++++++++++++++++ .../services/atoms/tinybird-query-atoms.ts | 8 + apps/web/src/lib/tinybird.ts | 5 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/endpoints.tsx | 51 ++++ packages/domain/src/tinybird-pipes.ts | 1 + packages/domain/src/tinybird/endpoints.ts | 63 +++++ 9 files changed, 461 insertions(+) create mode 100644 apps/web/src/api/tinybird/endpoints-overview.ts create mode 100644 apps/web/src/components/endpoints/endpoints-table.tsx create mode 100644 apps/web/src/routes/endpoints.tsx diff --git a/apps/web/src/api/tinybird/endpoints-overview.ts b/apps/web/src/api/tinybird/endpoints-overview.ts new file mode 100644 index 0000000..d3319ce --- /dev/null +++ b/apps/web/src/api/tinybird/endpoints-overview.ts @@ -0,0 +1,84 @@ +import { Effect, Schema } from "effect" +import { getTinybird, type HttpEndpointsOverviewOutput } from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, +} from "@/api/tinybird/effect-utils" + +const dateTimeString = TinybirdDateTimeString + +export interface HttpEndpointOverview { + serviceName: string + endpointName: string + httpMethod: string + count: number + avgDuration: number + p50Duration: number + p95Duration: number + p99Duration: number + errorRate: number +} + +export interface HttpEndpointsOverviewResponse { + data: HttpEndpointOverview[] +} + +const GetHttpEndpointsOverviewInput = Schema.Struct({ + startTime: dateTimeString, + endTime: dateTimeString, + serviceName: Schema.optional(Schema.String), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export type GetHttpEndpointsOverviewInput = Schema.Schema.Type + +function coerceRow(raw: HttpEndpointsOverviewOutput): HttpEndpointOverview { + return { + serviceName: raw.serviceName, + endpointName: raw.endpointName, + httpMethod: raw.httpMethod || "UNKNOWN", + count: Number(raw.count), + avgDuration: Number(raw.avgDuration), + p50Duration: Number(raw.p50Duration), + p95Duration: Number(raw.p95Duration), + p99Duration: Number(raw.p99Duration), + errorRate: Number(raw.errorRate), + } +} + +export function getHttpEndpointsOverview({ + data, +}: { + data: GetHttpEndpointsOverviewInput +}) { + return getHttpEndpointsOverviewEffect({ data }) +} + +const getHttpEndpointsOverviewEffect = Effect.fn("Tinybird.getHttpEndpointsOverview")( + function* ({ + data, + }: { + data: GetHttpEndpointsOverviewInput + }) { + const input = yield* decodeInput( + GetHttpEndpointsOverviewInput, + data, + "getHttpEndpointsOverview", + ) + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("http_endpoints_overview", () => + tinybird.query.http_endpoints_overview({ + start_time: input.startTime, + end_time: input.endTime, + service_name: input.serviceName, + environments: input.environments?.join(","), + }), + ) + + return { + data: result.data.map(coerceRow), + } + }, +) diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 4632b4a..3927d09 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -4,6 +4,7 @@ import { HouseIcon, FileIcon, PulseIcon, + ChartBarIcon, ChartLineIcon, ServerIcon, CircleWarningIcon, @@ -69,6 +70,11 @@ const servicesNavItems = [ ] const telemetryNavItems = [ + { + title: "Endpoints", + href: "/endpoints", + icon: ChartBarIcon, + }, { title: "Errors", href: "/errors", diff --git a/apps/web/src/components/endpoints/endpoints-table.tsx b/apps/web/src/components/endpoints/endpoints-table.tsx new file mode 100644 index 0000000..46e1abb --- /dev/null +++ b/apps/web/src/components/endpoints/endpoints-table.tsx @@ -0,0 +1,222 @@ +import { Result, useAtomValue } from "@effect-atom/atom-react" +import { Link } from "@tanstack/react-router" + +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Badge } from "@maple/ui/components/ui/badge" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" +import { getHttpEndpointsOverviewResultAtom } from "@/lib/services/atoms/tinybird-query-atoms" + +function formatLatency(ms: number): string { + if (ms == null || Number.isNaN(ms)) return "-" + if (ms < 1) return `${(ms * 1000).toFixed(0)}μs` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k` + return count.toLocaleString() +} + +function formatErrorRate(rate: number): string { + if (rate < 0.01) return "0%" + if (rate < 1) return `${rate.toFixed(2)}%` + return `${rate.toFixed(1)}%` +} + +const METHOD_COLORS: Record = { + GET: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400", + POST: "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400", + PUT: "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400", + PATCH: "bg-orange-500/10 text-orange-600 dark:bg-orange-400/10 dark:text-orange-400", + DELETE: "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400", +} + +function MethodBadge({ method }: { method: string }) { + const colorClass = METHOD_COLORS[method.toUpperCase()] ?? "" + return ( + + {method} + + ) +} + +export interface EndpointsTableProps { + filters?: { + startTime?: string + endTime?: string + service?: string + environments?: string[] + } +} + +function LoadingState() { + return ( +
+ + + + Method + Endpoint + Service + Requests + P50 + P95 + P99 + Error Rate + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + ))} + +
+
+ ) +} + +export function EndpointsTable({ filters }: EndpointsTableProps) { + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = + useEffectiveTimeRange(filters?.startTime, filters?.endTime) + + const result = useAtomValue( + getHttpEndpointsOverviewResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + serviceName: filters?.service, + environments: filters?.environments, + }, + }), + ) + + return Result.builder(result) + .onInitial(() => ) + .onError((error) => ( +
+

Failed to load endpoints

+
{error.message}
+
+ )) + .onSuccess((response, resultState) => { + const endpoints = response.data + + return ( +
+
+ + + + Method + Endpoint + Service + Requests + P50 + P95 + P99 + Error Rate + + + + {endpoints.length === 0 ? ( + + + No HTTP endpoints found in traces + + + ) : ( + endpoints.map((endpoint: HttpEndpointOverview) => ( + + + + + + + {endpoint.endpointName} + + + + + {endpoint.serviceName} + + + + {formatCount(endpoint.count)} + + + {formatLatency(endpoint.p50Duration)} + + + {formatLatency(endpoint.p95Duration)} + + + {formatLatency(endpoint.p99Duration)} + + + 5 + ? "text-red-600 dark:text-red-400 font-semibold" + : endpoint.errorRate > 1 + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground" + }`} + > + {formatErrorRate(endpoint.errorRate)} + + + + )) + )} + +
+
+ +
+ Showing {endpoints.length} endpoint{endpoints.length !== 1 ? "s" : ""} +
+
+ ) + }) + .render() +} diff --git a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts index b33c192..dd73589 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -6,6 +6,7 @@ import { getCustomChartTimeSeries, getOverviewTimeSeries, } from "@/api/tinybird/custom-charts" +import { getHttpEndpointsOverview } from "@/api/tinybird/endpoints-overview" import { getErrorDetailTraces, getErrorsByType, @@ -217,3 +218,10 @@ export const getResourceAttributeKeysResultAtom = makeQueryAtomFamily(getResourc export const getResourceAttributeValuesResultAtom = makeQueryAtomFamily(getResourceAttributeValues, { staleTime: 30_000, }) + +export const getHttpEndpointsOverviewResultAtom = makeQueryAtomFamily( + getHttpEndpointsOverview, + { + staleTime: 30_000, + }, +) diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index dc43f0e..a7b5024 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -29,6 +29,7 @@ import type { ServiceDependenciesOutput, ServiceOverviewOutput, ServicesFacetsOutput, + HttpEndpointsOverviewOutput, ResourceAttributeKeysOutput, ResourceAttributeValuesOutput, SpanAttributeKeysOutput, @@ -89,6 +90,8 @@ export type { ServiceOverviewOutput, ServicesFacetsParams, ServicesFacetsOutput, + HttpEndpointsOverviewParams, + HttpEndpointsOverviewOutput, ResourceAttributeKeysParams, ResourceAttributeKeysOutput, ResourceAttributeValuesParams, @@ -192,6 +195,8 @@ const query = { queryTinybird("resource_attribute_keys", params), resource_attribute_values: (params?: Record) => queryTinybird("resource_attribute_values", params), + http_endpoints_overview: (params?: Record) => + queryTinybird("http_endpoints_overview", params), } export function createTinybird() { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6cbd286..f6e6fe8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as MetricsRouteImport } from './routes/metrics' import { Route as McpRouteImport } from './routes/mcp' import { Route as LogsRouteImport } from './routes/logs' import { Route as ErrorsRouteImport } from './routes/errors' +import { Route as EndpointsRouteImport } from './routes/endpoints' import { Route as DeveloperRouteImport } from './routes/developer' import { Route as DashboardsRouteImport } from './routes/dashboards' import { Route as ConnectorsRouteImport } from './routes/connectors' @@ -90,6 +91,11 @@ const ErrorsRoute = ErrorsRouteImport.update({ path: '/errors', getParentRoute: () => rootRouteImport, } as any) +const EndpointsRoute = EndpointsRouteImport.update({ + id: '/endpoints', + path: '/endpoints', + getParentRoute: () => rootRouteImport, +} as any) const DeveloperRoute = DeveloperRouteImport.update({ id: '/developer', path: '/developer', @@ -136,6 +142,7 @@ export interface FileRoutesByFullPath { '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute + '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -158,6 +165,7 @@ export interface FileRoutesByTo { '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute + '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -181,6 +189,7 @@ export interface FileRoutesById { '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute + '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -205,6 +214,7 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' + | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -227,6 +237,7 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' + | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -249,6 +260,7 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' + | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -272,6 +284,7 @@ export interface RootRouteChildren { ConnectorsRoute: typeof ConnectorsRoute DashboardsRoute: typeof DashboardsRoute DeveloperRoute: typeof DeveloperRoute + EndpointsRoute: typeof EndpointsRoute ErrorsRoute: typeof ErrorsRoute LogsRoute: typeof LogsRoute McpRoute: typeof McpRoute @@ -376,6 +389,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ErrorsRouteImport parentRoute: typeof rootRouteImport } + '/endpoints': { + id: '/endpoints' + path: '/endpoints' + fullPath: '/endpoints' + preLoaderRoute: typeof EndpointsRouteImport + parentRoute: typeof rootRouteImport + } '/developer': { id: '/developer' path: '/developer' @@ -440,6 +460,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectorsRoute: ConnectorsRoute, DashboardsRoute: DashboardsRoute, DeveloperRoute: DeveloperRoute, + EndpointsRoute: EndpointsRoute, ErrorsRoute: ErrorsRoute, LogsRoute: LogsRoute, McpRoute: McpRoute, diff --git a/apps/web/src/routes/endpoints.tsx b/apps/web/src/routes/endpoints.tsx new file mode 100644 index 0000000..aeee870 --- /dev/null +++ b/apps/web/src/routes/endpoints.tsx @@ -0,0 +1,51 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Schema } from "effect" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { EndpointsTable } from "@/components/endpoints/endpoints-table" +import { TimeRangePicker } from "@/components/time-range-picker" + +const endpointsSearchSchema = Schema.Struct({ + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + service: Schema.optional(Schema.String), +}) + +export const Route = createFileRoute("/endpoints")({ + component: EndpointsPage, + validateSearch: Schema.standardSchemaV1(endpointsSearchSchema), +}) + +function EndpointsPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const handleTimeChange = ({ + startTime, + endTime, + }: { + startTime?: string + endTime?: string + }) => { + navigate({ + search: (prev: Record) => ({ ...prev, startTime, endTime }), + }) + } + + return ( + + } + > + + + ) +} diff --git a/packages/domain/src/tinybird-pipes.ts b/packages/domain/src/tinybird-pipes.ts index 732913f..b66df6d 100644 --- a/packages/domain/src/tinybird-pipes.ts +++ b/packages/domain/src/tinybird-pipes.ts @@ -31,6 +31,7 @@ export const tinybirdPipes = [ "span_attribute_values", "resource_attribute_keys", "resource_attribute_values", + "http_endpoints_overview", ] as const export type TinybirdPipe = (typeof tinybirdPipes)[number] diff --git a/packages/domain/src/tinybird/endpoints.ts b/packages/domain/src/tinybird/endpoints.ts index 93e64eb..4dc1dec 100644 --- a/packages/domain/src/tinybird/endpoints.ts +++ b/packages/domain/src/tinybird/endpoints.ts @@ -2770,3 +2770,66 @@ export const resourceAttributeValues = defineEndpoint("resource_attribute_values export type ResourceAttributeValuesParams = InferParams; export type ResourceAttributeValuesOutput = InferOutputRow; + +/** + * HTTP endpoints overview - aggregated metrics for HTTP server endpoints discovered from traces + */ +export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { + description: "Get aggregated metrics for HTTP endpoints grouped by service, method, and route.", + params: { + org_id: p.string().optional().describe("Organization ID"), + start_time: p.dateTime().describe("Start of time range"), + end_time: p.dateTime().describe("End of time range"), + service_name: p.string().optional().describe("Filter by service name"), + environments: p.string().optional().describe("Comma-separated environments filter"), + limit: p.int32().optional(100).describe("Maximum number of results"), + }, + nodes: [ + node({ + name: "http_endpoints_overview_node", + sql: ` + SELECT + ServiceName AS serviceName, + if( + SpanName LIKE 'http.server %' AND SpanAttributes['http.route'] != '', + concat(replaceOne(SpanName, 'http.server ', ''), ' ', SpanAttributes['http.route']), + SpanName + ) AS endpointName, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) AS httpMethod, + count() AS count, + avg(Duration) / 1000000 AS avgDuration, + quantile(0.5)(Duration) / 1000000 AS p50Duration, + quantile(0.95)(Duration) / 1000000 AS p95Duration, + quantile(0.99)(Duration) / 1000000 AS p99Duration, + if(count() > 0, countIf(StatusCode = 'Error') * 100.0 / count(), 0) AS errorRate + FROM traces + WHERE Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND OrgId = {{String(org_id, "")}} + AND SpanKind = 'Server' + {% if defined(service_name) %}AND ServiceName = {{String(service_name)}}{% end %} + {% if defined(environments) %} + AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) + {% end %} + GROUP BY serviceName, endpointName, httpMethod + ORDER BY count DESC + LIMIT {{Int32(limit, 100)}} + `, + }), + ], + output: { + serviceName: t.string(), + endpointName: t.string(), + httpMethod: t.string(), + count: t.uint64(), + avgDuration: t.float64(), + p50Duration: t.float64(), + p95Duration: t.float64(), + p99Duration: t.float64(), + errorRate: t.float64(), + }, +}); + +export type HttpEndpointsOverviewParams = InferParams; +export type HttpEndpointsOverviewOutput = InferOutputRow; From 3c79dd500970d58007a372dc6916acd757c65c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 09:05:30 +0000 Subject: [PATCH 2/5] feat: add sparkline charts to endpoints table Throughput and error rate sparklines per endpoint, matching the Services table pattern. New http_endpoints_timeseries Tinybird endpoint provides time-bucketed data grouped by endpoint key (service::name::method). https://claude.ai/code/session_01TvkFU6mqAgG7UFcFf6DMTk --- .../src/api/tinybird/endpoints-overview.ts | 100 ++++++++++ .../components/endpoints/endpoints-table.tsx | 187 +++++++++++------- .../services/atoms/tinybird-query-atoms.ts | 9 +- apps/web/src/lib/tinybird.ts | 5 + packages/domain/src/tinybird-pipes.ts | 1 + packages/domain/src/tinybird/endpoints.ts | 56 ++++++ 6 files changed, 283 insertions(+), 75 deletions(-) diff --git a/apps/web/src/api/tinybird/endpoints-overview.ts b/apps/web/src/api/tinybird/endpoints-overview.ts index d3319ce..225174e 100644 --- a/apps/web/src/api/tinybird/endpoints-overview.ts +++ b/apps/web/src/api/tinybird/endpoints-overview.ts @@ -5,6 +5,11 @@ import { decodeInput, runTinybirdQuery, } from "@/api/tinybird/effect-utils" +import { + buildBucketTimeline, + computeBucketSeconds, + toIsoBucket, +} from "@/api/tinybird/timeseries-utils" const dateTimeString = TinybirdDateTimeString @@ -82,3 +87,98 @@ const getHttpEndpointsOverviewEffect = Effect.fn("Tinybird.getHttpEndpointsOverv } }, ) + +// Sparkline types +export interface EndpointSparklinePoint { + bucket: string + throughput: number + errorRate: number +} + +const GetHttpEndpointsSparklinesInput = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + serviceName: Schema.optional(Schema.String), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export type GetHttpEndpointsSparklinesInput = Schema.Schema.Type + +function fillSparklinePoints( + points: EndpointSparklinePoint[], + timeline: string[], +): EndpointSparklinePoint[] { + if (timeline.length === 0) { + return [...points].sort((a, b) => a.bucket.localeCompare(b.bucket)) + } + + const byBucket = new Map() + for (const point of points) { + byBucket.set(toIsoBucket(point.bucket), point) + } + + return timeline.map((bucket) => { + const existing = byBucket.get(bucket) + if (existing) return existing + return { bucket, throughput: 0, errorRate: 0 } + }) +} + +export function getHttpEndpointsSparklines({ + data, +}: { + data: GetHttpEndpointsSparklinesInput +}) { + return getHttpEndpointsSparklinesEffect({ data }) +} + +const getHttpEndpointsSparklinesEffect = Effect.fn("Tinybird.getHttpEndpointsSparklines")( + function* ({ + data, + }: { + data: GetHttpEndpointsSparklinesInput + }) { + const input = yield* decodeInput( + GetHttpEndpointsSparklinesInput, + data ?? {}, + "getHttpEndpointsSparklines", + ) + + const tinybird = getTinybird() + const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) + const result = yield* runTinybirdQuery("http_endpoints_timeseries", () => + tinybird.query.http_endpoints_timeseries({ + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: bucketSeconds, + service_name: input.serviceName, + environments: input.environments?.join(","), + }), + ) + + const timeline = buildBucketTimeline(input.startTime, input.endTime, bucketSeconds) + // endpointKey format: "serviceName::endpointName::httpMethod" + const grouped: Record = {} + for (const row of result.data) { + const bucket = toIsoBucket(row.bucket) + const point: EndpointSparklinePoint = { + bucket, + throughput: Number(row.count), + errorRate: Number(row.errorRate), + } + if (!grouped[row.endpointKey]) { + grouped[row.endpointKey] = [] + } + grouped[row.endpointKey].push(point) + } + + const filledGrouped = Object.fromEntries( + Object.entries(grouped).map(([key, points]) => [ + key, + fillSparklinePoints(points, timeline), + ]), + ) + + return { data: filledGrouped } + }, +) diff --git a/apps/web/src/components/endpoints/endpoints-table.tsx b/apps/web/src/components/endpoints/endpoints-table.tsx index 46e1abb..4f94915 100644 --- a/apps/web/src/components/endpoints/endpoints-table.tsx +++ b/apps/web/src/components/endpoints/endpoints-table.tsx @@ -12,8 +12,12 @@ import { } from "@maple/ui/components/ui/table" import { Badge } from "@maple/ui/components/ui/badge" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Sparkline } from "@maple/ui/components/ui/gradient-chart" import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" -import { getHttpEndpointsOverviewResultAtom } from "@/lib/services/atoms/tinybird-query-atoms" +import { + getHttpEndpointsOverviewResultAtom, + getHttpEndpointsSparklinesResultAtom, +} from "@/lib/services/atoms/tinybird-query-atoms" function formatLatency(ms: number): string { if (ms == null || Number.isNaN(ms)) return "-" @@ -69,11 +73,11 @@ function LoadingState() { Method Endpoint Service - Requests P50 P95 P99 - Error Rate + Throughput + Error Rate @@ -85,8 +89,8 @@ function LoadingState() { - - + + ))} @@ -95,11 +99,15 @@ function LoadingState() { ) } +function endpointKey(endpoint: HttpEndpointOverview): string { + return `${endpoint.serviceName}::${endpoint.endpointName}::${endpoint.httpMethod}` +} + export function EndpointsTable({ filters }: EndpointsTableProps) { const { startTime: effectiveStartTime, endTime: effectiveEndTime } = useEffectiveTimeRange(filters?.startTime, filters?.endTime) - const result = useAtomValue( + const overviewResult = useAtomValue( getHttpEndpointsOverviewResultAtom({ data: { startTime: effectiveStartTime, @@ -110,7 +118,18 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { }), ) - return Result.builder(result) + const sparklinesResult = useAtomValue( + getHttpEndpointsSparklinesResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + serviceName: filters?.service, + environments: filters?.environments, + }, + }), + ) + + return Result.builder(Result.all([overviewResult, sparklinesResult])) .onInitial(() => ) .onError((error) => (
@@ -118,11 +137,12 @@ export function EndpointsTable({ filters }: EndpointsTableProps) {
{error.message}
)) - .onSuccess((response, resultState) => { - const endpoints = response.data + .onSuccess(([overviewResponse, sparklinesResponse], combinedResult) => { + const endpoints = overviewResponse.data + const sparklinesMap = sparklinesResponse.data return ( -
+
@@ -130,11 +150,11 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { Method Endpoint Service - Requests P50 P95 P99 - Error Rate + Throughput + Error Rate @@ -145,68 +165,87 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { ) : ( - endpoints.map((endpoint: HttpEndpointOverview) => ( - - - - - - - {endpoint.endpointName} - - - - - {endpoint.serviceName} - - - - {formatCount(endpoint.count)} - - - {formatLatency(endpoint.p50Duration)} - - - {formatLatency(endpoint.p95Duration)} - - - {formatLatency(endpoint.p99Duration)} - - - 5 - ? "text-red-600 dark:text-red-400 font-semibold" - : endpoint.errorRate > 1 - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground" - }`} - > - {formatErrorRate(endpoint.errorRate)} - - - - )) + endpoints.map((endpoint: HttpEndpointOverview) => { + const key = endpointKey(endpoint) + const series = sparklinesMap[key] + const throughputData = series?.map((p) => ({ value: p.throughput })) ?? [] + const errorRateData = series?.map((p) => ({ value: p.errorRate })) ?? [] + + return ( + + + + + + + {endpoint.endpointName} + + + + + {endpoint.serviceName} + + + + {formatLatency(endpoint.p50Duration)} + + + {formatLatency(endpoint.p95Duration)} + + + {formatLatency(endpoint.p99Duration)} + + +
+ +
+ + {formatCount(endpoint.count)} + +
+
+
+ +
+ +
+ + {formatErrorRate(endpoint.errorRate)} + +
+
+
+
+ ) + }) )}
diff --git a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts index dd73589..9f9a3de 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -6,7 +6,7 @@ import { getCustomChartTimeSeries, getOverviewTimeSeries, } from "@/api/tinybird/custom-charts" -import { getHttpEndpointsOverview } from "@/api/tinybird/endpoints-overview" +import { getHttpEndpointsOverview, getHttpEndpointsSparklines } from "@/api/tinybird/endpoints-overview" import { getErrorDetailTraces, getErrorsByType, @@ -225,3 +225,10 @@ export const getHttpEndpointsOverviewResultAtom = makeQueryAtomFamily( staleTime: 30_000, }, ) + +export const getHttpEndpointsSparklinesResultAtom = makeQueryAtomFamily( + getHttpEndpointsSparklines, + { + staleTime: 30_000, + }, +) diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index a7b5024..82504d2 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -30,6 +30,7 @@ import type { ServiceOverviewOutput, ServicesFacetsOutput, HttpEndpointsOverviewOutput, + HttpEndpointsTimeseriesOutput, ResourceAttributeKeysOutput, ResourceAttributeValuesOutput, SpanAttributeKeysOutput, @@ -92,6 +93,8 @@ export type { ServicesFacetsOutput, HttpEndpointsOverviewParams, HttpEndpointsOverviewOutput, + HttpEndpointsTimeseriesParams, + HttpEndpointsTimeseriesOutput, ResourceAttributeKeysParams, ResourceAttributeKeysOutput, ResourceAttributeValuesParams, @@ -197,6 +200,8 @@ const query = { queryTinybird("resource_attribute_values", params), http_endpoints_overview: (params?: Record) => queryTinybird("http_endpoints_overview", params), + http_endpoints_timeseries: (params?: Record) => + queryTinybird("http_endpoints_timeseries", params), } export function createTinybird() { diff --git a/packages/domain/src/tinybird-pipes.ts b/packages/domain/src/tinybird-pipes.ts index b66df6d..713d217 100644 --- a/packages/domain/src/tinybird-pipes.ts +++ b/packages/domain/src/tinybird-pipes.ts @@ -32,6 +32,7 @@ export const tinybirdPipes = [ "resource_attribute_keys", "resource_attribute_values", "http_endpoints_overview", + "http_endpoints_timeseries", ] as const export type TinybirdPipe = (typeof tinybirdPipes)[number] diff --git a/packages/domain/src/tinybird/endpoints.ts b/packages/domain/src/tinybird/endpoints.ts index 4dc1dec..1e14b0a 100644 --- a/packages/domain/src/tinybird/endpoints.ts +++ b/packages/domain/src/tinybird/endpoints.ts @@ -2833,3 +2833,59 @@ export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { export type HttpEndpointsOverviewParams = InferParams; export type HttpEndpointsOverviewOutput = InferOutputRow; + +/** + * HTTP endpoints time series - time-bucketed throughput and error rate per endpoint + */ +export const httpEndpointsTimeseries = defineEndpoint("http_endpoints_timeseries", { + description: "Time-bucketed throughput and error rate for HTTP server endpoints, grouped by endpoint name.", + params: { + org_id: p.string().optional().describe("Organization ID"), + start_time: p.dateTime().describe("Start of time range"), + end_time: p.dateTime().describe("End of time range"), + bucket_seconds: p.int32().optional(60).describe("Bucket size in seconds"), + service_name: p.string().optional().describe("Filter by service name"), + environments: p.string().optional().describe("Comma-separated environments filter"), + }, + nodes: [ + node({ + name: "http_endpoints_ts_node", + sql: ` + SELECT + toStartOfInterval(Timestamp, INTERVAL {{Int32(bucket_seconds, 60)}} SECOND) AS bucket, + concat( + ServiceName, '::', + if( + SpanName LIKE 'http.server %' AND SpanAttributes['http.route'] != '', + concat(replaceOne(SpanName, 'http.server ', ''), ' ', SpanAttributes['http.route']), + SpanName + ), '::', + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + ) AS endpointKey, + count() AS count, + if(count() > 0, countIf(StatusCode = 'Error') * 100.0 / count(), 0) AS errorRate + FROM traces + WHERE Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND OrgId = {{String(org_id, "")}} + AND SpanKind = 'Server' + {% if defined(service_name) %}AND ServiceName = {{String(service_name)}}{% end %} + {% if defined(environments) %} + AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) + {% end %} + GROUP BY bucket, endpointKey + ORDER BY bucket ASC, endpointKey ASC + `, + }), + ], + output: { + bucket: t.dateTime(), + endpointKey: t.string(), + count: t.uint64(), + errorRate: t.float64(), + }, +}); + +export type HttpEndpointsTimeseriesParams = InferParams; +export type HttpEndpointsTimeseriesOutput = InferOutputRow; From 56c48a08c193e853a58cda95086465982018a66c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 23 Feb 2026 14:21:03 +0100 Subject: [PATCH 3/5] stuff --- CLAUDE.md | 5 + apps/api/src/services/TinybirdService.ts | 4 + .../src/api/tinybird/endpoints-overview.ts | 12 ++- .../endpoints/endpoints-filter-sidebar.tsx | 92 +++++++++++++++++++ .../components/endpoints/endpoints-table.tsx | 51 ++++++---- apps/web/src/routes/endpoints.tsx | 51 +++++++++- packages/domain/src/tinybird/endpoints.ts | 32 +++++-- 7 files changed, 216 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 6e97f4a..f54b32e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,11 @@ export const myEndpoint = defineEndpoint("my_endpoint", { Never use raw `fetch()` calls to `/v0/sql` - always define typed endpoints for type safety and consistency. +When adding a new Tinybird endpoint, register it in **three** places: +1. Define in `packages/domain/src/tinybird/endpoints.ts` +2. Add pipe name to `packages/domain/src/tinybird-pipes.ts` +3. Register in `apps/api/src/services/TinybirdService.ts` pipes config + import + ## Environment Variables ``` diff --git a/apps/api/src/services/TinybirdService.ts b/apps/api/src/services/TinybirdService.ts index c6f287e..236c86d 100644 --- a/apps/api/src/services/TinybirdService.ts +++ b/apps/api/src/services/TinybirdService.ts @@ -25,6 +25,8 @@ import { errorsFacets, errorsSummary, getServiceUsage, + httpEndpointsOverview, + httpEndpointsTimeseries, listLogs, listMetrics, listTraces, @@ -99,6 +101,8 @@ export class TinybirdService extends Effect.Service()("Tinybird span_attribute_values: spanAttributeValues, resource_attribute_keys: resourceAttributeKeys, resource_attribute_values: resourceAttributeValues, + http_endpoints_overview: httpEndpointsOverview, + http_endpoints_timeseries: httpEndpointsTimeseries, }, }) diff --git a/apps/web/src/api/tinybird/endpoints-overview.ts b/apps/web/src/api/tinybird/endpoints-overview.ts index 225174e..df37cd2 100644 --- a/apps/web/src/api/tinybird/endpoints-overview.ts +++ b/apps/web/src/api/tinybird/endpoints-overview.ts @@ -32,7 +32,8 @@ export interface HttpEndpointsOverviewResponse { const GetHttpEndpointsOverviewInput = Schema.Struct({ startTime: dateTimeString, endTime: dateTimeString, - serviceName: Schema.optional(Schema.String), + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), }) @@ -77,7 +78,8 @@ const getHttpEndpointsOverviewEffect = Effect.fn("Tinybird.getHttpEndpointsOverv tinybird.query.http_endpoints_overview({ start_time: input.startTime, end_time: input.endTime, - service_name: input.serviceName, + services: input.services?.join(","), + http_methods: input.httpMethods?.join(","), environments: input.environments?.join(","), }), ) @@ -98,7 +100,8 @@ export interface EndpointSparklinePoint { const GetHttpEndpointsSparklinesInput = Schema.Struct({ startTime: Schema.optional(dateTimeString), endTime: Schema.optional(dateTimeString), - serviceName: Schema.optional(Schema.String), + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), }) @@ -151,7 +154,8 @@ const getHttpEndpointsSparklinesEffect = Effect.fn("Tinybird.getHttpEndpointsSpa start_time: input.startTime, end_time: input.endTime, bucket_seconds: bucketSeconds, - service_name: input.serviceName, + services: input.services?.join(","), + http_methods: input.httpMethods?.join(","), environments: input.environments?.join(","), }), ) diff --git a/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx b/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx new file mode 100644 index 0000000..e2ca935 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx @@ -0,0 +1,92 @@ +import { useNavigate } from "@tanstack/react-router" + +import { + FilterSection, + SearchableFilterSection, +} from "@/components/filters/filter-section" +import type { FilterOption } from "@/components/filters/filter-section" +import { Separator } from "@maple/ui/components/ui/separator" +import { + FilterSidebarBody, + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarLoading, +} from "@/components/filters/filter-sidebar" +import { Route } from "@/routes/endpoints" + +function LoadingState() { + return +} + +export interface EndpointsFacets { + services: FilterOption[] + httpMethods: FilterOption[] +} + +interface EndpointsFilterSidebarProps { + facets: EndpointsFacets | undefined + isLoading: boolean +} + +export function EndpointsFilterSidebar({ facets, isLoading }: EndpointsFilterSidebarProps) { + const navigate = useNavigate({ from: Route.fullPath }) + const search = Route.useSearch() + + const updateFilter = ( + key: K, + value: (typeof search)[K], + ) => { + navigate({ + search: (prev) => ({ + ...prev, + [key]: + value === undefined || (Array.isArray(value) && value.length === 0) + ? undefined + : value, + }), + }) + } + + const clearAllFilters = () => { + navigate({ + search: { + startTime: search.startTime, + endTime: search.endTime, + }, + }) + } + + const hasActiveFilters = + (search.services?.length ?? 0) > 0 || + (search.httpMethods?.length ?? 0) > 0 + + if (isLoading || !facets) { + return + } + + return ( + + + + updateFilter("services", val)} + /> + + {facets.httpMethods.length > 0 && ( + <> + + updateFilter("httpMethods", val)} + /> + + )} + + + ) +} diff --git a/apps/web/src/components/endpoints/endpoints-table.tsx b/apps/web/src/components/endpoints/endpoints-table.tsx index 4f94915..8bc04cb 100644 --- a/apps/web/src/components/endpoints/endpoints-table.tsx +++ b/apps/web/src/components/endpoints/endpoints-table.tsx @@ -26,10 +26,16 @@ function formatLatency(ms: number): string { return `${(ms / 1000).toFixed(2)}s` } -function formatCount(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k` - return count.toLocaleString() +function formatThroughput(count: number, durationSeconds: number): string { + if (durationSeconds <= 0) return `${count}` + const rate = count / durationSeconds + if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M/s` + if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k/s` + if (rate >= 100) return `${rate.toFixed(0)}/s` + if (rate >= 10) return `${rate.toFixed(1)}/s` + if (rate >= 1) return `${rate.toFixed(1)}/s` + if (rate >= 0.01) return `${rate.toFixed(2)}/s` + return `${rate.toFixed(3)}/s` } function formatErrorRate(rate: number): string { @@ -59,7 +65,8 @@ export interface EndpointsTableProps { filters?: { startTime?: string endTime?: string - service?: string + services?: string[] + httpMethods?: string[] environments?: string[] } } @@ -67,7 +74,7 @@ export interface EndpointsTableProps { function LoadingState() { return (
- +
Method @@ -76,15 +83,15 @@ function LoadingState() { P50 P95 P99 - Throughput - Error Rate + Throughput + Error Rate {Array.from({ length: 8 }).map((_, i) => ( - + @@ -107,12 +114,18 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { const { startTime: effectiveStartTime, endTime: effectiveEndTime } = useEffectiveTimeRange(filters?.startTime, filters?.endTime) + const durationSeconds = Math.max( + (new Date(effectiveEndTime).getTime() - new Date(effectiveStartTime).getTime()) / 1000, + 1, + ) + const overviewResult = useAtomValue( getHttpEndpointsOverviewResultAtom({ data: { startTime: effectiveStartTime, endTime: effectiveEndTime, - serviceName: filters?.service, + services: filters?.services, + httpMethods: filters?.httpMethods, environments: filters?.environments, }, }), @@ -123,7 +136,8 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { data: { startTime: effectiveStartTime, endTime: effectiveEndTime, - serviceName: filters?.service, + services: filters?.services, + httpMethods: filters?.httpMethods, environments: filters?.environments, }, }), @@ -144,7 +158,7 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { return (
-
+
Method @@ -153,8 +167,8 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { P50 P95 P99 - Throughput - Error Rate + Throughput + Error Rate @@ -179,7 +193,7 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { - + {endpoint.endpointName} @@ -216,7 +231,7 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { {formatLatency(endpoint.p99Duration)} -
+
- {formatCount(endpoint.count)} + {formatThroughput(endpoint.count, durationSeconds)}
-
+
{ + if (!Result.isSuccess(unfilteredResult)) return undefined + const data = unfilteredResult.value.data + + const serviceMap = new Map() + const methodMap = new Map() + for (const row of data) { + serviceMap.set(row.serviceName, (serviceMap.get(row.serviceName) ?? 0) + row.count) + if (row.httpMethod && row.httpMethod !== "UNKNOWN") { + methodMap.set(row.httpMethod, (methodMap.get(row.httpMethod) ?? 0) + row.count) + } + } + + const toSorted = (map: Map): FilterOption[] => + Array.from(map.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + + return { + services: toSorted(serviceMap), + httpMethods: toSorted(methodMap), + } + }, [unfilteredResult]) + + const isLoading = Result.isInitial(unfilteredResult) + return ( } + filterSidebar={ + + } > diff --git a/packages/domain/src/tinybird/endpoints.ts b/packages/domain/src/tinybird/endpoints.ts index 1e14b0a..e770ace 100644 --- a/packages/domain/src/tinybird/endpoints.ts +++ b/packages/domain/src/tinybird/endpoints.ts @@ -2780,7 +2780,8 @@ export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { org_id: p.string().optional().describe("Organization ID"), start_time: p.dateTime().describe("Start of time range"), end_time: p.dateTime().describe("End of time range"), - service_name: p.string().optional().describe("Filter by service name"), + services: p.string().optional().describe("Comma-separated service names filter"), + http_methods: p.string().optional().describe("Comma-separated HTTP methods filter"), environments: p.string().optional().describe("Comma-separated environments filter"), limit: p.int32().optional(100).describe("Maximum number of results"), }, @@ -2791,8 +2792,8 @@ export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { SELECT ServiceName AS serviceName, if( - SpanName LIKE 'http.server %' AND SpanAttributes['http.route'] != '', - concat(replaceOne(SpanName, 'http.server ', ''), ' ', SpanAttributes['http.route']), + SpanAttributes['http.route'] != '', + SpanAttributes['http.route'], SpanName ) AS endpointName, if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], @@ -2808,7 +2809,14 @@ export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { AND Timestamp <= {{DateTime(end_time)}} AND OrgId = {{String(org_id, "")}} AND SpanKind = 'Server' - {% if defined(service_name) %}AND ServiceName = {{String(service_name)}}{% end %} + {% if defined(services) %} + AND ServiceName IN splitByChar(',', {{String(services, "")}}) + {% end %} + {% if defined(http_methods) %} + AND if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + IN splitByChar(',', {{String(http_methods, "")}}) + {% end %} {% if defined(environments) %} AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) {% end %} @@ -2844,7 +2852,8 @@ export const httpEndpointsTimeseries = defineEndpoint("http_endpoints_timeseries start_time: p.dateTime().describe("Start of time range"), end_time: p.dateTime().describe("End of time range"), bucket_seconds: p.int32().optional(60).describe("Bucket size in seconds"), - service_name: p.string().optional().describe("Filter by service name"), + services: p.string().optional().describe("Comma-separated service names filter"), + http_methods: p.string().optional().describe("Comma-separated HTTP methods filter"), environments: p.string().optional().describe("Comma-separated environments filter"), }, nodes: [ @@ -2856,8 +2865,8 @@ export const httpEndpointsTimeseries = defineEndpoint("http_endpoints_timeseries concat( ServiceName, '::', if( - SpanName LIKE 'http.server %' AND SpanAttributes['http.route'] != '', - concat(replaceOne(SpanName, 'http.server ', ''), ' ', SpanAttributes['http.route']), + SpanAttributes['http.route'] != '', + SpanAttributes['http.route'], SpanName ), '::', if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], @@ -2870,7 +2879,14 @@ export const httpEndpointsTimeseries = defineEndpoint("http_endpoints_timeseries AND Timestamp <= {{DateTime(end_time)}} AND OrgId = {{String(org_id, "")}} AND SpanKind = 'Server' - {% if defined(service_name) %}AND ServiceName = {{String(service_name)}}{% end %} + {% if defined(services) %} + AND ServiceName IN splitByChar(',', {{String(services, "")}}) + {% end %} + {% if defined(http_methods) %} + AND if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + IN splitByChar(',', {{String(http_methods, "")}}) + {% end %} {% if defined(environments) %} AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) {% end %} From 080506e4ebf15aed04617bb34104236b94a4dc82 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 23 Feb 2026 14:42:59 +0100 Subject: [PATCH 4/5] fix --- apps/web/src/api/tinybird/custom-charts.ts | 2 +- apps/web/src/api/tinybird/endpoint-detail.ts | 136 ++++++++++ .../endpoints/endpoint-recent-traces.tsx | 152 +++++++++++ .../endpoints/endpoint-status-breakdown.tsx | 73 ++++++ .../endpoints/endpoint-summary-stats.tsx | 74 ++++++ .../components/endpoints/endpoints-table.tsx | 26 +- .../src/components/endpoints/method-badge.tsx | 18 ++ .../services/atoms/tinybird-query-atoms.ts | 15 ++ apps/web/src/routeTree.gen.ts | 63 +++-- apps/web/src/routes/endpoints/detail.tsx | 236 ++++++++++++++++++ .../{endpoints.tsx => endpoints/index.tsx} | 2 +- 11 files changed, 753 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/api/tinybird/endpoint-detail.ts create mode 100644 apps/web/src/components/endpoints/endpoint-recent-traces.tsx create mode 100644 apps/web/src/components/endpoints/endpoint-status-breakdown.tsx create mode 100644 apps/web/src/components/endpoints/endpoint-summary-stats.tsx create mode 100644 apps/web/src/components/endpoints/method-badge.tsx create mode 100644 apps/web/src/routes/endpoints/detail.tsx rename apps/web/src/routes/{endpoints.tsx => endpoints/index.tsx} (98%) diff --git a/apps/web/src/api/tinybird/custom-charts.ts b/apps/web/src/api/tinybird/custom-charts.ts index cf485ff..3788637 100644 --- a/apps/web/src/api/tinybird/custom-charts.ts +++ b/apps/web/src/api/tinybird/custom-charts.ts @@ -30,7 +30,7 @@ function sortByBucket(rows: T[]): T[] { return [...rows].sort((left, right) => left.bucket.localeCompare(right.bucket)) } -function fillServiceDetailPoints( +export function fillServiceDetailPoints( points: ServiceDetailTimeSeriesPoint[], startTime: string | undefined, endTime: string | undefined, diff --git a/apps/web/src/api/tinybird/endpoint-detail.ts b/apps/web/src/api/tinybird/endpoint-detail.ts new file mode 100644 index 0000000..8ff420f --- /dev/null +++ b/apps/web/src/api/tinybird/endpoint-detail.ts @@ -0,0 +1,136 @@ +import { Effect, Schema } from "effect" +import { getTinybird } from "@/lib/tinybird" +import { + computeBucketSeconds, + toIsoBucket, +} from "@/api/tinybird/timeseries-utils" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, +} from "@/api/tinybird/effect-utils" +import { fillServiceDetailPoints } from "@/api/tinybird/custom-charts" +import type { ServiceDetailTimeSeriesPoint } from "@/api/tinybird/services" + +const GetEndpointDetailTimeSeriesInputSchema = Schema.Struct({ + serviceName: Schema.String, + spanName: Schema.String, + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) + +export type GetEndpointDetailTimeSeriesInput = Schema.Schema.Type< + typeof GetEndpointDetailTimeSeriesInputSchema +> + +export function getEndpointDetailTimeSeries({ + data, +}: { + data: GetEndpointDetailTimeSeriesInput +}) { + return getEndpointDetailTimeSeriesEffect({ data }) +} + +const getEndpointDetailTimeSeriesEffect = Effect.fn( + "Tinybird.getEndpointDetailTimeSeries", +)(function* ({ + data, +}: { + data: GetEndpointDetailTimeSeriesInput +}) { + const input = yield* decodeInput( + GetEndpointDetailTimeSeriesInputSchema, + data, + "getEndpointDetailTimeSeries", + ) + + const tinybird = getTinybird() + const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) + const result = yield* runTinybirdQuery("custom_traces_timeseries", () => + tinybird.query.custom_traces_timeseries({ + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: bucketSeconds, + service_name: input.serviceName, + span_name: input.spanName, + }), + ) + + const points = result.data.map( + (row): ServiceDetailTimeSeriesPoint => ({ + bucket: toIsoBucket(row.bucket), + throughput: Number(row.count), + errorRate: Number(row.errorRate), + p50LatencyMs: Number(row.p50Duration), + p95LatencyMs: Number(row.p95Duration), + p99LatencyMs: Number(row.p99Duration), + }), + ) + + return { + data: fillServiceDetailPoints( + points, + input.startTime, + input.endTime, + bucketSeconds, + ), + } +}) + +const GetEndpointStatusCodeBreakdownInputSchema = Schema.Struct({ + serviceName: Schema.String, + spanName: Schema.String, + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) + +export type GetEndpointStatusCodeBreakdownInput = Schema.Schema.Type< + typeof GetEndpointStatusCodeBreakdownInputSchema +> + +export interface StatusCodeBreakdownItem { + statusCode: string + count: number +} + +export function getEndpointStatusCodeBreakdown({ + data, +}: { + data: GetEndpointStatusCodeBreakdownInput +}) { + return getEndpointStatusCodeBreakdownEffect({ data }) +} + +const getEndpointStatusCodeBreakdownEffect = Effect.fn( + "Tinybird.getEndpointStatusCodeBreakdown", +)(function* ({ + data, +}: { + data: GetEndpointStatusCodeBreakdownInput +}) { + const input = yield* decodeInput( + GetEndpointStatusCodeBreakdownInputSchema, + data, + "getEndpointStatusCodeBreakdown", + ) + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("custom_traces_breakdown", () => + tinybird.query.custom_traces_breakdown({ + start_time: input.startTime, + end_time: input.endTime, + service_name: input.serviceName, + span_name: input.spanName, + group_by_attribute: "http.status_code", + }), + ) + + return { + data: result.data.map( + (row): StatusCodeBreakdownItem => ({ + statusCode: String(row.name), + count: Number(row.count), + }), + ), + } +}) diff --git a/apps/web/src/components/endpoints/endpoint-recent-traces.tsx b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx new file mode 100644 index 0000000..e2d82f5 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx @@ -0,0 +1,152 @@ +import { Link, useNavigate } from "@tanstack/react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Badge } from "@maple/ui/components/ui/badge" +import type { Trace } from "@/api/tinybird/traces" + +function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}us` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp) + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +function truncateId(id: string, length = 8): string { + if (id.length <= length) return id + return id.slice(0, length) +} + +function StatusBadge({ hasError }: { hasError: boolean }) { + if (hasError) { + return ( + + Error + + ) + } + return ( + + OK + + ) +} + +interface EndpointRecentTracesProps { + traces: Trace[] + service: string + endpoint: string + startTime?: string + endTime?: string +} + +export function EndpointRecentTraces({ + traces, + service, + endpoint, + startTime, + endTime, +}: EndpointRecentTracesProps) { + const navigate = useNavigate() + + return ( +
+
+
+ + + Trace ID + Time + Duration + Spans + Status + + + + {traces.length === 0 ? ( + + + No traces found for this endpoint + + + ) : ( + traces.map((trace) => ( + + navigate({ + to: "/traces/$traceId", + params: { traceId: trace.traceId }, + }) + } + > + + e.stopPropagation()} + > + {truncateId(trace.traceId)} + + + + {formatTimestamp(trace.startTime)} + + + {formatDuration(trace.durationMs)} + + + {trace.spanCount} + + + + + + )) + )} + +
+
+ + {traces.length > 0 && ( +
+ + View all traces for this endpoint → + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx b/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx new file mode 100644 index 0000000..2bc84a1 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx @@ -0,0 +1,73 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Badge } from "@maple/ui/components/ui/badge" +import type { StatusCodeBreakdownItem } from "@/api/tinybird/endpoint-detail" + +function getStatusColorClass(code: string): string { + const num = Number.parseInt(code, 10) + if (num >= 500) return "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400" + if (num >= 400) return "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400" + if (num >= 300) return "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400" + if (num >= 200) return "bg-green-500/10 text-green-600 dark:bg-green-400/10 dark:text-green-400" + return "" +} + +interface EndpointStatusBreakdownProps { + data: StatusCodeBreakdownItem[] +} + +export function EndpointStatusBreakdown({ data }: EndpointStatusBreakdownProps) { + const total = data.reduce((sum, item) => sum + item.count, 0) + const sorted = [...data] + .filter((item) => item.statusCode && item.statusCode !== "") + .sort((a, b) => b.count - a.count) + + if (sorted.length === 0) { + return ( +
+ No status code data available +
+ ) + } + + return ( + + + + Status Code + Count + Percentage + + + + {sorted.map((item) => { + const pct = total > 0 ? (item.count / total) * 100 : 0 + return ( + + + + {item.statusCode} + + + + {item.count.toLocaleString()} + + + {pct.toFixed(1)}% + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoint-summary-stats.tsx b/apps/web/src/components/endpoints/endpoint-summary-stats.tsx new file mode 100644 index 0000000..34c8c26 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-summary-stats.tsx @@ -0,0 +1,74 @@ +import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" + +function formatLatency(ms: number): string { + if (ms == null || Number.isNaN(ms)) return "-" + if (ms < 1) return `${(ms * 1000).toFixed(0)}us` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` + return n.toLocaleString() +} + +function formatRate(rate: number): string { + if (rate < 0.01) return "0%" + if (rate < 1) return `${rate.toFixed(2)}%` + return `${rate.toFixed(1)}%` +} + +function formatThroughput(count: number, durationSeconds: number): string { + if (durationSeconds <= 0) return `${count}` + const rate = count / durationSeconds + if (rate >= 1000) return `${(rate / 1000).toFixed(1)}k/s` + if (rate >= 1) return `${rate.toFixed(1)}/s` + if (rate >= 0.01) return `${rate.toFixed(2)}/s` + return `${rate.toFixed(3)}/s` +} + +interface StatCardProps { + label: string + value: string +} + +function StatCard({ label, value }: StatCardProps) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +interface EndpointSummaryStatsProps { + endpoint: HttpEndpointOverview | undefined + durationSeconds: number +} + +export function EndpointSummaryStats({ endpoint, durationSeconds }: EndpointSummaryStatsProps) { + if (!endpoint) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) + } + + return ( +
+ + + + + + +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoints-table.tsx b/apps/web/src/components/endpoints/endpoints-table.tsx index 8bc04cb..70af0da 100644 --- a/apps/web/src/components/endpoints/endpoints-table.tsx +++ b/apps/web/src/components/endpoints/endpoints-table.tsx @@ -10,10 +10,10 @@ import { TableHeader, TableRow, } from "@maple/ui/components/ui/table" -import { Badge } from "@maple/ui/components/ui/badge" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Sparkline } from "@maple/ui/components/ui/gradient-chart" import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" +import { MethodBadge } from "@/components/endpoints/method-badge" import { getHttpEndpointsOverviewResultAtom, getHttpEndpointsSparklinesResultAtom, @@ -44,23 +44,6 @@ function formatErrorRate(rate: number): string { return `${rate.toFixed(1)}%` } -const METHOD_COLORS: Record = { - GET: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400", - POST: "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400", - PUT: "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400", - PATCH: "bg-orange-500/10 text-orange-600 dark:bg-orange-400/10 dark:text-orange-400", - DELETE: "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400", -} - -function MethodBadge({ method }: { method: string }) { - const colorClass = METHOD_COLORS[method.toUpperCase()] ?? "" - return ( - - {method} - - ) -} - export interface EndpointsTableProps { filters?: { startTime?: string @@ -195,10 +178,11 @@ export function EndpointsTable({ filters }: EndpointsTableProps) { = { + GET: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400", + POST: "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400", + PUT: "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400", + PATCH: "bg-orange-500/10 text-orange-600 dark:bg-orange-400/10 dark:text-orange-400", + DELETE: "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400", +} + +export function MethodBadge({ method, className }: { method: string; className?: string }) { + const colorClass = METHOD_COLORS[method.toUpperCase()] ?? "" + return ( + + {method} + + ) +} diff --git a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts index 9f9a3de..e926ce0 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -6,6 +6,7 @@ import { getCustomChartTimeSeries, getOverviewTimeSeries, } from "@/api/tinybird/custom-charts" +import { getEndpointDetailTimeSeries, getEndpointStatusCodeBreakdown } from "@/api/tinybird/endpoint-detail" import { getHttpEndpointsOverview, getHttpEndpointsSparklines } from "@/api/tinybird/endpoints-overview" import { getErrorDetailTraces, @@ -219,6 +220,20 @@ export const getResourceAttributeValuesResultAtom = makeQueryAtomFamily(getResou staleTime: 30_000, }) +export const getEndpointDetailTimeSeriesResultAtom = makeQueryAtomFamily( + getEndpointDetailTimeSeries, + { + staleTime: 30_000, + }, +) + +export const getEndpointStatusCodeBreakdownResultAtom = makeQueryAtomFamily( + getEndpointStatusCodeBreakdown, + { + staleTime: 30_000, + }, +) + export const getHttpEndpointsOverviewResultAtom = makeQueryAtomFamily( getHttpEndpointsOverview, { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f6e6fe8..02bc8c1 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -21,15 +21,16 @@ import { Route as MetricsRouteImport } from './routes/metrics' import { Route as McpRouteImport } from './routes/mcp' import { Route as LogsRouteImport } from './routes/logs' import { Route as ErrorsRouteImport } from './routes/errors' -import { Route as EndpointsRouteImport } from './routes/endpoints' import { Route as DeveloperRouteImport } from './routes/developer' import { Route as DashboardsRouteImport } from './routes/dashboards' import { Route as ConnectorsRouteImport } from './routes/connectors' import { Route as IndexRouteImport } from './routes/index' import { Route as TracesIndexRouteImport } from './routes/traces/index' import { Route as ServicesIndexRouteImport } from './routes/services/index' +import { Route as EndpointsIndexRouteImport } from './routes/endpoints/index' import { Route as TracesTraceIdRouteImport } from './routes/traces/$traceId' import { Route as ServicesServiceNameRouteImport } from './routes/services/$serviceName' +import { Route as EndpointsDetailRouteImport } from './routes/endpoints/detail' const SignUpRoute = SignUpRouteImport.update({ id: '/sign-up', @@ -91,11 +92,6 @@ const ErrorsRoute = ErrorsRouteImport.update({ path: '/errors', getParentRoute: () => rootRouteImport, } as any) -const EndpointsRoute = EndpointsRouteImport.update({ - id: '/endpoints', - path: '/endpoints', - getParentRoute: () => rootRouteImport, -} as any) const DeveloperRoute = DeveloperRouteImport.update({ id: '/developer', path: '/developer', @@ -126,6 +122,11 @@ const ServicesIndexRoute = ServicesIndexRouteImport.update({ path: '/services/', getParentRoute: () => rootRouteImport, } as any) +const EndpointsIndexRoute = EndpointsIndexRouteImport.update({ + id: '/endpoints/', + path: '/endpoints/', + getParentRoute: () => rootRouteImport, +} as any) const TracesTraceIdRoute = TracesTraceIdRouteImport.update({ id: '/traces/$traceId', path: '/traces/$traceId', @@ -136,13 +137,17 @@ const ServicesServiceNameRoute = ServicesServiceNameRouteImport.update({ path: '/services/$serviceName', getParentRoute: () => rootRouteImport, } as any) +const EndpointsDetailRoute = EndpointsDetailRouteImport.update({ + id: '/endpoints/detail', + path: '/endpoints/detail', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute - '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -155,8 +160,10 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints/': typeof EndpointsIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute } @@ -165,7 +172,6 @@ export interface FileRoutesByTo { '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute - '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -178,8 +184,10 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints': typeof EndpointsIndexRoute '/services': typeof ServicesIndexRoute '/traces': typeof TracesIndexRoute } @@ -189,7 +197,6 @@ export interface FileRoutesById { '/connectors': typeof ConnectorsRoute '/dashboards': typeof DashboardsRoute '/developer': typeof DeveloperRoute - '/endpoints': typeof EndpointsRoute '/errors': typeof ErrorsRoute '/logs': typeof LogsRoute '/mcp': typeof McpRoute @@ -202,8 +209,10 @@ export interface FileRoutesById { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints/': typeof EndpointsIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute } @@ -214,7 +223,6 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' - | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -227,8 +235,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints/' | '/services/' | '/traces/' fileRoutesByTo: FileRoutesByTo @@ -237,7 +247,6 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' - | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -250,8 +259,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints' | '/services' | '/traces' id: @@ -260,7 +271,6 @@ export interface FileRouteTypes { | '/connectors' | '/dashboards' | '/developer' - | '/endpoints' | '/errors' | '/logs' | '/mcp' @@ -273,8 +283,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints/' | '/services/' | '/traces/' fileRoutesById: FileRoutesById @@ -284,7 +296,6 @@ export interface RootRouteChildren { ConnectorsRoute: typeof ConnectorsRoute DashboardsRoute: typeof DashboardsRoute DeveloperRoute: typeof DeveloperRoute - EndpointsRoute: typeof EndpointsRoute ErrorsRoute: typeof ErrorsRoute LogsRoute: typeof LogsRoute McpRoute: typeof McpRoute @@ -297,8 +308,10 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute + EndpointsDetailRoute: typeof EndpointsDetailRoute ServicesServiceNameRoute: typeof ServicesServiceNameRoute TracesTraceIdRoute: typeof TracesTraceIdRoute + EndpointsIndexRoute: typeof EndpointsIndexRoute ServicesIndexRoute: typeof ServicesIndexRoute TracesIndexRoute: typeof TracesIndexRoute } @@ -389,13 +402,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ErrorsRouteImport parentRoute: typeof rootRouteImport } - '/endpoints': { - id: '/endpoints' - path: '/endpoints' - fullPath: '/endpoints' - preLoaderRoute: typeof EndpointsRouteImport - parentRoute: typeof rootRouteImport - } '/developer': { id: '/developer' path: '/developer' @@ -438,6 +444,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServicesIndexRouteImport parentRoute: typeof rootRouteImport } + '/endpoints/': { + id: '/endpoints/' + path: '/endpoints' + fullPath: '/endpoints/' + preLoaderRoute: typeof EndpointsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/traces/$traceId': { id: '/traces/$traceId' path: '/traces/$traceId' @@ -452,6 +465,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServicesServiceNameRouteImport parentRoute: typeof rootRouteImport } + '/endpoints/detail': { + id: '/endpoints/detail' + path: '/endpoints/detail' + fullPath: '/endpoints/detail' + preLoaderRoute: typeof EndpointsDetailRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -460,7 +480,6 @@ const rootRouteChildren: RootRouteChildren = { ConnectorsRoute: ConnectorsRoute, DashboardsRoute: DashboardsRoute, DeveloperRoute: DeveloperRoute, - EndpointsRoute: EndpointsRoute, ErrorsRoute: ErrorsRoute, LogsRoute: LogsRoute, McpRoute: McpRoute, @@ -473,8 +492,10 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, + EndpointsDetailRoute: EndpointsDetailRoute, ServicesServiceNameRoute: ServicesServiceNameRoute, TracesTraceIdRoute: TracesTraceIdRoute, + EndpointsIndexRoute: EndpointsIndexRoute, ServicesIndexRoute: ServicesIndexRoute, TracesIndexRoute: TracesIndexRoute, } diff --git a/apps/web/src/routes/endpoints/detail.tsx b/apps/web/src/routes/endpoints/detail.tsx new file mode 100644 index 0000000..5baa35f --- /dev/null +++ b/apps/web/src/routes/endpoints/detail.tsx @@ -0,0 +1,236 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { Result, useAtomValue } from "@effect-atom/atom-react" +import { Schema } from "effect" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { TimeRangePicker } from "@/components/time-range-picker" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { MetricsGrid } from "@/components/dashboard/metrics-grid" +import { MethodBadge } from "@/components/endpoints/method-badge" +import { EndpointSummaryStats } from "@/components/endpoints/endpoint-summary-stats" +import { EndpointStatusBreakdown } from "@/components/endpoints/endpoint-status-breakdown" +import { EndpointRecentTraces } from "@/components/endpoints/endpoint-recent-traces" +import { ReadonlyWidgetShell } from "@/components/dashboard-builder/widgets/widget-shell" +import { + getEndpointDetailTimeSeriesResultAtom, + getEndpointStatusCodeBreakdownResultAtom, + getHttpEndpointsOverviewResultAtom, + listTracesResultAtom, +} from "@/lib/services/atoms/tinybird-query-atoms" +import type { ChartLegendMode, ChartTooltipMode } from "@maple/ui/components/charts/_shared/chart-types" + +const endpointDetailSearchSchema = Schema.Struct({ + service: Schema.String, + endpoint: Schema.String, + method: Schema.String, + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), +}) + +export const Route = createFileRoute("/endpoints/detail")({ + component: EndpointDetailPage, + validateSearch: Schema.standardSchemaV1(endpointDetailSearchSchema), +}) + +interface EndpointChartConfig { + id: string + chartId: string + title: string + layout: { x: number; y: number; w: number; h: number } + legend?: ChartLegendMode + tooltip?: ChartTooltipMode +} + +const ENDPOINT_CHARTS: EndpointChartConfig[] = [ + { id: "latency", chartId: "latency-line", title: "Latency", layout: { x: 0, y: 0, w: 6, h: 4 }, legend: "visible", tooltip: "visible" }, + { id: "throughput", chartId: "throughput-area", title: "Throughput", layout: { x: 6, y: 0, w: 6, h: 4 }, tooltip: "visible" }, + { id: "error-rate", chartId: "error-rate-area", title: "Error Rate", layout: { x: 0, y: 4, w: 6, h: 4 }, tooltip: "visible" }, +] + +function EndpointDetailPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = + useEffectiveTimeRange(search.startTime, search.endTime) + + const durationSeconds = Math.max( + (new Date(effectiveEndTime).getTime() - new Date(effectiveStartTime).getTime()) / 1000, + 1, + ) + + const handleTimeChange = ({ + startTime, + endTime, + presetValue, + }: { + startTime?: string + endTime?: string + presetValue?: string + }) => { + navigate({ + search: { + ...search, + startTime, + endTime, + timePreset: presetValue, + }, + }) + } + + // Time series data for charts + const timeSeriesResult = useAtomValue( + getEndpointDetailTimeSeriesResultAtom({ + data: { + serviceName: search.service, + spanName: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }, + }), + ) + + // Status code breakdown + const statusCodeResult = useAtomValue( + getEndpointStatusCodeBreakdownResultAtom({ + data: { + serviceName: search.service, + spanName: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }, + }), + ) + + // Overview stats (reuse existing endpoint, filter client-side) + const overviewResult = useAtomValue( + getHttpEndpointsOverviewResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + services: [search.service], + }, + }), + ) + + // Recent traces + const tracesResult = useAtomValue( + listTracesResultAtom({ + data: { + service: search.service, + spanName: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + limit: 20, + }, + }), + ) + + // Extract matching endpoint from overview + const matchingEndpoint = Result.builder(overviewResult) + .onSuccess((response) => + response.data.find( + (ep) => + ep.serviceName === search.service && + ep.endpointName === search.endpoint && + ep.httpMethod === search.method, + ), + ) + .orElse(() => undefined) + + // Chart data + const detailPoints = Result.builder(timeSeriesResult) + .onSuccess((response) => response.data as unknown as Record[]) + .orElse(() => []) + + const metrics = ENDPOINT_CHARTS.map((chart) => ({ + id: chart.id, + chartId: chart.chartId, + title: chart.title, + layout: chart.layout, + data: detailPoints, + legend: chart.legend, + tooltip: chart.tooltip, + })) + + // Status code data + const statusCodeData = Result.builder(statusCodeResult) + .onSuccess((response) => response.data) + .orElse(() => []) + + // Traces data + const traces = Result.builder(tracesResult) + .onSuccess((response) => response.data) + .orElse(() => []) + + return ( + +
+ +

+ {search.endpoint} +

+
+

+ Service:{" "} + + {search.service} + +

+
+ } + headerActions={ + + } + > +
+ {/* Summary Stats */} + + + {/* Charts */} +
+ {/* Latency, Throughput, Error Rate charts */} +
+ +
+ + {/* Status Code Breakdown */} +
+ + + +
+
+ + {/* Recent Traces */} +
+

Recent Traces

+ +
+
+ + ) +} diff --git a/apps/web/src/routes/endpoints.tsx b/apps/web/src/routes/endpoints/index.tsx similarity index 98% rename from apps/web/src/routes/endpoints.tsx rename to apps/web/src/routes/endpoints/index.tsx index 2811af2..db24b96 100644 --- a/apps/web/src/routes/endpoints.tsx +++ b/apps/web/src/routes/endpoints/index.tsx @@ -18,7 +18,7 @@ const endpointsSearchSchema = Schema.Struct({ httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), }) -export const Route = createFileRoute("/endpoints")({ +export const Route = createFileRoute("/endpoints/")({ component: EndpointsPage, validateSearch: Schema.standardSchemaV1(endpointsSearchSchema), }) From d7e3c2c375e9728171bd8d793a6a315f238e69e3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 23 Feb 2026 14:48:00 +0100 Subject: [PATCH 5/5] stuff --- .../src/components/endpoints/endpoint-recent-traces.tsx | 6 +++++- apps/web/src/routes/endpoints/detail.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/endpoints/endpoint-recent-traces.tsx b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx index e2d82f5..0f74f63 100644 --- a/apps/web/src/components/endpoints/endpoint-recent-traces.tsx +++ b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx @@ -57,6 +57,7 @@ interface EndpointRecentTracesProps { traces: Trace[] service: string endpoint: string + method: string startTime?: string endTime?: string } @@ -65,6 +66,7 @@ export function EndpointRecentTraces({ traces, service, endpoint, + method, startTime, endTime, }: EndpointRecentTracesProps) { @@ -137,7 +139,9 @@ export function EndpointRecentTraces({ to="/traces" search={{ services: [service], - spanNames: [endpoint], + httpMethods: [method], + attributeKey: "http.route", + attributeValue: endpoint, startTime, endTime, }} diff --git a/apps/web/src/routes/endpoints/detail.tsx b/apps/web/src/routes/endpoints/detail.tsx index 5baa35f..7b516cf 100644 --- a/apps/web/src/routes/endpoints/detail.tsx +++ b/apps/web/src/routes/endpoints/detail.tsx @@ -114,12 +114,15 @@ function EndpointDetailPage() { }), ) - // Recent traces + // Recent traces — filter by http.route attribute since endpointName + // comes from http.route (not rootSpanName which list_traces filters on) const tracesResult = useAtomValue( listTracesResultAtom({ data: { service: search.service, - spanName: search.endpoint, + httpMethod: search.method, + attributeKey: "http.route", + attributeValue: search.endpoint, startTime: effectiveStartTime, endTime: effectiveEndTime, limit: 20, @@ -226,6 +229,7 @@ function EndpointDetailPage() { traces={traces} service={search.service} endpoint={search.endpoint} + method={search.method} startTime={search.startTime} endTime={search.endTime} />