mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
701639ea22
commit
16a36c66b4
59 changed files with 2487 additions and 237 deletions
|
|
@ -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: ...
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,))
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
|
|
@ -1740,7 +1740,7 @@
|
|||
"count": 5
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 132
|
||||
"count": 131
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 26
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
89
ui/litellm-dashboard/src/components/Alerts/AlertActions.tsx
Normal file
89
ui/litellm-dashboard/src/components/Alerts/AlertActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
ui/litellm-dashboard/src/components/Alerts/AlertChart.tsx
Normal file
123
ui/litellm-dashboard/src/components/Alerts/AlertChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
ui/litellm-dashboard/src/components/Alerts/AlertDetail.tsx
Normal file
83
ui/litellm-dashboard/src/components/Alerts/AlertDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
230
ui/litellm-dashboard/src/components/Alerts/Alerts.tsx
Normal file
230
ui/litellm-dashboard/src/components/Alerts/Alerts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
166
ui/litellm-dashboard/src/components/Alerts/alertPresentation.ts
Normal file
166
ui/litellm-dashboard/src/components/Alerts/alertPresentation.ts
Normal 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>;
|
||||
44
ui/litellm-dashboard/src/components/Alerts/useAlerts.ts
Normal file
44
ui/litellm-dashboard/src/components/Alerts/useAlerts.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
113
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
113
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue