Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .i18n-tracker.lock
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ const resources: ResourceProps[] = [
parent: "model_service",
},
},
{
name: "model_gateway",
},
{
name: "external_endpoints",
list: "/:workspace/external-endpoints",
Expand All @@ -340,7 +343,7 @@ const resources: ResourceProps[] = [
icon: <Globe />,
workspaced: true,
idColumnName: "metadata->name",
parent: "model_service",
parent: "model_gateway",
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4 overflow-auto h-full">
<div className="grid grid-cols-4 gap-4">
{metricCards.map((card) => (
<Card key={card.label}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{card.label}</p>
{isLoading && rows.length === 0 ? (
<Skeleton className="h-7 w-20 mt-1" />
) : (
<p className="text-2xl font-semibold">{card.value}</p>
)}
</CardContent>
</Card>
))}
</div>

<Card>
<CardContent className="pt-4">
{isLoading && rows.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{t("loading")}
</div>
) : rows.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
{t("external_endpoints.monitor.noData")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("external_endpoints.monitor.date")}</TableHead>
<TableHead>
{t("external_endpoints.monitor.apiKey")}
</TableHead>
<TableHead>{t("external_endpoints.monitor.model")}</TableHead>
<TableHead className="text-right">
{t("external_endpoints.stats.promptTokens")}
</TableHead>
<TableHead className="text-right">
{t("external_endpoints.stats.completionTokens")}
</TableHead>
<TableHead className="text-right">
{t("external_endpoints.stats.requests")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
<TableCell>{row.date}</TableCell>
<TableCell>{row.api_key_name}</TableCell>
<TableCell>
{row.model_name ?? (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{row.prompt_tokens != null ? (
formatTokens(row.prompt_tokens)
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{row.completion_tokens != null ? (
formatTokens(row.completion_tokens)
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right font-medium">
{formatTokens(row.usage)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
97 changes: 97 additions & 0 deletions src/domains/external-endpoint/hooks/use-external-endpoint-usage.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalEndpointUsageRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync } = useCustomMutation();
const mutateRef = useRef(mutateAsync);
mutateRef.current = mutateAsync;

const summary = useMemo<ExternalEndpointUsageSummary>(
() =>
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 };
}
53 changes: 53 additions & 0 deletions src/domains/external-endpoint/lib/get-endpoint-type.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
16 changes: 16 additions & 0 deletions src/domains/external-endpoint/lib/get-endpoint-type.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading
Loading