From 23bfa79f47467b434463f7408cc45a047e35ec9a Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Tue, 10 Mar 2026 11:35:37 +0800 Subject: [PATCH] feat: model gateway nav group, type column, and monitoring tab for external endpoints - Add "Model Gateway" as a top-level nav group and move external endpoints under it - Add "Type" column (External / Endpoint Ref / Mixed) to list page - Add Monitor tab to show page with usage summary and detail table - Add getEndpointType utility with unit tests Co-Authored-By: Claude Opus 4.6 --- .i18n-tracker.lock | 11 +- src/App.tsx | 5 +- .../ExternalEndpointMonitorPanel.tsx | 134 ++++++++++ .../hooks/use-external-endpoint-usage.ts | 97 ++++++++ .../lib/get-endpoint-type.test.ts | 53 ++++ .../lib/get-endpoint-type.ts | 16 ++ src/locales/en-US.json | 22 +- src/pages/external-endpoints/list.tsx | 24 ++ src/pages/external-endpoints/show.tsx | 232 ++++++++++-------- 9 files changed, 486 insertions(+), 108 deletions(-) create mode 100644 src/domains/external-endpoint/components/ExternalEndpointMonitorPanel.tsx create mode 100644 src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts create mode 100644 src/domains/external-endpoint/lib/get-endpoint-type.test.ts create mode 100644 src/domains/external-endpoint/lib/get-endpoint-type.ts diff --git a/.i18n-tracker.lock b/.i18n-tracker.lock index 3c4a869e..dd8fae22 100644 --- a/.i18n-tracker.lock +++ b/.i18n-tracker.lock @@ -1,6 +1,6 @@ { "vite.config.ts": "a2ebabd7035347e83eb48536602b2fe1", - "src/App.tsx": "7f88f0646603079a73d54a3962c65582", + "src/App.tsx": "c584b916e6bc106dc2cf58f0f1fb7d2e", "src/Root.tsx": "0bf1b5abed7cbc71c06832cc2de84f12", "src/index.tsx": "6eaed6f7788c9ba4ace41044c3cbc5a8", "src/pages/workspaces/create.tsx": "eda0d8545f33f74948967657ec231e35", @@ -244,8 +244,8 @@ "src/domains/endpoint/hooks/use-endpoint-engine-options.ts": "b2466d871f8436202bb1e6011ec61ecd", "src/pages/external-endpoints/create.tsx": "bd5d137b2a4a49d077ea1891707f2988", "src/pages/external-endpoints/edit.tsx": "e6fdd4684d41f910da1e2ca2ddf4cbd9", - "src/pages/external-endpoints/list.tsx": "801c730953e90d4cb2f8e843810668b7", - "src/pages/external-endpoints/show.tsx": "cd3d5cb92e6c12bc7be111621d3e1775", + "src/pages/external-endpoints/list.tsx": "94eea3c9acfd95fc2a6bd31827753491", + "src/pages/external-endpoints/show.tsx": "0a1d7f1727af4a94080d8ca6d8385d9d", "src/domains/external-endpoint/types.ts": "919dea3f18a6795e2dde24e4a460a89e", "src/domains/external-endpoint/hooks/use-external-endpoint-form.tsx": "0311314ef7322ae29db3c049bbde3875", "src/domains/external-endpoint/components/CurlExample.tsx": "dcec443881aae4d5808b6ba38394fe9a", @@ -273,5 +273,8 @@ "src/domains/external-endpoint/lib/build-embedding-curl-command.ts": "85d12f78fe8a5c38ea2cb22cccd17f09", "src/foundation/components/NumberInput.tsx": "48ac647b7f27621ace1542cb441c5a4d", "src/domains/external-endpoint/lib/find-overlapping-model-keys.ts": "c95ecffb5e8e3e0a45983f6e1f5d2ea4", - "src/pages/auth/lib/validate-password-match.ts": "c113f0c5136ce533297e38cc20bf698f" + "src/pages/auth/lib/validate-password-match.ts": "c113f0c5136ce533297e38cc20bf698f", + "src/domains/external-endpoint/lib/get-endpoint-type.ts": "a5fec42612881e3206ee2e19ca3c820f", + "src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts": "39bd3af89c5f5b84532b38b2d7a23f3d", + "src/domains/external-endpoint/components/ExternalEndpointMonitorPanel.tsx": "16f4ded507020d253d47ce3c526f3993" } diff --git a/src/App.tsx b/src/App.tsx index 9b99559e..f69efa07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -330,6 +330,9 @@ const resources: ResourceProps[] = [ parent: "model_service", }, }, + { + name: "model_gateway", + }, { name: "external_endpoints", list: "/:workspace/external-endpoints", @@ -340,7 +343,7 @@ const resources: ResourceProps[] = [ icon: , workspaced: true, idColumnName: "metadata->name", - parent: "model_service", + parent: "model_gateway", }, }, { diff --git a/src/domains/external-endpoint/components/ExternalEndpointMonitorPanel.tsx b/src/domains/external-endpoint/components/ExternalEndpointMonitorPanel.tsx new file mode 100644 index 00000000..ee6e591b --- /dev/null +++ b/src/domains/external-endpoint/components/ExternalEndpointMonitorPanel.tsx @@ -0,0 +1,134 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useExternalEndpointUsage } from "@/domains/external-endpoint/hooks/use-external-endpoint-usage"; +import { formatTokens } from "@/foundation/lib/unit"; +import { Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +type Props = { + endpointName: string; + workspace: string; +}; + +export default function ExternalEndpointMonitorPanel({ + endpointName, + workspace, +}: Props) { + const { t } = useTranslation(); + const { rows, summary, isLoading } = useExternalEndpointUsage( + endpointName, + workspace, + ); + + const metricCards = [ + { + label: t("external_endpoints.stats.promptTokens"), + value: formatTokens(summary.promptTokens) ?? "0", + }, + { + label: t("external_endpoints.stats.completionTokens"), + value: formatTokens(summary.completionTokens) ?? "0", + }, + { + label: t("external_endpoints.stats.totalTokens"), + value: formatTokens(summary.totalTokens) ?? "0", + }, + { + label: t("external_endpoints.stats.requests"), + value: formatTokens(summary.totalRequests) ?? "0", + }, + ]; + + return ( +
+
+ {metricCards.map((card) => ( + + +

{card.label}

+ {isLoading && rows.length === 0 ? ( + + ) : ( +

{card.value}

+ )} +
+
+ ))} +
+ + + + {isLoading && rows.length === 0 ? ( +
+ + {t("loading")} +
+ ) : rows.length === 0 ? ( +

+ {t("external_endpoints.monitor.noData")} +

+ ) : ( + + + + {t("external_endpoints.monitor.date")} + + {t("external_endpoints.monitor.apiKey")} + + {t("external_endpoints.monitor.model")} + + {t("external_endpoints.stats.promptTokens")} + + + {t("external_endpoints.stats.completionTokens")} + + + {t("external_endpoints.stats.requests")} + + + + + {rows.map((row, index) => ( + + {row.date} + {row.api_key_name} + + {row.model_name ?? ( + + )} + + + {row.prompt_tokens != null ? ( + formatTokens(row.prompt_tokens) + ) : ( + + )} + + + {row.completion_tokens != null ? ( + formatTokens(row.completion_tokens) + ) : ( + + )} + + + {formatTokens(row.usage)} + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts b/src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts new file mode 100644 index 00000000..c9fee87c --- /dev/null +++ b/src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts @@ -0,0 +1,97 @@ +import { useCustomMutation } from "@refinedev/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type ExternalEndpointUsageRow = { + date: string; + api_key_name: string; + model_name: string | null; + usage: number; + prompt_tokens: number | null; + completion_tokens: number | null; +}; + +type ExternalEndpointUsageSummary = { + totalRequests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; +}; + +const POLL_INTERVAL_MS = 60_000; + +function getStartDate(): string { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().slice(0, 10); +} + +export function useExternalEndpointUsage( + endpointName: string | undefined, + workspace: string | undefined, +) { + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { mutateAsync } = useCustomMutation(); + const mutateRef = useRef(mutateAsync); + mutateRef.current = mutateAsync; + + const summary = useMemo( + () => + rows.reduce( + (acc, row) => ({ + totalRequests: acc.totalRequests + (row.usage ?? 0), + promptTokens: acc.promptTokens + (row.prompt_tokens ?? 0), + completionTokens: acc.completionTokens + (row.completion_tokens ?? 0), + totalTokens: + acc.totalTokens + + (row.prompt_tokens ?? 0) + + (row.completion_tokens ?? 0), + }), + { + totalRequests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }, + ), + [rows], + ); + + const fetchUsage = useCallback(async () => { + if (!endpointName || !workspace) return; + + setIsLoading(true); + try { + const res = await mutateRef.current({ + url: "/rpc/get_usage_by_dimension", + method: "post", + values: { + p_start_date: getStartDate(), + p_end_date: new Date().toISOString(), + p_endpoint_name: endpointName, + p_workspace: workspace, + }, + successNotification: false, + errorNotification: false, + }); + + setRows(res.data as ExternalEndpointUsageRow[]); + } catch { + // Non-critical — monitor panel degrades gracefully + } finally { + setIsLoading(false); + } + }, [endpointName, workspace]); + + useEffect(() => { + fetchUsage(); + }, [fetchUsage]); + + useEffect(() => { + if (!endpointName || !workspace) return; + const interval = setInterval(fetchUsage, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchUsage, endpointName, workspace]); + + return { rows, summary, isLoading }; +} diff --git a/src/domains/external-endpoint/lib/get-endpoint-type.test.ts b/src/domains/external-endpoint/lib/get-endpoint-type.test.ts new file mode 100644 index 00000000..88f228ef --- /dev/null +++ b/src/domains/external-endpoint/lib/get-endpoint-type.test.ts @@ -0,0 +1,53 @@ +import type { ExternalEndpointSpec } from "@/domains/external-endpoint/types"; +import { describe, expect, it } from "vitest"; +import { getEndpointType } from "./get-endpoint-type"; + +const makeSpec = ( + upstreams: Array<{ endpoint_ref?: string }>, +): ExternalEndpointSpec => ({ + timeout: null, + upstreams: upstreams.map((u) => ({ + ...u, + model_mapping: {}, + models: null, + upstream: u.endpoint_ref ? null : { url: "https://api.example.com" }, + })), +}); + +describe("getEndpointType", () => { + it("returns null for null spec", () => { + expect(getEndpointType(null)).toBeNull(); + }); + + it("returns null for empty upstreams", () => { + expect(getEndpointType({ timeout: null, upstreams: [] })).toBeNull(); + }); + + it('returns "external" when all upstreams are external', () => { + expect(getEndpointType(makeSpec([{}, {}]))).toBe("external"); + }); + + it('returns "endpoint_ref" when all upstreams use endpoint_ref', () => { + expect( + getEndpointType( + makeSpec([{ endpoint_ref: "ep-1" }, { endpoint_ref: "ep-2" }]), + ), + ).toBe("endpoint_ref"); + }); + + it('returns "mixed" when upstreams have both types', () => { + expect(getEndpointType(makeSpec([{}, { endpoint_ref: "ep-1" }]))).toBe( + "mixed", + ); + }); + + it('returns "external" for a single external upstream', () => { + expect(getEndpointType(makeSpec([{}]))).toBe("external"); + }); + + it('returns "endpoint_ref" for a single endpoint_ref upstream', () => { + expect(getEndpointType(makeSpec([{ endpoint_ref: "ep-1" }]))).toBe( + "endpoint_ref", + ); + }); +}); diff --git a/src/domains/external-endpoint/lib/get-endpoint-type.ts b/src/domains/external-endpoint/lib/get-endpoint-type.ts new file mode 100644 index 00000000..9845971c --- /dev/null +++ b/src/domains/external-endpoint/lib/get-endpoint-type.ts @@ -0,0 +1,16 @@ +import type { ExternalEndpointSpec } from "@/domains/external-endpoint/types"; + +export type ExternalEndpointType = "external" | "endpoint_ref" | "mixed"; + +export function getEndpointType( + spec: ExternalEndpointSpec | null, +): ExternalEndpointType | null { + if (!spec?.upstreams?.length) return null; + + const hasExternal = spec.upstreams.some((u) => !u.endpoint_ref); + const hasEndpointRef = spec.upstreams.some((u) => !!u.endpoint_ref); + + if (hasExternal && hasEndpointRef) return "mixed"; + if (hasEndpointRef) return "endpoint_ref"; + return "external"; +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 85809653..9acb2a93 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -578,7 +578,8 @@ "modelMapping": "Model Mapping", "exposedModelName": "Exposed Model Name", "upstreamModelName": "Upstream Model Name", - "models": "Models" + "models": "Models", + "type": "Type" }, "sections": { "configuration": "Configuration", @@ -599,7 +600,8 @@ "options": { "chatCompletions": "Chat Completions", "upstreamTypeExternal": "External", - "upstreamTypeEndpointRef": "Endpoint Ref" + "upstreamTypeEndpointRef": "Endpoint Ref", + "upstreamTypeMixed": "Mixed" }, "actions": { "addUpstream": "Add Upstream", @@ -615,6 +617,19 @@ "curlExampleEmbedding": "Embedding", "credentialHidden": "Credential is hidden for security" }, + "stats": { + "endpoints": "Endpoints", + "requests": "Requests", + "promptTokens": "Prompt Tokens", + "completionTokens": "Completion Tokens", + "totalTokens": "Total Tokens" + }, + "monitor": { + "date": "Date", + "apiKey": "API Key", + "model": "Model", + "noData": "No usage data available yet" + }, "validation": { "nameRequired": "Name is required", "routeTypeRequired": "Route type is required", @@ -716,6 +731,9 @@ "model_service": { "title": "Model Service" }, + "model_gateway": { + "title": "Model Gateway" + }, "access_control": { "title": "Access Control" }, diff --git a/src/pages/external-endpoints/list.tsx b/src/pages/external-endpoints/list.tsx index 02b0efe5..557152f3 100644 --- a/src/pages/external-endpoints/list.tsx +++ b/src/pages/external-endpoints/list.tsx @@ -1,4 +1,9 @@ +import { Badge } from "@/components/ui/badge"; import ExternalEndpointStatus from "@/domains/external-endpoint/components/ExternalEndpointStatus"; +import { + type ExternalEndpointType, + getEndpointType, +} from "@/domains/external-endpoint/lib/get-endpoint-type"; import { getExposedModels } from "@/domains/external-endpoint/lib/get-exposed-models"; import type { ExternalEndpoint } from "@/domains/external-endpoint/types"; import { ListPage } from "@/foundation/components/ListPage"; @@ -35,6 +40,25 @@ export const ExternalEndpointsList = () => { /> )} /> + { + const spec = getValue() as unknown as ExternalEndpoint["spec"]; + const endpointType = getEndpointType(spec); + if (!endpointType) return "-"; + const labelMap: Record = { + external: t("external_endpoints.options.upstreamTypeExternal"), + endpoint_ref: t( + "external_endpoints.options.upstreamTypeEndpointRef", + ), + mixed: t("external_endpoints.options.upstreamTypeMixed"), + }; + return {labelMap[endpointType]}; + }} + /> { return ( - - - - - - -
- - {formatTimeout(record.spec?.timeout)} - - {record.status?.service_url && ( -
- - + + + {t("common.tabs.basic")} + {t("common.tabs.monitor")} + + + + + + + + +
+ + {formatTimeout(record.spec?.timeout)} + {record.status?.service_url && ( +
+ + + +
+ )}
- )} -
- - + + - {record.spec?.upstreams?.map((upstream, index) => ( - - - - {t("external_endpoints.sections.upstream", { - index: index + 1, - })} - - - -
- {upstream.endpoint_ref ? ( -
- - - {upstream.endpoint_ref} - - -
- ) : ( - <> - {upstream.upstream?.url && ( + {record.spec?.upstreams?.map((upstream, index) => ( + + + + {t("external_endpoints.sections.upstream", { + index: index + 1, + })} + + + +
+ {upstream.endpoint_ref ? (
- {upstream.upstream.url} + {upstream.endpoint_ref}
+ ) : ( + <> + {upstream.upstream?.url && ( +
+ + + {upstream.upstream.url} + + +
+ )} + {upstream.auth && ( + + {upstream.auth.type} + + )} + )} - {upstream.auth && ( - - {upstream.auth.type} - - )} - - )} -
- {upstream.model_mapping && - Object.keys(upstream.model_mapping).length > 0 && ( -
-
- {t("external_endpoints.fields.modelMapping")} -
-
- - - - - - - - - {Object.entries(upstream.model_mapping).map( - ([exposed, upstreamModel]) => ( - - - - - ), - )} - -
- {t("external_endpoints.fields.exposedModelName")} - - {t("external_endpoints.fields.upstreamModelName")} -
- {exposed} - - {upstreamModel} -
-
- )} -
-
- ))} + {upstream.model_mapping && + Object.keys(upstream.model_mapping).length > 0 && ( +
+
+ {t("external_endpoints.fields.modelMapping")} +
+
+ + + + + + + + + {Object.entries(upstream.model_mapping).map( + ([exposed, upstreamModel]) => ( + + + + + ), + )} + +
+ {t( + "external_endpoints.fields.exposedModelName", + )} + + {t( + "external_endpoints.fields.upstreamModelName", + )} +
+ {exposed} + + + {upstreamModel} + +
+
+
+ )} + + + ))} - {record.status?.phase === "Running" && record.status?.service_url && ( - - )} + {record.status?.phase === "Running" && record.status?.service_url && ( + + )} + + + + + ); };