feat(ui): link team and key model chips to the models page filtered to that group

Model chips on the team info and virtual key info pages (overview and settings tabs) now link to /models-and-endpoints?model_group=<name>, which the All Models tab reads through a new nuqs-backed model group filter. Grant sentinels such as all-proxy-models stay plain badges. The selected group is also sent as the server-side search so the matching deployments are fetched even when they are beyond the first page.
This commit is contained in:
ryan-crabbe-berri 2026-08-27 21:08:33 -07:00
parent 3300fc3a96
commit 56a80c8125
13 changed files with 244 additions and 26 deletions

View file

@ -260,6 +260,27 @@ describe("AllModelsTab", () => {
expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument();
}); });
it("queries the server for the selected model group so deployments beyond the first page are found", () => {
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
expect(lastModelsInfoCall().search).toBe("claude-opus");
});
it.each(["all", "wildcard"])("does not seed the server search from the %s pseudo group", (group) => {
render(<AllModelsTab {...defaultProps} selectedModelGroup={group} />);
expect(lastModelsInfoCall().search).toBeUndefined();
});
it("lets a typed search override the selected model group in the server query", async () => {
const user = userEvent.setup();
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
await user.type(screen.getByPlaceholderText("Search model names…"), "gpt");
await waitFor(() => expect(lastModelsInfoCall().search).toBe("gpt"));
});
it("resets search, filters, team and sorting from the drawer reset button", async () => { it("resets search, filters, team and sorting from the drawer reset button", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />); render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);

View file

@ -81,6 +81,11 @@ const AllModelsTab = ({
}, [modelNameSearch, debouncedUpdateSearch]); }, [modelNameSearch, debouncedUpdateSearch]);
const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue; const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue;
const isConcreteModelGroup =
Boolean(selectedModelGroup) &&
selectedModelGroup !== ALL_MODEL_GROUPS_VALUE &&
selectedModelGroup !== WILDCARD_MODEL_GROUP_VALUE;
const searchForQuery = debouncedSearch || (isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined);
const sortBy = useMemo(() => { const sortBy = useMemo(() => {
if (sorting.length === 0) return undefined; if (sorting.length === 0) return undefined;
@ -100,7 +105,7 @@ const AllModelsTab = ({
} = useModelsInfo( } = useModelsInfo(
pagination.pageIndex + 1, pagination.pageIndex + 1,
pagination.pageSize, pagination.pageSize,
debouncedSearch || undefined, searchForQuery,
undefined, undefined,
teamIdForQuery, teamIdForQuery,
sortBy, sortBy,

View file

@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useModelDetailRouting } from "./detailNavigation"; import { useModelDetailRouting, useModelGroupFilterRouting } from "./detailNavigation";
describe("useModelDetailRouting", () => { describe("useModelDetailRouting", () => {
it("openModel sets ?model= with a history push", async () => { it("openModel sets ?model= with a history push", async () => {
@ -54,3 +54,29 @@ describe("useModelDetailRouting", () => {
expect(result.current.teamId).toBeNull(); expect(result.current.teamId).toBeNull();
}); });
}); });
describe("useModelGroupFilterRouting", () => {
it("reads the selected group from ?model_group=", () => {
const { result } = renderHook(() => useModelGroupFilterRouting(), {
wrapper: withNuqsTestingAdapter({ searchParams: "?model_group=gpt-4.1" }),
});
expect(result.current.modelGroup).toBe("gpt-4.1");
});
it("writes the selected group to ?model_group= and clears it on null", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const { result } = renderHook(() => useModelGroupFilterRouting(), {
wrapper: withNuqsTestingAdapter({ onUrlUpdate }),
});
await act(async () => {
result.current.setModelGroup("claude-sonnet-5");
});
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("model_group")).toBe("claude-sonnet-5");
await act(async () => {
result.current.setModelGroup(null);
});
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("model_group")).toBe(false));
});
});

View file

@ -1,4 +1,4 @@
import { parseAsString, useQueryStates } from "nuqs"; import { parseAsString, useQueryState, useQueryStates } from "nuqs";
import { useCallback } from "react"; import { useCallback } from "react";
export interface ModelDetailRouting { export interface ModelDetailRouting {
@ -41,3 +41,22 @@ export function useModelDetailRouting(): ModelDetailRouting {
close, close,
}; };
} }
export interface ModelGroupFilterRouting {
modelGroup: string | null;
setModelGroup: (modelGroup: string | null) => void;
}
/** `?model_group=` backs the All Models group filter so other pages can deep-link to one group. */
export function useModelGroupFilterRouting(): ModelGroupFilterRouting {
const [modelGroup, setParam] = useQueryState("model_group", parseAsString);
const setModelGroup = useCallback(
(next: string | null) => {
void setParam(next);
},
[setParam],
);
return { modelGroup, setModelGroup };
}

View file

@ -1,19 +1,22 @@
"use client"; "use client";
import { useState } from "react";
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab"; import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
import { ALL_MODEL_GROUPS_VALUE } from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTable";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; import {
useModelDetailRouting,
useModelGroupFilterRouting,
} from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
export default function AllModelsPanel() { export default function AllModelsPanel() {
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null); const { modelGroup, setModelGroup } = useModelGroupFilterRouting();
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
const { openModel, openTeam } = useModelDetailRouting(); const { openModel, openTeam } = useModelDetailRouting();
return ( return (
<AllModelsTab <AllModelsTab
selectedModelGroup={selectedModelGroup} selectedModelGroup={modelGroup}
setSelectedModelGroup={setSelectedModelGroup} setSelectedModelGroup={(group) => setModelGroup(group === ALL_MODEL_GROUPS_VALUE ? null : group)}
availableModelGroups={availableModelGroups} availableModelGroups={availableModelGroups}
availableModelAccessGroups={availableModelAccessGroups} availableModelAccessGroups={availableModelAccessGroups}
setSelectedModelId={openModel} setSelectedModelId={openModel}

View file

@ -1,9 +1,12 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { StatusBadge, type StatusTone } from "./status_badge"; import { StatusBadge, type StatusTone } from "./status_badge";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
describe("StatusBadge", () => { describe("StatusBadge", () => {
const toneClasses: Record<StatusTone, string[]> = { const toneClasses: Record<StatusTone, string[]> = {
success: ["border-success/20", "bg-success/10", "text-success"], success: ["border-success/20", "bg-success/10", "text-success"],
@ -39,4 +42,19 @@ describe("StatusBadge", () => {
await user.hover(screen.getByText("Blocked")); await user.hover(screen.getByText("Blocked"));
expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument(); expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument();
}); });
it("renders a tinted anchor that navigates client-side when href is given", async () => {
const user = userEvent.setup();
render(<StatusBadge tone="info" label="gpt-4.1" href="/models-and-endpoints?model_group=gpt-4.1" />);
const link = screen.getByRole("link", { name: "gpt-4.1" });
expect(link).toHaveAttribute("href", "/models-and-endpoints?model_group=gpt-4.1");
expect(link.className).toContain("text-info");
await user.click(link);
expect(push).toHaveBeenCalledWith("/models-and-endpoints?model_group=gpt-4.1");
});
it("renders no anchor without an href", () => {
render(<StatusBadge tone="info" label="gpt-4.1" />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
}); });

View file

@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useEntityLinkClick } from "@/components/shared/EntityLink";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/cva.config"; import { cn } from "@/lib/cva.config";
@ -23,15 +24,17 @@ interface StatusBadgeProps {
tooltip?: React.ReactNode; tooltip?: React.ReactNode;
dataTestId?: string; dataTestId?: string;
className?: string; className?: string;
href?: string;
} }
export function StatusBadge({ tone, label, tooltip, dataTestId, className }: StatusBadgeProps) { export function StatusBadge({ tone, label, tooltip, dataTestId, className, href }: StatusBadgeProps) {
const badge = ( const badgeClassName = cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className);
<Badge const badge = href ? (
variant="outline" <LinkedStatusBadge href={href} dataTestId={dataTestId} className={badgeClassName}>
data-testid={dataTestId} {label}
className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className)} </LinkedStatusBadge>
> ) : (
<Badge variant="outline" data-testid={dataTestId} className={badgeClassName}>
{label} {label}
</Badge> </Badge>
); );
@ -41,3 +44,25 @@ export function StatusBadge({ tone, label, tooltip, dataTestId, className }: Sta
} }
return <CellTooltip content={tooltip} trigger={badge} />; return <CellTooltip content={tooltip} trigger={badge} />;
} }
interface LinkedStatusBadgeProps {
href: string;
dataTestId?: string;
className: string;
children: string;
}
function LinkedStatusBadge({ href, dataTestId, className, children }: LinkedStatusBadgeProps) {
const handleClick = useEntityLinkClick(href);
return (
<Badge
variant="outline"
data-testid={dataTestId}
className={cn("cursor-pointer hover:underline", className)}
render={<a href={href} onClick={handleClick} />}
>
{children}
</Badge>
);
}

View file

@ -21,7 +21,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
}), }),
})); }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/networking", () => ({ vi.mock("@/components/networking", () => ({
serverRootPath: "",
teamInfoCall: vi.fn(), teamInfoCall: vi.fn(),
teamMemberDeleteCall: vi.fn(), teamMemberDeleteCall: vi.fn(),
teamMemberAddCall: vi.fn(), teamMemberAddCall: vi.fn(),
@ -278,6 +281,36 @@ describe("TeamInfoView", () => {
}); });
}); });
it("links direct and access-group model badges to the models page filtered to that group", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4.1"],
access_group_models: ["claude-sonnet-5"],
access_group_details: [{ access_group_id: "ag-1", access_group_name: "prod", models: ["claude-sonnet-5"] }],
}),
);
renderWithProviders(<TeamInfoView {...defaultProps} />);
expect(await screen.findByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
"href",
expect.stringContaining("/models-and-endpoints?model_group=gpt-4.1"),
);
expect(screen.getByRole("link", { name: "claude-sonnet-5" })).toHaveAttribute(
"href",
expect.stringContaining("/models-and-endpoints?model_group=claude-sonnet-5"),
);
});
it("keeps the all-proxy-models badge non-clickable", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] }));
renderWithProviders(<TeamInfoView {...defaultProps} />);
expect(await screen.findByText("All proxy models")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "All proxy models" })).not.toBeInTheDocument();
});
it("should display loading state while fetching team data", () => { it("should display loading state while fetching team data", () => {
vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => {})); vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => {}));

View file

@ -22,7 +22,9 @@ import type { ObjectPermission } from "@/components/object_permission_types";
import { isProxyAdminRole } from "@/utils/roles"; import { isProxyAdminRole } from "@/utils/roles";
import { ArrowLeftIcon } from "@heroicons/react/outline"; import { ArrowLeftIcon } from "@heroicons/react/outline";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { modelGroupHref } from "@/utils/entityLinks";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input"; import { Input as UIInput } from "@/components/ui/input";
@ -53,6 +55,7 @@ import {
computeTeamModelBadges, computeTeamModelBadges,
normalizeTeamModelSelection, normalizeTeamModelSelection,
TeamAccessGroupModelGrant, TeamAccessGroupModelGrant,
TeamModelBadge,
TeamModelBadgeKind, TeamModelBadgeKind,
} from "./teamModelAccess"; } from "./teamModelAccess";
import MetadataKeyValueFields, { import MetadataKeyValueFields, {
@ -111,6 +114,9 @@ const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
"access-group": "success", "access-group": "success",
}; };
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
export interface TeamMembership { export interface TeamMembership {
user_id: string; user_id: string;
team_id: string; team_id: string;
@ -1006,7 +1012,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
(badge, index) => ( (badge, index) => (
<SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}> <SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}>
<span> <span>
<StatusBadge tone={TEAM_MODEL_BADGE_TONES[badge.kind]} label={badge.label} /> <StatusBadge
tone={TEAM_MODEL_BADGE_TONES[badge.kind]}
label={badge.label}
href={teamModelBadgeHref(badge)}
/>
</span> </span>
</SimpleTooltip> </SimpleTooltip>
), ),
@ -1727,9 +1737,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Models</p> <p className="font-medium">Models</p>
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-2 mt-1">
{info.models.map((model, index) => ( {info.models.map((model, index) => (
<Badge key={index} variant="secondary"> <BadgeLink key={index} href={modelGroupHref(model)}>
{model} {model}
</Badge> </BadgeLink>
))} ))}
</div> </div>
</div> </div>
@ -1738,9 +1748,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Default Member Models</p> <p className="font-medium">Default Member Models</p>
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-2 mt-1">
{info.default_team_member_models.map((model, index) => ( {info.default_team_member_models.map((model, index) => (
<Badge key={index} variant="secondary"> <BadgeLink key={index} href={modelGroupHref(model)}>
{model} {model}
</Badge> </BadgeLink>
))} ))}
</div> </div>
</div> </div>

View file

@ -561,6 +561,32 @@ describe("KeyInfoView", () => {
); );
}); });
it("links each model chip to the models page filtered to that model group", async () => {
const keyData = { ...MOCK_KEY_DATA, models: ["gpt-4.1", "anthropic/*"] };
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
);
expect(await screen.findByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
"href",
expect.stringContaining("/models-and-endpoints?model_group=gpt-4.1"),
);
expect(screen.getByRole("link", { name: "anthropic/*" })).toHaveAttribute(
"href",
expect.stringContaining("/models-and-endpoints?model_group=anthropic%2F*"),
);
});
it("keeps the all-proxy-models grant chip non-clickable", async () => {
const keyData = { ...MOCK_KEY_DATA, models: ["all-proxy-models"] };
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
);
expect((await screen.findAllByText("all-proxy-models")).length).toBeGreaterThan(0);
expect(screen.queryByRole("link", { name: "all-proxy-models" })).not.toBeInTheDocument();
});
it("renders no team link when the key has no team", async () => { it("renders no team link when the key has no team", async () => {
renderWithProviders( renderWithProviders(
<KeyInfoView <KeyInfoView

View file

@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EntityLink } from "@/components/shared/EntityLink"; import { EntityLink } from "@/components/shared/EntityLink";
import { teamDetailHref } from "@/utils/entityLinks"; import { modelGroupHref, teamDetailHref } from "@/utils/entityLinks";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { KeyInfoHeader } from "./KeyInfoHeader"; import { KeyInfoHeader } from "./KeyInfoHeader";
import KeySavingsTab from "./KeySavingsTab"; import KeySavingsTab from "./KeySavingsTab";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -660,9 +661,9 @@ export default function KeyInfoView({
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{currentKeyData.models && currentKeyData.models.length > 0 ? ( {currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => ( currentKeyData.models.map((model, index) => (
<Badge key={index} variant="secondary" className="min-w-0 break-words"> <BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
{model} {model}
</Badge> </BadgeLink>
)) ))
) : ( ) : (
<p className="text-sm">No models specified</p> <p className="text-sm">No models specified</p>
@ -996,9 +997,9 @@ export default function KeyInfoView({
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-2 mt-1">
{currentKeyData.models && currentKeyData.models.length > 0 ? ( {currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => ( currentKeyData.models.map((model, index) => (
<span key={index} className="px-2 py-1 bg-info/15 rounded-sm text-xs"> <BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
{model} {model}
</span> </BadgeLink>
)) ))
) : ( ) : (
<p className="text-sm">No models specified</p> <p className="text-sm">No models specified</p>

View file

@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { modelGroupHref } from "./entityLinks";
describe("modelGroupHref", () => {
it("targets the models page filtered to the encoded model group", () => {
expect(modelGroupHref("gpt-4.1")).toMatch(/\/models-and-endpoints\?model_group=gpt-4\.1$/);
expect(modelGroupHref("openai/*")).toMatch(/\?model_group=openai%2F\*$/);
});
it.each(["all-proxy-models", "all-team-models", "no-default-models"])(
"returns no href for the %s grant sentinel",
(sentinel) => {
expect(modelGroupHref(sentinel)).toBeUndefined();
},
);
});

View file

@ -1,5 +1,11 @@
import { migratedHref } from "@/utils/migratedPages"; import { migratedHref } from "@/utils/migratedPages";
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
"all-proxy-models",
"all-team-models",
"no-default-models",
]);
export function teamDetailHref(teamId: string): string { export function teamDetailHref(teamId: string): string {
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`; return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
} }
@ -15,3 +21,9 @@ export function userDetailHref(userId: string): string {
export function orgDetailHref(orgId: string): string { export function orgDetailHref(orgId: string): string {
return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`; return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`;
} }
/** Models page filtered to one model group; undefined for grant sentinels that name no deployment. */
export function modelGroupHref(modelGroup: string): string | undefined {
if (MODEL_GRANT_SENTINELS.has(modelGroup)) return undefined;
return `${migratedHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`;
}