This commit is contained in:
ryan-crabbe-berri 2026-08-27 21:11:50 -07:00 committed by GitHub
commit 8ebaa32c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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();
});
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 () => {
const user = userEvent.setup();
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);

View file

@ -81,6 +81,11 @@ const AllModelsTab = ({
}, [modelNameSearch, debouncedUpdateSearch]);
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(() => {
if (sorting.length === 0) return undefined;
@ -100,7 +105,7 @@ const AllModelsTab = ({
} = useModelsInfo(
pagination.pageIndex + 1,
pagination.pageSize,
debouncedSearch || undefined,
searchForQuery,
undefined,
teamIdForQuery,
sortBy,

View file

@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
import { describe, expect, it, vi } from "vitest";
import { useModelDetailRouting } from "./detailNavigation";
import { useModelDetailRouting, useModelGroupFilterRouting } from "./detailNavigation";
describe("useModelDetailRouting", () => {
it("openModel sets ?model= with a history push", async () => {
@ -54,3 +54,29 @@ describe("useModelDetailRouting", () => {
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";
export interface ModelDetailRouting {
@ -41,3 +41,22 @@ export function useModelDetailRouting(): ModelDetailRouting {
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";
import { useState } from "react";
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 { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import {
useModelDetailRouting,
useModelGroupFilterRouting,
} from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
export default function AllModelsPanel() {
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
const { modelGroup, setModelGroup } = useModelGroupFilterRouting();
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
const { openModel, openTeam } = useModelDetailRouting();
return (
<AllModelsTab
selectedModelGroup={selectedModelGroup}
setSelectedModelGroup={setSelectedModelGroup}
selectedModelGroup={modelGroup}
setSelectedModelGroup={(group) => setModelGroup(group === ALL_MODEL_GROUPS_VALUE ? null : group)}
availableModelGroups={availableModelGroups}
availableModelAccessGroups={availableModelAccessGroups}
setSelectedModelId={openModel}

View file

@ -1,9 +1,12 @@
import { render, screen } from "@testing-library/react";
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";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
describe("StatusBadge", () => {
const toneClasses: Record<StatusTone, string[]> = {
success: ["border-success/20", "bg-success/10", "text-success"],
@ -39,4 +42,19 @@ describe("StatusBadge", () => {
await user.hover(screen.getByText("Blocked"));
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 { useEntityLinkClick } from "@/components/shared/EntityLink";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/cva.config";
@ -23,15 +24,17 @@ interface StatusBadgeProps {
tooltip?: React.ReactNode;
dataTestId?: string;
className?: string;
href?: string;
}
export function StatusBadge({ tone, label, tooltip, dataTestId, className }: StatusBadgeProps) {
const badge = (
<Badge
variant="outline"
data-testid={dataTestId}
className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className)}
>
export function StatusBadge({ tone, label, tooltip, dataTestId, className, href }: StatusBadgeProps) {
const badgeClassName = cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className);
const badge = href ? (
<LinkedStatusBadge href={href} dataTestId={dataTestId} className={badgeClassName}>
{label}
</LinkedStatusBadge>
) : (
<Badge variant="outline" data-testid={dataTestId} className={badgeClassName}>
{label}
</Badge>
);
@ -41,3 +44,25 @@ export function StatusBadge({ tone, label, tooltip, dataTestId, className }: Sta
}
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", () => ({
serverRootPath: "",
teamInfoCall: vi.fn(),
teamMemberDeleteCall: 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", () => {
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 { ArrowLeftIcon } from "@heroicons/react/outline";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { Badge } from "@/components/ui/badge";
import { modelGroupHref } from "@/utils/entityLinks";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
@ -53,6 +55,7 @@ import {
computeTeamModelBadges,
normalizeTeamModelSelection,
TeamAccessGroupModelGrant,
TeamModelBadge,
TeamModelBadgeKind,
} from "./teamModelAccess";
import MetadataKeyValueFields, {
@ -111,6 +114,9 @@ const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
"access-group": "success",
};
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
export interface TeamMembership {
user_id: string;
team_id: string;
@ -1006,7 +1012,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
(badge, index) => (
<SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}>
<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>
</SimpleTooltip>
),
@ -1727,9 +1737,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.models.map((model, index) => (
<Badge key={index} variant="secondary">
<BadgeLink key={index} href={modelGroupHref(model)}>
{model}
</Badge>
</BadgeLink>
))}
</div>
</div>
@ -1738,9 +1748,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Default Member Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.default_team_member_models.map((model, index) => (
<Badge key={index} variant="secondary">
<BadgeLink key={index} href={modelGroupHref(model)}>
{model}
</Badge>
</BadgeLink>
))}
</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 () => {
renderWithProviders(
<KeyInfoView

View file

@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 KeySavingsTab from "./KeySavingsTab";
import { useEffect, useState } from "react";
@ -660,9 +661,9 @@ export default function KeyInfoView({
<div className="mt-2 flex flex-wrap gap-2">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
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}
</Badge>
</BadgeLink>
))
) : (
<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">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
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}
</span>
</BadgeLink>
))
) : (
<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";
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
"all-proxy-models",
"all-team-models",
"no-default-models",
]);
export function teamDetailHref(teamId: string): string {
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
}
@ -15,3 +21,9 @@ export function userDetailHref(userId: string): string {
export function orgDetailHref(orgId: string): string {
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)}`;
}