feat(ui): show performance alerts in the Alerts page (#51104)

* feat(ui): restore performance alerts on updated detector

* fix(ui): keep team key detail tabs independent

* fix(ui): preserve team tabs while permissions load

* feat(ui): show concise alerts in the notification bell

* refactor(ui): remove replaced deprecation bell item

* fix(ui): pin notification resource links

* fix(ui): show deprecation failures in notifications

* fix(ui): reset activity state when changing resources

* fix(ui): reset key detail state from every entry point
This commit is contained in:
moe-berri 2026-11-12 10:23:03 +08:00 • committed by GitHub
parent 701639ea22
commit 16a36c66b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2487 additions and 237 deletions

View file

@ -1,10 +1,12 @@
from datetime import date, datetime, timezone
from datetime import date as Date
from datetime import datetime, timezone
from typing import Annotated, Final, Literal, Protocol, TypeAlias
from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Self
AlertType: TypeAlias = Literal["spend", "traffic", "latency", "reliability", "model_deprecation"]
AlertFilter: TypeAlias = AlertType | Literal["performance"]
AlertStatus: TypeAlias = Literal["open", "resolved", "dismissed"]
AlertResourceType: TypeAlias = Literal["gateway", "key", "team", "user", "model"]
MAX_ALERT_ID: Final = 2**63 - 1
@ -33,7 +35,7 @@ class MetricEvidence(BaseModel):
class DeprecationEvidence(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
deprecation_date: date = Field(description="Date the affected model retires.")
deprecation_date: Date = Field(description="Date the affected model retires.")
replacement_model: str | None = Field(
default=None, min_length=1, max_length=1024, description="Suggested replacement."
)
@ -106,6 +108,22 @@ class Alert(AlertDetection):
updated_by: str | None = Field(default=None, max_length=1024, description="User who last changed the status.")
class AlertHistoryPoint(BaseModel):
model_config = ConfigDict(frozen=True)
date: Date = Field(description="UTC day represented by this value.")
value: float = Field(allow_inf_nan=False, description="Recorded daily value in the alert metric's unit.")
class AlertView(Alert):
resource_name: str | None = Field(default=None, max_length=1024, description="Current resource display name.")
history: tuple[AlertHistoryPoint, ...] | None = Field(
default=None,
max_length=8,
description="Current Usage values for the seven baseline days and observed day. Null if unavailable.",
)
class AlertUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@ -121,10 +139,21 @@ class AlertPage(BaseModel):
)
class AlertViewPage(BaseModel):
model_config = ConfigDict(frozen=True)
data: tuple[AlertView, ...] = Field(description="Alerts ordered newest first, with current Usage history.")
next_cursor: AlertID | None = Field(default=None, description="Cursor for the next page, or null at the end.")
class AlertStore(Protocol):
async def record(self, detection: AlertDetection) -> Alert: ...
async def list(self, status: AlertStatus, limit: int, cursor: int | None) -> AlertPage: ...
async def list(
self, status: AlertStatus, limit: int, cursor: int | None, alert_type: AlertFilter | None = None
) -> AlertPage: ...
async def enrich(self, alerts: tuple[Alert, ...]) -> tuple[AlertView, ...]: ...
async def get(self, alert_id: int) -> Alert | None: ...

View file

@ -2,7 +2,16 @@ from typing import Annotated, Final, TypeAlias
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from litellm.models.alert import Alert, AlertID, AlertPage, AlertStatus, AlertStore, AlertUpdate
from litellm.models.alert import (
Alert,
AlertFilter,
AlertID,
AlertStatus,
AlertStore,
AlertUpdate,
AlertView,
AlertViewPage,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.deps.repositories import AlertRepositoryDep
@ -29,24 +38,29 @@ def _found(alert: Alert | None) -> Alert:
return alert
@router.get("/alerts", response_model=AlertPage, summary="List saved alerts")
@router.get("/alerts", response_model=AlertViewPage, summary="List saved alerts")
async def list_alerts(
user: Auth,
repository: AlertRepositoryDep,
status: Annotated[AlertStatus, Query(description="Status to list.")] = "open",
limit: Annotated[int, Query(ge=1, le=100, description="Maximum alerts per page.")] = 25,
cursor: Annotated[AlertID | None, Query(description="next_cursor from the preceding page.")] = None,
) -> AlertPage:
"""Read saved alerts, newest first. Gateway admins and view-only admins can read. No usage queries run."""
alert_type: Annotated[
AlertFilter | None, Query(alias="type", description="Alert category, or all performance alerts.")
] = None,
) -> AlertViewPage:
"""Read saved alerts with current resource names and eight days of Usage history. Requires a gateway admin."""
store: Final = _authorized_store(user, repository, write=False)
return await store.list(status, limit, int(cursor) if cursor is not None else None)
page: Final = await store.list(status, limit, int(cursor) if cursor is not None else None, alert_type)
return AlertViewPage(data=await store.enrich(page.data), next_cursor=page.next_cursor)
@router.get("/alerts/{alert_id}", response_model=Alert, summary="Read an alert")
async def get_alert(alert_id: AlertPath, user: Auth, repository: AlertRepositoryDep) -> Alert:
@router.get("/alerts/{alert_id}", response_model=AlertView, summary="Read an alert")
async def get_alert(alert_id: AlertPath, user: Auth, repository: AlertRepositoryDep) -> AlertView:
"""Return the saved evidence and status. Requires a gateway admin or view-only admin."""
store: Final = _authorized_store(user, repository, write=False)
return _found(await store.get(int(alert_id)))
alert: Final = _found(await store.get(int(alert_id)))
return (await store.enrich((alert,)))[0]
@router.patch("/alerts/{alert_id}", response_model=Alert, summary="Change an alert's status")

View file

@ -1,6 +1,9 @@
import hashlib
import json
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import date as Date
from datetime import timedelta
from typing import (
TYPE_CHECKING,
Final,
@ -8,13 +11,103 @@ from typing import (
cast, # noqa: TID251 # Prisma exposes generated table actions dynamically
)
from litellm.models.alert import Alert, AlertDetection, AlertPage, AlertStatus
from pydantic import BaseModel, ConfigDict, TypeAdapter
from typing_extensions import LiteralString
from litellm._logging import verbose_proxy_logger
from litellm.models.alert import (
Alert,
AlertDetection,
AlertFilter,
AlertHistoryPoint,
AlertPage,
AlertResourceType,
AlertStatus,
AlertView,
)
from litellm.repositories.prisma_protocols import PrismaRecord, TableActions
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
class _HistoryTransaction(Protocol):
async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ...
class _HistoryDatabase(Protocol):
def tx(self, *, timeout: timedelta) -> AbstractAsyncContextManager[_HistoryTransaction]: ...
class _HistoryRow(BaseModel):
model_config = ConfigDict(frozen=True)
id: str
resource_name: str | None
date: Date | None
spend: float
requests: int
failed: int
response_ms: int
timed: int
def point(self, kind: str) -> AlertHistoryPoint | None:
if self.date is None or (kind == "latency" and not self.timed) or (kind == "reliability" and not self.requests):
return None
value: Final = {
"spend": self.spend,
"traffic": float(self.requests),
"latency": self.response_ms / self.timed if self.timed else 0,
"reliability": min(1.0, self.failed / self.requests) if self.requests else 0,
}[kind]
return AlertHistoryPoint(date=self.date, value=value)
_HISTORY_ROWS: Final = TypeAdapter(tuple[_HistoryRow, ...])
_HISTORY_SOURCES: Final[
Mapping[AlertResourceType, tuple[LiteralString, LiteralString, LiteralString, LiteralString, LiteralString]]
] = {
"gateway": ('"LiteLLM_DailyModelUsage"', "'gateway'", "request_count", "", "'Gateway'"),
"model": ('"LiteLLM_DailyModelUsage"', "d.model_group", "request_count", "", "r.resource_id"),
"team": (
'"LiteLLM_DailyTeamSpend"',
"d.team_id",
"api_requests",
'LEFT JOIN "LiteLLM_TeamTable" m ON m.team_id = r.resource_id',
"m.team_alias",
),
"user": (
'"LiteLLM_DailyUserSpend"',
"d.user_id",
"api_requests",
'LEFT JOIN "LiteLLM_UserTable" m ON m.user_id = r.resource_id',
"m.user_alias",
),
"key": (
'"LiteLLM_DailyUserSpend"',
"d.api_key",
"api_requests",
'LEFT JOIN "LiteLLM_VerificationToken" m ON m.token = r.resource_id',
"m.key_alias",
),
}
def _history_sql(scope: AlertResourceType) -> LiteralString:
table, resource, requests, metadata, name = _HISTORY_SOURCES[scope]
return f"""SELECT r.id, LEFT({name}, 1024) AS resource_name, d.date,
COALESCE(SUM(d.spend), 0)::float8 AS spend,
COALESCE(SUM(d.{requests}), 0)::bigint AS requests,
COALESCE(SUM(d.failed_requests), 0)::bigint AS failed,
COALESCE(SUM(d.total_response_time_ms), 0)::bigint AS response_ms,
COALESCE(SUM(d.timed_requests), 0)::bigint AS timed
FROM requested r
LEFT JOIN {table} d ON {resource} = r.resource_id AND d.date BETWEEN r.start AND r.day
{metadata}
WHERE r.resource_type = '{scope}'
GROUP BY r.id, LEFT({name}, 1024), d.date"""
class AlertTransaction(Protocol):
async def execute_raw(self, query: str, *args: object) -> int: ...
@ -52,6 +145,9 @@ def _alert(row: PrismaRecord) -> Alert:
class AlertRepository:
def __init__(self, prisma_client: "PrismaClient") -> None:
self._database: Final = cast( # cast-ok: Prisma transaction exposes bounded raw query methods
_HistoryDatabase, prisma_client.writer_db
)
self._table = cast( # cast-ok: the generated Prisma client delegates typed table actions dynamically
TableActions[PrismaRecord], prisma_client.writer_db.litellm_alert
)
@ -76,10 +172,17 @@ class AlertRepository:
raise
return _alert(existing)
async def list(self, status: AlertStatus, limit: int, cursor: int | None) -> AlertPage:
async def list(
self, status: AlertStatus, limit: int, cursor: int | None, alert_type: AlertFilter | None = None
) -> AlertPage:
size: Final = max(1, min(limit, 100))
category: Final = {"not": "model_deprecation"} if alert_type == "performance" else alert_type
rows: Final = await self._table.find_many(
where={"status": status, **({"id": {"lt": cursor}} if cursor is not None else {})},
where={
"status": status,
**({"id": {"lt": cursor}} if cursor is not None else {}),
**({"type": category} if category is not None else {}),
},
order={"id": "desc"},
take=size + 1,
)
@ -95,3 +198,49 @@ class AlertRepository:
async def set_status(self, alert_id: int, status: AlertStatus, actor: str) -> Alert | None:
row: Final = await self._table.update(where={"id": alert_id}, data={"status": status, "updated_by": actor})
return None if row is None else _alert(row)
async def enrich(self, alerts: tuple[Alert, ...]) -> tuple[AlertView, ...]:
from prisma.errors import PrismaError
requested: Final = tuple(
{
"id": alert.id,
"resource_id": alert.resource_id,
"resource_type": alert.resource_type,
"day": alert.window_start.date().isoformat(),
"start": (alert.window_start.date() - timedelta(days=7)).isoformat(),
}
for alert in alerts
if alert.type != "model_deprecation"
)
if not requested:
return tuple(AlertView.model_validate(alert.model_dump()) for alert in alerts)
scopes: Final = frozenset[AlertResourceType](alert.resource_type for alert in alerts)
histories: Final = " UNION ALL ".join(_history_sql(scope) for scope in scopes)
query: Final = f"""WITH requested AS (
SELECT * FROM jsonb_to_recordset($1::jsonb)
AS r(id text, resource_id text, resource_type text, day text, start text)
) {histories} ORDER BY id, date"""
try:
async with self._database.tx(timeout=timedelta(seconds=3)) as transaction:
await transaction.query_raw(
"SELECT set_config('statement_timeout', '2s', true), set_config('work_mem', '1MB', true), "
"set_config('max_parallel_workers_per_gather', '0', true)"
)
rows: Final = _HISTORY_ROWS.validate_python(await transaction.query_raw(query, json.dumps(requested)))
return tuple(_view(alert, rows) for alert in alerts)
except (PrismaError, TimeoutError, ValueError) as error:
verbose_proxy_logger.warning("Alert Usage history unavailable (%s)", type(error).__name__)
return tuple(AlertView.model_validate(alert.model_dump()) for alert in alerts)
def _view(alert: Alert, rows: tuple[_HistoryRow, ...]) -> AlertView:
history: Final = tuple(row for row in rows if row.id == alert.id)
points: Final = tuple(point for row in history if (point := row.point(alert.type)) is not None)
return AlertView.model_validate(
{
**alert.model_dump(),
"resource_name": history[0].resource_name if history else None,
"history": points,
}
)

View file

@ -8,8 +8,17 @@ from typing import Final
import pytest
import pytest_asyncio
from psycopg import sql
from litellm.models.alert import Alert, AlertDetection, AlertPage, AlertType
from litellm.models.alert import (
Alert,
AlertDetection,
AlertPage,
AlertResourceType,
AlertType,
AlertView,
AlertViewPage,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.alert_repository import AlertRepository
@ -17,6 +26,18 @@ from tests.integration._support.client import Gateway
from tests.integration._support.database import read_rows, write_rows
def saved_alert(content: bytes) -> Alert:
view: Final = AlertView.model_validate_json(content)
return Alert.model_validate(view.model_dump(exclude={"resource_name", "history"}))
def saved_page(content: bytes) -> AlertPage:
page: Final = AlertViewPage.model_validate_json(content)
return AlertPage(
data=tuple(saved_alert(alert.model_dump_json().encode()) for alert in page.data), next_cursor=page.next_cursor
)
@dataclass(frozen=True, slots=True)
class Alerts:
repository: AlertRepository
@ -71,13 +92,13 @@ async def test_each_alert_type_round_trips_through_api_and_status_changes(
saved: Final = await alerts.repository.record(detection)
response: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert response.status_code == 200, response.text
assert Alert.model_validate_json(response.content) == saved
assert saved_alert(response.content) == saved
assert saved.status == "open"
assert saved.model_dump(include=set(AlertDetection.model_fields)) == detection.model_dump()
for status in ("resolved", "dismissed", "open"):
update: Final = gateway.request("PATCH", f"/alerts/{saved.id}", {"status": status})
assert update.status_code == 200, update.text
changed: Final = Alert.model_validate_json(update.content)
changed: Final = saved_alert(update.content)
assert changed.status == status
assert changed.updated_by
assert changed.evidence == saved.evidence
@ -100,13 +121,13 @@ async def test_gateway_alert_round_trips_without_colliding_with_resource_alerts(
assert scoped.id != saved.id
detail: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert detail.status_code == 200, detail.text
assert Alert.model_validate_json(detail.content) == saved
assert saved_alert(detail.content) == saved
listed: Final = gateway.request("GET", "/alerts", params={"limit": "2"})
assert listed.status_code == 200, listed.text
assert AlertPage.model_validate_json(listed.content).data == (scoped, saved)
assert saved_page(listed.content).data == (scoped, saved)
update: Final = gateway.request("PATCH", f"/alerts/{saved.id}", {"status": "dismissed"})
assert update.status_code == 200, update.text
dismissed: Final = Alert.model_validate_json(update.content)
dismissed: Final = saved_alert(update.content)
assert dismissed.status == "dismissed"
assert await alerts.repository.record(detection) == dismissed
assert await alerts.repository.get(int(scoped.id)) == scoped
@ -143,19 +164,19 @@ async def test_keyset_pages_survive_status_changes_and_do_not_include_newer_aler
newest: Final = await alerts.repository.record(alerts.detection(day=2))
first: Final = gateway.request("GET", "/alerts", params={"limit": "2"})
assert first.status_code == 200, first.text
page: Final = AlertPage.model_validate_json(first.content)
page: Final = saved_page(first.content)
assert tuple(alert.id for alert in page.data) == (newest.id, middle.id)
assert page.next_cursor == middle.id
await alerts.repository.set_status(int(middle.id), "resolved", "reviewer")
late: Final = await alerts.repository.record(alerts.detection(day=3))
second: Final = gateway.request("GET", "/alerts", params={"limit": "2", "cursor": middle.id})
assert second.status_code == 200, second.text
remainder: Final = AlertPage.model_validate_json(second.content)
remainder: Final = saved_page(second.content)
assert oldest.id in tuple(alert.id for alert in remainder.data)
assert not {newest.id, middle.id, late.id}.intersection(alert.id for alert in remainder.data)
resolved: Final = gateway.request("GET", "/alerts", params={"status": "resolved", "limit": "1"})
assert resolved.status_code == 200, resolved.text
assert AlertPage.model_validate_json(resolved.content).data[0].id == middle.id
assert saved_page(resolved.content).data[0].id == middle.id
@pytest.mark.asyncio
@ -212,3 +233,140 @@ async def test_windows_round_trip_at_database_precision_and_timezone(alerts: Ale
}
)
assert await alerts.repository.record(shifted) == saved
@pytest.mark.parametrize("scope", ("model", "key", "user", "team", "gateway"))
@pytest.mark.asyncio
async def test_daily_history_matches_scope_and_weighted_metrics(
alerts: Alerts, gateway: Gateway, scope: AlertResourceType
) -> None:
resource: Final = {"key": uuid.uuid4().hex * 2, "gateway": "gateway"}.get(scope, alerts.resource_id)
model_source: Final = scope in ("model", "gateway")
table, column = {
"model": ("LiteLLM_DailyModelUsage", "model_group"),
"gateway": ("LiteLLM_DailyModelUsage", "model_group"),
"team": ("LiteLLM_DailyTeamSpend", "team_id"),
"user": ("LiteLLM_DailyUserSpend", "user_id"),
"key": ("LiteLLM_DailyUserSpend", "api_key"),
}[scope]
marker: Final = alerts.resource_id
request_column: Final = "request_count" if model_source else "api_requests"
insert: Final = sql.SQL("""INSERT INTO {} ({}, date, model, {}, {}, spend, {}, failed_requests,
total_response_time_ms, timed_requests, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())""").format(
sql.Identifier(table),
sql.Identifier(column),
sql.Identifier("custom_llm_provider" if model_source else "id"),
sql.Identifier("task_type" if model_source else ("user_id" if scope == "key" else "api_key")),
sql.Identifier(request_column),
)
try:
for day in ("2025-12-24", "2025-12-25", "2026-01-01", "2026-01-02"):
for deployment, count in (("a", 2), ("b", 8)):
write_rows(
insert,
(
resource,
day,
marker + deployment,
"example" if model_source else uuid.uuid4().hex,
"chat" if model_source else "",
float(count),
count,
1,
count * count * 100,
count,
),
)
for kind, expected in (("spend", 10), ("traffic", 10), ("latency", 680), ("reliability", 0.2)):
detection: Final = AlertDetection.model_validate(
{
**alerts.detection(kind).model_dump(),
"resource_type": scope,
"resource_id": resource,
}
)
saved: Final = await alerts.repository.record(detection)
response: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert response.status_code == 200, response.text
view: Final = AlertView.model_validate_json(response.content)
assert view.history is not None
assert tuple(point.date.isoformat() for point in view.history) == ("2025-12-25", "2026-01-01")
assert tuple(point.value for point in view.history) == pytest.approx((expected, expected))
assert view.evidence == saved.evidence
assert view.resource_name == (
resource if model_source and scope != "gateway" else "Gateway" if scope == "gateway" else None
)
listed: Final = gateway.request("GET", "/alerts", params={"type": kind, "limit": "1"})
assert listed.status_code == 200, listed.text
assert AlertViewPage.model_validate_json(listed.content).data[0] == view
finally:
write_rows(
sql.SQL("DELETE FROM {} WHERE model IN (%s, %s)").format(sql.Identifier(table)),
(marker + "a", marker + "b"),
)
write_rows('DELETE FROM "LiteLLM_Alert" WHERE resource_id=%s', (resource,))
@pytest.mark.asyncio
async def test_type_filter_applies_before_cursor_limit(alerts: Alerts, gateway: Gateway) -> None:
oldest: Final = await alerts.repository.record(alerts.detection("traffic"))
await alerts.repository.record(alerts.detection("spend"))
newest: Final = await alerts.repository.record(alerts.detection("traffic", day=1))
await alerts.repository.record(alerts.detection("model_deprecation"))
page: Final = gateway.request("GET", "/alerts", params={"type": "traffic", "limit": "1"})
assert page.status_code == 200, page.text
first: Final = AlertViewPage.model_validate_json(page.content)
assert first.data[0].id == newest.id
assert first.next_cursor == newest.id
following: Final = gateway.request("GET", "/alerts", params={"type": "traffic", "limit": "1", "cursor": newest.id})
assert following.status_code == 200, following.text
assert AlertViewPage.model_validate_json(following.content).data[0].id == oldest.id
performance: Final = gateway.request("GET", "/alerts", params={"type": "performance"})
assert performance.status_code == 200, performance.text
assert all(
alert.type != "model_deprecation" for alert in AlertViewPage.model_validate_json(performance.content).data
)
@pytest.mark.asyncio
async def test_deleted_resource_preserves_history_and_history_timeout_preserves_alert(
alerts: Alerts, gateway: Gateway
) -> None:
import psycopg
team: Final = alerts.resource_id
write_rows('INSERT INTO "LiteLLM_TeamTable" (team_id, team_alias) VALUES (%s, %s)', (team, "Example team"))
write_rows(
"""INSERT INTO "LiteLLM_DailyTeamSpend" (id, team_id, api_key, date, spend, updated_at)
VALUES (%s, %s, '', '2026-01-01', 20, NOW())""",
(team, team),
)
try:
saved: Final = await alerts.repository.record(
AlertDetection.model_validate(
{
**alerts.detection().model_dump(),
"resource_type": "team",
}
)
)
first: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert first.status_code == 200, first.text
assert AlertView.model_validate_json(first.content).resource_name == "Example team"
write_rows('DELETE FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,))
deleted: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert deleted.status_code == 200, deleted.text
view: Final = AlertView.model_validate_json(deleted.content)
assert view.resource_name is None
assert view.history and view.history[0].value == 20
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
connection.execute('LOCK TABLE "LiteLLM_DailyTeamSpend" IN ACCESS EXCLUSIVE MODE')
unavailable: Final = gateway.request("GET", f"/alerts/{saved.id}")
assert unavailable.status_code == 200, unavailable.text
fallback: Final = AlertView.model_validate_json(unavailable.content)
assert fallback.history is None
assert fallback.evidence == saved.evidence
finally:
write_rows('DELETE FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,))
write_rows('DELETE FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,))

View file

@ -43,11 +43,17 @@ def table() -> MagicMock:
@pytest.fixture
def app(table: MagicMock) -> FastAPI:
def history_query() -> AsyncMock:
return AsyncMock(return_value=[])
@pytest.fixture
def app(table: MagicMock, history_query: AsyncMock) -> FastAPI:
app: Final = FastAPI()
app.include_router(router)
prisma: Final = MagicMock()
prisma.writer_db.litellm_alert = table
prisma.writer_db.tx.return_value.__aenter__.return_value.query_raw = history_query
app.dependency_overrides[get_database] = lambda: Database(prisma, native_database())
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
@ -79,7 +85,8 @@ def test_permissions_precede_database_access(
@pytest.mark.parametrize(
"query", ("limit=0", "limit=101", "cursor=0", "cursor=9223372036854775808", "cursor=oops", "status=invalid")
"query",
("limit=0", "limit=101", "cursor=0", "cursor=9223372036854775808", "cursor=oops", "status=invalid", "type=invalid"),
)
def test_invalid_page_never_reads_database(client: TestClient, table: MagicMock, query: str) -> None:
assert client.get(f"/alerts?{query}").status_code == 422
@ -134,7 +141,7 @@ def test_openapi_keeps_ids_and_cursors_as_strings(client: TestClient) -> None:
assert {s["type"] for s in cursor["schema"]["anyOf"]} == {"string", "null"}
schemas: Final = spec["components"]["schemas"]
assert schemas["Alert"]["properties"]["id"]["type"] == "string"
assert {s["type"] for s in schemas["AlertPage"]["properties"]["next_cursor"]["anyOf"]} == {"string", "null"}
assert {s["type"] for s in schemas["AlertViewPage"]["properties"]["next_cursor"]["anyOf"]} == {"string", "null"}
def test_viewer_reads_saved_alert_with_string_id(app: FastAPI, table: MagicMock) -> None:
@ -145,7 +152,10 @@ def test_viewer_reads_saved_alert_with_string_id(app: FastAPI, table: MagicMock)
response: Final = client.get("/alerts?status=open&limit=1")
assert response.status_code == 200, response.text
expected: Final = StoredAlert().model_dump(mode="json", exclude={"detection_key"})
assert response.json() == {"data": [{**expected, "id": "5"}], "next_cursor": None}
assert response.json() == {
"data": [{**expected, "id": "5", "resource_name": None, "history": []}],
"next_cursor": None,
}
@pytest.mark.parametrize("method", ("GET", "PATCH"))
@ -159,3 +169,82 @@ def test_missing_alert_returns_not_found(client: TestClient, method: str) -> Non
def test_status_update_rejects_other_changes(client: TestClient, table: MagicMock, body: object) -> None:
assert client.patch("/alerts/5", json=body).status_code == 422
table.update.assert_not_awaited()
def test_empty_alert_page_does_not_read_usage(client: TestClient, table: MagicMock, history_query: AsyncMock) -> None:
table.find_many.return_value = []
response: Final = client.get("/alerts")
assert response.status_code == 200
assert response.json() == {"data": [], "next_cursor": None}
history_query.assert_not_awaited()
@pytest.mark.parametrize(
"kind,unit,expected",
(
("spend", "usd", 12),
("traffic", "requests", 20),
("latency", "milliseconds", 250),
("reliability", "ratio", 0.25),
),
)
def test_detail_returns_usage_history_without_replacing_saved_evidence(
client: TestClient, table: MagicMock, history_query: AsyncMock, kind: str, unit: str, expected: float
) -> None:
evidence: Final = {"value": 0.5, "baseline": 0.1, "unit": unit, "sample_size": 50}
table.find_unique.return_value = StoredAlert(type=kind, evidence=evidence)
history_query.return_value = [
{
"id": "5",
"resource_name": "Research",
"date": "2026-01-08",
"spend": 12,
"requests": 20,
"failed": 5,
"response_ms": 3750,
"timed": 15,
}
]
response: Final = client.get("/alerts/5")
assert response.status_code == 200
assert response.json()["resource_name"] == "Research"
assert response.json()["history"] == [{"date": "2026-01-08", "value": expected}]
assert response.json()["evidence"] == evidence
@pytest.mark.parametrize("failure", (TimeoutError("private row value"), ValueError("private row value")))
def test_history_failure_keeps_alert_readable_and_logs_only_error_type(
client: TestClient, table: MagicMock, history_query: AsyncMock, caplog: pytest.LogCaptureFixture, failure: Exception
) -> None:
table.find_unique.return_value = StoredAlert()
history_query.side_effect = failure
response: Final = client.get("/alerts/5")
assert response.status_code == 200
assert response.json()["evidence"] == StoredAlert().evidence
assert response.json()["history"] is None
assert f"Alert Usage history unavailable ({type(failure).__name__})" in caplog.text
assert "private row value" not in caplog.text
@pytest.mark.parametrize("kind,unit", (("latency", "milliseconds"), ("reliability", "ratio")))
def test_no_samples_leave_a_gap_in_history(
client: TestClient, table: MagicMock, history_query: AsyncMock, kind: str, unit: str
) -> None:
table.find_unique.return_value = StoredAlert(
type=kind, evidence={"value": 0.5, "baseline": 0.1, "unit": unit, "sample_size": 50}
)
history_query.return_value = [
{
"id": "5",
"resource_name": "Research",
"date": "2026-01-08",
"spend": 0,
"requests": 0,
"failed": 0,
"response_ms": 0,
"timed": 0,
}
]
response: Final = client.get("/alerts/5")
assert response.status_code == 200
assert response.json()["history"] == []

View file

@ -1740,7 +1740,7 @@
"count": 5
},
"no-restricted-syntax": {
"count": 132
"count": 131
},
"prefer-const": {
"count": 26

View file

@ -1,6 +1,7 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../../../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";

View file

@ -1,5 +1,10 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../../../../../tests/test-utils";
import { parseAsString, useQueryStates } from "nuqs";
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
import AgentFormFields from "./agent_form_fields";
@ -14,7 +19,30 @@ vi.mock("@/components/networking", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
useKeys: vi.fn(() => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() })),
}));
vi.mock("@/components/templates/key_info_view", () => ({
default: function KeyInfoViewMock({ onClose, onDelete }: { onClose: () => void; onDelete: () => void }) {
const [, setActivity] = useQueryStates({
detail_tab: parseAsString,
activity_from: parseAsString,
activity_to: parseAsString,
});
return (
<div>
<button
onClick={() =>
void setActivity({ detail_tab: "activity", activity_from: "2026-11-01", activity_to: "2026-11-02" })
}
>
Select activity
</button>
<button onClick={onClose}>Back to agent</button>
<button onClick={onDelete}>Delete key</button>
</div>
);
},
}));
vi.mock("./AgentIdentityDetails", () => ({
@ -82,6 +110,11 @@ const agent = {
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(useKeys).mockReturnValue({
data: { keys: [] },
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useKeys>);
vi.mocked(AgentFormFields)
.mockReset()
.mockImplementation(() => <div data-testid="agent-form-fields" />);
@ -91,6 +124,31 @@ describe("AgentInfoView settings", () => {
vi.mocked(networking.triggerAgentKillSwitchCall).mockReset();
});
it.each(["Back to agent", "Delete key"])("clears key tabs and dates when opening a key and on %s", async (action) => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const refetch = vi.fn();
vi.mocked(useKeys).mockReturnValue({
data: { keys: [{ token: "agent-key-1", key_alias: "Agent key" } as KeyResponse] },
isLoading: false,
refetch,
} as unknown as ReturnType<typeof useKeys>);
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />, {
searchParams: "?agent=agent-1&detail_tab=activity&activity_from=2026-10-01&activity_to=2026-10-02",
onUrlUpdate,
});
fireEvent.click(await screen.findByRole("button", { name: "agent-key-1..." }));
expect(await screen.findByRole("button", { name: "Back to agent" })).toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("agent=agent-1"));
fireEvent.click(screen.getByRole("button", { name: "Select activity" }));
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("activity_from")).toBe("2026-11-01"));
fireEvent.click(screen.getByRole("button", { name: action }));
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("agent=agent-1"));
expect(await screen.findByRole("button", { name: "agent-key-1..." })).toBeInTheDocument();
if (action === "Delete key") expect(refetch).toHaveBeenCalledOnce();
});
it("submits the edited agent when Save Changes is pressed", async () => {
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);

View file

@ -51,6 +51,7 @@ import AgentCostView from "./agent_cost_view";
import { detectAgentType, parseDynamicAgentForForm } from "./agent_type_utils";
import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery";
import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils";
import { useResetDetailState } from "@/hooks/useUrlTab";
interface AgentInfoViewProps {
agentId: string;
@ -81,8 +82,13 @@ const DetailItem: React.FC<{ label: React.ReactNode; children: React.ReactNode }
const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessToken, isAdmin }) => {
const { t } = useTranslation();
const resetDetailState = useResetDetailState();
const [agent, setAgent] = useState<Agent | null>(null);
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const closeKey = () => {
resetDetailState();
setSelectedKey(null);
};
const { data: keysData, isLoading: keysLoading, refetch: refetchAgentKeys } = useKeys(1, 100, { agentID: agentId });
const agentKeys = keysData?.keys ?? [];
const [isLoading, setIsLoading] = useState(true);
@ -310,9 +316,9 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
<KeyInfoView
keyId={selectedKey.token}
keyData={selectedKey}
onClose={() => setSelectedKey(null)}
onClose={closeKey}
onDelete={() => {
setSelectedKey(null);
closeKey();
refetchAgentKeys();
}}
teams={null}
@ -424,7 +430,14 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
<DetailItem label={t("agents.info.details.updatedAt")}>{formatDate(agent.updated_at)}</DetailItem>
</DetailList>
<AgentVirtualKeys keys={agentKeys} isLoading={keysLoading} onKeyClick={setSelectedKey} />
<AgentVirtualKeys
keys={agentKeys}
isLoading={keysLoading}
onKeyClick={(key) => {
resetDetailState();
setSelectedKey(key);
}}
/>
{agent.object_permission &&
(agent.object_permission.mcp_servers?.length ||

View file

@ -1,21 +1,5 @@
"use client";
import { useTranslation } from "react-i18next";
import { Page, PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
import Alerts from "@/components/Alerts/Alerts";
export default function AlertsPage() {
const { t } = useTranslation();
return (
<Page className="h-full gap-4">
<PageTabs defaultValue="model-deprecations" className="gap-4">
<PageTabsList>
<PageTabsTrigger value="model-deprecations">{t("alerts.page.tabs.modelDeprecations")}</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="model-deprecations">
<ModelDeprecationsPanel />
</PageTabsContent>
</PageTabs>
</Page>
);
return <Alerts />;
}

View file

@ -1,5 +1,6 @@
import { renderWithProviders as render } from "../../../../../tests/test-utils";
import React from "react";
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { fireEvent, waitFor, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

View file

@ -1,5 +1,6 @@
import { renderWithProviders as render } from "../../../../../tests/test-utils";
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

View file

@ -1,9 +1,13 @@
import { renderHook } from "@testing-library/react";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { act, renderHook as testingRenderHook, waitFor } from "@testing-library/react";
import { type PropsWithChildren } from "react";
import { describe, expect, it, vi } from "vitest";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
const mockUseAggregatedDailyActivity = vi.fn();
const renderHook: typeof testingRenderHook = (render, options) =>
testingRenderHook(render, { ...options, wrapper: NuqsTestingAdapter });
vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({
useAggregatedDailyActivity: (options: unknown) => {
@ -87,3 +91,39 @@ describe("useDailyActivityRange", () => {
});
});
});
it("opens the alert's complete UTC date window", () => {
const { result } = testingRenderHook(() => useActivityDateRange(), {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="activity_from=2026-01-01&activity_to=2026-01-08">{children}</NuqsTestingAdapter>
),
});
expect(result.current.dateValue.from?.getDate()).toBe(1);
expect(result.current.dateValue.to?.getDate()).toBe(8);
expect(result.current.dateValue.from?.getFullYear()).toBe(2026);
});
it("updates an already mounted Activity view when another alert changes the URL dates", async () => {
let searchParams = "activity_from=2026-01-01&activity_to=2026-01-08";
const wrapper = ({ children }: PropsWithChildren) => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
{children}
</NuqsTestingAdapter>
);
const { result, rerender } = testingRenderHook(() => useActivityDateRange(), { wrapper });
searchParams = "activity_from=2026-02-01&activity_to=2026-02-08";
rerender();
await waitFor(() => expect(result.current.dateValue.from).toEqual(new Date(2026, 1, 1, 12)));
expect(result.current.dateValue.to).toEqual(new Date(2026, 1, 8, 12));
});
it("updates the dates when the user changes the date picker", async () => {
const wrapper = ({ children }: PropsWithChildren) => (
<NuqsTestingAdapter searchParams="activity_from=2026-01-01&activity_to=2026-01-08" hasMemory>
{children}
</NuqsTestingAdapter>
);
const { result } = testingRenderHook(() => useActivityDateRange(), { wrapper });
await act(async () => result.current.onDateChange({ from: new Date(2026, 2, 1, 12), to: new Date(2026, 2, 8, 12) }));
expect(result.current.dateValue.from).toEqual(new Date(2026, 2, 1, 12));
});

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { createParser, useQueryStates } from "nuqs";
import { dailyActivityAggregatedCall } from "@/components/networking";
import {
@ -12,6 +13,15 @@ import { spendScopeUserId } from "@/utils/roles";
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const activityDate = createParser({
parse: (value) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T12:00:00`);
return Number.isFinite(date.getTime()) && date.toLocaleDateString("en-CA") === value ? date : null;
},
serialize: (value) => value.toLocaleDateString("en-CA"),
});
const activityDates = { activity_from: activityDate, activity_to: activityDate };
export interface DateRange {
from?: Date;
@ -41,8 +51,18 @@ export type ActivityDateRange = Pick<DailyActivityRange, "dateValue" | "onDateCh
export const useActivityDateRange = (): ActivityDateRange => {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
const [dateValue, setDateValue] = useState<DateRange>({ from: initialFrom, to: initialTo });
return { dateValue, onDateChange: setDateValue };
const [params, setParams] = useQueryStates(activityDates);
const dateValue = useMemo<DateRange>(
() =>
params.activity_from && params.activity_to && params.activity_from <= params.activity_to
? { from: params.activity_from, to: params.activity_to }
: { from: initialFrom, to: initialTo },
[params.activity_from, params.activity_to, initialFrom, initialTo],
);
const onDateChange = (value: DateRange) => {
void setParams({ activity_from: value.from ?? null, activity_to: value.to ?? null });
};
return { dateValue, onDateChange };
};
export interface ScopedActivityInput {

View file

@ -46,6 +46,29 @@ describe("useModelDetailRouting", () => {
expect(event?.searchParams.has("team")).toBe(false);
});
it.each(["openModel", "openTeam", "close"] as const)(
"%s clears the previous resource's tab and dates",
async (action) => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const { result } = renderHook(() => useModelDetailRouting(), {
wrapper: withNuqsTestingAdapter({
searchParams:
"?model=old&detail_tab=activity&key_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08&tab=all-models",
onUrlUpdate,
}),
});
await act(async () => {
result.current[action]("next");
});
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const identity = action === "close" ? {} : { [action === "openModel" ? "model" : "team"]: "next" };
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({
...identity,
tab: "all-models",
});
},
);
it("reads modelId and teamId from the query string", () => {
const { result } = renderHook(() => useModelDetailRouting(), {
wrapper: withNuqsTestingAdapter({ searchParams: "?model=xyz" }),

View file

@ -1,5 +1,6 @@
import { parseAsString, useQueryState, useQueryStates } from "nuqs";
import { useCallback } from "react";
import { useResetDetailState } from "@/hooks/useUrlTab";
export interface ModelDetailRouting {
modelId: string | null;
@ -10,6 +11,7 @@ export interface ModelDetailRouting {
}
export function useModelDetailRouting(): ModelDetailRouting {
const resetDetailState = useResetDetailState();
const [{ model, team }, setParams] = useQueryStates(
{ model: parseAsString, team: parseAsString },
{ history: "push" },
@ -17,21 +19,24 @@ export function useModelDetailRouting(): ModelDetailRouting {
const openModel = useCallback(
(id: string) => {
resetDetailState();
void setParams({ model: id, team: null });
},
[setParams],
[setParams, resetDetailState],
);
const openTeam = useCallback(
(id: string) => {
resetDetailState();
void setParams({ model: null, team: id });
},
[setParams],
[setParams, resetDetailState],
);
const close = useCallback(() => {
resetDetailState();
void setParams({ model: null, team: null });
}, [setParams]);
}, [setParams, resetDetailState]);
return {
modelId: model,

View file

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
@ -33,8 +34,21 @@ vi.mock("@/components/networking", () => ({
// The detail view has its own test; stub it so this file covers the parent's swap.
vi.mock("./view_users/user_info_view", () => ({
default: function UserInfoViewMock({ userId, startInEditMode }: { userId: string; startInEditMode?: boolean }) {
return <div data-testid="user-info-view">{`detail:${userId}:${String(Boolean(startInEditMode))}`}</div>;
default: function UserInfoViewMock({
userId,
startInEditMode,
onClose,
}: {
userId: string;
startInEditMode?: boolean;
onClose: () => void;
}) {
return (
<div data-testid="user-info-view">
{`detail:${userId}:${String(Boolean(startInEditMode))}`}
<button onClick={onClose}>Close user</button>
</div>
);
},
}));
@ -85,11 +99,15 @@ const defaultProps = {
teams: [],
};
const renderDashboard = (overrides: Partial<typeof defaultProps> = {}) =>
const renderDashboard = (
overrides: Partial<typeof defaultProps> = {},
options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction },
) =>
renderWithProviders(
<QueryClientProvider client={createQueryClient()}>
<ViewUserDashboard {...defaultProps} {...overrides} />
</QueryClientProvider>,
options,
);
describe("ViewUserDashboard", () => {
@ -196,7 +214,11 @@ describe("ViewUserDashboard", () => {
it("should swap to the detail view when the identity cell is clicked", async () => {
const user = userEvent.setup();
renderDashboard();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderDashboard(
{},
{ searchParams: "?detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08", onUrlUpdate },
);
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
@ -206,11 +228,17 @@ describe("ViewUserDashboard", () => {
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false");
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ user: "user-1" });
});
it("should open the detail view in edit mode from the row actions menu", async () => {
const user = userEvent.setup();
renderDashboard();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderDashboard(
{},
{ searchParams: "?detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08", onUrlUpdate },
);
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
@ -220,6 +248,22 @@ describe("ViewUserDashboard", () => {
await user.click(await screen.findByTestId("user-action-edit"));
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:true");
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ user: "user-1" });
});
it("closing a user clears its tab and dates while preserving list filters", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderDashboard(
{},
{
searchParams: "?user=user-1&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08&tab=users",
onUrlUpdate,
},
);
fireEvent.click(await screen.findByRole("button", { name: "Close user" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ tab: "users" });
});
describe("bulk edit selection", () => {

View file

@ -1,4 +1,5 @@
import { parseAsString, useQueryState } from "nuqs";
import { useResetDetailState } from "@/hooks/useUrlTab";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@ -73,6 +74,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const [selectionMode, setSelectionMode] = useState(false);
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
const resetDetailState = useResetDetailState();
const [selectedUserId, setSelectedUserId] = useQueryState("user", parseAsString.withOptions({ history: "push" }));
const [openInEditMode, setOpenInEditMode] = useState(false);
@ -140,16 +142,18 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const handleUserClick = useCallback(
(userId: string, openInEdit: boolean = false) => {
resetDetailState();
void setSelectedUserId(userId);
setOpenInEditMode(openInEdit);
},
[setSelectedUserId],
[setSelectedUserId, resetDetailState],
);
const handleCloseUserInfo = useCallback(() => {
resetDetailState();
void setSelectedUserId(null);
setOpenInEditMode(false);
}, [setSelectedUserId]);
}, [setSelectedUserId, resetDetailState]);
const handleDelete = useCallback((user: UserInfo) => {
setUserToDelete(user);

View file

@ -418,7 +418,11 @@ describe("UserInfoView auto-router usage", () => {
Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }),
);
mockFetch.mockReset().mockResolvedValue(Response.json(routerUsageResponse(42)));
vi.stubGlobal("fetch", mockFetch);
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/telemetry/")) return Promise.resolve(Response.json({ enabled: false }));
return mockFetch(input, init);
});
});
afterEach(() => {

View file

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders as render } from "../../../../../../tests/test-utils";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UserInfoView from "./user_info_view";

View file

@ -1,3 +1,4 @@
import { useUrlTab } from "@/hooks/useUrlTab";
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import { MemberRoleSelect } from "./MemberRoleSelect";
import React, { useState } from "react";
@ -99,7 +100,11 @@ export default function UserInfoView({
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>(initialTab === 1 ? "details" : "overview");
const [activeTab, setActiveTab] = useUrlTab(
["overview", "details", "activity", "savings", "auto-router-usage"],
initialTab === 1 ? "details" : "overview",
"detail_tab",
);
const hiddenUsageTab = (activeTab === "savings" || activeTab === "activity") && !canViewUserUsage;
const hiddenRouterTab = activeTab === "auto-router-usage" && !canViewAutoRouterUsage;
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
@ -484,7 +489,7 @@ export default function UserInfoView({
<Tabs
value={hiddenUsageTab || hiddenRouterTab ? "overview" : activeTab}
onValueChange={(v: unknown) => setActiveTab(String(v))}
onValueChange={(v: unknown) => setActiveTab(String(v) as typeof activeTab)}
className="gap-0"
>
<TabsList variant="line" className="mb-4">

View file

@ -0,0 +1,89 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { backgroundFetchClient } from "@/lib/http/api";
import { AlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { AlertItem, presentAlert } from "./alertPresentation";
export default function AlertActions({ item, readOnly }: { item: AlertItem; readOnly: boolean }) {
const { t } = useTranslation();
const client = useQueryClient();
const mutation = useMutation({
mutationFn: async (status: AlertStatus) => {
if (item.source === "performance") {
const result = await backgroundFetchClient.PATCH("/alerts/{alert_id}", {
params: { path: { alert_id: item.alert.id } },
body: { status },
});
if (!result.data) throw new Error("Could not save alert status");
return;
}
const alert = item.alert;
const result = await backgroundFetchClient.PUT("/alerting/model-deprecations/state", {
body: {
model: alert.usageModel,
deprecation_date: alert.model.deprecation_date,
status,
last_seen_date: alert.lastUsedDate,
last_seen_requests: alert.lastDayRequests,
},
});
if (!result.data) throw new Error("Could not save alert status");
},
onSuccess: () =>
Promise.all(
(item.source === "performance" ? ["alerts", "alert"] : ["deprecationAlerts"]).map((key) =>
client.invalidateQueries({ queryKey: [key] }),
),
),
});
const status = presentAlert(item).status;
if (readOnly) return <span className="text-sm capitalize text-muted-foreground">{t(`alerts.status.${status}`)}</span>;
return (
<div className="space-y-2">
<div className="flex shrink-0 items-center gap-2">
{status === "open" ? (
<>
<Button
variant="outline"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate(AlertStatus.Resolved)}
>
<Check className="size-3.5" />
{t("alerts.migrationDetail.markResolved")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate(AlertStatus.Dismissed)}
>
<X className="size-3.5" />
{t("alerts.migrationDetail.dismiss")}
</Button>
</>
) : (
<>
<span className="text-sm capitalize text-muted-foreground">{t(`alerts.status.${status}`)}</span>
<Button
variant="outline"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate(AlertStatus.Open)}
>
<RotateCcw className="size-3.5" />
{t("alerts.migrationDetail.reopen")}
</Button>
</>
)}
</div>
{mutation.isError && (
<p role="alert" className="text-xs text-destructive">
{t("alerts.unified.saveError")}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,123 @@
import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, XAxis, YAxis } from "recharts";
import { useTranslation } from "react-i18next";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { AlertPresentation, dateLabel, metricValue } from "./alertPresentation";
export default function AlertChart({ alert, compact = false }: { alert: AlertPresentation; compact?: boolean }) {
const { t } = useTranslation();
if (!alert.history?.length)
return compact ? null : (
<p className="py-8 text-sm text-muted-foreground">{t("alerts.unified.chart.unavailable")}</p>
);
const end = Date.parse(alert.observed);
const start =
alert.baseline === undefined
? Math.min(...alert.history.map((point) => Date.parse(point.date)))
: end - 7 * 86400000;
const values = new Map(alert.history.map((point) => [point.date, point.value]));
const data = Array.from({ length: Math.min(31, Math.round((end - start) / 86400000) + 1) }, (_, index) => {
const date = new Date(start + index * 86400000).toISOString().slice(0, 10);
return { date, value: values.get(date) ?? null };
});
return (
<figure className="min-w-0 space-y-3" aria-label={alert.chartTitle}>
{!compact && (
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-semibold">{alert.chartTitle}</h2>
<span className="text-xs text-muted-foreground">
{dateLabel(data[0].date)} – {dateLabel(alert.observed)} UTC
</span>
</div>
)}
<ChartContainer
config={{ value: { label: alert.chartTitle, color: "#2b3fd6" } }}
className={compact ? "h-20 w-full aspect-auto" : "h-64 w-full aspect-auto"}
>
<BarChart accessibilityLayer data={data} margin={{ top: compact ? 2 : 16, right: 8, bottom: 0, left: 0 }}>
{!compact && <CartesianGrid vertical={false} />}
<XAxis
dataKey="date"
hide={compact}
axisLine={false}
tickLine={false}
tick={{ fill: "var(--muted-foreground)" }}
minTickGap={16}
tickFormatter={(date: string) =>
new Date(`${date}T00:00:00Z`).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
})
}
/>
<YAxis
hide={compact}
axisLine={false}
tickLine={false}
tick={{ fill: "var(--muted-foreground)" }}
width={62}
tickFormatter={(value: number) => metricValue(value, alert.unit)}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideIndicator
labelFormatter={(value) => dateLabel(String(value))}
formatter={(value) => metricValue(Number(value), alert.unit)}
/>
}
/>
{!compact && alert.baseline !== undefined && (
<ReferenceLine
y={alert.baseline}
ifOverflow="extendDomain"
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
/>
)}
<Bar
dataKey="value"
fill="var(--color-value)"
maxBarSize={compact ? 20 : 52}
radius={[2, 2, 0, 0]}
isAnimationActive={false}
>
{data.map((point) => (
<Cell key={point.date} fillOpacity={point.date === alert.observed ? 1 : 0.35} />
))}
</Bar>
</BarChart>
</ChartContainer>
{!compact && (
<>
<figcaption className="text-xs leading-5 text-muted-foreground">
{alert.baseline !== undefined &&
t("alerts.unified.chart.baseline", { value: metricValue(alert.baseline, alert.unit) })}
{t("alerts.unified.chart.missingDays")}
{alert.baseline !== undefined && t("alerts.unified.chart.currentUsage")}
</figcaption>
<table className="sr-only" aria-label={t("alerts.unified.chart.values", { title: alert.chartTitle })}>
<thead>
<tr>
<th>{t("alerts.unified.chart.date")}</th>
<th>{t("alerts.unified.chart.value")}</th>
</tr>
</thead>
<tbody>
{data.map((point) => (
<tr key={point.date}>
<td>{point.date}</td>
<td>
{point.value === null
? t("alerts.migrationDetail.notRecorded")
: metricValue(point.value, alert.unit)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</figure>
);
}

View file

@ -0,0 +1,83 @@
import { ArrowLeft, KeyRound, Network, Server, User, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import RecommendedReplacement from "@/components/ModelDeprecationAlerts/RecommendedReplacement";
import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
import AlertActions from "./AlertActions";
import AlertChart from "./AlertChart";
import { AlertItem, dateLabel, presentAlert } from "./alertPresentation";
export default function AlertDetail({
item,
readOnly,
onBack,
}: {
item: AlertItem;
readOnly: boolean;
onBack: () => void;
}) {
const { t } = useTranslation();
const alert = presentAlert(item);
const Icon = { key: KeyRound, team: Users, user: User, model: Network, gateway: Server }[alert.resourceType];
return (
<div className="min-w-0 space-y-6">
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
onClick={onBack}
>
<ArrowLeft className="size-4" />
{t("alerts.unified.title")}
</button>
<div className="space-y-7">
<header className="space-y-3">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:gap-6">
<h1 className="min-w-0 break-words text-2xl font-semibold leading-snug">{alert.title}</h1>
<div className="shrink-0 sm:pt-0.5">
<AlertActions item={item} readOnly={readOnly} />
</div>
</div>
<p className="text-base leading-relaxed text-muted-foreground">{alert.description}</p>
</header>
<AlertChart alert={alert} />
<section className="space-y-2 border-t border-border pt-5">
<h2 className="text-sm font-medium">{t("alerts.unified.resourceHeading")}</h2>
<ul className="divide-y divide-border">
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-8 gap-y-4 border-b border-border py-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,1fr)_auto]">
<div className="order-1 flex min-w-0 items-center gap-2.5 text-sm">
<Icon strokeWidth={1.75} className="size-[18px] shrink-0 text-muted-foreground" />
<span className="break-all font-medium">{alert.resourceName}</span>
</div>
<dl className="order-3 col-span-2 grid grid-cols-2 gap-x-8 text-xs lg:order-2 lg:col-span-1 lg:gap-x-10">
{alert.dates.map(([label, date]) => (
<div key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="mt-1">{dateLabel(date)} UTC</dd>
</div>
))}
</dl>
{alert.href && (
<a
className={buttonVariants({ variant: "outline", size: "sm", className: "order-2 lg:order-3" })}
href={alert.href}
>
{alert.linkLabel}
</a>
)}
</li>
</ul>
</section>
{item.source === "deprecation" && (
<section className="space-y-3">
<RecommendedReplacement recommendation={item.alert.model} />
<a
className={buttonVariants({ variant: "outline", size: "sm" })}
href={`${migrationHref(item.alert.usageModel)}&migration=true`}
>
{t("alerts.unified.migrationDetails")}
</a>
</section>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient, within } from "../../../tests/test-utils";
import Alerts from "./Alerts";
import type { SavedAlert } from "./alertPresentation";
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
let role = "proxy_admin";
const alert: SavedAlert = {
id: "9007199254740993",
type: "spend",
resource_type: "key",
resource_id: "a".repeat(64),
resource_name: "Research agent",
status: "open",
window_start: "2026-01-08T00:00:00Z",
window_end: "2026-01-09T00:00:00Z",
created_at: "2026-01-09T03:00:00Z",
updated_at: "2026-01-09T03:00:00Z",
evidence: { value: 20, baseline: 2, unit: "usd", sample_size: 100 },
history: [
{ date: "2026-01-01", value: 2 },
{ date: "2026-01-08", value: 21 },
],
};
const calls: { path: string; query: URLSearchParams; method: string; body: unknown }[] = [];
let status = "open";
let rejectWrite = false;
let rejectHistory = false;
function response(data: unknown, code = 200) {
return new Response(JSON.stringify(data), { status: code, headers: { "Content-Type": "application/json" } });
}
describe("Alerts", () => {
beforeEach(() => {
testQueryClient.clear();
calls.length = 0;
status = "open";
rejectWrite = false;
rejectHistory = false;
role = "proxy_admin";
document.cookie = `token=${btoa(JSON.stringify({ alg: "none" }))}.${btoa(JSON.stringify({ key: "example-session", exp: 4102444800 }))}.example; path=/`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const request = input instanceof Request ? input : new Request(input);
const url = new URL(request.url);
const body = request.method === "PATCH" ? await request.clone().json() : null;
const call = { path: url.pathname, query: url.searchParams, method: request.method, body };
calls.push(call);
if (url.pathname === "/litellm/.well-known/litellm-ui-config") return response({});
if (url.pathname === "/management/v1/users/me") return response({ user_id: "example-admin", user_role: role });
if (url.pathname === "/model/deprecations") return response({ deprecated: [], imminent: [] });
if (request.method === "PATCH") {
if (rejectWrite) return response({ detail: "Unavailable" }, 503);
status = body.status;
return response({ ...alert, status });
}
if (url.pathname === `/alerts/${alert.id}`)
return response({ ...alert, status, history: rejectHistory ? null : alert.history });
if (url.pathname === "/alerts") {
if (url.searchParams.has("cursor"))
return response({ data: [{ ...alert, id: "7", resource_name: "Second key" }], next_cursor: null });
return response({
data: url.searchParams.get("status") === status ? [{ ...alert, status }] : [],
next_cursor: "8",
});
}
throw new Error(`Unexpected endpoint ${url.pathname}`);
});
});
afterEach(() => {
vi.restoreAllMocks();
document.cookie = "token=; Max-Age=0; path=/";
});
it("shows saved evidence, daily gaps, and a scoped Activity link", async () => {
renderWithProviders(<Alerts />, { searchParams: { alert: alert.id } });
expect(
await screen.findByRole("heading", { name: "Spend on Research agent rose to 10× its baseline" }),
).toBeInTheDocument();
expect(screen.getByText(/\$20.00 on/)).toBeInTheDocument();
const chart = screen.getByRole("table", { name: "Daily spend values" });
expect(within(chart).getByRole("row", { name: "2026-01-08 $21.00" })).toBeInTheDocument();
expect(within(chart).getByRole("row", { name: "2026-01-02 Not recorded" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "View key activity" })).toHaveAttribute(
"href",
`/ui/api-keys?key=${alert.resource_id}&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08`,
);
});
it("keeps saved evidence visible when daily history cannot be loaded", async () => {
rejectHistory = true;
renderWithProviders(<Alerts />, { searchParams: { alert: alert.id } });
expect(await screen.findByText(/Daily history is unavailable/)).toBeInTheDocument();
expect(screen.getByText(/\$20.00 on/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeEnabled();
});
it("saves status and reloads the detail, retaining exact large IDs", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />, { searchParams: { alert: alert.id } });
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
expect(await screen.findByRole("button", { name: "Reopen" })).toBeInTheDocument();
expect(calls.find((call) => call.method === "PATCH")).toMatchObject({
path: `/alerts/${alert.id}`,
body: { status: "resolved" },
});
await user.click(screen.getByRole("button", { name: "Reopen" }));
expect(await screen.findByRole("button", { name: "Dismiss" })).toBeInTheDocument();
});
it("retains the open alert when a status write fails", async () => {
rejectWrite = true;
const user = userEvent.setup();
renderWithProviders(<Alerts />, { searchParams: { alert: alert.id } });
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save status");
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeEnabled();
});
it("uses the selected type and cursor when loading more results", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />, { searchParams: { type: "spend" } });
await user.click(await screen.findByRole("button", { name: "Load more" }));
expect(await screen.findByRole("button", { name: /Spend on Second key/ })).toBeInTheDocument();
expect(
calls.filter((call) => call.path === "/alerts").map((call) => [call.query.get("type"), call.query.get("cursor")]),
).toEqual([
["spend", null],
["spend", "8"],
]);
await user.click(screen.getByRole("tab", { name: "resolved" }));
expect(await screen.findByText("No resolved alerts.")).toBeInTheDocument();
});
it("keeps deprecation email setup accessible from the inbox", async () => {
const user = userEvent.setup();
renderWithProviders(<Alerts />);
await user.click(await screen.findByRole("button", { name: "Set up email reminders" }));
expect(screen.getByRole("link", { name: "Open Email Settings" })).toHaveAttribute(
"href",
"/ui/logging-and-alerts?tab=email-alerts",
);
});
it("lets viewers read the evidence without write controls", async () => {
role = "proxy_admin_viewer";
renderWithProviders(<Alerts />, { searchParams: { alert: alert.id } });
expect(await screen.findByRole("heading", { name: /Spend on Research agent/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mark resolved" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,230 @@
"use client";
import { parseAsBoolean, parseAsString, parseAsStringEnum, parseAsStringLiteral, useQueryStates } from "nuqs";
import { RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { isProxyAdminTierRole } from "@/utils/roles";
import { useDeprecationAlerts } from "@/components/ModelDeprecationAlerts/useDeprecationAlerts";
import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
import DeprecationEmailSetup from "@/components/ModelDeprecationAlerts/DeprecationEmailSetup";
import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { useAlert, useAlerts } from "./useAlerts";
import { ALERT_TYPES, AlertFilter, AlertItem, TYPE_LABEL_KEYS, presentAlert } from "./alertPresentation";
import AlertActions from "./AlertActions";
import AlertChart from "./AlertChart";
import AlertDetail from "./AlertDetail";
const parsers = {
alert: parseAsString,
model: parseAsString,
migration: parseAsBoolean.withDefault(false),
state: parseAsStringEnum(Object.values(AlertStatus)).withDefault(AlertStatus.Open),
type: parseAsStringLiteral(ALERT_TYPES).withDefault("all"),
};
function LoadError({ children, retry }: { children: React.ReactNode; retry: () => void }) {
const { t } = useTranslation();
return (
<div role="alert" className="flex items-center gap-3 py-4 text-sm">
<p>{children}</p>
<Button variant="outline" size="sm" onClick={retry}>
{t("alerts.panel.retry")}
</Button>
</div>
);
}
export default function Alerts() {
const { t } = useTranslation();
const auth = useAuthorized();
const [params, setParams] = useQueryStates(parsers);
const allowed = Boolean(auth.isAuthorized && isProxyAdminTierRole(auth.userRole));
if (auth.isLoading)
return (
<Page>
<p role="status">{t("alerts.unified.loading")}</p>
</Page>
);
if (!allowed)
return (
<Page>
<p>{t("alerts.unified.adminRequired")}</p>
</Page>
);
return (
<Page className="mx-auto max-w-[1440px]">
{params.alert || params.model ? (
<SelectedAlert
auth={auth}
onBack={() => void setParams({ alert: null, model: null, migration: null }, { history: "push" })}
/>
) : (
<AlertsInbox auth={auth} />
)}
</Page>
);
}
function SelectedAlert({ auth, onBack }: { auth: ReturnType<typeof useAuthorized>; onBack: () => void }) {
const { t } = useTranslation();
const [params] = useQueryStates(parsers);
const scope = JSON.stringify([auth.userId, auth.userRole]);
const detail = useAlert(params.alert, scope, true);
const deprecations = useDeprecationAlerts(params.model ? auth.accessToken : null);
const selectedDeprecation = deprecations.data?.find((alert) => alert.usageModel === params.model);
const selected: AlertItem | undefined = params.alert
? detail.data && { source: "performance", alert: detail.data }
: selectedDeprecation && { source: "deprecation", alert: selectedDeprecation };
if (params.migration && params.model) return <ModelDeprecationsPanel />;
const query = params.alert ? detail : deprecations;
if (selected && !query.isError)
return <AlertDetail item={selected} readOnly={Boolean(auth.isViewOnly)} onBack={onBack} />;
return (
<>
<Button variant="ghost" className="w-fit" onClick={onBack}>
{t("alerts.unified.back")}
</Button>
{query.isError ? (
<LoadError retry={() => void query.refetch()}>{t("alerts.unified.loadError")}</LoadError>
) : (
<p role="status">{query.isPending ? t("alerts.panel.loadingAlert") : t("alerts.unified.unavailable")}</p>
)}
</>
);
}
function AlertsInbox({ auth }: { auth: ReturnType<typeof useAuthorized> }) {
const { t } = useTranslation();
const [params, setParams] = useQueryStates(parsers);
const scope = JSON.stringify([auth.userId, auth.userRole]);
const showDeprecations = params.type === "all" || params.type === "model-deprecations";
const showPerformance = params.type !== "model-deprecations";
const list = useAlerts(scope, showPerformance, params.state, params.type);
const deprecations = useDeprecationAlerts(showDeprecations ? auth.accessToken : null);
const retirementItems: AlertItem[] = (deprecations.data ?? [])
.filter((alert) => effectiveAlertStatus(alert) === params.state)
.map((alert) => ({ source: "deprecation", alert }));
const performanceItems: AlertItem[] = (list.data?.pages.flatMap((page) => page.data) ?? []).map((alert) => ({
source: "performance",
alert,
}));
const items = [...retirementItems, ...performanceItems];
const loading = list.isLoading || deprecations.isLoading;
const hasError = (showPerformance && list.isError) || (showDeprecations && deprecations.isError);
return (
<>
<h1 className="text-2xl font-semibold">{t("alerts.unified.title")}</h1>
<PageTabs
value={params.state}
onValueChange={(value) => void setParams({ state: value as AlertStatus })}
className="flex-none"
>
<PageTabsList>
{Object.values(AlertStatus).map((status) => (
<PageTabsTrigger key={status} value={status} className="capitalize">
{t(`alerts.status.${status}`)}
</PageTabsTrigger>
))}
</PageTabsList>
</PageTabs>
<div className="flex items-center justify-between gap-3">
<Select value={params.type} onValueChange={(value) => void setParams({ type: value as AlertFilter })}>
<SelectTrigger className="w-52" aria-label={t("alerts.unified.typeFilter")}>
<SelectValue>{t(TYPE_LABEL_KEYS[params.type])}</SelectValue>
</SelectTrigger>
<SelectContent>
{ALERT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(TYPE_LABEL_KEYS[type])}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={list.isFetching || deprecations.isFetching}
onClick={() => {
if (showPerformance) void list.refetch();
if (showDeprecations) void deprecations.refetch();
}}
>
<RefreshCw className="size-3.5" />
{t("alerts.unified.refresh")}
</Button>
</div>
{showDeprecations && deprecations.isError && (
<LoadError retry={() => void deprecations.refetch()}>{t("alerts.unified.deprecationsError")}</LoadError>
)}
{showPerformance && list.isError && (
<LoadError retry={() => void list.refetch()}>{t("alerts.unified.performanceError")}</LoadError>
)}
<InboxNotice loading={loading} error={hasError} count={items.length} status={params.state} />
<ul className="divide-y divide-border">
{items.map((item) => {
const alert = presentAlert(item);
return (
<li
key={`${item.source}-${alert.id}`}
className="grid min-w-0 gap-6 py-6 lg:grid-cols-[minmax(0,1fr)_200px]"
>
<div className="min-w-0 space-y-3">
<button
className="text-left text-base font-semibold leading-6 hover:underline break-words"
onClick={() =>
void setParams(
item.source === "performance"
? { alert: item.alert.id, model: null }
: { alert: null, model: item.alert.usageModel },
{ history: "push" },
)
}
>
{alert.title}
</button>
<p className="text-sm leading-6 text-muted-foreground">{alert.description}</p>
<AlertActions item={item} readOnly={Boolean(auth.isViewOnly)} />
</div>
<div className="hidden self-center lg:block">
<AlertChart alert={alert} compact />
</div>
</li>
);
})}
</ul>
{showPerformance && list.hasNextPage && (
<Button
variant="outline"
className="self-center"
disabled={list.isFetchingNextPage}
onClick={() => void list.fetchNextPage()}
>
{list.isFetchingNextPage ? t("alerts.unified.loadingMore") : t("alerts.unified.loadMore")}
</Button>
)}
{showDeprecations && <DeprecationEmailSetup />}
</>
);
}
function InboxNotice({
loading,
error,
count,
status,
}: {
loading: boolean;
error: boolean;
count: number;
status: AlertStatus;
}) {
const { t } = useTranslation();
if (count > 0 || error) return null;
return (
<p role={loading ? "status" : undefined} className="py-8 text-sm text-muted-foreground">
{loading ? t("alerts.unified.loading") : t(`alerts.unified.empty.${status}`)}
</p>
);
}

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { activityLink, presentAlert, type SavedAlert } from "./alertPresentation";
const saved: SavedAlert = {
id: "1",
type: "spend",
resource_type: "team",
resource_id: "example-team",
status: "open",
window_start: "2026-01-08T00:00:00Z",
window_end: "2026-01-09T00:00:00Z",
created_at: "2026-01-09T03:00:00Z",
updated_at: "2026-01-09T03:00:00Z",
evidence: { value: 20, baseline: 2, unit: "usd", sample_size: 100 },
};
describe("alert presentation", () => {
it.each(
(
[
["spend", "usd", 20, 2, "Spend", "$20.00"],
["traffic", "requests", 1000, 100, "Traffic", "1,000"],
["latency", "milliseconds", 4000, 1000, "Mean response time", "4 s"],
["reliability", "ratio", 0.2, 0.01, "Error rate", "20%"],
] as const
).map((row) => [row] as const),
)("labels %s with the recorded units", ([type, unit, value, baseline, label, formatted]) => {
const alert = { ...saved, type, evidence: { value, baseline, unit, sample_size: 100 } };
const presentation = presentAlert({ source: "performance", alert });
expect(presentation.title).toContain(`${label} on example-team`);
expect(presentation.description).toContain(formatted);
expect(presentation.baseline).toBe(baseline);
});
it("links to the resource with an inclusive eight-day window", () => {
expect(activityLink(saved)).toBe(
"/ui/teams?team=example-team&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08",
);
expect(activityLink({ ...saved, resource_type: "user", resource_id: "reader@example.com" })).toContain(
"user=reader%40example.com&detail_tab=activity",
);
expect(activityLink({ ...saved, resource_type: "gateway", resource_id: "gateway" })).toBe(
"/ui/usage?from=2026-01-01&to=2026-01-08",
);
});
it("links model groups to routing instead of an unrelated deployment's Activity", () => {
const presentation = presentAlert({
source: "performance",
alert: { ...saved, resource_type: "model", resource_id: "research/model" },
});
expect(presentation.href).toBe("/ui/models-and-endpoints?model_group=research%2Fmodel");
expect(presentation.linkLabel).toBe("View model routing");
});
it("keeps the saved comparison when current Usage has changed", () => {
const presentation = presentAlert({
source: "performance",
alert: { ...saved, resource_name: "Research", history: [{ date: "2026-01-08", value: 40 }] },
});
expect(presentation.description).toContain("$20.00");
expect(presentation.history?.[0].value).toBe(40);
expect(presentation.title).toContain("Research");
});
it("shows retirement and last-used dates without inventing a detection date", () => {
const presentation = presentAlert({
source: "deprecation",
alert: {
id: "retirement",
usageModel: "example/retiring-model",
requests: 20,
lastDayRequests: 20,
lastUsedDate: "2026-01-08",
model: {
model_name: "example-group",
deprecation_date: "2026-01-31",
days_until_deprecation: 23,
status: "imminent",
},
},
});
expect(presentation.dates).toEqual([
["Retires", "2026-01-31"],
["Last used", "2026-01-08"],
]);
expect(presentation.status).toBe("open");
});
});

View file

@ -0,0 +1,166 @@
import type { components } from "@/lib/http/schema";
import i18n from "@/i18n/i18n";
import type { DeprecationAlert } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
import { effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { keyDetailHref, teamDetailHref, userDetailHref, modelGroupHref } from "@/utils/entityLinks";
import { uiHref } from "@/utils/uiHref";
export type SavedAlert = components["schemas"]["AlertView"];
export type AlertItem =
| { source: "performance"; alert: SavedAlert }
| { source: "deprecation"; alert: DeprecationAlert };
export type MetricUnit = "usd" | "requests" | "milliseconds" | "ratio";
export type HistoryPoint = { date: string; value: number | null };
export const ALERT_TYPES = [
"all",
"performance",
"model-deprecations",
"spend",
"traffic",
"latency",
"reliability",
] as const;
export type AlertFilter = (typeof ALERT_TYPES)[number];
export const TYPE_LABEL_KEYS = {
all: "alerts.unified.types.all",
performance: "alerts.unified.types.performance",
"model-deprecations": "alerts.page.tabs.modelDeprecations",
spend: "alerts.unified.types.spend",
traffic: "alerts.unified.types.traffic",
latency: "alerts.unified.types.latency",
reliability: "alerts.unified.types.reliability",
} as const;
const dateFormat = { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" } as const;
export const dateLabel = (value: string) =>
new Date(value.length === 10 ? `${value}T00:00:00Z` : value).toLocaleDateString(undefined, dateFormat);
export function metricValue(value: number, unit: MetricUnit): string {
if (unit === "usd") return new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }).format(value);
if (unit === "ratio") return `${(value * 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
if (unit === "milliseconds") return `${(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 2 })} s`;
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
export function activityLink(alert: SavedAlert): string | undefined {
const href = (() => {
switch (alert.resource_type) {
case "key":
return keyDetailHref(alert.resource_id);
case "team":
return teamDetailHref(alert.resource_id);
case "user":
return userDetailHref(alert.resource_id);
case "model":
return modelGroupHref(alert.resource_id);
case "gateway":
return uiHref("usage");
}
})();
if (!href || alert.resource_type === "model") return href;
const from = new Date(Date.parse(alert.window_start) - 7 * 86400000).toISOString().slice(0, 10);
const to = alert.window_start.slice(0, 10);
const query =
alert.resource_type === "gateway"
? `from=${from}&to=${to}`
: `detail_tab=activity&activity_from=${from}&activity_to=${to}`;
return `${href}${href.includes("?") ? "&" : "?"}${query}`;
}
export function presentAlert(item: AlertItem) {
if (item.source === "deprecation") {
const alert = item.alert;
return {
id: alert.id,
title: i18n.t(
alert.model.status === "deprecated" ? "alerts.unified.retiredTitle" : "alerts.unified.retiresTitle",
{
name: alert.usageModel,
date: dateLabel(alert.model.deprecation_date),
},
),
description: i18n.t("alerts.unified.deprecationDescription", {
requests: alert.requests.toLocaleString(),
date: dateLabel(alert.lastUsedDate),
}),
status: effectiveAlertStatus(alert),
resourceType: "model" as const,
resourceName: alert.usageModel,
href: modelGroupHref(alert.model.model_name),
linkLabel: i18n.t("alerts.unified.links.model"),
unit: "requests" as MetricUnit,
history: alert.history ?? null,
baseline: undefined,
observed: alert.lastUsedDate,
dates: [
[
i18n.t(alert.model.status === "deprecated" ? "alerts.unified.retired" : "alerts.unified.retires"),
alert.model.deprecation_date,
],
[i18n.t("alerts.unified.lastUsed"), alert.lastUsedDate],
],
chartTitle: i18n.t("alerts.usageChart.title"),
};
}
const alert = item.alert;
const name =
alert.resource_name ||
(alert.resource_type === "key"
? i18n.t("alerts.unified.keyFallback", { id: alert.resource_id.slice(0, 8) })
: alert.resource_id);
if (!("value" in alert.evidence)) {
return {
id: alert.id,
title: i18n.t("alerts.unified.retiresTitle", { name, date: dateLabel(alert.evidence.deprecation_date) }),
description: i18n.t("alerts.unified.deprecation"),
status: alert.status,
resourceType: alert.resource_type,
resourceName: name,
href: modelGroupHref(alert.resource_id),
linkLabel: i18n.t("alerts.unified.links.model"),
unit: "requests" as MetricUnit,
history: null,
baseline: undefined,
observed: alert.window_start.slice(0, 10),
dates: [[i18n.t("alerts.unified.retires"), alert.evidence.deprecation_date]],
chartTitle: i18n.t("alerts.usageChart.title"),
};
}
const evidence = alert.evidence;
const observed = alert.window_start.slice(0, 10);
const label = i18n.t(`alerts.unified.metrics.${alert.type}`);
const direction = evidence.value < evidence.baseline ? "fell" : "rose";
return {
id: alert.id,
title:
evidence.baseline > 0
? i18n.t(`alerts.unified.comparison.${direction}`, {
label,
name,
ratio: (evidence.value / evidence.baseline).toLocaleString(undefined, { maximumFractionDigits: 2 }),
})
: i18n.t(`alerts.unified.value.${direction}`, {
label,
name,
value: metricValue(evidence.value, evidence.unit),
}),
description: i18n.t("alerts.unified.description", {
value: metricValue(evidence.value, evidence.unit),
date: dateLabel(observed),
baseline: metricValue(evidence.baseline, evidence.unit),
}),
status: alert.status,
resourceType: alert.resource_type,
resourceName: name,
href: activityLink(alert),
linkLabel: i18n.t(`alerts.unified.links.${alert.resource_type}`),
unit: evidence.unit,
history: alert.history,
baseline: evidence.baseline,
observed,
dates: [
[i18n.t("alerts.unified.observed"), observed],
[i18n.t("alerts.unified.firstDetected"), alert.created_at],
],
chartTitle: i18n.t(`alerts.unified.chart.titles.${evidence.unit}`),
};
}
export type AlertPresentation = ReturnType<typeof presentAlert>;

View file

@ -0,0 +1,44 @@
import type { components } from "@/lib/http/schema";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { backgroundFetchClient } from "@/lib/http/api";
import { getProxyBaseUrl } from "@/components/networking";
import { AlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import type { AlertFilter } from "./alertPresentation";
export function useAlerts(scope: string, enabled: boolean, status: AlertStatus, filter: AlertFilter) {
const options = {
queryKey: ["alerts", getProxyBaseUrl(), scope, status, filter],
enabled: enabled && filter !== "model-deprecations",
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }: { pageParam: string | undefined; signal: AbortSignal }) => {
const type = filter === "all" || filter === "model-deprecations" ? "performance" : filter;
const { data } = await backgroundFetchClient.GET("/alerts", {
params: { query: { status, type, limit: 25, cursor: pageParam } },
signal,
});
if (!data) throw new Error("Could not load alerts");
return data;
},
getNextPageParam: (page: components["schemas"]["AlertViewPage"]) => page.next_cursor ?? undefined,
staleTime: 60_000,
retry: false,
};
return useInfiniteQuery(options);
}
export function useAlert(id: string | null, scope: string, enabled: boolean) {
const options = {
queryKey: ["alert", getProxyBaseUrl(), scope, id],
enabled: enabled && id !== null,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data } = await backgroundFetchClient.GET("/alerts/{alert_id}", {
params: { path: { alert_id: id! } },
signal,
});
if (!data) throw new Error("Could not load alert");
return data;
},
staleTime: 60_000,
retry: false,
};
return useQuery(options);
}

View file

@ -12,12 +12,48 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
let role = "proxy_admin";
const alertRequests: string[] = [];
const performanceAlert = {
id: "9007199254740993",
type: "spend",
resource_type: "team",
resource_id: "example-team",
resource_name: "Research",
status: "open",
window_start: "2026-11-10T00:00:00Z",
window_end: "2026-11-11T00:00:00Z",
created_at: "2026-11-11T03:00:00Z",
updated_at: "2026-11-11T03:00:00Z",
evidence: { value: 1200, baseline: 200, unit: "usd", sample_size: 1000 },
};
let openAlerts = [performanceAlert];
let rejectAlerts = false;
let rejectDeprecations = false;
beforeEach(() => {
role = "proxy_admin";
alertRequests.length = 0;
openAlerts = [performanceAlert];
rejectAlerts = false;
rejectDeprecations = false;
localStorage.clear();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/management/v1/users/me")) {
return Response.json({ user_id: "example-admin", user_role: role });
}
if (url.endsWith("/model/deprecations?warn_within_days=30")) {
if (rejectDeprecations) return Response.json({ detail: "Unavailable" }, { status: 503 });
return Response.json({ deprecated: [], imminent: [] });
}
if (url.includes("/alerts?")) {
alertRequests.push(url);
if (rejectAlerts) return Response.json({ detail: "Unavailable" }, { status: 503 });
return Response.json({ data: openAlerts, next_cursor: "next-page" });
}
if (url.endsWith("/get/ui_settings")) return Response.json({ values: {} });
if (url.endsWith("/litellm/.well-known/litellm-ui-config")) {
const config = {
@ -56,19 +92,19 @@ afterEach(() => {
localStorage.clear();
});
function renderHeader() {
function renderHeader(accessToken: string | null = null) {
const user = userEvent.setup();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<PluginModeProvider accessToken="test-session">
<CommandPaletteProvider>
<DashboardHeader accessToken={null} />
<DashboardHeader accessToken={accessToken} />
</CommandPaletteProvider>
</PluginModeProvider>
</QueryClientProvider>,
);
return { user };
return { user, client };
}
describe("dashboard header controls", () => {
@ -84,7 +120,7 @@ describe("dashboard header controls", () => {
it("offers docs, community and latest posts under the bell", async () => {
const { user } = renderHeader();
await user.click(screen.getByRole("button", { name: /Notifications/ }));
const tools = await screen.findByRole("dialog", { name: "What's new" });
const tools = await screen.findByRole("dialog", { name: "Notifications" });
expect(await within(tools).findByRole("link", { name: /Gateway update/ })).toHaveAttribute(
"href",
"https://example.com/update",
@ -102,7 +138,7 @@ describe("dashboard header controls", () => {
const { user } = renderHeader();
expect(screen.queryByRole("link", { name: "Join Slack" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Notifications/ }));
const tools = await screen.findByRole("dialog", { name: "What's new" });
const tools = await screen.findByRole("dialog", { name: "Notifications" });
expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toBeVisible();
expect(within(tools).queryByRole("link", { name: "Blog", exact: true })).not.toBeInTheDocument();
expect(within(tools).queryByRole("link", { name: "Discord", exact: true })).not.toBeInTheDocument();
@ -118,3 +154,66 @@ describe("dashboard header controls", () => {
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
});
});
describe("header performance notifications", () => {
it.each(["proxy_admin", "proxy_admin_viewer"])(
"loads open alerts for %s and refreshes after resolution",
async (userRole) => {
role = userRole;
const { user, client } = renderHeader("example-session");
await user.click(await screen.findByRole("button", { name: "Notifications, alerts need attention" }));
expect(screen.getByRole("link", { name: /Spend on Research/ })).toHaveAttribute(
"href",
`/ui/alerts?alert=${performanceAlert.id}`,
);
expect(alertRequests).toHaveLength(1);
const query = new URL(alertRequests[0]).searchParams;
expect(query.get("status")).toBe("open");
expect(query.get("type")).toBe("performance");
openAlerts = [];
await client.invalidateQueries({ queryKey: ["alerts"] });
expect(await screen.findByText("No open alerts.")).toBeVisible();
expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
client.clear();
},
);
it("hides cached performance alerts when refreshing fails", async () => {
const { user, client } = renderHeader("example-session");
await user.click(await screen.findByRole("button", { name: "Notifications, alerts need attention" }));
expect(screen.getByRole("link", { name: /Spend on Research/ })).toHaveTextContent("$1,200.00 vs. $200.00 usual");
rejectAlerts = true;
await client.invalidateQueries({ queryKey: ["alerts"] });
expect(await screen.findByText("Could not load performance alerts.")).toBeVisible();
expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
expect(screen.queryByText("No open alerts.")).not.toBeInTheDocument();
client.clear();
});
it.each([false, true])("reports deprecation failures with performance failure %s", async (performanceFails) => {
openAlerts = [];
rejectAlerts = performanceFails;
const { user, client } = renderHeader("example-session");
await user.click(screen.getByRole("button", { name: /Notifications/ }));
if (performanceFails) {
expect(await screen.findByText("Could not load performance alerts.")).toBeVisible();
} else {
expect(await screen.findByText("No open alerts.")).toBeVisible();
}
rejectDeprecations = true;
await client.invalidateQueries({ queryKey: ["deprecationAlerts"] });
expect(await screen.findByText("Could not load model deprecations.")).toBeVisible();
expect(screen.queryByText("No open alerts.")).not.toBeInTheDocument();
client.clear();
});
it.each(["internal_user", "org_admin"])("does not request performance alerts for %s", async (userRole) => {
role = userRole;
const { user, client } = renderHeader("example-session");
await user.click(screen.getByRole("button", { name: /Notifications/ }));
expect(await screen.findByRole("link", { name: /Gateway update/ })).toBeVisible();
expect(alertRequests).toHaveLength(0);
expect(screen.queryByRole("link", { name: /Spend on Research/ })).not.toBeInTheDocument();
client.clear();
});
});

View file

@ -1,37 +0,0 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
import RecommendedReplacement from "./RecommendedReplacement";
import type { DeprecationAlert } from "./deprecationAlerts";
export default function DeprecationAlertItem({ alert }: { alert: DeprecationAlert }) {
const { t } = useTranslation();
const retired = alert.model.status === "deprecated";
const title = retired
? t("alerts.alertItem.deprecatedTitle", { model: alert.usageModel })
: t("alerts.alertItem.retiresTitle", { model: alert.usageModel, days: alert.model.days_until_deprecation });
return (
<article aria-label={title} className="flex flex-col gap-3">
<div className="flex items-start gap-2.5">
<div className="min-w-0 flex-1">
<DeprecationModel alert={alert} />
<p className="mt-1 text-xs">
<DeprecationTiming alert={alert} />
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("alerts.alertItem.usage", { requests: alert.requests.toLocaleString(), date: alert.lastUsedDate })}
</p>
<RecommendedReplacement recommendation={alert.model} />
</div>
</div>
<a className={cn(buttonVariants({ size: "sm" }), "self-start")} href={migrationHref(alert.usageModel)}>
{t("alerts.alertItem.reviewMigration")} <ArrowUpRight className="size-3.5" />
</a>
</article>
);
}

View file

@ -1,7 +1,7 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import ModelAlertsPage from "@/app/(dashboard)/alerts/page";
import ModelDeprecationsPanel from "./ModelDeprecationsPanel";
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({ data: [{ team_id: "support", team_alias: "Support" }] }),
@ -69,19 +69,9 @@ describe("model migration impact", () => {
});
});
afterEach(() => vi.restoreAllMocks());
it("should render the inbox with last seen and the recommendation supplied by the API", () => {
renderWithProviders(<ModelAlertsPage />);
expect(screen.getByRole("tab", { name: "Model deprecations", exact: true })).toBeInTheDocument();
expect(screen.getByText("replacement-model")).toBeInTheDocument();
expect(screen.getByText("2026-11-04")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Review impact →" })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations&model=openai%2Fgpt-4-turbo",
);
});
it("should page through all affected keys instead of showing a top-ten sample", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelAlertsPage />, { searchParams: { model: "openai/gpt-4-turbo" } });
renderWithProviders(<ModelDeprecationsPanel />, { searchParams: { model: "openai/gpt-4-turbo" } });
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();
expect(screen.getByText(/Showing 1-50 of 120/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Go to next page", exact: true }));
@ -97,7 +87,7 @@ describe("model migration impact", () => {
});
it("should resolve the whole model using its global latest-day snapshot even in a team-filtered view", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelAlertsPage />, {
renderWithProviders(<ModelDeprecationsPanel />, {
searchParams: { model: "openai/gpt-4-turbo", team: "support" },
});
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();

View file

@ -76,3 +76,14 @@ describe("deprecationUsageRange", () => {
});
});
});
it("retains daily totals, explicit zeroes and gaps for the alert chart", () => {
const alerts = usedDeprecationAlerts(
[model],
[day("2026-11-04", 12), day("2026-11-04", 3), day("2026-11-02", 0), day("2026-11-01", 8, "example-other")],
);
expect(alerts[0].history).toEqual([
{ date: "2026-11-02", value: 0 },
{ date: "2026-11-04", value: 15 },
]);
});

View file

@ -10,6 +10,7 @@ export interface DeprecationAlert {
requests: number;
lastUsedDate: string;
lastDayRequests: number;
history?: { date: string; value: number }[];
state?: components["schemas"]["DeprecationAlertState"];
}
@ -52,7 +53,23 @@ export function usedDeprecationAlerts(
}
}
if (requests > 0) {
const alert = { id, model, usageModel, requests, lastUsedDate, lastDayRequests };
const history = Array.from(new Set(days.map((day) => day.date)))
.sort()
.flatMap((date) => {
const recorded = days.filter((day) => day.date === date && day.breakdown?.models?.[usageModel]);
return recorded.length
? [
{
date,
value: recorded.reduce(
(sum, day) => sum + (day.breakdown?.models?.[usageModel]?.metrics.api_requests ?? 0),
0,
),
},
]
: [];
});
const alert = { id, model, usageModel, requests, lastUsedDate, lastDayRequests, history };
alerts.push(alert);
}
}

View file

@ -120,7 +120,7 @@ export function DashboardResources({ children }: { children?: ReactNode }) {
))}
</section>
)}
<div className="border-t border-border bg-muted/25 p-2">
<div className="sticky bottom-0 border-t border-border bg-popover p-2">
<nav aria-label={t("shell.dashboardResources.helpAndResources")} className="grid grid-cols-3 gap-1">
{links.map(({ href, label, Icon }) => (
<a

View file

@ -1,7 +1,8 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { renderWithProviders, screen, within } from "../../../../tests/test-utils";
import { AUTO_ROUTER_DOCS_URL, NotificationsBell, NotificationsBellView } from "./NotificationsBell";
import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import React from "react";
import type { SavedAlert } from "@/components/Alerts/alertPresentation";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -13,6 +14,13 @@ vi.mock("@/components/ModelDeprecationAlerts/useDeprecationAlerts", () => ({
}),
}));
vi.mock("@/app/(dashboard)/hooks/currentUser/useCurrentUser", () => ({
useCurrentUser: () => ({ data: undefined }),
}));
vi.mock("@/components/Alerts/useAlerts", () => ({
useAlerts: () => ({ data: undefined }),
}));
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
@ -216,22 +224,18 @@ const DEPRECATION = {
describe("model deprecation notifications", () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it("should show last seen and link to the migration workspace and open alerts inbox", async () => {
it("links a concise deprecation summary to its detail and the unified inbox", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationsBellView currentVersion={null} latestRelease={null} deprecationAlerts={[DEPRECATION]} />,
);
await user.click(screen.getByRole("button", { name: /1 model deprecation alert/ }));
expect(screen.getByRole("article", { name: /retires in 26 days/ })).toHaveTextContent("Last seen 2026-11-04 UTC");
expect(screen.getByRole("link", { name: "Review migration" })).toHaveAttribute(
await user.click(screen.getByRole("button", { name: /alerts need attention/ }));
expect(screen.getByRole("link", { name: /anthropic\/claude-sonnet-4-5 retires/ })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations&model=anthropic%2Fclaude-sonnet-4-5",
);
expect(screen.getByRole("link", { name: "View all 1" })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations",
);
expect(screen.getByText("recommended-model")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/ui/alerts");
expect(screen.queryByRole("button", { name: "Mark resolved" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Dismiss", exact: true })).not.toBeInTheDocument();
});
it("should clear the warning when the alert has a shared resolved acknowledgement", () => {
@ -250,7 +254,7 @@ describe("model deprecation notifications", () => {
/>,
);
expect(bell(1)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /model deprecation alert/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /alerts need attention/ })).not.toBeInTheDocument();
});
it("should reopen a resolved alert when more requests arrive on the last-seen day", () => {
const state = {
@ -267,6 +271,50 @@ describe("model deprecation notifications", () => {
deprecationAlerts={[{ ...DEPRECATION, state }]}
/>,
);
expect(screen.getByRole("button", { name: /1 model deprecation alert/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /alerts need attention/ })).toBeInTheDocument();
});
});
const PERFORMANCE: SavedAlert = {
id: "9007199254740993",
type: "spend",
resource_type: "team",
resource_id: "example-team",
resource_name: "Research",
status: "open",
window_start: "2026-11-10T00:00:00Z",
window_end: "2026-11-11T00:00:00Z",
created_at: "2026-11-11T03:00:00Z",
updated_at: "2026-11-11T03:00:00Z",
evidence: { value: 1200, baseline: 200, unit: "usd", sample_size: 1000 },
};
describe("combined alert notifications", () => {
it("shows at most five open alerts across both sources with detail links", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationsBellView
currentVersion={null}
latestRelease={null}
deprecationAlerts={[DEPRECATION]}
performanceAlerts={[
{ ...PERFORMANCE, id: "resolved", status: "resolved" },
{ ...PERFORMANCE, id: "dismissed", status: "dismissed" },
PERFORMANCE,
...["2", "3", "4", "5"].map((id) => ({ ...PERFORMANCE, id, resource_name: `Team ${id}` })),
]}
/>,
);
await user.click(screen.getByRole("button", { name: /alerts need attention/ }));
const alerts = within(screen.getByRole("region", { name: "Alerts" }));
expect(alerts.getAllByRole("listitem")).toHaveLength(5);
expect(alerts.getByRole("link", { name: /Spend on Research/ })).toHaveAttribute(
"href",
`/ui/alerts?alert=${PERFORMANCE.id}`,
);
expect(alerts.getByRole("link", { name: /Spend on Team 4/ })).toBeInTheDocument();
expect(alerts.queryByRole("link", { name: /Spend on Team 5/ })).not.toBeInTheDocument();
expect(alerts.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/ui/alerts");
expect(alerts.queryByRole("button")).not.toBeInTheDocument();
});
});

View file

@ -18,7 +18,11 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cva.config";
import { isNewerVersion } from "@/utils/versionUtils";
import { AlertTriangle, ArrowUpRight, Bell } from "lucide-react";
import DeprecationAlertItem from "@/components/ModelDeprecationAlerts/DeprecationAlertItem";
import { useCurrentUser } from "@/app/(dashboard)/hooks/currentUser/useCurrentUser";
import { metricValue, presentAlert, type AlertItem, type SavedAlert } from "@/components/Alerts/alertPresentation";
import { useAlerts } from "@/components/Alerts/useAlerts";
import { effectiveSessionRole, isProxyAdminTierRole } from "@/utils/roles";
import { uiHref } from "@/utils/uiHref";
import { type DeprecationAlert } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
import { useDeprecationAlerts } from "@/components/ModelDeprecationAlerts/useDeprecationAlerts";
import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
@ -115,6 +119,9 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
interface NotificationsBellViewProps {
deprecationAlerts?: readonly DeprecationAlert[];
performanceAlerts?: readonly SavedAlert[];
performanceError?: boolean;
deprecationError?: boolean;
deploymentHealthLink?: React.ReactNode;
currentVersion: string | null | undefined;
latestRelease: LatestReleaseInfo | null | undefined;
@ -125,21 +132,48 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
latestRelease,
deploymentHealthLink,
deprecationAlerts,
performanceAlerts,
performanceError,
deprecationError,
}) => {
const { t } = useTranslation();
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
const announcements = upgrade ? [upgrade, autoRouterAnnouncement()] : [autoRouterAnnouncement()];
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
const openAlerts = (deprecationAlerts ?? []).filter((alert) => effectiveAlertStatus(alert) === AlertStatus.Open);
const openDeprecations = openAlerts.length;
let label =
const items: AlertItem[] = [
...(deprecationAlerts ?? []).map((alert): AlertItem => ({ source: "deprecation", alert })),
...(performanceAlerts ?? []).map((alert): AlertItem => ({ source: "performance", alert })),
];
const openAlerts = items
.filter(
(item) =>
(item.source === "deprecation" ? effectiveAlertStatus(item.alert) : item.alert.status) === AlertStatus.Open,
)
.slice(0, 5)
.map((item) => {
const alert = presentAlert(item);
const description =
item.source === "performance" && "value" in item.alert.evidence
? t("shell.notifications.bell.comparison", {
value: metricValue(item.alert.evidence.value, item.alert.evidence.unit),
baseline: metricValue(item.alert.evidence.baseline, item.alert.evidence.unit),
})
: alert.description;
const href =
item.source === "deprecation"
? migrationHref(item.alert.usageModel)
: `${uiHref("alerts")}?alert=${encodeURIComponent(alert.id)}`;
return { id: `${item.source}:${alert.id}`, title: alert.title, description, href };
});
const hasAlerts = openAlerts.length > 0;
const hasError = performanceError || deprecationError;
const showAlerts = Boolean(deprecationAlerts || performanceAlerts || hasError);
const announcementLabel =
unreadCount > 0
? t("shell.notifications.bell.triggerUnread", { unread: unreadCount })
: t("shell.notifications.bell.trigger");
if (openDeprecations > 0) {
label = t("shell.notifications.bell.triggerDeprecations", { count: openDeprecations });
}
const label = hasAlerts ? t("shell.notifications.bell.triggerAlerts") : announcementLabel;
return (
<Popover>
@ -148,13 +182,13 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
aria-label={label}
>
<span className="relative inline-flex">
<Bell className={cn("size-4", openDeprecations > 0 && "text-amber-700")} aria-hidden />
{openDeprecations > 0 ? (
<Bell className={cn("size-4", hasAlerts && "text-amber-700")} aria-hidden />
{hasAlerts ? (
<span className="absolute -top-2 -right-2 rounded-full bg-background p-px text-amber-600" aria-hidden>
<AlertTriangle className="size-3" fill="currentColor" stroke="var(--background)" strokeWidth={1.5} />
</span>
) : null}
{openDeprecations === 0 && unreadCount > 0 ? (
{!hasAlerts && unreadCount > 0 ? (
<Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden />
) : null}
</span>
@ -169,36 +203,43 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
</PopoverTitle>
<div className="max-h-[min(32rem,calc(100dvh-10rem))] overflow-y-auto">
<div className="space-y-4 p-4">
{openAlerts.length > 0 && (
<>
<div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold">{t("shell.notifications.bell.deprecationsTitle")}</p>
<a className="text-xs text-primary hover:underline" href={migrationHref()}>
{t("shell.notifications.bell.viewAllDeprecations", { total: openDeprecations })}
</a>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{t("shell.notifications.bell.deprecationsDescription")}
</p>
{showAlerts && (
<section aria-label={t("alerts.unified.title")}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">{t("alerts.unified.title")}</h3>
<a className="text-xs text-primary hover:underline" href={uiHref("alerts")}>
{t("shell.notifications.bell.seeAll")}
</a>
</div>
{openAlerts.slice(0, 3).map((alert) => (
<Fragment key={alert.id}>
<Separator />
<DeprecationAlertItem alert={alert} />
</Fragment>
))}
<ul className="mt-2 divide-y divide-border">
{openAlerts.map((alert) => (
<li key={alert.id}>
<a href={alert.href} className="-mx-2 block rounded-md px-2 py-3 hover:bg-accent">
<p className="line-clamp-2 break-words text-sm font-medium">{alert.title}</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{alert.description}</p>
</a>
</li>
))}
</ul>
{deprecationError && (
<p className="mt-2 text-xs text-muted-foreground">{t("alerts.unified.deprecationsError")}</p>
)}
{performanceError && (
<p className="mt-2 text-xs text-muted-foreground">{t("alerts.unified.performanceError")}</p>
)}
{!hasAlerts && !hasError && (
<p className="mt-2 text-xs text-muted-foreground">{t("shell.notifications.bell.noOpenAlerts")}</p>
)}
</section>
)}
{showAlerts && (
<>
<Separator />
<p className="text-xs font-medium text-muted-foreground">
{t("shell.notifications.bell.announcementsHeading")}
</p>
</>
)}
{deprecationAlerts && openAlerts.length === 0 && (
<a href={migrationHref()} className="text-sm font-medium text-primary hover:underline">
{t("shell.notifications.bell.viewDeprecations")}
</a>
)}
{announcements.map((announcement, index) => (
<Fragment key={announcement.readKey}>
@ -220,12 +261,20 @@ export const NotificationsBell: React.FC<{ accessToken: string | null; deploymen
}) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
const { data: deprecationAlerts } = useDeprecationAlerts(accessToken);
const { data: currentUser, isError: userError } = useCurrentUser(accessToken);
const authenticated = Boolean(accessToken && currentUser?.user_id && !userError);
const allowed = authenticated && isProxyAdminTierRole(currentUser?.user_role ?? "");
const scope = JSON.stringify([currentUser?.user_id, effectiveSessionRole(currentUser?.user_role ?? undefined)]);
const performance = useAlerts(scope, allowed, AlertStatus.Open, "all");
const deprecations = useDeprecationAlerts(accessToken);
return (
<NotificationsBellView
currentVersion={healthData?.litellm_version}
latestRelease={latestRelease}
deprecationAlerts={deprecationAlerts}
deprecationAlerts={deprecations.isError ? undefined : deprecations.data}
performanceAlerts={allowed && !performance.isError ? performance.data?.pages[0]?.data : undefined}
performanceError={allowed && performance.isError}
deprecationError={deprecations.isError}
deploymentHealthLink={deploymentHealthLink}
/>
);

View file

@ -493,14 +493,17 @@ describe("Teams - team detail deep link (?team=)", () => {
it("selecting a team pushes ?team= to the URL", async () => {
const onUrlUpdate = vi.fn();
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, { onUrlUpdate });
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
onUrlUpdate,
searchParams: "?detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08",
});
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
act(() => mockTeamsTableProps.onSelectTeam({ ...baseTableTeam, team_id: "team-deep-link" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.get("team")).toBe("team-deep-link");
expect(Object.fromEntries(lastUpdate.searchParams)).toEqual({ team: "team-deep-link" });
expect(lastUpdate.options.history).toBe("push");
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
@ -519,7 +522,8 @@ describe("Teams - team detail deep link (?team=)", () => {
it("closing the team detail view removes ?team= from the URL", async () => {
const onUrlUpdate = vi.fn();
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
searchParams: "?team=team-from-url",
searchParams:
"?team=team-from-url&detail_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08&key_tab=activity&tab=teams",
onUrlUpdate,
});
@ -527,7 +531,7 @@ describe("Teams - team detail deep link (?team=)", () => {
act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose());
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(onUrlUpdate.mock.calls.at(-1)![0].searchParams.has("team")).toBe(false);
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ tab: "teams" });
await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument());
});
});

View file

@ -28,6 +28,7 @@ import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { Button as UIButton } from "@/components/ui/button";
import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
import { parseAsString, useQueryState } from "nuqs";
import { useResetDetailState } from "@/hooks/useUrlTab";
import { TeamsTable } from "./TeamsPage/TeamsTable";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import MetadataKeyValueFields, {
@ -270,6 +271,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
const watchedMcpSelection = form.watch("allowed_mcp_servers_and_groups");
const watchedToolPermissions = form.watch("mcp_tool_permissions");
const resetDetailState = useResetDetailState();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" }));
const [editTeam, setEditTeam] = useState<boolean>(false);
@ -619,11 +621,13 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
userRole={userRole}
userID={userID}
onSelectTeam={(team) => {
resetDetailState();
setSelectedTeam(team);
void setSelectedTeamId(team.team_id);
setEditTeam(false);
}}
onEditTeam={(team) => {
resetDetailState();
setSelectedTeam(team);
void setSelectedTeamId(team.team_id);
setEditTeam(true);
@ -686,6 +690,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
refreshTeams();
}}
onClose={() => {
resetDetailState();
setSelectedTeam(null);
void setSelectedTeamId(null);
setEditTeam(false);

View file

@ -1,4 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../../../tests/test-utils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import * as networking from "@/components/networking";
@ -44,6 +47,37 @@ const response: DailyActivityAggregatedResponse = {
describe("ResourceActivity", () => {
afterEach(() => vi.useRealTimers());
it("keeps alert dates when Activity mounts after another date consumer", async () => {
function Details() {
useActivityDateRange();
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Activity</button>
{open && <ResourceActivity scope="team" id="resource-1" />}
</>
);
}
render(<Details />, { searchParams: "activity_from=2026-01-01&activity_to=2026-01-08" });
fireEvent.click(screen.getByRole("button", { name: "Open Activity" }));
expect(await screen.findByText("$12.34")).toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
"team",
expect.objectContaining({ startTime: new Date(2026, 0, 1, 12), endTime: new Date(2026, 0, 8, 12) }),
);
});
it("preserves an unfinished date edit when the parent rerenders", async () => {
const { rerender } = render(<ResourceActivity scope="team" id="resource-1" />, {
searchParams: "activity_from=2026-01-01&activity_to=2026-01-08",
});
expect(await screen.findByText("$12.34")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "1 Jan, 12:00 - 8 Jan, 12:00" }));
fireEvent.change(screen.getByLabelText("Start date"), { target: { value: "2026-01-02" } });
rerender(<ResourceActivity scope="team" id="resource-1" />);
expect(screen.getByLabelText("Start date")).toHaveValue("2026-01-02");
});
it.each(["team", "user"] as const)("shows the returned model breakdown for a %s", async (scope) => {
vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({
...response,

View file

@ -1,5 +1,8 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../../../../../tests/test-utils";
import { parseAsString, useQueryStates } from "nuqs";
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse } from "../../../key_team_helpers/key_list";
@ -21,12 +24,26 @@ vi.mock("../../../key_team_helpers/transform_key_info", () => ({
}));
vi.mock("../../../templates/key_info_view", () => ({
default: ({ keyId, onClose }: { keyId: string; onClose: () => void }) => (
<div data-testid="key-info-view">
<div>Key Info View for {keyId}</div>
<button onClick={onClose}>Close</button>
</div>
),
default: function KeyInfoViewMock({ keyId, onClose }: { keyId: string; onClose: () => void }) {
const [, setActivity] = useQueryStates({
detail_tab: parseAsString,
activity_from: parseAsString,
activity_to: parseAsString,
});
return (
<div data-testid="key-info-view">
<div>Key Info View for {keyId}</div>
<button
onClick={() =>
void setActivity({ detail_tab: "activity", activity_from: "2026-11-01", activity_to: "2026-11-02" })
}
>
Select activity
</button>
<button onClick={onClose}>Close</button>
</div>
);
},
}));
const chartBars = (container: HTMLElement) => Array.from(container.querySelectorAll("path.recharts-rectangle"));
@ -458,6 +475,26 @@ describe("TopKeyView", () => {
expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument();
});
it("clears another key's tab and dates on open and clears the selected activity on close", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockKeyInfoV1Call.mockResolvedValue({ key: "key-123" });
mockTransformKeyInfo.mockReturnValue({ token: "key-123" } as KeyResponse);
render(<TopKeyView {...baseProps} topKeys={[{ api_key: "key-123", key_alias: "Test Key", spend: 100 }]} />, {
searchParams: "?tab=usage&detail_tab=activity&activity_from=2026-10-01&activity_to=2026-10-02",
onUrlUpdate,
});
fireEvent.click(screen.getByRole("button", { name: "key-123" }));
expect(await screen.findByText("Key Info View for key-123")).toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("tab=usage"));
fireEvent.click(screen.getByRole("button", { name: "Select activity" }));
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("activity_from")).toBe("2026-11-01"));
fireEvent.click(screen.getByLabelText("Close"));
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe("tab=usage"));
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
});
it("should open modal when key ID is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;

View file

@ -14,6 +14,7 @@ import { keyInfoV1Call } from "../../../networking";
import KeyInfoView from "../../../templates/key_info_view";
import { TagUsage } from "../../types";
import { storedKeyId } from "../../keyActivityData";
import { useResetDetailState } from "@/hooks/useUrlTab";
const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
const LIMIT_OPTIONS = TOP_KEYS_LIMITS.map((limit) => ({ value: String(limit), label: String(limit) }));
@ -51,6 +52,7 @@ interface TopKeyViewProps {
const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const resetDetailState = useResetDetailState();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyData, setKeyData] = useState<any | undefined>(undefined);
@ -77,6 +79,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
const keyInfo = await keyInfoV1Call(accessToken, keyId);
const transformedKeyData = transformKeyInfo(keyInfo);
resetDetailState();
setKeyData(transformedKeyData);
setSelectedKey(keyId);
setIsModalOpen(true); // Open modal when key is clicked
@ -85,11 +88,12 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
}
};
const handleClose = () => {
const handleClose = React.useCallback(() => {
resetDetailState();
setIsModalOpen(false);
setSelectedKey(null);
setKeyData(undefined);
};
}, [resetDetailState]);
// Handle clicking outside the modal
const handleOutsideClick = (e: React.MouseEvent<HTMLDivElement>) => {
@ -108,7 +112,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
document.addEventListener("keydown", handleEscapeKey);
return () => document.removeEventListener("keydown", handleEscapeKey);
}, [isModalOpen]);
}, [isModalOpen, handleClose]);
// Define columns for the table view
const baseColumns = [

View file

@ -25,7 +25,10 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => {
};
});
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({
usePathname: () => "/ui/",
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("../networking", async (importOriginal) => ({
...(await importOriginal<typeof import("../networking")>()),
@ -480,7 +483,10 @@ it.each([
it("clicking the key cell deep-links via ?key=", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
renderWithProviders(<VirtualKeysTable />, {
onUrlUpdate,
searchParams: { detail_tab: "activity", activity_from: "2026-01-01", activity_to: "2026-01-08" },
});
await waitFor(() => {
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
@ -491,12 +497,21 @@ it("clicking the key cell deep-links via ?key=", async () => {
await waitFor(() => {
expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token);
});
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ key: mockKey.token });
expect(lastHistoryMode(onUrlUpdate)).toBe("push");
});
it("renders KeyInfoView when the URL has ?key= for a key on the current page, without refetching it", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { searchParams: { key: mockKey.token }, onUrlUpdate });
renderWithProviders(<VirtualKeysTable />, {
searchParams: {
key: mockKey.token,
activity_from: "2026-01-01",
activity_to: "2026-01-08",
filter_user: "user-42",
},
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText("Back to Keys")).toBeInTheDocument();
@ -509,6 +524,7 @@ it("renders KeyInfoView when the URL has ?key= for a key on the current page, wi
await waitFor(() => {
expect(lastKeyParam(onUrlUpdate)).toBeNull();
});
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({ filter_user: "user-42" });
expect(screen.getByTestId("pagination-range")).toBeInTheDocument();
});

View file

@ -26,6 +26,7 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, functionalUpdate, OnChangeFn } from "@tanstack/react-table";
import { KeyRound } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useResetDetailState } from "@/hooks/useUrlTab";
import React, { useCallback, useMemo, useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
@ -98,6 +99,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
const { data: fetchedTeams } = useAllTeams();
const allTeams = useMemo<Team[]>(() => fetchedTeams ?? [], [fetchedTeams]);
const resetDetailState = useResetDetailState();
const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" }));
const {
search: searchInput,
@ -161,10 +163,13 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
allTeams,
organizations,
teamMemberBudgets,
onSelectKey: (key: KeyResponse) => void setSelectedKeyId(key.token),
onSelectKey: (key: KeyResponse) => {
resetDetailState();
void setSelectedKeyId(key.token);
},
applyUserBudgetToTeamKeys,
}),
[allTeams, organizations, teamMemberBudgets, setSelectedKeyId, applyUserBudgetToTeamKeys],
[allTeams, organizations, teamMemberBudgets, setSelectedKeyId, applyUserBudgetToTeamKeys, resetDetailState],
);
const columns = useMemo(() => getKeyTableColumns(columnDeps, t), [columnDeps, t]);
@ -235,7 +240,10 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<div className="w-full h-full overflow-hidden">
<KeyInfoView
keyId={selectedKeyId}
onClose={() => void setSelectedKeyId(null)}
onClose={() => {
resetDetailState();
void setSelectedKeyId(null);
}}
keyData={selectedKey}
teams={allTeams}
onDelete={refetch}

View file

@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { parseAsString, useQueryStates } from "nuqs";
import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -22,6 +23,28 @@ vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
vi.mock("../../templates/key_info_view", () => ({
default: function KeyInfoViewMock({ onClose }: { onClose: () => void }) {
const [, setActivity] = useQueryStates({
detail_tab: parseAsString,
activity_from: parseAsString,
activity_to: parseAsString,
});
return (
<div>
<button
onClick={() =>
void setActivity({ detail_tab: "activity", activity_from: "2026-11-01", activity_to: "2026-11-02" })
}
>
Select activity
</button>
<button onClick={onClose}>Back to logs</button>
</div>
);
},
}));
vi.mock("../detail", () => ({
LogDetailsDrawer: function LogDetailsDrawerMock({
open,
@ -146,6 +169,21 @@ describe("RequestLogsPanel", () => {
debounce.settled = null;
});
it("clears another key's tab and dates on open and clears the selected activity on close", async () => {
respondWith([logEntry({ metadata: { user_api_key: "key-1" } })]);
renderPanel("?tab=requests&detail_tab=activity&activity_from=2026-10-01&activity_to=2026-10-02");
fireEvent.click(await screen.findByRole("button", { name: "key-1" }));
expect(await screen.findByRole("button", { name: "Back to logs" })).toBeInTheDocument();
await waitFor(() => expect(urlParams().toString()).toBe("tab=requests"));
fireEvent.click(screen.getByRole("button", { name: "Select activity" }));
await waitFor(() => expect(urlParams().get("activity_from")).toBe("2026-11-01"));
fireEvent.click(screen.getByRole("button", { name: "Back to logs" }));
await waitFor(() => expect(urlParams().toString()).toBe("tab=requests"));
expect(await screen.findByRole("button", { name: "key-1" })).toBeInTheDocument();
});
describe("server-grouped session pagination (#38060)", () => {
it("requests session-grouped pages of 25 rows by default without a cursor", async () => {
renderPanel();

View file

@ -11,6 +11,7 @@ import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useResetDetailState } from "@/hooks/useUrlTab";
import type { KeyResponse } from "../../key_team_helpers/key_list";
import { keyInfoV1Call, uiSpendLogsCall } from "../../networking";
import KeyInfoView from "../../templates/key_info_view";
@ -49,6 +50,7 @@ interface RequestLogsPanelProps {
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const { t } = useTranslation();
const resetDetailState = useResetDetailState();
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -281,9 +283,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
[selectLog, displaySessionId],
);
const handleKeyHashClick = useCallback((keyHash: string) => {
setSelectedKeyIdInfoView(keyHash);
}, []);
const handleKeyHashClick = useCallback(
(keyHash: string) => {
resetDetailState();
setSelectedKeyIdInfoView(keyHash);
},
[resetDetailState],
);
if (selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView) {
return (
@ -291,7 +297,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
keyId={selectedKeyIdInfoView}
keyData={selectedKeyInfo}
teams={allTeams ?? []}
onClose={() => setSelectedKeyIdInfoView(null)}
onClose={() => {
resetDetailState();
setSelectedKeyIdInfoView(null);
}}
backButtonText={t("logs.request.panel.backToLogs")}
/>
);

View file

@ -1,3 +1,4 @@
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@ -197,8 +198,11 @@ describe("ModelInfoView", () => {
mockCredentialCreateCall.mockResolvedValue({});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter hasMemory>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
);
it("loads Activity for the underlying model only when its tab is opened", async () => {
vi.mocked(networking.dailyActivityAggregatedCall).mockReset().mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE);

View file

@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { toast } from "@/lib/toast";
import type { Team } from "../key_team_helpers/key_list";
import type { KeyResponse, Team } from "../key_team_helpers/key_list";
import { MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES } from "./memberBudgetReset";
import TeamInfoView, { type TeamData } from "./TeamInfo";
@ -24,11 +24,12 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/ui/alerts" }));
vi.mock("@/components/networking", () => ({
serverRootPath: "",
teamInfoCall: vi.fn(),
teamListCall: vi.fn().mockResolvedValue([]),
teamMemberDeleteCall: vi.fn(),
teamMemberAddCall: vi.fn(),
teamMemberUpdateCall: vi.fn(),
@ -429,6 +430,70 @@ describe("TeamInfoView - member budget reset prompt", () => {
vi.clearAllMocks();
});
it.each([
["members", "Members", true],
["member-permissions", "Member Permissions", true],
["settings", "Settings", true],
["settings", "Settings", false],
])("opens %s only when loaded team permissions allow it (%s, %s)", async (tab, label, allowed) => {
const response = Promise.withResolvers<TeamData>();
vi.mocked(networking.teamInfoCall).mockReturnValue(response.promise);
renderWithProviders(<TeamInfoView {...props} is_team_admin={false} is_proxy_admin={false} />, {
searchParams: { detail_tab: tab },
});
expect(screen.queryByRole("tab", { name: label })).not.toBeInTheDocument();
await waitFor(() => expect(networking.teamInfoCall).toHaveBeenCalled());
response.resolve(
createMockTeamData({
caller_edit_access: allowed ? { kind: "team_admin", editable_fields: [] } : { kind: "none" },
}) as TeamData,
);
expect(await screen.findByRole("tab", { name: allowed ? label : "Overview" })).toHaveAttribute(
"aria-selected",
"true",
);
if (!allowed) expect(screen.queryByRole("tab", { name: label })).not.toBeInTheDocument();
});
it("keeps a team's key visible while switching its tabs and returning to the key list", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData() as TeamData);
mockUseKeys.mockReturnValue({
data: {
keys: [
{
token: "example-key",
token_id: "example-key",
key_alias: "Research agent",
team_id: "123",
models: [],
metadata: {},
spend: 0,
max_budget: 100,
} as KeyResponse,
],
total_count: 1,
current_page: 1,
total_pages: 1,
},
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as ReturnType<typeof useKeys>);
renderWithProviders(<TeamInfoView {...props} />, { searchParams: { detail_tab: "virtual-keys" } });
await user.click(await screen.findByRole("button", { name: "example-key" }));
expect(await screen.findByRole("button", { name: "Back to Keys" })).toBeInTheDocument();
const keyPanel = screen.getByRole("tabpanel", { name: "Virtual Keys" });
await user.click(within(keyPanel).getByRole("tab", { name: "Settings" }));
expect(screen.getByRole("tab", { name: "Virtual Keys" })).toHaveAttribute("aria-selected", "true");
expect(within(keyPanel).getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("button", { name: "Back to Keys" }));
expect(await screen.findByRole("button", { name: "example-key" })).toBeInTheDocument();
});
it("holds the save behind the prompt when the default changes while a member has a custom budget", async () => {
const user = userEvent.setup({ delay: null });
const input = await openEditorWithCustomMembers(user);

View file

@ -32,7 +32,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({
usePathname: () => "/ui/",
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/components/networking", () => ({
serverRootPath: "",

View file

@ -1,3 +1,4 @@
import { useUrlTab } from "@/hooks/useUrlTab";
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
@ -703,7 +704,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const canEditTeam = is_team_admin || is_proxy_admin || teamEditAccess.kind !== "none";
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);
const [activeTab, setActiveTab] = useUrlTab(
loading ? getTeamInfoVisibleTabs(true) : visibleTabs,
defaultTabKey,
"detail_tab",
);
const { onTabChange, hasVisited } = useVisitedTabs(activeTab);
const teamFormValues = (): TeamUpdateFormValues => {
const info = teamData?.team_info;
@ -2771,7 +2777,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
</div>
<Tabs defaultValue={defaultTabKey} className="mb-4" onValueChange={onTabChange}>
<Tabs
value={activeTab}
className="mb-4"
onValueChange={(value) => {
setActiveTab(String(value));
onTabChange(value);
}}
>
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
{tabItems.map(({ key, label }) => (
<TabsTrigger key={key} value={key} className="flex-none rounded-none px-4 py-2">

View file

@ -1,4 +1,5 @@
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { afterEach, beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils";
@ -503,7 +504,12 @@ describe("TeamVirtualKeysTable", () => {
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />, {
searchParams:
"?team=team-1&detail_tab=virtual-keys&key_tab=activity&activity_from=2026-01-01&activity_to=2026-01-08",
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText("clickable_key")).toBeInTheDocument();
@ -515,6 +521,13 @@ describe("TeamVirtualKeysTable", () => {
await waitFor(() => {
expect(screen.getByText("Key Info View")).toBeInTheDocument();
});
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams)).toEqual({
team: "team-1",
detail_tab: "virtual-keys",
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(await screen.findByText("Key ID")).toBeInTheDocument();
});
it("should open a key without its own organization without the team's organization", async () => {

View file

@ -1,4 +1,5 @@
"use client";
import { useResetDetailState } from "@/hooks/useUrlTab";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useTeamMemberBudgets } from "@/app/(dashboard)/hooks/teams/useTeamMemberBudgets";
import { teamMemberBudgetGate } from "@/components/shared/InheritedBudgetHint";
@ -52,6 +53,7 @@ const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
const { t } = useTranslation();
const resetKeyDetailState = useResetDetailState("key_tab");
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({
@ -153,7 +155,13 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
size: 120,
enableSorting: true,
cell: (info) => (
<IdCell value={info.getValue() as string | null} onClick={() => setSelectedKey(info.row.original)} />
<IdCell
value={info.getValue() as string | null}
onClick={() => {
resetKeyDetailState();
setSelectedKey(info.row.original);
}}
/>
),
},
{
@ -538,7 +546,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
},
},
],
[expandedAccordions, teamMemberBudgets, teamId, organization?.organization_id, t],
[expandedAccordions, teamMemberBudgets, teamId, organization?.organization_id, t, resetKeyDetailState],
);
const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction<SortingState>) => {
@ -550,8 +558,12 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
<div className="w-full">
{selectedKey ? (
<KeyInfoView
tabParam="key_tab"
keyId={selectedKey.token}
onClose={() => setSelectedKey(null)}
onClose={() => {
resetKeyDetailState();
setSelectedKey(null);
}}
keyData={selectedKey}
teams={[currentTeam]}
onDelete={refetch}

View file

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../../../tests/test-utils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "@/lib/toast";
import type { KeyResponse } from "../key_team_helpers/key_list";
@ -123,6 +124,7 @@ vi.mock("../common_components/AutoRotationView", () => {
// Mock Next.js router to avoid "invariant expected app router to be mounted" error
vi.mock("next/navigation", () => ({
usePathname: () => "/ui/",
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),

View file

@ -15,7 +15,10 @@ import type { Organization } from "../networking";
// where the overview "Spend" card formatted `max_budget` with the default 0
// decimals, truncating sub-dollar budgets (e.g. $0.10) to "$0".
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({
usePathname: () => "/ui/",
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("./key_edit_view", () => ({
KeyEditView: () => <div data-testid="key-edit-view-stub" />,

View file

@ -19,7 +19,7 @@ const editViewMocks = vi.hoisted(() => ({
onSubmit: undefined as ((v: Record<string, any>) => Promise<void>) | undefined,
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/ui/alerts" }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => ({ data: [] }),

View file

@ -1,3 +1,4 @@
import { useUrlTab } from "@/hooks/useUrlTab";
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
@ -76,6 +77,7 @@ interface KeyInfoViewProps {
onDelete?: () => void;
teams: any[] | null;
backButtonText?: string;
tabParam?: string;
}
// Premium fields (from LiteLLM_ManagementEndpoint_MetadataFields_Premium in
@ -106,11 +108,17 @@ export default function KeyInfoView({
onKeyDataUpdate,
onDelete,
backButtonText = i18n.t("keyDetail.keyInfoView.backToKeys"),
tabParam = "detail_tab",
}: KeyInfoViewProps) {
const { t } = useTranslation();
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const accessGroupNames = useModelAccessGroupNames();
const activityDateRange = useActivityDateRange();
const [activeTab, setActiveTab] = useUrlTab(
["overview", "activity", "savings", "auto-router-usage", "settings"],
"overview",
tabParam,
);
const queryClient = useQueryClient();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const { teams: teamsData } = useTeams();
@ -742,7 +750,7 @@ export default function KeyInfoView({
</DialogContent>
</Dialog>
<Tabs defaultValue="overview">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(String(value) as typeof activeTab)}>
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
{t("keyDetail.keyInfoView.tabs.overview")}

View file

@ -1,5 +1,5 @@
import { usePathname } from "next/navigation";
import { parseAsString, useQueryState } from "nuqs";
import { parseAsString, useQueryState, useQueryStates } from "nuqs";
import { useCallback, useEffect } from "react";
import { recordUiEvent } from "@/lib/telemetry/uiEvents";
import { routeSegmentForPathname } from "@/utils/uiHref";
@ -20,3 +20,16 @@ export function useUrlTab<T extends string>(values: readonly T[], fallback: T, k
);
return [tab, setTab];
}
export function useResetDetailState(tabParam = "detail_tab"): () => void {
const parsers = {
[tabParam]: parseAsString,
key_tab: parseAsString,
activity_from: parseAsString,
activity_to: parseAsString,
};
const [, setParams] = useQueryStates(parsers);
return useCallback(() => {
void setParams(null);
}, [setParams]);
}

View file

@ -5778,6 +5778,84 @@
"dismiss": "Dismiss banner"
},
"alerts": {
"unified": {
"title": "Alerts",
"saveError": "Could not save status. Please try again.",
"resourceHeading": "This alert is tied to the following resource",
"migrationDetails": "View migration details",
"loading": "Loading alerts…",
"adminRequired": "Only gateway admins can view alerts.",
"back": "Back to Alerts",
"loadError": "Could not load this alert. It may have been removed or you may no longer have access.",
"unavailable": "This alert is no longer available.",
"typeFilter": "Alert type",
"refresh": "Refresh",
"deprecationsError": "Could not load model deprecations.",
"performanceError": "Could not load performance alerts.",
"loadingMore": "Loading…",
"loadMore": "Load more",
"empty": {
"open": "No open alerts.",
"resolved": "No resolved alerts.",
"dismissed": "No dismissed alerts."
},
"types": {
"all": "All alerts",
"performance": "Performance",
"spend": "Spend",
"traffic": "Traffic",
"latency": "Latency",
"reliability": "Reliability"
},
"retiredTitle": "{{name}} retired on {{date}}",
"retiresTitle": "{{name}} retires on {{date}}",
"deprecationDescription": "{{requests}} recorded requests. Last used {{date}} UTC.",
"retired": "Retired",
"retires": "Retires",
"lastUsed": "Last used",
"keyFallback": "Key {{id}}",
"deprecation": "Model deprecation",
"metrics": {
"spend": "Spend",
"traffic": "Traffic",
"latency": "Mean response time",
"reliability": "Error rate",
"model_deprecation": "Usage"
},
"comparison": {
"fell": "{{label}} on {{name}} fell to {{ratio}}× its baseline",
"rose": "{{label}} on {{name}} rose to {{ratio}}× its baseline"
},
"value": {
"fell": "{{label}} on {{name}} fell to {{value}}",
"rose": "{{label}} on {{name}} rose to {{value}}"
},
"description": "{{value}} on {{date}} UTC, compared with the usual {{baseline}}.",
"links": {
"gateway": "View Usage",
"model": "View model routing",
"key": "View key activity",
"team": "View team activity",
"user": "View user activity"
},
"observed": "Observed",
"firstDetected": "First detected",
"chart": {
"unavailable": "Daily history is unavailable. The saved alert comparison is shown above.",
"baseline": "Dashed line: saved baseline of {{value}}. ",
"missingDays": "Missing days are left blank.",
"currentUsage": " Bars show current Usage, which may have changed since detection.",
"values": "{{title}} values",
"date": "Date",
"value": "Value",
"titles": {
"usd": "Daily spend",
"requests": "Daily requests",
"milliseconds": "Mean response time per day",
"ratio": "Daily error rate"
}
}
},
"page": {
"tabs": {
"modelDeprecations": "Model deprecations"
@ -8211,16 +8289,14 @@
"markAsRead": "Mark as read"
},
"bell": {
"triggerDeprecations_one": "Notifications, {{count}} model deprecation alert needs attention",
"triggerDeprecations_other": "Notifications, {{count}} model deprecation alerts need attention",
"deprecationsTitle": "Model deprecations",
"viewAllDeprecations": "View all {{total}}",
"deprecationsDescription": "Model migrations across your keys and teams.",
"triggerAlerts": "Notifications, alerts need attention",
"seeAll": "See all",
"comparison": "{{value}} vs. {{baseline}} usual",
"noOpenAlerts": "No open alerts.",
"announcementsHeading": "Announcements",
"viewDeprecations": "View model deprecations",
"trigger": "Notifications",
"triggerUnread": "Notifications, {{unread}} unread",
"title": "What's new"
"title": "Notifications"
}
},
"dashboardCommunityLinks": {

View file

@ -1011,7 +1011,7 @@ export interface paths {
};
/**
* List saved alerts
* @description Read saved alerts, newest first. Gateway admins and view-only admins can read. No usage queries run.
* @description Read saved alerts with current resource names and eight days of Usage history. Requires a gateway admin.
*/
get: operations["list_alerts_alerts_get"];
put?: never;
@ -27215,18 +27215,19 @@ export interface components {
*/
window_start: string;
};
/** AlertPage */
AlertPage: {
/** AlertHistoryPoint */
AlertHistoryPoint: {
/**
* Data
* @description Alerts ordered newest first.
* Date
* Format: date
* @description UTC day represented by this value.
*/
data: components["schemas"]["Alert"][];
date: string;
/**
* Next Cursor
* @description Pass as cursor to fetch the next page, or null at the end.
* Value
* @description Recorded daily value in the alert metric's unit.
*/
next_cursor?: string | null;
value: number;
};
/**
* AlertType
@ -27243,6 +27244,94 @@ export interface components {
*/
status: "open" | "resolved" | "dismissed";
};
/** AlertView */
AlertView: {
/**
* Created At
* Format: date-time
* @description When the alert was first saved.
*/
created_at: string;
/**
* Evidence
* @description Small saved snapshot explaining the alert.
*/
evidence: components["schemas"]["MetricEvidence"] | components["schemas"]["DeprecationEvidence"];
/**
* History
* @description Current Usage values for the seven baseline days and observed day. Null if unavailable.
*/
history?: components["schemas"]["AlertHistoryPoint"][] | null;
/**
* Id
* @description Opaque alert ID, also used as a pagination cursor.
*/
id: string;
/**
* Resource Id
* @description Resource ID. Whole-gateway alerts use 'gateway'. Keys use their SHA-256 hash, never a secret.
*/
resource_id: string;
/**
* Resource Name
* @description Current resource display name.
*/
resource_name?: string | null;
/**
* Resource Type
* @description Kind of affected resource.
* @enum {string}
*/
resource_type: "gateway" | "key" | "team" | "user" | "model";
/**
* Status
* @description Current review status.
* @enum {string}
*/
status: "open" | "resolved" | "dismissed";
/**
* Type
* @description Built-in alert category.
* @enum {string}
*/
type: "spend" | "traffic" | "latency" | "reliability" | "model_deprecation";
/**
* Updated At
* Format: date-time
* @description When its status last changed.
*/
updated_at: string;
/**
* Updated By
* @description User who last changed the status.
*/
updated_by?: string | null;
/**
* Window End
* Format: date-time
* @description Exclusive end of the observed window, normalized to UTC milliseconds.
*/
window_end: string;
/**
* Window Start
* Format: date-time
* @description Inclusive start of the observed window, normalized to UTC milliseconds.
*/
window_start: string;
};
/** AlertViewPage */
AlertViewPage: {
/**
* Data
* @description Alerts ordered newest first, with current Usage history.
*/
data: components["schemas"]["AlertView"][];
/**
* Next Cursor
* @description Cursor for the next page, or null at the end.
*/
next_cursor?: string | null;
};
/** AllowedVectorStoreIndexItem */
AllowedVectorStoreIndexItem: {
/** Index Name */
@ -54715,6 +54804,8 @@ export interface operations {
limit?: number;
/** @description next_cursor from the preceding page. */
cursor?: string | null;
/** @description Alert category, or all performance alerts. */
type?: ("spend" | "traffic" | "latency" | "reliability" | "model_deprecation") | "performance" | null;
};
header?: never;
path?: never;
@ -54728,7 +54819,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertPage"];
"application/json": components["schemas"]["AlertViewPage"];
};
};
/** @description Validation Error */
@ -54760,7 +54851,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Alert"];
"application/json": components["schemas"]["AlertView"];
};
};
/** @description Validation Error */