diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index 3dea3258f..e3771ba15 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -18,6 +18,7 @@ calculate_next_control_center_auto_refresh_run, execute_control_center_refresh, get_control_center_auto_refresh_schedule, + is_control_center_auto_refresh_due, parse_control_center_auto_refresh_datetime, ) from functions_cosmos_throughput import ( @@ -308,13 +309,14 @@ def _seed_control_center_auto_refresh_next_run(settings, current_time): 'control_center_auto_refresh_time': schedule['time'], 'control_center_auto_refresh_hour': schedule['hour'], 'control_center_auto_refresh_minute': schedule['minute'], + 'control_center_auto_refresh_timezone': schedule['timezone'], 'control_center_auto_refresh_next_run': next_run.isoformat(), }) return next_run def check_control_center_auto_refresh_once(): - """Run the scheduled Control Center refresh when its daily UTC schedule is due.""" + """Run the scheduled Control Center refresh when its UTC timestamp is due.""" settings = get_settings() if not settings.get('control_center_auto_refresh_enabled', True): return None @@ -325,12 +327,15 @@ def check_control_center_auto_refresh_once(): _seed_control_center_auto_refresh_next_run(settings, current_time) return None - if current_time < next_run: + if not is_control_center_auto_refresh_due(settings, current_time=current_time): return None lock_document = acquire_distributed_task_lock('control_center_auto_refresh', lease_seconds=7200) if not lock_document: - debug_print('Skipping Control Center auto-refresh because another worker holds the lease.') + log_event( + '[ControlCenterAutoRefresh] Skipped scheduled refresh because another worker holds the lease.', + debug_only=True, + ) return None try: @@ -343,11 +348,32 @@ def check_control_center_auto_refresh_once(): if not next_run: _seed_control_center_auto_refresh_next_run(settings, current_time) return None - if current_time < next_run: + if not is_control_center_auto_refresh_due(settings, current_time=current_time): return None - print(f"Executing scheduled Control Center auto-refresh at {current_time.isoformat()}") - return execute_control_center_refresh(manual_execution=False) + schedule = get_control_center_auto_refresh_schedule(settings) + log_event( + '[ControlCenterAutoRefresh] Starting scheduled Control Center metrics refresh.', + extra={ + 'scheduled_run_utc': next_run.isoformat(), + 'schedule_time': schedule['time'], + 'schedule_timezone': schedule['timezone'], + }, + level=logging.INFO, + ) + result = execute_control_center_refresh(manual_execution=False) + log_event( + '[ControlCenterAutoRefresh] Scheduled Control Center metrics refresh completed.', + extra={ + 'success': bool(result and result.get('success')), + 'refreshed_users': result.get('refreshed_users', 0) if result else 0, + 'failed_users': result.get('failed_users', 0) if result else 0, + 'refreshed_groups': result.get('refreshed_groups', 0) if result else 0, + 'failed_groups': result.get('failed_groups', 0) if result else 0, + }, + level=logging.INFO, + ) + return result finally: release_distributed_task_lock(lock_document) @@ -394,8 +420,12 @@ def run_control_center_auto_refresh_loop(): try: check_control_center_auto_refresh_once() except Exception as exc: - print(f"Error in Control Center auto-refresh check: {exc}") - log_event(f"Error in Control Center auto-refresh check: {exc}", level=logging.ERROR) + log_event( + '[ControlCenterAutoRefresh] Error checking the scheduled Control Center refresh.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) time.sleep(300) diff --git a/application/single_app/functions_control_center.py b/application/single_app/functions_control_center.py index 2d395ba85..d50ea0859 100644 --- a/application/single_app/functions_control_center.py +++ b/application/single_app/functions_control_center.py @@ -1,23 +1,31 @@ # functions_control_center.py """ Functions for Control Center operations including scheduled auto-refresh. -Version: 0.241.029 +Version: 0.250.102 """ -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + from config import cosmos_user_settings_container, cosmos_groups_container from functions_debug import debug_print from functions_settings import get_settings, update_settings from functions_appinsights import log_event -CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR = 6 +CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR = 2 CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE = 0 -CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME = '06:00' +CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME = '02:00' +CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE = 'America/New_York' -def normalize_control_center_auto_refresh_time(schedule_time=None, schedule_hour=None, schedule_minute=None): - """Return a normalized UTC daily refresh schedule.""" +def normalize_control_center_auto_refresh_time( + schedule_time=None, + schedule_hour=None, + schedule_minute=None, + schedule_timezone=None, +): + """Return a normalized daily refresh rule with an IANA timezone.""" normalized_hour = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR normalized_minute = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE @@ -47,10 +55,21 @@ def normalize_control_center_auto_refresh_time(schedule_time=None, schedule_hour except (TypeError, ValueError): pass + normalized_timezone = ( + schedule_timezone.strip() + if isinstance(schedule_timezone, str) and schedule_timezone.strip() + else CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE + ) + try: + ZoneInfo(normalized_timezone) + except (ZoneInfoNotFoundError, ValueError): + normalized_timezone = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE + return { 'hour': normalized_hour, 'minute': normalized_minute, 'time': f"{normalized_hour:02d}:{normalized_minute:02d}", + 'timezone': normalized_timezone, } @@ -61,11 +80,12 @@ def get_control_center_auto_refresh_schedule(settings=None): settings.get('control_center_auto_refresh_time'), settings.get('control_center_auto_refresh_hour'), settings.get('control_center_auto_refresh_minute'), + settings.get('control_center_auto_refresh_timezone'), ) def calculate_next_control_center_auto_refresh_run(settings=None, current_time=None): - """Calculate the next UTC daily Control Center auto-refresh run time.""" + """Calculate the next daily Control Center refresh as a UTC datetime.""" current_time = current_time or datetime.now(timezone.utc) if current_time.tzinfo is None: current_time = current_time.replace(tzinfo=timezone.utc) @@ -73,16 +93,18 @@ def calculate_next_control_center_auto_refresh_run(settings=None, current_time=N current_time = current_time.astimezone(timezone.utc) schedule = get_control_center_auto_refresh_schedule(settings) - next_run = current_time.replace( + schedule_timezone = ZoneInfo(schedule['timezone']) + local_current_time = current_time.astimezone(schedule_timezone) + next_run_local = local_current_time.replace( hour=schedule['hour'], minute=schedule['minute'], second=0, microsecond=0, ) - if next_run <= current_time: - next_run += timedelta(days=1) + if next_run_local <= local_current_time: + next_run_local += timedelta(days=1) - return next_run + return next_run_local.astimezone(timezone.utc) def parse_control_center_auto_refresh_datetime(timestamp_value): @@ -103,6 +125,27 @@ def parse_control_center_auto_refresh_datetime(timestamp_value): return None +def is_control_center_auto_refresh_due(settings=None, current_time=None): + """Return whether an enabled schedule has reached its saved UTC next run.""" + settings = settings or {} + if not settings.get('control_center_auto_refresh_enabled', True): + return False + + next_run = parse_control_center_auto_refresh_datetime( + settings.get('control_center_auto_refresh_next_run') + ) + if not next_run: + return False + + current_time = current_time or datetime.now(timezone.utc) + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + else: + current_time = current_time.astimezone(timezone.utc) + + return current_time >= next_run + + def execute_control_center_refresh(manual_execution=False): """ Execute Control Center data refresh operation. @@ -196,6 +239,7 @@ def execute_control_center_refresh(manual_execution=False): settings['control_center_auto_refresh_time'] = schedule['time'] settings['control_center_auto_refresh_hour'] = schedule['hour'] settings['control_center_auto_refresh_minute'] = schedule['minute'] + settings['control_center_auto_refresh_timezone'] = schedule['timezone'] # Calculate next scheduled auto-refresh time if enabled if settings.get('control_center_auto_refresh_enabled', True): diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 4eb0ca77f..300531924 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -837,9 +837,10 @@ def get_settings(use_cosmos=False, include_source=False): # Control Center settings 'control_center_last_refresh': None, # Timestamp of last data refresh 'control_center_auto_refresh_enabled': True, - 'control_center_auto_refresh_time': '06:00', - 'control_center_auto_refresh_hour': 6, + 'control_center_auto_refresh_time': '02:00', + 'control_center_auto_refresh_hour': 2, 'control_center_auto_refresh_minute': 0, + 'control_center_auto_refresh_timezone': 'America/New_York', 'control_center_auto_refresh_next_run': None, # -- Your entire default dictionary here -- 'app_title': 'Simple Chat', @@ -1376,9 +1377,33 @@ def _format_result(settings_payload, source): level=logging.WARNING ) + legacy_control_center_schedule = ( + 'control_center_auto_refresh_timezone' not in settings_item + ) + legacy_control_center_time = settings_item.get('control_center_auto_refresh_time') + if not isinstance(legacy_control_center_time, str): + legacy_hour = settings_item.get('control_center_auto_refresh_hour', 6) + legacy_minute = settings_item.get('control_center_auto_refresh_minute', 0) + if not isinstance(legacy_hour, int): + legacy_hour = 6 + if not isinstance(legacy_minute, int): + legacy_minute = 0 + legacy_control_center_time = f"{legacy_hour:02d}:{legacy_minute:02d}" + # Merge default_settings in, to fill in any missing or nested keys merge_changed = deep_merge_dicts(default_settings, settings_item) merged = settings_item + control_center_schedule_migration_updated = False + if legacy_control_center_schedule: + if legacy_control_center_time == '06:00': + merged['control_center_auto_refresh_time'] = '02:00' + merged['control_center_auto_refresh_hour'] = 2 + merged['control_center_auto_refresh_minute'] = 0 + merged['control_center_auto_refresh_timezone'] = 'America/New_York' + else: + merged['control_center_auto_refresh_timezone'] = 'UTC' + merged['control_center_auto_refresh_next_run'] = None + control_center_schedule_migration_updated = True migration_updated = apply_custom_endpoint_setting_migration(merged) assignment_settings_updated = normalize_group_workflow_assignment_settings(merged) promoted_popular_settings_updated = normalize_agents_page_promoted_popular_settings(merged) @@ -1390,6 +1415,7 @@ def _format_result(settings_payload, source): # If merging added anything new, upsert back to Cosmos so future reads remain up to date if ( merge_changed + or control_center_schedule_migration_updated or migration_updated or assignment_settings_updated or promoted_popular_settings_updated diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index d0ff5016f..a9d3570c7 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -63,4 +63,5 @@ aiohttp==3.14.1 html2text==2025.4.15 matplotlib==3.10.7 azure-cognitiveservices-speech==1.48.2 -playwright==1.58.0 \ No newline at end of file +playwright==1.58.0 +tzdata==2026.3 diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index c820fd6bb..e64d50f87 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -11,6 +11,11 @@ from config import * from functions_authentication import * from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version +from functions_control_center import ( + calculate_next_control_center_auto_refresh_run, + get_control_center_auto_refresh_schedule, + parse_control_center_auto_refresh_datetime, +) from functions_settings import * from functions_logging import * from functions_activity_logging import * @@ -5873,14 +5878,32 @@ def api_get_refresh_status(): Get the last refresh timestamp for Control Center data. """ try: - from functions_settings import get_settings - settings = get_settings() last_refresh = settings.get('control_center_last_refresh') - + last_refresh_datetime = parse_control_center_auto_refresh_datetime(last_refresh) + auto_refresh_enabled = settings.get('control_center_auto_refresh_enabled', True) + auto_refresh_schedule = get_control_center_auto_refresh_schedule(settings) + auto_refresh_next_run = parse_control_center_auto_refresh_datetime( + settings.get('control_center_auto_refresh_next_run') + ) + if auto_refresh_enabled and not auto_refresh_next_run: + auto_refresh_next_run = calculate_next_control_center_auto_refresh_run(settings) + return jsonify({ 'last_refresh': last_refresh, - 'last_refresh_formatted': None if not last_refresh else datetime.fromisoformat(last_refresh.replace('Z', '+00:00') if 'Z' in last_refresh else last_refresh).strftime('%m/%d/%Y %I:%M %p UTC') + 'last_refresh_formatted': ( + last_refresh_datetime.strftime('%m/%d/%Y %I:%M %p UTC') + if last_refresh_datetime + else None + ), + 'auto_refresh_enabled': auto_refresh_enabled, + 'auto_refresh_time': auto_refresh_schedule['time'], + 'auto_refresh_timezone': auto_refresh_schedule['timezone'], + 'auto_refresh_next_run_utc': ( + auto_refresh_next_run.isoformat() + if auto_refresh_next_run + else None + ), }), 200 except Exception as e: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 1cc72aeb4..eef98059d 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -591,6 +591,14 @@ def admin_settings(): settings['control_center_auto_refresh_time'] = control_center_auto_refresh_schedule['time'] settings['control_center_auto_refresh_hour'] = control_center_auto_refresh_schedule['hour'] settings['control_center_auto_refresh_minute'] = control_center_auto_refresh_schedule['minute'] + settings['control_center_auto_refresh_timezone'] = control_center_auto_refresh_schedule['timezone'] + if ( + settings['control_center_auto_refresh_enabled'] + and not settings.get('control_center_auto_refresh_next_run') + ): + settings['control_center_auto_refresh_next_run'] = ( + calculate_next_control_center_auto_refresh_run(settings).isoformat() + ) settings.update(normalize_cosmos_throughput_settings(settings)) cosmos_resource_config = get_cosmos_resource_config(settings) settings['cosmos_throughput_resolved_subscription_id'] = cosmos_resource_config.get('subscription_id', '') @@ -1146,19 +1154,25 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul control_center_auto_refresh_enabled = form_data.get('control_center_auto_refresh_enabled') == 'on' incoming_control_center_auto_refresh_time = form_data.get( 'control_center_auto_refresh_time', - settings.get('control_center_auto_refresh_time', '06:00') + settings.get('control_center_auto_refresh_time', '02:00') + ) + incoming_control_center_auto_refresh_timezone = form_data.get( + 'control_center_auto_refresh_timezone', + settings.get('control_center_auto_refresh_timezone', 'America/New_York'), ) control_center_auto_refresh_schedule = get_control_center_auto_refresh_schedule({ 'control_center_auto_refresh_time': incoming_control_center_auto_refresh_time, - 'control_center_auto_refresh_hour': settings.get('control_center_auto_refresh_hour', 6), + 'control_center_auto_refresh_hour': settings.get('control_center_auto_refresh_hour', 2), 'control_center_auto_refresh_minute': settings.get('control_center_auto_refresh_minute', 0), + 'control_center_auto_refresh_timezone': incoming_control_center_auto_refresh_timezone, }) existing_control_center_auto_refresh_schedule = get_control_center_auto_refresh_schedule(settings) existing_control_center_auto_refresh_enabled = settings.get('control_center_auto_refresh_enabled', True) existing_control_center_auto_refresh_next_run = settings.get('control_center_auto_refresh_next_run') control_center_auto_refresh_schedule_changed = ( control_center_auto_refresh_enabled != existing_control_center_auto_refresh_enabled or - control_center_auto_refresh_schedule['time'] != existing_control_center_auto_refresh_schedule['time'] + control_center_auto_refresh_schedule['time'] != existing_control_center_auto_refresh_schedule['time'] or + control_center_auto_refresh_schedule['timezone'] != existing_control_center_auto_refresh_schedule['timezone'] ) if control_center_auto_refresh_enabled: if control_center_auto_refresh_schedule_changed or not existing_control_center_auto_refresh_next_run: @@ -1167,6 +1181,7 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul 'control_center_auto_refresh_time': control_center_auto_refresh_schedule['time'], 'control_center_auto_refresh_hour': control_center_auto_refresh_schedule['hour'], 'control_center_auto_refresh_minute': control_center_auto_refresh_schedule['minute'], + 'control_center_auto_refresh_timezone': control_center_auto_refresh_schedule['timezone'], }, current_time=datetime.now(timezone.utc), ).isoformat() @@ -2706,6 +2721,7 @@ def is_valid_url(url): 'control_center_auto_refresh_time': control_center_auto_refresh_schedule['time'], 'control_center_auto_refresh_hour': control_center_auto_refresh_schedule['hour'], 'control_center_auto_refresh_minute': control_center_auto_refresh_schedule['minute'], + 'control_center_auto_refresh_timezone': control_center_auto_refresh_schedule['timezone'], 'control_center_auto_refresh_next_run': control_center_auto_refresh_next_run, } diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index cec79346b..99c7a5c06 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -4769,27 +4769,50 @@ async function loadRefreshStatus() { try { const response = await fetch('/api/admin/control-center/refresh-status'); - if (response.ok) { - const result = await response.json(); - const lastRefreshElement = document.getElementById('lastRefreshTime'); - - if (lastRefreshElement) { - if (result.last_refresh_formatted) { - lastRefreshElement.textContent = result.last_refresh_formatted; - if (lastRefreshElement.parentElement) { - lastRefreshElement.parentElement.style.display = ''; - } - } else { - lastRefreshElement.textContent = 'Never'; - if (lastRefreshElement.parentElement) { - lastRefreshElement.parentElement.style.display = ''; + if (!response.ok) { + console.error('Failed to load refresh status:', response.status); + return; + } + + const result = await response.json(); + const resolvedViewerTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const viewerTimezone = resolvedViewerTimezone || 'local time'; + const timestampFormatOptions = { + dateStyle: 'medium', + timeStyle: 'short', + }; + if (resolvedViewerTimezone) { + timestampFormatOptions.timeZone = resolvedViewerTimezone; + } + const lastRefreshElement = document.getElementById('lastRefreshTime'); + const autoRefreshInfoElement = document.getElementById('autoRefreshInfo'); + const autoRefreshStatusElement = document.getElementById('autoRefreshStatus'); + + if (lastRefreshElement) { + if (result.last_refresh) { + const lastRefresh = new Date(result.last_refresh); + lastRefreshElement.textContent = Number.isNaN(lastRefresh.getTime()) + ? 'Unavailable' + : `${lastRefresh.toLocaleString([], timestampFormatOptions)} (${viewerTimezone})`; + } else { + lastRefreshElement.textContent = 'Never'; + } + } + + if (autoRefreshInfoElement && autoRefreshStatusElement) { + if (result.auto_refresh_enabled) { + let statusText = `Auto-refresh: daily at ${result.auto_refresh_time} ${result.auto_refresh_timezone}`; + if (result.auto_refresh_next_run_utc) { + const nextRun = new Date(result.auto_refresh_next_run_utc); + if (!Number.isNaN(nextRun.getTime())) { + statusText += ` (next: ${nextRun.toLocaleString([], timestampFormatOptions)} ${viewerTimezone})`; } } + autoRefreshStatusElement.textContent = statusText; + autoRefreshInfoElement.classList.remove('d-none'); } else { - console.warn('lastRefreshTime element not found'); + autoRefreshInfoElement.classList.add('d-none'); } - } else { - console.error('Failed to load refresh status:', response.status); } } catch (error) { console.error('Error loading refresh status:', error); @@ -4798,32 +4821,6 @@ async function loadRefreshStatus() { lastRefreshElement.textContent = 'Error loading'; } } - - // Load and display auto-refresh schedule info - try { - const response = await fetch('/api/admin/control-center/refresh-status'); - if (response.ok) { - const result = await response.json(); - const autoRefreshInfoElement = document.getElementById('autoRefreshInfo'); - const autoRefreshStatusElement = document.getElementById('autoRefreshStatus'); - - if (autoRefreshInfoElement && autoRefreshStatusElement) { - if (result.auto_refresh_enabled) { - // Build status text - let statusText = `Auto-refresh: daily at ${result.auto_refresh_hour_formatted || result.auto_refresh_hour + ':00 UTC'}`; - if (result.auto_refresh_next_run_formatted) { - statusText += ` (next: ${result.auto_refresh_next_run_formatted})`; - } - autoRefreshStatusElement.textContent = statusText; - autoRefreshInfoElement.classList.remove('d-none'); - } else { - autoRefreshInfoElement.classList.add('d-none'); - } - } - } - } catch (autoRefreshError) { - console.error('Error loading auto-refresh status:', autoRefreshError); - } } async function refreshActiveTabContent() { diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 1207a450a..af136449e 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -5369,7 +5369,7 @@
Run the Control Center metrics refresh on a daily UTC schedule
+Run the Control Center metrics refresh overnight on a daily timezone-aware schedule
@@ -5386,25 +5386,40 @@