From 167b50d320b16bdb04739bed01f01af51a515a4f Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Tue, 6 Oct 2026 18:59:17 -0400
Subject: [PATCH 1/5] Bring V2 Operations admin settings to parity with the
classic page
Describe all seven Operations sections in the V2 settings schema so none
fall back to guessed enable_* switches, and add readouts and guides that
show what a save will do before it is made.
- Share the schedule and logging-timer rules between the classic save,
the V2 normalizer and the background checks (functions_logging_timers,
functions_control_center_schedule). Turnoff times are stored in UTC;
legacy naive values are still read as server-local time.
- V2 validates refresh time and timezone, clamps timer durations per
unit, and derives turnoff times and the next refresh on save.
- Add refresh schedule (both zones, Use my timezone), Control Center
access table, logging turnoff, restart-pending, endpoint links, stored
log cleanup, and Document Access Index diagnostics with a DAI link.
- Port the role setup, health check and Swagger modals as in-app guides,
correcting statements the classic modals got wrong.
- Fix the DashboardReader registry entry, rewrite docs/admin/operations.md,
document enable_dai_debug instead of exempting it, bump to 0.261.260.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
application/single_app/admin_app_roles.py | 8 +-
.../single_app/admin_settings_fields.py | 705 ++++++++++++++++--
application/single_app/background_tasks.py | 59 +-
application/single_app/config.py | 2 +-
.../single_app/functions_appinsights.py | 18 +
.../single_app/functions_control_center.py | 150 +---
.../functions_control_center_schedule.py | 263 +++++++
.../single_app/functions_logging_timers.py | 200 +++++
application/single_app/route_backend_v2.py | 68 +-
.../route_frontend_admin_settings.py | 188 ++---
application/single_app/swagger_wrapper.py | 17 +-
.../admin/ControlCenterAccessMatrix.tsx | 123 +++
.../v2_ui/src/components/admin/CopyValue.tsx | 95 +++
.../src/components/admin/EndpointLinks.tsx | 159 ++++
.../admin/FileProcessingLogCleanup.tsx | 211 ++++++
.../components/admin/LoggingTimerStatus.tsx | 97 +++
.../v2_ui/src/components/admin/ReadoutRow.tsx | 80 ++
.../admin/RefreshScheduleStatus.tsx | 156 ++++
.../components/admin/RelatedSectionLink.tsx | 53 ++
.../src/components/admin/RestartStatus.tsx | 66 ++
.../src/components/admin/SettingsSection.tsx | 52 +-
.../v2_ui/src/components/admin/fields.tsx | 93 ++-
.../admin/guides/ControlCenterRolesGuide.tsx | 240 ++++++
.../components/admin/guides/GuideDialog.tsx | 161 ++++
.../admin/guides/HealthCheckGuide.tsx | 157 ++++
.../components/admin/guides/SwaggerGuide.tsx | 66 ++
.../components/admin/guides/sectionGuides.tsx | 76 ++
.../v2_ui/src/components/admin/useNow.ts | 19 +
application/v2_ui/src/lib/adminFields.ts | 78 +-
application/v2_ui/src/lib/adminOperations.ts | 630 ++++++++++++++++
.../v2_ui/src/pages/AdminSettingsPage.tsx | 163 +++-
docs/admin/operations.md | 319 +++++++-
.../COSMOS_PERFORMANCE_OPTIMIZATION_PLAN.md | 2 +-
.../features/V2_ADMIN_OPERATIONS_SETTINGS.md | 160 ++++
docs/reference/logging-tags.md | 1 +
..._admin_update_banner_version_comparison.py | 6 +-
.../test_app_settings_auxiliary_writers.py | 19 +-
...st_control_center_auto_refresh_schedule.py | 23 +-
.../test_docs_app_surface_coverage.py | 4 +-
.../test_v2_admin_capability_placement.py | 24 +-
.../test_v2_admin_operations_derivations.py | 478 ++++++++++++
.../test_v2_admin_operations_logic.ts | 464 ++++++++++++
.../test_v2_admin_operations_parity.py | 496 ++++++++++++
ui_tests/test_v2_admin_operations_settings.py | 373 +++++++++
44 files changed, 6347 insertions(+), 475 deletions(-)
create mode 100644 application/single_app/functions_control_center_schedule.py
create mode 100644 application/single_app/functions_logging_timers.py
create mode 100644 application/v2_ui/src/components/admin/ControlCenterAccessMatrix.tsx
create mode 100644 application/v2_ui/src/components/admin/CopyValue.tsx
create mode 100644 application/v2_ui/src/components/admin/EndpointLinks.tsx
create mode 100644 application/v2_ui/src/components/admin/FileProcessingLogCleanup.tsx
create mode 100644 application/v2_ui/src/components/admin/LoggingTimerStatus.tsx
create mode 100644 application/v2_ui/src/components/admin/ReadoutRow.tsx
create mode 100644 application/v2_ui/src/components/admin/RefreshScheduleStatus.tsx
create mode 100644 application/v2_ui/src/components/admin/RelatedSectionLink.tsx
create mode 100644 application/v2_ui/src/components/admin/RestartStatus.tsx
create mode 100644 application/v2_ui/src/components/admin/guides/ControlCenterRolesGuide.tsx
create mode 100644 application/v2_ui/src/components/admin/guides/GuideDialog.tsx
create mode 100644 application/v2_ui/src/components/admin/guides/HealthCheckGuide.tsx
create mode 100644 application/v2_ui/src/components/admin/guides/SwaggerGuide.tsx
create mode 100644 application/v2_ui/src/components/admin/guides/sectionGuides.tsx
create mode 100644 application/v2_ui/src/components/admin/useNow.ts
create mode 100644 application/v2_ui/src/lib/adminOperations.ts
create mode 100644 docs/explanation/features/V2_ADMIN_OPERATIONS_SETTINGS.md
create mode 100644 functional_tests/test_v2_admin_operations_derivations.py
create mode 100644 functional_tests/test_v2_admin_operations_logic.ts
create mode 100644 functional_tests/test_v2_admin_operations_parity.py
create mode 100644 ui_tests/test_v2_admin_operations_settings.py
diff --git a/application/single_app/admin_app_roles.py b/application/single_app/admin_app_roles.py
index 9ac16fafc..22f3d0864 100644
--- a/application/single_app/admin_app_roles.py
+++ b/application/single_app/admin_app_roles.py
@@ -69,12 +69,14 @@
"role": "ControlCenterDashboardReader",
"label": "Control Center dashboard, read only",
"section_id": "control-center-overview-section",
+ # Independent of the ControlCenterAdmin requirement: control_center_required
+ # and the navigation both admit a reader whether or not that is enforced.
"grants": (
- "The Control Center dashboard without the management features. Only "
- "takes effect while the ControlCenterAdmin requirement is enforced."
+ "The Control Center dashboard without the management features, whether "
+ "or not the ControlCenterAdmin requirement is enforced."
),
"when_off": "There is no dashboard-only tier; access is all or nothing.",
- "depends_on": "require_member_of_control_center_admin",
+ "depends_on": None,
},
{
"key": "require_member_of_create_group",
diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py
index 374c9632f..c0f55f8cf 100644
--- a/application/single_app/admin_settings_fields.py
+++ b/application/single_app/admin_settings_fields.py
@@ -59,8 +59,8 @@
input would otherwise save to a top-level key nothing reads. See
``_apply_nested_paths``.
-The Appearance, Agents & Actions, Chat, Knowledge, Workflow, Workspaces and
-Security groups are described in full. Sections with no entry here fall back to the V2 surface's
+The Appearance, Agents & Actions, Chat, Knowledge, Operations, Workflow, Workspaces
+and Security groups are described in full. Sections with no entry here fall back to the V2 surface's
``enable_*`` scan, so undescribed groups keep working exactly as they did. A
handful of individual fields outside those groups are also declared: that scan
places a key by guessing from shared word stems, and declaring a field is the
@@ -104,6 +104,12 @@
CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH,
normalize_content_safety_violation_message,
)
+from functions_control_center_schedule import (
+ is_valid_control_center_auto_refresh_time,
+ is_valid_control_center_auto_refresh_timezone,
+ normalize_control_center_auto_refresh_time,
+ resolve_control_center_auto_refresh_settings,
+)
from functions_model_endpoint_identity_header import (
DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME,
DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE,
@@ -113,6 +119,11 @@
from functions_group_assignment_ids import (
normalize_group_workflow_allowed_group_ids,
)
+from functions_logging_timers import (
+ LOGGING_TIMERS,
+ clamp_logging_timer_value,
+ resolve_logging_timer_settings,
+)
from functions_m365_transport import M365ProviderError, normalize_m365_transport_settings
from functions_model_endpoint_identity_header import (
DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME,
@@ -221,11 +232,31 @@
"Model endpoints hold credentials that are stripped before they reach "
"the browser, so they cannot be saved from here."
),
+ # Calculated by the server whenever the settings they follow from are saved.
+ # Accepting one directly would let a save move a scheduled refresh or extend a
+ # running debug session without touching the controls that are meant to.
+ "control_center_auto_refresh_next_run": (
+ "The next refresh is calculated from the schedule when it is saved."
+ ),
+ "control_center_auto_refresh_hour": (
+ "The refresh hour is taken from the refresh time when it is saved."
+ ),
+ "control_center_auto_refresh_minute": (
+ "The refresh minute is taken from the refresh time when it is saved."
+ ),
+ "debug_logging_turnoff_time": (
+ "The turnoff time is calculated from the debug logging timer when it is saved."
+ ),
+ "file_processing_logs_turnoff_time": (
+ "The turnoff time is calculated from the file processing log timer when it is saved."
+ ),
}
# ``input_type`` values a text field may ask the browser for. Anything else would
-# reach the DOM unvalidated, so the schema test rejects it.
-TEXT_INPUT_TYPES = ("text", "email", "url")
+# reach the DOM unvalidated, so the schema test rejects it. ``time`` is a 24-hour
+# HH:MM picker and ``timezone`` an IANA zone with suggestions; both are validated
+# on save rather than trusted from the browser.
+TEXT_INPUT_TYPES = ("text", "email", "url", "time", "timezone")
LANDING_PAGE_ALIGNMENTS = ("left", "center", "right")
@@ -812,37 +843,12 @@
# The sections below are not part of the Appearance group. They are described
# here because the V2 surface's `enable_*` fallback was filing their toggles
# under Appearance: it matches a key to a section by shared leading word
- # stems and takes the first section that scores at all, so
- # `enable_external_healthcheck` matched "external" in External Links long
- # before it could reach Health Check, whose id splits into "health" and
- # "check" and so never matches the single token "healthcheck". Declaring a
- # key is what takes it out of that scan, so these five are declared rather
- # than guessed at. Wording is taken from the V1 panes so both interfaces say
- # the same thing.
- "health-check-section": [
- {
- "key": "enable_external_healthcheck",
- "type": "switch",
- "label": "Enable /external/healthcheck",
- "help": (
- "Authenticated endpoint for external monitoring systems. Best for "
- "internal monitors or diagnostics tooling that already signs in to "
- "the application."
- ),
- "default": False,
- },
- {
- "key": "enable_no_auth_external_healthcheck",
- "type": "switch",
- "label": "Enable /external/healthcheckz",
- "help": (
- "Unauthenticated endpoint for platform probes that cannot sign in. "
- "This route is intentionally unauthenticated, so only enable it for "
- "trusted health probes or controlled network paths."
- ),
- "default": False,
- },
- ],
+ # stems and takes the first section that scores at all. Declaring a key is
+ # what takes it out of that scan, so these are declared rather than guessed
+ # at. Health Check had the same problem -- `enable_external_healthcheck`
+ # matched "external" in External Links -- and is now declared with the rest
+ # of the Operations group below. Wording is taken from the V1 panes so both
+ # interfaces say the same thing.
"user-facing-latest-features-section": [
{
"key": "enable_support_latest_feature_documentation_links",
@@ -3375,31 +3381,6 @@
# including this role requirement gated on the capability itself. A second
# declaration here would override that one and silently drop
# `enable_chat_file_uploads`, because a later key wins in a dict literal.
- "control-center-overview-section": [
- {
- "key": "require_member_of_control_center_admin",
- "type": "switch",
- "label": "Require ControlCenterAdmin App Role",
- "help": (
- "Narrows the Control Center -- user management, group oversight, "
- "public workspace control and activity logs -- to holders of the "
- "ControlCenterAdmin app role. Note that this takes it away from "
- "general Admins, so assign the role before switching it on."
- ),
- "default": False,
- },
- {
- "key": "require_member_of_control_center_dashboard_reader",
- "type": "switch",
- "label": "Allow ControlCenterDashboardReader App Role",
- "help": (
- "Grants the Control Center dashboard, and nothing else, to holders of "
- "the ControlCenterDashboardReader app role. Useful for giving someone "
- "the usage picture without any management ability."
- ),
- "default": False,
- },
- ],
"group-workspaces-section": [
{
"key": "enable_group_workspaces",
@@ -6152,6 +6133,438 @@
],
},
],
+ # ------------------------------------------------------------------
+ # Operations group: Control Center and Logging & Health. Sections and order
+ # follow admin_settings_nav.py, and every control follows the V1
+ # control-center-config and logging panes, so a setting never exists in one
+ # interface only.
+ #
+ # Several values in this group are calculated rather than edited: the next
+ # scheduled Control Center refresh and each log's turnoff time. They are worked
+ # out on save by the helpers both admin surfaces share (see
+ # ``_apply_operations_derivations``) and refused if submitted directly.
+ # ------------------------------------------------------------------
+ "control-center-auto-refresh-section": [
+ {
+ "key": "control_center_auto_refresh_enabled",
+ "type": "switch",
+ "role": "capability",
+ "label": "Enable Daily Control Center Refresh",
+ "help": (
+ "Recalculates the Control Center's cached activity figures for every "
+ "user and group once a day, so the dashboard is current each morning "
+ "without anyone refreshing it by hand. When off, the figures change "
+ "only when an administrator refreshes them from the Control Center."
+ ),
+ "default": True,
+ },
+ {
+ "key": "control_center_auto_refresh_time",
+ "type": "text",
+ "input_type": "time",
+ "label": "Refresh Time",
+ "help": (
+ "When the refresh starts each day, read in the timezone below. Pick a "
+ "quiet hour: the refresh reads every user and group record."
+ ),
+ "default": "02:00",
+ "max_length": 8,
+ "depends_on": {"key": "control_center_auto_refresh_enabled", "equals": True},
+ },
+ {
+ "key": "control_center_auto_refresh_timezone",
+ "type": "text",
+ "input_type": "timezone",
+ "label": "Timezone",
+ "help": (
+ "The refresh follows this zone's clock, daylight saving included, so "
+ "02:00 stays 02:00 all year. The next run is stored in UTC."
+ ),
+ "default": "America/New_York",
+ "max_length": 64,
+ "depends_on": {"key": "control_center_auto_refresh_enabled", "equals": True},
+ },
+ {
+ "type": "component",
+ "component": "control-center-refresh-schedule",
+ "label": "Next refresh",
+ "help": (
+ "Worked out when the schedule is saved and again after each scheduled "
+ "refresh. Refreshing by hand in the Control Center leaves it where it is."
+ ),
+ },
+ ],
+ "control-center-overview-section": [
+ {
+ "key": "require_member_of_control_center_admin",
+ "type": "switch",
+ "label": "Require ControlCenterAdmin App Role",
+ "help": (
+ "Restricts the whole Control Center -- dashboard, user management, "
+ "group oversight, public workspace control and activity logs -- to "
+ "accounts holding the ControlCenterAdmin app role. General Admins lose "
+ "access, so assign the role before switching this on."
+ ),
+ "notice": (
+ "A new role assignment reaches a user at their next sign-in. Assign "
+ "ControlCenterAdmin, including to yourself, and sign in again before "
+ "saving. Admin Settings stays open to general Admins either way, so this "
+ "can always be switched back off."
+ ),
+ "notice_level": "warning",
+ "default": False,
+ },
+ {
+ "key": "require_member_of_control_center_dashboard_reader",
+ "type": "switch",
+ "label": "Allow ControlCenterDashboardReader App Role",
+ "help": (
+ "Opens the Control Center dashboard -- usage statistics, activity "
+ "trends and metrics -- to accounts holding the ControlCenterDashboardReader "
+ "app role, without any management ability. It works whether or not the "
+ "ControlCenterAdmin requirement is on."
+ ),
+ "default": False,
+ },
+ {
+ "type": "component",
+ "component": "control-center-access-matrix",
+ "label": "Who can open the Control Center",
+ "help": (
+ "Follows the two switches above, including changes you have not saved "
+ "yet. Copy a role value to paste it into the Entra app registration."
+ ),
+ },
+ ],
+ "application-insights-section": [
+ {
+ "key": "enable_appinsights_global_logging",
+ "type": "switch",
+ "role": "capability",
+ "label": "Enable Application Insights Global Logging",
+ "help": (
+ "Raises the application-wide log level to informational, so messages "
+ "from every module and library reach Application Insights -- agents and "
+ "orchestration included -- rather than only SimpleChat's own events and "
+ "the warnings and errors from elsewhere. Useful while tracing a problem; "
+ "expect noticeably more ingestion, and cost, while it is on."
+ ),
+ "notice": (
+ "Application Insights is wired up once, when the app starts. Restart the "
+ "App Service after saving for this to take effect."
+ ),
+ "notice_level": "warning",
+ "default": False,
+ },
+ {
+ "type": "status",
+ "status_source": "appinsights_connection",
+ "label": "Connection",
+ "help": (
+ "Application Insights is reached through the "
+ "APPLICATIONINSIGHTS_CONNECTION_STRING App Service setting, not through "
+ "anything on this page."
+ ),
+ },
+ {
+ "type": "component",
+ "component": "restart-status",
+ "label": "Running state",
+ "help": "Compares the saved setting with how the running app was started.",
+ "watches": "enable_appinsights_global_logging",
+ "runtime_flag": "appinsights_global_logging_active",
+ "runtime_requires": "appinsights_connection_configured",
+ },
+ ],
+ "debug-logging-section": [
+ {
+ "key": "enable_debug_logging",
+ "type": "switch",
+ "role": "capability",
+ "label": "Enable Debug Logging",
+ "help": (
+ "Turns on SimpleChat's DEBUG output everywhere, for troubleshooting a "
+ "problem you can reproduce. It is written to the App Service log stream "
+ "and, while Application Insights is connected, sent there as traces."
+ ),
+ "notice": (
+ "Debug output can include tokens, keys and request content that ordinary "
+ "logs leave out. Turn it off once you have what you need, or let the "
+ "timer below do it."
+ ),
+ "notice_level": "warning",
+ "default": False,
+ },
+ {
+ "key": "debug_logging_timer_enabled",
+ "type": "switch",
+ "label": "Turn off automatically",
+ "help": (
+ "Switches debug logging off by itself once the time below has passed, "
+ "so a troubleshooting session cannot quietly keep running for weeks."
+ ),
+ "default": False,
+ "depends_on": {"key": "enable_debug_logging", "equals": True},
+ },
+ {
+ "key": "debug_timer_value",
+ "type": "number",
+ "label": "Duration",
+ "help": "How long debug logging stays on, counted from the save that starts the timer.",
+ "default": 1,
+ "min": 1,
+ "max": 120,
+ "depends_on": [
+ {"key": "enable_debug_logging", "equals": True},
+ {"key": "debug_logging_timer_enabled", "equals": True},
+ ],
+ },
+ {
+ "key": "debug_timer_unit",
+ "type": "select",
+ "label": "Time Unit",
+ "help": "Each unit has its own cap, shown beside it. The longest a timer can run is 52 weeks.",
+ "default": "hours",
+ "options": [
+ {"value": "minutes", "label": "Minutes (1-120)"},
+ {"value": "hours", "label": "Hours (1-24)"},
+ {"value": "days", "label": "Days (1-7)"},
+ {"value": "weeks", "label": "Weeks (1-52)"},
+ ],
+ "depends_on": [
+ {"key": "enable_debug_logging", "equals": True},
+ {"key": "debug_logging_timer_enabled", "equals": True},
+ ],
+ },
+ {
+ "type": "component",
+ "component": "logging-timer-status",
+ "label": "Turns off",
+ "help": "Calculated when the timer is saved, and shown in your own timezone.",
+ "timer_keys": LOGGING_TIMERS["debug"],
+ "depends_on": {"key": "enable_debug_logging", "equals": True},
+ },
+ {
+ "key": "enable_dai_debug",
+ "type": "switch",
+ "label": "Document Access Index diagnostics",
+ "help": (
+ "Adds the manual backfill, checkpoint reset and shadow validation "
+ "diagnostics to Scale > Cosmos > DAI Metrics. Leave it off unless you are "
+ "investigating document access projection problems; the background "
+ "maintenance runs either way."
+ ),
+ "default": False,
+ "group": {
+ "id": "feature-diagnostics",
+ "label": "Feature diagnostics",
+ "variant": "advanced",
+ },
+ "related_section": {
+ "section_id": "document-access-index-section",
+ "label": "DAI Metrics",
+ # The diagnostics this reveals are drawn by the server-rendered pane
+ # only; V2's card for the section holds its switches, not them.
+ "classic_only": True,
+ },
+ },
+ ],
+ "file-processing-logs-section": [
+ {
+ "key": "enable_file_processing_logs",
+ "type": "switch",
+ "role": "capability",
+ "label": "Enable File Processing Logs",
+ "help": (
+ "Records each step SimpleChat takes while processing an uploaded file, "
+ "including the errors, in the file_processing container in Cosmos DB. "
+ "Read them there when an upload stalls or fails. Turning this off stops "
+ "new records; existing ones stay until you delete them below."
+ ),
+ "default": True,
+ },
+ {
+ "key": "file_processing_logs_timer_enabled",
+ "type": "switch",
+ "label": "Turn off automatically",
+ "help": (
+ "Switches file processing logs off by itself once the time below has "
+ "passed, which keeps a temporary investigation from growing the "
+ "container indefinitely."
+ ),
+ "default": False,
+ "depends_on": {"key": "enable_file_processing_logs", "equals": True},
+ },
+ {
+ "key": "file_timer_value",
+ "type": "number",
+ "label": "Duration",
+ "help": "How long the logs stay on, counted from the save that starts the timer.",
+ "default": 1,
+ "min": 1,
+ "max": 120,
+ "depends_on": [
+ {"key": "enable_file_processing_logs", "equals": True},
+ {"key": "file_processing_logs_timer_enabled", "equals": True},
+ ],
+ },
+ {
+ "key": "file_timer_unit",
+ "type": "select",
+ "label": "Time Unit",
+ "help": "Each unit has its own cap, shown beside it. The longest a timer can run is 52 weeks.",
+ "default": "hours",
+ "options": [
+ {"value": "minutes", "label": "Minutes (1-120)"},
+ {"value": "hours", "label": "Hours (1-24)"},
+ {"value": "days", "label": "Days (1-7)"},
+ {"value": "weeks", "label": "Weeks (1-52)"},
+ ],
+ "depends_on": [
+ {"key": "enable_file_processing_logs", "equals": True},
+ {"key": "file_processing_logs_timer_enabled", "equals": True},
+ ],
+ },
+ {
+ "type": "component",
+ "component": "logging-timer-status",
+ "label": "Turns off",
+ "help": "Calculated when the timer is saved, and shown in your own timezone.",
+ "timer_keys": LOGGING_TIMERS["file"],
+ "depends_on": {"key": "enable_file_processing_logs", "equals": True},
+ },
+ {
+ "type": "component",
+ "component": "file-processing-log-cleanup",
+ "label": "Delete stored logs",
+ "help": (
+ "Permanently removes file processing records from Cosmos DB, whether or "
+ "not logging is on. These act immediately and are not part of Save."
+ ),
+ },
+ ],
+ "health-check-section": [
+ {
+ "key": "enable_external_healthcheck",
+ "type": "switch",
+ "label": "Enable /external/healthcheck",
+ "help": (
+ "A lightweight availability check for monitors that reach the app "
+ "through its normal access boundary, such as App Service Authentication. "
+ "It answers with the server time once the app can read its settings, and "
+ "does not exercise Azure AI Search, Azure OpenAI or storage."
+ ),
+ "notice": (
+ "The bundled deployers set App Service Health check to this path. While "
+ "that is configured, switching this off makes the route answer HTTP 400, "
+ "and App Service starts treating its instances as unhealthy."
+ ),
+ "notice_level": "warning",
+ "default": False,
+ },
+ {
+ "key": "enable_no_auth_external_healthcheck",
+ "type": "switch",
+ "label": "Enable /external/healthcheckz",
+ "help": (
+ "The same check for platform probes that cannot sign in. It returns a "
+ "small JSON status and nothing else."
+ ),
+ "notice": (
+ "This route answers anyone who can reach the app, without signing in. "
+ "Only enable it for trusted health probes or controlled network paths."
+ ),
+ "notice_level": "warning",
+ "default": False,
+ },
+ {
+ "type": "component",
+ "component": "endpoint-links",
+ "label": "Endpoints",
+ "help": (
+ "Copy a full address into your monitoring tool. Open is offered once the "
+ "saved setting has the endpoint turned on."
+ ),
+ "endpoints": [
+ {
+ "path": "/external/healthcheck",
+ "label": "Authenticated check",
+ "gate_key": "enable_external_healthcheck",
+ "access": "protected",
+ "returns": "HTTP 200 with the server time as text.",
+ },
+ {
+ "path": "/external/healthcheckz",
+ "label": "Unauthenticated check",
+ "gate_key": "enable_no_auth_external_healthcheck",
+ "access": "public",
+ "returns": 'HTTP 200 with {"status": "ok", "time": ...}.',
+ },
+ ],
+ },
+ ],
+ "swagger-section": [
+ {
+ "key": "enable_swagger",
+ "type": "switch",
+ "role": "capability",
+ "label": "Enable Swagger/OpenAPI Documentation (/swagger)",
+ "help": (
+ "Serves an interactive explorer for SimpleChat's API at /swagger, and the "
+ "OpenAPI specification it is generated from at /swagger.json and "
+ "/swagger.yaml. Any signed-in user can open them. Useful for developers "
+ "and integrations; turn it off if you would rather not publish the API "
+ "surface to every user."
+ ),
+ "notice": (
+ "These routes are registered when the app starts. Restart the App Service "
+ "after saving for this to take effect."
+ ),
+ "notice_level": "warning",
+ # No seeded default exists; swagger_wrapper treats a missing value as on.
+ "default": True,
+ },
+ {
+ "type": "component",
+ "component": "restart-status",
+ "label": "Running state",
+ "help": "Compares the saved setting with how the running app was started.",
+ "watches": "enable_swagger",
+ "runtime_flag": "swagger_routes_registered",
+ },
+ {
+ "type": "component",
+ "component": "endpoint-links",
+ "label": "Documentation links",
+ "help": (
+ "Share these with developers. They need to sign in, and the specification "
+ "endpoints answer at most 30 requests a minute per client address."
+ ),
+ "endpoints": [
+ {
+ "path": "/swagger",
+ "label": "Swagger UI",
+ "runtime_flag": "swagger_routes_registered",
+ "access": "signed_in",
+ "returns": "The interactive API explorer.",
+ },
+ {
+ "path": "/swagger.json",
+ "label": "OpenAPI specification (JSON)",
+ "runtime_flag": "swagger_routes_registered",
+ "access": "signed_in",
+ "returns": "The OpenAPI 3 document, for client generators and API tools.",
+ },
+ {
+ "path": "/swagger.yaml",
+ "label": "OpenAPI specification (YAML)",
+ "runtime_flag": "swagger_routes_registered",
+ "access": "signed_in",
+ "returns": "The same specification as YAML.",
+ },
+ ],
+ },
+ ],
}
@@ -6193,6 +6606,10 @@
"inbound_mcp_allowed_client_app_entries": ["inbound_mcp_allowed_client_app_entries_json"],
"inbound_mcp_allowed_tenant_entries": ["inbound_mcp_allowed_tenant_entries_json"],
"inbound_mcp_allowed_source_entries": ["inbound_mcp_allowed_source_entries_json"],
+ # V1 names the auto-turnoff switches after the control rather than after the
+ # stored key, which reads the other way round.
+ "debug_logging_timer_enabled": ["enable_debug_logging_timer"],
+ "file_processing_logs_timer_enabled": ["enable_file_processing_logs_timer"],
}
# Field names present in the V1 panes covered by a parity test that intentionally
@@ -6241,6 +6658,48 @@
"Same as enable_app_maintenance: no server-rendered control, and misfiled "
"into Security by the fallback scan until it was declared."
),
+ "enable_dai_debug": (
+ "The server-rendered DAI Metrics pane only reads it, to decide whether to "
+ "show its backfill, checkpoint reset and shadow validation diagnostics, so "
+ "it could only be changed by editing the settings document. The fallback "
+ "scan drew it as an unexplained 'Dai debug' switch under Debug Logging; it "
+ "is declared there instead, named for what it does and linked to DAI Metrics."
+ ),
+}
+
+
+# In-app guides a section header offers, keyed by section id.
+#
+# Some settings only make sense alongside work done outside SimpleChat -- creating
+# app roles in Entra, pointing App Service Health check at a path -- and the
+# server-rendered panes carry that walkthrough in a modal next to the section. The
+# V2 surface draws the same guide from its own registry of guide components, so
+# this names which guide a section offers and what its button says. ``docs_url``
+# is the matching page on the documentation site, linked from inside the guide.
+ADMIN_SECTION_GUIDES = {
+ "control-center-overview-section": {
+ "id": "control-center-roles",
+ "label": "Role setup guide",
+ "docs_url": (
+ "https://microsoft.github.io/simplechat/admin/operations/"
+ "#control-center-overview-section"
+ ),
+ },
+ "health-check-section": {
+ "id": "health-check",
+ "label": "Configuration guide",
+ "docs_url": (
+ "https://microsoft.github.io/simplechat/admin/operations/"
+ "#health-check-section"
+ ),
+ },
+ "swagger-section": {
+ "id": "swagger",
+ "label": "Why enable Swagger?",
+ "docs_url": (
+ "https://microsoft.github.io/simplechat/admin/operations/#swagger-section"
+ ),
+ },
}
@@ -6341,6 +6800,11 @@ def get_admin_section_status():
return ADMIN_SECTION_STATUS
+def get_admin_section_guides():
+ """Return the section-id keyed in-app guide descriptors."""
+ return ADMIN_SECTION_GUIDES
+
+
def iter_fields():
"""Yield ``(section_id, field)`` for every declared field."""
for section_id, fields in ADMIN_SETTINGS_FIELDS.items():
@@ -7466,6 +7930,22 @@ def normalize_admin_settings_updates(updates, current_settings=None):
normalized[key] = front_door_value
continue
+ if key == "control_center_auto_refresh_time":
+ refresh_time, refresh_time_error = _validate_control_center_refresh_time(value)
+ if refresh_time_error:
+ errors[key] = refresh_time_error
+ else:
+ normalized[key] = refresh_time
+ continue
+
+ if key == "control_center_auto_refresh_timezone":
+ refresh_zone, refresh_zone_error = _validate_control_center_refresh_timezone(value)
+ if refresh_zone_error:
+ errors[key] = refresh_zone_error
+ else:
+ normalized[key] = refresh_zone
+ continue
+
field_value, error, warning = _normalize_field_value(key, value, field)
if error:
errors[key] = error
@@ -7501,10 +7981,67 @@ def normalize_admin_settings_updates(updates, current_settings=None):
if not errors:
_apply_nested_paths(normalized, current)
_apply_inbound_mcp_derivations(normalized, current)
+ _apply_operations_derivations(normalized, current)
return normalized, errors, warnings
+def _validate_control_center_refresh_time(value):
+ """Return ``(time, error)`` for the daily Control Center refresh time.
+
+ The shared schedule normalizer reads anything it cannot parse as 02:00. That is
+ right for a stored value and wrong for one an administrator just typed, which
+ would otherwise save as a different time with no word said.
+ """
+ candidate = str(value if value is not None else "").strip()
+ if not is_valid_control_center_auto_refresh_time(candidate):
+ return None, "Enter a time on a 24-hour clock, such as 02:00."
+ return normalize_control_center_auto_refresh_time(candidate)["time"], None
+
+
+def _validate_control_center_refresh_timezone(value):
+ """Return ``(zone, error)`` for the Control Center refresh timezone.
+
+ Refused rather than replaced: the shared normalizer substitutes
+ America/New_York for an unknown zone, which would quietly move the refresh by
+ several hours for anyone outside it.
+ """
+ candidate = str(value if value is not None else "").strip()
+ if not is_valid_control_center_auto_refresh_timezone(candidate):
+ return None, "Choose an IANA timezone, such as America/New_York or Europe/London."
+ return candidate, None
+
+
+def _apply_operations_derivations(normalized, current_settings):
+ """Work out the Operations values that are calculated rather than edited.
+
+ The next scheduled Control Center refresh and each log's turnoff time follow
+ from other settings. The server-rendered form calculates them on every save;
+ without the same step here, a schedule or timer edited in V2 would be stored and
+ then ignored. Both surfaces call the same helpers, so they agree on when a save
+ moves a refresh or restarts a timer, and a save that touches neither leaves both
+ alone.
+ """
+ for kind, keys in LOGGING_TIMERS.items():
+ inputs = (keys["enabled_key"], keys["timer_key"], keys["value_key"], keys["unit_key"])
+ incoming = {key: normalized[key] for key in inputs if key in normalized}
+ if incoming:
+ normalized.update(
+ resolve_logging_timer_settings(kind, incoming, current_settings)
+ )
+
+ schedule_inputs = (
+ "control_center_auto_refresh_enabled",
+ "control_center_auto_refresh_time",
+ "control_center_auto_refresh_timezone",
+ )
+ incoming = {key: normalized[key] for key in schedule_inputs if key in normalized}
+ if incoming:
+ normalized.update(
+ resolve_control_center_auto_refresh_settings(incoming, current_settings)
+ )
+
+
def _check_content_screening_dependency(normalized, current_settings, errors):
"""Keep the storage prerequisite authoritative for partial settings saves."""
screening = normalized.get(
@@ -7581,12 +8118,18 @@ def _check_planner_model_selection(normalized, current_settings, errors):
def _apply_cross_field_rules(normalized, current_settings, warnings):
"""Reconcile settings whose valid range depends on another setting.
- Only the idle warning needs this today. It has to arrive before the sign-out it
- warns about, and the two are separate fields that can be saved independently,
- so the check cannot live on either field's own definition. The server-rendered
- form silently lowers the warning to match; doing the same and saying so is
- kinder than rejecting a save over a value the administrator did not touch.
+ The pairs may be edited together or one at a time, so the check cannot live on
+ either field's own definition: only the merged state of a save can be judged.
+ The server-rendered form silently brings an out-of-range value back into range;
+ doing the same and saying so is kinder than rejecting a save over a value the
+ administrator did not touch.
"""
+ _apply_idle_warning_rule(normalized, current_settings, warnings)
+ _apply_logging_timer_limits(normalized, current_settings, warnings)
+
+
+def _apply_idle_warning_rule(normalized, current_settings, warnings):
+ """Keep the idle warning from arriving after the sign-out it warns about."""
if "idle_warning_minutes" not in normalized and "idle_timeout_minutes" not in normalized:
return
@@ -7612,6 +8155,38 @@ def merged(key, fallback):
)
+def _apply_logging_timer_limits(normalized, current_settings, warnings):
+ """Keep each log's auto-turnoff duration inside its unit's range.
+
+ The duration field accepts 1 to 120 because that is the widest any unit
+ allows, but the real cap depends on the unit -- 24 hours, 7 days, 52 weeks --
+ and either may be saved on its own. Switching 90 minutes to hours would
+ otherwise ask for a 90-hour timer.
+ """
+ for keys in LOGGING_TIMERS.values():
+ value_key, unit_key = keys["value_key"], keys["unit_key"]
+ if value_key not in normalized and unit_key not in normalized:
+ continue
+
+ value = normalized.get(value_key, current_settings.get(value_key))
+ unit = normalized.get(unit_key, current_settings.get(unit_key))
+ try:
+ requested = int(float(value))
+ except (TypeError, ValueError):
+ continue
+
+ allowed, normalized_unit = clamp_logging_timer_value(requested, unit)
+ if allowed == requested:
+ continue
+
+ normalized[value_key] = allowed
+ direction = "Lowered" if requested > allowed else "Raised"
+ limit = "the longest" if requested > allowed else "the shortest"
+ warnings[value_key] = (
+ f"{direction} to {allowed}, {limit} a timer measured in {normalized_unit} can run."
+ )
+
+
def _check_minimum_selections(normalized, current_settings, errors):
"""Enforce ``min_selected`` once the merged state of a save is known."""
def read_value(key):
diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py
index bc4c64c57..40f923a15 100644
--- a/application/single_app/background_tasks.py
+++ b/application/single_app/background_tasks.py
@@ -37,6 +37,7 @@
from functions_debug import debug_print
from functions_data_management import check_due_data_management_jobs_once
from functions_file_sync import check_due_file_sync_sources_once
+from functions_logging_timers import LOGGING_TIMERS, is_logging_turnoff_due
from functions_keyvault_reminders import (
KEY_VAULT_SECRET_REMINDER_LOCK_NAME,
check_due_key_vault_secret_reminders_once,
@@ -219,46 +220,30 @@ def _should_run_retention_policy(settings, current_time):
def check_logging_timers_once():
- """Disable temporary logging settings after their timer expires."""
+ """Disable temporary logging settings after their timer expires.
+
+ Turnoff times are compared in UTC. Ones stored before timers were kept in UTC
+ carry no offset and are read as server-local time, which is how they were
+ written, so a timer set before an upgrade still ends when it was meant to.
+ """
settings = get_settings()
- current_time = datetime.now()
settings_updates = {}
- if (
- settings.get('enable_debug_logging', False)
- and settings.get('debug_logging_timer_enabled', False)
- and settings.get('debug_logging_turnoff_time')
- ):
- turnoff_time = settings.get('debug_logging_turnoff_time')
- if isinstance(turnoff_time, str):
- try:
- turnoff_time = datetime.fromisoformat(turnoff_time)
- except Exception:
- turnoff_time = None
-
- if turnoff_time and current_time >= turnoff_time:
- debug_print(f"logging timer expired at {turnoff_time}. Disabling debug logging.")
- settings_updates['enable_debug_logging'] = False
- settings_updates['debug_logging_timer_enabled'] = False
- settings_updates['debug_logging_turnoff_time'] = None
-
- if (
- settings.get('enable_file_processing_logs', False)
- and settings.get('file_processing_logs_timer_enabled', False)
- and settings.get('file_processing_logs_turnoff_time')
- ):
- turnoff_time = settings.get('file_processing_logs_turnoff_time')
- if isinstance(turnoff_time, str):
- try:
- turnoff_time = datetime.fromisoformat(turnoff_time)
- except Exception:
- turnoff_time = None
-
- if turnoff_time and current_time >= turnoff_time:
- print(f"File processing logs timer expired at {turnoff_time}. Disabling file processing logs.")
- settings_updates['enable_file_processing_logs'] = False
- settings_updates['file_processing_logs_timer_enabled'] = False
- settings_updates['file_processing_logs_turnoff_time'] = None
+ for timer_keys in LOGGING_TIMERS.values():
+ enabled_key = timer_keys['enabled_key']
+ timer_key = timer_keys['timer_key']
+ turnoff_key = timer_keys['turnoff_key']
+ if not (settings.get(enabled_key, False) and settings.get(timer_key, False)):
+ continue
+
+ turnoff_time = settings.get(turnoff_key)
+ if not is_logging_turnoff_due(turnoff_time):
+ continue
+
+ debug_print(f"[LOGGING_TIMERS] Timer for {enabled_key} expired at {turnoff_time}. Turning it off.")
+ settings_updates[enabled_key] = False
+ settings_updates[timer_key] = False
+ settings_updates[turnoff_key] = None
if settings_updates:
return update_settings(settings_updates, expected_etag=settings.get('_etag'))
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 670ab4808..3a890675e 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.259"
+VERSION = "0.261.260"
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/functions_appinsights.py b/application/single_app/functions_appinsights.py
index 7fa5d88ef..a301a8795 100644
--- a/application/single_app/functions_appinsights.py
+++ b/application/single_app/functions_appinsights.py
@@ -451,6 +451,24 @@ def get_appinsights_logger():
return None
+
+def get_appinsights_runtime_state() -> Dict[str, bool]:
+ """Report how Application Insights was wired when this process started.
+
+ The settings document records what an administrator asked for; this reports
+ what the running process is doing. The two differ until the App Service
+ restarts, because ``setup_appinsights_logging`` configures the exporter and the
+ global logger once, at startup. Only booleans are returned, so the connection
+ string itself never leaves the server.
+ """
+ return {
+ 'connection_configured': bool(os.environ.get('APPLICATIONINSIGHTS_CONNECTION_STRING')),
+ 'exporter_configured': bool(_azure_monitor_configured),
+ 'global_logging_active': (
+ _appinsights_logger is not None and _appinsights_logger is logging.getLogger()
+ ),
+ }
+
# --- Logging function for Application Insights ---
def log_event(
message: Any,
diff --git a/application/single_app/functions_control_center.py b/application/single_app/functions_control_center.py
index eb434f926..ef56d2c0a 100644
--- a/application/single_app/functions_control_center.py
+++ b/application/single_app/functions_control_center.py
@@ -4,146 +4,26 @@
Version: 0.250.102
"""
-from datetime import datetime, timedelta, timezone
-from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+from datetime import datetime, timezone
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 = 2
-CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE = 0
-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,
- 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
-
- if isinstance(schedule_time, str) and schedule_time.strip():
- time_parts = schedule_time.strip().split(':')
- if len(time_parts) >= 2:
- try:
- parsed_hour = int(time_parts[0])
- parsed_minute = int(time_parts[1])
- if 0 <= parsed_hour <= 23 and 0 <= parsed_minute <= 59:
- normalized_hour = parsed_hour
- normalized_minute = parsed_minute
- except (TypeError, ValueError):
- pass
- else:
- try:
- parsed_hour = int(schedule_hour)
- if 0 <= parsed_hour <= 23:
- normalized_hour = parsed_hour
- except (TypeError, ValueError):
- pass
-
- try:
- parsed_minute = int(schedule_minute)
- if 0 <= parsed_minute <= 59:
- normalized_minute = parsed_minute
- 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,
- }
-
-
-def get_control_center_auto_refresh_schedule(settings=None):
- """Normalize schedule fields from app settings."""
- settings = settings or {}
- return normalize_control_center_auto_refresh_time(
- 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 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)
- else:
- current_time = current_time.astimezone(timezone.utc)
-
- schedule = get_control_center_auto_refresh_schedule(settings)
- 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_local <= local_current_time:
- next_run_local += timedelta(days=1)
-
- return next_run_local.astimezone(timezone.utc)
-
-
-def parse_control_center_auto_refresh_datetime(timestamp_value):
- """Parse an ISO timestamp as a timezone-aware UTC datetime."""
- if not timestamp_value:
- return None
-
- try:
- if isinstance(timestamp_value, datetime):
- parsed_datetime = timestamp_value
- else:
- normalized_value = timestamp_value.replace('Z', '+00:00') if isinstance(timestamp_value, str) else timestamp_value
- parsed_datetime = datetime.fromisoformat(normalized_value)
- if parsed_datetime.tzinfo is None:
- parsed_datetime = parsed_datetime.replace(tzinfo=timezone.utc)
- return parsed_datetime.astimezone(timezone.utc)
- except (TypeError, ValueError):
- 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
+# The schedule rules are pure and live apart from this module so the admin settings
+# normalizer can share them without importing config. Re-exported for existing callers.
+from functions_control_center_schedule import ( # noqa: F401
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR,
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE,
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME,
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE,
+ calculate_next_control_center_auto_refresh_run,
+ get_control_center_auto_refresh_schedule,
+ is_control_center_auto_refresh_due,
+ normalize_control_center_auto_refresh_time,
+ parse_control_center_auto_refresh_datetime,
+ resolve_control_center_auto_refresh_settings,
+)
def execute_control_center_refresh(manual_execution=False):
diff --git a/application/single_app/functions_control_center_schedule.py b/application/single_app/functions_control_center_schedule.py
new file mode 100644
index 000000000..3b2511bcc
--- /dev/null
+++ b/application/single_app/functions_control_center_schedule.py
@@ -0,0 +1,263 @@
+# functions_control_center_schedule.py
+"""Daily schedule rules for the Control Center metrics refresh.
+
+The refresh runs once a day at a wall-clock time in an IANA timezone, and the next
+run is stored as a UTC timestamp that the scheduler compares against. The
+scheduler and the Control Center routes need these rules, and so do both admin
+surfaces: the server-rendered Admin Settings form and the V2 admin settings PATCH.
+
+The module imports nothing from the application. ``functions_control_center``
+reaches ``config``, which builds Azure clients on import, so keeping the pure rules
+here is what lets the V2 settings normalizer share them without pulling that
+chain into ``admin_settings_fields``. ``functions_control_center`` re-exports every
+name, so existing imports keep working.
+"""
+
+import re
+from datetime import datetime, timedelta, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+
+CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR = 2
+CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE = 0
+CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME = '02:00'
+CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE = 'America/New_York'
+
+# A refresh time as a browser time input submits it: 24-hour HH:MM, with seconds
+# tolerated because some browsers add them. Seconds are dropped on save.
+CONTROL_CENTER_AUTO_REFRESH_TIME_PATTERN = re.compile(r'^([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d)?$')
+
+_TRUE_STRINGS = ('true', 'on', 'yes', '1')
+
+
+def _coerce_bool(value):
+ """Read a stored or submitted switch value as a boolean."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in _TRUE_STRINGS
+ return bool(value)
+
+
+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
+
+ if isinstance(schedule_time, str) and schedule_time.strip():
+ time_parts = schedule_time.strip().split(':')
+ if len(time_parts) >= 2:
+ try:
+ parsed_hour = int(time_parts[0])
+ parsed_minute = int(time_parts[1])
+ if 0 <= parsed_hour <= 23 and 0 <= parsed_minute <= 59:
+ normalized_hour = parsed_hour
+ normalized_minute = parsed_minute
+ except (TypeError, ValueError):
+ pass
+ else:
+ try:
+ parsed_hour = int(schedule_hour)
+ if 0 <= parsed_hour <= 23:
+ normalized_hour = parsed_hour
+ except (TypeError, ValueError):
+ pass
+
+ try:
+ parsed_minute = int(schedule_minute)
+ if 0 <= parsed_minute <= 59:
+ normalized_minute = parsed_minute
+ 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,
+ }
+
+
+def get_control_center_auto_refresh_schedule(settings=None):
+ """Normalize schedule fields from app settings."""
+ settings = settings or {}
+ return normalize_control_center_auto_refresh_time(
+ 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 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)
+ else:
+ current_time = current_time.astimezone(timezone.utc)
+
+ schedule = get_control_center_auto_refresh_schedule(settings)
+ 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_local <= local_current_time:
+ next_run_local += timedelta(days=1)
+
+ return next_run_local.astimezone(timezone.utc)
+
+
+def parse_control_center_auto_refresh_datetime(timestamp_value):
+ """Parse an ISO timestamp as a timezone-aware UTC datetime."""
+ if not timestamp_value:
+ return None
+
+ try:
+ if isinstance(timestamp_value, datetime):
+ parsed_datetime = timestamp_value
+ else:
+ normalized_value = timestamp_value.replace('Z', '+00:00') if isinstance(timestamp_value, str) else timestamp_value
+ parsed_datetime = datetime.fromisoformat(normalized_value)
+ if parsed_datetime.tzinfo is None:
+ parsed_datetime = parsed_datetime.replace(tzinfo=timezone.utc)
+ return parsed_datetime.astimezone(timezone.utc)
+ except (TypeError, ValueError):
+ 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 is_valid_control_center_auto_refresh_time(value):
+ """Whether a submitted refresh time is a 24-hour ``HH:MM`` value.
+
+ ``normalize_control_center_auto_refresh_time`` quietly falls back to 02:00 for
+ anything it cannot read, which is right for a stored value but wrong for one
+ an administrator just typed: the V2 surface reports it on the field instead.
+ """
+ return isinstance(value, str) and bool(
+ CONTROL_CENTER_AUTO_REFRESH_TIME_PATTERN.match(value.strip())
+ )
+
+
+def is_valid_control_center_auto_refresh_timezone(value):
+ """Whether a submitted value names an IANA timezone this server knows."""
+ if not isinstance(value, str) or not value.strip():
+ return False
+ try:
+ ZoneInfo(value.strip())
+ except (ZoneInfoNotFoundError, ValueError):
+ return False
+ return True
+
+
+def resolve_control_center_auto_refresh_settings(incoming, existing=None, current_time=None):
+ """Return the schedule settings a save leaves behind.
+
+ ``incoming`` holds any of ``control_center_auto_refresh_enabled``,
+ ``control_center_auto_refresh_time`` and ``control_center_auto_refresh_timezone``;
+ anything it does not carry keeps its stored value from ``existing``. The
+ server-rendered form submits all three and the V2 PATCH only the edited ones.
+
+ The next run is recalculated only when the switch, time or timezone changed, or
+ when none is stored, so saving an unrelated setting does not move a scheduled
+ refresh. It is cleared while the schedule is off.
+ """
+ existing = existing or {}
+
+ def merged(key, default):
+ if key in incoming:
+ return incoming[key]
+ stored = existing.get(key)
+ return default if stored is None else stored
+
+ enabled = _coerce_bool(merged('control_center_auto_refresh_enabled', True))
+ schedule = get_control_center_auto_refresh_schedule({
+ 'control_center_auto_refresh_time': merged(
+ 'control_center_auto_refresh_time',
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME,
+ ),
+ 'control_center_auto_refresh_hour': existing.get(
+ 'control_center_auto_refresh_hour',
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR,
+ ),
+ 'control_center_auto_refresh_minute': existing.get(
+ 'control_center_auto_refresh_minute',
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE,
+ ),
+ 'control_center_auto_refresh_timezone': merged(
+ 'control_center_auto_refresh_timezone',
+ CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE,
+ ),
+ })
+ existing_schedule = get_control_center_auto_refresh_schedule(existing)
+ existing_enabled = _coerce_bool(existing.get('control_center_auto_refresh_enabled', True))
+ existing_next_run = existing.get('control_center_auto_refresh_next_run')
+
+ schedule_changed = (
+ enabled != existing_enabled
+ or schedule['time'] != existing_schedule['time']
+ or schedule['timezone'] != existing_schedule['timezone']
+ )
+
+ if not enabled:
+ next_run = None
+ elif schedule_changed or not existing_next_run:
+ next_run = calculate_next_control_center_auto_refresh_run(
+ {
+ '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'],
+ },
+ current_time=current_time or datetime.now(timezone.utc),
+ ).isoformat()
+ else:
+ next_run = existing_next_run
+
+ return {
+ 'control_center_auto_refresh_enabled': enabled,
+ '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,
+ }
diff --git a/application/single_app/functions_logging_timers.py b/application/single_app/functions_logging_timers.py
new file mode 100644
index 000000000..6c3d23af1
--- /dev/null
+++ b/application/single_app/functions_logging_timers.py
@@ -0,0 +1,200 @@
+# functions_logging_timers.py
+"""Time-based automatic turnoff for the debug and file processing logs.
+
+Each log carries three controls besides its own switch: a timer switch, a duration
+and a unit. When the timer is on, saving works out a turnoff time, and the
+background checker switches the log off once that time has passed.
+
+Three callers need the same rules -- the server-rendered Admin Settings form, the
+V2 admin settings PATCH and the background checker -- so they live here once:
+
+``LOGGING_TIMER_UNIT_LIMITS``
+ The durations each unit accepts.
+
+``resolve_logging_timer_settings``
+ The timer settings a save leaves behind. The turnoff time is only
+ recalculated when the timer settings change, when the log has just been
+ switched on, or when no usable turnoff time is stored, so saving an unrelated
+ setting does not quietly extend a running timer.
+
+``parse_logging_turnoff_time`` / ``is_logging_turnoff_due``
+ Read a stored turnoff time. New values are stored as UTC with an explicit
+ offset, which is what lets an administrator be shown the time in their own
+ zone. Values written before that carry no offset; they were produced by
+ ``datetime.now()`` on the server, so they are read as server-local time,
+ which is exactly what the checker used to compare them with.
+
+The module imports nothing from the application, so the settings normalizer can
+use it without reaching ``config``.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+
+LOGGING_TIMER_UNIT_LIMITS = {
+ 'minutes': (1, 120),
+ 'hours': (1, 24),
+ 'days': (1, 7),
+ 'weeks': (1, 52),
+}
+LOGGING_TIMER_DEFAULT_UNIT = 'hours'
+LOGGING_TIMER_DEFAULT_VALUE = 1
+
+# The settings keys each log's timer is stored under.
+LOGGING_TIMERS = {
+ 'debug': {
+ 'enabled_key': 'enable_debug_logging',
+ 'timer_key': 'debug_logging_timer_enabled',
+ 'value_key': 'debug_timer_value',
+ 'unit_key': 'debug_timer_unit',
+ 'turnoff_key': 'debug_logging_turnoff_time',
+ },
+ 'file': {
+ 'enabled_key': 'enable_file_processing_logs',
+ 'timer_key': 'file_processing_logs_timer_enabled',
+ 'value_key': 'file_timer_value',
+ 'unit_key': 'file_timer_unit',
+ 'turnoff_key': 'file_processing_logs_turnoff_time',
+ },
+}
+
+_TRUE_STRINGS = ('true', 'on', 'yes', '1')
+
+
+def _coerce_bool(value):
+ """Read a stored or submitted switch value as a boolean."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in _TRUE_STRINGS
+ return bool(value)
+
+
+def _as_utc(moment):
+ """Return an aware UTC datetime; a naive value is taken to already be UTC."""
+ if moment.tzinfo is None:
+ return moment.replace(tzinfo=timezone.utc)
+ return moment.astimezone(timezone.utc)
+
+
+def normalize_logging_timer_unit(unit):
+ """Return a supported unit, falling back to hours for anything else."""
+ normalized = str(unit or '').strip().lower()
+ if normalized in LOGGING_TIMER_UNIT_LIMITS:
+ return normalized
+ return LOGGING_TIMER_DEFAULT_UNIT
+
+
+def clamp_logging_timer_value(value, unit):
+ """Return ``(value, unit)`` with the duration inside the unit's range.
+
+ A value that is not a number falls back to the default duration rather than
+ failing the save, because the duration is meaningless on its own and the
+ unit's range is applied straight afterwards anyway.
+ """
+ normalized_unit = normalize_logging_timer_unit(unit)
+ minimum, maximum = LOGGING_TIMER_UNIT_LIMITS[normalized_unit]
+ if isinstance(value, bool):
+ number = LOGGING_TIMER_DEFAULT_VALUE
+ else:
+ try:
+ number = int(float(value))
+ except (TypeError, ValueError, OverflowError):
+ number = LOGGING_TIMER_DEFAULT_VALUE
+ return min(max(number, minimum), maximum), normalized_unit
+
+
+def calculate_logging_turnoff_time(value, unit, now=None):
+ """Return the UTC moment a timer of this duration started ``now`` ends."""
+ duration, normalized_unit = clamp_logging_timer_value(value, unit)
+ reference = _as_utc(now) if now is not None else datetime.now(timezone.utc)
+ return reference + timedelta(**{normalized_unit: duration})
+
+
+def parse_logging_turnoff_time(value):
+ """Return a stored turnoff time as an aware UTC datetime, or None.
+
+ Values stored without an offset predate UTC storage. They were written with
+ the server's ``datetime.now()``, so they are interpreted as server-local
+ time rather than as UTC.
+ """
+ if not value:
+ return None
+ if isinstance(value, datetime):
+ parsed = value
+ elif isinstance(value, str):
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ else:
+ return None
+
+ if parsed.tzinfo is None:
+ parsed = parsed.astimezone()
+ return parsed.astimezone(timezone.utc)
+
+
+def is_logging_turnoff_due(value, now=None):
+ """Whether a stored turnoff time has been reached."""
+ turnoff = parse_logging_turnoff_time(value)
+ if turnoff is None:
+ return False
+ reference = _as_utc(now) if now is not None else datetime.now(timezone.utc)
+ return reference >= turnoff
+
+
+def resolve_logging_timer_settings(kind, incoming, existing=None, now=None):
+ """Return the timer settings a save of one log leaves behind.
+
+ ``kind`` is ``'debug'`` or ``'file'``. ``incoming`` holds the values being
+ saved, keyed by their settings keys; any key it does not carry keeps its
+ stored value from ``existing``. The server-rendered form submits every key and
+ the V2 PATCH only the edited ones, and both take this one path.
+
+ The turnoff time is recalculated only when the timer switch, duration or unit
+ changed, when the log has just been switched on, or when no usable turnoff time
+ is stored. It is cleared whenever the log or its timer is off.
+ """
+ keys = LOGGING_TIMERS[kind]
+ existing = existing or {}
+
+ def merged(key, default):
+ if key in incoming:
+ return incoming[key]
+ stored = existing.get(key)
+ return default if stored is None else stored
+
+ enabled = _coerce_bool(merged(keys['enabled_key'], False))
+ timer_enabled = _coerce_bool(merged(keys['timer_key'], False))
+ value, unit = clamp_logging_timer_value(
+ merged(keys['value_key'], LOGGING_TIMER_DEFAULT_VALUE),
+ merged(keys['unit_key'], LOGGING_TIMER_DEFAULT_UNIT),
+ )
+
+ existing_enabled = _coerce_bool(existing.get(keys['enabled_key'], False))
+ existing_timer_enabled = _coerce_bool(existing.get(keys['timer_key'], False))
+ existing_value = existing.get(keys['value_key'], LOGGING_TIMER_DEFAULT_VALUE)
+ existing_unit = existing.get(keys['unit_key'], LOGGING_TIMER_DEFAULT_UNIT)
+ existing_turnoff = parse_logging_turnoff_time(existing.get(keys['turnoff_key']))
+
+ timer_changed = (
+ timer_enabled != existing_timer_enabled
+ or value != existing_value
+ or unit != existing_unit
+ )
+ newly_enabled = enabled and not existing_enabled
+
+ if not (enabled and timer_enabled):
+ turnoff = None
+ elif timer_changed or newly_enabled or existing_turnoff is None:
+ turnoff = calculate_logging_turnoff_time(value, unit, now=now).isoformat()
+ else:
+ turnoff = existing_turnoff.isoformat()
+
+ return {
+ keys['timer_key']: timer_enabled,
+ keys['value_key']: value,
+ keys['unit_key']: unit,
+ keys['turnoff_key']: turnoff,
+ }
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index 424079ae1..01427032f 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -33,6 +33,7 @@
LOGO_SCALE_MAX_PERCENT,
LOGO_SCALE_MIN_PERCENT,
SECRET_REDACTED_VALUE,
+ get_admin_section_guides,
get_admin_section_status,
get_admin_settings_fields,
get_secret_field_keys,
@@ -52,7 +53,7 @@
ensure_custom_logo_file_exists,
get_allowed_extension_categories,
)
-from functions_appinsights import log_event
+from functions_appinsights import get_appinsights_runtime_state, log_event
from functions_branding_images import (
ALLOWED_FAVICON_EXTENSIONS,
ALLOWED_LOGO_EXTENSIONS,
@@ -192,7 +193,7 @@
)
from functions_documents import get_audio_runtime_capabilities
from config import VERSION
-from swagger_wrapper import get_auth_security, swagger_route
+from swagger_wrapper import are_swagger_routes_registered, get_auth_security, swagger_route
logger = logging.getLogger(__name__)
@@ -1295,8 +1296,60 @@ def _build_status_readouts():
"message": "Audio runtime support could not be checked.",
}
+ readouts["appinsights_connection"] = _build_appinsights_connection_readout()
+
return readouts
+ def _build_appinsights_connection_readout():
+ """Say whether Application Insights has anywhere to send telemetry.
+
+ Global logging is a switch on this page, but the destination is an App Service
+ application setting. Turning the switch on without one does nothing at all,
+ and nothing else on the page would say so.
+ """
+ state = get_appinsights_runtime_state()
+ if not state["connection_configured"]:
+ return {
+ "ok": False,
+ "message": (
+ "APPLICATIONINSIGHTS_CONNECTION_STRING is not set on this App "
+ "Service, so nothing reaches Application Insights whatever the "
+ "switch above says."
+ ),
+ }
+ if not state["exporter_configured"]:
+ return {
+ "ok": False,
+ "message": (
+ "APPLICATIONINSIGHTS_CONNECTION_STRING is set, but the exporter did "
+ "not start. Check the application's startup log."
+ ),
+ }
+ return {
+ "ok": True,
+ "message": (
+ "Connected through the APPLICATIONINSIGHTS_CONNECTION_STRING App "
+ "Service setting."
+ ),
+ }
+
+ def _build_runtime_flags():
+ """Server-resolved flags the admin surface reads but cannot set.
+
+ ``mcp_ui_enabled`` gates a navigation section on an App Service application
+ setting. The rest describe how this process was started -- whether
+ Application Insights has a destination, whether its global logging is live
+ and whether the Swagger routes were registered -- so a section can say when
+ a saved change is still waiting for a restart.
+ """
+ appinsights = get_appinsights_runtime_state()
+ return {
+ "mcp_ui_enabled": is_mcp_ui_enabled(),
+ "appinsights_connection_configured": appinsights["connection_configured"],
+ "appinsights_global_logging_active": appinsights["global_logging_active"],
+ "swagger_routes_registered": are_swagger_routes_registered(current_app),
+ }
+
def _build_endpoint_readouts(settings):
"""Readouts derived from the settings document rather than from the host."""
endpoint = str(settings.get("video_indexer_endpoint") or "").strip()
@@ -1382,7 +1435,8 @@ def v2_admin_get_settings():
no entry are rendered by the SPA's ``enable_*`` fallback scan, so groups that have
not been described yet keep working. ``suppressed_capabilities`` names the keys
that scan must skip because they are derived or are staged rollout flags with no
- administrator control.
+ administrator control. ``section_guides`` names the in-app guide a section header
+ offers, for settings that depend on work done outside SimpleChat.
The application release status is deliberately not part of this response. A
release check can contact GitHub, so it is served by ``v2_admin_get_update_status``
@@ -1398,6 +1452,7 @@ def v2_admin_get_settings():
"admin_nav": ADMIN_NAV,
"field_schema": get_admin_settings_fields(),
"section_status": get_admin_section_status(),
+ "section_guides": get_admin_section_guides(),
"app_role_requirements": get_app_role_requirements(),
"branding_assets": _build_branding_assets(settings),
"status_readouts": {
@@ -1406,10 +1461,9 @@ def v2_admin_get_settings():
},
"model_catalog": _build_model_catalog(settings),
# Navigation sections may be conditional on a runtime flag
- # rather than a stored setting. Inbound MCP is gated by an
- # App Service application setting, so its value cannot be
- # read out of the settings document the SPA already holds.
- "runtime_flags": {"mcp_ui_enabled": is_mcp_ui_enabled()},
+ # rather than a stored setting, and Operations reports how
+ # the running process was started. See _build_runtime_flags.
+ "runtime_flags": _build_runtime_flags(),
"suppressed_capabilities": get_suppressed_capability_keys(),
"version": VERSION,
}
diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py
index b13dd7517..dd456a568 100644
--- a/application/single_app/route_frontend_admin_settings.py
+++ b/application/single_app/route_frontend_admin_settings.py
@@ -57,7 +57,9 @@
from functions_control_center import (
calculate_next_control_center_auto_refresh_run,
get_control_center_auto_refresh_schedule,
+ resolve_control_center_auto_refresh_settings,
)
+from functions_logging_timers import resolve_logging_timer_settings
from functions_cosmos_throughput import (
get_cached_cosmos_throughput_status,
get_cosmos_resource_config,
@@ -1344,35 +1346,22 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul
'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', 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'] 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:
- control_center_auto_refresh_next_run = calculate_next_control_center_auto_refresh_run(
- {
- '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()
- else:
- control_center_auto_refresh_next_run = existing_control_center_auto_refresh_next_run
- else:
- control_center_auto_refresh_next_run = None
+ # Shared with the V2 settings PATCH, so both surfaces decide the same way
+ # whether a save moves the next scheduled refresh.
+ control_center_auto_refresh_settings = resolve_control_center_auto_refresh_settings(
+ {
+ 'control_center_auto_refresh_enabled': control_center_auto_refresh_enabled,
+ 'control_center_auto_refresh_time': incoming_control_center_auto_refresh_time,
+ 'control_center_auto_refresh_timezone': incoming_control_center_auto_refresh_timezone,
+ },
+ settings,
+ )
+ control_center_auto_refresh_schedule = get_control_center_auto_refresh_schedule(
+ control_center_auto_refresh_settings
+ )
+ control_center_auto_refresh_next_run = control_center_auto_refresh_settings[
+ 'control_center_auto_refresh_next_run'
+ ]
web_search_consent_message = (
"When you use Grounding with Bing Search, your customer data is transferred "
@@ -2038,124 +2027,39 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul
enable_debug_logging = form_data.get('enable_debug_logging') == 'on'
# --- Debug Logging Timer Settings ---
- debug_logging_timer_enabled = form_data.get('enable_debug_logging_timer') == 'on'
- debug_timer_value = int(form_data.get('debug_timer_value', 1))
- debug_timer_unit = form_data.get('debug_timer_unit', 'hours')
- debug_logging_turnoff_time = None
-
- # Validate debug timer values
- timer_limits = {
- 'minutes': (1, 120),
- 'hours': (1, 24),
- 'days': (1, 7),
- 'weeks': (1, 52)
- }
-
- if debug_timer_unit in timer_limits:
- min_val, max_val = timer_limits[debug_timer_unit]
- if debug_timer_value < min_val or debug_timer_value > max_val:
- debug_timer_value = min(max(debug_timer_value, min_val), max_val)
-
- # Get existing timer settings to check if they've changed
- existing_debug_timer_enabled = settings.get('debug_logging_timer_enabled', False)
- existing_debug_timer_value = settings.get('debug_timer_value', 1)
- existing_debug_timer_unit = settings.get('debug_timer_unit', 'hours')
- existing_debug_logging_enabled = settings.get('enable_debug_logging', False)
- existing_debug_turnoff_time = settings.get('debug_logging_turnoff_time')
-
- # Determine if timer settings have changed
- timer_settings_changed = (
- debug_logging_timer_enabled != existing_debug_timer_enabled or
- debug_timer_value != existing_debug_timer_value or
- debug_timer_unit != existing_debug_timer_unit
+ # The turnoff rules are shared with the V2 settings PATCH and the background
+ # checker, so a timer behaves the same whichever surface saved it.
+ debug_logging_timer = resolve_logging_timer_settings(
+ 'debug',
+ {
+ 'enable_debug_logging': enable_debug_logging,
+ 'debug_logging_timer_enabled': form_data.get('enable_debug_logging_timer') == 'on',
+ 'debug_timer_value': form_data.get('debug_timer_value', 1),
+ 'debug_timer_unit': form_data.get('debug_timer_unit', 'hours'),
+ },
+ settings,
)
- debug_logging_newly_enabled = enable_debug_logging and not existing_debug_logging_enabled
-
- # Calculate debug logging turnoff time if timer is enabled and debug logging is on
- if enable_debug_logging and debug_logging_timer_enabled:
- # Only recalculate turnoff time if:
- # 1. Timer settings have changed (value, unit, or enabled state), OR
- # 2. Debug logging was just enabled, OR
- # 3. No existing turnoff time exists
- if timer_settings_changed or debug_logging_newly_enabled or not existing_debug_turnoff_time:
- now = datetime.now()
-
- if debug_timer_unit == 'minutes':
- delta = timedelta(minutes=debug_timer_value)
- elif debug_timer_unit == 'hours':
- delta = timedelta(hours=debug_timer_value)
- elif debug_timer_unit == 'days':
- delta = timedelta(days=debug_timer_value)
- elif debug_timer_unit == 'weeks':
- delta = timedelta(weeks=debug_timer_value)
- else:
- delta = timedelta(hours=1) # default fallback
-
- debug_logging_turnoff_time = now + delta
- # Convert to ISO string for JSON serialization
- debug_logging_turnoff_time_str = debug_logging_turnoff_time.isoformat()
- else:
- # Preserve existing turnoff time
- debug_logging_turnoff_time_str = existing_debug_turnoff_time
- else:
- debug_logging_turnoff_time_str = None
+ debug_logging_timer_enabled = debug_logging_timer['debug_logging_timer_enabled']
+ debug_timer_value = debug_logging_timer['debug_timer_value']
+ debug_timer_unit = debug_logging_timer['debug_timer_unit']
+ debug_logging_turnoff_time_str = debug_logging_timer['debug_logging_turnoff_time']
# --- File Processing Logs Timer Settings ---
- file_processing_logs_timer_enabled = form_data.get('enable_file_processing_logs_timer') == 'on'
- file_timer_value = int(form_data.get('file_timer_value', 1))
- file_timer_unit = form_data.get('file_timer_unit', 'hours')
- file_processing_logs_turnoff_time = None
enable_file_processing_logs = form_data.get('enable_file_processing_logs') == 'on'
-
- # Validate file timer values
- if file_timer_unit in timer_limits:
- min_val, max_val = timer_limits[file_timer_unit]
- if file_timer_value < min_val or file_timer_value > max_val:
- file_timer_value = min(max(file_timer_value, min_val), max_val)
-
- # Get existing file timer settings to check if they've changed
- existing_file_timer_enabled = settings.get('file_processing_logs_timer_enabled', False)
- existing_file_timer_value = settings.get('file_timer_value', 1)
- existing_file_timer_unit = settings.get('file_timer_unit', 'hours')
- existing_file_processing_logs_enabled = settings.get('enable_file_processing_logs', False)
- existing_file_turnoff_time = settings.get('file_processing_logs_turnoff_time')
-
- # Determine if timer settings have changed
- file_timer_settings_changed = (
- file_processing_logs_timer_enabled != existing_file_timer_enabled or
- file_timer_value != existing_file_timer_value or
- file_timer_unit != existing_file_timer_unit
+ file_processing_logs_timer = resolve_logging_timer_settings(
+ 'file',
+ {
+ 'enable_file_processing_logs': enable_file_processing_logs,
+ 'file_processing_logs_timer_enabled': form_data.get('enable_file_processing_logs_timer') == 'on',
+ 'file_timer_value': form_data.get('file_timer_value', 1),
+ 'file_timer_unit': form_data.get('file_timer_unit', 'hours'),
+ },
+ settings,
)
- file_processing_logs_newly_enabled = enable_file_processing_logs and not existing_file_processing_logs_enabled
-
- # Calculate file processing logs turnoff time if timer is enabled and file processing logs are on
- if enable_file_processing_logs and file_processing_logs_timer_enabled:
- # Only recalculate turnoff time if:
- # 1. Timer settings have changed (value, unit, or enabled state), OR
- # 2. File processing logs was just enabled, OR
- # 3. No existing turnoff time exists
- if file_timer_settings_changed or file_processing_logs_newly_enabled or not existing_file_turnoff_time:
- now = datetime.now()
-
- if file_timer_unit == 'minutes':
- delta = timedelta(minutes=file_timer_value)
- elif file_timer_unit == 'hours':
- delta = timedelta(hours=file_timer_value)
- elif file_timer_unit == 'days':
- delta = timedelta(days=file_timer_value)
- elif file_timer_unit == 'weeks':
- delta = timedelta(weeks=file_timer_value)
- else:
- delta = timedelta(hours=1) # default fallback
-
- file_processing_logs_turnoff_time = now + delta
- # Convert to ISO string for JSON serialization
- file_processing_logs_turnoff_time_str = file_processing_logs_turnoff_time.isoformat()
- else:
- # Preserve existing turnoff time
- file_processing_logs_turnoff_time_str = existing_file_turnoff_time
- else:
- file_processing_logs_turnoff_time_str = None
+ file_processing_logs_timer_enabled = file_processing_logs_timer['file_processing_logs_timer_enabled']
+ file_timer_value = file_processing_logs_timer['file_timer_value']
+ file_timer_unit = file_processing_logs_timer['file_timer_unit']
+ file_processing_logs_turnoff_time_str = file_processing_logs_timer['file_processing_logs_turnoff_time']
# --- Retention Policy Settings ---
enable_retention_policy_personal = form_data.get('enable_retention_policy_personal') == 'on'
diff --git a/application/single_app/swagger_wrapper.py b/application/single_app/swagger_wrapper.py
index 54cf7e76b..af97c60d0 100644
--- a/application/single_app/swagger_wrapper.py
+++ b/application/single_app/swagger_wrapper.py
@@ -1543,6 +1543,10 @@ def extract_route_info(app: Flask) -> Dict[str, Any]:
return openapi_spec
+# The blueprint the Swagger routes are registered under, which is also how a running
+# process tells whether it is serving them.
+SWAGGER_BLUEPRINT_NAME = 'swagger_docs'
+
def register_swagger_routes(app: Flask):
"""
Register swagger documentation routes if enabled in settings.
@@ -1559,7 +1563,7 @@ def register_swagger_routes(app: Flask):
print("Swagger documentation is disabled in admin settings.")
return
- swagger_bp = Blueprint('swagger_docs', __name__)
+ swagger_bp = Blueprint(SWAGGER_BLUEPRINT_NAME, __name__)
swagger_bp.before_request(apply_blueprint_auth('login_required'))
@swagger_bp.route('/swagger')
@@ -2281,6 +2285,17 @@ def swagger_cache_management():
app.register_blueprint(swagger_bp)
+
+def are_swagger_routes_registered(app: Flask) -> bool:
+ """Whether this process registered the Swagger routes when it started.
+
+ ``register_swagger_routes`` reads ``enable_swagger`` once, at startup, and adds
+ the blueprint only when it is on. The saved setting and the routes a running
+ process serves can therefore disagree until the App Service restarts, and the
+ admin surface uses this to say so.
+ """
+ return SWAGGER_BLUEPRINT_NAME in app.blueprints
+
# Utility function to create common response schemas
def create_response_schema(success_schema: Optional[Dict[str, Any]] = None, error_schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
diff --git a/application/v2_ui/src/components/admin/ControlCenterAccessMatrix.tsx b/application/v2_ui/src/components/admin/ControlCenterAccessMatrix.tsx
new file mode 100644
index 000000000..32b7ac084
--- /dev/null
+++ b/application/v2_ui/src/components/admin/ControlCenterAccessMatrix.tsx
@@ -0,0 +1,123 @@
+// ControlCenterAccessMatrix.tsx
+// Who can open the Control Center, under the switches as they currently stand.
+//
+// Two switches decide Control Center access, and they interact in a way neither label can
+// say on its own: requiring ControlCenterAdmin takes access away from general Admins, while
+// the dashboard reader role works whether or not that requirement is on. The server-rendered
+// page explained this in prose spread across a pane and a modal. A table read straight from
+// the switches -- unsaved edits included -- shows the consequence of a change before it is
+// saved.
+//
+// The rules are `controlCenterAccess`, which mirrors `control_center_required`.
+
+import { clsx } from 'clsx';
+import { Check, Minus } from 'lucide-react';
+import { controlCenterAccess, type ControlCenterRole } from '../../lib/adminOperations';
+import { CopyChip } from './CopyValue';
+import { ReadoutRow } from './ReadoutRow';
+
+const ROLE_DESCRIPTIONS: Record = {
+ Admin: "SimpleChat's general administrator role.",
+ ControlCenterAdmin: 'Delegated Control Center administration.',
+ ControlCenterDashboardReader: 'The usage picture, with no way to change anything.',
+};
+
+function AccessCell({ granted, role, area }: { granted: boolean; role: string; area: string }) {
+ return (
+
+
+ {granted ? (
+
+ ) : (
+
+ )}
+ {granted ? 'Yes' : 'No'}
+
+ {granted ? `, ${role} can use the ${area}` : `, ${role} cannot use the ${area}`}
+
+
+
+ );
+}
+
+export function ControlCenterAccessMatrix({
+ label,
+ help,
+ requireAdminRole,
+ allowDashboardReader,
+ unsaved,
+}: {
+ label: string;
+ help?: string;
+ requireAdminRole: boolean;
+ allowDashboardReader: boolean;
+ /** Whether either switch holds an edit that has not been saved yet. */
+ unsaved: boolean;
+}) {
+ const rows = controlCenterAccess(requireAdminRole, allowDashboardReader);
+
+ return (
+
+ {/* Positioned so the cells' screen-reader text, which is absolutely positioned,
+ scrolls with the table instead of widening the card past the viewport. */}
+
+
+
+ Control Center access for each app role
+ {unsaved ? ', including changes that have not been saved' : ''}
+
+ Each row is one role held on its own; someone holding two gets the better row.
+ {unsaved ? ' Showing your unsaved changes.' : ''}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/CopyValue.tsx b/application/v2_ui/src/components/admin/CopyValue.tsx
new file mode 100644
index 000000000..b6713ccb8
--- /dev/null
+++ b/application/v2_ui/src/components/admin/CopyValue.tsx
@@ -0,0 +1,95 @@
+// CopyValue.tsx
+// One way to copy a value to the clipboard, for every Operations control that shares one.
+//
+// Role values are typed into Entra by hand, health check addresses into a monitoring tool,
+// and a PowerShell snippet into a terminal. Each is easy to mistype and each fails in a way
+// that is hard to trace back to the typo, so they are copyable wherever they appear. The
+// pattern itself was first drawn by the app role roster's role chips; it lives here so the
+// copy affordance looks and behaves the same everywhere it is offered.
+
+import { useEffect, useRef, useState } from 'react';
+import { clsx } from 'clsx';
+import { Check, Copy } from 'lucide-react';
+
+/** Copy text, reporting success so the control can say so. */
+function useCopy(value: string): [boolean, () => Promise] {
+ const [copied, setCopied] = useState(false);
+ const timer = useRef(null);
+
+ useEffect(
+ () => () => {
+ if (timer.current !== null) {
+ window.clearTimeout(timer.current);
+ }
+ },
+ [],
+ );
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ if (timer.current !== null) {
+ window.clearTimeout(timer.current);
+ }
+ timer.current = window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Clipboard access can be refused by permissions policy. The value is on screen
+ // and selectable either way, so there is nothing to recover from.
+ }
+ };
+
+ return [copied, copy];
+}
+
+/** A small copy button for a value shown beside it. */
+export function CopyButton({ value, label }: { value: string; label: string }) {
+ const [copied, copy] = useCopy(value);
+ return (
+
+ );
+}
+
+/** A value shown as a monospace chip that copies itself when pressed. */
+export function CopyChip({ value, label }: { value: string; label?: string }) {
+ const [copied, copy] = useCopy(value);
+ const description = label ?? value;
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/EndpointLinks.tsx b/application/v2_ui/src/components/admin/EndpointLinks.tsx
new file mode 100644
index 000000000..bf604410f
--- /dev/null
+++ b/application/v2_ui/src/components/admin/EndpointLinks.tsx
@@ -0,0 +1,159 @@
+// EndpointLinks.tsx
+// The addresses a section exposes, ready to copy into the tool that will call them.
+//
+// Health checks are pasted into App Service, Azure Monitor or a load balancer probe, and the
+// Swagger links are handed to developers. In each case the full address -- this
+// deployment's origin plus the path -- is what is needed, and typing it by hand is where it
+// goes wrong. Each row also says who the endpoint answers and whether it is live right now:
+// a health check is live once its switch is saved on, while Swagger is live only once the
+// running app has registered it, which a save alone does not do.
+
+import { clsx } from 'clsx';
+import { ArrowUpRight } from 'lucide-react';
+import type { AdminEndpointAccess, AdminEndpointLink } from '../../lib/adminFields';
+import { absoluteUrl } from '../../lib/adminOperations';
+import { apiUrl } from '../../lib/apiClient';
+import { CopyButton } from './CopyValue';
+import { ReadoutRow } from './ReadoutRow';
+
+const ACCESS_LABEL: Record = {
+ protected: 'Protected',
+ public: 'No sign-in',
+ signed_in: 'Sign-in required',
+};
+
+type EndpointState =
+ | { kind: 'on' }
+ | { kind: 'off' }
+ | { kind: 'turns-on' }
+ | { kind: 'turns-off' }
+ | { kind: 'running' }
+ | { kind: 'not-running' };
+
+const STATE_LABEL: Record = {
+ on: 'On',
+ off: 'Off',
+ 'turns-on': 'On after you save',
+ 'turns-off': 'Off after you save',
+ running: 'Running',
+ 'not-running': 'Not running',
+};
+
+function stateOf(
+ endpoint: AdminEndpointLink,
+ isSavedOn: (key: string) => boolean,
+ isDraftOn: (key: string) => boolean,
+ runtimeFlags: Record,
+): EndpointState {
+ if (endpoint.runtime_flag) {
+ return runtimeFlags[endpoint.runtime_flag] ? { kind: 'running' } : { kind: 'not-running' };
+ }
+ if (!endpoint.gate_key) {
+ return { kind: 'on' };
+ }
+ const saved = isSavedOn(endpoint.gate_key);
+ const draft = isDraftOn(endpoint.gate_key);
+ if (saved === draft) {
+ return saved ? { kind: 'on' } : { kind: 'off' };
+ }
+ return draft ? { kind: 'turns-on' } : { kind: 'turns-off' };
+}
+
+/** Whether the address answers right now, which is what decides if Open is offered. */
+function isLive(state: EndpointState): boolean {
+ return state.kind === 'on' || state.kind === 'running' || state.kind === 'turns-off';
+}
+
+function Chip({ children, tone }: { children: string; tone: 'ok' | 'warn' | 'muted' | 'info' }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function stateTone(state: EndpointState): 'ok' | 'warn' | 'muted' | 'info' {
+ if (state.kind === 'on' || state.kind === 'running') {
+ return 'ok';
+ }
+ if (state.kind === 'not-running') {
+ return 'warn';
+ }
+ if (state.kind === 'turns-on' || state.kind === 'turns-off') {
+ return 'info';
+ }
+ return 'muted';
+}
+
+export function EndpointLinks({
+ label,
+ help,
+ endpoints,
+ isSavedOn,
+ isDraftOn,
+ runtimeFlags,
+}: {
+ label: string;
+ help?: string;
+ endpoints: AdminEndpointLink[];
+ isSavedOn: (key: string) => boolean;
+ isDraftOn: (key: string) => boolean;
+ runtimeFlags: Record;
+}) {
+ const origin = window.location.origin;
+
+ return (
+
+
+ The records are removed from Cosmos DB for good. Logging itself is not
+ changed.
+
+ {failure ? (
+
+
+ {failure}
+
+ ) : null}
+
+
+ ) : null}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/LoggingTimerStatus.tsx b/application/v2_ui/src/components/admin/LoggingTimerStatus.tsx
new file mode 100644
index 000000000..c84026518
--- /dev/null
+++ b/application/v2_ui/src/components/admin/LoggingTimerStatus.tsx
@@ -0,0 +1,97 @@
+// LoggingTimerStatus.tsx
+// When a log's automatic turnoff will happen, in the administrator's own time.
+//
+// The server-rendered page printed the stored turnoff time as a raw timestamp in the
+// server's clock, which told an administrator in another zone almost nothing. Turnoff times
+// are now stored in UTC, so this shows them in the browser's zone with how far off they are.
+//
+// It also says what a save would do. Changing the duration restarts the clock from the
+// moment of saving, while saving an unrelated setting does not, and the readout follows the
+// same rule as the server so it never promises a restart that will not happen.
+
+import { AlarmClock, CircleAlert, Clock, Infinity as Endless } from 'lucide-react';
+import type { AdminLoggingTimerKeys } from '../../lib/adminFields';
+import { describeLoggingTimer, formatInstant, formatRelative } from '../../lib/adminOperations';
+import type { Json } from '../../lib/types';
+import { ReadoutLine, ReadoutRow } from './ReadoutRow';
+import { useNow } from './useNow';
+
+export function LoggingTimerStatus({
+ label,
+ help,
+ keys,
+ settings,
+ draft,
+}: {
+ label: string;
+ help?: string;
+ keys: AdminLoggingTimerKeys;
+ settings: Json;
+ draft: Json;
+}) {
+ const now = useNow();
+ const readout = describeLoggingTimer(keys, settings, draft, now);
+
+ if (readout.kind === 'off') {
+ return null;
+ }
+
+ return (
+
+
+ {readout.kind === 'no-timer' ? (
+
+ No automatic turnoff: it stays on until someone switches it off.
+
+ ) : null}
+
+ {readout.kind === 'on-save' && readout.reason !== 'missing' ? (
+
+ Saving now would turn it off {formatInstant(readout.projected)} (
+ {formatRelative(readout.projected, now)}).
+
+ ) : null}
+
+ {readout.kind === 'on-save' && readout.reason === 'missing' ? (
+
+ No turnoff time is stored, so it will not switch itself off yet.
+
+ ) : null}
+
+ {readout.kind === 'scheduled' ? (
+
+ Turns off {formatInstant(readout.at)} ({formatRelative(readout.at, now)}).
+
+ ) : null}
+
+ {readout.kind === 'overdue' ? (
+
+ Due to turn off now; it was set for {formatInstant(readout.at)}.
+
+ ) : null}
+
+ {readout.kind === 'legacy' ? (
+
+ Turns off at {readout.text}, server time.
+
+ ) : null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/ReadoutRow.tsx b/application/v2_ui/src/components/admin/ReadoutRow.tsx
new file mode 100644
index 000000000..ad3ae5d87
--- /dev/null
+++ b/application/v2_ui/src/components/admin/ReadoutRow.tsx
@@ -0,0 +1,80 @@
+// ReadoutRow.tsx
+// The frame an Operations readout sits in, and the status line inside it.
+//
+// A readout reports something rather than editing it -- when a refresh runs next, whether a
+// saved setting is live yet -- but it still reads as one of the section's settings. It uses
+// the same label, help and control columns as every field (see `.admin-field` in
+// theme.css), so on a wide card it lines up with the controls around it instead of
+// floating as a banner.
+
+import type { ReactNode } from 'react';
+import { clsx } from 'clsx';
+import type { LucideIcon } from 'lucide-react';
+import type { FieldWidth } from './fields';
+
+export type ReadoutTone = 'ok' | 'warn' | 'info' | 'muted';
+
+const TONE_CLASS: Record = {
+ ok: 'border-ok/40 bg-ok/5 text-text-2',
+ warn: 'border-warn/40 bg-warn/5 text-text-1',
+ info: 'border-edge bg-surface-2 text-text-2',
+ muted: 'border-edge bg-surface-1 text-text-3',
+};
+
+const ICON_CLASS: Record = {
+ ok: 'text-ok',
+ warn: 'text-warn',
+ info: 'text-text-3',
+ muted: 'text-text-3',
+};
+
+export function ReadoutRow({
+ label,
+ help,
+ width = 'wide',
+ children,
+}: {
+ label: string;
+ help?: string;
+ width?: FieldWidth;
+ children: ReactNode;
+}) {
+ return (
+
+
{label}
+ {help ? (
+
{help}
+ ) : null}
+
{children}
+
+ );
+}
+
+/** One status statement, with its tone carried by colour, icon and wording together. */
+export function ReadoutLine({
+ tone,
+ icon: Icon,
+ children,
+ detail,
+}: {
+ tone: ReadoutTone;
+ icon: LucideIcon;
+ children: ReactNode;
+ /** A quieter second line, for the supporting fact. */
+ detail?: ReactNode;
+}) {
+ return (
+
+
+
+
{children}
+ {detail ?
{detail}
: null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/RefreshScheduleStatus.tsx b/application/v2_ui/src/components/admin/RefreshScheduleStatus.tsx
new file mode 100644
index 000000000..edb88bccb
--- /dev/null
+++ b/application/v2_ui/src/components/admin/RefreshScheduleStatus.tsx
@@ -0,0 +1,156 @@
+// RefreshScheduleStatus.tsx
+// When the Control Center's daily refresh runs next, and when it last ran.
+//
+// The schedule is set in one timezone and read by administrators in others, so the next
+// run is shown twice when the two differ: in the schedule's own zone, which is what the
+// refresh follows, and in the reader's, which is what they will actually be doing at the
+// time. The server-rendered page showed only the reader's.
+//
+// It follows the server's rule for when a save moves the next run, so an unsaved change to
+// the time or timezone is shown as what the save would schedule rather than as the run
+// that is still stored.
+
+import { ArrowUpRight, CalendarClock, CalendarOff, Clock, History } from 'lucide-react';
+import {
+ CONTROL_CENTER_SCHEDULE_KEYS,
+ describeRefreshSchedule,
+ formatInstant,
+ formatRelative,
+ formatWallTime,
+ isKnownTimeZone,
+ readStoredInstant,
+ viewerTimeZone,
+} from '../../lib/adminOperations';
+import type { Json } from '../../lib/types';
+import { ReadoutLine, ReadoutRow } from './ReadoutRow';
+import { useNow } from './useNow';
+
+/** "Tue, 2:00 AM (America/New_York)", plus the reader's own time when it differs. */
+function ZonedTime({ at, zone, now }: { at: Date; zone: string; now: Date }) {
+ const viewer = viewerTimeZone();
+ const zoneKnown = isKnownTimeZone(zone);
+ if (!zoneKnown || !viewer || viewer === zone) {
+ return (
+ <>
+ {formatInstant(at)} ({formatRelative(at, now)})
+ >
+ );
+ }
+ return (
+ <>
+ {formatInstant(at, zone)} in {zone}, which is {formatInstant(at)} your time (
+ {formatRelative(at, now)})
+ >
+ );
+}
+
+export function RefreshScheduleStatus({
+ label,
+ help,
+ settings,
+ draft,
+}: {
+ label: string;
+ help?: string;
+ settings: Json;
+ draft: Json;
+}) {
+ const now = useNow();
+ const readout = describeRefreshSchedule(settings, draft, now);
+ const lastRefresh = readStoredInstant(settings[CONTROL_CENTER_SCHEDULE_KEYS.lastRefresh]);
+
+ return (
+
+
+
+ {readout.kind === 'off' ? (
+
+ The daily refresh is off.
+
+ ) : null}
+
+ {readout.kind === 'on-save' ? (
+
+ {readout.projected ? (
+ <>
+ Saving schedules the next refresh for{' '}
+ .
+ >
+ ) : (
+ 'Saving calculates the next refresh from the time and timezone above.'
+ )}
+
+ ) : null}
+
+ {readout.kind === 'scheduled' ? (
+
+ Next refresh .
+
+ ) : null}
+
+ {readout.kind === 'overdue' ? (
+
+ Due now; it was scheduled for{' '}
+ {isKnownTimeZone(readout.timezone)
+ ? formatInstant(readout.at, readout.timezone)
+ : formatInstant(readout.at)}
+ .
+
+ ) : null}
+
+ {readout.kind === 'unscheduled' ? (
+
+ No run is scheduled yet.
+
+ ) : null}
+
+
+
+
+
+ {lastRefresh.kind === 'instant' ? (
+ <>
+ Last refreshed {formatInstant(lastRefresh.at)} (
+ {formatRelative(lastRefresh.at, now)}).
+ >
+ ) : lastRefresh.kind === 'legacy' ? (
+ <>Last refreshed {lastRefresh.text}, server time.>
+ ) : (
+ 'Not refreshed yet.'
+ )}
+
+
+ Open Control Center
+
+ (opens in a new tab)
+
+
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/RelatedSectionLink.tsx b/application/v2_ui/src/components/admin/RelatedSectionLink.tsx
new file mode 100644
index 000000000..67e1d62d0
--- /dev/null
+++ b/application/v2_ui/src/components/admin/RelatedSectionLink.tsx
@@ -0,0 +1,53 @@
+// RelatedSectionLink.tsx
+// Points from a setting to the section where its effect shows up.
+//
+// Some settings act somewhere else entirely: the Document Access Index diagnostics switch
+// lives with Debug Logging but changes what Scale > Cosmos > DAI Metrics draws. Saying where,
+// with a way to get there, saves an administrator from flipping a switch and then hunting
+// for what changed. The server-rendered page addresses a tab rather than a section, so a
+// link there opens the tab that holds it.
+
+import { ArrowRight, ArrowUpRight } from 'lucide-react';
+
+export function RelatedSectionLink({
+ label,
+ location,
+ onNavigate,
+}: {
+ label: string;
+ /** Where the section sits in the navigation, or null when it is not in it. */
+ location: { groupLabel: string; tabId: string; tabLabel: string; sectionLabel: string } | null;
+ /** Present when the section is shown on this page; otherwise the classic page is linked. */
+ onNavigate?: () => void;
+}) {
+ if (!location) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/RestartStatus.tsx b/application/v2_ui/src/components/admin/RestartStatus.tsx
new file mode 100644
index 000000000..62806f704
--- /dev/null
+++ b/application/v2_ui/src/components/admin/RestartStatus.tsx
@@ -0,0 +1,66 @@
+// RestartStatus.tsx
+// Says whether a setting the app only reads at startup is live yet.
+//
+// Application Insights global logging and the Swagger routes are both wired up once, when
+// the App Service starts. Saving either switch changes the settings document and nothing
+// else, so the page would otherwise show a setting as on while the running app behaves as
+// though it were off -- with nothing to explain why the change "did not work". The server
+// reports how the running process was started; this compares that with the saved value and
+// any unsaved edit.
+
+import { CheckCircle2, Info, RotateCcw } from 'lucide-react';
+import { describeRestartState } from '../../lib/adminOperations';
+import { ReadoutLine, ReadoutRow } from './ReadoutRow';
+
+function onOff(value: boolean): string {
+ return value ? 'on' : 'off';
+}
+
+export function RestartStatus({
+ label,
+ help,
+ saved,
+ draft,
+ running,
+ available,
+}: {
+ label: string;
+ help?: string;
+ saved: boolean;
+ draft: boolean;
+ running: boolean;
+ /** False when the setting cannot take effect whatever is saved; another readout says why. */
+ available: boolean;
+}) {
+ const state = describeRestartState({ saved, draft, running, available });
+ if (state.kind === 'unavailable') {
+ return null;
+ }
+
+ return (
+
+
+ {state.kind === 'in-sync' ? (
+
+ Running as saved: {onOff(state.running)}.
+
+ ) : null}
+ {state.kind === 'restart' ? (
+
+ Saved as {onOff(state.saved)}, but the running app started with it{' '}
+ {onOff(state.running)}.
+
+ ) : null}
+ {state.kind === 'save-then-restart' ? (
+
+ Turns {onOff(state.next)} after you save and then restart the App Service.
+
+ ) : null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/SettingsSection.tsx b/application/v2_ui/src/components/admin/SettingsSection.tsx
index aee198871..e6c16687f 100644
--- a/application/v2_ui/src/components/admin/SettingsSection.tsx
+++ b/application/v2_ui/src/components/admin/SettingsSection.tsx
@@ -28,7 +28,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { clsx } from 'clsx';
-import { AlertTriangle, ChevronRight, type LucideIcon } from 'lucide-react';
+import { AlertTriangle, BookOpen, ChevronRight, type LucideIcon } from 'lucide-react';
import {
asBoolean,
groupFields,
@@ -106,6 +106,11 @@ export interface SettingsSectionProps {
forceExpanded?: boolean;
/** Opt-in presentation overrides; never changes the schema's behavior. */
appearance?: SettingsSectionAppearance;
+ /**
+ * An in-app guide the header offers, for settings that depend on work done outside
+ * SimpleChat. Declared per section by `ADMIN_SECTION_GUIDES`.
+ */
+ guide?: { label: string; onOpen: () => void };
children?: ReactNode;
}
@@ -287,6 +292,7 @@ export function SettingsSection({
renderCapability,
forceExpanded,
appearance,
+ guide,
children,
}: SettingsSectionProps) {
const capability = useMemo(() => findCapabilityField(fields), [fields]);
@@ -412,18 +418,38 @@ export function SettingsSection({
- {presentation ? (
- // Free to shrink once it has wrapped onto its own line, so a long
- // status at a large text size wraps inside the card instead of past it.
-
-
- {presentation.label}
-
+ {guide || presentation ? (
+
+ {guide ? (
+
+ ) : null}
+ {presentation ? (
+ // Free to shrink once it has wrapped onto its own line, so a long
+ // status at a large text size wraps inside the card instead of past it.
+
+
+ {presentation.label}
+
+ ) : null}
+
) : null}
diff --git a/application/v2_ui/src/components/admin/fields.tsx b/application/v2_ui/src/components/admin/fields.tsx
index 55bc64924..4dfa94693 100644
--- a/application/v2_ui/src/components/admin/fields.tsx
+++ b/application/v2_ui/src/components/admin/fields.tsx
@@ -10,8 +10,8 @@
// rejected save points at the control that caused it.
import { clsx } from 'clsx';
-import { AlertCircle, Check, CheckCircle2, Info, KeyRound, Lock, RotateCcw } from 'lucide-react';
-import { useState, type ReactNode } from 'react';
+import { AlertCircle, Check, CheckCircle2, Info, KeyRound, LocateFixed, Lock, RotateCcw } from 'lucide-react';
+import { useMemo, useState, type ReactNode } from 'react';
import {
asBoolean,
asNumber,
@@ -21,6 +21,12 @@ import {
SECRET_PLACEHOLDER,
type AdminField,
} from '../../lib/adminFields';
+import {
+ formatUtcOffset,
+ isKnownTimeZone,
+ listTimeZones,
+ viewerTimeZone,
+} from '../../lib/adminOperations';
import { Toggle } from '../ui/primitives';
const inputClass = clsx(
@@ -147,9 +153,29 @@ export interface FieldControlProps {
}
function TextControl({ field, value, error, warning, disabled, onChange }: FieldControlProps) {
+ if (field.input_type === 'timezone') {
+ return (
+
+ );
+ }
+
const id = `admin-field-${field.key}`;
+ const isTime = field.input_type === 'time';
return (
-
+
listTimeZones(), []);
+ const viewerZone = useMemo(() => viewerTimeZone(), []);
+ const current = asString(value, asString(field.default));
+ const known = isKnownTimeZone(current);
+
+ return (
+
+
+ {known
+ ? `Currently ${formatUtcOffset(current, new Date())}.`
+ : 'Not a timezone this browser recognises. Pick one from the suggestions.'}
+
+
+ );
+}
+
/**
* A write-only credential.
*
diff --git a/application/v2_ui/src/components/admin/guides/ControlCenterRolesGuide.tsx b/application/v2_ui/src/components/admin/guides/ControlCenterRolesGuide.tsx
new file mode 100644
index 000000000..de5d982d5
--- /dev/null
+++ b/application/v2_ui/src/components/admin/guides/ControlCenterRolesGuide.tsx
@@ -0,0 +1,240 @@
+// ControlCenterRolesGuide.tsx
+// How to create, assign and enforce the two Control Center app roles.
+//
+// Ported from the server-rendered page's Role Setup Guide. One troubleshooting line is
+// corrected rather than copied: it told administrators that a ControlCenterAdmin holder also
+// needs the general Admin role, but `control_center_required` and the navigation admit the
+// role on its own while the requirement is enforced.
+
+import { CopyChip } from '../CopyValue';
+import {
+ GuideCode,
+ GuideIssue,
+ GuideLinks,
+ GuideNote,
+ GuideSection,
+ GuideSteps,
+ Literal,
+ UiName,
+} from './GuideDialog';
+
+const ROLE_DEFINITIONS = [
+ {
+ value: 'ControlCenterAdmin',
+ description: 'Full administrative access to Control Center features',
+ grants: 'The whole Control Center: dashboard, users, groups, public workspaces and activity logs.',
+ },
+ {
+ value: 'ControlCenterDashboardReader',
+ description: 'Read-only access to Control Center dashboard and metrics',
+ grants: 'The dashboard only: statistics, activity trends and usage metrics, with no management.',
+ },
+] as const;
+
+const INSTALL_GRAPH = 'Install-Module Microsoft.Graph -Scope CurrentUser';
+
+const CONNECT_GRAPH = 'Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All", "User.Read.All"';
+
+const FIND_ROLES = `# Get your app's service principal
+$sp = Get-MgServicePrincipal -Filter "displayName eq 'YourAppName'"
+
+# List its app roles and their ids
+$sp.AppRoles | Select-Object DisplayName, Id, Value`;
+
+const ASSIGN_ROLE = `# Get the user
+$user = Get-MgUser -UserId "user@domain.com"
+
+# The id of the role to assign, from the previous step
+$appRoleId = "role-guid-from-previous-step"
+
+# Create the assignment
+$params = @{
+ principalId = $user.Id
+ resourceId = $sp.Id
+ appRoleId = $appRoleId
+}
+
+New-MgUserAppRoleAssignment -UserId $user.Id -BodyParameter $params`;
+
+export function ControlCenterRolesGuide() {
+ return (
+ <>
+
+
+ App roles let you hand out Control Center access without making someone a full
+ SimpleChat administrator. Each is defined once on the app registration and then
+ assigned to people or groups.
+
+
+ {ROLE_DEFINITIONS.map((role) => (
+
+
+
+
+
{role.grants}
+
+ ))}
+
+
+ While Require ControlCenterAdmin App Role is on, general Admins
+ without that role lose Control Center access. Admin Settings stays open to them, so
+ the requirement can always be switched back off.
+
+
+
+
+
+
+ In the Azure portal, open Microsoft Entra ID, then{' '}
+ App registrations, and select SimpleChat’s registration.
+
+
+ Select App roles, then Create app role.
+
+
+ Use ControlCenterAdmin as both the display name and the value,
+ allow Users/Groups, add the description below, leave the role
+ enabled, and select Apply.
+
+
+ Repeat for ControlCenterDashboardReader.
+
+
+
+
+
Values for each app role
+
+
+
+ Display name and value
+
+
+ Description
+
+
+
+
+ {ROLE_DEFINITIONS.map((role) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ The value must match exactly. It is case-sensitive.
+
+
+
+
+
+ In Microsoft Entra ID, open Enterprise applications,
+ set the application type filter to All applications, and select the
+ application with the same name as the registration.
+
+
+ Select Users and groups, then Add user/group.
+
+
Choose the users or groups, choose the role, and select Assign.
+
+
+ A new assignment reaches someone at their next sign-in, so ask them to sign out and
+ back in.
+
+
+
+
+
Useful for assigning many people at once, or from automation.
+
+
+
Install the module.
+
+
+
+
Connect with permission to manage role assignments.
+
+
+
+
Find the application and its role ids.
+
+
+
+
Assign a role to a user.
+
+
+
+
+ Replace YourAppName with the application’s display name and{' '}
+ user@domain.com with the person’s sign-in name.
+
+
+
+
+
+
Make sure everyone you assigned has signed in again.
+
+ Turn on Require ControlCenterAdmin App Role,{' '}
+ Allow ControlCenterDashboardReader App Role, or both, and save.
+
+
+ Open the Control Center as each kind of user. ControlCenterAdmin holders see every
+ tab; dashboard readers see the dashboard only.
+
+
+
+
+
+
+
They have not signed in again since the role was assigned.
+
The role value in Entra does not match exactly, including case.
+
The matching switch here is not on, or has not been saved.
+
+ While the ControlCenterAdmin requirement is on, that role is enough by itself. The
+ general Admin role is neither needed nor enough.
+
+
+
+
+ They were created on the enterprise application instead of the app registration.
+
+
A role was left disabled.
+
New roles can take a few minutes to appear; refresh the enterprise application.
+
+
+
+ The role covers the dashboard only. Users, groups, public workspaces and activity
+ logs are refused by design.
+
+
Assign ControlCenterAdmin to anyone who needs the management features.
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/admin/guides/GuideDialog.tsx b/application/v2_ui/src/components/admin/guides/GuideDialog.tsx
new file mode 100644
index 000000000..7ac014e1c
--- /dev/null
+++ b/application/v2_ui/src/components/admin/guides/GuideDialog.tsx
@@ -0,0 +1,161 @@
+// GuideDialog.tsx
+// The frame and building blocks every in-app setup guide is written with.
+//
+// Some settings only work alongside steps taken outside SimpleChat -- app roles created in
+// Entra, a health check path set on the App Service -- and the server-rendered page carried
+// those walkthroughs in modals beside the section. The V2 guides keep them one click from the
+// setting they serve, written with a small, consistent vocabulary: sections with real
+// headings, numbered steps, code that copies itself, and notes whose tone matches their
+// weight. The documentation site has the same material for reading away from the page.
+
+import type { ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { AlertCircle, ArrowUpRight, Info } from 'lucide-react';
+import { Modal } from '../../ui/Modal';
+import { GlassButton } from '../../ui/primitives';
+import { CopyButton } from '../CopyValue';
+
+export function GuideDialog({
+ title,
+ description,
+ docsUrl,
+ onClose,
+ children,
+}: {
+ title: string;
+ description?: string;
+ docsUrl?: string;
+ onClose: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {docsUrl ? (
+
+ Read this in the documentation
+
+ (opens in a new tab)
+
+ ) : null}
+
+ Done
+
+ >
+ }
+ >
+ {/* Role names such as ControlCenterDashboardReader are single long words; the
+ admin cards let them break anywhere, and the guides, portalled outside the
+ cards, need the same rule to fit a phone at large text sizes. */}
+
+ {children}
+
+ );
+}
+
+export function GuideSteps({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function GuideList({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+/** A name on screen in the Azure portal or Entra, set apart so it can be found there. */
+export function UiName({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/** A literal value -- a path, a role value -- in the same monospace as the code blocks. */
+export function Literal({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function GuideCode({ code, label }: { code: string; label: string }) {
+ return (
+
+
+ {code}
+
+
+
+
+
+ );
+}
+
+export function GuideNote({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) {
+ const Icon = tone === 'warning' ? AlertCircle : Info;
+ return (
+
+
+
{children}
+
+ );
+}
+
+/** A symptom and its likely causes, for troubleshooting that reads as problem then fixes. */
+export function GuideIssue({ symptom, children }: { symptom: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/guides/HealthCheckGuide.tsx b/application/v2_ui/src/components/admin/guides/HealthCheckGuide.tsx
new file mode 100644
index 000000000..a7cab5661
--- /dev/null
+++ b/application/v2_ui/src/components/admin/guides/HealthCheckGuide.tsx
@@ -0,0 +1,157 @@
+// HealthCheckGuide.tsx
+// How to point Azure App Service, or another monitor, at SimpleChat's health endpoints.
+//
+// Ported from the server-rendered page's Configuration Guide, with its response examples
+// replaced. That guide documented a JSON body with per-dependency checks and an HTTP 503
+// for an unhealthy app; neither route returns anything of the kind. `/external/healthcheck`
+// answers with the server time as text, `/external/healthcheckz` with a two-field JSON
+// status, and either answers HTTP 400 while it is switched off. A monitor configured
+// against the documented shape would have been waiting for a response that never comes.
+
+import { asBoolean } from '../../../lib/adminFields';
+import { absoluteUrl } from '../../../lib/adminOperations';
+import { apiUrl } from '../../../lib/apiClient';
+import type { Json } from '../../../lib/types';
+import { CopyButton } from '../CopyValue';
+import {
+ GuideCode,
+ GuideIssue,
+ GuideLinks,
+ GuideNote,
+ GuideSection,
+ GuideSteps,
+ Literal,
+ UiName,
+} from './GuideDialog';
+
+const ENDPOINTS = [
+ {
+ path: '/external/healthcheck',
+ key: 'enable_external_healthcheck',
+ name: 'Authenticated check',
+ answer: 'HTTP 200 with the server time as text.',
+ example: '2026-10-06 17:45:33',
+ // `enabled_required` builds the message from the setting key, so each route names its own.
+ disabled: '{"error": "Enable External Healthcheck is disabled."}',
+ },
+ {
+ path: '/external/healthcheckz',
+ key: 'enable_no_auth_external_healthcheck',
+ name: 'Unauthenticated check',
+ answer: 'HTTP 200 with a small JSON status.',
+ example: '{\n "status": "ok",\n "time": "2026-10-06 17:45:33"\n}',
+ disabled: '{"error": "Enable No Auth External Healthcheck is disabled."}',
+ },
+] as const;
+
+export function HealthCheckGuide({ settings }: { settings: Json }) {
+ const origin = window.location.origin;
+
+ return (
+ <>
+
+
+ Both confirm that the web app is answering requests and can read its own settings.
+ Neither exercises Azure AI Search, Azure OpenAI or storage, so a failing check points
+ at the app itself rather than at a downstream service.
+
+ {endpoint.name}
+
+ {enabled ? 'On in the saved settings' : 'Off in the saved settings'}
+
+
+
+
+ {url}
+
+
+
+
{endpoint.answer}
+
+
+ Switched off, it answers HTTP 400 with {endpoint.disabled}
+
+
+ );
+ })}
+
+
+
+
+
+
+ Turn on Enable /external/healthcheck here and save first, so the
+ path answers before App Service starts asking.
+
+
+ In the Azure portal, open the App Service and select Health check{' '}
+ under Monitoring.
+
+
+
+ Select Enable, enter this path, and select Save.
+
+
+
+
+
+ The bundled deployers already set this path. App Service’s own checks work with
+ App Service Authentication turned on.
+
+
+ If the path stays configured while the endpoint is switched off here, every check gets
+ HTTP 400 and App Service starts treating its instances as unhealthy.
+
+
+
+
+
+ Use /external/healthcheckz for an external monitor or load balancer
+ probe that has no way to authenticate. It answers anyone who can reach the app, so only
+ turn it on for trusted probes or controlled network paths.
+
+
+ If App Service Authentication requires sign-in for every request, exclude this path
+ there as well, or the probe is turned away before it reaches SimpleChat.
+
+
+
+
+
+
The endpoint is switched off in these settings. Turn it on and save.
+
+
+
+ App Service Authentication is answering before SimpleChat does. Sign the probe in,
+ or point it at /external/healthcheckz and exclude that path from
+ authentication.
+
+
+
+
+ Confirm the configured path is switched on here, then check the application log
+ stream: a failing check means the app is not answering requests at all.
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/admin/guides/SwaggerGuide.tsx b/application/v2_ui/src/components/admin/guides/SwaggerGuide.tsx
new file mode 100644
index 000000000..7422cfddd
--- /dev/null
+++ b/application/v2_ui/src/components/admin/guides/SwaggerGuide.tsx
@@ -0,0 +1,66 @@
+// SwaggerGuide.tsx
+// What the API documentation gives an administrator, and who else can see it.
+//
+// Ported from the server-rendered page's "Why Enable Swagger?" dialog, with one claim
+// corrected: it said the explorer requires an admin sign-in. The Swagger routes are guarded
+// by `login_required`, so any signed-in user can open them, and that is the fact an
+// administrator weighing whether to publish the API surface needs most.
+
+import { GuideList, GuideNote, GuideSection, Literal } from './GuideDialog';
+
+export function SwaggerGuide({ running }: { running: boolean }) {
+ return (
+ <>
+
+
+
+ /swagger: an interactive explorer for every route the app
+ registers, with search and filtering. Requests sent from it run with your own
+ session and permissions.
+
+
+ /swagger.json and /swagger.yaml: the
+ OpenAPI 3 specification the explorer is built from, for client generators and
+ API tools.
+
+
+
+ The specification is generated from the application’s own routes, so it stays
+ current as features are added, and shows which routes require a signed-in session.
+
+
+
+
+
+
Developers integrating with SimpleChat can find routes and request shapes themselves.
+
Requests can be tried from the browser while debugging, without separate tooling.
+
Administrators can review the API surface and its authentication requirements.
+
+
+
+
+
+ Any signed-in user can open these pages, not only administrators. There is no
+ admin-only mode: turn it off if you would rather not publish the API surface to
+ everyone who can sign in.
+
+
+
The specification is cached on the server.
+
+ /swagger.json and /swagger.yaml answer at most
+ 30 requests a minute from each client address.
+
+
+
+
+
+
+ The routes are registered when the App Service starts, so a saved change takes effect
+ after a restart. {running
+ ? 'This app is serving them now.'
+ : 'This app is not serving them now.'}
+
+
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/admin/guides/sectionGuides.tsx b/application/v2_ui/src/components/admin/guides/sectionGuides.tsx
new file mode 100644
index 000000000..b2c7007dc
--- /dev/null
+++ b/application/v2_ui/src/components/admin/guides/sectionGuides.tsx
@@ -0,0 +1,76 @@
+// sectionGuides.tsx
+// The in-app guides a section header can offer, by the id the server names them with.
+//
+// `ADMIN_SECTION_GUIDES` in admin_settings_fields.py decides which section offers which
+// guide and what its button says; this decides what the guide contains. A section whose
+// guide id has no entry here simply offers no button, rather than one that opens nothing.
+
+import type { ReactNode } from 'react';
+import type { AdminSectionGuide } from '../../../lib/adminFields';
+import type { Json } from '../../../lib/types';
+import { ControlCenterRolesGuide } from './ControlCenterRolesGuide';
+import { GuideDialog } from './GuideDialog';
+import { HealthCheckGuide } from './HealthCheckGuide';
+import { SwaggerGuide } from './SwaggerGuide';
+
+/** What a guide may read about the deployment it is describing. */
+export interface SectionGuideContext {
+ /** Saved settings, not the draft: a guide describes what is live. */
+ settings: Json;
+ runtimeFlags: Record;
+}
+
+interface SectionGuideDefinition {
+ title: string;
+ description: string;
+ render: (context: SectionGuideContext) => ReactNode;
+}
+
+const SECTION_GUIDES: Record = {
+ 'control-center-roles': {
+ title: 'Control Center role setup',
+ description: 'Create the app roles in Entra, assign them, then switch the requirements on.',
+ render: () => ,
+ },
+ 'health-check': {
+ title: 'Health check configuration',
+ description: "Point App Service Health check, or another monitor, at SimpleChat's endpoints.",
+ render: ({ settings }) => ,
+ },
+ 'swagger': {
+ title: 'Why enable Swagger?',
+ description: 'What the API documentation offers, and who can see it.',
+ render: ({ runtimeFlags }) => (
+
+ ),
+ },
+};
+
+export function hasSectionGuide(id: string): boolean {
+ return Object.prototype.hasOwnProperty.call(SECTION_GUIDES, id);
+}
+
+export function SectionGuide({
+ guide,
+ context,
+ onClose,
+}: {
+ guide: AdminSectionGuide;
+ context: SectionGuideContext;
+ onClose: () => void;
+}) {
+ if (!hasSectionGuide(guide.id)) {
+ return null;
+ }
+ const definition = SECTION_GUIDES[guide.id];
+ return (
+
+ {definition.render(context)}
+
+ );
+}
diff --git a/application/v2_ui/src/components/admin/useNow.ts b/application/v2_ui/src/components/admin/useNow.ts
new file mode 100644
index 000000000..32428ec9b
--- /dev/null
+++ b/application/v2_ui/src/components/admin/useNow.ts
@@ -0,0 +1,19 @@
+// useNow.ts
+// The current time, refreshed on an interval, for readouts that say "in 3 hours".
+//
+// A relative time rendered once goes stale while the page stays open, which on a settings
+// page left in a tab is most of the time. Ticking every half minute keeps "in 3 hours"
+// honest without re-rendering every second.
+
+import { useEffect, useState } from 'react';
+
+export function useNow(intervalMs = 30_000): Date {
+ const [now, setNow] = useState(() => new Date());
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(new Date()), intervalMs);
+ return () => window.clearInterval(timer);
+ }, [intervalMs]);
+
+ return now;
+}
diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts
index 308b9bc5b..f0d7f3783 100644
--- a/application/v2_ui/src/lib/adminFields.ts
+++ b/application/v2_ui/src/lib/adminFields.ts
@@ -160,7 +160,7 @@ export interface AdminField {
fallback_when_empty?: boolean;
item_fields?: AdminField[];
/** Text fields only: the input type the browser should use. */
- input_type?: 'text' | 'email' | 'url';
+ input_type?: 'text' | 'email' | 'url' | 'time' | 'timezone';
/** String list fields only: per-item character cap. */
max_item_length?: number;
/** Optional sub-heading grouping consecutive fields inside one section. */
@@ -262,6 +262,70 @@ export interface AdminField {
*/
scale?: number;
requires_acknowledgement?: AdminFieldAcknowledgement;
+ /**
+ * `restart-status` components only: the setting whose saved value is compared
+ * with the running process, the runtime flag reporting that process, and an
+ * optional flag that must hold for the setting to be able to take effect at all.
+ */
+ watches?: string;
+ runtime_flag?: string;
+ runtime_requires?: string;
+ /** `logging-timer-status` components only: where one log's timer is stored. */
+ timer_keys?: AdminLoggingTimerKeys;
+ /** `endpoint-links` components only: the addresses to list. */
+ endpoints?: AdminEndpointLink[];
+ /** Another section this setting acts on, offered as a link beneath it. */
+ related_section?: AdminRelatedSection;
+}
+
+/** Mirrors one entry of `LOGGING_TIMERS` in `functions_logging_timers.py`. */
+export interface AdminLoggingTimerKeys {
+ enabled_key: string;
+ timer_key: string;
+ value_key: string;
+ unit_key: string;
+ turnoff_key: string;
+}
+
+/** Who an endpoint answers. */
+export type AdminEndpointAccess = 'protected' | 'public' | 'signed_in';
+
+/**
+ * One address an `endpoint-links` component lists.
+ *
+ * An endpoint is live when its `gate_key` setting is saved on, or when the
+ * `runtime_flag` the server reports is true -- Swagger, for instance, is registered at
+ * startup and is not live merely because the setting says so.
+ */
+export interface AdminEndpointLink {
+ path: string;
+ label: string;
+ gate_key?: string;
+ runtime_flag?: string;
+ access: AdminEndpointAccess;
+ /** What the endpoint answers with, in a sentence. */
+ returns?: string;
+}
+
+/** A section a field relates to, which may live on the classic page only. */
+export interface AdminRelatedSection {
+ section_id: string;
+ label: string;
+ /**
+ * The related content is only drawn by the server-rendered page, so the link goes there
+ * even when V2 shows a card for the same section.
+ */
+ classic_only?: boolean;
+}
+
+/** Mirrors one `ADMIN_SECTION_GUIDES` entry. */
+export interface AdminSectionGuide {
+ /** Which guide component to open. */
+ id: string;
+ /** The header button's label. */
+ label: string;
+ /** The matching page on the documentation site. */
+ docs_url?: string;
}
/** Section id -> ordered fields. Section ids come from `admin_settings_nav.py`. */
@@ -305,13 +369,19 @@ export interface AdminSettingsResponse {
admin_nav: import('./types').AdminNavGroup[];
field_schema: AdminFieldSchema;
section_status: AdminSectionStatusSchema;
+ /** In-app guides a section header offers, keyed by section id. */
+ section_guides?: Record;
app_role_requirements: AppRoleRequirement[];
branding_assets: BrandingAssets;
/**
- * Server-resolved flags a navigation section may be conditional on.
+ * Server-resolved flags a navigation section may be conditional on, and which
+ * describe how the running process was started.
*
* `mcp_ui_enabled` comes from an App Service application setting rather than
- * the settings document, so it cannot be read from `settings`.
+ * the settings document, so it cannot be read from `settings`. The Operations
+ * flags -- `appinsights_connection_configured`, `appinsights_global_logging_active`
+ * and `swagger_routes_registered` -- report what the process is doing, which
+ * differs from the saved settings until the App Service restarts.
*/
runtime_flags?: Record;
/**
@@ -821,6 +891,8 @@ export function fieldSearchText(field: AdminField): string {
field.help ?? '',
field.notice ?? '',
field.component ?? '',
+ // An endpoint list is found by the addresses it lists, not only by its label.
+ ...(field.endpoints ?? []).map((endpoint) => `${endpoint.path} ${endpoint.label}`),
]
.join(' ')
.toLowerCase();
diff --git a/application/v2_ui/src/lib/adminOperations.ts b/application/v2_ui/src/lib/adminOperations.ts
new file mode 100644
index 000000000..8fd06db31
--- /dev/null
+++ b/application/v2_ui/src/lib/adminOperations.ts
@@ -0,0 +1,630 @@
+// adminOperations.ts
+// The decisions behind the Operations sections of Admin Settings.
+//
+// Kept apart from the components because these are the parts worth executing in a test
+// rather than reviewing by eye: who the Control Center admits under each combination of
+// role switches, whether a save will restart a logging timer, and when a daily schedule
+// in some other timezone next fires. All three are easy to get subtly wrong, and all
+// three are invisible in a screenshot.
+//
+// Where the server decides something, the rule here mirrors it rather than inventing
+// its own -- the server's answer is the one that is stored, so a readout that disagreed
+// with it would be describing a schedule that does not exist.
+
+import { asBoolean, asString, type AdminLoggingTimerKeys } from './adminFields';
+import type { AdminNavGroup, Json } from './types';
+
+const MINUTE_MS = 60_000;
+const HOUR_MS = 60 * MINUTE_MS;
+const DAY_MS = 24 * HOUR_MS;
+
+function has(record: Json, key: string): boolean {
+ return Object.prototype.hasOwnProperty.call(record, key);
+}
+
+/** A value as it will be after a save: the unsaved edit if there is one, else the stored one. */
+function merged(settings: Json, draft: Json, key: string): unknown {
+ return has(draft, key) ? draft[key] : settings[key];
+}
+
+// ---------------------------------------------------------------------------------
+// Stored instants
+// ---------------------------------------------------------------------------------
+
+/**
+ * A stored timestamp, read the way the server reads it.
+ *
+ * Values written in UTC carry an offset and are an exact instant. Older logging turnoff
+ * times carry none: the server wrote them with its own local clock, which the browser has
+ * no way of knowing, so they are reported as `legacy` and shown as written rather than
+ * converted on a guess.
+ */
+export type StoredInstant =
+ | { kind: 'none' }
+ | { kind: 'instant'; at: Date }
+ | { kind: 'legacy'; text: string };
+
+const OFFSET_SUFFIX = /(?:z|[+-]\d{2}:?\d{2})$/i;
+
+export function readStoredInstant(value: unknown): StoredInstant {
+ const text = asString(value).trim();
+ if (!text) {
+ return { kind: 'none' };
+ }
+ if (!OFFSET_SUFFIX.test(text)) {
+ return { kind: 'legacy', text };
+ }
+ // Python writes microseconds; Date only promises to read milliseconds.
+ const at = new Date(text.replace(/(\.\d{3})\d+/, '$1'));
+ return Number.isNaN(at.getTime()) ? { kind: 'none' } : { kind: 'instant', at };
+}
+
+// ---------------------------------------------------------------------------------
+// Logging timers
+// ---------------------------------------------------------------------------------
+
+/** Mirrors `LOGGING_TIMER_UNIT_LIMITS` in functions_logging_timers.py. */
+export const LOGGING_TIMER_UNIT_LIMITS = {
+ minutes: [1, 120],
+ hours: [1, 24],
+ days: [1, 7],
+ weeks: [1, 52],
+} as const;
+
+export type LoggingTimerUnit = keyof typeof LOGGING_TIMER_UNIT_LIMITS;
+
+const UNIT_MS: Record = {
+ minutes: MINUTE_MS,
+ hours: HOUR_MS,
+ days: DAY_MS,
+ weeks: 7 * DAY_MS,
+};
+
+/** Mirrors `normalize_logging_timer_unit`: anything unsupported reads as hours. */
+export function normalizeTimerUnit(unit: unknown): LoggingTimerUnit {
+ const candidate = asString(unit).trim().toLowerCase();
+ return Object.prototype.hasOwnProperty.call(LOGGING_TIMER_UNIT_LIMITS, candidate)
+ ? (candidate as LoggingTimerUnit)
+ : 'hours';
+}
+
+/** Mirrors `clamp_logging_timer_value`. */
+export function clampTimerValue(
+ value: unknown,
+ unit: unknown,
+): { value: number; unit: LoggingTimerUnit } {
+ const normalizedUnit = normalizeTimerUnit(unit);
+ const [minimum, maximum] = LOGGING_TIMER_UNIT_LIMITS[normalizedUnit];
+ const parsed = typeof value === 'boolean' ? Number.NaN : Number.parseFloat(asString(value));
+ const whole = Number.isFinite(parsed) ? Math.trunc(parsed) : 1;
+ return { value: Math.min(Math.max(whole, minimum), maximum), unit: normalizedUnit };
+}
+
+/** How long a timer runs, in milliseconds. */
+export function timerDurationMs(value: unknown, unit: unknown): number {
+ const timer = clampTimerValue(value, unit);
+ return timer.value * UNIT_MS[timer.unit];
+}
+
+/**
+ * What a log's timer will do.
+ *
+ * `on-save` carries why a save would set a new turnoff time, because the honest wording
+ * differs: an edit is about to restart the clock, while a timer with nothing stored has
+ * never started and needs a save of its own.
+ */
+export type TimerReadout =
+ | { kind: 'off' }
+ | { kind: 'no-timer' }
+ | { kind: 'on-save'; reason: 'changed' | 'enabling' | 'missing'; projected: Date }
+ | { kind: 'scheduled'; at: Date }
+ | { kind: 'overdue'; at: Date }
+ | { kind: 'legacy'; text: string };
+
+/**
+ * Read a log's timer, preferring unsaved edits.
+ *
+ * Mirrors `resolve_logging_timer_settings`: a save recalculates the turnoff time only when
+ * the timer switch, duration or unit changes, when the log is being switched on, or when no
+ * usable time is stored. Any other save keeps the stored time, so the readout must not
+ * claim a save would restart the clock when it would not.
+ */
+export function describeLoggingTimer(
+ keys: AdminLoggingTimerKeys,
+ settings: Json,
+ draft: Json,
+ now: Date,
+): TimerReadout {
+ if (!asBoolean(merged(settings, draft, keys.enabled_key))) {
+ return { kind: 'off' };
+ }
+ const timerOn = asBoolean(merged(settings, draft, keys.timer_key));
+ if (!timerOn) {
+ return { kind: 'no-timer' };
+ }
+
+ const value = merged(settings, draft, keys.value_key) ?? 1;
+ const unit = merged(settings, draft, keys.unit_key) ?? 'hours';
+ const timer = clampTimerValue(value, unit);
+ const projected = new Date(now.getTime() + timerDurationMs(timer.value, timer.unit));
+
+ const changed =
+ timerOn !== asBoolean(settings[keys.timer_key]) ||
+ timer.value !== Number(settings[keys.value_key] ?? 1) ||
+ timer.unit !== asString(settings[keys.unit_key] ?? 'hours');
+ if (changed) {
+ return { kind: 'on-save', reason: 'changed', projected };
+ }
+ if (!asBoolean(settings[keys.enabled_key])) {
+ return { kind: 'on-save', reason: 'enabling', projected };
+ }
+
+ const stored = readStoredInstant(settings[keys.turnoff_key]);
+ if (stored.kind === 'none') {
+ return { kind: 'on-save', reason: 'missing', projected };
+ }
+ if (stored.kind === 'legacy') {
+ return { kind: 'legacy', text: stored.text };
+ }
+ return stored.at.getTime() <= now.getTime()
+ ? { kind: 'overdue', at: stored.at }
+ : { kind: 'scheduled', at: stored.at };
+}
+
+// ---------------------------------------------------------------------------------
+// Timezones
+// ---------------------------------------------------------------------------------
+
+interface ZonedParts {
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+ minute: number;
+ second: number;
+}
+
+const zonedFormatters = new Map();
+
+function zonedFormatter(zone: string): Intl.DateTimeFormat {
+ let formatter = zonedFormatters.get(zone);
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone: zone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+ zonedFormatters.set(zone, formatter);
+ }
+ return formatter;
+}
+
+/** The wall-clock reading of an instant in a zone. */
+function partsInZone(instant: Date, zone: string): ZonedParts {
+ const values: Record = {};
+ for (const part of zonedFormatter(zone).formatToParts(instant)) {
+ if (part.type !== 'literal') {
+ values[part.type] = Number(part.value);
+ }
+ }
+ return {
+ year: values.year,
+ month: values.month,
+ day: values.day,
+ // Some engines still write midnight as 24 even when asked for a 0-23 clock.
+ hour: values.hour % 24,
+ minute: values.minute,
+ second: values.second,
+ };
+}
+
+/** Whether this browser recognises an IANA zone name. */
+export function isKnownTimeZone(zone: string): boolean {
+ if (!zone.trim()) {
+ return false;
+ }
+ try {
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** A zone's offset from UTC at an instant, in minutes east of Greenwich. */
+export function zoneOffsetMinutes(zone: string, instant: Date): number {
+ const parts = partsInZone(instant, zone);
+ const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
+ const wholeSeconds = Math.floor(instant.getTime() / 1000) * 1000;
+ return Math.round((asUtc - wholeSeconds) / MINUTE_MS);
+}
+
+/** `UTC-04:00`, for showing beside a zone name. */
+export function formatUtcOffset(zone: string, instant: Date): string {
+ const offset = zoneOffsetMinutes(zone, instant);
+ const sign = offset < 0 ? '-' : '+';
+ const absolute = Math.abs(offset);
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
+ const minutes = String(absolute % 60).padStart(2, '0');
+ return `UTC${sign}${hours}:${minutes}`;
+}
+
+/**
+ * The instant a wall-clock time on a calendar date happens in a zone.
+ *
+ * Resolved the way Python's zoneinfo resolves it with fold=0, which is what stores the
+ * run: a time that happens twice, when the clocks go back, is its first occurrence, and
+ * one that never happens, when they go forward, keeps the offset in force before the
+ * change.
+ */
+export function wallTimeToInstant(
+ zone: string,
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+): Date {
+ const guess = Date.UTC(year, month - 1, day, hour, minute);
+ const offsetBefore = zoneOffsetMinutes(zone, new Date(guess - 12 * HOUR_MS));
+ const offsetAfter = zoneOffsetMinutes(zone, new Date(guess + 12 * HOUR_MS));
+ const candidates = [...new Set([offsetBefore, offsetAfter])].map(
+ (offset) => guess - offset * MINUTE_MS,
+ );
+ const matching = candidates.filter((candidate) => {
+ const parts = partsInZone(new Date(candidate), zone);
+ return (
+ parts.year === year &&
+ parts.month === month &&
+ parts.day === day &&
+ parts.hour === hour &&
+ parts.minute === minute
+ );
+ });
+ return new Date(matching.length ? Math.min(...matching) : guess - offsetBefore * MINUTE_MS);
+}
+
+const SCHEDULE_TIME = /^([01]\d|2[0-3]):([0-5]\d)(?::[0-5]\d)?$/;
+
+/** Read an `HH:MM` refresh time, or null when it is not one. */
+export function parseScheduleTime(value: unknown): { hour: number; minute: number } | null {
+ const match = SCHEDULE_TIME.exec(asString(value).trim());
+ return match ? { hour: Number(match[1]), minute: Number(match[2]) } : null;
+}
+
+/**
+ * The next time a daily schedule fires after `now`.
+ *
+ * Mirrors `calculate_next_control_center_auto_refresh_run`: today's run if it is still
+ * ahead, otherwise tomorrow's at the same wall-clock time. Null when the time or zone
+ * cannot be read, in which case the server will refuse the save anyway.
+ */
+export function nextDailyRun(time: unknown, zone: string, now: Date): Date | null {
+ const parsed = parseScheduleTime(time);
+ if (!parsed || !isKnownTimeZone(zone)) {
+ return null;
+ }
+ const today = partsInZone(now, zone);
+ const todaysRun = wallTimeToInstant(zone, today.year, today.month, today.day, parsed.hour, parsed.minute);
+ if (todaysRun.getTime() > now.getTime()) {
+ return todaysRun;
+ }
+ const tomorrow = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
+ return wallTimeToInstant(
+ zone,
+ tomorrow.getUTCFullYear(),
+ tomorrow.getUTCMonth() + 1,
+ tomorrow.getUTCDate(),
+ parsed.hour,
+ parsed.minute,
+ );
+}
+
+/** The browser's own IANA zone, or an empty string when it will not say. */
+export function viewerTimeZone(): string {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * Every zone this browser can name, for suggestions.
+ *
+ * Some engines leave UTC out of the list while accepting it, and an administrator may well
+ * want it, so it is added.
+ */
+export function listTimeZones(): string[] {
+ const zones = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
+ return [...new Set(['UTC', ...zones])];
+}
+
+// ---------------------------------------------------------------------------------
+// The daily Control Center refresh
+// ---------------------------------------------------------------------------------
+
+/** Where the schedule lives. Mirrors the keys `resolve_control_center_auto_refresh_settings` writes. */
+export const CONTROL_CENTER_SCHEDULE_KEYS = {
+ enabled: 'control_center_auto_refresh_enabled',
+ time: 'control_center_auto_refresh_time',
+ timezone: 'control_center_auto_refresh_timezone',
+ nextRun: 'control_center_auto_refresh_next_run',
+ lastRefresh: 'control_center_last_refresh',
+} as const;
+
+/** Mirrors the defaults seeded in functions_settings.py. */
+export const CONTROL_CENTER_SCHEDULE_DEFAULTS = {
+ enabled: true,
+ time: '02:00',
+ timezone: 'America/New_York',
+} as const;
+
+export type ScheduleReadout =
+ | { kind: 'off' }
+ | { kind: 'on-save'; projected: Date | null; time: string; timezone: string }
+ | { kind: 'scheduled'; at: Date; time: string; timezone: string }
+ | { kind: 'overdue'; at: Date; time: string; timezone: string }
+ | { kind: 'unscheduled'; time: string; timezone: string };
+
+function scheduleValue(source: Json, key: string, fallback: string): string {
+ const value = asString(source[key]).trim();
+ return value || fallback;
+}
+
+/**
+ * Read the refresh schedule, preferring unsaved edits.
+ *
+ * Mirrors `resolve_control_center_auto_refresh_settings`: a save moves the next run only
+ * when the switch, time or timezone changes. A stored run is otherwise kept, so it is what
+ * the readout shows.
+ */
+export function describeRefreshSchedule(settings: Json, draft: Json, now: Date): ScheduleReadout {
+ const keys = CONTROL_CENTER_SCHEDULE_KEYS;
+ const defaults = CONTROL_CENTER_SCHEDULE_DEFAULTS;
+ const enabledValue = merged(settings, draft, keys.enabled);
+ const enabled = enabledValue === undefined ? defaults.enabled : asBoolean(enabledValue);
+ if (!enabled) {
+ return { kind: 'off' };
+ }
+
+ const effective = { ...settings, ...draft };
+ const time = scheduleValue(effective, keys.time, defaults.time);
+ const timezone = scheduleValue(effective, keys.timezone, defaults.timezone);
+
+ const savedEnabled = settings[keys.enabled] === undefined
+ ? defaults.enabled
+ : asBoolean(settings[keys.enabled]);
+ const savedTime = parseScheduleTime(scheduleValue(settings, keys.time, defaults.time));
+ const draftTime = parseScheduleTime(time);
+ const changed =
+ enabled !== savedEnabled ||
+ timezone !== scheduleValue(settings, keys.timezone, defaults.timezone) ||
+ draftTime?.hour !== savedTime?.hour ||
+ draftTime?.minute !== savedTime?.minute;
+ if (changed) {
+ return { kind: 'on-save', projected: nextDailyRun(time, timezone, now), time, timezone };
+ }
+
+ const stored = readStoredInstant(settings[keys.nextRun]);
+ if (stored.kind !== 'instant') {
+ return { kind: 'unscheduled', time, timezone };
+ }
+ return stored.at.getTime() <= now.getTime()
+ ? { kind: 'overdue', at: stored.at, time, timezone }
+ : { kind: 'scheduled', at: stored.at, time, timezone };
+}
+
+// ---------------------------------------------------------------------------------
+// Control Center access
+// ---------------------------------------------------------------------------------
+
+export const CONTROL_CENTER_ACCESS_KEYS = {
+ requireAdminRole: 'require_member_of_control_center_admin',
+ allowDashboardReader: 'require_member_of_control_center_dashboard_reader',
+} as const;
+
+export type ControlCenterAccessLevel = 'full' | 'dashboard' | 'none';
+
+export type ControlCenterRole = 'Admin' | 'ControlCenterAdmin' | 'ControlCenterDashboardReader';
+
+export interface ControlCenterAccessRow {
+ role: ControlCenterRole;
+ access: ControlCenterAccessLevel;
+}
+
+/**
+ * Who the Control Center admits, one role at a time.
+ *
+ * Mirrors `control_center_required` and the navigation templates. Each row is the role
+ * held on its own; an account holding two of them gets the better of the two rows.
+ */
+export function controlCenterAccess(
+ requireAdminRole: boolean,
+ allowDashboardReader: boolean,
+): ControlCenterAccessRow[] {
+ return [
+ { role: 'Admin', access: requireAdminRole ? 'none' : 'full' },
+ { role: 'ControlCenterAdmin', access: requireAdminRole ? 'full' : 'none' },
+ { role: 'ControlCenterDashboardReader', access: allowDashboardReader ? 'dashboard' : 'none' },
+ ];
+}
+
+// ---------------------------------------------------------------------------------
+// Restart-bound settings
+// ---------------------------------------------------------------------------------
+
+/**
+ * How a setting the app only reads at startup compares with the running process.
+ *
+ * `unavailable` is for a setting that cannot take effect whatever is saved, such as
+ * global logging with no Application Insights destination; another readout says why.
+ */
+export type RestartState =
+ | { kind: 'unavailable' }
+ | { kind: 'save-then-restart'; next: boolean }
+ | { kind: 'restart'; saved: boolean; running: boolean }
+ | { kind: 'in-sync'; running: boolean };
+
+export function describeRestartState({
+ saved,
+ draft,
+ running,
+ available,
+}: {
+ saved: boolean;
+ draft: boolean;
+ running: boolean;
+ available: boolean;
+}): RestartState {
+ if (!available) {
+ return { kind: 'unavailable' };
+ }
+ if (draft !== saved) {
+ return { kind: 'save-then-restart', next: draft };
+ }
+ if (saved !== running) {
+ return { kind: 'restart', saved, running };
+ }
+ return { kind: 'in-sync', running };
+}
+
+// ---------------------------------------------------------------------------------
+// Endpoints and links
+// ---------------------------------------------------------------------------------
+
+/** The full address of a path on this deployment, for pasting into another tool. */
+export function absoluteUrl(path: string, base: string): string {
+ try {
+ return new URL(path, base).href;
+ } catch {
+ return path;
+ }
+}
+
+/** Where a section lives on the classic page, whose address names a tab, not a section. */
+export function findNavLocation(
+ nav: AdminNavGroup[],
+ sectionId: string,
+): { groupLabel: string; tabId: string; tabLabel: string; sectionLabel: string } | null {
+ for (const group of nav) {
+ for (const tab of group.tabs) {
+ const section = tab.sections.find((candidate) => candidate.id === sectionId);
+ if (section) {
+ return {
+ groupLabel: group.label,
+ tabId: tab.id,
+ tabLabel: tab.label,
+ sectionLabel: section.label,
+ };
+ }
+ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------------
+// Deleting stored file processing logs
+// ---------------------------------------------------------------------------------
+
+/** The existing admin endpoint the server-rendered page uses for the same action. */
+export const FILE_PROCESSING_LOG_CLEANUP_ENDPOINT = '/api/admin/settings/file-processing-logs/cleanup';
+
+/** Mirrors `FILE_PROCESSING_LOG_AGE_UNITS` in functions_logging.py. */
+export const LOG_CLEANUP_UNITS = [
+ { value: 'days', label: 'Days' },
+ { value: 'weeks', label: 'Weeks' },
+ { value: 'months', label: 'Months (30 days)' },
+] as const;
+
+export type LogCleanupUnit = (typeof LOG_CLEANUP_UNITS)[number]['value'];
+
+export type LogCleanupRequest =
+ | { delete_all: true }
+ | { delete_all: false; age: number; unit: LogCleanupUnit };
+
+/**
+ * Turn the cleanup form into a request, or say what is wrong with it.
+ *
+ * The confirmation sentence is built here as well, because it has to describe exactly
+ * what the request will delete and nothing vaguer.
+ */
+export function buildLogCleanupRequest(
+ mode: 'older' | 'all',
+ ageText: string,
+ unit: string,
+): { request: LogCleanupRequest; confirmation: string } | { error: string } {
+ if (mode === 'all') {
+ return {
+ request: { delete_all: true },
+ confirmation: 'Delete every stored file processing log?',
+ };
+ }
+
+ const age = Number(ageText.trim());
+ if (!ageText.trim() || !Number.isInteger(age) || age < 1) {
+ return { error: 'Enter a whole number greater than zero.' };
+ }
+ const selected = LOG_CLEANUP_UNITS.find((option) => option.value === unit);
+ if (!selected) {
+ return { error: 'Choose days, weeks or months.' };
+ }
+ const noun = age === 1 ? selected.value.replace(/s$/, '') : selected.value;
+ return {
+ request: { delete_all: false, age, unit: selected.value },
+ confirmation: `Delete every file processing log older than ${age} ${noun}?`,
+ };
+}
+
+// ---------------------------------------------------------------------------------
+// Formatting
+// ---------------------------------------------------------------------------------
+
+/** A date and time, in a given zone or the viewer's own. */
+export function formatInstant(at: Date, zone?: string): string {
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ ...(zone ? { timeZone: zone } : {}),
+ }).format(at);
+}
+
+/**
+ * A stored "HH:MM" schedule time as the reader's locale writes it, so the readout says
+ * "2:00 AM" where the time picker beside it does. It is a time of day with no date or
+ * zone of its own, so it is formatted in UTC to keep any offset out of it.
+ */
+export function formatWallTime(time: unknown, locale?: string): string {
+ const parsed = parseScheduleTime(time);
+ if (!parsed) {
+ return asString(time);
+ }
+ return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(
+ new Date(Date.UTC(2000, 0, 1, parsed.hour, parsed.minute)),
+ );
+}
+
+/**
+ * "in 3 hours", "12 minutes ago".
+ *
+ * Each unit takes over a little before it is exact, the way people round, so a turnoff
+ * 59 minutes away reads as "in 1 hour" rather than "in 59 minutes", and one 23 hours
+ * away as "tomorrow".
+ */
+export function formatRelative(at: Date, now: Date, locale?: string): string {
+ const difference = at.getTime() - now.getTime();
+ const distance = Math.abs(difference);
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
+ if (distance >= 22 * HOUR_MS) {
+ return formatter.format(Math.round(difference / DAY_MS), 'day');
+ }
+ if (distance >= 45 * MINUTE_MS) {
+ return formatter.format(Math.round(difference / HOUR_MS), 'hour');
+ }
+ return formatter.format(Math.round(difference / MINUTE_MS), 'minute');
+}
diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx
index f5354af7b..6cc3ccbd0 100644
--- a/application/v2_ui/src/pages/AdminSettingsPage.tsx
+++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx
@@ -40,15 +40,19 @@ import { ChatDefaultModel } from '../components/admin/ChatDefaultModel';
import { CapabilityModelPicker } from '../components/admin/CapabilityModelPicker';
import { ChatModeNotice } from '../components/admin/ChatModeNotice';
import { ConnectionTest } from '../components/admin/ConnectionTest';
+import { ControlCenterAccessMatrix } from '../components/admin/ControlCenterAccessMatrix';
import { CustomPagesTable } from '../components/admin/CustomPagesTable';
+import { EndpointLinks } from '../components/admin/EndpointLinks';
import { EnhancedCitationsStorageTest } from '../components/admin/EnhancedCitationsStorageTest';
import { EntryListEditor } from '../components/admin/EntryListEditor';
import { ExternalLinksEditor } from '../components/admin/ExternalLinksEditor';
+import { FileProcessingLogCleanup } from '../components/admin/FileProcessingLogCleanup';
import { FrontDoorRedirectPreview } from '../components/admin/FrontDoorRedirectPreview';
import { GlobalIdentitiesList } from '../components/admin/GlobalIdentitiesList';
import { GroupAssignmentField } from '../components/admin/GroupAssignmentField';
import { InboundMcpNotice } from '../components/admin/InboundMcpNotice';
import { KeyVaultReminders } from '../components/admin/KeyVaultReminders';
+import { LoggingTimerStatus } from '../components/admin/LoggingTimerStatus';
import { ModelConnectionsManager } from '../components/admin/ModelConnectionsManager';
import { ModelCatalogManager } from '../components/admin/ModelCatalogManager';
import { ModelPicker } from '../components/admin/ModelPicker';
@@ -59,6 +63,10 @@ import { ModelSelectionPicker } from '../components/admin/ModelSelectionPicker';
import { OrchestrationCard } from '../components/admin/OrchestrationCard';
import { OrchestrationPlannerModelPicker } from '../components/admin/OrchestrationPlannerModelPicker';
import { PromotedAgentsEditor } from '../components/admin/PromotedAgentsEditor';
+import { RefreshScheduleStatus } from '../components/admin/RefreshScheduleStatus';
+import { RelatedSectionLink } from '../components/admin/RelatedSectionLink';
+import { RestartStatus } from '../components/admin/RestartStatus';
+import { SectionGuide, hasSectionGuide } from '../components/admin/guides/sectionGuides';
import { AgentDelegationManager } from '../components/agents/AgentDelegationManager';
import { GLOBAL_DELEGATION_SCOPE } from '../lib/agentDelegation';
import { SaveBar } from '../components/admin/SaveBar';
@@ -86,6 +94,7 @@ import {
isSectionVisible,
readFieldValue,
type AdminField,
+ type AdminSectionGuide,
type AdminSettingsPatchResponse,
type AdminSettingsResponse,
type AdminUpdateStatusResponse,
@@ -93,6 +102,7 @@ import {
type BrandingAssets,
type BrandingUploadResponse,
} from '../lib/adminFields';
+import { CONTROL_CENTER_ACCESS_KEYS, findNavLocation } from '../lib/adminOperations';
import { toast } from '../stores/toastStore';
import { computeSectionStatus, type SectionStatus } from '../lib/adminSections';
import { hasUnsavedDiscoveryEdits } from '../lib/modelSelection';
@@ -243,6 +253,7 @@ export function AdminSettingsPage() {
const [fieldWarnings, setFieldWarnings] = useState>({});
const [pendingAck, setPendingAck] = useState(null);
const [pendingScroll, setPendingScroll] = useState(null);
+ const [openGuide, setOpenGuide] = useState(null);
const searchRef = useRef(null);
/** The pane the cards scroll inside; the page index watches it to mark the current section. */
@@ -337,6 +348,20 @@ export function AdminSettingsPage() {
const runtimeFlags = useMemo(() => data?.runtime_flags ?? {}, [data]);
const sectionStatus = useMemo(() => data?.section_status ?? {}, [data]);
+ /**
+ * The in-app guide a section's header offers, if the UI has one by that id.
+ *
+ * A guide the server names but this build cannot draw is left off rather than shown as
+ * a button that opens nothing.
+ */
+ const guideFor = useCallback(
+ (sectionId: string): AdminSectionGuide | undefined => {
+ const guide = data?.section_guides?.[sectionId];
+ return guide && hasSectionGuide(guide.id) ? guide : undefined;
+ },
+ [data],
+ );
+
const declaredKeys = useMemo(() => {
const keys = new Set();
for (const fields of Object.values(schema)) {
@@ -455,7 +480,9 @@ export function AdminSettingsPage() {
}
const location =
- `${section.label} ${section.tabLabel} ${section.groupLabel}`.toLowerCase();
+ `${section.label} ${section.tabLabel} ${section.groupLabel} ${
+ guideFor(section.sectionId)?.label ?? ''
+ }`.toLowerCase();
if (location.includes(needle)) {
return section;
}
@@ -473,7 +500,7 @@ export function AdminSettingsPage() {
: null;
})
.filter((section): section is RenderedSection => section !== null);
- }, [sections, query, activeGroup]);
+ }, [sections, query, activeGroup, guideFor]);
const settingCount = declaredKeys.size + capabilityRows.length;
@@ -706,6 +733,35 @@ export function AdminSettingsPage() {
const readSibling = (key: string, fallback = '') =>
asString(readFieldValue(READ_ONLY_REF(key), settings, draft), fallback);
+ /**
+ * A switch's value, saved or with unsaved edits applied, falling back to its declared
+ * default. Swagger has no seeded default, so without the declared one a fresh
+ * deployment would read as off while the app treats it as on.
+ */
+ const readSwitch = (key: string, includeDraft: boolean) =>
+ asBoolean(
+ readFieldValue(fieldsByKey.get(key) ?? READ_ONLY_REF(key), settings, includeDraft ? draft : {}),
+ );
+
+ /** A link from a setting to the section its effect shows up in. */
+ const renderRelatedSection = (field: AdminField) => {
+ const related = field.related_section;
+ if (!related || !data) {
+ return null;
+ }
+ const shownHere =
+ !related.classic_only && sections.some((section) => section.sectionId === related.section_id);
+ return (
+
+ );
+ };
+
/**
* Move the page to a section, from a cross-reference elsewhere on it.
*
@@ -1079,6 +1135,81 @@ export function AdminSettingsPage() {
help={field.help}
/>
);
+ case 'control-center-refresh-schedule':
+ return (
+
+ );
+ case 'control-center-access-matrix': {
+ const accessKeys = Object.values(CONTROL_CENTER_ACCESS_KEYS);
+ return (
+ readSwitch(accessKey, true) !== readSwitch(accessKey, false),
+ )}
+ />
+ );
+ }
+ case 'restart-status':
+ // Compared against how the running process started, which no save changes.
+ return field.watches && field.runtime_flag ? (
+
+ ) : null;
+ case 'logging-timer-status':
+ return field.timer_keys ? (
+
+ ) : null;
+ case 'file-processing-log-cleanup':
+ return (
+
+ );
+ case 'endpoint-links':
+ return (
+ readSwitch(gateKey, false)}
+ isDraftOn={(gateKey) => readSwitch(gateKey, true)}
+ runtimeFlags={runtimeFlags}
+ />
+ );
default:
return null;
}
@@ -1131,7 +1262,12 @@ export function AdminSettingsPage() {
);
}
- return
{control}
;
+ return (
+
+ {control}
+ {renderRelatedSection(field)}
+
+ );
};
if (!isAdmin) {
@@ -1360,7 +1496,9 @@ export function AdminSettingsPage() {
)}
- {visibleSections.map((section) => (
+ {visibleSections.map((section) => {
+ const guide = guideFor(section.sectionId);
+ return (
setOpenGuide(guide) }
+ : undefined
+ }
// While a search is filtering, a match inside a
// collapsed group has to be shown or the card would
// appear empty.
@@ -1412,7 +1555,8 @@ export function AdminSettingsPage() {
) : null}
- ))}
+ );
+ })}
{!loading && activeGroupUsesFallback && (
@@ -1448,6 +1592,15 @@ export function AdminSettingsPage() {
+ {openGuide ? (
+ setOpenGuide(null)}
+ />
+ ) : null}
+
{pendingAck?.requires_acknowledgement ? (
Scale displays Redis DAI cache status and lightweight in-proces
DAI is now treated as an always-on production read path in the Admin Settings experience. The default Redis DAI cache TTL is **900 seconds**, relying on scope-version invalidation for immediate visibility after document, tag, share, repair, delete, and backfill changes. The Scale left navigation now links directly to **DAI Metrics**, **Cosmos Metrics**, and **Redis Metrics** so admins can jump to the relevant operational dashboard.
-Support-only controls and diagnostics are hidden by default behind the app setting `enable_dai_debug`, which defaults to `false` and is not exposed in the Admin Settings UI. When that setting is edited directly in Cosmos and set to `true`, the DAI card renders manual backfill/reset controls, DAI read/cache toggles, batch-size fields, shadow validation controls, and rolling shadow-decision diagnostics for troubleshooting. Default admins see only production DAI health, maintenance, cache, fallback, RU, latency, repair, and backfill metrics.
+Support-only controls and diagnostics are hidden by default behind the app setting `enable_dai_debug`, which defaults to `false`. The classic Admin Settings page has no control for it; since **0.261.260** the V2 Admin Settings page offers it as **Document Access Index diagnostics** under Operations > Logging & Health > Debug Logging. When it is set to `true`, the classic DAI card renders manual backfill/reset controls, DAI read/cache toggles, batch-size fields, shadow validation controls, and rolling shadow-decision diagnostics for troubleshooting. Default admins see only production DAI health, maintenance, cache, fallback, RU, latency, repair, and backfill metrics.
### 2026-07-06 Phase 3 Low-Churn Cache Hardening
diff --git a/docs/explanation/features/V2_ADMIN_OPERATIONS_SETTINGS.md b/docs/explanation/features/V2_ADMIN_OPERATIONS_SETTINGS.md
new file mode 100644
index 000000000..67aad023e
--- /dev/null
+++ b/docs/explanation/features/V2_ADMIN_OPERATIONS_SETTINGS.md
@@ -0,0 +1,160 @@
+# V2 Admin Operations Settings
+
+## Overview
+
+The V2 Admin Settings page renders from `admin_settings_fields.py`. A section with no entry
+there falls back to scanning the settings document for `enable_*` booleans, which can only
+produce switches named after their keys.
+
+Operations was almost entirely undescribed, so that fallback was most of its interface.
+Two of its seven sections had partial declarations; the other five rendered as guessed
+switches such as "Dai debug" or did not render at all. This work describes the whole group,
+moves the rules the classic page used for schedules and timers into shared modules so both
+pages and the background checks agree, and replaces the classic page's three setup modals
+with in-app guides.
+
+**Implemented in version:** 0.261.260
+
+**Dependencies:** `admin_settings_nav.py` for section ids, the existing
+`/api/admin/settings/file-processing-logs/cleanup` endpoint, `swagger_wrapper.py` route
+registration, `functions_appinsights.py` exporter state, and the shared V2 Admin Settings
+renderer (`AdminSettingsPage.tsx`, `SettingsSection.tsx`, `fields.tsx`).
+
+## What was missing
+
+| Section | V2 before | V2 after |
+| --- | --- | --- |
+| Automatic Data Refresh | Not rendered | Switch, time picker, timezone with **Use my timezone**, next and last refresh in both zones, link to the Control Center |
+| Control Center Access | Two switches | Two switches, an access table that follows unsaved edits, copyable role values, **Role setup guide** |
+| Application Insights | One switch | Switch, connection status, running state against the saved value |
+| Debug Logging | Switch plus a raw "Dai debug" switch | Switch, nested timer with per-unit limits, turnoff time in the reader's zone, Document Access Index diagnostics with a link to DAI Metrics |
+| File Process Logging | One switch | Switch, nested timer, turnoff time, stored log cleanup |
+| Health Check | Not rendered | Two switches, full copyable endpoint addresses with live state, **Configuration guide** |
+| API Documentation | Not rendered | Switch, running state, copyable documentation links, **Why enable Swagger?** guide |
+
+## Architecture
+
+### Shared rules
+
+Two pure modules hold the rules that the classic save, the V2 normalizer and the
+background tasks previously implemented separately or not at all. Neither imports
+`config`, so `admin_settings_fields.py` stays importable in isolated tests.
+
+| Module | Holds |
+| --- | --- |
+| `functions_logging_timers.py` | `LOGGING_TIMERS` key map, per-unit limits, clamping, `calculate_logging_turnoff_time`, `parse_logging_turnoff_time`, `is_logging_turnoff_due`, `resolve_logging_timer_settings` |
+| `functions_control_center_schedule.py` | Schedule helpers moved from `functions_control_center.py` (re-exported there), time and timezone validation, `resolve_control_center_auto_refresh_settings` |
+
+`resolve_logging_timer_settings` recalculates a turnoff time only when the timer, duration
+or unit changed, the log was newly enabled, or no usable time is stored, so saving an
+unrelated setting no longer restarts a timer. `resolve_control_center_auto_refresh_settings`
+does the same for the next scheduled refresh.
+
+### Turnoff times in UTC
+
+The classic page stored turnoff times as naive `datetime.now()` values in the server's
+clock. They are now stored as UTC ISO strings. `parse_logging_turnoff_time` reads a value
+without an offset as server-local time, which is how it was written, so a timer set before
+an upgrade still ends when it was meant to. `check_logging_timers_once` compares in UTC for
+both logs and logs `[LOGGING_TIMERS]` when it turns one off.
+
+### Schema additions
+
+| Addition | Purpose |
+| --- | --- |
+| `input_type: "time"` and `"timezone"` on `text` | HH:MM picker; IANA zone with browser suggestions and **Use my timezone** |
+| `watches`, `runtime_flag`, `runtime_requires` | Compare a saved switch with how the running process started |
+| `timer_keys` | Bind a turnoff readout to one entry of `LOGGING_TIMERS` |
+| `endpoints` | Addresses a section exposes, with access level and live-state gate |
+| `related_section` | Point a setting at the section its effect appears in; `classic_only` links the classic page |
+| `ADMIN_SECTION_GUIDES` | Which section header offers which in-app guide, and its documentation anchor |
+
+Five derived keys are added to `NON_PATCHABLE_KEYS`:
+`control_center_auto_refresh_next_run`, `control_center_auto_refresh_hour`,
+`control_center_auto_refresh_minute`, `debug_logging_turnoff_time` and
+`file_processing_logs_turnoff_time`. `enable_dai_debug` is declared in `V2_ONLY_FIELDS`,
+because the classic page has never had a control for it.
+
+### Normalizer
+
+`normalize_admin_settings_updates` now:
+
+- Refuses a refresh time that is not `HH:MM` and a timezone the server's `zoneinfo` does not
+ know, as field errors. The classic page still falls back to `02:00` and
+ `America/New_York`.
+- Clamps a timer duration to its unit's limit and returns a warning saying so.
+- Calls `_apply_operations_derivations` to calculate turnoff times and the next refresh with
+ the shared helpers, so a V2 save stores the same derived values a classic save does.
+
+### V2 settings payload
+
+`GET /api/v2/admin/settings` adds:
+
+| Key | Contents |
+| --- | --- |
+| `section_guides` | `get_admin_section_guides()` |
+| `runtime_flags.appinsights_connection_configured` | Whether `APPLICATIONINSIGHTS_CONNECTION_STRING` is set |
+| `runtime_flags.appinsights_global_logging_active` | Whether the running process started with global logging |
+| `runtime_flags.swagger_routes_registered` | Whether the Swagger blueprint is registered on the running app |
+| `status_readouts.appinsights_connection` | Missing, exporter-failed or connected, as `{ok, message}` |
+
+Only booleans and fixed messages are returned; the connection string never leaves the
+server.
+
+### Frontend components
+
+| File | Role |
+| --- | --- |
+| `lib/adminOperations.ts` | Pure readout logic, including a TypeScript mirror of the Python schedule and timer rules |
+| `components/admin/RefreshScheduleStatus.tsx` | Next and last refresh, in the schedule's zone and the reader's |
+| `components/admin/ControlCenterAccessMatrix.tsx` | Access table read from the two switches, unsaved edits included |
+| `components/admin/LoggingTimerStatus.tsx` | Turnoff time, or what a save would set |
+| `components/admin/RestartStatus.tsx` | Running state against saved and draft values |
+| `components/admin/EndpointLinks.tsx` | Full addresses with copy, access level, live state and **Open** |
+| `components/admin/FileProcessingLogCleanup.tsx` | Age or delete-all cleanup with confirmation and partial-failure reporting |
+| `components/admin/RelatedSectionLink.tsx` | Cross-reference to another section |
+| `components/admin/CopyValue.tsx`, `ReadoutRow.tsx`, `useNow.ts` | Shared copy affordance, readout frame and clock |
+| `components/admin/guides/*` | Guide frame and building blocks, the three guides, and the guide registry |
+
+### Corrections carried into V2
+
+The classic modals were ported, not copied. Each of these statements was checked against the
+code and corrected in the V2 guides and documentation:
+
+| Classic statement | Behavior |
+| --- | --- |
+| Health checks return JSON with per-dependency results and HTTP 503 when unhealthy | `/external/healthcheck` returns the server time as text; `/external/healthcheckz` returns `{"status", "time"}`; either returns HTTP 400 naming its setting while switched off |
+| Swagger requires an admin sign-in | Any signed-in user can open it (`login_required`) |
+| A ControlCenterAdmin holder also needs the Admin role | While the requirement is on, ControlCenterAdmin is enough by itself |
+
+The app role registry (`admin_app_roles.py`) also described ControlCenterDashboardReader as
+depending on the ControlCenterAdmin requirement. It does not, and the entry now says so.
+
+## Usage
+
+Open **Admin Settings** in V2 and choose **Operations**. Administrator documentation is in
+`docs/admin/operations.md`, which the guide dialogs link to by section anchor.
+
+## Testing and validation
+
+| Test | Covers |
+| --- | --- |
+| `functional_tests/test_v2_admin_operations_parity.py` | Every classic control is declared, guides and anchors exist, derived keys are read-only |
+| `functional_tests/test_v2_admin_operations_derivations.py` | Shared timer and schedule rules, normalizer validation and derivations, the payload additions, and the TypeScript logic checks |
+| `functional_tests/test_v2_admin_operations_logic.ts` | Zone arithmetic across daylight saving, readout states, access table, cleanup requests, relative time |
+| `functional_tests/test_app_settings_auxiliary_writers.py` | The background timer check with the shared helpers |
+| `functional_tests/test_control_center_auto_refresh_schedule.py` | Schedule helpers after the move |
+| `ui_tests/test_v2_admin_operations_settings.py` | The built page: sections, timer and schedule saves, access table, guides, endpoints, restart state, cleanup, and overflow at phone and desktop widths in light and dark |
+
+## Known limitations
+
+- The Document Access Index diagnostics are drawn only by the classic Scale > Cosmos card,
+ so the V2 switch links there.
+- Application Insights global logging and Swagger still need an App Service restart; V2
+ reports whether one is pending but cannot perform it.
+- A manual Control Center refresh updates the last-refreshed time only. The next scheduled
+ run moves when the schedule is saved or a scheduled refresh completes.
+- The timezone suggestions come from the browser. The server validates the saved name
+ against its own `zoneinfo` data.
+- The classic page's own modals are unchanged and still carry the statements corrected
+ above.
diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md
index d9a5e18e5..ae41520a6 100644
--- a/docs/reference/logging-tags.md
+++ b/docs/reference/logging-tags.md
@@ -150,6 +150,7 @@ Last inventoried: 2026-08-10
- `[LOGGED_PLUGIN_LOADER]`
- `[LOGGING_AGENT_REQUEST]`
- `[LOGGING_AGENT_RESPONSE]`
+- `[LOGGING_TIMERS]`
- `[MAGENTIC_AGENT_RESPONSE_CALLBACK]`
- `[MAGENTIC_STREAMING_AGENT_RESPONSE_CALLBACK]`
- `[MASK]`
diff --git a/functional_tests/test_admin_update_banner_version_comparison.py b/functional_tests/test_admin_update_banner_version_comparison.py
index 88ce5b93d..5fa51d9e2 100644
--- a/functional_tests/test_admin_update_banner_version_comparison.py
+++ b/functional_tests/test_admin_update_banner_version_comparison.py
@@ -1,9 +1,10 @@
# test_admin_update_banner_version_comparison.py
"""
Functional coverage for shared classic/V2 admin release status.
-Version: 0.261.133
+Version: 0.261.260
Implemented in: 0.261.126
Non-blocking V2 release check: 0.261.133
+Settings payload adds section guides and runtime flags: 0.261.260
Execute the production checker, parser and comparator with HTTP/storage boundaries
mocked. AST loading avoids initializing Azure clients; these are behavior tests,
@@ -212,9 +213,10 @@ def test_v2_settings_get_never_waits_for_release_check(self):
'VERSION': CURRENT, 'ADMIN_NAV': [], 'jsonify': lambda value: value,
'_redact_admin_settings_for_v2': lambda settings: {'secret': 'REDACTED'},
'get_admin_settings_fields': dict, 'get_admin_section_status': dict,
+ 'get_admin_section_guides': dict,
'get_app_role_requirements': list, '_build_branding_assets': lambda settings: {},
'_build_status_readouts': dict, '_build_endpoint_readouts': lambda settings: {},
- '_build_model_catalog': lambda settings: [], 'is_mcp_ui_enabled': lambda: False,
+ '_build_model_catalog': lambda settings: [], '_build_runtime_flags': dict,
'get_suppressed_capability_keys': list, 'logging': logging, 'log_event': self.log,
})
# An empty cache would force a GitHub request if the GET still ran the check.
diff --git a/functional_tests/test_app_settings_auxiliary_writers.py b/functional_tests/test_app_settings_auxiliary_writers.py
index 063b31169..653d076b8 100644
--- a/functional_tests/test_app_settings_auxiliary_writers.py
+++ b/functional_tests/test_app_settings_auxiliary_writers.py
@@ -1,8 +1,9 @@
# test_app_settings_auxiliary_writers.py
"""
Functional tests for auxiliary app-settings writers.
-Version: 0.261.213
+Version: 0.261.260
Implemented in: 0.261.025
+Logging timers checked through the shared UTC helpers: 0.261.260
Execute isolated production functions through AST extraction, without importing
application configuration or contacting Redis, Cosmos DB, or other cloud services.
@@ -27,6 +28,14 @@
sys.path.insert(0, str(APP_DIR))
from functions_action_manifest import resolve_action_type # noqa: E402
+from functions_logging_timers import LOGGING_TIMERS, is_logging_turnoff_due # noqa: E402
+
+# The logging timer rules are pure and shared by every writer, so the isolated
+# checker runs against the real ones rather than doubles.
+LOGGING_TIMER_HELPERS = {
+ "LOGGING_TIMERS": LOGGING_TIMERS,
+ "is_logging_turnoff_due": is_logging_turnoff_due,
+}
WRITER_FILES = (
"background_tasks.py",
@@ -167,7 +176,9 @@ def test_logging_expiration_uses_guarded_deltas(logging_type, available, conflic
expected_keys.extend([enabled_key, timer_key, expiry_key])
store = SettingsStore(settings, available=available, conflict=conflict)
original = deepcopy(store.snapshot)
- check = load_function("background_tasks.py", "check_logging_timers_once", store)
+ check = load_function(
+ "background_tasks.py", "check_logging_timers_once", store, **LOGGING_TIMER_HELPERS,
+ )
assert check() is (available and not conflict)
assert_delta(store, expected_keys, etag='"original"')
@@ -182,7 +193,9 @@ def test_logging_expiration_does_not_write_before_expiry():
"debug_logging_timer_enabled": True,
"debug_logging_turnoff_time": (datetime.now() + timedelta(days=1)).isoformat(),
})
- check = load_function("background_tasks.py", "check_logging_timers_once", store)
+ check = load_function(
+ "background_tasks.py", "check_logging_timers_once", store, **LOGGING_TIMER_HELPERS,
+ )
check()
assert store.calls == []
diff --git a/functional_tests/test_control_center_auto_refresh_schedule.py b/functional_tests/test_control_center_auto_refresh_schedule.py
index c4497c601..00b8cbaa4 100644
--- a/functional_tests/test_control_center_auto_refresh_schedule.py
+++ b/functional_tests/test_control_center_auto_refresh_schedule.py
@@ -2,22 +2,22 @@
#!/usr/bin/env python3
"""
Functional test for Control Center auto-refresh scheduling.
-Version: 0.250.102
+Version: 0.261.260
Implemented in: 0.241.026
-Updated in: 0.250.102
+Updated in: 0.261.260 (schedule helpers moved to functions_control_center_schedule.py)
This test validates the enabled 02:00 Eastern default, timezone normalization,
DST-aware UTC next-run timestamps, and scheduler/admin/status integration.
"""
import importlib.util
-import re
import sys
import types
from datetime import datetime, timezone
from pathlib import Path
from test_support.templates import read_admin_settings_template
+from test_support.versioning import assert_app_version_at_least
ROOT_DIR = Path(__file__).resolve().parents[1]
@@ -29,7 +29,6 @@
CONTROL_CENTER_ROUTE_FILE = APP_DIR / "route_backend_control_center.py"
ADMIN_TEMPLATE_FILE = APP_DIR / "templates" / "admin_settings.html"
CONTROL_CENTER_JS_FILE = APP_DIR / "static" / "js" / "control-center.js"
-CONFIG_FILE = APP_DIR / "config.py"
def load_control_center_module():
@@ -53,6 +52,12 @@ def load_control_center_module():
original_modules[module_name] = sys.modules.get(module_name)
sys.modules[module_name] = module
+ # The schedule rules live in functions_control_center_schedule, which
+ # functions_control_center re-exports, so the app directory has to be importable.
+ added_app_dir = str(APP_DIR) not in sys.path
+ if added_app_dir:
+ sys.path.insert(0, str(APP_DIR))
+
try:
spec = importlib.util.spec_from_file_location(
"control_center_schedule_under_test",
@@ -62,6 +67,8 @@ def load_control_center_module():
spec.loader.exec_module(module)
return module
finally:
+ if added_app_dir:
+ sys.path.remove(str(APP_DIR))
for module_name, original_module in original_modules.items():
if original_module is None:
sys.modules.pop(module_name, None)
@@ -220,12 +227,8 @@ def test_integration_wiring():
def test_version():
- """Validate the application version for this update."""
- config_source = CONFIG_FILE.read_text(encoding="utf-8")
- version_match = re.search(r'VERSION = "([^"]+)"', config_source)
- if not version_match:
- raise AssertionError("Could not find VERSION in config.py")
- assert version_match.group(1) == "0.250.102"
+ """Validate the application version is at least the schedule's latest update."""
+ assert_app_version_at_least("0.250.102")
def run_all_tests():
diff --git a/functional_tests/test_docs_app_surface_coverage.py b/functional_tests/test_docs_app_surface_coverage.py
index 343b0e707..7cbedef0d 100644
--- a/functional_tests/test_docs_app_surface_coverage.py
+++ b/functional_tests/test_docs_app_surface_coverage.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""
Functional test for documentation coverage of the application surface.
-Version: 0.260.020
+Version: 0.261.260
Implemented in: 0.250.230
+Document Access Index diagnostics documented rather than exempted: 0.261.260
This test ensures that every user-facing capability of SimpleChat has
documentation on the docs site. It is the mechanism that keeps documentation
@@ -57,7 +58,6 @@
"enable_analysis_deliverable_contract_telemetry": "Internal telemetry switch for deliverable contract diagnostics.",
"enable_tabular_parity_contract_telemetry": "Internal telemetry switch for tabular parity diagnostics.",
"enable_mixed_source_development_telemetry": "Internal development telemetry switch.",
- "enable_dai_debug": "Internal Document Intelligence debug switch.",
"enable_tabular_generation_balanced_batches": "Internal tabular batching tuning flag.",
"enable_tabular_completion_driven_checkpointing": "Internal tabular checkpointing tuning flag.",
"enable_tabular_independent_batch_retries": "Internal tabular retry tuning flag.",
diff --git a/functional_tests/test_v2_admin_capability_placement.py b/functional_tests/test_v2_admin_capability_placement.py
index 8c45da801..69a587428 100644
--- a/functional_tests/test_v2_admin_capability_placement.py
+++ b/functional_tests/test_v2_admin_capability_placement.py
@@ -2,8 +2,9 @@
#!/usr/bin/env python3
"""
Functional test pinning where the V2 admin surface files each capability toggle.
-Version: 0.261.122
+Version: 0.261.260
Implemented in: 0.261.047
+Operations described in full: 0.261.260
Settings that ``admin_settings_fields.py`` does not describe are still shown in the
V2 admin UI, by scanning the settings document for ``enable_*`` booleans and
@@ -27,9 +28,10 @@
Declaring a field is what takes a key out of that scan. This test holds three
invariants so the misfiling cannot come back:
- 1. The Appearance, Chat and Security groups are fully described by the schema, so
- they must receive *no* guessed rows at all. A new undeclared key that lands in
- any of them fails here, and the fix is to declare it in its real section.
+ 1. The Appearance, Chat, Security, Agents & Actions and Operations groups are
+ fully described by the schema, so they must receive *no* guessed rows at all. A
+ new undeclared key that lands in any of them fails here, and the fix is to
+ declare it in its real section.
2. The keys that were moved stay declared where they were moved to.
3. Keys that are not editable settings at all stay suppressed rather than
declared. ``enable_tabular_processing_plugin`` is the clearest case: it is
@@ -68,7 +70,13 @@
# Groups whose sections are described by the schema in full. A guessed row landing
# in one of these is a key that was filed by word stems into a group that has a
# real home for everything it owns, which means it is in the wrong place.
-FULLY_DESCRIBED_GROUP_IDS = (APPEARANCE_GROUP_ID, "chat", "security", "agents-actions")
+FULLY_DESCRIBED_GROUP_IDS = (
+ APPEARANCE_GROUP_ID,
+ "chat",
+ "security",
+ "agents-actions",
+ "operations",
+)
# Where each relocated toggle now lives, and the V1 pane it is mirrored from. The
# pane is checked too, because a schema field with no server-rendered counterpart
@@ -111,6 +119,9 @@
"enable_chat_completion_audio_cues": ("desktop-notifications-section", "audio-video"),
"enable_video_file_support": ("video-intelligence-section", "audio-video"),
"enable_enhanced_extraction": ("document-intelligence-section", "extraction"),
+ # Operations. The token "logging" filed Application Insights under Debug
+ # Logging, so the Application Insights section rendered empty and was skipped.
+ "enable_appinsights_global_logging": ("application-insights-section", "logging"),
}
# Keys the scan must skip entirely, because they are not settings an
@@ -128,6 +139,9 @@
RELOCATED_CAPABILITIES_WITHOUT_V1_FIELD = {
"enable_app_maintenance": "cosmos-maintenance-section",
"enable_startup_app_maintenance": "cosmos-maintenance-section",
+ # Drawn by the fallback scan as an unexplained "Dai debug" switch under Debug
+ # Logging. V1 only reads it, so it stays there, declared and explained.
+ "enable_dai_debug": "debug-logging-section",
}
# The rules the ported heuristic depends on. If the renderer stops doing any of
diff --git a/functional_tests/test_v2_admin_operations_derivations.py b/functional_tests/test_v2_admin_operations_derivations.py
new file mode 100644
index 000000000..2bc3fd0a8
--- /dev/null
+++ b/functional_tests/test_v2_admin_operations_derivations.py
@@ -0,0 +1,478 @@
+#!/usr/bin/env python3
+# test_v2_admin_operations_derivations.py
+"""
+Functional test for the values the Operations settings calculate on save.
+Version: 0.261.260
+Implemented in: 0.261.260
+
+Three Operations values are worked out by the server rather than typed: the next
+scheduled Control Center refresh and each log's automatic turnoff time. The
+server-rendered form calculated them on every save; the V2 settings PATCH did not,
+so a schedule or timer edited in V2 would have been stored and then ignored. Both
+surfaces, and the background checker that switches logs off, now share
+``functions_logging_timers`` and ``functions_control_center_schedule``.
+
+This test executes those rules through the V2 normalizer, pins that the classic
+form and the checker call the same helpers, checks that turnoff times are stored in
+UTC while ones written before that still expire when they were meant to, and runs
+the browser-side checks in ``test_v2_admin_operations_logic.ts``, which hold the
+readouts to the same answers.
+"""
+
+import ast
+import logging
+import subprocess
+import sys
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Dict
+from zoneinfo import ZoneInfo
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import APP_ROOT, import_app_module
+from test_support.versioning import assert_app_version_at_least
+
+if str(APP_ROOT) not in sys.path:
+ sys.path.insert(0, str(APP_ROOT))
+
+# Both modules are pure by design, so they are imported as shipped.
+from functions_control_center_schedule import ( # noqa: E402
+ resolve_control_center_auto_refresh_settings,
+)
+from functions_logging_timers import ( # noqa: E402
+ is_logging_turnoff_due,
+ parse_logging_turnoff_time,
+ resolve_logging_timer_settings,
+)
+
+
+REPO_ROOT = APP_ROOT.parents[1]
+V2_DIR = REPO_ROOT / "application" / "v2_ui"
+LOGIC_CHECK_TS = Path(__file__).resolve().parent / "test_v2_admin_operations_logic.ts"
+
+fields_module = import_app_module("admin_settings_fields")
+normalize = fields_module.normalize_admin_settings_updates
+
+TIMER_KEYS = (
+ "debug_logging_timer_enabled",
+ "debug_timer_value",
+ "debug_timer_unit",
+ "debug_logging_turnoff_time",
+ "file_processing_logs_timer_enabled",
+ "file_timer_value",
+ "file_timer_unit",
+ "file_processing_logs_turnoff_time",
+)
+SCHEDULE_KEYS = (
+ "control_center_auto_refresh_time",
+ "control_center_auto_refresh_hour",
+ "control_center_auto_refresh_minute",
+ "control_center_auto_refresh_timezone",
+ "control_center_auto_refresh_next_run",
+)
+
+
+def stored_settings(**overrides):
+ """A settings document with both timers and the schedule at their defaults."""
+ settings = {
+ "enable_debug_logging": False,
+ "debug_logging_timer_enabled": False,
+ "debug_timer_value": 1,
+ "debug_timer_unit": "hours",
+ "debug_logging_turnoff_time": None,
+ "enable_file_processing_logs": True,
+ "file_processing_logs_timer_enabled": False,
+ "file_timer_value": 1,
+ "file_timer_unit": "hours",
+ "file_processing_logs_turnoff_time": None,
+ "control_center_auto_refresh_enabled": True,
+ "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": "2026-10-07T06:00:00+00:00",
+ }
+ settings.update(overrides)
+ return settings
+
+
+def save(updates, current):
+ normalized, errors, warnings = normalize(updates, current)
+ assert not errors, errors
+ return normalized, warnings
+
+
+def test_switching_debug_logging_on_starts_a_utc_timer():
+ """The V2 save works out the turnoff time, and stores it as an exact instant."""
+ print("Testing that enabling a timed log sets a UTC turnoff...")
+
+ assert_app_version_at_least("0.261.260")
+
+ before = datetime.now(timezone.utc)
+ normalized, _ = save(
+ {
+ "enable_debug_logging": True,
+ "debug_logging_timer_enabled": True,
+ "debug_timer_value": 2,
+ "debug_timer_unit": "hours",
+ },
+ stored_settings(),
+ )
+ after = datetime.now(timezone.utc)
+
+ stored = normalized["debug_logging_turnoff_time"]
+ assert stored.endswith("+00:00"), f"Turnoff time is not stored in UTC: {stored}"
+ turnoff = datetime.fromisoformat(stored)
+ assert before + timedelta(hours=2) <= turnoff <= after + timedelta(hours=2), turnoff
+ print(" The turnoff time is two hours from the save, in UTC.")
+
+
+def test_an_unrelated_save_leaves_timers_and_the_schedule_alone():
+ """Saving one switch must not restart a timer or move a scheduled refresh."""
+ print("\nTesting that unrelated saves calculate nothing...")
+
+ normalized, _ = save({"app_title": "Renamed"}, stored_settings())
+ calculated = [key for key in TIMER_KEYS + SCHEDULE_KEYS if key in normalized]
+ assert not calculated, f"An unrelated save wrote: {calculated}"
+ print(" Nothing is recalculated.")
+
+
+def test_resaving_an_unchanged_timer_keeps_its_turnoff():
+ """Only a timer change restarts the clock; legacy values keep their instant."""
+ print("\nTesting that an unchanged timer keeps its turnoff...")
+
+ utc_turnoff = "2026-10-07T09:00:00+00:00"
+ current = stored_settings(
+ enable_debug_logging=True,
+ debug_logging_timer_enabled=True,
+ debug_timer_value=3,
+ debug_timer_unit="days",
+ debug_logging_turnoff_time=utc_turnoff,
+ )
+ normalized, _ = save({"enable_debug_logging": True}, current)
+ assert normalized["debug_logging_turnoff_time"] == utc_turnoff
+
+ # Written before turnoff times were kept in UTC: server-local, no offset. The
+ # instant survives the save, now with its offset made explicit.
+ legacy = (datetime.now() + timedelta(days=2)).replace(microsecond=0).isoformat()
+ normalized, _ = save(
+ {"enable_debug_logging": True},
+ {**current, "debug_logging_turnoff_time": legacy},
+ )
+ kept = parse_logging_turnoff_time(normalized["debug_logging_turnoff_time"])
+ assert kept == parse_logging_turnoff_time(legacy), (kept, legacy)
+ assert normalized["debug_logging_turnoff_time"].endswith("+00:00")
+ print(" UTC and legacy turnoff times both survive a save unchanged.")
+
+
+def test_switching_a_log_off_clears_its_turnoff():
+ """A turnoff time for a log that is off would switch it off again later."""
+ print("\nTesting that switching a log off clears its turnoff...")
+
+ current = stored_settings(
+ file_processing_logs_timer_enabled=True,
+ file_processing_logs_turnoff_time="2026-10-07T09:00:00+00:00",
+ )
+ normalized, _ = save({"enable_file_processing_logs": False}, current)
+ assert normalized["file_processing_logs_turnoff_time"] is None
+ assert normalized["file_processing_logs_timer_enabled"] is True, (
+ "The timer preference survives, so switching the log back on restarts it."
+ )
+ print(" The turnoff is cleared and the timer preference is kept.")
+
+
+def test_a_unit_change_brings_the_duration_into_range_and_says_so():
+ """Ninety minutes switched to hours is not a ninety-hour timer."""
+ print("\nTesting the per-unit duration limit...")
+
+ current = stored_settings(
+ enable_debug_logging=True,
+ debug_logging_timer_enabled=True,
+ debug_timer_value=90,
+ debug_timer_unit="minutes",
+ debug_logging_turnoff_time="2026-10-07T09:00:00+00:00",
+ )
+ normalized, warnings = save({"debug_timer_unit": "hours"}, current)
+ assert normalized["debug_timer_value"] == 24, normalized
+ assert "debug_timer_value" in warnings and "24" in warnings["debug_timer_value"], warnings
+ turnoff = datetime.fromisoformat(normalized["debug_logging_turnoff_time"])
+ assert turnoff > datetime.now(timezone.utc) + timedelta(hours=23), (
+ "A changed unit restarts the clock with the clamped duration."
+ )
+ print(" The duration is lowered to 24 hours with a warning.")
+
+
+def test_refresh_time_and_timezone_are_validated_rather_than_replaced():
+ """The shared normalizer quietly substitutes defaults; a typed value is refused."""
+ print("\nTesting refresh time and timezone validation...")
+
+ for bad_time in ("25:00", "2am", "", "02:60"):
+ _normalized, errors, _ = normalize({"control_center_auto_refresh_time": bad_time}, stored_settings())
+ assert "control_center_auto_refresh_time" in errors, bad_time
+ for bad_zone in ("Mars/Olympus", "", "../etc/passwd"):
+ _normalized, errors, _ = normalize({"control_center_auto_refresh_timezone": bad_zone}, stored_settings())
+ assert "control_center_auto_refresh_timezone" in errors, bad_zone
+
+ normalized, _ = save({"control_center_auto_refresh_time": "03:30:00"}, stored_settings())
+ assert normalized["control_center_auto_refresh_time"] == "03:30", "Seconds are dropped."
+ print(" Bad values are refused; seconds are dropped from good ones.")
+
+
+def test_a_schedule_change_moves_the_next_run():
+ """The next run follows the new zone's clock and is stored in UTC."""
+ print("\nTesting that a schedule change recalculates the next run...")
+
+ before = datetime.now(timezone.utc)
+ normalized, _ = save(
+ {
+ "control_center_auto_refresh_time": "03:30",
+ "control_center_auto_refresh_timezone": "Europe/Paris",
+ },
+ stored_settings(),
+ )
+ assert normalized["control_center_auto_refresh_hour"] == 3
+ assert normalized["control_center_auto_refresh_minute"] == 30
+ next_run = datetime.fromisoformat(normalized["control_center_auto_refresh_next_run"])
+ assert next_run.utcoffset() == timedelta(0), "The next run is not stored in UTC."
+ local = next_run.astimezone(ZoneInfo("Europe/Paris"))
+ assert (local.hour, local.minute) == (3, 30), local
+ assert before < next_run <= before + timedelta(days=1, minutes=1), next_run
+ print(f" Next run {next_run.isoformat()} is 03:30 in Paris.")
+
+
+def test_an_unchanged_schedule_keeps_its_next_run_and_off_clears_it():
+ """Re-saving keeps the stored run; switching the refresh off clears it."""
+ print("\nTesting schedule preservation and clearing...")
+
+ current = stored_settings()
+ normalized, _ = save({"control_center_auto_refresh_enabled": True}, current)
+ assert normalized["control_center_auto_refresh_next_run"] == current["control_center_auto_refresh_next_run"]
+
+ normalized, _ = save({"control_center_auto_refresh_enabled": False}, current)
+ assert normalized["control_center_auto_refresh_next_run"] is None
+ print(" An unchanged schedule keeps its run; a disabled one has none.")
+
+
+def test_the_shared_helpers_apply_the_classic_rules():
+ """The rules the classic form always applied, now in one place."""
+ print("\nTesting the shared timer and schedule helpers directly...")
+
+ now = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
+ existing = {
+ "enable_debug_logging": True,
+ "debug_logging_timer_enabled": True,
+ "debug_timer_value": 1,
+ "debug_timer_unit": "weeks",
+ "debug_logging_turnoff_time": "2026-10-10T00:00:00+00:00",
+ }
+ same = resolve_logging_timer_settings("debug", dict(existing), existing, now=now)
+ assert same["debug_logging_turnoff_time"] == "2026-10-10T00:00:00+00:00"
+
+ changed = resolve_logging_timer_settings("debug", {**existing, "debug_timer_value": 2}, existing, now=now)
+ assert changed["debug_logging_turnoff_time"] == "2026-10-20T12:00:00+00:00"
+
+ unreadable = resolve_logging_timer_settings(
+ "debug", dict(existing), {**existing, "debug_logging_turnoff_time": "not a time"}, now=now,
+ )
+ assert unreadable["debug_logging_turnoff_time"] == "2026-10-13T12:00:00+00:00", (
+ "An unreadable stored time is replaced, since the checker could never act on it."
+ )
+
+ schedule = resolve_control_center_auto_refresh_settings(
+ {"control_center_auto_refresh_time": "02:00"},
+ {"control_center_auto_refresh_enabled": True, "control_center_auto_refresh_next_run": None},
+ current_time=datetime(2026, 1, 15, 6, 30, tzinfo=timezone.utc),
+ )
+ assert schedule["control_center_auto_refresh_next_run"] == "2026-01-15T07:00:00+00:00"
+ print(" Preserve, recalculate and replace-unreadable all behave as the classic form did.")
+
+
+def test_the_checker_reads_utc_and_legacy_turnoffs():
+ """A timer set before the upgrade still ends; a UTC one ends on time."""
+ print("\nTesting turnoff due checks...")
+
+ now = datetime.now(timezone.utc)
+ assert is_logging_turnoff_due((now - timedelta(minutes=1)).isoformat(), now=now)
+ assert not is_logging_turnoff_due((now + timedelta(minutes=1)).isoformat(), now=now)
+
+ local_now = datetime.now()
+ assert is_logging_turnoff_due((local_now - timedelta(minutes=1)).isoformat())
+ assert not is_logging_turnoff_due((local_now + timedelta(minutes=5)).isoformat())
+
+ for unusable in (None, "", "garbage", 42):
+ assert not is_logging_turnoff_due(unusable), unusable
+ print(" UTC and legacy server-local times are both honoured.")
+
+
+def test_every_writer_uses_the_shared_helpers():
+ """A third copy of the rules is how the two surfaces drifted in the first place."""
+ print("\nTesting that the classic form, the PATCH and the checker share the rules...")
+
+ classic = (APP_ROOT / "route_frontend_admin_settings.py").read_text(encoding="utf-8")
+ checker = (APP_ROOT / "background_tasks.py").read_text(encoding="utf-8")
+ schema = (APP_ROOT / "admin_settings_fields.py").read_text(encoding="utf-8")
+
+ assert classic.count("resolve_logging_timer_settings(") == 2, "The classic form has its own timer rules."
+ assert "resolve_control_center_auto_refresh_settings(" in classic
+ assert "timer_limits = {" not in classic, "The classic form still carries a private copy of the limits."
+ assert "is_logging_turnoff_due(" in checker
+ assert "datetime.fromisoformat(turnoff_time)" not in checker, "The checker still parses times itself."
+ assert "resolve_logging_timer_settings(kind, incoming, current_settings)" in schema
+ assert "resolve_control_center_auto_refresh_settings(incoming, current_settings)" in schema
+ print(" All three writers call the shared helpers.")
+
+
+def _load_function(path, name, namespace):
+ """Execute one function from an application module, without its imports."""
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ function = next(
+ node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name
+ )
+ module = ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[]))
+ exec(compile(module, str(path), "exec"), namespace)
+ return namespace[name]
+
+
+def test_runtime_state_reports_how_the_process_started():
+ """The readouts compare saved settings with this, so it must be accurate."""
+ print("\nTesting the runtime state accessors...")
+
+ root_logger = logging.getLogger()
+ for environ, configured, app_logger, expected in (
+ ({}, False, None, (False, False, False)),
+ ({"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=x"}, True, root_logger, (True, True, True)),
+ ({"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=x"}, True, logging.getLogger("azure_monitor"), (True, True, False)),
+ ):
+ state = _load_function(
+ APP_ROOT / "functions_appinsights.py",
+ "get_appinsights_runtime_state",
+ {
+ "os": SimpleNamespace(environ=environ),
+ "logging": logging,
+ "Dict": Dict,
+ "_appinsights_logger": app_logger,
+ "_azure_monitor_configured": configured,
+ },
+ )()
+ assert (
+ state["connection_configured"],
+ state["exporter_configured"],
+ state["global_logging_active"],
+ ) == expected, state
+ assert all(isinstance(value, bool) for value in state.values()), "Only booleans may leave the server."
+
+ registered = _load_function(
+ APP_ROOT / "swagger_wrapper.py",
+ "are_swagger_routes_registered",
+ {"Flask": object, "SWAGGER_BLUEPRINT_NAME": "swagger_docs"},
+ )
+ assert registered(SimpleNamespace(blueprints={"swagger_docs": object()}))
+ assert not registered(SimpleNamespace(blueprints={}))
+ print(" Application Insights and Swagger report how this process started.")
+
+
+def test_the_connection_readout_explains_each_state():
+ """Global logging without a destination silently does nothing; the readout says so."""
+ print("\nTesting the Application Insights connection readout...")
+
+ readout_for = lambda state: _load_function( # noqa: E731
+ APP_ROOT / "route_backend_v2.py",
+ "_build_appinsights_connection_readout",
+ {"get_appinsights_runtime_state": lambda: state},
+ )()
+
+ missing = readout_for({"connection_configured": False, "exporter_configured": False, "global_logging_active": False})
+ failed = readout_for({"connection_configured": True, "exporter_configured": False, "global_logging_active": False})
+ connected = readout_for({"connection_configured": True, "exporter_configured": True, "global_logging_active": False})
+
+ assert missing["ok"] is False and "not set" in missing["message"]
+ assert failed["ok"] is False and "did not start" in failed["message"]
+ assert connected["ok"] is True
+ print(" Missing, failed and connected states each read differently.")
+
+
+def test_the_typescript_logic_checks_pass():
+ """The browser readouts follow the same rules; skipped without the front-end toolchain."""
+ print("\nTesting the Operations readout logic (TypeScript)...")
+
+ if not (V2_DIR / "node_modules").exists():
+ print(" skip application/v2_ui/node_modules is absent; run npm install to include")
+ return
+
+ assert LOGIC_CHECK_TS.exists(), "The TypeScript logic checks are missing"
+
+ # functional_tests/ has no node_modules of its own, so the bundle is written where
+ # node can resolve bare imports from.
+ bundle = V2_DIR / "node_modules" / ".cache-admin-operations-check.mjs"
+ try:
+ subprocess.run(
+ [
+ "npx",
+ "esbuild",
+ str(LOGIC_CHECK_TS),
+ "--bundle",
+ "--platform=node",
+ "--format=esm",
+ "--packages=external",
+ "--define:import.meta.env={}",
+ f"--outfile={bundle}",
+ "--log-level=error",
+ ],
+ cwd=str(V2_DIR),
+ check=True,
+ shell=(sys.platform == "win32"),
+ )
+ result = subprocess.run(
+ ["node", str(bundle)],
+ cwd=str(V2_DIR),
+ capture_output=True,
+ text=True,
+ shell=(sys.platform == "win32"),
+ )
+ finally:
+ if bundle.exists():
+ bundle.unlink()
+
+ if result.returncode != 0:
+ print(result.stdout)
+ print(result.stderr)
+ raise AssertionError("the TypeScript logic checks failed")
+
+ passed = result.stdout.count(" ok ")
+ print(f" ok {passed} TypeScript logic checks passed")
+
+
+if __name__ == "__main__":
+ tests = [
+ test_switching_debug_logging_on_starts_a_utc_timer,
+ test_an_unrelated_save_leaves_timers_and_the_schedule_alone,
+ test_resaving_an_unchanged_timer_keeps_its_turnoff,
+ test_switching_a_log_off_clears_its_turnoff,
+ test_a_unit_change_brings_the_duration_into_range_and_says_so,
+ test_refresh_time_and_timezone_are_validated_rather_than_replaced,
+ test_a_schedule_change_moves_the_next_run,
+ test_an_unchanged_schedule_keeps_its_next_run_and_off_clears_it,
+ test_the_shared_helpers_apply_the_classic_rules,
+ test_the_checker_reads_utc_and_legacy_turnoffs,
+ test_every_writer_uses_the_shared_helpers,
+ test_runtime_state_reports_how_the_process_started,
+ test_the_connection_readout_explains_each_state,
+ test_the_typescript_logic_checks_pass,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ test()
+ results.append(True)
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ import traceback
+
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_v2_admin_operations_logic.ts b/functional_tests/test_v2_admin_operations_logic.ts
new file mode 100644
index 000000000..0bdab6e36
--- /dev/null
+++ b/functional_tests/test_v2_admin_operations_logic.ts
@@ -0,0 +1,464 @@
+// test_v2_admin_operations_logic.ts
+//
+// Runtime test for the decisions behind the V2 Operations settings.
+// Version: 0.261.260
+// Implemented in: 0.261.260
+//
+// The Operations readouts make claims an administrator will act on: who the Control Center
+// admits under a pair of role switches, when a logging timer ends, whether a save would
+// restart it, and when a daily refresh in another timezone next runs. Each mirrors a rule
+// the server applies when it stores the value, so the cases below are the server's own --
+// the DST answers are the ones test_control_center_auto_refresh_schedule.py pins for
+// calculate_next_control_center_auto_refresh_run -- and a readout that drifted from them
+// would be describing a schedule that does not exist.
+//
+// Run by test_v2_admin_operations_derivations.py, which bundles this with esbuild and executes
+// it under node, skipping when the front-end toolchain is absent.
+
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ absoluteUrl,
+ buildLogCleanupRequest,
+ clampTimerValue,
+ controlCenterAccess,
+ describeLoggingTimer,
+ describeRefreshSchedule,
+ describeRestartState,
+ findNavLocation,
+ formatRelative,
+ formatWallTime,
+ nextDailyRun,
+ readStoredInstant,
+ wallTimeToInstant,
+} from '../application/v2_ui/src/lib/adminOperations';
+import type { AdminLoggingTimerKeys } from '../application/v2_ui/src/lib/adminFields';
+import type { AdminNavGroup } from '../application/v2_ui/src/lib/types';
+import { ControlCenterAccessMatrix } from '../application/v2_ui/src/components/admin/ControlCenterAccessMatrix';
+import { EndpointLinks } from '../application/v2_ui/src/components/admin/EndpointLinks';
+import { RestartStatus } from '../application/v2_ui/src/components/admin/RestartStatus';
+import { SettingsSection } from '../application/v2_ui/src/components/admin/SettingsSection';
+
+const checks: [string, () => void][] = [];
+function check(name: string, fn: () => void) {
+ checks.push([name, fn]);
+}
+
+const DEBUG: AdminLoggingTimerKeys = {
+ enabled_key: 'enable_debug_logging',
+ timer_key: 'debug_logging_timer_enabled',
+ value_key: 'debug_timer_value',
+ unit_key: 'debug_timer_unit',
+ turnoff_key: 'debug_logging_turnoff_time',
+};
+
+const NOW = new Date('2026-10-06T12:00:00Z');
+const HOUR = 3_600_000;
+
+// ---------------------------------------------------------------------------------------
+// Control Center access
+// ---------------------------------------------------------------------------------------
+
+check('general Admins have full access until ControlCenterAdmin is required', () => {
+ assert.deepEqual(controlCenterAccess(false, false), [
+ { role: 'Admin', access: 'full' },
+ { role: 'ControlCenterAdmin', access: 'none' },
+ { role: 'ControlCenterDashboardReader', access: 'none' },
+ ]);
+});
+
+check('requiring ControlCenterAdmin moves full access from Admin to the role', () => {
+ const rows = controlCenterAccess(true, false);
+ assert.equal(rows.find((row) => row.role === 'Admin')?.access, 'none');
+ assert.equal(rows.find((row) => row.role === 'ControlCenterAdmin')?.access, 'full');
+});
+
+check('the dashboard reader role works whether or not ControlCenterAdmin is required', () => {
+ for (const requireAdminRole of [false, true]) {
+ const reader = controlCenterAccess(requireAdminRole, true).find(
+ (row) => row.role === 'ControlCenterDashboardReader',
+ );
+ assert.equal(reader?.access, 'dashboard', `requireAdminRole=${requireAdminRole}`);
+ }
+});
+
+// ---------------------------------------------------------------------------------------
+// Logging timers
+// ---------------------------------------------------------------------------------------
+
+check('timer durations are clamped to their unit exactly as the server clamps them', () => {
+ const cases: [unknown, unknown, number, string][] = [
+ [0, 'minutes', 1, 'minutes'],
+ [150, 'minutes', 120, 'minutes'],
+ [30, 'hours', 24, 'hours'],
+ [10, 'days', 7, 'days'],
+ [100, 'weeks', 52, 'weeks'],
+ [2.7, 'hours', 2, 'hours'],
+ ['not a number', 'hours', 1, 'hours'],
+ [true, 'hours', 1, 'hours'],
+ [5, 'fortnights', 5, 'hours'],
+ [5, 'constructor', 5, 'hours'],
+ ];
+ for (const [value, unit, expectedValue, expectedUnit] of cases) {
+ assert.deepEqual(clampTimerValue(value, unit), { value: expectedValue, unit: expectedUnit }, `${value} ${unit}`);
+ }
+});
+
+check('a log that is off, or has no timer, reports that rather than a time', () => {
+ assert.deepEqual(describeLoggingTimer(DEBUG, { enable_debug_logging: false }, {}, NOW), { kind: 'off' });
+ assert.deepEqual(
+ describeLoggingTimer(DEBUG, { enable_debug_logging: true, debug_logging_timer_enabled: false }, {}, NOW),
+ { kind: 'no-timer' },
+ );
+});
+
+check('changing the duration says the clock restarts on save', () => {
+ const settings = {
+ enable_debug_logging: true,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 1,
+ debug_timer_unit: 'hours',
+ debug_logging_turnoff_time: '2026-10-06T12:30:00+00:00',
+ };
+ const readout = describeLoggingTimer(DEBUG, settings, { debug_timer_value: 3 }, NOW);
+ assert.equal(readout.kind, 'on-save');
+ assert.ok(readout.kind === 'on-save');
+ assert.equal(readout.reason, 'changed');
+ assert.equal(readout.projected.getTime(), NOW.getTime() + 3 * HOUR);
+});
+
+check('switching a log on restarts its timer from the save', () => {
+ const settings = {
+ enable_debug_logging: false,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 2,
+ debug_timer_unit: 'hours',
+ };
+ const readout = describeLoggingTimer(DEBUG, settings, { enable_debug_logging: true }, NOW);
+ assert.ok(readout.kind === 'on-save');
+ assert.equal(readout.reason, 'enabling');
+ assert.equal(readout.projected.getTime(), NOW.getTime() + 2 * HOUR);
+});
+
+check('a saved timer with nothing stored is reported as not yet started', () => {
+ const settings = {
+ enable_debug_logging: true,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 1,
+ debug_timer_unit: 'hours',
+ debug_logging_turnoff_time: null,
+ };
+ const readout = describeLoggingTimer(DEBUG, settings, {}, NOW);
+ assert.ok(readout.kind === 'on-save');
+ assert.equal(readout.reason, 'missing');
+});
+
+check('a stored UTC turnoff is shown as scheduled, or overdue once passed', () => {
+ const base = {
+ enable_debug_logging: true,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 1,
+ debug_timer_unit: 'hours',
+ };
+ const future = describeLoggingTimer(
+ DEBUG,
+ { ...base, debug_logging_turnoff_time: '2026-10-06T13:00:00.123456+00:00' },
+ {},
+ NOW,
+ );
+ assert.ok(future.kind === 'scheduled');
+ assert.equal(future.at.toISOString(), '2026-10-06T13:00:00.123Z');
+
+ const past = describeLoggingTimer(DEBUG, { ...base, debug_logging_turnoff_time: '2026-10-06T11:59:00Z' }, {}, NOW);
+ assert.equal(past.kind, 'overdue');
+});
+
+check('an unrelated edit keeps the stored turnoff, as the server does', () => {
+ const settings = {
+ enable_debug_logging: true,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 1,
+ debug_timer_unit: 'hours',
+ debug_logging_turnoff_time: '2026-10-06T13:00:00+00:00',
+ };
+ const readout = describeLoggingTimer(DEBUG, settings, { app_title: 'Renamed' }, NOW);
+ assert.equal(readout.kind, 'scheduled');
+});
+
+check('a turnoff written before UTC storage is shown as written, not guessed at', () => {
+ const settings = {
+ enable_debug_logging: true,
+ debug_logging_timer_enabled: true,
+ debug_timer_value: 1,
+ debug_timer_unit: 'hours',
+ debug_logging_turnoff_time: '2026-01-22T11:05:44.417753',
+ };
+ assert.deepEqual(describeLoggingTimer(DEBUG, settings, {}, NOW), {
+ kind: 'legacy',
+ text: '2026-01-22T11:05:44.417753',
+ });
+});
+
+check('stored instants are read the way the server writes them', () => {
+ assert.deepEqual(readStoredInstant(null), { kind: 'none' });
+ assert.deepEqual(readStoredInstant(' '), { kind: 'none' });
+ assert.deepEqual(readStoredInstant('not-a-date+00:00'), { kind: 'none' });
+ const zulu = readStoredInstant('2026-10-06T12:00:00Z');
+ assert.ok(zulu.kind === 'instant');
+ assert.equal(zulu.at.toISOString(), '2026-10-06T12:00:00.000Z');
+ const offset = readStoredInstant('2026-10-06T14:00:00.999999+02:00');
+ assert.ok(offset.kind === 'instant');
+ assert.equal(offset.at.toISOString(), '2026-10-06T12:00:00.999Z');
+});
+
+// ---------------------------------------------------------------------------------------
+// The daily Control Center refresh
+// ---------------------------------------------------------------------------------------
+
+check('next runs match the server across daylight saving, including the gap', () => {
+ const cases: [string, string, string][] = [
+ // The four cases test_control_center_auto_refresh_schedule.py pins in Python.
+ ['2026-01-15T06:30:00Z', '02:00', '2026-01-15T07:00:00.000Z'],
+ ['2026-07-15T05:30:00Z', '02:00', '2026-07-15T06:00:00.000Z'],
+ ['2026-03-08T06:30:00Z', '02:00', '2026-03-08T07:00:00.000Z'],
+ ['2026-11-01T06:30:00Z', '02:00', '2026-11-01T07:00:00.000Z'],
+ // Today's run has passed, so tomorrow's at the same wall-clock time.
+ ['2026-01-15T08:00:00Z', '02:00', '2026-01-16T07:00:00.000Z'],
+ ];
+ for (const [now, time, expected] of cases) {
+ assert.equal(nextDailyRun(time, 'America/New_York', new Date(now))?.toISOString(), expected, now);
+ }
+});
+
+check('a wall time that happens twice resolves to its first occurrence', () => {
+ // 01:30 on the November change happens at 05:30Z (EDT) and again at 06:30Z (EST).
+ assert.equal(
+ wallTimeToInstant('America/New_York', 2026, 11, 1, 1, 30).toISOString(),
+ '2026-11-01T05:30:00.000Z',
+ );
+});
+
+check('a schedule in another zone matches the server, which stored 01:30 UTC for 03:30 Paris', () => {
+ assert.equal(
+ nextDailyRun('03:30', 'Europe/Paris', new Date('2026-10-06T21:15:59Z'))?.toISOString(),
+ '2026-10-07T01:30:00.000Z',
+ );
+});
+
+check('an unreadable time or zone projects nothing', () => {
+ assert.equal(nextDailyRun('25:00', 'America/New_York', NOW), null);
+ assert.equal(nextDailyRun('02:00', 'Mars/Olympus', NOW), null);
+});
+
+check('the schedule readout follows unsaved edits and keeps the stored run otherwise', () => {
+ const saved = {
+ control_center_auto_refresh_enabled: true,
+ control_center_auto_refresh_time: '02:00',
+ control_center_auto_refresh_timezone: 'America/New_York',
+ control_center_auto_refresh_next_run: '2026-10-07T06:00:00+00:00',
+ };
+ const scheduled = describeRefreshSchedule(saved, {}, NOW);
+ assert.ok(scheduled.kind === 'scheduled');
+ assert.equal(scheduled.at.toISOString(), '2026-10-07T06:00:00.000Z');
+
+ const edited = describeRefreshSchedule(saved, { control_center_auto_refresh_time: '04:15' }, NOW);
+ assert.ok(edited.kind === 'on-save');
+ assert.equal(edited.projected?.toISOString(), '2026-10-07T08:15:00.000Z');
+
+ // The browser's time input may submit seconds; 02:00:00 is the same schedule.
+ assert.equal(describeRefreshSchedule(saved, { control_center_auto_refresh_time: '02:00:00' }, NOW).kind, 'scheduled');
+
+ assert.deepEqual(describeRefreshSchedule(saved, { control_center_auto_refresh_enabled: false }, NOW), { kind: 'off' });
+ assert.equal(
+ describeRefreshSchedule({ ...saved, control_center_auto_refresh_next_run: null }, {}, NOW).kind,
+ 'unscheduled',
+ );
+ assert.equal(
+ describeRefreshSchedule({ ...saved, control_center_auto_refresh_next_run: '2026-10-06T06:00:00Z' }, {}, NOW).kind,
+ 'overdue',
+ );
+});
+
+check('a schedule with no stored values reads as the seeded default, which is on', () => {
+ const readout = describeRefreshSchedule({}, {}, NOW);
+ assert.ok(readout.kind === 'unscheduled');
+ assert.equal(readout.time, '02:00');
+ assert.equal(readout.timezone, 'America/New_York');
+});
+
+// ---------------------------------------------------------------------------------------
+// Restart-bound settings, cleanup and links
+// ---------------------------------------------------------------------------------------
+
+check('a startup-bound setting reports save, restart, and in-sync states', () => {
+ assert.deepEqual(describeRestartState({ saved: true, draft: true, running: true, available: true }), {
+ kind: 'in-sync',
+ running: true,
+ });
+ assert.deepEqual(describeRestartState({ saved: true, draft: true, running: false, available: true }), {
+ kind: 'restart',
+ saved: true,
+ running: false,
+ });
+ assert.deepEqual(describeRestartState({ saved: false, draft: true, running: false, available: true }), {
+ kind: 'save-then-restart',
+ next: true,
+ });
+ assert.deepEqual(describeRestartState({ saved: true, draft: true, running: false, available: false }), {
+ kind: 'unavailable',
+ });
+});
+
+check('the cleanup request states exactly what will be deleted', () => {
+ assert.deepEqual(buildLogCleanupRequest('all', '', 'days'), {
+ request: { delete_all: true },
+ confirmation: 'Delete every stored file processing log?',
+ });
+ assert.deepEqual(buildLogCleanupRequest('older', '30', 'days'), {
+ request: { delete_all: false, age: 30, unit: 'days' },
+ confirmation: 'Delete every file processing log older than 30 days?',
+ });
+ assert.deepEqual(buildLogCleanupRequest('older', '1', 'months'), {
+ request: { delete_all: false, age: 1, unit: 'months' },
+ confirmation: 'Delete every file processing log older than 1 month?',
+ });
+ for (const age of ['', '0', '-2', '1.5', 'ten']) {
+ assert.ok('error' in buildLogCleanupRequest('older', age, 'days'), `age ${age}`);
+ }
+ assert.ok('error' in buildLogCleanupRequest('older', '3', 'years'));
+});
+
+check('a related section resolves to the tab the classic page addresses', () => {
+ const nav: AdminNavGroup[] = [
+ {
+ id: 'scale',
+ label: 'Scale',
+ tabs: [
+ {
+ id: 'cosmos',
+ label: 'Cosmos',
+ sections: [{ id: 'document-access-index-section', label: 'DAI Metrics' }],
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(findNavLocation(nav, 'document-access-index-section'), {
+ groupLabel: 'Scale',
+ tabId: 'cosmos',
+ tabLabel: 'Cosmos',
+ sectionLabel: 'DAI Metrics',
+ });
+ assert.equal(findNavLocation(nav, 'missing-section'), null);
+});
+
+check('endpoint addresses are absolute on this deployment', () => {
+ assert.equal(
+ absoluteUrl('/external/healthcheck', 'https://chat.contoso.com'),
+ 'https://chat.contoso.com/external/healthcheck',
+ );
+});
+
+check('relative times round the way people read them', () => {
+ const at = (offsetMs: number) => new Date(NOW.getTime() + offsetMs);
+ // Pinned to English so the wording is checkable; the page uses the reader's locale.
+ const english = (target: Date) => formatRelative(target, NOW, 'en-US');
+ assert.equal(english(at(HOUR - 1_000)), 'in 1 hour');
+ assert.equal(english(at(10 * 60_000)), 'in 10 minutes');
+ assert.equal(english(at(-3 * HOUR)), '3 hours ago');
+ assert.equal(english(at(23 * HOUR)), 'tomorrow');
+ assert.equal(english(at(5 * 24 * HOUR)), 'in 5 days');
+});
+
+check('a schedule time reads the way the time picker shows it', () => {
+ // ICU may separate "AM" with a narrow no-break space; the words are what matter.
+ const english = (time: unknown) => formatWallTime(time, 'en-US').replace(/\s/g, ' ');
+ assert.equal(english('02:00'), '2:00 AM');
+ assert.equal(english('14:30'), '2:30 PM');
+ assert.equal(formatWallTime('14:30', 'en-GB'), '14:30');
+ // An unreadable value is shown as stored rather than as a guess.
+ assert.equal(formatWallTime('later', 'en-US'), 'later');
+});
+
+// ---------------------------------------------------------------------------------------
+// Rendered output
+// ---------------------------------------------------------------------------------------
+
+check('the access table reads the switches and offers each role value to copy', () => {
+ const markup = renderToStaticMarkup(
+ createElement(ControlCenterAccessMatrix, {
+ label: 'Who can open the Control Center',
+ requireAdminRole: true,
+ allowDashboardReader: true,
+ unsaved: true,
+ }),
+ );
+ assert.match(markup, /
{
+ const quiet = renderToStaticMarkup(
+ createElement(RestartStatus, { label: 'Running state', saved: true, draft: true, running: false, available: false }),
+ );
+ assert.equal(quiet, '');
+ const pending = renderToStaticMarkup(
+ createElement(RestartStatus, { label: 'Running state', saved: true, draft: true, running: false, available: true }),
+ );
+ assert.match(pending, /Restart the App Service/);
+});
+
+check('endpoint rows offer Open only while the endpoint answers', () => {
+ (globalThis as { window?: unknown }).window = { location: { origin: 'https://chat.contoso.com' } };
+ const markup = renderToStaticMarkup(
+ createElement(EndpointLinks, {
+ label: 'Endpoints',
+ endpoints: [
+ { path: '/external/healthcheck', label: 'Authenticated check', gate_key: 'on_key', access: 'protected' },
+ { path: '/external/healthcheckz', label: 'Unauthenticated check', gate_key: 'off_key', access: 'public' },
+ ],
+ isSavedOn: (key: string) => key === 'on_key',
+ isDraftOn: (key: string) => key === 'on_key',
+ runtimeFlags: {},
+ }),
+ );
+ assert.match(markup, /https:\/\/chat\.contoso\.com\/external\/healthcheck/);
+ assert.equal((markup.match(/>Open {
+ const markup = renderToStaticMarkup(
+ createElement(SettingsSection, {
+ sectionId: 'health-check-section',
+ label: 'Health Check',
+ groupLabel: 'Operations',
+ tabLabel: 'Logging & Health',
+ fields: [],
+ settings: {},
+ draft: {},
+ renderField: () => null,
+ renderCapability: () => null,
+ guide: { label: 'Configuration guide', onOpen: () => undefined },
+ }),
+ );
+ assert.match(markup, /aria-haspopup="dialog"[^>]*>.*Configuration guide/);
+});
+
+let passed = 0;
+for (const [name, fn] of checks) {
+ try {
+ fn();
+ console.log(` ok ${name}`);
+ passed += 1;
+ } catch (error) {
+ console.error(` FAIL ${name}`);
+ console.error(` ${(error as Error).message}`);
+ }
+}
+
+console.log(`\nResults: ${passed}/${checks.length} checks passed`);
+process.exit(passed === checks.length ? 0 : 1);
diff --git a/functional_tests/test_v2_admin_operations_parity.py b/functional_tests/test_v2_admin_operations_parity.py
new file mode 100644
index 000000000..5f07b0a7c
--- /dev/null
+++ b/functional_tests/test_v2_admin_operations_parity.py
@@ -0,0 +1,496 @@
+#!/usr/bin/env python3
+# test_v2_admin_operations_parity.py
+"""
+Functional test pinning V1/V2 parity for the Admin Settings Operations group.
+Version: 0.261.260
+Implemented in: 0.261.260
+
+The V2 React admin surface renders from ``admin_settings_fields.py`` rather than
+from the server-rendered panes, so the two can drift apart silently: a field added
+to a V1 pane simply never appears in V2, and nothing fails.
+
+Operations was almost entirely undescribed. The fallback ``enable_*`` scan drew
+three unexplained switches under Debug Logging -- one of them Application
+Insights, which left its own section empty and therefore not rendered at all --
+and Automatic Data Refresh, whose keys are not named ``enable_*``, did not appear
+in V2 in any form. The auto-turnoff timers, the stored-log cleanup and the setup
+guides existed on the server-rendered page only.
+
+This test does for Operations what ``test_v2_admin_security_parity.py`` does for
+Security: it reads the two panes that make up the group and requires every form
+field V1 submits to be claimed by the schema, with matching options, bounds and
+visibility chains. It also pins the parts that are particular to this group: the
+values the server calculates rather than accepts, the guides each section header
+offers, and the descriptors the bespoke readouts depend on.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import import_app_module
+from test_support.nav import ADMIN_NAV
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_ROOT = REPO_ROOT / "application" / "single_app"
+PANES_DIR = APP_ROOT / "templates" / "admin" / "_panes"
+V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src"
+FIELDS_TSX = V2_SRC / "components" / "admin" / "fields.tsx"
+GUIDE_REGISTRY_TSX = V2_SRC / "components" / "admin" / "guides" / "sectionGuides.tsx"
+ROUTE_V2 = APP_ROOT / "route_backend_v2.py"
+OPERATIONS_DOCS = REPO_ROOT / "docs" / "admin" / "operations.md"
+DOCS_SITE_BASE = "https://microsoft.github.io/simplechat"
+
+OPERATIONS_GROUP_ID = "operations"
+
+# The tabs that make up the Operations group, and the sections each contributes.
+OPERATIONS_PANES = {
+ "control-center-config": (
+ "control-center-auto-refresh-section",
+ "control-center-overview-section",
+ ),
+ "logging": (
+ "application-insights-section",
+ "debug-logging-section",
+ "file-processing-logs-section",
+ "health-check-section",
+ "swagger-section",
+ ),
+}
+
+# Each of these is nested inside an enclosing block in the server-rendered pane, so
+# V2 has to carry every gate on the field itself to hide it in the same situations.
+EXPECTED_DEPENDENCY_CHAINS = {
+ "control_center_auto_refresh_time": {("control_center_auto_refresh_enabled", True)},
+ "control_center_auto_refresh_timezone": {("control_center_auto_refresh_enabled", True)},
+ "debug_logging_timer_enabled": {("enable_debug_logging", True)},
+ "debug_timer_value": {
+ ("enable_debug_logging", True),
+ ("debug_logging_timer_enabled", True),
+ },
+ "debug_timer_unit": {
+ ("enable_debug_logging", True),
+ ("debug_logging_timer_enabled", True),
+ },
+ "file_processing_logs_timer_enabled": {("enable_file_processing_logs", True)},
+ "file_timer_value": {
+ ("enable_file_processing_logs", True),
+ ("file_processing_logs_timer_enabled", True),
+ },
+ "file_timer_unit": {
+ ("enable_file_processing_logs", True),
+ ("file_processing_logs_timer_enabled", True),
+ },
+}
+
+# Values the server works out on save. Each must be refused when submitted, or a
+# save could move a scheduled refresh or extend a debug session without touching
+# the controls that are meant to.
+DERIVED_KEYS = (
+ "control_center_auto_refresh_next_run",
+ "control_center_auto_refresh_hour",
+ "control_center_auto_refresh_minute",
+ "debug_logging_turnoff_time",
+ "file_processing_logs_turnoff_time",
+)
+
+# Runtime flags the Operations readouts read. Sent by ``_build_runtime_flags``.
+OPERATIONS_RUNTIME_FLAGS = {
+ "appinsights_connection_configured",
+ "appinsights_global_logging_active",
+ "swagger_routes_registered",
+}
+
+ENDPOINT_ACCESS_VALUES = {"protected", "public", "signed_in"}
+
+FIELD_NAME_RE = re.compile(r'\sname="([^"]+)"')
+JINJA_RE = re.compile(r"\{\{|\{%")
+OPTION_RE = re.compile(r'