mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge c73a0c0227 into 780c504e00
This commit is contained in:
commit
455b05b7ab
58 changed files with 1152 additions and 120 deletions
|
|
@ -856,6 +856,16 @@
|
|||
],
|
||||
"title": "Created By"
|
||||
},
|
||||
"created_by_user": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AccessGroupResource"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -882,6 +892,16 @@
|
|||
}
|
||||
],
|
||||
"title": "Updated By"
|
||||
},
|
||||
"updated_by_user": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AccessGroupResource"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.proxy.management_helpers.resource_display_names import (
|
|||
agent_display_names,
|
||||
key_display_names,
|
||||
mcp_server_display_names,
|
||||
user_display_names,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw
|
||||
from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository
|
||||
|
|
@ -66,6 +67,12 @@ class _AccessGroupRecord(Protocol):
|
|||
@property
|
||||
def assigned_key_ids(self) -> Sequence[str] | None: ...
|
||||
|
||||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def updated_by(self) -> str | None: ...
|
||||
|
||||
def dict(self) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
|
|
@ -185,12 +192,19 @@ class _ResourceNames:
|
|||
agents: Mapping[str, str]
|
||||
teams: Mapping[str, str | None]
|
||||
keys: Mapping[str, str]
|
||||
users: Mapping[str, str]
|
||||
|
||||
|
||||
def _label(ids: Sequence[str], names: Mapping[str, str | None]) -> tuple[AccessGroupResource, ...]:
|
||||
return tuple(AccessGroupResource(id=resource_id, name=names.get(resource_id)) for resource_id in ids)
|
||||
|
||||
|
||||
def _actor_resource(user_id: str | None, names: Mapping[str, str]) -> AccessGroupResource | None:
|
||||
if user_id is None:
|
||||
return None
|
||||
return AccessGroupResource(id=user_id, name=names.get(user_id))
|
||||
|
||||
|
||||
def _record_to_response(
|
||||
record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str], names: _ResourceNames
|
||||
) -> AccessGroupResponse:
|
||||
|
|
@ -202,6 +216,8 @@ def _record_to_response(
|
|||
"access_agents": _label(record.access_agent_ids or (), names.agents),
|
||||
"assigned_teams": _label(assigned_team_ids, names.teams),
|
||||
"assigned_keys": _label(record.assigned_key_ids or (), names.keys),
|
||||
"created_by_user": _actor_resource(record.created_by, names.users),
|
||||
"updated_by_user": _actor_resource(record.updated_by, names.users),
|
||||
}
|
||||
)
|
||||
return AccessGroupResponse.model_validate(payload)
|
||||
|
|
@ -219,7 +235,7 @@ async def _responses_for(
|
|||
if not records:
|
||||
return ()
|
||||
teams: Final = await _teams_touching(TeamRepository(prisma_client).table, records)
|
||||
mcp_servers, agents, keys = await asyncio.gather(
|
||||
mcp_servers, agents, keys, users = await asyncio.gather(
|
||||
mcp_server_display_names(
|
||||
prisma_client,
|
||||
_ids_across(records, lambda record: record.access_mcp_server_ids),
|
||||
|
|
@ -229,12 +245,19 @@ async def _responses_for(
|
|||
prisma_client, _ids_across(records, lambda record: record.access_agent_ids), global_agent_registry
|
||||
),
|
||||
key_display_names(prisma_client, _ids_across(records, lambda record: record.assigned_key_ids)),
|
||||
user_display_names(
|
||||
prisma_client,
|
||||
_ids_across(
|
||||
records, lambda record: tuple(user_id for user_id in (record.created_by, record.updated_by) if user_id)
|
||||
),
|
||||
),
|
||||
)
|
||||
names: Final = _ResourceNames(
|
||||
mcp_servers=mcp_servers,
|
||||
agents=agents,
|
||||
teams=MappingProxyType({team.team_id: team.team_alias for team in teams}),
|
||||
keys=keys,
|
||||
users=users,
|
||||
)
|
||||
attached: Final = _attached_team_ids_by_group(records, teams)
|
||||
return tuple(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from typing import Final
|
|||
|
||||
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.chunked_in import find_many_in
|
||||
from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.repositories.verification_token_repository import VerificationTokenRepository
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
|
@ -59,3 +61,11 @@ async def key_display_names(prisma_client: PrismaClient, tokens: Sequence[str])
|
|||
where: Final = {"token": {"in": tuple(frozenset(tokens))}}
|
||||
rows: Final = await VerificationTokenRepository(prisma_client).table.find_many(where=where)
|
||||
return MappingProxyType({row.token: row.key_alias for row in rows if row.key_alias})
|
||||
|
||||
|
||||
async def user_display_names(prisma_client: PrismaClient, user_ids: Sequence[str]) -> Mapping[str, str]:
|
||||
"""user_id -> user_alias, else user_email, for the users that have either."""
|
||||
if not user_ids:
|
||||
return MappingProxyType({})
|
||||
rows: Final = await find_many_in(UserRepository(prisma_client).table, "user_id", frozenset(user_ids))
|
||||
return MappingProxyType({row.user_id: name for row in rows if (name := row.user_alias or row.user_email)})
|
||||
|
|
|
|||
|
|
@ -45,5 +45,7 @@ class AccessGroupResponse(LiteLLMBaseModel):
|
|||
assigned_keys: tuple[AccessGroupResource, ...]
|
||||
created_at: datetime
|
||||
created_by: str | None = None
|
||||
created_by_user: AccessGroupResource | None = None
|
||||
updated_at: datetime
|
||||
updated_by: str | None = None
|
||||
updated_by_user: AccessGroupResource | None = None
|
||||
|
|
|
|||
261
tests/integration/management/test_access_group_display_names.py
Normal file
261
tests/integration/management/test_access_group_display_names.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.integration._support.client import Gateway, object_value, string_value
|
||||
from tests.integration._support.database import write_rows
|
||||
|
||||
FALLBACK_EMAIL: Final = f"fallback-{uuid.uuid4().hex}@example.com"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _access_group(gateway: Gateway, body: dict[str, object], *, key: str) -> Iterator[str]:
|
||||
created: Final = gateway.request("POST", "/v1/access_group", body, key=key)
|
||||
assert created.status_code == 201, created.text
|
||||
identity: Final = string_value(created.json()["access_group_id"])
|
||||
try:
|
||||
yield identity
|
||||
finally:
|
||||
deleted: Final = gateway.request("DELETE", f"/v1/access_group/{identity}")
|
||||
assert deleted.status_code == 204, deleted.text
|
||||
|
||||
|
||||
def test_access_group_detail_names_the_user_who_created_and_updated_it(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
team_id: Final = scenario.team(team_alias="Review Team")
|
||||
user_id: Final = scenario.user(
|
||||
user_alias="Ada Reviewer",
|
||||
user_email=f"ada-{uuid.uuid4().hex}@example.com",
|
||||
user_role="proxy_admin",
|
||||
)
|
||||
key: Final = scenario.key(user_id=user_id)
|
||||
with _access_group(
|
||||
gateway,
|
||||
{
|
||||
"access_group_name": f"integration-{uuid.uuid4().hex}",
|
||||
"access_model_names": [],
|
||||
"assigned_team_ids": [team_id],
|
||||
},
|
||||
key=key,
|
||||
) as group_id:
|
||||
updated: Final = gateway.request(
|
||||
"PUT",
|
||||
f"/v1/access_group/{group_id}",
|
||||
{"description": "updated description"},
|
||||
key=key,
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
group: Final = object_value(detail.json())
|
||||
assert group["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text
|
||||
assert group["updated_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text
|
||||
assert group["assigned_teams"] == [{"id": team_id, "name": "Review Team"}], detail.text
|
||||
|
||||
listing: Final = gateway.request("GET", "/v1/access_group")
|
||||
assert listing.status_code == 200, listing.text
|
||||
entries: Final = listing.json()
|
||||
assert isinstance(entries, list), listing.text
|
||||
listed: Final = object_value(next(entry for entry in entries if entry["access_group_id"] == group_id))
|
||||
assert listed["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, listing.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_fields", "expected_name"),
|
||||
(
|
||||
pytest.param({"user_email": FALLBACK_EMAIL}, FALLBACK_EMAIL, id="email"),
|
||||
pytest.param({}, None, id="neither"),
|
||||
),
|
||||
)
|
||||
def test_access_group_creator_without_alias_falls_back_to_email_then_none(
|
||||
gateway: Gateway, user_fields: dict[str, str], expected_name: str | None
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
user_id: Final = scenario.user(user_role="proxy_admin", **user_fields)
|
||||
key: Final = scenario.key(user_id=user_id)
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=key,
|
||||
) as group_id:
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert object_value(detail.json())["created_by_user"] == {
|
||||
"id": user_id,
|
||||
"name": expected_name,
|
||||
}, detail.text
|
||||
|
||||
|
||||
def test_access_group_created_by_master_key_names_the_default_admin(gateway: Gateway) -> None:
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=gateway.key,
|
||||
) as group_id:
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert object_value(detail.json())["created_by_user"] == {
|
||||
"id": "default_user_id",
|
||||
"name": None,
|
||||
}, detail.text
|
||||
|
||||
|
||||
def test_access_group_creator_with_blank_alias_falls_back_to_email(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
email: Final = f"blank-alias-{uuid.uuid4().hex}@example.com"
|
||||
user_id: Final = scenario.user(user_role="proxy_admin", user_alias="", user_email=email)
|
||||
key: Final = scenario.key(user_id=user_id)
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=key,
|
||||
) as group_id:
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert object_value(detail.json())["created_by_user"] == {"id": user_id, "name": email}, detail.text
|
||||
|
||||
|
||||
def test_access_group_update_by_another_user_keeps_creator_and_names_updater(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
creator_id: Final = scenario.user(user_alias="Creator One", user_role="proxy_admin")
|
||||
creator_key: Final = scenario.key(user_id=creator_id)
|
||||
updater_id: Final = scenario.user(
|
||||
user_alias="Updater Two", user_email=f"updater-{uuid.uuid4().hex}@example.com", user_role="proxy_admin"
|
||||
)
|
||||
updater_key: Final = scenario.key(user_id=updater_id)
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=creator_key,
|
||||
) as group_id:
|
||||
updated: Final = gateway.request(
|
||||
"PUT", f"/v1/access_group/{group_id}", {"description": "by another"}, key=updater_key
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
group: Final = object_value(detail.json())
|
||||
assert group["created_by_user"] == {"id": creator_id, "name": "Creator One"}, detail.text
|
||||
assert group["updated_by_user"] == {"id": updater_id, "name": "Updater Two"}, detail.text
|
||||
|
||||
|
||||
def test_access_group_creator_deleted_afterwards_resolves_name_to_none(gateway: Gateway) -> None:
|
||||
user_id: Final = f"del-{uuid.uuid4().hex}"
|
||||
created_user: Final = gateway.request(
|
||||
"POST", "/user/new", {"user_id": user_id, "user_alias": "Soon Deleted", "user_role": "proxy_admin"}
|
||||
)
|
||||
assert created_user.status_code == 200, created_user.text
|
||||
minted: Final = gateway.request("POST", "/key/generate", {"user_id": user_id})
|
||||
assert minted.status_code == 200, minted.text
|
||||
key: Final = string_value(minted.json()["key"])
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=key,
|
||||
) as group_id:
|
||||
deleted: Final = gateway.request("POST", "/user/delete", {"user_ids": [user_id]})
|
||||
assert deleted.status_code == 200 and deleted.json() == 1, deleted.text
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert object_value(detail.json())["created_by_user"] == {
|
||||
"id": user_id,
|
||||
"name": None,
|
||||
}, detail.text
|
||||
|
||||
|
||||
def test_access_group_name_reflects_the_current_alias_after_a_rename(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
user_id: Final = scenario.user(user_alias="Old Alias", user_role="proxy_admin")
|
||||
key: Final = scenario.key(user_id=user_id)
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=key,
|
||||
) as group_id:
|
||||
renamed: Final = gateway.request("POST", "/user/update", {"user_id": user_id, "user_alias": "New Alias"})
|
||||
assert renamed.status_code == 200, renamed.text
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert object_value(detail.json())["created_by_user"] == {
|
||||
"id": user_id,
|
||||
"name": "New Alias",
|
||||
}, detail.text
|
||||
|
||||
|
||||
def test_access_group_list_resolves_names_past_the_in_chunk_size(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
chunk_size: Final = 5000
|
||||
write_rows(
|
||||
f'INSERT INTO "LiteLLM_UserTable" (user_id, user_alias) '
|
||||
f"SELECT 'chunk-{marker}-' || i, 'ChunkUser-' || i FROM generate_series(1, {chunk_size + 1}) AS i",
|
||||
(),
|
||||
)
|
||||
write_rows(
|
||||
f'INSERT INTO "LiteLLM_AccessGroupTable" (access_group_id, access_group_name, created_by) '
|
||||
f"SELECT gen_random_uuid(), 'chunkgrp-{marker}-' || i, 'chunk-{marker}-' || i "
|
||||
f"FROM generate_series(1, {chunk_size + 1}) AS i",
|
||||
(),
|
||||
)
|
||||
try:
|
||||
listing: Final = gateway.request("GET", "/v1/access_group")
|
||||
assert listing.status_code == 200, listing.text
|
||||
entries: Final = listing.json()
|
||||
assert isinstance(entries, list), listing.text
|
||||
seeded: Final = [e for e in entries if str(e.get("access_group_name", "")).startswith(f"chunkgrp-{marker}-")]
|
||||
assert len(seeded) == chunk_size + 1
|
||||
for entry in seeded:
|
||||
assert entry["created_by_user"]["name"] == str(entry["created_by_user"]["id"]).replace(
|
||||
f"chunk-{marker}-", "ChunkUser-"
|
||||
), entry
|
||||
finally:
|
||||
write_rows('DELETE FROM "LiteLLM_AccessGroupTable" WHERE access_group_name LIKE %s', (f"chunkgrp-{marker}-%",))
|
||||
write_rows('DELETE FROM "LiteLLM_UserTable" WHERE user_id LIKE %s', (f"chunk-{marker}-%",))
|
||||
|
||||
|
||||
def test_access_group_endpoints_reject_a_non_admin_caller(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
user_id: Final = scenario.user(user_role="internal_user")
|
||||
key: Final = scenario.key(user_id=user_id)
|
||||
listing: Final = gateway.request("GET", "/v1/access_group", key=key)
|
||||
assert listing.status_code == 401, listing.text
|
||||
|
||||
|
||||
def test_access_group_get_unknown_id_returns_404(gateway: Gateway) -> None:
|
||||
response: Final = gateway.request("GET", f"/v1/access_group/{uuid.uuid4()}")
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
def test_access_group_put_with_malformed_body_returns_422(gateway: Gateway) -> None:
|
||||
response: Final = gateway.request("PUT", f"/v1/access_group/{uuid.uuid4()}", {"access_model_names": "not-a-list"})
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
def test_access_group_post_with_malformed_body_returns_422(gateway: Gateway) -> None:
|
||||
response: Final = gateway.request("POST", "/v1/access_group", {"access_group_name": 123})
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
def test_access_group_concurrent_updates_while_listing_stay_consistent(gateway: Gateway) -> None:
|
||||
with _access_group(
|
||||
gateway,
|
||||
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
|
||||
key=gateway.key,
|
||||
) as group_id:
|
||||
|
||||
def call(index: int) -> int:
|
||||
if index % 2 == 0:
|
||||
response = gateway.request("PUT", f"/v1/access_group/{group_id}", {"description": f"desc-{index}"})
|
||||
else:
|
||||
response = gateway.request("GET", "/v1/access_group")
|
||||
return response.status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
statuses: Final = list(pool.map(call, range(20)))
|
||||
assert all(status == 200 for status in statuses), statuses
|
||||
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
|
|
@ -130,6 +130,9 @@ def client_and_mocks(monkeypatch):
|
|||
mock_agents_table.find_many = AsyncMock(return_value=[])
|
||||
mock_agents_table.update = AsyncMock(return_value=None)
|
||||
|
||||
mock_user_table = MagicMock()
|
||||
mock_user_table.find_many = AsyncMock(return_value=[])
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_tx():
|
||||
tx = types.SimpleNamespace(
|
||||
|
|
@ -146,6 +149,7 @@ def client_and_mocks(monkeypatch):
|
|||
litellm_verificationtoken=mock_key_table,
|
||||
litellm_mcpservertable=mock_mcp_server_table,
|
||||
litellm_agentstable=mock_agents_table,
|
||||
litellm_usertable=mock_user_table,
|
||||
tx=mock_tx,
|
||||
)
|
||||
mock_prisma.db = mock_db
|
||||
|
|
@ -423,6 +427,51 @@ def test_get_access_group_success(client_and_mocks, base_path, access_group_id):
|
|||
assert resp.json()["access_group_id"] == access_group_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
def test_get_access_group_resolves_actor_display_names(client_and_mocks, base_path):
|
||||
"""Get names the creator/updater from the user table: alias beats email, unresolved ids keep name=None."""
|
||||
client, mock_prisma, mock_table, *_ = client_and_mocks
|
||||
|
||||
record = _make_access_group_record(
|
||||
access_group_id="ag-123", created_by="ada", updated_by="ghost-user"
|
||||
)
|
||||
mock_table.find_unique = AsyncMock(return_value=record)
|
||||
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
|
||||
return_value=[types.SimpleNamespace(user_id="ada", user_alias="Ada Reviewer", user_email="ada@x.com")]
|
||||
)
|
||||
|
||||
resp = client.get(f"{base_path}/ag-123")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["created_by_user"] == {"id": "ada", "name": "Ada Reviewer"}
|
||||
assert body["updated_by_user"] == {"id": "ghost-user", "name": None}
|
||||
queried_ids = mock_prisma.db.litellm_usertable.find_many.call_args.kwargs["where"]["user_id"]["in"]
|
||||
assert sorted(queried_ids) == ["ada", "ghost-user"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
def test_list_access_groups_resolves_actor_display_names_across_records(client_and_mocks, base_path):
|
||||
"""List carries created_by_user per group from one batched user query."""
|
||||
client, mock_prisma, mock_table, *_ = client_and_mocks
|
||||
|
||||
records = [
|
||||
_make_access_group_record(access_group_id="ag-1", created_by="ada", updated_by="ada"),
|
||||
_make_access_group_record(access_group_id="ag-2", created_by="bob", updated_by="bob"),
|
||||
]
|
||||
mock_table.find_many = AsyncMock(return_value=records)
|
||||
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
|
||||
return_value=[types.SimpleNamespace(user_id="bob", user_alias=None, user_email="bob@x.com")]
|
||||
)
|
||||
|
||||
resp = client.get(base_path)
|
||||
assert resp.status_code == 200
|
||||
body = {entry["access_group_id"]: entry for entry in resp.json()}
|
||||
assert body["ag-1"]["created_by_user"] == {"id": "ada", "name": None}
|
||||
assert body["ag-2"]["created_by_user"] == {"id": "bob", "name": "bob@x.com"}
|
||||
assert body["ag-2"]["updated_by_user"] == {"id": "bob", "name": "bob@x.com"}
|
||||
mock_prisma.db.litellm_usertable.find_many.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
def test_get_access_group_derives_assigned_teams_from_team_table(client_and_mocks, base_path):
|
||||
"""Get drops ghost ids from the stored column and adds teams that carry the group but were never mirrored."""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.proxy.management_helpers.resource_display_names import (
|
|||
agent_display_names,
|
||||
key_display_names,
|
||||
mcp_server_display_names,
|
||||
user_display_names,
|
||||
)
|
||||
from litellm.types.agents import AgentResponse
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
|
@ -128,3 +129,29 @@ async def test_key_empty_ids_skip_the_db():
|
|||
table = _table()
|
||||
assert dict(await key_display_names(_prisma(litellm_verificationtoken=table), ())) == {}
|
||||
table.find_many.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_alias_beats_email_and_users_with_neither_are_skipped():
|
||||
table = _table(
|
||||
[
|
||||
types.SimpleNamespace(user_id="u1", user_alias="Ada Reviewer", user_email="ada@x.com"),
|
||||
types.SimpleNamespace(user_id="u2", user_alias=None, user_email="bob@x.com"),
|
||||
types.SimpleNamespace(user_id="u3", user_alias=None, user_email=None),
|
||||
]
|
||||
)
|
||||
names = await user_display_names(_prisma(litellm_usertable=table), ("u1", "u2", "u3", "missing", "u1"))
|
||||
assert dict(names) == {"u1": "Ada Reviewer", "u2": "bob@x.com"}
|
||||
assert sorted(table.find_many.call_args.kwargs["where"]["user_id"]["in"]) == [
|
||||
"missing",
|
||||
"u1",
|
||||
"u2",
|
||||
"u3",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_empty_ids_skip_the_db():
|
||||
table = _table()
|
||||
assert dict(await user_display_names(_prisma(litellm_usertable=table), ())) == {}
|
||||
table.find_many.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -143,6 +143,20 @@ describe("AccessGroupDetail", () => {
|
|||
expect(screen.getByText("A test access group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the creator and updater names instead of their raw user ids", () => {
|
||||
renderWith({
|
||||
created_by: "user-abc",
|
||||
created_by_user: { id: "user-abc", name: "Ada Reviewer" },
|
||||
updated_by: "user-def",
|
||||
updated_by_user: { id: "user-def", name: null },
|
||||
});
|
||||
|
||||
const creatorLink = screen.getByRole("link", { name: "Ada Reviewer" });
|
||||
expect(creatorLink).toHaveAttribute("href", expect.stringContaining("user-abc"));
|
||||
expect(screen.queryByText("user-abc")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "user-def" })).toHaveAttribute("href", expect.stringContaining("user-def"));
|
||||
});
|
||||
|
||||
it("should display em dash when description is empty", () => {
|
||||
renderWith({ description: null });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
|
||||
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -127,27 +127,27 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<CardTitle>Group Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dl className="grid w-1/2 grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Description</dt>
|
||||
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.created_at).toLocaleString()}
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{new Date(accessGroup.created_at).toLocaleString()}</span>
|
||||
{accessGroup.created_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
</>
|
||||
<span className="flex min-w-0 grow basis-24 items-center gap-1">
|
||||
<span className="shrink-0">by</span>
|
||||
<UserReference userId={accessGroup.created_by} displayName={accessGroup.created_by_user?.name} />
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Last Updated</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.updated_at).toLocaleString()}
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{new Date(accessGroup.updated_at).toLocaleString()}</span>
|
||||
{accessGroup.updated_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
</>
|
||||
<span className="flex min-w-0 grow basis-24 items-center gap-1">
|
||||
<span className="shrink-0">by</span>
|
||||
<UserReference userId={accessGroup.updated_by} displayName={accessGroup.updated_by_user?.name} />
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers";
|
||||
import { useInfiniteUsers, useUserDisplayNames, useUserEmailLookup, useUserLookup } from "./useUsers";
|
||||
import { userListCall } from "@/components/networking";
|
||||
import type { UserListResponse } from "@/components/networking";
|
||||
|
||||
|
|
@ -404,3 +404,122 @@ describe("useUserEmailLookup", () => {
|
|||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUserDisplayNames", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("prefers the alias, falls back to the email, and omits users with neither", async () => {
|
||||
const response = buildUserListResponse(1, 1, 2);
|
||||
vi.mocked(userListCall).mockResolvedValue({
|
||||
...response,
|
||||
users: [
|
||||
{ ...response.users[0], user_alias: "Ada Reviewer" },
|
||||
{ ...response.users[1], user_alias: null },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({
|
||||
"user-1-0": "Ada Reviewer",
|
||||
"user-1-1": "user-1-1@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits users that have neither alias nor email so callers fall back to the id", async () => {
|
||||
const response = buildUserListResponse(1, 1, 2);
|
||||
vi.mocked(userListCall).mockResolvedValue({
|
||||
...response,
|
||||
users: [{ ...response.users[0], user_email: null, user_alias: null }, response.users[1]],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({ "user-1-1": "user-1-1@example.com" });
|
||||
});
|
||||
|
||||
it("does not query with no ids", async () => {
|
||||
const { result } = renderHook(() => useUserDisplayNames([]), { wrapper });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pages through ids in chunks of the max page size and merges the names", async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, i) => `u${String(i).padStart(3, "0")}`);
|
||||
const response = buildUserListResponse(1, 1, 0);
|
||||
vi.mocked(userListCall).mockImplementation(async (_token, userIDs) => ({
|
||||
...response,
|
||||
users: [
|
||||
{
|
||||
...response.users[0],
|
||||
user_id: userIDs![0],
|
||||
user_alias: `Alias ${userIDs![0]}`,
|
||||
} as UserListResponse["users"][number],
|
||||
],
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useUserDisplayNames(ids), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(userListCall).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(userListCall).mock.calls[0][1]).toHaveLength(100);
|
||||
expect(vi.mocked(userListCall).mock.calls[1][1]).toEqual(["u100", "u100"]);
|
||||
expect(result.current.data).toEqual({ u000: "Alias u000", u100: "Alias u100" });
|
||||
});
|
||||
|
||||
it("requests the max page size and keeps only exact id matches", async () => {
|
||||
const response = buildUserListResponse(1, 1, 0);
|
||||
vi.mocked(userListCall).mockResolvedValue({
|
||||
...response,
|
||||
users: [
|
||||
{ ...response.users[0], user_id: "ada-reviewer", user_alias: "Wrong" },
|
||||
{ ...response.users[0], user_id: "ada", user_alias: "Ada" },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["ada", "ada"], 1, 100);
|
||||
expect(result.current.data).toEqual({ ada: "Ada" });
|
||||
});
|
||||
|
||||
it("resolves a single id even when substring matches would fill the page", async () => {
|
||||
const allUsers = [
|
||||
...Array.from({ length: 100 }, (_, i) => ({
|
||||
user_id: `ada-${i + 1}`,
|
||||
user_alias: `Ada ${i + 1}`,
|
||||
user_email: null,
|
||||
})),
|
||||
{ user_id: "ada", user_alias: "Ada Exact", user_email: null },
|
||||
];
|
||||
const response = buildUserListResponse(1, 1, 0);
|
||||
vi.mocked(userListCall).mockImplementation(async (_token, userIDs, _page, pageSize) => ({
|
||||
...response,
|
||||
users:
|
||||
userIDs!.length === 1
|
||||
? (allUsers
|
||||
.filter((user) => user.user_id.includes(userIDs![0]))
|
||||
.slice(0, pageSize) as UserListResponse["users"])
|
||||
: (allUsers.filter((user) => userIDs!.includes(user.user_id)) as UserListResponse["users"]),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({ ada: "Ada Exact" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,39 @@ export const useUserEmailLookup = (userIds: readonly string[]) => {
|
|||
});
|
||||
};
|
||||
|
||||
const userDisplayNameKeys = createQueryKeys("userDisplayNames");
|
||||
|
||||
export const useUserDisplayNames = (userIds: readonly string[]) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const distinctIds = Array.from(new Set(userIds.filter((id) => id !== ""))).sort();
|
||||
return useQuery<Record<string, string>>({
|
||||
queryKey: userDisplayNameKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }),
|
||||
queryFn: async () => {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < distinctIds.length; i += USER_LIST_MAX_PAGE_SIZE) {
|
||||
chunks.push(distinctIds.slice(i, i + USER_LIST_MAX_PAGE_SIZE));
|
||||
}
|
||||
const responses = await Promise.all(
|
||||
chunks.map((ids) =>
|
||||
// a single user_ids value is a substring search on /user/list
|
||||
userListCall(accessToken!, ids.length === 1 ? [ids[0], ids[0]] : ids, 1, USER_LIST_MAX_PAGE_SIZE),
|
||||
),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
responses.flatMap((response, chunkIndex) => {
|
||||
const requested = new Set(chunks[chunkIndex]);
|
||||
return response.users.flatMap((user) => {
|
||||
if (!requested.has(user.user_id)) return [];
|
||||
const name = user.user_alias || user.user_email;
|
||||
return name ? [[user.user_id, name]] : [];
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
enabled: Boolean(accessToken) && distinctIds.length > 0 && canListUsers(userRole),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserLookup = (userId: string | null) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return useQuery<UserInfo | null>({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { MCPGatewaySessionsTab, describeTerminateResult, formatIdleSeconds } fro
|
|||
import * as networking from "@/components/networking";
|
||||
import type { MCPGatewaySessionsResponse, MCPGatewaySessionsTerminateResponse } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchMCPGatewaySessions: vi.fn(),
|
||||
terminateMCPGatewaySessions: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { fetchMCPGatewaySessions, terminateMCPGatewaySessions } from "@/components/networking";
|
||||
import { EntityReference, UserReference } from "@/components/shared/EntityReference";
|
||||
import { teamDetailHref } from "@/utils/entityLinks";
|
||||
import type {
|
||||
MCPGatewaySessionGroupCount,
|
||||
MCPGatewaySessionSelector,
|
||||
|
|
@ -216,16 +218,21 @@ function SessionsBody({
|
|||
{session.user_id === null ? (
|
||||
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-xs">{session.user_id}</span>
|
||||
{session.user_email ? (
|
||||
<span className="ml-1 text-xs text-muted-foreground">{session.user_email}</span>
|
||||
) : null}
|
||||
</>
|
||||
<UserReference userId={session.user_id} displayName={session.user_email} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{session.key_alias ?? "-"}</TableCell>
|
||||
<TableCell className="text-xs">{session.team_alias ?? session.team_id ?? "-"}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{session.team_id ? (
|
||||
<EntityReference
|
||||
id={session.team_id}
|
||||
name={session.team_alias}
|
||||
href={teamDetailHref(session.team_id)}
|
||||
/>
|
||||
) : (
|
||||
session.team_alias ?? "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{session.client_ip || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs">{formatIdleSeconds(session.idle_seconds)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{session.in_flight_requests}</TableCell>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import MCPServers, { compareServers, type SortKey } from "./mcp_servers";
|
|||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchMCPServers: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { MemoryRow } from "@/components/networking";
|
||||
|
||||
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
|
||||
memory_id: "mem-1",
|
||||
|
|
@ -66,8 +72,10 @@ describe("MemoryDetailDrawer", () => {
|
|||
it("attributes the created and updated timestamps to their actors", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute("href", expect.stringContaining("alice"));
|
||||
expect(screen.getByText(/^Created /)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Updated /)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an em dash for a timestamp the backend did not send", () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import React from "react";
|
|||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
interface MemoryDetailDrawerProps {
|
||||
row: MemoryRow | null;
|
||||
|
|
@ -25,6 +27,9 @@ function formatTimestamp(ts?: string): string {
|
|||
}
|
||||
|
||||
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
React.useMemo(() => [row?.created_by ?? "", row?.updated_by ?? ""], [row]),
|
||||
);
|
||||
return (
|
||||
<Sheet
|
||||
open={!!row}
|
||||
|
|
@ -69,12 +74,26 @@ export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
|
|||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Created {formatTimestamp(row.created_at)}
|
||||
{row.created_by ? ` by ${row.created_by}` : ""}
|
||||
{row.created_by ? (
|
||||
<>
|
||||
{" by "}
|
||||
<UserReference userId={row.created_by} displayName={displayNames?.[row.created_by]} />
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
Updated {formatTimestamp(row.updated_at)}
|
||||
{row.updated_by ? ` by ${row.updated_by}` : ""}
|
||||
{row.updated_by ? (
|
||||
<>
|
||||
{" by "}
|
||||
<UserReference userId={row.updated_by} displayName={displayNames?.[row.updated_by]} />
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import AllModelsTab from "./AllModelsTab";
|
||||
import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const mockModelDeleteCall = vi.fn().mockResolvedValue({});
|
||||
const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({});
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -568,6 +570,10 @@ describe("AllModelsTab", () => {
|
|||
});
|
||||
|
||||
it("opens the team detail view from the team ID cell", async () => {
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AllModelsTab {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ModelData } from "@/components/model_dashboard/types";
|
|||
|
||||
import { AllModelsTable } from "./AllModelsTable";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const makeModel = (overrides: Partial<ModelData> = {}): ModelData =>
|
||||
({
|
||||
model_name: "gpt-4-public",
|
||||
|
|
@ -480,6 +482,10 @@ describe("AllModelsTable", () => {
|
|||
});
|
||||
|
||||
it("runs the full reset from the filter drawer", async () => {
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const onResetFilters = vi.fn();
|
||||
render(<AllModelsTable {...baseProps} onResetFilters={onResetFilters} />);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
|
|
@ -132,6 +133,9 @@ export function AllModelsTable({
|
|||
credentialLabels,
|
||||
}: AllModelsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => data.map((model) => model.model_info.created_by ?? ""), [data]),
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = {
|
||||
|
|
@ -144,6 +148,7 @@ export function AllModelsTable({
|
|||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
credentialLabels,
|
||||
resolveUserName: (userId: string) => displayNames?.[userId],
|
||||
};
|
||||
return getModelsTableColumns(columnDeps);
|
||||
}, [
|
||||
|
|
@ -156,6 +161,7 @@ export function AllModelsTable({
|
|||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
credentialLabels,
|
||||
displayNames,
|
||||
]);
|
||||
|
||||
const modelGroupOptions = useMemo(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
|||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
|
|
@ -181,17 +182,30 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u
|
|||
);
|
||||
}
|
||||
|
||||
function CreatedByCell({ model }: { model: ModelData }) {
|
||||
function CreatedByCell({
|
||||
model,
|
||||
resolveUserName,
|
||||
}: {
|
||||
model: ModelData;
|
||||
resolveUserName: (userId: string) => string | undefined;
|
||||
}) {
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdAt = formatShortDate(model.model_info.created_at);
|
||||
const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown";
|
||||
const secondaryForDbModel = createdAt ?? "Unknown date";
|
||||
const createdBy = model.model_info.created_by;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-44 truncate text-sm text-foreground" title={primary}>
|
||||
{primary}
|
||||
</span>
|
||||
{!isConfigModel && createdBy ? (
|
||||
<span className="flex min-w-0 max-w-44 text-sm text-foreground">
|
||||
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="max-w-44 truncate text-sm text-foreground" title={primary}>
|
||||
{primary}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">{isConfigModel ? "-" : secondaryForDbModel}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -368,6 +382,7 @@ export interface ModelsTableColumnDeps {
|
|||
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>;
|
||||
pausingModelId?: string | null;
|
||||
credentialLabels?: ReadonlyMap<string, string>;
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const getModelsTableColumns = ({
|
||||
|
|
@ -380,6 +395,7 @@ export const getModelsTableColumns = ({
|
|||
onTogglePauseClick,
|
||||
pausingModelId,
|
||||
credentialLabels,
|
||||
resolveUserName = () => undefined,
|
||||
}: ModelsTableColumnDeps): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
id: MODEL_ID_COLUMN_ID,
|
||||
|
|
@ -435,7 +451,7 @@ export const getModelsTableColumns = ({
|
|||
enableSorting: true,
|
||||
size: 180,
|
||||
minSize: 110,
|
||||
cell: ({ row }) => <CreatedByCell model={row.original} />,
|
||||
cell: ({ row }) => <CreatedByCell model={row.original} resolveUserName={resolveUserName} />,
|
||||
},
|
||||
{
|
||||
id: UPDATED_AT_COLUMN_ID,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import userEvent from "@testing-library/user-event";
|
|||
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
|
||||
import { ProjectDetail } from "./ProjectDetailsPage";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const mockUseProjectDetails = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
|||
import { BarChart } from "@/components/shared/charts";
|
||||
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -28,6 +29,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
const { data: project, isLoading } = useProjectDetails(projectId);
|
||||
const { data: teamInfo } = useTeam(project?.team_id ?? undefined);
|
||||
const { userId } = useAuthorized();
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => [project?.created_by ?? "", project?.updated_by ?? ""], [project]),
|
||||
);
|
||||
const canEditProject = useCanManageProjects(
|
||||
isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""),
|
||||
);
|
||||
|
|
@ -107,27 +111,27 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dl className="grid w-1/2 grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Description</dt>
|
||||
<dd className="text-foreground">{project.description || "—"}</dd>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(project.created_at).toLocaleString()}
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{new Date(project.created_at).toLocaleString()}</span>
|
||||
{project.created_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.created_by} />
|
||||
</>
|
||||
<span className="flex min-w-0 grow basis-24 items-center gap-1">
|
||||
<span className="shrink-0">by</span>
|
||||
<UserReference userId={project.created_by} displayName={displayNames?.[project.created_by]} />
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Last Updated</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(project.updated_at).toLocaleString()}
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{new Date(project.updated_at).toLocaleString()}</span>
|
||||
{project.updated_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.updated_by} />
|
||||
</>
|
||||
<span className="flex min-w-0 grow basis-24 items-center gap-1">
|
||||
<span className="shrink-0">by</span>
|
||||
<UserReference userId={project.updated_by} displayName={displayNames?.[project.updated_by]} />
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
@ -188,7 +192,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2 [&>*]:min-w-0">
|
||||
<ProjectKeysSection projectId={projectId} />
|
||||
|
||||
<Card className="h-full">
|
||||
|
|
@ -209,7 +213,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">{teamInfo.team_alias || teamInfo.team_id}</p>
|
||||
<p
|
||||
className="truncate text-base font-medium text-foreground"
|
||||
title={teamInfo.team_alias || teamInfo.team_id}
|
||||
>
|
||||
{teamInfo.team_alias || teamInfo.team_id}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>ID: {teamInfo.team_id}</span>
|
||||
<CopyButton value={teamInfo.team_id} label="Copy team ID" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { PromptSpec } from "@/components/networking";
|
||||
|
||||
import PromptTable from "./PromptTable";
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
|
|
@ -60,7 +64,7 @@ describe("PromptTable", () => {
|
|||
render(<PromptTable {...defaultProps} promptsList={prompts} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "user-1" })).toHaveAttribute("href", "/ui/users?user=user-1");
|
||||
expect(screen.getByText("default_user_id")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
import { modelHubCall, PromptSpec } from "@/components/networking";
|
||||
|
||||
import { getPromptTableColumns } from "./PromptTableColumns";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { ModelGroupInfo } from "./prompt_utils";
|
||||
|
||||
interface PromptTableProps {
|
||||
|
|
@ -65,9 +66,19 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
fetchModelHubData();
|
||||
}, [accessToken]);
|
||||
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => promptsList.map((prompt) => prompt.created_by ?? ""), [promptsList]),
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick }),
|
||||
[modelHubData, isAdmin, onPromptClick, onDeleteClick],
|
||||
() =>
|
||||
getPromptTableColumns({
|
||||
modelHubData,
|
||||
isAdmin,
|
||||
onPromptClick,
|
||||
onDeleteClick,
|
||||
resolveUserName: (userId) => displayNames?.[userId],
|
||||
}),
|
||||
[modelHubData, isAdmin, onPromptClick, onDeleteClick, displayNames],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { userDetailHref } from "@/utils/entityLinks";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
|
||||
import { extractModel, getProviderFromModelHub, ModelGroupInfo } from "./prompt_utils";
|
||||
|
||||
|
|
@ -111,6 +111,7 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
|
|||
}
|
||||
|
||||
interface PromptTableColumnsDeps {
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
modelHubData: Map<string, ModelGroupInfo>;
|
||||
isAdmin: boolean;
|
||||
onPromptClick?: (id: string, environment: string) => void;
|
||||
|
|
@ -118,6 +119,7 @@ interface PromptTableColumnsDeps {
|
|||
}
|
||||
|
||||
export const getPromptTableColumns = ({
|
||||
resolveUserName = () => undefined,
|
||||
modelHubData,
|
||||
isAdmin,
|
||||
onPromptClick,
|
||||
|
|
@ -196,12 +198,8 @@ export const getPromptTableColumns = ({
|
|||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60" title={createdBy}>
|
||||
<IdentityCell
|
||||
title={createdBy}
|
||||
titleClassName="font-normal text-muted-foreground"
|
||||
href={userDetailHref(createdBy)}
|
||||
/>
|
||||
<span className="block max-w-60">
|
||||
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import PromptInfoView from "./prompt_info";
|
||||
import * as networking from "@/components/networking";
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getPromptInfo: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { toast } from "@/lib/toast";
|
|||
import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets";
|
||||
import { extractModel, extractTemplateVariables, getBasePromptId, getCurrentVersion } from "./prompt_utils";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
export interface PromptInfoProps {
|
||||
promptId: string;
|
||||
|
|
@ -51,6 +53,12 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
const [versionHistory, setVersionHistory] = useState<PromptSpec[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
React.useMemo(
|
||||
() => [promptData?.created_by ?? "", ...versionHistory.map((v) => v.created_by ?? "")],
|
||||
[promptData, versionHistory],
|
||||
),
|
||||
);
|
||||
|
||||
// Fetches the requested environment (or the serve-time default when omitted) plus the environments list
|
||||
const fetchPromptInfo = async (environment?: string) => {
|
||||
|
|
@ -329,7 +337,16 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<Card className="block p-6">
|
||||
<p>Created By</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-sm font-medium">{promptData.created_by || "-"}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{promptData.created_by ? (
|
||||
<UserReference
|
||||
userId={promptData.created_by}
|
||||
displayName={displayNames?.[promptData.created_by]}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -379,7 +396,13 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{v.created_by || "-"}</span>
|
||||
<span className="text-sm">
|
||||
{v.created_by ? (
|
||||
<UserReference userId={v.created_by} displayName={displayNames?.[v.created_by]} />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{formatDate(v.created_at)}</span>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
screen,
|
||||
testQueryClient,
|
||||
waitFor,
|
||||
within,
|
||||
} from "../../../../../../tests/test-utils";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
|
@ -215,9 +216,10 @@ describe("UserInfoView add-to-team form", () => {
|
|||
|
||||
await user.click(teamField());
|
||||
|
||||
expect(await screen.findByTitle("Gamma Team")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Alpha Team")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Beta Team")).not.toBeInTheDocument();
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
expect(within(listbox).getByTitle("Gamma Team")).toBeInTheDocument();
|
||||
expect(within(listbox).queryByTitle("Alpha Team")).not.toBeInTheDocument();
|
||||
expect(within(listbox).queryByTitle("Beta Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the default role name on the trigger rather than a blank or raw value", async () => {
|
||||
|
|
|
|||
|
|
@ -540,8 +540,10 @@ export default function UserInfoView({
|
|||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => (
|
||||
<TableRow key={team.team_id}>
|
||||
<TableCell>
|
||||
<BadgeLink href={teamDetailHref(team.team_id)}>
|
||||
{team.team_alias || team.team_id}
|
||||
<BadgeLink href={teamDetailHref(team.team_id)} className="max-w-[16rem]">
|
||||
<span className="min-w-0 truncate" title={team.team_alias || team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</span>
|
||||
</BadgeLink>
|
||||
</TableCell>
|
||||
{isProxyAdmin && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { VectorStoreIndex } from "./IndexesTab";
|
||||
import IndexesTable from "./IndexesTable";
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", async () => ({
|
||||
...(await vi.importActual("next/navigation")),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
|
||||
import type { VectorStoreIndex } from "./IndexesTab";
|
||||
import { getIndexesTableColumns } from "./IndexesTableColumns";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
interface IndexesTableProps {
|
||||
data: VectorStoreIndex[];
|
||||
|
|
@ -38,9 +39,17 @@ const IndexesTable: React.FC<IndexesTableProps> = ({
|
|||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => data.map((index) => index.created_by ?? ""), [data]),
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }),
|
||||
[resolveVectorStoreId, onViewVectorStore],
|
||||
() =>
|
||||
getIndexesTableColumns({
|
||||
resolveVectorStoreId,
|
||||
onViewVectorStore,
|
||||
resolveUserName: (userId) => displayNames?.[userId],
|
||||
}),
|
||||
[resolveVectorStoreId, onViewVectorStore, displayNames],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
import { userDetailHref } from "@/utils/entityLinks";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
|
||||
import type { VectorStoreIndex } from "./IndexesTab";
|
||||
|
||||
interface IndexesTableColumnsDeps {
|
||||
resolveVectorStoreId: (name: string) => string | undefined;
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
onViewVectorStore: (vectorStoreId: string) => void;
|
||||
}
|
||||
|
||||
export const getIndexesTableColumns = ({
|
||||
resolveVectorStoreId,
|
||||
resolveUserName = () => undefined,
|
||||
onViewVectorStore,
|
||||
}: IndexesTableColumnsDeps): ColumnDef<VectorStoreIndex>[] => [
|
||||
{
|
||||
|
|
@ -84,12 +86,9 @@ export const getIndexesTableColumns = ({
|
|||
const createdBy = row.original.created_by;
|
||||
if (createdBy) {
|
||||
return (
|
||||
<IdentityCell
|
||||
title={createdBy}
|
||||
titleClassName="font-normal"
|
||||
className="max-w-48"
|
||||
href={userDetailHref(createdBy)}
|
||||
/>
|
||||
<span className="block max-w-48">
|
||||
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="block max-w-48 truncate text-sm">-</span>;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import { credentialListCall, indexesListCall, vectorStoreListCall } from "@/comp
|
|||
|
||||
import VectorStoreManagement from "./index";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", async () => ({
|
||||
...(await vi.importActual("next/navigation")),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
vectorStoreListCall: vi.fn(),
|
||||
vectorStoreDeleteCall: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { getMCPHubTableColumns, MCPServerData } from "./MCPHubTableColumns";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const SERVER_URL = "https://mcp.exa.ai/mcp";
|
||||
|
||||
const mockServer: MCPServerData = {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Copy, Info, MoreHorizontal } from "lucide-react";
|
|||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { MCP_REACHABLE_DESCRIPTION } from "@/components/mcp_tools/types";
|
||||
import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -88,9 +89,13 @@ function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) {
|
|||
|
||||
interface MCPHubTableColumnsDeps {
|
||||
onServerClick: (server: MCPServerData) => void;
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps): ColumnDef<MCPServerData>[] => [
|
||||
export const getMCPHubTableColumns = ({
|
||||
onServerClick,
|
||||
resolveUserName = () => undefined,
|
||||
}: MCPHubTableColumnsDeps): ColumnDef<MCPServerData>[] => [
|
||||
{
|
||||
id: "server_name",
|
||||
accessorKey: "server_name",
|
||||
|
|
@ -194,11 +199,14 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
|
|||
size: 140,
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-60 truncate text-xs" title={row.original.created_by || undefined}>
|
||||
{row.original.created_by || "-"}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.created_by ? (
|
||||
<span className="flex min-w-0 max-w-60 text-xs">
|
||||
<UserReference userId={row.original.created_by} displayName={resolveUserName(row.original.created_by)} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="block max-w-60 truncate text-xs">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "is_public",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm";
|
|||
import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm";
|
||||
import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
|
||||
import { getMCPHubTableColumns, MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { getModelHubTableColumns, ModelHubData } from "@/components/AIHub/ModelHubTableColumns";
|
||||
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
|
||||
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
|
||||
|
|
@ -402,7 +404,18 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
|
|||
() => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
|
||||
[agentHubData, agentSearchTerm],
|
||||
);
|
||||
const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]);
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(
|
||||
() => (mcpHubData ?? []).flatMap((server) => [server.created_by ?? "", server.updated_by ?? ""]),
|
||||
[mcpHubData],
|
||||
),
|
||||
);
|
||||
const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]);
|
||||
|
||||
const mcpColumns = useMemo(
|
||||
() => getMCPHubTableColumns({ onServerClick: showMcpModal, resolveUserName }),
|
||||
[showMcpModal, resolveUserName],
|
||||
);
|
||||
|
||||
// If this is a public page, use the dedicated PublicModelHub component
|
||||
if (publicPage && publicPageAllowed) {
|
||||
|
|
@ -1076,11 +1089,21 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-medium">Created By:</p>
|
||||
<p>{selectedMcpServer.created_by}</p>
|
||||
<p>
|
||||
<UserReference
|
||||
userId={selectedMcpServer.created_by}
|
||||
displayName={resolveUserName(selectedMcpServer.created_by)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Updated By:</p>
|
||||
<p>{selectedMcpServer.updated_by}</p>
|
||||
<p>
|
||||
<UserReference
|
||||
userId={selectedMcpServer.updated_by}
|
||||
displayName={resolveUserName(selectedMcpServer.updated_by)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Created At:</p>
|
||||
|
|
|
|||
|
|
@ -98,6 +98,6 @@ it("leaves the default_user_id placeholder unlinked in the deleted by cell", ()
|
|||
const team = makeDeletedTeam({ deleted_by: "default_user_id", organization_id: null });
|
||||
renderWithProviders(<DeletedTeamsTable teams={[team]} isLoading={false} rowCount={1} {...paginationProps} />);
|
||||
|
||||
expect(screen.getByText("default_user_id")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
interface DeletedTeamsTableProps {
|
||||
teams: DeletedTeam[];
|
||||
|
|
@ -40,7 +41,11 @@ export function DeletedTeamsTable({
|
|||
}: DeletedTeamsTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => teams.map((team) => team.deleted_by ?? ""), [teams]),
|
||||
);
|
||||
|
||||
const columns = useMemo(() => getDeletedTeamsTableColumns((userId) => displayNames?.[userId]), [displayNames]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { orgDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
import { orgDetailHref } from "@/utils/entityLinks";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
|
||||
function EntityCell({ value, href }: { value: string | null | undefined; href: string | undefined }) {
|
||||
if (!value) {
|
||||
|
|
@ -18,7 +19,9 @@ function EntityCell({ value, href }: { value: string | null | undefined; href: s
|
|||
);
|
||||
}
|
||||
|
||||
export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
||||
export const getDeletedTeamsTableColumns = (
|
||||
resolveUserName: (userId: string) => string | undefined = () => undefined,
|
||||
): ColumnDef<DeletedTeam>[] => [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
|
|
@ -113,7 +116,10 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const deletedBy = row.original.deleted_by;
|
||||
return <EntityCell value={deletedBy} href={deletedBy ? userDetailHref(deletedBy) : undefined} />;
|
||||
if (!deletedBy) {
|
||||
return <EntityCell value={deletedBy} href={undefined} />;
|
||||
}
|
||||
return <UserReference userId={deletedBy} displayName={resolveUserName(deletedBy)} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import moment from "moment";
|
|||
import { AuditLogDrawer } from "./AuditLogDrawer";
|
||||
import { AuditLogEntry } from "../AuditLogsTableColumns";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("../../../common_components/DefaultProxyAdminTag", () => ({
|
||||
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Check, Copy } from "lucide-react";
|
|||
import { useState, useCallback } from "react";
|
||||
import moment from "moment";
|
||||
import { AuditLogEntry, AUDIT_TABLE_NAME_DISPLAY } from "../AuditLogsTableColumns";
|
||||
import DefaultProxyAdminTag from "../../../common_components/DefaultProxyAdminTag";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,6 +13,7 @@ interface AuditLogDrawerProps {
|
|||
open: boolean;
|
||||
onClose: () => void;
|
||||
log: AuditLogEntry | null;
|
||||
userDisplayName?: string | null;
|
||||
}
|
||||
|
||||
const ACTION_TONE: Record<string, StatusTone> = {
|
||||
|
|
@ -68,7 +69,7 @@ function MetadataRow({ label, value }: { label: string; value: React.ReactNode }
|
|||
return (
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<span className="w-36 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs break-all text-foreground">{value}</span>
|
||||
<span className="min-w-0 flex-1 text-xs break-all text-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,7 +171,7 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
||||
export function AuditLogDrawer({ open, onClose, log, userDisplayName }: AuditLogDrawerProps) {
|
||||
if (!log) return null;
|
||||
|
||||
const tableDisplay = AUDIT_TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name;
|
||||
|
|
@ -200,7 +201,10 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
|||
</span>
|
||||
}
|
||||
/>
|
||||
<MetadataRow label="Changed By" value={<DefaultProxyAdminTag userId={log.changed_by} />} />
|
||||
<MetadataRow
|
||||
label="Changed By"
|
||||
value={<UserReference userId={log.changed_by} displayName={userDisplayName} />}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="API Key (Hash)"
|
||||
value={
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ vi.mock("@tanstack/react-pacer/debouncer", () => ({
|
|||
|
||||
import { uiAuditLogsCall } from "../../networking";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
type AuditLogsParams = NonNullable<Parameters<typeof uiAuditLogsCall>[0]["params"]>;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { uiAuditLogsCall } from "../../networking";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { AuditLogEntry } from "./AuditLogsTableColumns";
|
||||
import { AuditLogsTable } from "./AuditLogsTable";
|
||||
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
|
||||
|
|
@ -98,6 +99,10 @@ export default function AuditLogsPanel({
|
|||
setDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
const changedByIds = useMemo(() => (query.data?.audit_logs ?? []).map((log) => log.changed_by), [query.data]);
|
||||
const { data: displayNames } = useUserDisplayNames(changedByIds);
|
||||
const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]);
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto text-center">
|
||||
|
|
@ -129,6 +134,7 @@ export default function AuditLogsPanel({
|
|||
return (
|
||||
<>
|
||||
<AuditLogsTable
|
||||
resolveUserName={resolveUserName}
|
||||
data={query.data?.audit_logs ?? []}
|
||||
rowCount={query.data?.total ?? 0}
|
||||
isLoading={query.isLoading}
|
||||
|
|
@ -143,7 +149,12 @@ export default function AuditLogsPanel({
|
|||
onViewLog={handleViewLog}
|
||||
/>
|
||||
|
||||
<AuditLogDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} log={selectedLog} />
|
||||
<AuditLogDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
log={selectedLog}
|
||||
userDisplayName={selectedLog ? displayNames?.[selectedLog.changed_by] : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AuditLogsTable } from "./AuditLogsTable";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
import type { AuditLogEntry } from "./AuditLogsTableColumns";
|
||||
|
||||
const ROWS: AuditLogEntry[] = [
|
||||
|
|
@ -69,6 +71,15 @@ describe("AuditLogsTable", () => {
|
|||
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the resolved user name in the Changed By column when provided", () => {
|
||||
renderTable({ resolveUserName: (userId) => (userId === "user-42" ? "Ada Reviewer" : undefined) });
|
||||
|
||||
expect(screen.getByRole("link", { name: "Ada Reviewer" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("user-42"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the detail drawer from the Object ID identity cell with the full row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderTable();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface AuditLogsTableProps {
|
|||
onSearchChange?: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onViewLog: (log: AuditLogEntry) => void;
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
}
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
|
@ -110,9 +111,10 @@ export function AuditLogsTable({
|
|||
onSearchChange,
|
||||
onRefresh,
|
||||
onViewLog,
|
||||
resolveUserName,
|
||||
}: AuditLogsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog, resolveUserName }), [onViewLog, resolveUserName]);
|
||||
const hasActiveSearch = Boolean(searchValue?.trim());
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
|
||||
import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
|
||||
export type AuditLogEntry = {
|
||||
id: string;
|
||||
|
|
@ -42,9 +41,13 @@ export const auditActionLabel = (action: string): string => {
|
|||
|
||||
interface AuditLogsTableColumnsDeps {
|
||||
onViewLog: (log: AuditLogEntry) => void;
|
||||
resolveUserName?: (userId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
|
||||
export const getAuditLogsTableColumns = ({
|
||||
onViewLog,
|
||||
resolveUserName = () => undefined,
|
||||
}: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
|
|
@ -94,7 +97,9 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
header: "Changed By",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DefaultProxyAdminTag userId={row.original.changed_by} />,
|
||||
cell: ({ row }) => (
|
||||
<UserReference userId={row.original.changed_by} displayName={resolveUserName(row.original.changed_by)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "changed_by_api_key",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
|||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { uiSpendLogsCall } from "../../networking";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
|
||||
request_id: "req-1",
|
||||
api_key: "key-1",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
import type { LogEntry } from "../types";
|
||||
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import type { ColumnDef } from "@tanstack/react-table";
|
|||
import { Copy } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { EntityReference } from "@/components/shared/EntityReference";
|
||||
import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { teamDetailHref } from "@/utils/entityLinks";
|
||||
import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
import { getProviderLogoAndName } from "../../provider_info_helpers";
|
||||
|
|
@ -275,7 +277,13 @@ export const getRequestLogsTableColumns = ({
|
|||
header: "Team Name",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedText value={readMetaString(row.original.metadata, "user_api_key_team_alias")} />,
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.team_id;
|
||||
const alias = readMetaString(row.original.metadata, "user_api_key_team_alias");
|
||||
if (!teamId && !alias) return <TruncatedText value={undefined} />;
|
||||
if (!teamId) return <TruncatedText value={alias} />;
|
||||
return <EntityReference id={teamId} name={alias} href={teamDetailHref(teamId)} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "key_hash",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import ModelInfoView from "./model_info_view";
|
||||
import { toast } from "@/lib/toast";
|
||||
import * as networking from "./networking";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../tests/mocks/complexityScorerDefaults"),
|
||||
|
|
@ -1479,6 +1481,10 @@ describe("ModelInfoView", () => {
|
|||
// EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or
|
||||
// quality router lets a save write auto_router_config onto a row that stores its settings
|
||||
// elsewhere. These rows stay reachable from Health Status and direct ?model= links even
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
// though the Models table now excludes auto-routers, so the button itself has to be gated.
|
||||
describe("Edit Auto Router affordance", () => {
|
||||
const withRouter = (litellmParams: Record<string, unknown>) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {
|
|||
import { canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions";
|
||||
import { teamsUserCanAssign } from "@/utils/roles";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
|
||||
import ReuseCredentialsModal from "./model_add/reuse_credentials";
|
||||
|
|
@ -118,6 +120,9 @@ export default function ModelInfoView({
|
|||
|
||||
// Keep modelData variable name for backwards compatibility
|
||||
const modelData = transformedModelData;
|
||||
const { data: displayNames } = useUserDisplayNames(
|
||||
useMemo(() => [modelData?.model_info?.created_by ?? ""], [modelData]),
|
||||
);
|
||||
|
||||
const aliasForTeam = (teamId: string | null | undefined): string | null =>
|
||||
teams?.find((team) => team.team_id === teamId)?.team_alias || null;
|
||||
|
|
@ -703,7 +708,15 @@ export default function ModelInfoView({
|
|||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Created By {modelData.model_info.created_by || "Not Set"}
|
||||
Created By{" "}
|
||||
{modelData.model_info.created_by ? (
|
||||
<UserReference
|
||||
userId={modelData.model_info.created_by}
|
||||
displayName={displayNames?.[modelData.model_info.created_by]}
|
||||
/>
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -788,7 +801,10 @@ export default function ModelInfoView({
|
|||
},
|
||||
{
|
||||
label: "Created By",
|
||||
value: modelData?.model_info?.created_by || "Not Set",
|
||||
value:
|
||||
(modelData?.model_info?.created_by &&
|
||||
(displayNames?.[modelData.model_info.created_by] ?? modelData.model_info.created_by)) ||
|
||||
"Not Set",
|
||||
},
|
||||
]}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,34 @@ test("renders organization view after loading data", async () => {
|
|||
expect(orgName).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the creator's email when their user alias is an empty string", async () => {
|
||||
const orgWithBlankAliasCreator = {
|
||||
...mockOrg,
|
||||
created_by: "creator-user-id",
|
||||
members: [
|
||||
{
|
||||
user_id: "creator-user-id",
|
||||
user: { user_id: "creator-user-id", user_alias: "", user_email: "e@x.com" },
|
||||
},
|
||||
],
|
||||
};
|
||||
mockUseOrganization.mockReturnValue({ data: orgWithBlankAliasCreator, isLoading: false } as any);
|
||||
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={false}
|
||||
is_proxy_admin={false}
|
||||
userModels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("e@x.com")).toBeInTheDocument();
|
||||
expect(screen.queryByText("creator-user-id")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should display empty state when organization has no members", async () => {
|
||||
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
|
||||
|
||||
|
|
@ -375,6 +403,10 @@ describe("organization detail tab in the URL (?org_tab=)", () => {
|
|||
});
|
||||
|
||||
test("falls back to Overview for an unknown ?org_tab= and removes it from the URL", async () => {
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
|
||||
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
|
||||
useUserDisplayNames: () => ({ data: undefined }),
|
||||
}));
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
render(renderOrgView(), {
|
||||
wrapper: ({ children }) => (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useUrlTab } from "@/hooks/useUrlTab";
|
|||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { UserReference } from "@/components/shared/EntityReference";
|
||||
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -57,6 +59,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
const { onTabChange, hasVisited } = useVisitedTabs(tab);
|
||||
|
||||
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
|
||||
const { data: displayNames } = useUserDisplayNames(useMemo(() => [orgData?.created_by ?? ""], [orgData?.created_by]));
|
||||
|
||||
const handleTabChange = (value: OrganizationTab) => {
|
||||
setTab(value);
|
||||
|
|
@ -128,6 +131,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
}
|
||||
|
||||
const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m]));
|
||||
const createdByMember = orgData.created_by ? orgMemberById.get(orgData.created_by) : undefined;
|
||||
const createdByName =
|
||||
createdByMember?.user?.user_alias ||
|
||||
createdByMember?.user?.user_email ||
|
||||
(orgData.created_by ? displayNames?.[orgData.created_by] : undefined);
|
||||
const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined);
|
||||
|
||||
const orgExtraColumns: MemberTableColumn[] = [
|
||||
|
|
@ -152,12 +160,17 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
return (
|
||||
<div className="h-screen w-full bg-background p-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<Button variant="ghost" onClick={onClose} className="mb-4">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Organizations
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
|
||||
<h1
|
||||
className="truncate text-xl font-semibold tracking-tight text-foreground"
|
||||
title={orgData.organization_alias}
|
||||
>
|
||||
{orgData.organization_alias}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
|
||||
<CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
|
||||
|
|
@ -186,7 +199,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<div className="mt-2 text-sm text-foreground">
|
||||
<p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
|
||||
<p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
|
||||
<p>Created By: {orgData.created_by}</p>
|
||||
<p>
|
||||
Created By:{" "}
|
||||
{orgData.created_by ? (
|
||||
<UserReference userId={orgData.created_by} displayName={createdByName} />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -240,8 +260,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<p className="text-sm text-muted-foreground">Teams</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<BadgeLink key={index} href={teamDetailHref(team.team_id)}>
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
<BadgeLink key={index} href={teamDetailHref(team.team_id)} className="max-w-64">
|
||||
<span className="min-w-0 truncate" title={teamAliasMap[team.team_id] || team.team_id}>
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</span>
|
||||
</BadgeLink>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -300,7 +322,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Organization Name</p>
|
||||
<div>{orgData.organization_alias}</div>
|
||||
<div className="truncate" title={orgData.organization_alias}>
|
||||
{orgData.organization_alias}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Organization ID</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import { EntityReference, UserReference } from "./EntityReference";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
describe("EntityReference", () => {
|
||||
it("shows the name as the primary linked text when a name and href exist", () => {
|
||||
render(<EntityReference id="team-123" name="Review Team" href="/ui/teams?team=team-123" />);
|
||||
expect(screen.getByRole("link", { name: "Review Team" })).toHaveAttribute("href", "/ui/teams?team=team-123");
|
||||
expect(screen.queryByText("team-123")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full name and the raw id together in the tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
const longName = "Review Team " + "x".repeat(200);
|
||||
render(<EntityReference id="team-123" name={longName} />);
|
||||
const triggers = screen.getAllByText(longName);
|
||||
await user.hover(triggers[0]);
|
||||
const tips = await screen.findAllByText(longName);
|
||||
expect(tips.length).toBeGreaterThan(1);
|
||||
expect(await screen.findByText("team-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("caps the named trigger at the container width so long names truncate", () => {
|
||||
const { container } = render(<EntityReference id="team-123" name="Review Team" />);
|
||||
expect(container.querySelector("span")).toHaveClass("max-w-64");
|
||||
});
|
||||
|
||||
it("falls back to the raw id in mono when there is no name", () => {
|
||||
render(<EntityReference id="team-123" href="/ui/teams?team=team-123" />);
|
||||
const link = screen.getByRole("link", { name: "team-123" });
|
||||
expect(link).toHaveAttribute("href", "/ui/teams?team=team-123");
|
||||
expect(link).toHaveClass("font-mono");
|
||||
});
|
||||
|
||||
it("renders the raw id without a link when neither name nor href exist", () => {
|
||||
render(<EntityReference id="team-123" />);
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("team-123")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserReference", () => {
|
||||
it("renders nothing for a nullish user id", () => {
|
||||
const { container } = render(<UserReference userId={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows the display name linked to the user detail page", () => {
|
||||
render(<UserReference userId="user-9" displayName="Ada Reviewer" />);
|
||||
const link = screen.getByRole("link", { name: "Ada Reviewer" });
|
||||
expect(link).toHaveAttribute("href", expect.stringContaining("user-9"));
|
||||
});
|
||||
|
||||
it("keeps the default proxy admin badge when the sentinel id has no name", () => {
|
||||
render(<UserReference userId={DEFAULT_PROXY_ADMIN_USER_ID} />);
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the raw user id when no display name resolved", () => {
|
||||
render(<UserReference userId="user-9" />);
|
||||
expect(screen.getByRole("link", { name: "user-9" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { EntityLink } from "@/components/shared/EntityLink";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { userDetailHref } from "@/utils/entityLinks";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
|
||||
interface EntityReferenceProps {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function EntityReference({ id, name, href }: EntityReferenceProps) {
|
||||
if (!name) {
|
||||
return (
|
||||
<EntityLink href={href} className="max-w-64 font-mono text-xs font-normal">
|
||||
{id}
|
||||
</EntityLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="break-all font-medium">{name}</span>
|
||||
<span className="break-all font-mono text-xs">{id}</span>
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 max-w-64"
|
||||
>
|
||||
<EntityLink href={href}>{name}</EntityLink>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserReferenceProps {
|
||||
userId: string | null | undefined;
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
export function UserReference({ userId, displayName }: UserReferenceProps) {
|
||||
if (userId == null || userId === "") return null;
|
||||
if (userId === DEFAULT_PROXY_ADMIN_USER_ID && !displayName) {
|
||||
return <DefaultProxyAdminTag userId={userId} />;
|
||||
}
|
||||
return <EntityReference id={userId} name={displayName} href={userDetailHref(userId)} />;
|
||||
}
|
||||
|
|
@ -26,8 +26,9 @@ import { isProxyAdminRole } from "@/utils/roles";
|
|||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import { EntityReference } from "@/components/shared/EntityReference";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { modelGroupHref, modelOrAccessGroupHref } from "@/utils/entityLinks";
|
||||
import { modelGroupHref, modelOrAccessGroupHref, orgDetailHref } from "@/utils/entityLinks";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input as UIInput } from "@/components/ui/input";
|
||||
|
|
@ -2258,7 +2259,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium">Team Name</p>
|
||||
<div>{info.team_alias}</div>
|
||||
<div className="truncate" title={info.team_alias}>
|
||||
{info.team_alias}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Team ID</p>
|
||||
|
|
@ -2427,8 +2430,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Organization ID</p>
|
||||
<div>{info.organization_id}</div>
|
||||
<p className="font-medium">Organization</p>
|
||||
<div>
|
||||
{info.organization_id ? (
|
||||
<EntityReference
|
||||
id={info.organization_id}
|
||||
name={organization?.organization_alias}
|
||||
href={orgDetailHref(info.organization_id)}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Status</p>
|
||||
|
|
@ -2484,12 +2497,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<Button variant="ghost" onClick={onClose} className="mb-4">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to Teams
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold">{info.team_alias}</h1>
|
||||
<h1 className="truncate text-2xl font-semibold" title={info.team_alias}>
|
||||
{info.team_alias}
|
||||
</h1>
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm text-muted-foreground font-mono">{info.team_id}</p>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { Card } from "@/components/ui/card";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EntityLink } from "@/components/shared/EntityLink";
|
||||
import { modelOrAccessGroupHref, teamDetailHref } from "@/utils/entityLinks";
|
||||
import { EntityReference } from "@/components/shared/EntityReference";
|
||||
import { modelOrAccessGroupHref, orgDetailHref, teamDetailHref } from "@/utils/entityLinks";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import { KeyInfoHeader } from "./KeyInfoHeader";
|
||||
import KeySavingsTab from "./KeySavingsTab";
|
||||
|
|
@ -547,6 +547,7 @@ export default function KeyInfoView({
|
|||
const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null;
|
||||
const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || "";
|
||||
const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null;
|
||||
const keyOrgId = currentKeyData.organization_id ?? currentKeyData.org_id;
|
||||
|
||||
const hasOwnBudget = currentKeyData.max_budget !== null;
|
||||
const ownerUser = keyOwnerBudgetSource(currentKeyData, applyUserBudgetToTeamKeys);
|
||||
|
|
@ -943,9 +944,11 @@ export default function KeyInfoView({
|
|||
<p className="text-sm font-medium">Team ID</p>
|
||||
<p className="text-sm">
|
||||
{currentKeyData.team_id ? (
|
||||
<EntityLink href={teamDetailHref(currentKeyData.team_id)} className="font-normal">
|
||||
{currentKeyData.team_id}
|
||||
</EntityLink>
|
||||
<EntityReference
|
||||
id={currentKeyData.team_id}
|
||||
name={parentTeam?.team_alias}
|
||||
href={teamDetailHref(currentKeyData.team_id)}
|
||||
/>
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
|
|
@ -970,7 +973,17 @@ export default function KeyInfoView({
|
|||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Organization</p>
|
||||
<p className="text-sm">{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}</p>
|
||||
<p className="text-sm">
|
||||
{keyOrgId ? (
|
||||
<EntityReference
|
||||
id={keyOrgId}
|
||||
name={parentOrg?.organization_id === keyOrgId ? parentOrg.organization_alias : undefined}
|
||||
href={orgDetailHref(keyOrgId)}
|
||||
/>
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -24994,6 +24994,7 @@ export interface components {
|
|||
created_at: string;
|
||||
/** Created By */
|
||||
created_by?: string | null;
|
||||
created_by_user?: components["schemas"]["AccessGroupResource"] | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/**
|
||||
|
|
@ -25003,6 +25004,7 @@ export interface components {
|
|||
updated_at: string;
|
||||
/** Updated By */
|
||||
updated_by?: string | null;
|
||||
updated_by_user?: components["schemas"]["AccessGroupResource"] | null;
|
||||
};
|
||||
/** AccessGroupUpdateRequest */
|
||||
AccessGroupUpdateRequest: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue