From a7c05022dd96fda0b7447a4e5805658dadb68763 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 09:54:31 -0400 Subject: [PATCH 1/3] Add V2 Control Center dashboard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../route_backend_control_center.py | 629 +++++++++++++ .../controlCenter/DashboardSection.tsx | 834 ++++++++++++++++++ .../v2_ui/src/pages/ControlCenterPage.tsx | 5 +- .../explanation/features/V2_CONTROL_CENTER.md | 34 +- docs/explanation/release_notes.md | 10 + docs/guides/v2-control-center.md | 12 +- .../test_route_blueprint_policy_inventory.py | 5 +- functional_tests/test_v2_api_security.py | 18 +- .../test_v2_control_center_dashboard.py | 233 +++++ ui_tests/test_v2_control_center_dashboard.py | 268 ++++++ 11 files changed, 2038 insertions(+), 12 deletions(-) create mode 100644 application/v2_ui/src/components/controlCenter/DashboardSection.tsx create mode 100644 functional_tests/test_v2_control_center_dashboard.py create mode 100644 ui_tests/test_v2_control_center_dashboard.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 7378b2436..0b2eb9674 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.278" +VERSION = "0.261.279" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index ebba74e75..3fdae93d6 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -48,12 +48,16 @@ from datetime import datetime, timedelta, timezone import json from functions_debug import debug_print +from functions_appinsights import log_event ACTIVITY_LOGS_DEFAULT_PER_PAGE = 50 ACTIVITY_LOGS_MAX_PER_PAGE = 200 CONTROL_CENTER_MANAGEMENT_DEFAULT_PER_PAGE = 25 CONTROL_CENTER_MANAGEMENT_MAX_PER_PAGE = 250 +CONTROL_CENTER_DASHBOARD_CACHE_TTL_SECONDS = 90 +CONTROL_CENTER_DASHBOARD_CACHE_MAX_ENTRIES = 128 +_control_center_dashboard_cache = {} # The answers an approved ownership change gives when the group's current copy no # longer matches the request. Each is stored on the approval as its failure reason. @@ -107,6 +111,241 @@ def __init__(self, answer): self.answer = answer +def _dashboard_cache_get(key, force_refresh=False, now=None): + """Return an unexpired dashboard cache entry, if one is available.""" + if force_refresh: + return None + current_time = time.monotonic() if now is None else now + entry = _control_center_dashboard_cache.get(key) + if not entry: + return None + expires_at, payload = entry + if expires_at <= current_time: + _control_center_dashboard_cache.pop(key, None) + return None + return payload + + +def _dashboard_cache_set(key, payload, now=None): + """Store a short-lived dashboard response and bound cache growth.""" + current_time = time.monotonic() if now is None else now + expired_keys = [ + cache_key + for cache_key, (expires_at, _) in _control_center_dashboard_cache.items() + if expires_at <= current_time + ] + for cache_key in expired_keys: + _control_center_dashboard_cache.pop(cache_key, None) + if len(_control_center_dashboard_cache) >= CONTROL_CENTER_DASHBOARD_CACHE_MAX_ENTRIES: + _control_center_dashboard_cache.pop(next(iter(_control_center_dashboard_cache))) + _control_center_dashboard_cache[key] = ( + current_time + CONTROL_CENTER_DASHBOARD_CACHE_TTL_SECONDS, + payload, + ) + + +def _dashboard_metric(current_value, previous_value=None): + """Format a current-period value with a comparable prior-period delta.""" + current_value = int(current_value or 0) + if previous_value is None: + return {"value": current_value, "delta": None, "percent_change": None} + previous_value = int(previous_value or 0) + delta = current_value - previous_value + percent_change = ( + round((delta / previous_value) * 100, 1) + if previous_value + else (0.0 if delta == 0 else None) + ) + return { + "value": current_value, + "delta": delta, + "previous": previous_value, + "percent_change": percent_change, + } + + +def _dashboard_status_counts(total, grouped_statuses, unknown_status='active'): + """Normalize stored status counts while preserving each workspace type's default.""" + counts = { + "active": 0, + "locked": 0, + "upload_disabled": 0, + "inactive": 0, + } + for row in grouped_statuses: + raw_status = row.get("status") + status = str(raw_status or "active").strip().lower() + count = int(row.get("count") or 0) + if status in {"locked", "upload_disabled", "inactive"}: + counts[status] += count + elif status != "active" and raw_status and unknown_status == 'inactive': + counts["inactive"] += count + counts["active"] = max( + int(total or 0) - counts["locked"] - counts["upload_disabled"] - counts["inactive"], + 0, + ) + return counts + + +def _dashboard_parse_period(args): + """Resolve the requested day preset or custom UTC calendar range.""" + custom_start = args.get("start_date") + custom_end = args.get("end_date") + if custom_start or custom_end: + if not custom_start or not custom_end: + raise ValueError("Both start_date and end_date are required.") + try: + start_day = datetime.strptime(custom_start, "%Y-%m-%d").date() + end_day = datetime.strptime(custom_end, "%Y-%m-%d").date() + except (TypeError, ValueError) as ex: + raise ValueError("Dates must use YYYY-MM-DD format.") from ex + if end_day < start_day: + raise ValueError("end_date must be on or after start_date.") + days = (end_day - start_day).days + 1 + if days > 366: + raise ValueError("Custom ranges may not exceed 366 days.") + else: + try: + days = int(args.get("days", 30)) + except (TypeError, ValueError) as ex: + raise ValueError("days must be 7, 30, or 90.") from ex + if days not in {7, 30, 90}: + raise ValueError("days must be 7, 30, or 90.") + end_day = datetime.utcnow().date() + start_day = end_day - timedelta(days=days - 1) + + start_date = datetime.combine(start_day, datetime.min.time()) + end_date = datetime.combine(end_day, datetime.max.time()) + period_days = (end_day - start_day).days + 1 + previous_end = start_date - timedelta(microseconds=1) + previous_start = previous_end - timedelta(days=period_days) + timedelta(microseconds=1) + return start_date, end_date, previous_start, previous_end, period_days + + +def _dashboard_query_count(container, query, parameters=None): + """Execute a cross-partition aggregate count.""" + rows = list(container.query_items( + query=query, + parameters=parameters or [], + enable_cross_partition_query=True, + )) + return int(rows[0] or 0) if rows else 0 + + +def _dashboard_count_active_users(start_date, end_date): + """Count distinct users with recorded login activity in a UTC interval.""" + return _dashboard_query_count( + cosmos_activity_logs_container, + """ + SELECT VALUE COUNT(1) FROM ( + SELECT DISTINCT c.user_id FROM c + WHERE c.activity_type = 'user_login' + AND IS_DEFINED(c.user_id) + AND c.timestamp >= @start_date + AND c.timestamp <= @end_date + ) + """, + [ + {"name": "@start_date", "value": start_date.isoformat()}, + {"name": "@end_date", "value": end_date.isoformat()}, + ], + ) + + +def _dashboard_activity_count(activity_type, start_date, end_date): + """Count recorded creation events in a UTC interval.""" + return _dashboard_query_count( + cosmos_activity_logs_container, + """ + SELECT VALUE COUNT(1) FROM c + WHERE c.activity_type = @activity_type + AND ( + (c.timestamp >= @start_date AND c.timestamp <= @end_date) + OR (c.created_at >= @start_date AND c.created_at <= @end_date) + ) + """, + [ + {"name": "@activity_type", "value": activity_type}, + {"name": "@start_date", "value": start_date.isoformat()}, + {"name": "@end_date", "value": end_date.isoformat()}, + ], + ) + + +def _dashboard_document_upload_counts(start_date, end_date): + """Aggregate document-creation activity by the recorded workspace type.""" + rows = list(cosmos_activity_logs_container.query_items( + query=""" + SELECT c.workspace_type AS workspace_type, COUNT(1) AS count FROM c + WHERE c.activity_type = 'document_creation' + AND ( + (c.timestamp >= @start_date AND c.timestamp <= @end_date) + OR (c.created_at >= @start_date AND c.created_at <= @end_date) + ) + GROUP BY c.workspace_type + """, + parameters=[ + {"name": "@start_date", "value": start_date.isoformat()}, + {"name": "@end_date", "value": end_date.isoformat()}, + ], + enable_cross_partition_query=True, + )) + counts = {"personal": 0, "group": 0, "public": 0} + for row in rows: + workspace_type = row.get("workspace_type") or "personal" + if workspace_type not in counts: + workspace_type = "personal" + counts[workspace_type] += int(row.get("count") or 0) + return counts + + +def _dashboard_token_total(start_date, end_date, token_filters): + """Aggregate token usage without returning individual activity records.""" + where_clause, parameters = build_token_usage_query_context( + start_date, + end_date, + token_filters=token_filters, + ) + rows = list(cosmos_activity_logs_container.query_items( + query=f"SELECT VALUE SUM(c.usage.total_tokens) FROM c WHERE {where_clause}", + parameters=parameters, + enable_cross_partition_query=True, + )) + return int(rows[0] or 0) if rows else 0 + + +def _dashboard_document_failure_count(container, start_date, end_date): + """Count stored document processing states that explicitly report failure.""" + return _dashboard_query_count( + container, + """ + SELECT VALUE COUNT(1) FROM c + WHERE IS_STRING(c.status) + AND (CONTAINS(LOWER(c.status), 'failed') OR CONTAINS(LOWER(c.status), 'error')) + AND ( + (c.upload_date >= @start_date AND c.upload_date <= @end_date) + OR (c.created_at >= @start_date AND c.created_at <= @end_date) + ) + """, + [ + {"name": "@start_date", "value": start_date.isoformat()}, + {"name": "@end_date", "value": end_date.isoformat()}, + ], + ) + + +def _dashboard_document_failures(start_date, end_date): + """Count failed processing records across personal, group, and public workspaces.""" + return sum( + _dashboard_document_failure_count(container, start_date, end_date) + for container in ( + cosmos_user_documents_container, + cosmos_group_documents_container, + cosmos_public_documents_container, + ) + ) + + def parse_control_center_management_pagination(request_args): """Parse shared Control Center management pagination query parameters.""" try: @@ -5397,6 +5636,396 @@ def api_delete_public_workspace_admin(workspace_id): debug_print(f"Error creating workspace deletion request: {e}") return jsonify({'error': str(e)}), 500 + @bp.route('/api/v2/control-center/dashboard/summary', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('dashboard') + def api_v2_control_center_dashboard_summary(): + """Return cached aggregate dashboard metrics for the requested UTC period.""" + try: + start_date, end_date, previous_start, previous_end, period_days = ( + _dashboard_parse_period(request.args) + ) + except ValueError as ex: + return jsonify({'error': str(ex)}), 400 + + token_filters = extract_token_filters(request.args) + cache_key = ( + start_date.isoformat(), + end_date.isoformat(), + json.dumps(token_filters, sort_keys=True), + ) + force_refresh = request.args.get('force_refresh', '').strip().lower() in { + '1', 'true', 'yes' + } + cached = _dashboard_cache_get(cache_key, force_refresh=force_refresh) + if cached is not None: + return jsonify({**cached, 'cached': True}) + + try: + total_users = _dashboard_query_count( + cosmos_user_settings_container, + 'SELECT VALUE COUNT(1) FROM c', + ) + blocked_users = _dashboard_query_count( + cosmos_user_settings_container, + """ + SELECT VALUE COUNT(1) FROM c + WHERE c.settings.access.status = 'deny' + """, + ) + + groups_total = _dashboard_query_count( + cosmos_groups_container, + 'SELECT VALUE COUNT(1) FROM c', + ) + group_statuses = list(cosmos_groups_container.query_items( + query=""" + SELECT c.status AS status, COUNT(1) AS count FROM c + WHERE IS_DEFINED(c.status) + GROUP BY c.status + """, + enable_cross_partition_query=True, + )) + groups_by_status = _dashboard_status_counts(groups_total, group_statuses) + + workspaces_total = _dashboard_query_count( + cosmos_public_workspaces_container, + 'SELECT VALUE COUNT(1) FROM c', + ) + workspace_statuses = list(cosmos_public_workspaces_container.query_items( + query=""" + SELECT c.status AS status, COUNT(1) AS count FROM c + WHERE IS_DEFINED(c.status) + GROUP BY c.status + """, + enable_cross_partition_query=True, + )) + workspaces_by_status = _dashboard_status_counts( + workspaces_total, + workspace_statuses, + unknown_status='inactive', + ) + + active_users = _dashboard_count_active_users(start_date, end_date) + previous_active_users = _dashboard_count_active_users( + previous_start, + previous_end, + ) + current_dau = _dashboard_count_active_users( + datetime.combine(end_date.date(), datetime.min.time()), + end_date, + ) + current_wau = _dashboard_count_active_users( + datetime.combine(end_date.date() - timedelta(days=6), datetime.min.time()), + end_date, + ) + current_mau = _dashboard_count_active_users( + datetime.combine(end_date.date() - timedelta(days=29), datetime.min.time()), + end_date, + ) + + conversations = _dashboard_activity_count( + 'conversation_creation', + start_date, + end_date, + ) + previous_conversations = _dashboard_activity_count( + 'conversation_creation', + previous_start, + previous_end, + ) + document_uploads = _dashboard_document_upload_counts(start_date, end_date) + previous_document_uploads = _dashboard_document_upload_counts( + previous_start, + previous_end, + ) + current_token_total = _dashboard_token_total( + start_date, + end_date, + token_filters, + ) + previous_token_total = _dashboard_token_total( + previous_start, + previous_end, + token_filters, + ) + + try: + processing_failures = _dashboard_document_failures(start_date, end_date) + previous_processing_failures = _dashboard_document_failures( + previous_start, + previous_end, + ) + failure_metric = { + **_dashboard_metric( + processing_failures, + previous_processing_failures, + ), + 'available': True, + } + except Exception as ex: + log_event( + message='[CONTROL_CENTER] Document failure metrics are unavailable.', + extra={'error_type': type(ex).__name__}, + level=logging.WARNING, + ) + failure_metric = { + 'value': None, + 'delta': None, + 'percent_change': None, + 'available': False, + } + + try: + pending_approvals = _dashboard_query_count( + cosmos_approvals_container, + """ + SELECT VALUE COUNT(1) FROM c + WHERE c.status = 'pending' + """, + ) + except Exception as ex: + log_event( + message='[CONTROL_CENTER] Pending approval count is unavailable.', + extra={'error_type': type(ex).__name__}, + level=logging.WARNING, + ) + pending_approvals = None + + total_document_uploads = sum(document_uploads.values()) + previous_total_document_uploads = sum(previous_document_uploads.values()) + response_data = { + 'period': { + 'start_date': start_date.date().isoformat(), + 'end_date': end_date.date().isoformat(), + 'days': period_days, + 'timezone': 'UTC', + }, + 'refreshed_at': datetime.utcnow().isoformat() + 'Z', + 'users': { + 'total': _dashboard_metric(total_users), + 'active': _dashboard_metric(active_users, previous_active_users), + 'dau': _dashboard_metric(current_dau), + 'wau': _dashboard_metric(current_wau), + 'mau': _dashboard_metric(current_mau), + 'blocked': _dashboard_metric(blocked_users), + }, + 'groups': { + 'total': _dashboard_metric(groups_total), + 'by_status': { + status: _dashboard_metric(count) + for status, count in groups_by_status.items() + }, + }, + 'public_workspaces': { + 'total': _dashboard_metric(workspaces_total), + 'by_status': { + status: _dashboard_metric(count) + for status, count in workspaces_by_status.items() + }, + }, + 'conversations': _dashboard_metric( + conversations, + previous_conversations, + ), + 'document_uploads': { + 'total': _dashboard_metric( + total_document_uploads, + previous_total_document_uploads, + ), + 'by_workspace_type': { + workspace_type: _dashboard_metric( + count, + previous_document_uploads.get(workspace_type, 0), + ) + for workspace_type, count in document_uploads.items() + }, + }, + 'document_processing_failures': failure_metric, + 'tokens': _dashboard_metric(current_token_total, previous_token_total), + 'pending_approvals': ( + _dashboard_metric(pending_approvals) + if pending_approvals is not None else None + ), + 'status_history_available': False, + } + _dashboard_cache_set(cache_key, response_data) + return jsonify({**response_data, 'cached': False}) + except Exception as ex: + log_event( + message='[CONTROL_CENTER] Dashboard summary query failed.', + extra={'error_type': type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({'error': 'Failed to retrieve dashboard summary.'}), 500 + + @bp.route('/api/v2/control-center/dashboard/insights', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('dashboard') + def api_v2_control_center_dashboard_insights(): + """Return aggregate-only dashboard insights available from recorded activity fields.""" + try: + start_date, end_date, _, _, period_days = _dashboard_parse_period(request.args) + except ValueError as ex: + return jsonify({'error': str(ex)}), 400 + + token_filters = extract_token_filters(request.args) + cache_key = ( + 'insights', + start_date.isoformat(), + end_date.isoformat(), + json.dumps(token_filters, sort_keys=True), + ) + force_refresh = request.args.get('force_refresh', '').strip().lower() in { + '1', 'true', 'yes' + } + cached = _dashboard_cache_get(cache_key, force_refresh=force_refresh) + if cached is not None: + return jsonify({**cached, 'cached': True}) + + try: + token_where, token_parameters = build_token_usage_query_context( + start_date, + end_date, + token_filters=token_filters, + ) + model_rows = list(cosmos_activity_logs_container.query_items( + query=f""" + SELECT SUBSTRING(c.timestamp, 0, 10) AS date, + c.usage.model AS model, + SUM(c.usage.total_tokens) AS tokens + FROM c + WHERE {token_where} + AND IS_DEFINED(c.timestamp) + AND IS_DEFINED(c.usage.model) + GROUP BY SUBSTRING(c.timestamp, 0, 10), c.usage.model + """, + parameters=token_parameters, + enable_cross_partition_query=True, + )) + + def ranked_token_totals(field_path): + rows = list(cosmos_activity_logs_container.query_items( + query=f""" + SELECT c.{field_path} AS id, SUM(c.usage.total_tokens) AS tokens + FROM c + WHERE {token_where} AND IS_DEFINED(c.{field_path}) + GROUP BY c.{field_path} + """, + parameters=token_parameters, + enable_cross_partition_query=True, + )) + return sorted( + ( + {'id': row.get('id'), 'tokens': int(row.get('tokens') or 0)} + for row in rows if row.get('id') + ), + key=lambda item: item['tokens'], + reverse=True, + )[:10] + + def ranked_activity_totals(field_path): + rows = list(cosmos_activity_logs_container.query_items( + query=f""" + SELECT c.{field_path} AS id, COUNT(1) AS activity_count + FROM c + WHERE c.timestamp >= @start_date + AND c.timestamp <= @end_date + AND IS_DEFINED(c.{field_path}) + GROUP BY c.{field_path} + """, + parameters=[ + {'name': '@start_date', 'value': start_date.isoformat()}, + {'name': '@end_date', 'value': end_date.isoformat()}, + ], + enable_cross_partition_query=True, + )) + return sorted( + ( + {'id': row.get('id'), 'activity_count': int(row.get('activity_count') or 0)} + for row in rows if row.get('id') + ), + key=lambda item: item['activity_count'], + reverse=True, + )[:10] + + login_rows = list(cosmos_activity_logs_container.query_items( + query=""" + SELECT SUBSTRING(c.timestamp, 0, 10) AS date, + SUBSTRING(c.timestamp, 11, 2) AS hour, + COUNT(1) AS count + FROM c + WHERE c.activity_type = 'user_login' + AND c.timestamp >= @start_date + AND c.timestamp <= @end_date + GROUP BY SUBSTRING(c.timestamp, 0, 10), SUBSTRING(c.timestamp, 11, 2) + """, + parameters=[ + {'name': '@start_date', 'value': start_date.isoformat()}, + {'name': '@end_date', 'value': end_date.isoformat()}, + ], + enable_cross_partition_query=True, + )) + login_heatmap = [] + for row in login_rows: + try: + weekday = datetime.strptime(row['date'], '%Y-%m-%d').weekday() + hour = int(row['hour']) + login_heatmap.append({ + 'weekday': weekday, + 'hour': hour, + 'count': int(row.get('count') or 0), + }) + except (KeyError, TypeError, ValueError): + continue + + response_data = { + 'period': { + 'start_date': start_date.date().isoformat(), + 'end_date': end_date.date().isoformat(), + 'days': period_days, + 'timezone': 'UTC', + }, + 'token_usage_by_model': [ + { + 'date': row.get('date'), + 'model': row.get('model') or 'Unknown model', + 'tokens': int(row.get('tokens') or 0), + } + for row in model_rows + ], + 'top_tokens': { + 'users': ranked_token_totals('user_id'), + 'groups': ranked_token_totals('workspace_context.group_id'), + 'public_workspaces': ranked_token_totals( + 'workspace_context.public_workspace_id' + ), + }, + 'top_activity': { + 'users': ranked_activity_totals('user_id'), + 'groups': ranked_activity_totals('workspace_context.group_id'), + 'public_workspaces': ranked_activity_totals( + 'workspace_context.public_workspace_id' + ), + }, + 'login_heatmap': { + 'weekday_convention': 'Monday=0 through Sunday=6', + 'timezone': 'UTC', + 'cells': login_heatmap, + }, + } + _dashboard_cache_set(cache_key, response_data) + return jsonify({**response_data, 'cached': False}) + except Exception as ex: + log_event( + message='[CONTROL_CENTER] Dashboard insights query failed.', + extra={'error_type': type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({'error': 'Failed to retrieve dashboard insights.'}), 500 + # Activity Trends API @bp.route('/api/admin/control-center/activity-trends', methods=['GET']) @swagger_route(security=get_auth_security()) diff --git a/application/v2_ui/src/components/controlCenter/DashboardSection.tsx b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx new file mode 100644 index 000000000..6336da35e --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx @@ -0,0 +1,834 @@ +// DashboardSection.tsx +// Overview metrics and activity charts for the V2 Control Center. + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Download, MessageSquareText, RefreshCw } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { cartesianOptions, StatsChart, type StatsChartConfigBuilder } from '../settings/StatsChart'; +import { APPROVALS_URL, KpiCard } from './ControlCenterPrimitives'; + +type Metric = { + value: number | null; + delta: number | null; + percent_change?: number | null; + previous?: number; + available?: boolean; +}; + +type DashboardSummary = { + period: { start_date: string; end_date: string; days: number; timezone: string }; + refreshed_at: string; + users: { + total: Metric; + active: Metric; + dau: Metric; + wau: Metric; + mau: Metric; + blocked: Metric; + }; + groups: { total: Metric; by_status: Record }; + public_workspaces: { total: Metric; by_status: Record }; + conversations: Metric; + document_uploads: { total: Metric; by_workspace_type: Record }; + document_processing_failures: Metric; + tokens: Metric; + pending_approvals: Metric | null; + status_history_available: boolean; +}; + +type ActivitySeries = Record; +type UploadSeriesKey = 'personal_documents_created' | 'group_documents_created' | 'public_documents_created'; + +type ActivityTrends = { + success: boolean; + activity_data: { + chats: ActivitySeries; + logins: ActivitySeries; + personal_documents_created: ActivitySeries; + group_documents_created: ActivitySeries; + public_documents_created: ActivitySeries; + tokens: Record>; + }; +}; + +type RankedItem = { id: string; tokens?: number; activity_count?: number }; + +type DashboardInsights = { + token_usage_by_model: { date: string; model: string; tokens: number }[]; + top_tokens: { + users: RankedItem[]; + groups: RankedItem[]; + public_workspaces: RankedItem[]; + }; + top_activity: { + users: RankedItem[]; + groups: RankedItem[]; + public_workspaces: RankedItem[]; + }; + login_heatmap: { + weekday_convention: string; + timezone: string; + cells: { weekday: number; hour: number; count: number }[]; + }; +}; + +type FilterOption = { id?: string; value?: string; label: string; name?: string; display_name?: string }; +type TokenFilterOptions = { + filters: { + users: FilterOption[]; + groups: FilterOption[]; + public_workspaces: FilterOption[]; + models: FilterOption[]; + workspace_types: FilterOption[]; + token_types: FilterOption[]; + }; +}; + +type TokenFilters = { + user_id: string; + workspace_type: string; + group_id: string; + public_workspace_id: string; + model: string; + token_type: string; +}; + +const EMPTY_FILTERS: TokenFilters = { + user_id: '', + workspace_type: '', + group_id: '', + public_workspace_id: '', + model: '', + token_type: '', +}; + +const CHART_COLORS = { + blue: { border: '#4f8cff', fill: 'rgba(79, 140, 255, 0.24)' }, + cyan: { border: '#22b8cf', fill: 'rgba(34, 184, 207, 0.30)' }, + green: { border: '#37b679', fill: 'rgba(55, 182, 121, 0.28)' }, + amber: { border: '#e8a23a', fill: 'rgba(232, 162, 58, 0.28)' }, + purple: { border: '#a78bfa', fill: 'rgba(167, 139, 250, 0.28)' }, + rose: { border: '#f472b6', fill: 'rgba(244, 114, 182, 0.28)' }, +}; + +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +function rangeDates(days: number): { startDate: string; endDate: string } { + const end = new Date(); + const endDate = end.toISOString().slice(0, 10); + const start = new Date(`${endDate}T00:00:00.000Z`); + start.setUTCDate(start.getUTCDate() - days + 1); + return { startDate: start.toISOString().slice(0, 10), endDate }; +} + +function metricDetail(metric: Metric, days: number): string { + if (metric.value === null || metric.available === false) { + return 'Not available for this period'; + } + if (metric.delta === null) { + return 'Current status; historical status snapshots are not recorded'; + } + if (metric.delta === 0) { + return `No change vs the previous ${days}-day period`; + } + const direction = metric.delta > 0 ? '↑' : '↓'; + const percentage = metric.percent_change === null || metric.percent_change === undefined + ? '' + : ` (${Math.abs(metric.percent_change)}%)`; + return `${direction} ${Math.abs(metric.delta).toLocaleString()} vs previous period${percentage}`; +} + +function buildParams( + startDate: string, + endDate: string, + filters: TokenFilters, + forceRefresh = false, +): string { + const params = new URLSearchParams({ start_date: startDate, end_date: endDate }); + Object.entries(filters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } + }); + if (forceRefresh) { + params.set('force_refresh', '1'); + } + return params.toString(); +} + +function seriesValues(series: ActivitySeries, dates: string[]): number[] { + return dates.map((date) => Number(series[date] ?? 0)); +} + +function makeDatasets(series: { label: string; values: number[]; color: keyof typeof CHART_COLORS }[]) { + return series.map(({ label, values, color }) => ({ + label, + data: values, + borderColor: CHART_COLORS[color].border, + backgroundColor: CHART_COLORS[color].fill, + borderWidth: 2, + borderRadius: 3, + pointRadius: 1, + tension: 0.3, + })); +} + +function MetricTile({ + label, + metric, + to, + days, + valueLabel, +}: { + label: string; + metric: Metric; + to: string; + days: number; + valueLabel?: string; +}) { + const value = metric.value === null || metric.available === false + ? 'Not tracked' + : valueLabel ?? metric.value.toLocaleString(); + return ( + + + + ); +} + +function ChartDataTable({ + title, + dates, + series, +}: { + title: string; + dates: string[]; + series: { label: string; values: number[] }[]; +}) { + return ( +
+ View {title.toLowerCase()} as a data table +
+ + + + + + {series.map((item) => )} + + + + {dates.map((date, index) => ( + + + {series.map((item) => )} + + ))} + +
{title} chart data by day
Date{item.label}
{date}{item.values[index] ?? 0}
+
+
+ ); +} + +function ChartPanel({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function TokenFilterSelect({ + id, + label, + value, + options, + onChange, +}: { + id: string; + label: string; + value: string; + options: FilterOption[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function RankedTable({ + title, + rows, + valueKey, + section, +}: { + title: string; + rows: RankedItem[]; + valueKey: 'tokens' | 'activity_count'; + section: 'users' | 'groups' | 'public-workspaces'; +}) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

No activity recorded in this period.

+ ) : ( +
    + {rows.slice(0, 10).map((row, index) => ( +
  1. + + {index + 1}. {row.id} + + {Number(row[valueKey] ?? 0).toLocaleString()} +
  2. + ))} +
+ )} +
+ ); +} + +export function DashboardSection() { + const navigate = useNavigate(); + const [preset, setPreset] = useState<'7' | '30' | '90' | 'custom'>('30'); + const [customStart, setCustomStart] = useState(() => rangeDates(30).startDate); + const [customEnd, setCustomEnd] = useState(() => rangeDates(30).endDate); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [filterOptions, setFilterOptions] = useState(null); + const [summary, setSummary] = useState(null); + const [trends, setTrends] = useState(null); + const [insights, setInsights] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterError, setFilterError] = useState(null); + const [refreshVersion, setRefreshVersion] = useState(0); + const handledRefreshVersion = useRef(0); + const [action, setAction] = useState<'export' | 'chat' | null>(null); + const [actionError, setActionError] = useState(null); + const [chatUrl, setChatUrl] = useState(null); + + const dates = useMemo( + () => (preset === 'custom' ? { startDate: customStart, endDate: customEnd } : rangeDates(Number(preset))), + [preset, customStart, customEnd], + ); + const validRange = dates.startDate <= dates.endDate; + const query = useMemo( + () => buildParams(dates.startDate, dates.endDate, filters), + [dates.startDate, dates.endDate, filters], + ); + + useEffect(() => { + let cancelled = false; + api.get('/api/admin/control-center/token-filters') + .then((response) => { + if (!cancelled) { + setFilterOptions(response.filters); + setFilterError(null); + } + }) + .catch((requestError: unknown) => { + if (!cancelled) { + setFilterError(requestError instanceof Error ? requestError.message : 'Token filter options could not be loaded.'); + } + }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (!validRange) { + setError('The start date must be on or before the end date.'); + setLoading(false); + return; + } + const controller = new AbortController(); + setLoading(true); + setError(null); + setChatUrl(null); + const forceRefresh = refreshVersion > handledRefreshVersion.current; + handledRefreshVersion.current = refreshVersion; + const requestQuery = buildParams(dates.startDate, dates.endDate, filters, forceRefresh); + const trendUrl = `/api/admin/control-center/activity-trends?${requestQuery}`; + Promise.all([ + api.get(`/api/v2/control-center/dashboard/summary?${requestQuery}`, controller.signal), + api.get(trendUrl, controller.signal), + api.get(`/api/v2/control-center/dashboard/insights?${requestQuery}`, controller.signal), + ]) + .then(([summaryResponse, trendResponse, insightResponse]) => { + setSummary(summaryResponse); + setTrends(trendResponse); + setInsights(insightResponse); + }) + .catch((requestError: unknown) => { + if (!controller.signal.aborted) { + setError(requestError instanceof Error ? requestError.message : 'Dashboard data could not be loaded.'); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + } + }); + return () => controller.abort(); + }, [dates.endDate, dates.startDate, filters, query, refreshVersion, validRange]); + + const periodDays = summary?.period.days ?? Number(preset === 'custom' ? 30 : preset); + const activity = trends?.activity_data; + const trendDates = useMemo(() => Object.keys(activity?.chats ?? {}).sort(), [activity]); + const tokensByDay = useMemo(() => activity?.tokens ?? {}, [activity]); + const tokenDates = useMemo(() => Object.keys(tokensByDay).sort(), [tokensByDay]); + const modelNames = useMemo( + () => [...new Set((insights?.token_usage_by_model ?? []).map((row) => row.model))].sort(), + [insights], + ); + const modelDates = useMemo( + () => [...new Set((insights?.token_usage_by_model ?? []).map((row) => row.date))].sort(), + [insights], + ); + const uploadSeries: { label: string; key: UploadSeriesKey; color: keyof typeof CHART_COLORS }[] = [ + { label: 'Personal', key: 'personal_documents_created', color: 'blue' }, + { label: 'Group', key: 'group_documents_created', color: 'cyan' }, + { label: 'Public', key: 'public_documents_created', color: 'green' }, + ]; + const tokenSeries = [ + { label: 'Chat', key: 'chat', color: 'blue' as const }, + { label: 'Embedding', key: 'embedding', color: 'green' as const }, + { label: 'Web search', key: 'web_search', color: 'amber' as const }, + ]; + + const updateFilter = (key: keyof TokenFilters, value: string) => { + setFilters((current) => ({ ...current, [key]: value })); + }; + + const navigateToActivity = (activityType: string, date?: string, extras: Record = {}) => { + const params = new URLSearchParams({ activity_type: activityType, ...extras }); + if (date) { + params.set('date', date); + } + navigate(`/control-center/activity-logs?${params.toString()}`); + }; + + const exportTrends = async () => { + setAction('export'); + setActionError(null); + try { + const response = await api.post('/api/admin/control-center/activity-trends/export', { + charts: ['logins', 'chats', 'personal_documents', 'group_documents', 'public_documents'], + time_window: 'custom', + start_date: dates.startDate, + end_date: dates.endDate, + token_filters: filters, + }); + const url = URL.createObjectURL(new Blob([response], { type: 'text/csv;charset=utf-8' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `control-center-trends-${dates.startDate}-${dates.endDate}.csv`; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (requestError) { + setActionError(requestError instanceof Error ? requestError.message : 'Trend export failed.'); + } finally { + setAction(null); + } + }; + + const chatWithTrends = async () => { + setAction('chat'); + setActionError(null); + setChatUrl(null); + try { + const response = await api.post<{ conversation_id: string }>( + '/api/admin/control-center/activity-trends/chat', + { + charts: ['logins', 'chats', 'documents'], + time_window: 'custom', + start_date: dates.startDate, + end_date: dates.endDate, + token_filters: filters, + }, + ); + setChatUrl(`/chat/${encodeURIComponent(response.conversation_id)}`); + } catch (requestError) { + setActionError(requestError instanceof Error ? requestError.message : 'Could not create a trends chat.'); + } finally { + setAction(null); + } + }; + + const activityConfig: StatsChartConfigBuilder = (theme) => ({ + type: 'line', + data: { + labels: trendDates, + datasets: makeDatasets([ + { label: 'Conversations created', values: seriesValues(activity?.chats ?? {}, trendDates), color: 'blue' }, + { label: 'Logins', values: seriesValues(activity?.logins ?? {}, trendDates), color: 'amber' }, + ]), + }, + options: { + ...cartesianOptions(theme, true), + onClick: (_event: unknown, elements: { index: number }[]) => { + const date = trendDates[elements[0]?.index ?? -1]; + if (date) { + navigateToActivity('conversation_creation', date); + } + }, + }, + }); + + const uploadConfig: StatsChartConfigBuilder = (theme) => ({ + type: 'bar', + data: { + labels: trendDates, + datasets: makeDatasets(uploadSeries.map((series) => ({ + label: series.label, + values: seriesValues(activity?.[series.key] ?? {}, trendDates), + color: series.color, + }))), + }, + options: { + ...cartesianOptions(theme, true), + scales: { + ...cartesianOptions(theme, true).scales, + x: { ...cartesianOptions(theme, true).scales.x, stacked: true }, + y: { ...cartesianOptions(theme, true).scales.y, stacked: true }, + }, + onClick: (_event: unknown, elements: { index: number; datasetIndex: number }[]) => { + const element = elements[0]; + const series = uploadSeries[element?.datasetIndex ?? -1]; + const date = trendDates[element?.index ?? -1]; + if (series && date) { + navigateToActivity('document_creation', date, { + workspace_type: series.key.startsWith('personal') ? 'personal' : series.key.startsWith('group') ? 'group' : 'public', + }); + } + }, + }, + }); + + const tokenConfig: StatsChartConfigBuilder = (theme) => ({ + type: 'bar', + data: { + labels: tokenDates, + datasets: makeDatasets(tokenSeries.map((series) => ({ + label: series.label, + values: tokenDates.map((date) => Number(tokensByDay[date]?.[series.key] ?? 0)), + color: series.color, + }))), + }, + options: { + ...cartesianOptions(theme, true), + scales: { + ...cartesianOptions(theme, true).scales, + x: { ...cartesianOptions(theme, true).scales.x, stacked: true }, + y: { ...cartesianOptions(theme, true).scales.y, stacked: true }, + }, + onClick: (_event: unknown, elements: { index: number; datasetIndex: number }[]) => { + const element = elements[0]; + const series = tokenSeries[element?.datasetIndex ?? -1]; + const date = tokenDates[element?.index ?? -1]; + if (series && date) { + navigateToActivity('token_usage', date, { token_type: series.key }); + } + }, + }, + }); + + const modelConfig: StatsChartConfigBuilder = (theme) => ({ + type: 'bar', + data: { + labels: modelDates, + datasets: makeDatasets(modelNames.map((model, index) => ({ + label: model, + values: modelDates.map((date) => (insights?.token_usage_by_model ?? []) + .filter((row) => row.date === date && row.model === model) + .reduce((total, row) => total + row.tokens, 0)), + color: (Object.keys(CHART_COLORS) as (keyof typeof CHART_COLORS)[])[index % Object.keys(CHART_COLORS).length], + }))), + }, + options: { + ...cartesianOptions(theme, true), + scales: { + ...cartesianOptions(theme, true).scales, + x: { ...cartesianOptions(theme, true).scales.x, stacked: true }, + y: { ...cartesianOptions(theme, true).scales.y, stacked: true }, + }, + onClick: (_event: unknown, elements: { index: number; datasetIndex: number }[]) => { + const element = elements[0]; + const model = modelNames[element?.datasetIndex ?? -1]; + const date = modelDates[element?.index ?? -1]; + if (model && date) { + navigateToActivity('token_usage', date, { model }); + } + }, + }, + }); + + const makeChartTable = (title: string, series: { label: string; values: number[] }[]) => ( + + ); + + return ( +
+
+
+

Dashboard

+

Usage and activity for the selected UTC date range.

+
+
+ + {preset === 'custom' ? ( + <> + + + + ) : null} + + + +
+
+ + {preset === 'custom' ? ( +

Custom ranges can cover up to 366 days. All dates are interpreted as UTC.

+ ) : null} + +
+

Token filters

+
+ updateFilter('user_id', value)} /> + updateFilter('workspace_type', value)} /> + updateFilter('group_id', value)} /> + updateFilter('public_workspace_id', value)} /> + updateFilter('model', value)} /> + updateFilter('token_type', value)} /> +
+ {filterError ?

{filterError} Token filters may be incomplete.

: null} +
+ + {actionError ?

{actionError}

: null} + {chatUrl ?

+ Trend chat created. Open conversation +

: null} + + {error ? ( +
+

{error}

+ +
+ ) : null} + + {loading && !summary ? ( +
+ {Array.from({ length: 9 }, (_, index) => ( +
+ ))} +
+ ) : summary ? ( +
+ + + + + + + + + + {summary.pending_approvals ? ( + + ) : null} +
+

Login activity

+

+ DAU {summary.users.dau.value?.toLocaleString() ?? '—'} + · + WAU {summary.users.wau.value?.toLocaleString() ?? '—'} + · + MAU {summary.users.mau.value?.toLocaleString() ?? '—'} + (unique users, measured through the selected end date) +

+
+ {Object.entries(summary.groups.by_status).map(([status, metric]) => ( + + Groups {status.replace('_', ' ')}: {metric.value?.toLocaleString() ?? 0} + + ))} + {Object.entries(summary.public_workspaces.by_status).map(([status, metric]) => ( + + Workspaces {status.replace('_', ' ')}: {metric.value?.toLocaleString() ?? 0} + + ))} + {summary.pending_approvals ? ( + + Pending approvals: {summary.pending_approvals.value?.toLocaleString() ?? 'Unavailable'} + + ) : null} +
+
+
+ ) : null} + + {loading && summary ?

Refreshing dashboard data…

: null} + + {trends && activity && trendDates.length > 0 ? ( +
+ + + {makeChartTable('Conversations and logins', [ + { label: 'Conversations', values: seriesValues(activity.chats, trendDates) }, + { label: 'Logins', values: seriesValues(activity.logins, trendDates) }, + ])} + + + + {makeChartTable('Document uploads by workspace', uploadSeries.map((series) => ({ + label: series.label, + values: seriesValues(activity[series.key], trendDates), + })))} + + + + ({ + label: series.label, + values: tokenDates.map((date) => Number(tokensByDay[date]?.[series.key] ?? 0)), + }))} /> + + + {modelDates.length > 0 ? ( + <> + + ({ + label: model, + values: modelDates.map((date) => (insights?.token_usage_by_model ?? []) + .filter((row) => row.date === date && row.model === model) + .reduce((total, row) => total + row.tokens, 0)), + }))} /> + + ) :

No model-level token records for this period.

} +
+
+ ) : !loading && !error ? ( +

+ No activity has been recorded for this date range. +

+ ) : null} + + {insights && (insights.top_tokens.users.length > 0 || insights.login_heatmap.cells.length > 0) ? ( +
+ +
+ + + +
+
+ +
+ + + +
+
+ + + + + + {Array.from({ length: 24 }, (_, hour) => )} + + + + {WEEKDAYS.map((weekday, dayIndex) => ( + + + {Array.from({ length: 24 }, (_, hour) => { + const count = insights.login_heatmap.cells.find((cell) => cell.weekday === dayIndex && cell.hour === hour)?.count ?? 0; + return ; + })} + + ))} + +
+ Logins by weekday and hour (UTC; Monday = 0) +
Weekday{hour}
{weekday} + {count || '·'} +
+
+
+
+ ) : null} + + {summary?.refreshed_at ?

Updated {new Date(summary.refreshed_at).toLocaleString()}

: null} +
+ ); +} diff --git a/application/v2_ui/src/pages/ControlCenterPage.tsx b/application/v2_ui/src/pages/ControlCenterPage.tsx index f817cb969..54d73e567 100644 --- a/application/v2_ui/src/pages/ControlCenterPage.tsx +++ b/application/v2_ui/src/pages/ControlCenterPage.tsx @@ -13,6 +13,7 @@ import { api } from '../lib/apiClient'; import { PageHeader } from '../components/layout/PageHeader'; import { ConfirmDialog } from '../components/ui/ConfirmDialog'; import { GlassButton, GlassPanel } from '../components/ui/primitives'; +import { DashboardSection } from '../components/controlCenter/DashboardSection'; import type { ControlCenterCapabilities } from '../lib/types'; import { useBootstrapStore } from '../stores/bootstrapStore'; import { useUserSettingsStore } from '../stores/userSettingsStore'; @@ -223,7 +224,9 @@ export function ControlCenterPage() {

Access unavailable

Your Control Center permissions do not include this section.

- ) : section === 'data-health' ? : } + ) : section === 'dashboard' ? + : section === 'data-health' ? + : }
diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index 57e1550c5..0c88c548b 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -2,7 +2,8 @@ The V2 Control Center is a permission-aware administration pane for managing SimpleChat. The foundation release provides the shared navigation and capability contract, placeholders for management areas that will arrive in later phases, and a manual activity-log data-health tool. -**Implemented in version:** 0.261.278 +**Dashboard implemented in version:** 0.261.279 +**Foundation implemented in version:** 0.261.278 **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. @@ -12,20 +13,41 @@ The Control Center is a distinct React route (`/control-center` and `/control-ce `get_control_center_capabilities()` in `functions_authentication.py` is the shared permission decision for both `control_center_required()` and the `/api/v2/bootstrap` response. When the ControlCenterAdmin role requirement is enabled, that role grants all capabilities; otherwise the regular Admin role grants them. The optional ControlCenterDashboardReader role grants dashboard viewing only when its setting is enabled. The bootstrap payload exposes `can_view_dashboard`, `can_manage_users`, `can_manage_groups`, `can_manage_workspaces`, `can_view_activity_logs`, and `can_run_maintenance`. -## Sections and data health +## Dashboard -Dashboard, Users, Groups, Public Workspaces, and Activity Logs are permission-gated placeholders until their V2 implementations are delivered. They link to the classic Control Center at `/admin/control-center`. +Users, Groups, Public Workspaces, and Activity Logs remain permission-gated placeholders until their V2 implementations are delivered. They link to the classic Control Center at `/admin/control-center`. + +The Dashboard is available to users with `can_view_dashboard`, including users assigned the configured ControlCenterDashboardReader role. `GET /api/v2/control-center/dashboard/summary` returns counts and period comparisons; `GET /api/v2/control-center/dashboard/insights` returns grouped chart data. Both use a 90-second in-process cache keyed by the date range and token filters. Pass `force_refresh=1` to bypass it. + +The date presets are 7, 30, and 90 UTC calendar days. Custom ranges use `start_date` and `end_date` in `YYYY-MM-DD` format and are limited to 366 days. The trend charts reuse the existing activity-trends and token-filter APIs, CSV export, and “Chat with these trends” endpoint. Chart data is grouped from the fields written by `functions_activity_logging.py`: `user_login.timestamp`, creation activity types and `workspace_type`, and `token_usage.usage.model`, `usage.total_tokens`, `token_type`, `user_id`, and `workspace_context` IDs. The login heatmap uses UTC and Monday=0. + +Group and public-workspace status counts use the stored `status` values (`active`, `locked`, `upload_disabled`, `inactive`); missing values count as active. Unknown group statuses count as active, matching the group permission default, while unknown public-workspace statuses count as inactive, matching its fail-closed permission behavior. Current user, blocked-user, group/workspace status, and pending-approval counts are snapshots. The application does not retain historical snapshots for those dimensions, so their period deltas are intentionally unavailable. Period deltas are shown for login activity, conversations, document creations, document processing failures, and tokens. Processing failures are counted only when a document's stored status text contains “failed” or “error”; when that query is unavailable, the dashboard shows the metric as unavailable rather than zero. Pending approvals are omitted when the aggregate query cannot be completed. + +### Control Center query-parameter contract + +Dashboard drill-through links set these parameters for the management sections being delivered in later phases: + +| Section | Parameters | +|---|---| +| Users | `user_id`, `filter`, `status` | +| Groups | `id`, `status` | +| Public Workspaces | `id`, `status` | +| Activity Logs | `activity_type`, `date`, `start_date`, `end_date`, `workspace_type`, `token_type`, `model`, `status` | + +Section paths are `/control-center/users`, `/control-center/groups`, `/control-center/public-workspaces`, and `/control-center/activity-logs`. IDs and parameter values are URL-encoded. `date` is a UTC calendar date; `start_date` and `end_date` are inclusive UTC dates. + +## Data health Data health is available to users with `can_run_maintenance`. Its Check button calls `GET /api/admin/control-center/migrate/status` only when requested. Run backfill requires confirmation and calls `POST /api/admin/control-center/migrate/all`. The legacy-flag counts do not prove that activity logs are missing: normal application writers already record activity, so the backfill is normally unnecessary. The backfill checks for a matching resource creation record in the user's activity-log partition and uses stable per-resource IDs to avoid duplicate writes on repeated or concurrent runs. ## Usage -Open **Account → Control Center**, then select a section in its internal rail. A bookmarked section URL opens that section directly. The Data health page is intended for explicit diagnosis or a known recovery scenario; check first, and run the backfill only when the result and operational context justify it. +Open **Account → Control Center**, then select a section in its internal rail. A bookmarked section URL opens that section directly. On the Dashboard, select a date range and optional token filters; charts include accessible data tables, and chart selections link to the corresponding filtered activity view. Export downloads the trend data as CSV. “Chat with these trends” creates a conversation containing the selected trend data. The Data health page is intended for explicit diagnosis or a known recovery scenario; check first, and run the backfill only when the result and operational context justify it. ## Testing and limitations -Functional checks cover capability parity, bootstrap exposure, manual-only migration checks, deduplication, and route wiring. Playwright coverage verifies the Data health interaction and capability visibility. This foundation does not implement the dashboard, user/group/workspace administration, or activity-log browser; those sections remain in the classic page until their subsequent phases. +Functional checks cover dashboard status normalization, period deltas, cache expiry and refresh, dashboard-reader access, capability parity, bootstrap exposure, manual-only migration checks, and route wiring. Playwright coverage verifies the Data health interaction and capability visibility. Top activity and token rankings are limited to entities present in the recorded `user_id` and workspace-context fields; unlogged historical status snapshots and model/provider details are not inferred. ## Version tracking -The application version is defined by `VERSION` in `application/single_app/config.py`. This implementation was added in **0.261.278**. +The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278** and the dashboard in **0.261.279**. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 7b2274e82..1ca9b3947 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.279)** + +#### New Features + +* **V2 Control Center Dashboard** + * Adds period-based summary metrics, correct group and public-workspace status counts, login/activity/token charts, token filters, CSV export, and chat-with-trends. + * Aggregates use activity-log and workspace data with a 90-second cache. Metrics without retained historical snapshots are identified as current state instead of presenting misleading period deltas. + * Dashboard readers can access the dashboard APIs without receiving management capabilities. + * (Ref: `route_backend_control_center.py`, `DashboardSection.tsx`, [V2 Control Center](features/V2_CONTROL_CENTER.md)) + ### **(v0.261.278)** #### New Features diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index e896dc328..5e43e6d14 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -1,7 +1,7 @@ --- layout: page title: "Use the V2 Control Center" -description: "Open the permission-aware Control Center pane and check activity-log data only when needed." +description: "Review usage trends in the V2 Control Center and check activity-log data only when needed." section: "Guides" audience: admin --- @@ -10,7 +10,15 @@ audience: admin The V2 Control Center is a separate administration pane for users assigned Control Center access. It is kept out of the primary workspace navigation and appears in **Account → Control Center** when at least one Control Center capability is available to you. -The section rail is filtered to your permissions. Dashboard, user and group management, public workspaces, and activity logs are being delivered in phases. Until a section is available in V2, open the classic Control Center from its placeholder. +The section rail is filtered to your permissions. The Dashboard is available to dashboard readers and Control Center administrators. User and group management, public workspaces, and activity logs are being delivered in phases; open the classic Control Center from those sections' placeholders. + +## Review dashboard activity + +Choose a 7-, 30-, or 90-day period, or set an inclusive custom UTC date range of up to 366 days. Summary cards show user and workspace counts, period activity, token usage, and pending approvals when that count is available. Counts that have no historical snapshots are labeled as current status instead of showing a misleading period delta. + +The charts use recorded logins, conversation creation, document creation by workspace type, token usage type, token models, and workspace activity. Select chart points or KPI cards to open the related section with query filters. Use **Export** to download trend data as CSV, or **Chat with these trends** to start a conversation containing the selected trend data. Token filters apply to token totals and token charts; they do not change login, conversation, or upload counts. + +The login heatmap reports UTC hours with Monday as weekday zero. Charts include data tables for screen-reader and text-based access. Dashboard summaries are cached for 90 seconds; choose **Refresh** to bypass the cache. ## Check activity-log data health diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 24c50f9c6..66ad81166 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for route blueprint policy inventory. -Version: 0.261.227 +Version: 0.261.279 Implemented in: 0.242.069 Plan editor policy coverage: 0.261.102 Selected-group context policy coverage: 0.261.126 @@ -15,6 +15,7 @@ Workflow run status policy coverage: 0.261.227 Workflow hand-off policy coverage: 0.261.250 Global agent and action editor policy coverage: 0.261.271 +Control Center dashboard route policy coverage: 0.261.279 This test ensures every SimpleChat route is assigned to a Blueprint-based security policy or an explicit reviewed route exemption. @@ -151,6 +152,8 @@ } SENSITIVE_ROUTE_POLICIES = { + ("route_backend_control_center.py", "api_v2_control_center_dashboard_summary"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_dashboard_insights"): ("login_required", "control_center_required"), ("route_backend_group_documents.py", "api_scoped_group_document_upload"): ("login_required", "user_required", "enabled_required"), ("route_backend_group_documents.py", "api_scoped_group_document_metadata"): ("login_required", "user_required", "enabled_required"), ("route_backend_group_documents.py", "api_scoped_group_document_delete"): ("login_required", "user_required", "enabled_required"), diff --git a/functional_tests/test_v2_api_security.py b/functional_tests/test_v2_api_security.py index da795a26e..dc2b29243 100644 --- a/functional_tests/test_v2_api_security.py +++ b/functional_tests/test_v2_api_security.py @@ -2,8 +2,9 @@ """ Functional test for V2 API security posture. -Version: 0.261.003 +Version: 0.261.279 Implemented in: 0.261.003 +Control Center dashboard route security coverage: 0.261.279 This test ensures that the V2 bootstrap endpoint never returns raw settings to the browser, that every V2 route carries the required swagger security decorator, and that the @@ -22,6 +23,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] APP_DIR = REPO_ROOT / "application" / "single_app" BACKEND_V2 = APP_DIR / "route_backend_v2.py" +BACKEND_CONTROL_CENTER = APP_DIR / "route_backend_control_center.py" FRONTEND_V2 = APP_DIR / "route_frontend_v2.py" sys.path.insert(0, str(REPO_ROOT / "functional_tests")) @@ -121,6 +123,20 @@ def test_all_v2_routes_declare_swagger_security(): f"{path.name}:{node.name} is missing @login_required" ) + dashboard_routes = [ + (node, decorators) + for node, decorators in _iter_route_functions(_parse(BACKEND_CONTROL_CENTER)) + if node.name.startswith("api_v2_control_center_dashboard_") + ] + assert {node.name for node, _ in dashboard_routes} == { + "api_v2_control_center_dashboard_summary", + "api_v2_control_center_dashboard_insights", + } + for node, decorators in dashboard_routes: + assert {"swagger_route", "login_required", "control_center_required"} <= set(decorators), ( + f"{node.name} must declare Swagger, login, and dashboard capability guards" + ) + print("Swagger security decorator test passed!") return True diff --git a/functional_tests/test_v2_control_center_dashboard.py b/functional_tests/test_v2_control_center_dashboard.py new file mode 100644 index 000000000..49bd9c47b --- /dev/null +++ b/functional_tests/test_v2_control_center_dashboard.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# test_v2_control_center_dashboard.py +""" +Functional test for the V2 Control Center dashboard. +Version: 0.261.279 +Implemented in: 0.261.279 + +This test validates status aggregation, period comparisons, bounded cache behavior, +dashboard-reader authorization, and the summary and insights route contracts. +""" + +import ast +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +ROUTE = APP / "route_backend_control_center.py" +AUTH = APP / "functions_authentication.py" +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.versioning import assert_app_version_at_least + + +def _function_namespace(path, names, initial=None): + tree = ast.parse(path.read_text(encoding="utf-8")) + wanted = [ + node for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names + ] + assert {node.name for node in wanted} == set(names) + namespace = initial or {} + exec(compile(ast.Module(body=wanted, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def test_group_and_workspace_status_counts_use_real_statuses(): + functions = _function_namespace(ROUTE, {"_dashboard_status_counts"}) + counts = functions["_dashboard_status_counts"]( + 8, + [ + {"status": "locked", "count": 2}, + {"status": "upload_disabled", "count": 1}, + {"status": "inactive", "count": 1}, + {"status": "active", "count": 1}, + {"status": "unexpected_legacy_value", "count": 1}, + ], + ) + assert counts == { + "active": 4, + "locked": 2, + "upload_disabled": 1, + "inactive": 1, + } + public_counts = functions["_dashboard_status_counts"]( + 5, + [ + {"status": "active", "count": 1}, + {"status": "locked", "count": 1}, + {"status": "upload_disabled", "count": 1}, + {"status": "inactive", "count": 1}, + {"status": "unexpected_legacy_value", "count": 1}, + ], + unknown_status="inactive", + ) + assert public_counts == { + "active": 1, + "locked": 1, + "upload_disabled": 1, + "inactive": 2, + } + + +def test_document_uploads_are_aggregated_by_recorded_workspace_type(): + class ActivityContainer: + def query_items(self, **kwargs): + self.query = kwargs + return iter([ + {"workspace_type": "personal", "count": 3}, + {"workspace_type": "group", "count": 2}, + {"workspace_type": "public", "count": 1}, + {"workspace_type": None, "count": 1}, + {"workspace_type": "legacy", "count": 1}, + ]) + + container = ActivityContainer() + functions = _function_namespace( + ROUTE, + {"_dashboard_document_upload_counts"}, + {"cosmos_activity_logs_container": container}, + ) + start = datetime(2026, 9, 1) + end = datetime(2026, 9, 7, 23, 59, 59) + counts = functions["_dashboard_document_upload_counts"](start, end) + assert counts == {"personal": 5, "group": 2, "public": 1} + assert "GROUP BY c.workspace_type" in container.query["query"] + assert container.query["enable_cross_partition_query"] is True + + +def test_period_deltas_and_custom_range_boundaries(): + functions = _function_namespace( + ROUTE, + {"_dashboard_metric", "_dashboard_parse_period"}, + {"datetime": datetime, "timedelta": timedelta}, + ) + metric = functions["_dashboard_metric"] + assert metric(15, 10) == { + "value": 15, + "delta": 5, + "previous": 10, + "percent_change": 50.0, + } + assert metric(7, 0)["percent_change"] is None + assert metric(0, 0)["percent_change"] == 0.0 + assert metric(5)["delta"] is None + + parse_period = functions["_dashboard_parse_period"] + start, end, previous_start, previous_end, days = parse_period({"days": "7"}) + assert days == 7 + assert (end - start).days == 6 + assert previous_end + timedelta(microseconds=1) == start + assert (start - previous_start).days == 7 + + custom_start, custom_end, _, _, custom_days = parse_period({ + "start_date": "2026-09-01", + "end_date": "2026-09-30", + }) + assert custom_start.date().isoformat() == "2026-09-01" + assert custom_end.date().isoformat() == "2026-09-30" + assert custom_days == 30 + try: + parse_period({"days": "8"}) + except ValueError: + pass + else: + raise AssertionError("Unsupported preset range should be rejected.") + + +def test_dashboard_cache_expires_and_can_be_bypassed(): + functions = _function_namespace( + ROUTE, + {"_dashboard_cache_get", "_dashboard_cache_set"}, + { + "_control_center_dashboard_cache": {}, + "CONTROL_CENTER_DASHBOARD_CACHE_TTL_SECONDS": 90, + "CONTROL_CENTER_DASHBOARD_CACHE_MAX_ENTRIES": 128, + "time": time, + }, + ) + functions["_dashboard_cache_set"]("range", {"value": 3}, now=10) + assert functions["_dashboard_cache_get"]("range", now=99) == {"value": 3} + assert functions["_dashboard_cache_get"]("range", force_refresh=True, now=99) is None + assert functions["_dashboard_cache_get"]("range", now=100) is None + + +def test_dashboard_reader_can_call_summary_and_insights_routes(): + route_source = ROUTE.read_text(encoding="utf-8") + route_tree = ast.parse(route_source) + route_functions = { + node.name: node + for node in ast.walk(route_tree) + if isinstance(node, ast.FunctionDef) + } + for name in ( + "api_v2_control_center_dashboard_summary", + "api_v2_control_center_dashboard_insights", + ): + route = route_functions[name] + decorators = [ + ast.unparse(item) for item in route.decorator_list + ] + assert any("swagger_route" in item and "get_auth_security" in item for item in decorators) + assert "login_required" in decorators + assert "control_center_required('dashboard')" in decorators + + auth_tree = ast.parse(AUTH.read_text(encoding="utf-8")) + capabilities_node = next( + node for node in auth_tree.body + if isinstance(node, ast.FunctionDef) and node.name == "get_control_center_capabilities" + ) + namespace = {} + exec(compile(ast.Module(body=[capabilities_node], type_ignores=[]), str(AUTH), "exec"), namespace) + capabilities = namespace["get_control_center_capabilities"] + reader_settings = { + "require_member_of_control_center_admin": True, + "require_member_of_control_center_dashboard_reader": True, + } + assert capabilities({"roles": ["ControlCenterDashboardReader"]}, reader_settings)["can_view_dashboard"] + assert not capabilities({"roles": ["User"]}, reader_settings)["can_view_dashboard"] + + +def test_summary_and_insights_cover_recorded_dashboard_fields(): + source = ROUTE.read_text(encoding="utf-8") + for required in ( + "cosmos_user_settings_container", + "c.settings.access.status = 'deny'", + "conversation_creation", + "document_creation", + "usage.total_tokens", + "cosmos_approvals_container", + "GROUP BY c.status", + "workspace_context.group_id", + "workspace_context.public_workspace_id", + "c.usage.model", + "login_heatmap", + "force_refresh", + "CONTROL_CENTER_DASHBOARD_CACHE_TTL_SECONDS = 90", + ): + assert required in source, f"Dashboard contract is missing {required!r}." + + +def test_version_is_at_least_implementation_version(): + assert_app_version_at_least("0.261.279") + + +TESTS = [ + test_group_and_workspace_status_counts_use_real_statuses, + test_document_uploads_are_aggregated_by_recorded_workspace_type, + test_period_deltas_and_custom_range_boundaries, + test_dashboard_cache_expires_and_can_be_bypassed, + test_dashboard_reader_can_call_summary_and_insights_routes, + test_summary_and_insights_cover_recorded_dashboard_fields, + test_version_is_at_least_implementation_version, +] + + +if __name__ == "__main__": + for test in TESTS: + test() + print(f"PASS {test.__name__}") + print(f"{len(TESTS)}/{len(TESTS)} dashboard checks passed") diff --git a/ui_tests/test_v2_control_center_dashboard.py b/ui_tests/test_v2_control_center_dashboard.py new file mode 100644 index 000000000..a0f0f7640 --- /dev/null +++ b/ui_tests/test_v2_control_center_dashboard.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# test_v2_control_center_dashboard.py +""" +Browser coverage for the V2 Control Center dashboard. +Version: 0.261.279 +Implemented in: 0.261.279 + +Validates dashboard loading, range selection, chart access, drill-through links, +CSV export, and chat-with-trends against a local SPA fixture. +""" + +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import Page, Route, expect + +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from v2_notification_stubs import is_notification_count, notification_count_payload + + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "application" / "single_app" / "static" +SPA_INDEX = STATIC / "v2" / "index.html" +ORIGIN = "http://simplechat.test" +TODAY = "2026-10-07" + + +def metric(value, delta=None, previous=None): + return { + "value": value, + "delta": delta, + "previous": previous, + "percent_change": None if previous in (None, 0) else round((delta or 0) / previous * 100, 1), + } + + +def dashboard_payload(): + return { + "period": {"start_date": "2026-09-08", "end_date": TODAY, "days": 30, "timezone": "UTC"}, + "refreshed_at": "2026-10-07T12:00:00Z", + "users": { + "total": metric(100), + "active": metric(20, 2, 18), + "dau": metric(5), + "wau": metric(12), + "mau": metric(20), + "blocked": metric(3), + }, + "groups": { + "total": metric(8), + "by_status": { + "active": metric(5), + "locked": metric(1), + "upload_disabled": metric(1), + "inactive": metric(1), + }, + }, + "public_workspaces": { + "total": metric(4), + "by_status": { + "active": metric(2), + "locked": metric(1), + "upload_disabled": metric(0), + "inactive": metric(1), + }, + }, + "conversations": metric(10, 2, 8), + "document_uploads": { + "total": metric(6, 1, 5), + "by_workspace_type": { + "personal": metric(3, 1, 2), + "group": metric(2, 0, 2), + "public": metric(1, 0, 1), + }, + }, + "document_processing_failures": {**metric(1, 1, 0), "available": True}, + "tokens": metric(2400, 400, 2000), + "pending_approvals": metric(2), + "status_history_available": False, + "cached": False, + } + + +class DashboardFixture: + def __init__(self, page: Page): + self.page = page + self.requests = [] + self.unexpected = [] + page.route("**/*", self._route) + + def _route(self, route: Route): + request = route.request + parsed = urlsplit(request.url) + path = parsed.path + if f"{parsed.scheme}://{parsed.netloc}" != ORIGIN: + self.unexpected.append(request.url) + route.abort() + return + if request.method == "GET" and path.startswith("/v2"): + route.fulfill(path=str(SPA_INDEX), content_type="text/html") + elif request.method == "GET" and path.startswith("/static/"): + asset = (STATIC / path.removeprefix("/static/")).resolve() + if asset.is_relative_to(STATIC.resolve()) and asset.is_file(): + route.fulfill(path=str(asset)) + else: + self.unexpected.append(path) + route.fulfill(status=404, body="Fixture asset not found") + elif path == "/api/v2/bootstrap" and request.method == "GET": + route.fulfill(json={ + "version": "0.261.279", + "user": {"id": "dashboard-reader", "display_name": "Dashboard Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, + "branding": {"app_title": "SimpleChat", "show_logo": False, "hide_app_title": False}, + "features": {}, + "control_center": { + "can_view_dashboard": True, + "can_manage_users": False, + "can_manage_groups": False, + "can_manage_workspaces": False, + "can_view_activity_logs": False, + "can_run_maintenance": False, + }, + "catalogs": {"models": [], "agents": [], "prompts": [], "initial_model_selection": None}, + "scope": {"groups": [], "public_workspaces": []}, + "navigation": { + "custom_pages": {"enabled": False, "items": []}, + "external_links": {"enabled": False, "items": []}, + }, + "workspace": {"sections": {}}, + "admin_nav": [], + "notices": {"ai": {}, "web_search": {}}, + "settings": {}, + }) + elif path == "/api/user/settings" and request.method == "GET": + route.fulfill(json={"settings": {}}) + elif is_notification_count(request.method, path): + route.fulfill(json=notification_count_payload()) + elif path == "/api/admin/control-center/token-filters" and request.method == "GET": + route.fulfill(json={"success": True, "filters": { + "users": [{"id": "user-1", "label": "User One"}], + "groups": [{"id": "group-1", "name": "Group One"}], + "public_workspaces": [{"id": "workspace-1", "name": "Workspace One"}], + "models": [{"value": "gpt-4o", "label": "gpt-4o"}], + "workspace_types": [ + {"value": "personal", "label": "Personal"}, + {"value": "group", "label": "Group"}, + {"value": "public", "label": "Public"}, + ], + "token_types": [ + {"value": "chat", "label": "Chat"}, + {"value": "embedding", "label": "Embedding"}, + {"value": "web_search", "label": "Web Search"}, + ], + }}) + elif path == "/api/v2/control-center/dashboard/summary" and request.method == "GET": + self.requests.append(("GET", path, parsed.query)) + route.fulfill(json=dashboard_payload()) + elif path == "/api/v2/control-center/dashboard/insights" and request.method == "GET": + self.requests.append(("GET", path, parsed.query)) + route.fulfill(json={ + "period": {"start_date": "2026-09-08", "end_date": TODAY, "days": 30, "timezone": "UTC"}, + "token_usage_by_model": [ + {"date": "2026-10-06", "model": "gpt-4o", "tokens": 1500}, + ], + "top_tokens": { + "users": [{"id": "user-1", "tokens": 1500}], + "groups": [{"id": "group-1", "tokens": 700}], + "public_workspaces": [{"id": "workspace-1", "tokens": 200}], + }, + "top_activity": { + "users": [{"id": "user-1", "activity_count": 9}], + "groups": [{"id": "group-1", "activity_count": 5}], + "public_workspaces": [{"id": "workspace-1", "activity_count": 2}], + }, + "login_heatmap": { + "weekday_convention": "Monday=0 through Sunday=6", + "timezone": "UTC", + "cells": [{"weekday": 0, "hour": 9, "count": 2}], + }, + }) + elif path == "/api/admin/control-center/activity-trends" and request.method == "GET": + self.requests.append(("GET", path, parsed.query)) + days = ["2026-10-01", "2026-10-02", "2026-10-03"] + route.fulfill(json={"success": True, "activity_data": { + "chats": {date: index + 1 for index, date in enumerate(days)}, + "logins": {date: index + 2 for index, date in enumerate(days)}, + "personal_documents_created": {date: 1 for date in days}, + "group_documents_created": {date: 1 for date in days}, + "public_documents_created": {date: 1 for date in days}, + "tokens": {date: {"chat": 20, "embedding": 10, "web_search": 0} for date in days}, + }}) + elif path == "/api/admin/control-center/activity-trends/export" and request.method == "POST": + self.requests.append(("POST", path, json.loads(request.post_data or "{}"))) + route.fulfill(status=200, content_type="text/csv", body="date,count\n2026-10-01,3\n") + elif path == "/api/admin/control-center/activity-trends/chat" and request.method == "POST": + self.requests.append(("POST", path, json.loads(request.post_data or "{}"))) + route.fulfill(json={"success": True, "conversation_id": "conversation-123"}) + else: + self.unexpected.append(f"{request.method} {path}") + route.fulfill(status=404, json={"error": "Unexpected fixture request."}) + + def open(self): + if not SPA_INDEX.is_file(): + pytest.fail("Build the V2 SPA first: npm --prefix application/v2_ui run build") + self.page.set_viewport_size({"width": 1440, "height": 900}) + self.page.goto(f"{ORIGIN}/v2/control-center/dashboard", wait_until="networkidle") + + def assert_clean(self): + assert not self.unexpected, self.unexpected + + +@pytest.fixture +def dashboard_ui(page): + fixture = DashboardFixture(page) + yield fixture + fixture.assert_clean() + + +pytestmark = pytest.mark.ui + + +def test_dashboard_reader_loads_metrics_charts_and_drillthrough(dashboard_ui): + dashboard_ui.open() + page = dashboard_ui.page + + expect(page.get_by_role("heading", name="Dashboard")).to_be_visible() + expect(page.get_by_text("Total users", exact=True)).to_be_visible() + expect(page.get_by_text("100", exact=True)).to_be_visible() + expect(page.get_by_role("heading", name="Token usage by model")).to_be_visible() + expect(page.get_by_role("img", name="Daily conversation creation and login counts. Select a point to open filtered activity logs.")).to_be_visible() + expect(page.get_by_role("table", name="Logins by weekday and hour (UTC; Monday = 0)")).to_be_visible() + model_filter = page.locator("#dashboard-filter-model") + expect(model_filter).to_have_value("") + model_filter.select_option("gpt-4o") + expect(model_filter).to_have_value("gpt-4o") + page.get_by_role("link", name="Groups locked: 1").click() + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/groups?status=locked") + + +def test_dashboard_range_refresh_export_and_trends_chat(dashboard_ui): + dashboard_ui.open() + page = dashboard_ui.page + + page.get_by_label("Date range").select_option("7") + expect(page.get_by_role("heading", name="Dashboard")).to_be_visible() + assert any( + path == "/api/v2/control-center/dashboard/summary" and "start_date=" in query + for method, path, query in dashboard_ui.requests if method == "GET" + ) + + page.get_by_role("button", name="Refresh").click() + expect(page.get_by_role("heading", name="Dashboard")).to_be_visible() + assert any( + path == "/api/v2/control-center/dashboard/summary" and "force_refresh=1" in query + for method, path, query in dashboard_ui.requests if method == "GET" + ) + + with page.expect_download(): + page.get_by_role("button", name="Export").click() + page.get_by_role("button", name="Chat with these trends").click() + expect(page.get_by_role("link", name="Open conversation")).to_have_attribute("href", "/v2/chat/conversation-123") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-m", "ui"])) From 5350215db27cb2a9fdfdc9417764e730380051ad Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 10:00:51 -0400 Subject: [PATCH 2/3] Guard dashboard metric navigation URLs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../controlCenter/DashboardSection.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 0b2eb9674..3f693e9b4 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.279" +VERSION = "0.261.280" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/controlCenter/DashboardSection.tsx b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx index 6336da35e..9268ebc96 100644 --- a/application/v2_ui/src/components/controlCenter/DashboardSection.tsx +++ b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx @@ -174,6 +174,21 @@ function makeDatasets(series: { label: string; values: number[]; color: keyof ty })); } +function safeControlCenterHref(value: string): string { + try { + const url = new URL(value, window.location.origin); + const isControlCenterPath = url.pathname === '/control-center' + || url.pathname.startsWith('/control-center/'); + if (url.origin !== window.location.origin + || (!isControlCenterPath && url.pathname !== '/approvals')) { + return '/control-center'; + } + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return '/control-center'; + } +} + function MetricTile({ label, metric, @@ -191,7 +206,7 @@ function MetricTile({ ? 'Not tracked' : valueLabel ?? metric.value.toLocaleString(); return ( - + ); From d8593ccc8ee473514c6ee9cf9209a0d8b6fd25d1 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 10:18:54 -0400 Subject: [PATCH 3/3] Prevent dashboard date errors exposing exception details Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../route_backend_control_center.py | 11 ++++--- .../explanation/features/V2_CONTROL_CENTER.md | 2 ++ docs/explanation/release_notes.md | 8 +++++ .../test_v2_control_center_dashboard.py | 31 +++++++++++++++++-- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 3fdae93d6..02cd312fd 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -57,6 +57,9 @@ CONTROL_CENTER_MANAGEMENT_MAX_PER_PAGE = 250 CONTROL_CENTER_DASHBOARD_CACHE_TTL_SECONDS = 90 CONTROL_CENTER_DASHBOARD_CACHE_MAX_ENTRIES = 128 +DASHBOARD_INVALID_RANGE_ERROR = ( + "Invalid dashboard date range. Use 7, 30, or 90 days or a valid custom range of up to 366 days." +) _control_center_dashboard_cache = {} # The answers an approved ownership change gives when the group's current copy no @@ -5646,8 +5649,8 @@ def api_v2_control_center_dashboard_summary(): start_date, end_date, previous_start, previous_end, period_days = ( _dashboard_parse_period(request.args) ) - except ValueError as ex: - return jsonify({'error': str(ex)}), 400 + except ValueError: + return jsonify({'error': DASHBOARD_INVALID_RANGE_ERROR}), 400 token_filters = extract_token_filters(request.args) cache_key = ( @@ -5868,8 +5871,8 @@ def api_v2_control_center_dashboard_insights(): """Return aggregate-only dashboard insights available from recorded activity fields.""" try: start_date, end_date, _, _, period_days = _dashboard_parse_period(request.args) - except ValueError as ex: - return jsonify({'error': str(ex)}), 400 + except ValueError: + return jsonify({'error': DASHBOARD_INVALID_RANGE_ERROR}), 400 token_filters = extract_token_filters(request.args) cache_key = ( diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index 0c88c548b..22d2a2451 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -21,6 +21,8 @@ The Dashboard is available to users with `can_view_dashboard`, including users a The date presets are 7, 30, and 90 UTC calendar days. Custom ranges use `start_date` and `end_date` in `YYYY-MM-DD` format and are limited to 366 days. The trend charts reuse the existing activity-trends and token-filter APIs, CSV export, and “Chat with these trends” endpoint. Chart data is grouped from the fields written by `functions_activity_logging.py`: `user_login.timestamp`, creation activity types and `workspace_type`, and `token_usage.usage.model`, `usage.total_tokens`, `token_type`, `user_id`, and `workspace_context` IDs. The login heatmap uses UTC and Monday=0. +Invalid dashboard date ranges return a generic validation error rather than exposing exception details. + Group and public-workspace status counts use the stored `status` values (`active`, `locked`, `upload_disabled`, `inactive`); missing values count as active. Unknown group statuses count as active, matching the group permission default, while unknown public-workspace statuses count as inactive, matching its fail-closed permission behavior. Current user, blocked-user, group/workspace status, and pending-approval counts are snapshots. The application does not retain historical snapshots for those dimensions, so their period deltas are intentionally unavailable. Period deltas are shown for login activity, conversations, document creations, document processing failures, and tokens. Processing failures are counted only when a document's stored status text contains “failed” or “error”; when that query is unavailable, the dashboard shows the metric as unavailable rather than zero. Pending approvals are omitted when the aggregate query cannot be completed. ### Control Center query-parameter contract diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 1ca9b3947..e26654554 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,14 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.280)** + +#### Bug Fixes + +* **Dashboard Date-Range Error Safety** + * Dashboard summary and insights APIs now return a generic validation message for invalid date ranges instead of returning exception text. + * (Ref: `route_backend_control_center.py`, `DASHBOARD_INVALID_RANGE_ERROR`) + ### **(v0.261.279)** #### New Features diff --git a/functional_tests/test_v2_control_center_dashboard.py b/functional_tests/test_v2_control_center_dashboard.py index 49bd9c47b..8afe17c38 100644 --- a/functional_tests/test_v2_control_center_dashboard.py +++ b/functional_tests/test_v2_control_center_dashboard.py @@ -2,8 +2,8 @@ # test_v2_control_center_dashboard.py """ Functional test for the V2 Control Center dashboard. -Version: 0.261.279 -Implemented in: 0.261.279 +Version: 0.261.280 +Implemented in: 0.261.280 This test validates status aggregation, period comparisons, bounded cache behavior, dashboard-reader authorization, and the summary and insights route contracts. @@ -211,8 +211,32 @@ def test_summary_and_insights_cover_recorded_dashboard_fields(): assert required in source, f"Dashboard contract is missing {required!r}." +def test_invalid_period_errors_do_not_expose_exception_text(): + route_tree = ast.parse(ROUTE.read_text(encoding="utf-8")) + route_functions = { + node.name: node + for node in ast.walk(route_tree) + if isinstance(node, ast.FunctionDef) + } + for name in ( + "api_v2_control_center_dashboard_summary", + "api_v2_control_center_dashboard_insights", + ): + handlers = [ + handler + for node in ast.walk(route_functions[name]) + if isinstance(node, ast.Try) + for handler in node.handlers + if isinstance(handler.type, ast.Name) and handler.type.id == "ValueError" + ] + assert len(handlers) == 1 + handler_source = "\n".join(ast.unparse(statement) for statement in handlers[0].body) + assert "str(ex)" not in handler_source + assert "DASHBOARD_INVALID_RANGE_ERROR" in handler_source + + def test_version_is_at_least_implementation_version(): - assert_app_version_at_least("0.261.279") + assert_app_version_at_least("0.261.280") TESTS = [ @@ -222,6 +246,7 @@ def test_version_is_at_least_implementation_version(): test_dashboard_cache_expires_and_can_be_bypassed, test_dashboard_reader_can_call_summary_and_insights_routes, test_summary_and_insights_cover_recorded_dashboard_fields, + test_invalid_period_errors_do_not_expose_exception_text, test_version_is_at_least_implementation_version, ]