feat(tags): show a tag's virtual keys on the tag details page

The tag details page gets a Virtual Keys card listing the keys that carry the tag, each linking to its key page, with loading, error and empty states
This commit is contained in:
Arnold Galovics 2026-09-24 14:23:00 +02:00
parent d7ca77e255
commit 22d3a9bbaf
6 changed files with 243 additions and 1 deletions

View file

@ -424,6 +424,22 @@ describe("useKeys", () => {
expect(result.current.data?.keys[0].project_id).toBe("project-1");
});
it("should pass the tag filter to the API", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockKeysResponse,
});
const { result } = renderHook(() => useKeys(1, 10, { tag: "prod batch" }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const callUrl = new URL(mockFetch.mock.calls[0][0], "http://localhost");
expect(callUrl.searchParams.get("tag")).toBe("prod batch");
});
it("should pass both projectID and teamID filters to the API", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,

View file

@ -37,6 +37,7 @@ export interface KeyListCallOptions {
teamID?: string | null;
projectID?: string | null;
agentID?: string | null;
tag?: string | null;
selectedKeyAlias?: string | null;
userID?: string | null;
keyHash?: string | null;
@ -59,6 +60,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number,
team_id: options.teamID,
project_id: options.projectID,
agent_id: options.agentID,
tag: options.tag,
organization_id: options.organizationID,
key_alias: options.selectedKeyAlias,
key_hash: options.keyHash,

View file

@ -0,0 +1,84 @@
import { render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
import TagKeysSection, { TAG_KEYS_PAGE_SIZE } from "./TagKeysSection";
const mockUseKeys = vi.fn();
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: (...args: unknown[]) => mockUseKeys(...args),
}));
const makeKey = (overrides: Partial<KeyResponse>): KeyResponse =>
({ token: "tok", key_alias: "", key_name: "sk-...0000", team_id: null, spend: 0, ...overrides }) as KeyResponse;
const loaded = (keys: KeyResponse[], totalCount = keys.length) => ({
data: { keys, total_count: totalCount, current_page: 1, total_pages: 1 },
isLoading: false,
isError: false,
});
describe("TagKeysSection", () => {
beforeEach(() => {
mockUseKeys.mockReset();
});
it("should request the first page of keys filtered by the tag name", () => {
mockUseKeys.mockReturnValue(loaded([]));
render(<TagKeysSection tagName="prod-batch" />);
expect(mockUseKeys).toHaveBeenCalledWith(1, TAG_KEYS_PAGE_SIZE, { tag: "prod-batch" });
});
it("should list each key with its alias linking to the key's page, its team and its spend", () => {
const teamKey: Partial<KeyResponse> = { token: "tok-1", key_alias: "batch-key-1", team_id: "team-a", spend: 1.5 };
mockUseKeys.mockReturnValue(loaded([makeKey(teamKey), makeKey({ token: "tok-2", key_alias: "batch-key-2" })]));
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByRole("link", { name: "batch-key-1" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1");
expect(screen.getByRole("link", { name: "batch-key-2" })).toHaveAttribute("href", "/ui/api-keys?key=tok-2");
const firstRow = screen.getByRole("row", { name: /batch-key-1/ });
expect(within(firstRow).getByText("team-a")).toBeInTheDocument();
expect(within(firstRow).getByText("1.5000")).toBeInTheDocument();
expect(within(screen.getByRole("row", { name: /batch-key-2/ })).getByText("-")).toBeInTheDocument();
});
it("should fall back to the masked key name when a key has no alias", () => {
mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-3", key_alias: "", key_name: "sk-...wxyz" })]));
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByRole("link", { name: "sk-...wxyz" })).toHaveAttribute("href", "/ui/api-keys?key=tok-3");
});
it("should say no virtual keys use the tag when none carry it", () => {
mockUseKeys.mockReturnValue(loaded([]));
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByText("No virtual keys use this tag")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("should show a loading message while the keys are loading", () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false });
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByText("Loading virtual keys...")).toBeInTheDocument();
expect(screen.queryByText("No virtual keys use this tag")).not.toBeInTheDocument();
});
it("should show an error message instead of the empty message when the keys fail to load", () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: false, isError: true });
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByText("Could not load the virtual keys for this tag")).toBeInTheDocument();
expect(screen.queryByText("No virtual keys use this tag")).not.toBeInTheDocument();
});
it("should say how many keys are shown when the tag has more keys than one page", () => {
mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-1", key_alias: "batch-key-1" })], 250));
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.getByText("Showing the 1 most recently created of 250 keys")).toBeInTheDocument();
});
it("should not show a count note when every key fits on the page", () => {
mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-1", key_alias: "batch-key-1" })]));
render(<TagKeysSection tagName="prod-batch" />);
expect(screen.queryByText(/most recently created/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,69 @@
"use client";
import React from "react";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { keyDetailHref } from "@/utils/entityLinks";
export const TAG_KEYS_PAGE_SIZE = 100;
interface TagKeysSectionProps {
tagName: string;
}
const TagKeysSection: React.FC<TagKeysSectionProps> = ({ tagName }) => {
const { data, isLoading, isError } = useKeys(1, TAG_KEYS_PAGE_SIZE, { tag: tagName });
const keys = data?.keys ?? [];
const totalCount = data?.total_count ?? 0;
const renderBody = () => {
if (isLoading) return <p className="mt-4 text-sm text-muted-foreground">Loading virtual keys...</p>;
if (isError) return <p className="mt-4 text-sm text-destructive">Could not load the virtual keys for this tag</p>;
if (keys.length === 0) return <p className="mt-4 text-sm text-muted-foreground">No virtual keys use this tag</p>;
return (
<>
{totalCount > keys.length && (
<p className="mt-4 text-sm text-muted-foreground">
Showing the {keys.length} most recently created of {totalCount} keys
</p>
)}
<Table className="mt-4">
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-right">Spend (USD)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.token}>
<TableCell className="font-medium">
<a href={keyDetailHref(key.token)} className="text-info hover:underline">
{key.key_alias || key.key_name}
</a>
</TableCell>
<TableCell>{key.team_id ?? "-"}</TableCell>
<TableCell className="text-right">{formatNumberWithCommas(key.spend, 4)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
};
return (
<Card>
<CardContent>
<CardTitle>Virtual Keys</CardTitle>
{renderBody()}
</CardContent>
</Card>
);
};
export default TagKeysSection;

View file

@ -1,5 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { tagInfoCall, tagUpdateCall } from "@/components/networking";
@ -10,6 +11,14 @@ import TagInfoView from "./tag_info";
vi.mock("@/components/networking", () => ({
tagInfoCall: vi.fn(),
tagUpdateCall: vi.fn(),
getProxyBaseUrl: () => "",
getGlobalLitellmHeaderName: () => "Authorization",
deriveErrorMessage: (errorData: unknown) => JSON.stringify(errorData),
handleError: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test" }),
}));
vi.mock("@/components/organisms/create_key_button", () => ({
@ -34,9 +43,20 @@ const tag: Tag = {
litellm_budget_table: { max_budget: 10, budget_duration: "7d", tpm_limit: 1000, rpm_limit: 60 },
};
const keyListFetch = vi.fn();
const renderTagInfo = (editTag: boolean) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<TagInfoView tagId="prod-tag" onClose={vi.fn()} accessToken="sk-test" is_admin editTag={editTag} />
</QueryClientProvider>,
);
};
const renderEditor = async () => {
const user = userEvent.setup();
render(<TagInfoView tagId="prod-tag" onClose={vi.fn()} accessToken="sk-test" is_admin editTag />);
renderTagInfo(true);
const nameInput = await screen.findByLabelText("Tag Name");
return { user, nameInput };
};
@ -46,6 +66,11 @@ describe("TagInfoView save payload", () => {
vi.clearAllMocks();
mockTagInfoCall.mockResolvedValue({ "prod-tag": tag });
mockTagUpdateCall.mockResolvedValue(undefined);
keyListFetch.mockResolvedValue({
ok: true,
json: async () => ({ keys: [], total_count: 0, current_page: 1, total_pages: 0 }),
});
vi.stubGlobal("fetch", keyListFetch);
});
it("should send the edited fields and omit the budget fields while the budget section is collapsed", async () => {
@ -150,3 +175,46 @@ describe("TagInfoView save payload", () => {
expect(mockTagUpdateCall).not.toHaveBeenCalled();
});
});
describe("TagInfoView virtual keys", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTagInfoCall.mockResolvedValue({ "prod-tag": tag });
vi.stubGlobal("fetch", keyListFetch);
});
it("should list the keys that /key/list returns for this tag, each linking to its key page", async () => {
keyListFetch.mockResolvedValue({
ok: true,
json: async () => ({
keys: [{ token: "tok-1", key_alias: "batch-key-1", key_name: "sk-...0001", team_id: "team-a", spend: 2 }],
total_count: 1,
current_page: 1,
total_pages: 1,
}),
});
renderTagInfo(false);
expect(await screen.findByRole("link", { name: "batch-key-1" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1");
const requestUrl = new URL(keyListFetch.mock.calls[0][0], "http://localhost");
expect(requestUrl.pathname).toBe("/key/list");
expect(requestUrl.searchParams.get("tag")).toBe("prod-tag");
});
it("should say no virtual keys use the tag when /key/list returns none", async () => {
keyListFetch.mockResolvedValue({
ok: true,
json: async () => ({ keys: [], total_count: 0, current_page: 1, total_pages: 0 }),
});
renderTagInfo(false);
expect(await screen.findByText("No virtual keys use this tag")).toBeInTheDocument();
});
it("should show an error in the section when /key/list fails", async () => {
keyListFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "boom" }) });
renderTagInfo(false);
expect(await screen.findByText("Could not load the virtual keys for this tag")).toBeInTheDocument();
});
});

View file

@ -22,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useZodForm } from "@/lib/forms/useZodForm";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, ChevronRight, CopyIcon } from "lucide-react";
import TagKeysSection from "./TagKeysSection";
const tagEditShape = {
name: z.string().min(1, "Please input a tag name"),
@ -337,6 +338,8 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
</CardContent>
</Card>
)}
<TagKeysSection tagName={tagDetails.name} />
</div>
)}
</div>