""" UI Components Module - Reusable UI Elements Provides all user interface components for the application """ import json import pandas as pd import streamlit as st def render_header(title: str, subtitle: str = None): """ Render professional header component Args: title: Main title text subtitle: Optional subtitle text """ st.title(title) if subtitle: st.markdown(f'*{subtitle}*') st.divider() def render_metrics(metrics_dict: dict): """ Render responsive metrics display with smaller font Args: metrics_dict: Dictionary of label -> value pairs """ # Use custom styling for smaller font st.markdown( """ """, unsafe_allow_html=True, ) cols = st.columns(len(metrics_dict)) for i, (label, value) in enumerate(metrics_dict.items()): with cols[i]: st.metric(label, value) def render_filters( df: pd.DataFrame, filter_key: int = 0, tenant_metadata: dict = None ) -> tuple[pd.DataFrame, tuple[str, str] | None]: """ Render filter controls and return filtered DataFrame and selected tenant Args: df: Input DataFrame to filter filter_key: Key to reset filters on new queries tenant_metadata: Dictionary of hydrated tenant DataFrames by type Returns: Tuple of (Filtered DataFrame, selected tenant tuple or None) """ # No need to copy - we'll create a new filtered dataframe through filtering filtered_df = df selected_tenant_result = None # Tenant filter (first filter) st.subheader('Tenant') if tenant_metadata: # Build list of unique tenants with their metadata tenants_list = [{'display': 'All', 'tenant_type': None, 'tenant_id': None}] # Group events by tenant_type and tenant_id grouped = df.groupby(['tenant_type', 'tenant_id']).size().reset_index(name='event_count') for _, row in grouped.iterrows(): tenant_type = row['tenant_type'] tenant_id = str(row['tenant_id']) event_count = row['event_count'] # Skip empty IDs only if not tenant_id or tenant_id == '': continue # Handle wildcard vendor specially if tenant_id == '*': tenant_name = 'All Orchard Labels (*)' display_label = f'{tenant_type} - {tenant_name} [{event_count} events]' else: # Get resource name from metadata if available tenant_name = 'Unknown' if tenant_metadata and tenant_type in tenant_metadata: metadata_df = tenant_metadata[tenant_type] matching = metadata_df[metadata_df['tenant_id'].astype(str) == tenant_id] if not matching.empty: tenant_name = matching.iloc[0]['tenant_name'] # Build display label display_label = ( f'{tenant_type} - {tenant_name} (ID: {tenant_id}) [{event_count} events]' ) tenants_list.append( { 'display': display_label, 'tenant_type': tenant_type, 'tenant_id': tenant_id, 'tenant_name': tenant_name, 'event_count': event_count, } ) # Sort by tenant_type, then tenant_name (skip "All" which is first) tenants_list[1:] = sorted( tenants_list[1:], key=lambda x: (x['tenant_type'], x['tenant_name']) ) st.caption('đĄ Tip: Click the dropdown and start typing to search') selected_idx = st.selectbox( 'Filter by tenant', range(len(tenants_list)), format_func=lambda i: tenants_list[i]['display'], label_visibility='collapsed', key=f'tenant_selector_{filter_key}', ) if selected_idx is not None and selected_idx > 0: selected = tenants_list[selected_idx] tenant_type = selected['tenant_type'] tenant_id = selected['tenant_id'] filtered_df = filtered_df[ (filtered_df['tenant_type'] == tenant_type) & (filtered_df['tenant_id'].astype(str) == str(tenant_id)) ] selected_tenant_result = (tenant_type, tenant_id) else: st.info('No tenants available') # Profile Type filter st.subheader('Profile Type') profile_types = ['All'] + sorted(filtered_df['profile_type'].dropna().unique().tolist()) selected_profile = st.selectbox( 'Filter by profile type', profile_types, label_visibility='collapsed', key=f'profile_filter_{filter_key}', ) if selected_profile != 'All': filtered_df = filtered_df[filtered_df['profile_type'] == selected_profile] # Relationship Type filter st.subheader('Relationship Type') relationship_types = ['All'] + sorted( filtered_df['relationship_type'].dropna().unique().tolist() ) selected_relationship = st.selectbox( 'Filter by relationship type', relationship_types, label_visibility='collapsed', key=f'relationship_filter_{filter_key}', ) if selected_relationship != 'All': filtered_df = filtered_df[filtered_df['relationship_type'] == selected_relationship] # Operation filter st.subheader('Operation Type') operations = ['All'] + sorted(filtered_df['operation'].unique().tolist()) selected_operation = st.selectbox( 'Filter by operation', operations, label_visibility='collapsed', key=f'operation_filter_{filter_key}', ) if selected_operation != 'All': filtered_df = filtered_df[filtered_df['operation'] == selected_operation] # Date Range filter st.subheader('Date Range') # event_time is already datetime from data_processing.py, no need to convert # Get min and max dates from the data min_date = df['event_time'].min().date() max_date = df['event_time'].max().date() # Date range selector date_range = st.date_input( 'Select date range', value=(min_date, max_date), min_value=min_date, max_value=max_date, label_visibility='collapsed', key=f'date_filter_{filter_key}', ) # Apply date filter if isinstance(date_range, tuple) and len(date_range) == 2: start_date, end_date = date_range # Check timezone before creating comparison timestamps has_tz = ( hasattr(filtered_df['event_time'].dtype, 'tz') and filtered_df['event_time'].dtype.tz is not None ) # Convert to datetime for comparison, matching timezone of EVENT_TIME if has_tz: start_datetime = pd.Timestamp(start_date, tz='UTC') end_datetime = ( pd.Timestamp(end_date, tz='UTC') + pd.Timedelta(days=1) - pd.Timedelta(seconds=1) ) else: start_datetime = pd.Timestamp(start_date) end_datetime = pd.Timestamp(end_date) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1) filtered_df = filtered_df[ (filtered_df['event_time'] >= start_datetime) & (filtered_df['event_time'] <= end_datetime) ] return filtered_df, selected_tenant_result def get_operation_color(operation: str) -> str: """ Get color code for operation type Args: operation: Operation type (created/updated/deleted) Returns: Color hex code """ colors = { 'created': '#28a745', # green 'updated': '#007bff', # blue 'deleted': '#dc3545', # red } return colors.get(operation.lower(), '#6c757d') # gray default def render_data_table(df: pd.DataFrame) -> int | None: """ Render the main data table using st.data_editor for better performance Args: df: DataFrame to display Returns: Selected row index or None """ if df.empty: st.warning('No data to display after applying filters') return None # Select key columns for display in specified order display_columns = [ 'event_time_formatted', # Use pre-formatted time from SQL 'operation', 'profile_type', 'profile_id', 'is_deleted', # Show if profile was deleted 'relationship_type', 'tenant_type', 'tenant_id', 'changed_by', ] # Filter to columns that exist in the dataframe available_columns = [col for col in display_columns if col in df.columns] # Column slicing creates a view/copy, no need for explicit .copy() display_df = df[available_columns].reset_index(drop=True) # Rename event_time_formatted to event_time for display if 'event_time_formatted' in display_df.columns: display_df = display_df.rename(columns={'event_time_formatted': 'event_time'}) # profile_id already formatted in data_processing.py, fillna() already done there too # Use st.data_editor for faster rendering (no styling, but much faster) # Skip styling entirely - st.data_editor is optimized for large datasets st.data_editor( display_df, use_container_width=True, hide_index=False, height=400, disabled=True, # Read-only ) st.caption(f'đĄ Showing {len(display_df):,} events. Note the row number to view details below.') return None def render_details_view(df: pd.DataFrame): """ Render details view with row selection via text input Args: df: DataFrame containing event data """ if df.empty: st.info('No events to display') return # Reset index to match table display df_indexed = df.reset_index(drop=True) # Simple number input to select row st.markdown('**Enter a row number from the table above to view the CDC details:**') col1, col2 = st.columns([1, 4]) with col1: selected_row = st.number_input( 'Row #', min_value=0, max_value=len(df_indexed) - 1, value=0, step=1 ) with col2: # Add spacing to align button with input box st.markdown('
', unsafe_allow_html=True) if st.button('Lookup', type='primary', use_container_width=False): st.session_state['selected_event_row'] = selected_row # Display details for selected row if ( 'selected_event_row' in st.session_state and st.session_state['selected_event_row'] is not None ): row_idx = st.session_state['selected_event_row'] if 0 <= row_idx < len(df_indexed): event = df_indexed.iloc[row_idx] st.divider() render_event_details(event) else: max_row = len(df_indexed) - 1 st.error(f'Row {row_idx} is out of range. Please select a row between 0 and {max_row}.') def render_tenant_selector( df: pd.DataFrame, tenant_metadata: dict, filter_key: int = 0 ) -> tuple[str, str] | None: """ Render tenant selector dropdown for Tenant Event History Args: df: Events DataFrame with tenant columns tenant_metadata: Dictionary of hydrated tenant DataFrames by type filter_key: Key to reset selector on new queries Returns: Tuple of (tenant_type, tenant_id) or None if no selection """ if df.empty: st.info('No tenants available') return None # Build list of unique tenants with their metadata tenants_list = [] # Group events by tenant_type and tenant_id grouped = df.groupby(['tenant_type', 'tenant_id']).size().reset_index(name='event_count') for _, row in grouped.iterrows(): tenant_type = row['tenant_type'] tenant_id = str(row['tenant_id']) event_count = row['event_count'] # Skip empty IDs only if not tenant_id or tenant_id == '': continue # Handle wildcard vendor specially if tenant_id == '*': tenant_name = 'All Orchard Labels (*)' display_label = f'{tenant_type} - {tenant_name} [{event_count} events]' else: # Get resource name from metadata if available tenant_name = 'Unknown' if tenant_metadata and tenant_type in tenant_metadata: metadata_df = tenant_metadata[tenant_type] matching = metadata_df[metadata_df['tenant_id'].astype(str) == tenant_id] if not matching.empty: tenant_name = matching.iloc[0]['tenant_name'] # Build display label display_label = ( f'{tenant_type} - {tenant_name} (ID: {tenant_id}) [{event_count} events]' ) tenants_list.append( { 'display': display_label, 'tenant_type': tenant_type, 'tenant_id': tenant_id, 'tenant_name': tenant_name, 'event_count': event_count, } ) if not tenants_list: st.info('No valid tenants found') return None # Sort by tenant_type, then tenant_name tenants_list.sort(key=lambda x: (x['tenant_type'], x['tenant_name'])) # Create dropdown st.markdown('**Select a tenant to view its event history:**') st.caption('đĄ Tip: Click the dropdown and start typing to search') selected_idx = st.selectbox( 'Tenant', range(len(tenants_list)), format_func=lambda i: tenants_list[i]['display'], label_visibility='collapsed', key=f'tenant_selector_{filter_key}', ) if selected_idx is not None: selected = tenants_list[selected_idx] return (selected['tenant_type'], selected['tenant_id']) return None def render_tenant_event_timeline(df: pd.DataFrame, tenant_type: str, tenant_id: str): """ Render timeline of events for a specific tenant Args: df: Events DataFrame tenant_type: Type of tenant to filter tenant_id: ID of tenant to filter """ # Filter events for this resource (filtering creates a new DataFrame, no need for .copy()) filtered = df[ (df['tenant_type'] == tenant_type) & (df['tenant_id'].astype(str) == str(tenant_id)) ] if filtered.empty: st.warning(f'No events found for {tenant_type} ID: {tenant_id}') return # Sort chronologically (oldest to newest) filtered = filtered.sort_values('event_time', ascending=True) # Show relationship type breakdown (no header - shared with chart above) relationship_counts = filtered['relationship_type'].value_counts().to_dict() relationship_summary = ', '.join([f'{k}: {v}' for k, v in relationship_counts.items()]) st.caption(f'đ Relationship types: {relationship_summary}') # Display columns - use pre-formatted time from SQL if available display_columns = [ 'event_time_formatted' if 'event_time_formatted' in filtered.columns else 'event_time', 'operation', 'profile_type', 'profile_id', 'is_deleted', # Show if profile was deleted 'relationship_type', 'changed_by', ] # Column slicing creates a view/copy, no need for explicit .copy() display_df = filtered[display_columns].reset_index(drop=True) # Rename event_time_formatted to event_time for display if 'event_time_formatted' in display_df.columns: display_df = display_df.rename(columns={'event_time_formatted': 'event_time'}) else: # Fallback: Format datetime if not pre-formatted (should rarely happen) display_df['event_time'] = display_df['event_time'].dt.strftime('%Y-%m-%d %H:%M:%S') # profile_id already formatted in data_processing.py, fillna() already done there too # Apply color styling def style_operations(row): color = get_operation_color(row['operation']) return ['background-color: ' + color + '20'] * len(row) styled_df = display_df.style.apply(style_operations, axis=1) st.dataframe(styled_df, use_container_width=True, hide_index=False, height=400) st.caption(f'đĄ Showing {len(display_df)} events in chronological order (oldest to newest)') def render_event_details(event: pd.Series): """ Render detailed information for a single event Args: event: Series containing event data """ # Create tabs for organized display tab1, tab2, tab3, tab4 = st.tabs( ['đ Overview', 'đ Property Changes', 'đˇī¸ Metadata', 'đ Raw Data'] ) with tab1: col1, col2 = st.columns(2) with col1: st.markdown('**Event Information**') st.write(f'**Time:** {event["event_time"]}') st.write(f'**Operation:** `{event["operation"]}`') st.write(f'**Changed By:** {event["changed_by"]}') st.markdown('**Profile Information**') st.write(f'**Type:** {event["profile_type"]}') st.write(f'**ID:** {event["profile_id"]}') st.write(f'**UUID:** `{event["profile_uuid"]}`') # Show if profile was deleted is_deleted = event.get('is_deleted', False) if is_deleted: st.warning('â ī¸ **Profile was deleted from this identity**') with col2: st.markdown('**Target Information**') st.write(f'**Type:** {event.get("tenant_type", "N/A")}') st.write(f'**Tenant ID:** {event.get("tenant_id", "N/A")}') # Show resource name if available tenant_name = event.get('tenant_name', '') if tenant_name and tenant_name != '': st.write(f'**Tenant Name:** {tenant_name}') st.write(f'**Relationship:** `{event.get("relationship_type", "N/A")}`') # Parse and display target labels try: target_labels = event.get('target_labels') if target_labels and target_labels != '': if isinstance(target_labels, str): labels = json.loads(target_labels) else: labels = target_labels st.write(f'**Labels:** {", ".join(labels)}') except: pass # Show tenant_uuid (hydrated) if available, otherwise fall back to target_uuid tenant_uuid = event.get('tenant_uuid', '') or event.get('target_uuid', 'N/A') st.write(f'**Target UUID:** `{tenant_uuid}`') st.write(f'**Relationship ID:** `{event.get("relationship_id", "N/A")}`') with tab2: st.markdown('### Property Changes') col1, col2 = st.columns(2) with col1: st.markdown('**Before**') properties_before = event.get('properties_before') if properties_before and properties_before != '' and pd.notna(properties_before): try: if isinstance(properties_before, str): props = json.loads(properties_before) else: props = properties_before st.json(props) except: st.code(str(properties_before)) else: st.info('No previous properties (new relationship)') with col2: st.markdown('**After**') properties_after = event.get('properties_after') if properties_after and properties_after != '' and pd.notna(properties_after): try: if isinstance(properties_after, str): props = json.loads(properties_after) else: props = properties_after st.json(props) except: st.code(str(properties_after)) else: st.info('No properties (deleted relationship)') with tab3: st.markdown('### Transaction Metadata') col1, col2 = st.columns(2) with col1: st.write(f'**Transaction ID:** {event.get("transaction_id", "N/A")}') st.write(f'**Kafka Offset:** {event.get("kafka_offset", "N/A")}') st.write(f'**Kafka Partition:** {event.get("kafka_partition", "N/A")}') with col2: # Additional metadata can be added here pass with tab4: st.markdown('### Raw CDC Event Data') st.markdown('_This is the original RECORD_CONTENT from the CDC table_') # Get the raw CDC record if available raw_cdc = event.get('raw_cdc_record') if raw_cdc and raw_cdc != '' and pd.notna(raw_cdc): try: # Parse if it's a string if isinstance(raw_cdc, str): cdc_data = json.loads(raw_cdc) else: cdc_data = raw_cdc # Display with nice formatting st.json(cdc_data) # Add helpful info about the structure with st.expander('âšī¸ CDC Record Structure'): st.markdown(""" **CDC records contain:** - `meta`: Operation metadata (timestamp, username, txId, etc.) - `payload`: The actual event data - `before`: Properties before the change - `after`: Properties after the change - `start`: Start node (Profile) - `end`: End node (Vendor, Subaccount, etc.) - `id`, `label`, `type`: Relationship details - `schema`: Database schema constraints and property types """) except Exception as e: st.error(f'Error parsing raw CDC data: {str(e)}') st.code(str(raw_cdc)) else: st.warning('Raw CDC record not available for this event') st.caption('Showing flattened event data instead:') st.json(event.to_dict())