diff --git a/src/TenantTabContent.tsx b/src/TenantTabContent.tsx index 95e9be8..8ac0ed9 100644 --- a/src/TenantTabContent.tsx +++ b/src/TenantTabContent.tsx @@ -2,7 +2,13 @@ import React, { FC, useState, useEffect } from 'react'; import { Box, Typography } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; import { TenantTabTarget, UpdateTenantTabSelectionFn } from './tenantTab'; -import { fetchDatabases, fetchTables, fetchSchema } from './api'; +import { + fetchDatabases, + fetchTables, + fetchSchema, + fetchTenantDetail, + ITenantDetailResponse +} from './api'; interface ITenantTabContentProps { target: TenantTabTarget; @@ -44,7 +50,9 @@ const tableStyles = { borderBottom: '1px solid var(--jp-border-color2)', fontWeight: 600, fontSize: '12px', - color: 'var(--jp-ui-font-color0)' + color: 'var(--jp-ui-font-color2)', + textTransform: 'uppercase' as const, + letterSpacing: '0.5px' }, header: { display: 'grid', @@ -52,7 +60,9 @@ const tableStyles = { borderBottom: '1px solid var(--jp-border-color2)', fontWeight: 600, fontSize: '12px', - color: 'var(--jp-ui-font-color0)' + color: 'var(--jp-ui-font-color2)', + textTransform: 'uppercase' as const, + letterSpacing: '0.5px' }, headerCell: { padding: '8px 12px', @@ -103,6 +113,7 @@ const tableStyles = { height: '18px', px: 0.75, ml: 0.75, + my: -0.25, borderRadius: 'var(--jp-border-radius)', border: '1px solid var(--jp-border-color2)', bgcolor: 'var(--jp-layout-color1)', @@ -112,6 +123,224 @@ const tableStyles = { } }; +/** Tenant information panel */ +const TenantInfo: FC<{ + detail: ITenantDetailResponse | undefined; + isLoading: boolean; + error: Error | null; +}> = ({ detail, isLoading, error }) => { + if (isLoading) { + return ( + + Loading tenant information... + + ); + } + if (error) { + return ( + + Error loading tenant info: {error.message} + + ); + } + if (!detail) { + return null; + } + + const { metadata, stewards, members } = detail; + + const infoCardStyle = { + flex: 1, + minWidth: 0, + border: '1px solid var(--jp-border-color2)', + borderRadius: '4px', + overflow: 'hidden' + }; + + const infoCardHeader = { + padding: '8px 12px', + backgroundColor: 'var(--jp-layout-color2)', + borderBottom: '1px solid var(--jp-border-color2)', + fontWeight: 600, + fontSize: '12px', + color: 'var(--jp-ui-font-color2)', + textTransform: 'uppercase' as const, + letterSpacing: '0.5px' + }; + + const infoCardBody = { + padding: '8px 12px', + fontSize: '12px', + color: 'var(--jp-ui-font-color0)' + }; + + return ( + + {/* Tenant Info */} + + Tenant Info + + + {metadata.display_name || metadata.tenant_name} + + {metadata.display_name && + metadata.display_name !== metadata.tenant_name && ( + + {metadata.tenant_name} + + )} + {metadata.organization && ( + + {metadata.organization} + + )} + {metadata.website && ( + + + {metadata.website} + + + )} + + + + {/* Description */} + + Description + + {metadata.description || ( + + No description + + )} + + + + {/* Data Steward(s) */} + + + Data Steward{stewards.length !== 1 ? 's' : ''} + + + {stewards.length === 0 && ( + + None assigned + + )} + {stewards.map(steward => ( + + {steward.display_name} + {steward.email && ( + + {steward.email} + + )} + + ))} + + + + {/* Members */} + + + Members + + {detail.member_count} + + + + {members.map(member => ( + + + {member.display_name} ({member.username}) + + ))} + + + + ); +}; + /** Databases list component */ const DatabasesList: FC<{ databases: string[]; @@ -388,6 +617,12 @@ export const TenantTabContent: FC = ({ }); }, [onSelectionChange, selectedDatabase, selectedTable, target.tenant]); + const tenantDetailQuery = useQuery({ + queryKey: ['tenantDetail', target.tenant], + queryFn: () => fetchTenantDetail(target.tenant!), + enabled: !!target.tenant + }); + const databasesQuery = useQuery({ queryKey: ['databases', target.tenant], queryFn: () => fetchDatabases(target.tenant) @@ -467,6 +702,19 @@ export const TenantTabContent: FC = ({ gap: 2 }} > + {/* Tenant Information */} + {target.tenant && ( + + )} + {/* Top section: Databases and Tables */} {/* Databases */} diff --git a/src/api.ts b/src/api.ts index cbf6424..24dc8c1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -17,6 +17,53 @@ export interface IGroupsResponse { group_count: number; } +/** Tenant metadata */ +export interface ITenantMetadata { + tenant_name: string; + display_name: string; + description: string | null; + website: string | null; + organization: string | null; + created_by: string; + created_at: string; + updated_at: string; + updated_by: string; +} + +/** Tenant steward */ +export interface ITenantSteward { + username: string; + display_name: string; + email: string | null; + assigned_by: string; + assigned_at: string; +} + +/** Tenant member */ +export interface ITenantMember { + username: string; + display_name: string; + email: string | null; + access_level: 'read_write' | 'read_only'; + is_steward: boolean; +} + +/** Tenant storage paths */ +export interface ITenantStoragePaths { + general_warehouse: string | null; + sql_warehouse: string | null; + namespace_prefix: string | null; +} + +/** Full tenant detail response */ +export interface ITenantDetailResponse { + metadata: ITenantMetadata; + stewards: ITenantSteward[]; + members: ITenantMember[]; + member_count: number; + storage_paths: ITenantStoragePaths; +} + /** Error response from server */ interface IErrorResponse { error: string; @@ -96,3 +143,14 @@ export async function fetchSchema( const params = new URLSearchParams({ database, table }); return serverGet('schema', params); } + +/** + * Fetch tenant detail including metadata, stewards, and members. + * @param tenant - Tenant name + */ +export async function fetchTenantDetail( + tenant: string +): Promise { + const params = new URLSearchParams({ tenant }); + return serverGet('tenant-detail', params); +} diff --git a/tenant_data_browser/cdm_methods.py b/tenant_data_browser/cdm_methods.py index 2efcbe2..46d913d 100644 --- a/tenant_data_browser/cdm_methods.py +++ b/tenant_data_browser/cdm_methods.py @@ -63,4 +63,32 @@ def get_namespace_prefix(tenant=None, return_json=False): from .mock_definitions import get_table_schema, get_databases, get_tables, get_my_groups, get_namespace_prefix - return get_table_schema, get_databases, get_tables, get_my_groups, get_namespace_prefix, True \ No newline at end of file + return get_table_schema, get_databases, get_tables, get_my_groups, get_namespace_prefix, True + + +def get_tenant_detail_method(): + """ + Returns a get_tenant_detail function. + Tries to use the berdl_notebook_utils wrapper, falls back to mocks. + """ + try: + from berdl_notebook_utils.minio_governance.tenant_management import ( + get_tenant_detail as _berdl_get_tenant_detail, + ) + logger.info("Using berdl_notebook_utils for tenant details") + + def get_tenant_detail(tenant_name, return_json=False): + import json + response = _berdl_get_tenant_detail(tenant_name) + result = response.to_dict() + if return_json: + return json.dumps(result) + return result + + return get_tenant_detail + except Exception as e: + logger.warning("berdl_notebook_utils import failed for tenant details: %s", e) + logger.info("Using mock get_tenant_detail") + + from .mock_definitions import get_tenant_detail + return get_tenant_detail diff --git a/tenant_data_browser/handlers.py b/tenant_data_browser/handlers.py index 191acaa..186d63a 100644 --- a/tenant_data_browser/handlers.py +++ b/tenant_data_browser/handlers.py @@ -14,7 +14,7 @@ from jupyter_server.utils import url_path_join import tornado.web -from .cdm_methods import get_cdm_methods +from .cdm_methods import get_cdm_methods, get_tenant_detail_method logger = logging.getLogger(__name__) @@ -28,6 +28,8 @@ using_mocks, ) = get_cdm_methods() +get_tenant_detail = get_tenant_detail_method() + class BaseHandler(APIHandler): """Base handler with common utilities.""" @@ -168,6 +170,32 @@ async def get(self) -> None: self.write_error_json(str(e), status=500) +class TenantDetailHandler(BaseHandler): + """Handler for fetching tenant detail information.""" + + @tornado.web.authenticated + async def get(self) -> None: + """ + GET /api/tenant-data-browser/tenant-detail?tenant= + + Returns tenant metadata, stewards, members, and storage paths. + """ + try: + tenant = self.get_argument("tenant") + except tornado.web.MissingArgumentError: + self.write_error_json("tenant query parameter is required", status=400) + return + + try: + result = await self.run_sync( + get_tenant_detail, tenant, return_json=False + ) + self.write_json(result) + except Exception as e: + logger.exception("Error fetching tenant detail for %s", tenant) + self.write_error_json(str(e), status=500) + + def setup_handlers(web_app: Any) -> None: """Register handlers with the Jupyter server.""" host_pattern = ".*$" @@ -179,6 +207,7 @@ def setup_handlers(web_app: Any) -> None: (url_path_join(base_path, "databases"), DatabasesHandler), (url_path_join(base_path, "tables"), TablesHandler), (url_path_join(base_path, "schema"), SchemaHandler), + (url_path_join(base_path, "tenant-detail"), TenantDetailHandler), ] web_app.add_handlers(host_pattern, handlers) diff --git a/tenant_data_browser/mock_data.py b/tenant_data_browser/mock_data.py index b93e5cd..0d53472 100644 --- a/tenant_data_browser/mock_data.py +++ b/tenant_data_browser/mock_data.py @@ -153,3 +153,228 @@ "created_at", "updated_at", ] + +# Mock tenant detail responses keyed by tenant name +MOCK_TENANT_DETAILS = { + "kbase": { + "metadata": { + "tenant_name": "kbase", + "display_name": "KBase Core", + "description": "Core KBase data including CDM tables, vocabularies, and genomics reference data.", + "website": "https://www.kbase.us", + "organization": "KBase", + "created_by": "admin", + "created_at": "2025-06-15T10:00:00", + "updated_at": "2026-01-20T14:30:00", + "updated_by": "admin", + }, + "stewards": [ + { + "username": "admin", + "display_name": "KBase Admin", + "email": "admin@kbase.us", + "assigned_by": "admin", + "assigned_at": "2025-06-15T10:00:00", + }, + ], + "members": [ + { + "username": "admin", + "display_name": "KBase Admin", + "email": "admin@kbase.us", + "access_level": "read_write", + "is_steward": True, + }, + { + "username": MOCK_USERNAME, + "display_name": "Mock User", + "email": "mock_user@kbase.us", + "access_level": "read_only", + "is_steward": False, + }, + ], + "member_count": 2, + "storage_paths": { + "general_warehouse": "s3a://cdm-lake/tenant-general-warehouse/kbase/", + "sql_warehouse": "s3a://cdm-lake/tenant-sql-warehouse/kbase/", + "namespace_prefix": "kbase_", + }, + }, + "globalusers": { + "metadata": { + "tenant_name": "globalusers", + "display_name": "Global Users", + "description": "Shared datasets and reference genomes accessible to all KBase users.", + "website": None, + "organization": "KBase", + "created_by": "admin", + "created_at": "2025-07-01T08:00:00", + "updated_at": "2026-02-10T11:00:00", + "updated_by": "admin", + }, + "stewards": [ + { + "username": "admin", + "display_name": "KBase Admin", + "email": "admin@kbase.us", + "assigned_by": "admin", + "assigned_at": "2025-07-01T08:00:00", + }, + ], + "members": [ + { + "username": "admin", + "display_name": "KBase Admin", + "email": "admin@kbase.us", + "access_level": "read_write", + "is_steward": True, + }, + { + "username": MOCK_USERNAME, + "display_name": "Mock User", + "email": "mock_user@kbase.us", + "access_level": "read_write", + "is_steward": False, + }, + { + "username": "researcher1", + "display_name": "Alice Researcher", + "email": "alice@lbl.gov", + "access_level": "read_only", + "is_steward": False, + }, + { + "username": "bchen", + "display_name": "Bob Chen", + "email": "bchen@lbl.gov", + "access_level": "read_write", + "is_steward": False, + }, + { + "username": "cpark", + "display_name": "Clara Park", + "email": "cpark@lbl.gov", + "access_level": "read_only", + "is_steward": False, + }, + { + "username": "dkim", + "display_name": "David Kim", + "email": "dkim@lbl.gov", + "access_level": "read_write", + "is_steward": False, + }, + { + "username": "ewang", + "display_name": "Emily Wang", + "email": "ewang@lbl.gov", + "access_level": "read_only", + "is_steward": False, + }, + { + "username": "fjones", + "display_name": "Frank Jones", + "email": "fjones@lbl.gov", + "access_level": "read_write", + "is_steward": False, + }, + { + "username": "glee", + "display_name": "Grace Lee", + "email": "glee@lbl.gov", + "access_level": "read_only", + "is_steward": False, + }, + { + "username": "hsingh", + "display_name": "Hassan Singh", + "email": "hsingh@lbl.gov", + "access_level": "read_write", + "is_steward": False, + }, + {"username": "user10", "display_name": "Irene Taylor", "email": None, "access_level": "read_only", "is_steward": False}, + {"username": "user11", "display_name": "Jake Martinez", "email": None, "access_level": "read_write", "is_steward": False}, + {"username": "user12", "display_name": "Karen Wu", "email": None, "access_level": "read_only", "is_steward": False}, + {"username": "user13", "display_name": "Leo Brown", "email": None, "access_level": "read_write", "is_steward": False}, + {"username": "user14", "display_name": "Maria Garcia", "email": None, "access_level": "read_only", "is_steward": False}, + {"username": "user15", "display_name": "Nate Wilson", "email": None, "access_level": "read_write", "is_steward": False}, + {"username": "user16", "display_name": "Olivia Davis", "email": None, "access_level": "read_only", "is_steward": False}, + {"username": "user17", "display_name": "Pat Thompson", "email": None, "access_level": "read_write", "is_steward": False}, + {"username": "user18", "display_name": "Quinn Anderson", "email": None, "access_level": "read_only", "is_steward": False}, + {"username": "user19", "display_name": "Rosa Hernandez", "email": None, "access_level": "read_write", "is_steward": False}, + ], + "member_count": 20, + "storage_paths": { + "general_warehouse": "s3a://cdm-lake/tenant-general-warehouse/globalusers/", + "sql_warehouse": "s3a://cdm-lake/tenant-sql-warehouse/globalusers/", + "namespace_prefix": "globalusers_", + }, + }, + "demo": { + "metadata": { + "tenant_name": "demo", + "display_name": "Demo Environment", + "description": "Demonstration datasets for clinical trials, imaging, and laboratory data.", + "website": None, + "organization": "KBase", + "created_by": "admin", + "created_at": "2025-09-01T12:00:00", + "updated_at": "2026-03-01T09:00:00", + "updated_by": MOCK_USERNAME, + }, + "stewards": [ + { + "username": MOCK_USERNAME, + "display_name": "Mock User", + "email": "mock_user@kbase.us", + "assigned_by": "admin", + "assigned_at": "2026-01-15T10:00:00", + }, + ], + "members": [ + { + "username": MOCK_USERNAME, + "display_name": "Mock User", + "email": "mock_user@kbase.us", + "access_level": "read_write", + "is_steward": True, + }, + { + "username": "researcher1", + "display_name": "Alice Researcher", + "email": "alice@lbl.gov", + "access_level": "read_write", + "is_steward": False, + }, + ], + "member_count": 2, + "storage_paths": { + "general_warehouse": "s3a://cdm-lake/tenant-general-warehouse/demo/", + "sql_warehouse": "s3a://cdm-lake/tenant-sql-warehouse/demo/", + "namespace_prefix": "demo_", + }, + }, +} + +# Default tenant detail for unknown tenants +MOCK_DEFAULT_TENANT_DETAIL = { + "metadata": { + "tenant_name": "unknown", + "display_name": "Unknown Tenant", + "description": None, + "website": None, + "organization": None, + "created_by": "admin", + "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00", + "updated_by": "admin", + }, + "stewards": [], + "members": [], + "member_count": 0, + "storage_paths": { + "general_warehouse": None, + "sql_warehouse": None, + "namespace_prefix": None, + }, +} diff --git a/tenant_data_browser/mock_definitions.py b/tenant_data_browser/mock_definitions.py index 64d9677..0e23030 100644 --- a/tenant_data_browser/mock_definitions.py +++ b/tenant_data_browser/mock_definitions.py @@ -18,6 +18,8 @@ MOCK_DEFAULT_COLUMNS, MOCK_NAMESPACE_PREFIXES, MOCK_USERNAME, + MOCK_TENANT_DETAILS, + MOCK_DEFAULT_TENANT_DETAIL, ) @@ -102,6 +104,32 @@ def get_tables(database, use_hms=True, return_json=True): return tables +def get_tenant_detail(tenant_name, return_json=False): + """ + Mock function that returns tenant detail information. + + Args: + tenant_name (str): Name of the tenant + return_json (bool): Whether to return JSON string instead of dict + + Returns: + dict or str: Tenant detail response + """ + time.sleep(MOCK_DELAY) + detail = MOCK_TENANT_DETAILS.get(tenant_name) + if detail is None: + detail = {**MOCK_DEFAULT_TENANT_DETAIL} + detail["metadata"] = { + **MOCK_DEFAULT_TENANT_DETAIL["metadata"], + "tenant_name": tenant_name, + "display_name": tenant_name.replace("_", " ").title(), + } + + if return_json: + return json.dumps(detail) + return detail + + def get_namespace_prefix(tenant=None, return_json=False): """ Mock function that returns namespace prefix for a tenant.