From a9a879e17a1fc72b0a016a7fd769e66fbdbd0de7 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 23:14:06 -0400 Subject: [PATCH 1/2] Bring V2 admin Scale settings to parity with the classic page - Declare all seven Scale sections in admin_settings_fields.py: the Redis connection with a Key Vault-aware key field and connection test, Redis Metrics and Explorer, the conversation cache, DAI metrics with diagnostics behind enable_dai_debug, Cosmos maintenance, the throughput console and guardrails, and the container policy workbench. - Enforce the classic throughput policy rules on V2 saves through the new structured collect_cosmos_throughput_policy_errors, report RU/s values the server rounds, and keep container policy timestamps from storage. - Add the label_variants and requires.when schema descriptors, and a "Used by" line driven by requires.target_section. - Fix flag-gated fields being dropped inside settings cards, which hid the Inbound MCP settings while its preview was enabled. - Confirm every action that changes a production resource, keep a capacity change busy until the status is read again, and keep validate-access readings out of the status manual changes estimate from. - Add Scale parity, normalization, browser-logic and UI tests, the admin page rewrite, a feature doc and release notes for 0.261.260. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/admin_settings_fields.py | 934 ++++++++++++++++- application/single_app/config.py | 2 +- .../single_app/functions_cosmos_throughput.py | 141 ++- application/single_app/functions_settings.py | 4 +- application/single_app/route_backend_v2.py | 57 +- .../components/admin/ConfirmActionModal.tsx | 67 ++ .../src/components/admin/ConnectionTest.tsx | 9 +- .../admin/ConversationCacheMetrics.tsx | 93 ++ .../admin/CosmosCapacityConfirm.tsx | 143 +++ .../admin/CosmosContainerMetrics.tsx | 487 +++++++++ .../admin/CosmosContainerPolicyForm.tsx | 272 +++++ .../admin/CosmosMaintenancePanel.tsx | 263 +++++ .../admin/CosmosThroughputConsole.tsx | 311 ++++++ .../admin/DocumentAccessIndexPanel.tsx | 362 +++++++ .../components/admin/OperationalReadouts.tsx | 327 ++++++ .../src/components/admin/RedisExplorer.tsx | 324 ++++++ .../components/admin/RedisMonitoringPanel.tsx | 220 ++++ .../src/components/admin/SettingsSection.tsx | 105 +- .../v2_ui/src/components/admin/fields.tsx | 28 +- application/v2_ui/src/lib/adminFields.ts | 46 +- application/v2_ui/src/lib/adminSections.ts | 71 +- application/v2_ui/src/lib/cosmosThroughput.ts | 868 ++++++++++++++++ application/v2_ui/src/lib/scaleFormat.ts | 170 ++++ application/v2_ui/src/lib/scaleMaintenance.ts | 396 ++++++++ application/v2_ui/src/lib/scaleRedis.ts | 279 ++++++ application/v2_ui/src/lib/useFirstVisible.ts | 41 + .../v2_ui/src/pages/AdminSettingsPage.tsx | 133 ++- .../v2_ui/src/stores/scaleStatusStore.ts | 291 ++++++ docs/admin/scale.md | 324 ++++-- .../features/V2_ADMIN_SCALE_SETTINGS.md | 170 ++++ docs/explanation/release_notes.md | 25 + .../test_docs_app_surface_coverage.py | 5 +- .../test_v2_admin_capability_placement.py | 20 +- functional_tests/test_v2_admin_scale_logic.py | 475 +++++++++ functional_tests/test_v2_admin_scale_logic.ts | 559 +++++++++++ .../test_v2_admin_scale_normalization.py | 412 ++++++++ .../test_v2_admin_scale_parity.py | 936 ++++++++++++++++++ .../test_v2_admin_schema_vocabulary.py | 85 +- .../test_v2_admin_section_logic.ts | 45 +- .../test_v2_admin_settings_schema.py | 8 +- ui_tests/fixtures/v2_admin_settings.py | 8 +- ui_tests/test_v2_admin_scale_settings.py | 771 +++++++++++++++ 42 files changed, 10134 insertions(+), 153 deletions(-) create mode 100644 application/v2_ui/src/components/admin/ConfirmActionModal.tsx create mode 100644 application/v2_ui/src/components/admin/ConversationCacheMetrics.tsx create mode 100644 application/v2_ui/src/components/admin/CosmosCapacityConfirm.tsx create mode 100644 application/v2_ui/src/components/admin/CosmosContainerMetrics.tsx create mode 100644 application/v2_ui/src/components/admin/CosmosContainerPolicyForm.tsx create mode 100644 application/v2_ui/src/components/admin/CosmosMaintenancePanel.tsx create mode 100644 application/v2_ui/src/components/admin/CosmosThroughputConsole.tsx create mode 100644 application/v2_ui/src/components/admin/DocumentAccessIndexPanel.tsx create mode 100644 application/v2_ui/src/components/admin/OperationalReadouts.tsx create mode 100644 application/v2_ui/src/components/admin/RedisExplorer.tsx create mode 100644 application/v2_ui/src/components/admin/RedisMonitoringPanel.tsx create mode 100644 application/v2_ui/src/lib/cosmosThroughput.ts create mode 100644 application/v2_ui/src/lib/scaleFormat.ts create mode 100644 application/v2_ui/src/lib/scaleMaintenance.ts create mode 100644 application/v2_ui/src/lib/scaleRedis.ts create mode 100644 application/v2_ui/src/lib/useFirstVisible.ts create mode 100644 application/v2_ui/src/stores/scaleStatusStore.ts create mode 100644 docs/explanation/features/V2_ADMIN_SCALE_SETTINGS.md create mode 100644 functional_tests/test_v2_admin_scale_logic.py create mode 100644 functional_tests/test_v2_admin_scale_logic.ts create mode 100644 functional_tests/test_v2_admin_scale_normalization.py create mode 100644 functional_tests/test_v2_admin_scale_parity.py create mode 100644 ui_tests/test_v2_admin_scale_settings.py diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 374c9632f..5bb311ae5 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -30,7 +30,7 @@ to the same normalizers the server-rendered form uses. Both interfaces therefore agree on what a valid value is. -Beyond a field's type, four optional descriptors shape how a section reads: +Beyond a field's type, five optional descriptors shape how a section reads: ``group`` Names the cluster a field belongs to, either as a label or with a variant @@ -50,6 +50,16 @@ ``data-requires`` attributes ``admin_settings_dependencies.js`` reads. File Sync needs Redis Cache, which lives under Scale, and saying so where the dependency is felt beats discovering it from a flash message after saving. + An optional ``when`` condition limits the prerequisite to one configuration: + Redis needs Key Vault only while its access key is read from a vault. A + ``target_section`` is also what lets the target card list what uses it. + +``label_variants`` + Replaces a field's label and help while a condition holds, for a stored + value whose meaning changes with another setting. ``redis_key`` holds the + Redis access key, or the name of the Key Vault secret that holds it, and the + server-rendered form relabels its single input the same way. Declaring a + second field would invent a setting nothing reads. ``paths`` Where the value actually lives, when that is not a top-level settings key. @@ -59,7 +69,7 @@ input would otherwise save to a top-level key nothing reads. See ``_apply_nested_paths``. -The Appearance, Agents & Actions, Chat, Knowledge, Workflow, Workspaces and +The Appearance, Agents & Actions, Chat, Knowledge, Scale, 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 @@ -354,6 +364,70 @@ ACCESS_DENIED_MESSAGE_MAX_LENGTH = 2000 +# Scale groups. Each is shared by several fields, and the first field of a group +# decides its label, variant and help, so declaring them once keeps them in step. +REDIS_CONNECTION_GROUP = { + "id": "redis-connection", + "label": "Connection", + "variant": "connection", + "help": "Where SimpleChat finds Redis, and how it signs in.", +} +DAI_DIAGNOSTICS_GROUP = { + "id": "dai-diagnostics", + "label": "Automatic maintenance and diagnostics", + "variant": "advanced", + "help": ( + "Shown because enable_dai_debug is set in the settings document. Container, " + "write-through, read-path and automatic backfill stay on regardless; the status " + "above reports them." + ), +} +DAI_READ_PATH_GROUP = { + "id": "dai-read-path", + "label": "Default read path", + "variant": "behavior", +} +COSMOS_RESOURCE_GROUP = { + "id": "cosmos-resource", + "label": "Cosmos resource", + "variant": "connection", + "help": ( + "The account automation and manual actions manage. Blank values fall back to " + "the App Service settings, so most deployments leave them empty." + ), +} +COSMOS_METRICS_WINDOW_GROUP = { + "id": "cosmos-metrics-window", + "label": "Metrics window", + "variant": "behavior", +} +COSMOS_SCALE_UP_GROUP = { + "id": "cosmos-scale-up", + "label": "Scale up policy", + "variant": "limits", +} +COSMOS_SCALE_DOWN_GROUP = { + "id": "cosmos-scale-down", + "label": "Scale down policy", + "variant": "limits", +} +COSMOS_CONTAINER_POLICY_GROUP = { + "id": "cosmos-autoscale-containers", + "label": "Autoscale and container policy", + "variant": "advanced", +} + +# Mirrors COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU, COSMOS_THROUGHPUT_DEFAULT_MIN_RU and +# DEFAULT_COSMOS_DATABASE_NAME in functions_cosmos_throughput.py. The scale parity +# test compares them, so a change to either side fails until both agree. +COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU = 10000 +COSMOS_THROUGHPUT_MIN_RU = 1000 +COSMOS_THROUGHPUT_DEFAULT_DATABASE_NAME = "SimpleChat" + +# Shared by every Redis-backed cache that bypasses itself without Redis. +REDIS_CACHE_REQUIREMENT_LABEL = "Redis Cache" +REDIS_CACHE_SECTION_ID = "redis-cache-section" + ADMIN_SETTINGS_FIELDS = { "branding-section": [ @@ -5764,6 +5838,352 @@ "depends_on": {"key": "enable_custom_rate_limit_message", "equals": True}, }, ], + # ------------------------------------------------------------------ + # Scale / Redis & Caching + # + # Redis is the prerequisite several other sections lean on: File Sync will + # not run without it, and the conversation and document list caches bypass + # themselves while it is missing. Those sections declare `requires` pointing + # at redis-cache-section, which is also what lets this card say what uses it. + # + # The access key and the Key Vault secret name share one stored value, + # `redis_key`, because the server-rendered form has a single input whose + # label follows the authentication type. `label_variants` reproduces that + # rather than inventing a second setting. + # + # The live readouts -- Redis INFO metrics, the Redis Explorer and the cache + # counters -- are bespoke components over the existing admin APIs. They own + # no settings, so they carry no key. + # ------------------------------------------------------------------ + "redis-cache-section": [ + { + "key": "enable_redis_cache", + "type": "switch", + "label": "Enable Redis Cache", + "help": ( + "Keeps sessions, app settings and shared caches in Redis, so several app " + "instances behave as one. Recommended for production and for any " + "deployment running more than one worker." + ), + "default": False, + "role": "capability", + "notice": ( + "Turning Redis on or off, or changing its connection, takes effect once " + "every web worker and the scheduler restart together. Do not leave workers " + "on different cache backends, and do not share one Redis database between " + "SimpleChat deployments." + ), + "notice_level": "warning", + }, + { + "key": "redis_url", + "type": "text", + "label": "Redis Server Host Name", + "help": ( + "Azure Managed Redis looks like simple-chat.eastus.redis.azure.net, and " + "Azure Cache for Redis like simple-chat.redis.cache.windows.net." + ), + "default": "", + "max_length": 255, + "placeholder": "simple-chat.eastus.redis.azure.net", + "required": True, + "group": REDIS_CONNECTION_GROUP, + "depends_on": {"key": "enable_redis_cache", "equals": True}, + }, + { + "key": "redis_service_type", + "type": "select", + "label": "Redis Service", + "help": ( + "Detection reads the host name suffix and uses port 10000 for Azure " + "Managed Redis or 6380 for Azure Cache for Redis. Choose the service " + "explicitly when a custom DNS name or private endpoint hides the suffix." + ), + "default": "auto", + "options": [ + {"value": "auto", "label": "Detect from host name"}, + {"value": "azure_managed_redis", "label": "Azure Managed Redis"}, + {"value": "azure_cache_for_redis", "label": "Azure Cache for Redis"}, + ], + "group": REDIS_CONNECTION_GROUP, + "depends_on": {"key": "enable_redis_cache", "equals": True}, + }, + { + "key": "redis_port", + "type": "text", + "label": "Redis Port", + "help": ( + "Only needed for a proxy or a non-standard listener. Left blank, the port " + "comes from the selected service." + ), + "default": "", + "max_length": 5, + "placeholder": "Leave blank to use the service's port", + "group": REDIS_CONNECTION_GROUP, + "depends_on": {"key": "enable_redis_cache", "equals": True}, + }, + { + "key": "redis_auth_type", + "type": "select", + "label": "Redis Authentication Type", + "help": ( + "Managed identity stores no credential. Key Vault keeps the access key in " + "the configured vault and stores only the secret's name here." + ), + "default": "key", + "options": [ + {"value": "key", "label": "Key"}, + {"value": "managed_identity", "label": "Managed Identity"}, + {"value": "key_vault", "label": "Key Vault"}, + ], + "group": REDIS_CONNECTION_GROUP, + "depends_on": {"key": "enable_redis_cache", "equals": True}, + }, + { + "key": "redis_key", + "type": "secret", + "label": "Redis Access Key", + "help": "Used with key authentication. Leave it blank to keep the stored key.", + "label_variants": [ + { + "when": {"key": "redis_auth_type", "equals": "key_vault"}, + "label": "Key Vault Secret Name", + "help": ( + "The full name of the Key Vault secret that holds the Redis access " + "key. Leave it blank to keep the stored name." + ), + }, + ], + "default": "", + "required": True, + "group": REDIS_CONNECTION_GROUP, + # Not `equals: "key"`: a document saved before the authentication type + # existed stores "", which every reader treats as key authentication. + "depends_on": [ + {"key": "enable_redis_cache", "equals": True}, + {"key": "redis_auth_type", "not_equals": "managed_identity"}, + ], + "requires": { + "key": "enable_key_vault_secret_storage", + "label": "Key Vault secret storage", + "mode": "warn", + "target_section": "keyvault-section", + "when": {"key": "redis_auth_type", "equals": "key_vault"}, + "description": ( + "With Key Vault authentication Redis reads its access key from the " + "vault, so the connection fails until a vault is configured." + ), + }, + }, + { + "type": "component", + "component": "connection-test", + "label": "Test Redis connection", + "help": ( + "Connects with the values above and writes, reads and expires a short-lived " + "test key, so a connection can be checked before it is saved." + ), + "test_type": "redis", + "test_payload": { + "endpoint": {"key": "redis_url"}, + "service_type": {"key": "redis_service_type"}, + "port": {"key": "redis_port"}, + "auth_type": {"key": "redis_auth_type"}, + "key": { + "key": "redis_key", + "when": {"key": "redis_auth_type", "not_equals": "managed_identity"}, + }, + }, + "group": REDIS_CONNECTION_GROUP, + "depends_on": {"key": "enable_redis_cache", "equals": True}, + }, + ], + "redis-monitoring-section": [ + { + "type": "component", + "component": "redis-monitoring", + "label": "Redis health and capacity", + "help": ( + "Availability, memory pressure, hit rate, evictions and key counts from the " + "Redis INFO command, plus a read-only Redis Explorer for troubleshooting " + "cache keys." + ), + "requires": { + "key": "enable_redis_cache", + "label": REDIS_CACHE_REQUIREMENT_LABEL, + "mode": "warn", + "target_section": REDIS_CACHE_SECTION_ID, + "description": ( + "Metrics are read through the Redis connection the running application " + "uses, so there is nothing to show until Redis is enabled." + ), + }, + }, + ], + "conversation-cache-section": [ + { + "key": "enable_conversation_cache", + "type": "switch", + "label": "Enable conversation cache", + "help": ( + "Caches each user's conversation list, feed and advanced-search results by " + "user and version, so a repeat load skips the Cosmos query. Turning it off " + "bypasses cache reads and writes." + ), + "default": True, + "role": "capability", + "requires": { + "key": "enable_redis_cache", + "label": REDIS_CACHE_REQUIREMENT_LABEL, + "mode": "warn", + "target_section": REDIS_CACHE_SECTION_ID, + "description": ( + "The cache lives in Redis. Until Redis is enabled, list, feed and search " + "requests bypass it and read Cosmos directly." + ), + }, + }, + { + "key": "conversation_cache_ttl_seconds", + "type": "number", + "label": "Cache TTL", + "help": ( + "How long a cached payload lives. Version invalidation already refreshes a " + "user's changed conversations, so this mainly bounds stale entries. Set 0 " + "to stop writing new entries." + ), + "default": 120, + "min": 0, + "suffix": " sec", + "depends_on": {"key": "enable_conversation_cache", "equals": True}, + }, + { + "type": "component", + "component": "conversation-cache-metrics", + "label": "Cache activity", + "help": ( + "Hits, misses, bypasses, writes and invalidations over the last 15 minutes, " + "counted in this app worker. Application Insights remains the fleet-wide " + "record of cache warnings and fallbacks." + ), + }, + ], + # ------------------------------------------------------------------ + # Scale / Cosmos + # + # The document access index (DAI) maintains itself, so its card is mostly a + # readout. Its tuning controls exist on the server-rendered page only while + # `enable_dai_debug` is set in the settings document, with no control for + # the flag itself. V2 matches that exactly: the server sends the flag as the + # `dai_debug_enabled` runtime flag, and the diagnostics fields depend on it. + # The four DAI switches the server forces on are suppressed rather than + # declared, and the status reports them instead. + # + # Throughput policy fields are hidden while automation is off, as on the + # server-rendered page. The status and actions stay visible, because manual + # scaling and access validation do not depend on automation. + # ------------------------------------------------------------------ + "document-access-index-section": [ + { + "type": "component", + "component": "document-access-index-status", + "label": "Document access index", + "help": ( + "The projection that replaces expensive cross-partition document access " + "queries. Maintenance is automatic: the background job repairs fail-open " + "records first, then runs bounded backfill batches while work remains. Read " + "metrics show DAI-served reads, Redis cache hits, source fallbacks, RU and " + "latency without shadow validation." + ), + "requires": { + "key": "enable_app_maintenance", + "label": "Background maintenance", + "mode": "warn", + "target_section": "cosmos-maintenance-section", + "description": ( + "Repair and backfill run inside the background maintenance job. With it " + "off the index stops converging, and document lists keep falling back " + "to slower source queries." + ), + }, + }, + { + "key": "enable_document_access_index_shadow_validation", + "type": "switch", + "label": "Enable shadow validation", + "help": ( + "Compares source list results to projection rows and logs mismatches " + "without changing reads." + ), + "default": False, + "group": DAI_DIAGNOSTICS_GROUP, + "depends_on": {"flag": "dai_debug_enabled", "equals": True}, + }, + { + "key": "document_access_index_backfill_batch_size", + "type": "number", + "label": "Backfill Batch Size", + "help": "Documents processed per manual or scheduled batch.", + "default": 200, + "min": 1, + "max": 1000, + "suffix": " documents", + "group": DAI_DIAGNOSTICS_GROUP, + "depends_on": {"flag": "dai_debug_enabled", "equals": True}, + }, + { + "key": "document_access_index_repair_batch_size", + "type": "number", + "label": "Repair Batch Size", + "help": "Fail-open repair records reconciled before each backfill batch.", + "default": 100, + "min": 1, + "max": 500, + "suffix": " records", + "group": DAI_DIAGNOSTICS_GROUP, + "depends_on": {"flag": "dai_debug_enabled", "equals": True}, + }, + { + "key": "enable_document_access_index_cache", + "type": "switch", + "label": "Redis document list cache", + "help": ( + "Read-through Redis caching for DAI document, tag and legacy-count reads. " + "If Redis is unavailable, reads bypass the cache and use DAI directly." + ), + "default": True, + "group": DAI_READ_PATH_GROUP, + "depends_on": {"flag": "dai_debug_enabled", "equals": True}, + "requires": { + "key": "enable_redis_cache", + "label": REDIS_CACHE_REQUIREMENT_LABEL, + "mode": "warn", + "target_section": REDIS_CACHE_SECTION_ID, + "description": ( + "Without Redis the document list cache is bypassed and every list reads " + "the index directly." + ), + }, + }, + { + "key": "document_access_index_cache_ttl_seconds", + "type": "number", + "label": "Cache TTL", + "help": ( + "Scope-version invalidation makes document changes visible immediately; the " + "TTL clears entries nothing can reach any more." + ), + "default": 900, + "min": 60, + "max": 900, + "suffix": " sec", + "group": DAI_READ_PATH_GROUP, + "depends_on": [ + {"flag": "dai_debug_enabled", "equals": True}, + {"key": "enable_document_access_index_cache", "equals": True}, + ], + }, + ], "cosmos-maintenance-section": [ { "key": "enable_app_maintenance", @@ -5776,6 +6196,7 @@ "converging, so document lists fall back to slower source queries." ), "default": True, + "role": "capability", }, { "key": "enable_startup_app_maintenance", @@ -5790,6 +6211,293 @@ "default": True, "depends_on": {"key": "enable_app_maintenance", "equals": True}, }, + { + "type": "component", + "component": "cosmos-maintenance-status", + "label": "Indexing policies and stale cache cleanup", + "help": ( + "What maintenance last found, and on-demand runs of its two Cosmos tasks. " + "Index maintenance only adds missing expected composite indexes and keeps " + "existing policy paths. Stale cleanup is limited to obsolete cache " + "artifacts, offers a dry run, and deletes at most one bounded batch per run." + ), + }, + ], + "cosmos-throughput-section": [ + { + "key": "cosmos_throughput_autoscale_enabled", + "type": "switch", + "label": "Enable Cosmos throughput automation", + "help": ( + "A background task checks recent RU utilization on the metrics window " + "cadence and scales the database, or each dedicated-throughput container, " + "within the guardrails below. SimpleChat changes capacity only at 10,000 " + "RU/s or lower; above that it monitors, and changes belong in the Azure " + "portal, where they can take 4 to 6 hours." + ), + "default": False, + "role": "capability", + }, + { + "type": "component", + "component": "cosmos-throughput-console", + "label": "Throughput status and actions", + "help": ( + "The current mode, RU/s and utilization, with access validation and manual " + "changes. Manual changes use the saved policy, so save any edits first." + ), + }, + { + "type": "status", + "status_source": "cosmos_throughput_resource", + "label": "Saved target", + "help": ( + "What automation and manual actions manage, from the saved values or, where " + "those are blank, the App Service settings." + ), + "group": COSMOS_RESOURCE_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_subscription_id", + "type": "text", + "label": "Subscription ID", + "default": "", + "max_length": 64, + "placeholder": "Uses AZURE_SUBSCRIPTION_ID when blank", + "group": COSMOS_RESOURCE_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_resource_group", + "type": "text", + "label": "Resource Group", + "default": "", + "max_length": 90, + "placeholder": "Uses AZURE_RESOURCE_GROUP when blank", + "group": COSMOS_RESOURCE_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_account_name", + "type": "text", + "label": "Cosmos Account", + "default": "", + "max_length": 64, + "placeholder": "Uses the account in AZURE_COSMOS_ENDPOINT when blank", + "group": COSMOS_RESOURCE_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_database_name", + "type": "text", + "label": "Database", + # A blank name also resolves to this database, so clearing the field is safe. + "default": COSMOS_THROUGHPUT_DEFAULT_DATABASE_NAME, + "max_length": 255, + "placeholder": COSMOS_THROUGHPUT_DEFAULT_DATABASE_NAME, + "group": COSMOS_RESOURCE_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_metrics_window_minutes", + "type": "number", + "label": "Metrics Window", + "help": ( + "How much recent utilization each check reads, and how often automation " + "runs. Scale intervals cannot be shorter than this." + ), + "default": 5, + "min": 1, + "max": 60, + "suffix": " min", + "group": COSMOS_METRICS_WINDOW_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_auto_scale_up_enabled", + "type": "switch", + "label": "Auto scale up", + "help": "Raise throughput automatically when utilization crosses the threshold.", + "default": True, + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_up_threshold_percent", + "type": "number", + "label": "Scale Up At", + "help": "Utilization that triggers a scale up. Must be higher than Scale Down At.", + "default": 90, + "min": 1, + "max": 100, + "suffix": " %", + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_up_step_ru", + "type": "number", + "label": "Scale Up Step", + "help": ( + "Added per scale up, automatic or manual. Saved in 1,000 RU/s increments, " + "which is how Cosmos autoscale moves." + ), + "default": 1000, + "min": COSMOS_THROUGHPUT_MIN_RU, + "step": 1000, + "suffix": " RU/s", + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_up_cooldown_minutes", + "type": "number", + "label": "Scale Up Interval", + "help": "The shortest gap between scale ups. At least the metrics window.", + "default": 5, + "min": 1, + "max": 1440, + "suffix": " min", + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_max_ru", + "type": "number", + "label": "Maximum RU/s", + "help": ( + "Scaling stops here. SimpleChat-managed scaling never goes above 10,000 " + "RU/s; use the Azure portal beyond that." + ), + "default": COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU, + "min": COSMOS_THROUGHPUT_MIN_RU, + "max": COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU, + "step": 1000, + "suffix": " RU/s", + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_ignore_max_limit", + "type": "switch", + "label": "Ignore maximum guardrail", + "help": ( + "Scale past Maximum RU/s. The 10,000 RU/s SimpleChat ceiling still applies." + ), + "default": False, + "group": COSMOS_SCALE_UP_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_auto_scale_down_enabled", + "type": "switch", + "label": "Auto scale down", + "help": "Lower throughput automatically when utilization stays under the threshold.", + "default": True, + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_down_threshold_percent", + "type": "number", + "label": "Scale Down At", + "help": "Utilization that allows a scale down. Must be lower than Scale Up At.", + "default": 70, + "min": 0, + "max": 99, + "suffix": " %", + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_down_step_ru", + "type": "number", + "label": "Scale Down Step", + "help": "Removed per scale down, automatic or manual, in 1,000 RU/s increments.", + "default": 1000, + "min": COSMOS_THROUGHPUT_MIN_RU, + "step": 1000, + "suffix": " RU/s", + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_scale_down_cooldown_minutes", + "type": "number", + "label": "Scale Down Interval", + "help": "The shortest gap between scale downs. At least the metrics window.", + "default": 20, + "min": 1, + "max": 1440, + "suffix": " min", + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_min_ru", + "type": "number", + "label": "Minimum RU/s", + "help": "Scaling down stops here.", + "default": COSMOS_THROUGHPUT_MIN_RU, + "min": COSMOS_THROUGHPUT_MIN_RU, + "step": 1000, + "suffix": " RU/s", + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_ignore_min_limit", + "type": "switch", + "label": "Ignore minimum guardrail", + "help": "Scale below Minimum RU/s, down to the Cosmos service minimum.", + "default": False, + "group": COSMOS_SCALE_DOWN_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_convert_manual_to_autoscale_enabled", + "type": "switch", + "label": "Convert manual throughput to Cosmos autoscale", + "help": ( + "Before applying guardrails, automation converts manual database or " + "dedicated container throughput to native Cosmos autoscale. The autoscale " + "maximum keeps the current manual RU/s, rounded up to autoscale increments." + ), + "default": False, + "group": COSMOS_CONTAINER_POLICY_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + { + "key": "cosmos_throughput_enforce_container_defaults", + "type": "switch", + "label": "Enforce global policy for all containers", + "help": ( + "Every dedicated-throughput container, including ones discovered later, " + "uses the thresholds, intervals, steps, guardrails and autoscale conversion " + "above instead of its own policy." + ), + "default": False, + "group": COSMOS_CONTAINER_POLICY_GROUP, + "depends_on": {"key": "cosmos_throughput_autoscale_enabled", "equals": True}, + }, + ], + "cosmos-throughput-metrics-table-section": [ + { + # The per-container policies are edited in the workbench this component + # opens, and staged into the draft like any other setting. The key is + # declared here so the save path validates it and the parity test can + # map V1's hidden JSON field to it. + "key": "cosmos_throughput_container_policies", + "type": "component", + "component": "cosmos-container-metrics", + "label": "Container throughput", + "help": ( + "RU utilization and request units per container over the metrics window, " + "and each container's automation policy. Containers sharing database " + "throughput are listed for visibility but cannot be scaled individually." + ), + }, ], "embeddings-config": [ { @@ -6193,6 +6901,9 @@ "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"], + # Same again: V1 stages the per-container throughput policies into a hidden JSON + # field from its modal; V2 stages the object itself from the policy workbench. + "cosmos_throughput_container_policies": ["cosmos_throughput_container_policies_json"], } # Field names present in the V1 panes covered by a parity test that intentionally @@ -6217,6 +6928,24 @@ "orchestration_type, and only meaningful for a multi-agent orchestration " "type. Owned by the agent-orchestration component." ), + # The DAI diagnostics block shows these three as permanently checked, disabled + # checkboxes. Nothing can change them -- the server forces them on -- so V2 + # reports them in the DAI status instead of drawing a control that cannot move. + "enable_document_access_index_write_through": ( + "A disabled, always-checked checkbox in V1. " + "normalize_document_access_index_required_settings forces it on, so V2 shows " + "it as an 'Always on' status in DAI Metrics." + ), + "enable_document_access_index_reads": ( + "A disabled, always-checked checkbox in V1. " + "normalize_document_access_index_required_settings forces it on, so V2 shows " + "it as an 'Always on' status in DAI Metrics." + ), + "enable_startup_document_access_index_backfill": ( + "A disabled, always-checked checkbox in V1. " + "normalize_document_access_index_required_settings forces it on, so V2 shows " + "it as an 'Always on' status in DAI Metrics." + ), } # Settings the schema declares that the server-rendered page has no control for, @@ -6328,6 +7057,32 @@ "Staged rollout flag gated behind enable_mixed_source_chat_search, with no " "control in the server-rendered admin form." ), + # Forced on by normalize_document_access_index_required_settings on every + # settings read and write. The fallback scan filed all four under DAI Metrics, + # where a switch appeared to save and then quietly reverted. + "enable_document_access_index_container": ( + "Always on: normalize_document_access_index_required_settings rewrites it to " + "true on every settings read and write. DAI Metrics reports it instead." + ), + "enable_document_access_index_write_through": ( + "Always on: normalize_document_access_index_required_settings rewrites it to " + "true on every settings read and write. DAI Metrics reports it instead." + ), + "enable_document_access_index_reads": ( + "Always on: normalize_document_access_index_required_settings rewrites it to " + "true on every settings read and write. DAI Metrics reports it instead." + ), + "enable_startup_document_access_index_backfill": ( + "Always on: normalize_document_access_index_required_settings rewrites it to " + "true on every settings read and write. DAI Metrics reports it instead." + ), + "enable_dai_debug": ( + "Internal DAI diagnostics flag with no control in either admin interface; it " + "is set in the settings document. The settings API sends it as the " + "dai_debug_enabled runtime flag, which reveals the same diagnostics the " + "server-rendered page shows. The fallback scan used to misfile it under " + "Operations > Debug Logging." + ), } @@ -6531,6 +7286,12 @@ def evaluate_dependency(dependency, read_value): evaluate_dependency(nested, read_value) for nested in dependency["all_of"] ) + if "key" not in dependency: + # A runtime flag is resolved in the browser from the flags the settings API + # sends. There is no settings key to read, and treating it as unmet would + # judge a field hidden that the administrator can see. + return True + current = read_value(dependency["key"]) if "not_equals" in dependency: @@ -7350,6 +8111,28 @@ def _validate_identity_header_name(value): return normalized, None +REDIS_PORT_ERROR = "Enter a port from 1 to 65535, or leave it blank to use the service's port." + + +def _validate_redis_port(value): + """Return ``(normalized, error)`` for the optional Redis port override. + + Stored as a string, as the server-rendered form stores it: the connection test + reads the value with ``.strip()``. Blank means "use the port for the selected + service". ``resolve_redis_port`` would ignore an out-of-range value at connection + time, but on an explicit save an administrator should be told it was refused. + """ + candidate = str(value if value is not None else "").strip() + if not candidate: + return "", None + if not candidate.isdigit(): + return None, REDIS_PORT_ERROR + port = int(candidate) + if not 1 <= port <= 65535: + return None, REDIS_PORT_ERROR + return str(port), None + + def _validate_multi_model_endpoint_enablement(value, current_settings): """Return ``(normalized, error)`` for the connections capability toggle. @@ -7466,6 +8249,25 @@ def normalize_admin_settings_updates(updates, current_settings=None): normalized[key] = front_door_value continue + if key == "redis_port": + port_value, port_error = _validate_redis_port(value) + if port_error: + errors[key] = port_error + else: + normalized[key] = port_value + continue + + if key == "cosmos_throughput_container_policies": + # A component field, so the type-driven path below would refuse it. + # Shape is checked here; the policy rules run once the whole save is + # known, in _check_cosmos_throughput_policy. + policies, policies_error = _normalize_cosmos_container_policies(value, current) + if policies_error: + errors[key] = policies_error + else: + normalized[key] = policies + continue + field_value, error, warning = _normalize_field_value(key, value, field) if error: errors[key] = error @@ -7486,6 +8288,7 @@ def normalize_admin_settings_updates(updates, current_settings=None): _check_minimum_selections(normalized, current, errors) _check_content_screening_dependency(normalized, current, errors) _check_planner_model_selection(normalized, current, errors) + _check_cosmos_throughput_policy(normalized, current, errors, warnings) # Applied last so the checks above still see flat keys, which is the shape # they and the schema are written against. @@ -7578,6 +8381,133 @@ def _check_planner_model_selection(normalized, current_settings, errors): errors[key] = message +# Throughput settings held in RU/s, which Cosmos autoscale moves in 1,000 RU/s steps. +COSMOS_THROUGHPUT_RU_KEYS = ( + "cosmos_throughput_scale_up_step_ru", + "cosmos_throughput_scale_down_step_ru", + "cosmos_throughput_min_ru", + "cosmos_throughput_max_ru", +) + + +def _normalize_cosmos_container_policies(value, current_settings): + """Return ``(policies, error)`` for the per-container throughput policies. + + The server-rendered form posts these as a JSON string and the V2 workbench as an + object, so both are accepted. Each policy's values are normalized with the rest + of the throughput settings once the whole save is known, in + ``_check_cosmos_throughput_policy``. + + A policy's scale timestamps belong to the automation, not the administrator. A + page loaded before an automatic or manual scale would otherwise write older + timestamps back and let a container rescale inside its cooldown, so they are + always taken from the stored document. + + ``functions_cosmos_throughput`` is imported here rather than at module scope + because it loads the Azure identity SDK and Application Insights logging, and + this schema module stays importable without them (see the module docstring). + """ + from functions_cosmos_throughput import COSMOS_THROUGHPUT_POLICY_RUNTIME_FIELDS + + if isinstance(value, str): + try: + value = json.loads(value or "{}") + except ValueError: + return None, "Container policies could not be read." + if not isinstance(value, dict): + return None, "Container policies must be an object keyed by container name." + + stored = current_settings.get("cosmos_throughput_container_policies") + stored = stored if isinstance(stored, dict) else {} + + policies = {} + for name, policy in value.items(): + container_name = str(name or "").strip() + if not container_name: + continue + if not isinstance(policy, dict): + return None, f"The policy for {container_name} must be an object." + cleaned = { + field_name: field_value + for field_name, field_value in policy.items() + if field_name not in COSMOS_THROUGHPUT_POLICY_RUNTIME_FIELDS + } + stored_policy = stored.get(container_name) + if isinstance(stored_policy, dict): + for field_name in COSMOS_THROUGHPUT_POLICY_RUNTIME_FIELDS: + if stored_policy.get(field_name): + cleaned[field_name] = stored_policy[field_name] + cleaned["container_name"] = container_name + policies[container_name] = cleaned + return policies, None + + +def _describe_cosmos_adjustment(key, submitted, resolved): + """Say why a throughput value was saved differently from how it was entered.""" + if key in COSMOS_THROUGHPUT_RU_KEYS: + if resolved == COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU and submitted > resolved: + return f"Saved as {resolved:,} RU/s, the most SimpleChat scales to." + if key == "cosmos_throughput_max_ru" and resolved > submitted and submitted % 1000 == 0: + return f"Raised to {resolved:,} RU/s so it is not below Minimum RU/s." + return ( + f"Saved as {resolved:,} RU/s. Cosmos autoscale moves in 1,000 RU/s " + "increments, so values round up." + ) + if key == "cosmos_throughput_scale_down_threshold_percent": + return f"Lowered to {resolved}% so it stays below Scale Up At." + return f"Saved as {resolved}." + + +def _check_cosmos_throughput_policy(normalized, current_settings, errors, warnings): + """Apply the throughput policy rules the server-rendered form enforces on save. + + The rules relate several settings -- Scale Up At must stay above Scale Down At, + and each interval must cover the metrics window -- and a partial save can carry + any one of them, so the merged result is what is judged. Each error is placed on + the controls involved, using the rule list both admin interfaces share. + + A save that passes is normalized exactly as the server-rendered form normalizes + it, which rounds RU/s to the increments Cosmos autoscale accepts. A value that + changes is reported as a warning instead of being rewritten silently. + + Imported lazily for the reason ``_normalize_cosmos_container_policies`` gives. + """ + if not any(key.startswith("cosmos_throughput_") for key in normalized): + return + + from functions_cosmos_throughput import ( + collect_cosmos_throughput_policy_errors, + get_cosmos_throughput_setting_keys, + normalize_cosmos_throughput_settings, + ) + + managed_keys = [key for key in get_cosmos_throughput_setting_keys() if key in normalized] + if not managed_keys: + return + + candidate = dict(current_settings) + candidate.update({key: normalized[key] for key in managed_keys}) + + problems = collect_cosmos_throughput_policy_errors(candidate) + for problem in problems: + for setting_key in problem["setting_keys"]: + errors.setdefault(setting_key, problem["message"]) + if problems: + return + + resolved = normalize_cosmos_throughput_settings(candidate) + for key in managed_keys: + submitted = normalized[key] + value = resolved.get(key, submitted) + if ( + isinstance(submitted, int) + and not isinstance(submitted, bool) + and value != submitted + ): + warnings[key] = _describe_cosmos_adjustment(key, submitted, value) + normalized[key] = value + + def _apply_cross_field_rules(normalized, current_settings, warnings): """Reconcile settings whose valid range depends on another setting. 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_cosmos_throughput.py b/application/single_app/functions_cosmos_throughput.py index 5ece45c37..6bdba3ea7 100644 --- a/application/single_app/functions_cosmos_throughput.py +++ b/application/single_app/functions_cosmos_throughput.py @@ -57,6 +57,14 @@ 'cosmos_throughput_container_policies', ) +# Fields in a container policy that the automation writes, never the administrator. +# They record when a container last scaled or converted, which the cooldowns read. +COSMOS_THROUGHPUT_POLICY_RUNTIME_FIELDS = ( + 'last_scale_up_at', + 'last_scale_down_at', + 'last_mode_conversion_at', +) + class CosmosThroughputError(Exception): """Raised when Cosmos throughput management cannot complete.""" @@ -320,28 +328,64 @@ def normalize_cosmos_throughput_settings(settings, repair_policy_relationships=T return normalized -def _append_policy_validation_errors(errors, policy_label, policy, metrics_window_minutes): - scale_up_threshold = policy.get('scale_up_threshold_percent') - scale_down_threshold = policy.get('scale_down_threshold_percent') - scale_up_interval = policy.get('scale_up_cooldown_minutes') - scale_down_interval = policy.get('scale_down_cooldown_minutes') - - if scale_up_threshold <= scale_down_threshold: - errors.append(f'{policy_label}: Scale Up At must be higher than Scale Down At.') - if scale_up_interval < metrics_window_minutes: - errors.append( - f'{policy_label}: Scale Up Interval must be greater than or equal to the Metrics Window ' - f'({metrics_window_minutes} minutes).' - ) - if scale_down_interval < metrics_window_minutes: - errors.append( - f'{policy_label}: Scale Down Interval must be greater than or equal to the Metrics Window ' - f'({metrics_window_minutes} minutes).' - ) - - -def validate_cosmos_throughput_policy_settings(settings, include_container_policies=True): - """Return save-blocking validation errors for Cosmos throughput policy settings.""" +# The global policy is stored as top-level settings, while a container policy uses +# the bare field names. A rule reports the policy fields it judged, and this maps +# them to the settings keys an admin edits, so each error can sit beside its control. +COSMOS_THROUGHPUT_GLOBAL_POLICY_FIELD_KEYS = { + 'scale_up_threshold_percent': 'cosmos_throughput_scale_up_threshold_percent', + 'scale_down_threshold_percent': 'cosmos_throughput_scale_down_threshold_percent', + 'scale_up_cooldown_minutes': 'cosmos_throughput_scale_up_cooldown_minutes', + 'scale_down_cooldown_minutes': 'cosmos_throughput_scale_down_cooldown_minutes', +} + + +def _collect_policy_problems(policy, metrics_window_minutes): + """Return ``(rule, policy_fields, message)`` for each rule a policy breaks. + + ``policy_fields`` names the policy values involved. An interval rule also + involves the metrics window, which callers add because it is a global value + rather than part of the policy. + """ + problems = [] + if policy.get('scale_up_threshold_percent') <= policy.get('scale_down_threshold_percent'): + problems.append(( + 'threshold_order', + ('scale_up_threshold_percent', 'scale_down_threshold_percent'), + 'Scale Up At must be higher than Scale Down At.', + )) + if policy.get('scale_up_cooldown_minutes') < metrics_window_minutes: + problems.append(( + 'scale_up_interval', + ('scale_up_cooldown_minutes',), + 'Scale Up Interval must be greater than or equal to the Metrics Window ' + f'({metrics_window_minutes} minutes).', + )) + if policy.get('scale_down_cooldown_minutes') < metrics_window_minutes: + problems.append(( + 'scale_down_interval', + ('scale_down_cooldown_minutes',), + 'Scale Down Interval must be greater than or equal to the Metrics Window ' + f'({metrics_window_minutes} minutes).', + )) + return problems + + +def collect_cosmos_throughput_policy_errors(settings, include_container_policies=True): + """Return each save-blocking throughput policy error with what it applies to. + + Each entry is a dict with: + + ``scope`` ``'global'`` or ``'container'`` + ``container_name`` the container for a container policy, otherwise ``''`` + ``rule`` ``threshold_order``, ``scale_up_interval`` or ``scale_down_interval`` + ``fields`` the policy field names the rule judged + ``setting_keys`` the admin settings keys to report the error against + ``message`` the same text ``validate_cosmos_throughput_policy_settings`` returns + + Both admin interfaces read these rules from here. The server-rendered form shows + the messages; the V2 surface uses ``setting_keys`` to place each one beside the + control that caused it. + """ normalized = normalize_cosmos_throughput_settings( settings, repair_policy_relationships=False, @@ -352,32 +396,51 @@ def validate_cosmos_throughput_policy_settings(settings, include_container_polic errors = [] metrics_window_minutes = normalized['cosmos_throughput_metrics_window_minutes'] - _append_policy_validation_errors( - errors, - 'Cosmos throughput policy', - { - 'scale_up_threshold_percent': normalized['cosmos_throughput_scale_up_threshold_percent'], - 'scale_down_threshold_percent': normalized['cosmos_throughput_scale_down_threshold_percent'], - 'scale_up_cooldown_minutes': normalized['cosmos_throughput_scale_up_cooldown_minutes'], - 'scale_down_cooldown_minutes': normalized['cosmos_throughput_scale_down_cooldown_minutes'], - }, - metrics_window_minutes, - ) + global_policy = { + field_name: normalized[setting_key] + for field_name, setting_key in COSMOS_THROUGHPUT_GLOBAL_POLICY_FIELD_KEYS.items() + } + for rule, fields, message in _collect_policy_problems(global_policy, metrics_window_minutes): + setting_keys = [COSMOS_THROUGHPUT_GLOBAL_POLICY_FIELD_KEYS[field] for field in fields] + if rule != 'threshold_order': + setting_keys.append('cosmos_throughput_metrics_window_minutes') + errors.append({ + 'scope': 'global', + 'container_name': '', + 'rule': rule, + 'fields': list(fields), + 'setting_keys': setting_keys, + 'message': f'Cosmos throughput policy: {message}', + }) if include_container_policies and not normalized.get('cosmos_throughput_enforce_container_defaults'): for container_name, policy in normalized.get('cosmos_throughput_container_policies', {}).items(): if policy.get('enabled') is False: continue - _append_policy_validation_errors( - errors, - f"Container '{container_name}' policy", - policy, - metrics_window_minutes, - ) + for rule, fields, message in _collect_policy_problems(policy, metrics_window_minutes): + errors.append({ + 'scope': 'container', + 'container_name': container_name, + 'rule': rule, + 'fields': list(fields), + 'setting_keys': ['cosmos_throughput_container_policies'], + 'message': f"Container '{container_name}' policy: {message}", + }) return errors +def validate_cosmos_throughput_policy_settings(settings, include_container_policies=True): + """Return save-blocking validation errors for Cosmos throughput policy settings.""" + return [ + error['message'] + for error in collect_cosmos_throughput_policy_errors( + settings, + include_container_policies=include_container_policies, + ) + ] + + def build_cosmos_throughput_access_validation(status): """Return admin-facing validation checks for Cosmos throughput access.""" status = status or {} diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index c13a81c53..19522afa6 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1711,7 +1711,9 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_redis_cache': False, 'redis_url': '', 'redis_key': '', - 'redis_auth_type': '', + # Every reader treats an empty value as key authentication, so seeding 'key' + # changes nothing at runtime and lets the admin select show what is in effect. + 'redis_auth_type': 'key', 'redis_service_type': 'auto', 'redis_port': '', diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 424079ae1..e4e32e551 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -191,6 +191,7 @@ validate_custom_model_endpoints, ) from functions_documents import get_audio_runtime_capabilities +from functions_cosmos_throughput import get_cosmos_resource_config from config import VERSION from swagger_wrapper import get_auth_security, swagger_route @@ -1304,9 +1305,55 @@ def _build_endpoint_readouts(settings): "video_indexer_endpoint": { "ok": bool(endpoint), "message": endpoint or "No endpoint resolved yet.", - } + }, + "cosmos_throughput_resource": _build_cosmos_throughput_readout(settings), } + def _build_cosmos_throughput_readout(settings): + """Describe the Cosmos account and database throughput actions will manage. + + Blank resource fields fall back to App Service settings, so the stored values + alone do not say what is targeted. The server-rendered page answers that by + pre-filling its inputs with the resolved values, which then get saved as if an + administrator had typed them. Stating the resolution beside empty inputs keeps + the environment the source of truth for anything left blank. + """ + try: + resource = get_cosmos_resource_config(settings) + except Exception as exc: + log_event( + "[V2_ADMIN_SETTINGS] Cosmos throughput target could not be resolved.", + extra={"error_type": type(exc).__name__}, + level=logging.WARNING, + ) + return {"ok": False, "message": "The Cosmos throughput target could not be resolved."} + + parts = ( + ("subscription_id", "Subscription", "cosmos_throughput_subscription_id"), + ("resource_group", "Resource group", "cosmos_throughput_resource_group"), + ("account_name", "Account", "cosmos_throughput_account_name"), + ("database_name", "Database", "cosmos_throughput_database_name"), + ) + described = [] + missing = [] + for resource_key, label, setting_key in parts: + value = str(resource.get(resource_key) or "").strip() + if not value: + missing.append(label.lower()) + continue + source = "saved" if str(settings.get(setting_key) or "").strip() else "App Service setting or default" + described.append(f"{label} {value} ({source})") + + if missing: + return { + "ok": False, + "message": ( + f"Missing {', '.join(missing)}. Enter each one here or set the matching " + "App Service setting." + ), + } + return {"ok": True, "message": "; ".join(described) + "."} + def _redact_admin_settings_for_v2(settings): """Replace stored credentials with the redaction placeholder. @@ -1409,7 +1456,13 @@ def v2_admin_get_settings(): # 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()}, + # DAI diagnostics are gated by enable_dai_debug, which has + # no control in either interface; it is read exactly as the + # server-rendered page reads it. + "runtime_flags": { + "mcp_ui_enabled": is_mcp_ui_enabled(), + "dai_debug_enabled": bool(settings.get("enable_dai_debug", False)), + }, "suppressed_capabilities": get_suppressed_capability_keys(), "version": VERSION, } diff --git a/application/v2_ui/src/components/admin/ConfirmActionModal.tsx b/application/v2_ui/src/components/admin/ConfirmActionModal.tsx new file mode 100644 index 000000000..f5181c7a3 --- /dev/null +++ b/application/v2_ui/src/components/admin/ConfirmActionModal.tsx @@ -0,0 +1,67 @@ +// ConfirmActionModal.tsx +// The confirmation in front of an operation that changes a production resource. +// +// Applying Cosmos indexes, deleting stale cache documents, resetting the document access +// backfill and changing throughput all take effect outside SimpleChat, and some cannot be +// undone from here. Each is stated in full before it runs, with the outcome first and the +// caveats after, so the decision is made on what will happen rather than on a button +// label. Everything else on the Scale cards acts without asking. +// +// Only deletions get the danger treatment. A change with lasting side effects keeps the +// primary button and says what the side effects are; an amber button would not pass text +// contrast in either theme, and colour is not where that warning belongs anyway. + +import type { ReactNode } from 'react'; +import { Loader2 } from 'lucide-react'; +import { AdminModal } from './AdminModal'; +import { GlassButton } from '../ui/primitives'; + +export function ConfirmActionModal({ + title, + confirmLabel, + tone = 'primary', + busy = false, + cancelLabel = 'Cancel', + confirmDisabled = false, + onConfirm, + onClose, + children, +}: { + title: string; + confirmLabel: string; + tone?: 'primary' | 'danger'; + busy?: boolean; + cancelLabel?: string; + confirmDisabled?: boolean; + onConfirm: () => void; + onClose: () => void; + children: ReactNode; +}) { + return ( + undefined : onClose} + footer={ + <> + + {cancelLabel} + + + {busy ? + + } + > +
{children}
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/ConnectionTest.tsx b/application/v2_ui/src/components/admin/ConnectionTest.tsx index 95f7a93dc..fd9763180 100644 --- a/application/v2_ui/src/components/admin/ConnectionTest.tsx +++ b/application/v2_ui/src/components/admin/ConnectionTest.tsx @@ -86,11 +86,14 @@ export function ConnectionTest({ settings, draft, disabled, + onSuccess, }: { field: AdminField; settings: Json; draft: Json; disabled?: boolean; + /** Runs after a test passes, for a readout that should look again, such as Redis Metrics. */ + onSuccess?: () => void; }) { const [running, setRunning] = useState(false); const [outcome, setOutcome] = useState(null); @@ -123,7 +126,11 @@ export function ConnectionTest({ '/api/v2/admin/settings/test-connection', payload, ); - setOutcome(readOutcome(response, true)); + const outcome = readOutcome(response, true); + setOutcome(outcome); + if (outcome.ok) { + onSuccess?.(); + } } catch (caught) { if (caught instanceof ApiError) { setOutcome( diff --git a/application/v2_ui/src/components/admin/ConversationCacheMetrics.tsx b/application/v2_ui/src/components/admin/ConversationCacheMetrics.tsx new file mode 100644 index 000000000..56b9c7f46 --- /dev/null +++ b/application/v2_ui/src/components/admin/ConversationCacheMetrics.tsx @@ -0,0 +1,93 @@ +// ConversationCacheMetrics.tsx +// Conversation cache activity for the Conversation Cache card. +// +// Hits, misses, bypasses and invalidations over the last 15 minutes, which is what tells an +// administrator whether the cache is earning its keep or quietly bypassing itself -- for +// example because Redis is unavailable. The counters are kept in-process by each worker, so +// they describe this worker only; the note says so rather than letting them pass for a +// fleet-wide figure. +// +// Shares the app maintenance status with DAI Metrics and Cosmos Maintenance. + +import { useEffect, useRef } from 'react'; +import { Gauge } from 'lucide-react'; +import type { AdminField } from '../../lib/adminFields'; +import { formatPercent } from '../../lib/scaleFormat'; +import { + describeConversationCacheRuntime, + formatCacheEvent, + formatCountPair, + formatOperationCounts, + metricWindow, +} from '../../lib/scaleMaintenance'; +import { useFirstVisible } from '../../lib/useFirstVisible'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { + OpsHeading, + OpsMessage, + OpsNote, + OpsPanel, + OpsToolbar, + Readout, + ReadoutGrid, + ReadoutSkeleton, +} from './OperationalReadouts'; + +export function ConversationCacheMetrics({ field }: { field: AdminField }) { + const containerRef = useRef(null); + const seen = useFirstVisible(containerRef); + const maintenance = useScaleStatusStore((state) => state.maintenance); + const loadMaintenance = useScaleStatusStore((state) => state.loadMaintenance); + + useEffect(() => { + if (seen) { + void loadMaintenance(); + } + }, [seen, loadMaintenance]); + + const cache = maintenance.data?.conversation_cache; + const recent = metricWindow(cache?.metrics); + + return ( +
+ + + void loadMaintenance({ force: true })} + refreshLabel="Refresh metrics" + /> + + + {!cache && maintenance.loading ? : null} + + {cache ? ( + <> + + + + + + + + + + + + Counted over the last 15 minutes in this app worker only. Application Insights keeps the + fleet-wide record of cache warnings and fallbacks. + + + ) : null} + +
+ ); +} diff --git a/application/v2_ui/src/components/admin/CosmosCapacityConfirm.tsx b/application/v2_ui/src/components/admin/CosmosCapacityConfirm.tsx new file mode 100644 index 000000000..4d925405c --- /dev/null +++ b/application/v2_ui/src/components/admin/CosmosCapacityConfirm.tsx @@ -0,0 +1,143 @@ +// CosmosCapacityConfirm.tsx +// The confirmation in front of a Cosmos throughput change, shared by both throughput cards. +// +// A scale or conversion changes provisioned capacity -- and with it cost -- in Azure as soon +// as it is sent. Before it is, the dialog says which target changes, from what, and to what +// the server will most likely set it. The estimate follows `calculate_manual_scale_target` +// on the saved settings, because that is what the server uses; it is labelled an estimate, +// and the server's own result is reported once the change is made. When the estimate +// already says the change cannot happen, the dialog says why instead of sending it. + +import { useMemo, useState } from 'react'; +import type { Json } from '../../lib/types'; +import { + containerScalePolicy, + databaseScalePolicy, + estimateManualScaleTarget, + readGlobalPolicy, + type CosmosThroughputTarget, + type ManualScalePolicy, +} from '../../lib/cosmosThroughput'; +import { formatRu } from '../../lib/scaleFormat'; +import { useScaleStatusStore, type PendingCapacityAction } from '../../stores/scaleStatusStore'; +import { ConfirmActionModal } from './ConfirmActionModal'; + +export function CosmosCapacityConfirm({ + settings, + unsavedThroughputEdits, +}: { + /** The saved settings. Never the draft: the server applies what is saved. */ + settings: Json; + unsavedThroughputEdits: boolean; +}) { + const action = useScaleStatusStore((state) => state.pendingCapacityAction); + const clearCapacityAction = useScaleStatusStore((state) => state.clearCapacityAction); + const performCapacityAction = useScaleStatusStore((state) => state.performCapacityAction); + const status = useScaleStatusStore((state) => state.throughput.status); + const acting = useScaleStatusStore((state) => state.throughput.acting); + // Each request is confirmed once. Another click before the dialog closes would send a + // second change, a step on from the first. + const [submitted, setSubmitted] = useState(null); + const sent = action !== null && submitted === action; + + const details = useMemo(() => { + if (!action) { + return null; + } + const globals = readGlobalPolicy((key) => settings[key]); + let target: CosmosThroughputTarget | null | undefined; + let policy: ManualScalePolicy; + let subject: string; + if (action.containerName) { + const container = (status?.containers ?? []).find((item) => item.container_name === action.containerName); + target = container; + policy = container ? containerScalePolicy(container, globals) : databaseScalePolicy(globals); + subject = `the ${action.containerName} container`; + } else { + target = status?.throughput; + policy = databaseScalePolicy(globals); + subject = `the ${status?.resource?.database_name || 'SimpleChat'} database`; + } + const estimate = + action.kind === 'scale' && action.direction + ? estimateManualScaleTarget(target, policy, action.direction) + : null; + return { target, policy, subject, estimate }; + }, [action, settings, status]); + + if (!action || !details) { + return null; + } + + const { target, policy, subject, estimate } = details; + const blocked = Boolean(estimate?.error); + const busy = acting || sent; + const close = () => { + if (!busy) { + clearCapacityAction(); + } + }; + const confirm = async () => { + if (sent) { + return; + } + setSubmitted(action); + await performCapacityAction(action); + clearCapacityAction(); + }; + + const title = + action.kind === 'convert' + ? 'Convert to Cosmos autoscale' + : action.direction === 'up' + ? 'Scale throughput up' + : 'Scale throughput down'; + + return ( + void confirm()} + onClose={close} + > + {action.kind === 'convert' ? ( + <> +

+ Converts {subject} from manual {formatRu(target?.current_ru)} to native Cosmos autoscale. The + autoscale maximum keeps the current RU/s, rounded up to Cosmos autoscale increments, within + the saved guardrails. +

+

+ SimpleChat does not convert throughput back to manual; that is done in the Azure portal. +

+ + ) : blocked ? ( +

+ {estimate?.error} +

+ ) : ( + <> +

+ Scales {subject} {action.direction} from{' '} + {formatRu(target?.current_ru)} to about{' '} + {formatRu(estimate?.target)}. +

+

+ {action.direction === 'up' + ? `Uses the saved step of ${formatRu(policy.scale_up_step_ru)}${policy.ignore_max_limit ? ', ignoring the maximum guardrail' : ` and stops at the ${formatRu(policy.max_ru)} maximum`}. SimpleChat never scales above 10,000 RU/s.` + : `Uses the saved step of ${formatRu(policy.scale_down_step_ru)}${policy.ignore_min_limit ? ', ignoring the minimum guardrail' : ` and stops at the ${formatRu(policy.min_ru)} minimum`}.`}{' '} + The change is made in Azure immediately and recorded in the admin activity log. +

+ + )} + {unsavedThroughputEdits ? ( +

+ You have unsaved throughput changes. This uses the saved policy; save first if the change depends + on them. +

+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/CosmosContainerMetrics.tsx b/application/v2_ui/src/components/admin/CosmosContainerMetrics.tsx new file mode 100644 index 000000000..73e2881a9 --- /dev/null +++ b/application/v2_ui/src/components/admin/CosmosContainerMetrics.tsx @@ -0,0 +1,487 @@ +// CosmosContainerMetrics.tsx +// Per-container throughput, and each container's automation policy, as a workbench. +// +// The list answers "which container is hot": utilization, RU/s and request units for every +// container over the metrics window, filterable and sortable. Selecting one shows the rest +// of what is known about it, its manual capacity actions, and its policy, edited in place. +// It is the same list-beside-detail pattern the Model Catalog uses, so a collection is +// browsed the same way everywhere in Admin Settings. +// +// The policies are one settings value, `cosmos_throughput_container_policies`, and are +// edited into the page draft. The status comes from the store this card shares with Cosmos +// DB Throughput, so a refresh in either updates both. + +import { useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { ArrowDownCircle, ArrowDownUp, ArrowUpCircle, RefreshCw, RotateCcw, Search, Zap } from 'lucide-react'; +import type { AdminField } from '../../lib/adminFields'; +import { + CONTAINER_POLICIES_KEY, + buildGlobalContainerPolicy, + containerActions, + containerPolicyLabel, + containerUtilization, + effectiveContainerPolicy, + filterContainers, + formatContainerUtilization, + hasStatusData, + isPortalManaged, + mergeRuntimePolicyFields, + normalizeContainerPolicy, + portalManagedMessage, + readContainerPolicies, + readGlobalPolicy, + sortContainers, + validateCosmosThroughputPolicy, + type ContainerSort, + type ContainerSortField, + type CosmosContainerPolicies, + type CosmosContainerPolicy, + type CosmosContainerStatus, + type CosmosThroughputStatus, +} from '../../lib/cosmosThroughput'; +import { formatCount, formatRu, type ToneText } from '../../lib/scaleFormat'; +import type { Json } from '../../lib/types'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { inputClass } from './fields'; +import { CosmosContainerPolicyForm } from './CosmosContainerPolicyForm'; +import { + OpsButton, + OpsEmptyState, + OpsHeading, + OpsMessage, + OpsPanel, + Readout, + ReadoutGrid, + StatePill, +} from './OperationalReadouts'; + +const THROUGHPUT_SECTION_ID = 'cosmos-throughput-section'; + +const SORT_OPTIONS: { value: ContainerSortField; label: string }[] = [ + { value: 'container_name', label: 'Container' }, + { value: 'ru_utilization', label: 'RU utilization' }, + { value: 'current_ru', label: 'Current RU/s' }, + { value: 'request_units', label: 'Request units' }, + { value: 'policy', label: 'Policy' }, +]; + +function containerBadge(container: CosmosContainerStatus): ToneText | null { + if (isPortalManaged(container)) { + return { text: 'Monitor only', tone: 'info' }; + } + if (!container.is_scalable) { + return { text: 'Shared throughput', tone: 'neutral' }; + } + return null; +} + +export function CosmosContainerMetrics({ + field, + value, + error, + settings, + draft, + saving, + onChange, + onNavigate, +}: { + field: AdminField; + value: unknown; + error?: string; + settings: Json; + draft: Json; + saving: boolean; + onChange: (next: CosmosContainerPolicies) => void; + onNavigate: (sectionId: string) => void; +}) { + const throughput = useScaleStatusStore((state) => state.throughput); + const seedThroughput = useScaleStatusStore((state) => state.seedThroughput); + const refreshThroughput = useScaleStatusStore((state) => state.refreshThroughput); + const requestCapacityAction = useScaleStatusStore((state) => state.requestCapacityAction); + const containerFocus = useScaleStatusStore((state) => state.containerFocus); + + const [filter, setFilter] = useState(''); + const [sort, setSort] = useState({ field: 'container_name', direction: 'asc' }); + const [selectedName, setSelectedName] = useState(null); + + // This card can be on screen without Cosmos DB Throughput, during a search for one. + const cached = settings['cosmos_throughput_cached_status'] as CosmosThroughputStatus | undefined; + useEffect(() => { + if (hasStatusData(cached)) { + seedThroughput({ ...(cached as CosmosThroughputStatus), is_cached: true }); + } + }, [cached, seedThroughput]); + + useEffect(() => { + if (containerFocus.revision === 0) { + return; + } + if (containerFocus.name) { + setFilter(''); + setSelectedName(containerFocus.name); + } + }, [containerFocus]); + + const read = (key: string): unknown => + Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : settings[key]; + const globals = readGlobalPolicy(read); + // Editing reads values as typed, unrepaired, so a broken relationship stays visible + // beside the value that breaks it instead of being quietly corrected on screen. + const editingGlobals = readGlobalPolicy(read, false); + const policies = readContainerPolicies(value); + const savedPolicies = readContainerPolicies(settings[CONTAINER_POLICIES_KEY]); + const validation = validateCosmosThroughputPolicy(read); + + const status = throughput.status; + const containers = useMemo(() => status?.containers ?? [], [status]); + const windowMinutes = status?.metrics?.window_minutes ?? globals.metrics_window_minutes; + + // A value the policy leaves out inherits the global one, so every field shows what + // governs the container now -- including one discovered after its policy was saved. + // A value the policy does set is shown as typed: normalizing it here would round a + // half-typed number on every keystroke. + const effective = (container: CosmosContainerStatus): CosmosContainerPolicy => { + const own = effectiveContainerPolicy(container, policies, globals); + const inherited = normalizeContainerPolicy(container.container_name, own, editingGlobals, false); + const typed = Object.fromEntries( + Object.entries(own).filter(([, fieldValue]) => fieldValue !== undefined && fieldValue !== null), + ) as CosmosContainerPolicy; + return { ...inherited, ...typed }; + }; + const labelFor = (container: CosmosContainerStatus) => containerPolicyLabel(container, effective(container), globals); + const visible = sortContainers(filterContainers(containers, filter), sort, { windowMinutes, policyLabel: labelFor }); + const selected = + containers.find((container) => container.container_name === selectedName) ?? visible[0] ?? null; + + const isUnsaved = (name: string) => + Object.prototype.hasOwnProperty.call(draft, CONTAINER_POLICIES_KEY) && + JSON.stringify(policies[name] ?? null) !== JSON.stringify(savedPolicies[name] ?? null); + + const updatePolicy = (container: CosmosContainerStatus, patch: Partial) => { + const name = container.container_name; + onChange({ ...policies, [name]: { ...effective(container), ...patch, container_name: name } }); + }; + + const applyGlobalPolicyTo = (targets: CosmosContainerStatus[]) => { + const next: CosmosContainerPolicies = { ...policies }; + for (const container of targets) { + const name = container.container_name; + if (!name) { + continue; + } + next[name] = mergeRuntimePolicyFields( + buildGlobalContainerPolicy(globals, name), + policies[name] ?? container.policy, + ); + } + onChange(next); + }; + + const busy = throughput.loading || throughput.acting; + const editable = Boolean(selected) && !globals.enforce_container_defaults; + + return ( + + + +
+
+ +
+
+
+
+ +
+ + + setSort((current) => ({ + ...current, + direction: current.direction === 'asc' ? 'desc' : 'asc', + })) + } + > + {sort.direction === 'asc' ? 'Ascending' : 'Descending'} + +
+
+ void refreshThroughput()}> + Refresh table + + {containers.length && !globals.enforce_container_defaults ? ( + applyGlobalPolicyTo(containers)} + title="Write the global policy into every discovered container's policy. Save to apply." + > + Apply global policy to all + + ) : null} +
+ + + {globals.enforce_container_defaults ? ( + + + + ) : null} + + {!status ? ( + + Refresh reads each container's throughput from Azure Resource Manager and its utilization from + Azure Monitor. + + ) : status.configured === false ? ( + + {status.error || 'Subscription, resource group, account and database are needed.'}{' '} + + + ) : !containers.length ? ( + + The configured Cosmos database reported no containers. + + ) : ( +
+
+

+ Showing {visible.length} of {containers.length} containers · {windowMinutes} min window +

+
    + {!visible.length ? ( +
  • No containers match “{filter}”.
  • + ) : null} + {visible.map((container) => { + const isSelected = container.container_name === selected?.container_name; + const utilization = containerUtilization(container, windowMinutes); + const badge = containerBadge(container); + return ( +
  • + +
  • + ); + })} +
+
+ +
+ {selected ? ( + updatePolicy(selected, patch)} + onUseGlobal={() => applyGlobalPolicyTo([selected])} + onAction={(kind, direction) => + requestCapacityAction({ kind, direction, containerName: selected.container_name }) + } + /> + ) : null} +
+
+ )} +
+ ); +} + +function ContainerDetail({ + container, + windowMinutes, + policy, + policyLabel, + errors, + editable, + busy, + onChange, + onUseGlobal, + onAction, +}: { + container: CosmosContainerStatus; + windowMinutes: number | null | undefined; + policy: CosmosContainerPolicy; + policyLabel: string; + errors: Parameters[0]['errors']; + editable: boolean; + busy: boolean; + onChange: (patch: Partial) => void; + onUseGlobal: () => void; + onAction: (kind: 'scale' | 'convert', direction?: 'up' | 'down') => void; +}) { + const actions = containerActions(container); + const portal = isPortalManaged(container); + const badge = containerBadge(container); + const actionReason = [actions.convert, actions.up, actions.down].every((action) => action.disabled) + ? actions.down.reason || actions.up.reason + : ''; + + return ( + <> +
+

{container.container_name}

+ {badge ? : null} +
+ + + + + + + + + {containerUtilization(container, windowMinutes).estimated ? ( +

+ Azure Monitor sent no utilization percentage for this window, so it is estimated from request + units against current RU/s. +

+ ) : null} + {container.error ? : null} + +
+
+ onAction('convert')} + > + Convert to autoscale + + onAction('scale', 'up')} + > + Scale up + + onAction('scale', 'down')} + > + Scale down + +
+ {actionReason ?

{actionReason}

: null} +
+ + {portal ? ( + + ) : !container.is_scalable ? ( +

+ This container shares the database's throughput, so it has no policy of its own. Database + automation covers it. +

+ ) : editable ? ( +
+
+

Automation policy

+ + Use global values + +
+ +
+ ) : null} + + ); +} diff --git a/application/v2_ui/src/components/admin/CosmosContainerPolicyForm.tsx b/application/v2_ui/src/components/admin/CosmosContainerPolicyForm.tsx new file mode 100644 index 000000000..ef49fbdea --- /dev/null +++ b/application/v2_ui/src/components/admin/CosmosContainerPolicyForm.tsx @@ -0,0 +1,272 @@ +// CosmosContainerPolicyForm.tsx +// One container's throughput policy, edited in place in the Cosmos Metrics workbench. +// +// Edits go straight into the page draft like every other setting, and the save bar +// persists them; there is no separate staging step to forget. A value the policy leaves +// out inherits the global one, so the form always starts from what governs the container +// now. Rule breaks -- Scale Up At not above Scale Down At, an interval shorter than the +// metrics window -- are shown beside the value, the same rules the server checks on save. + +import { useState, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Toggle } from '../ui/primitives'; +import { inputClass } from './fields'; +import { + COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU, + type ContainerPolicyField, + type CosmosContainerPolicy, +} from '../../lib/cosmosThroughput'; +import { formatTimestamp } from '../../lib/scaleFormat'; + +/** Container names are Cosmos ids; keep only what is safe in an element id. */ +function idPart(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'); +} + +function PolicyNumber({ + id, + label, + value, + min, + max, + step = 1, + suffix, + error, + disabled, + onChange, +}: { + id: string; + label: string; + value: number | undefined; + min: number; + max?: number; + step?: number; + suffix: string; + error?: string; + disabled?: boolean; + onChange: (next: number) => void; +}) { + const errorId = `${id}-error`; + // While the field has focus its text is what it shows, so clearing it to type another + // number does not commit 0 on the way, and nothing rewrites a half-typed value. + const [text, setText] = useState(null); + return ( +
+ +
+ setText(value === undefined ? '' : String(value))} + onBlur={() => setText(null)} + onChange={(event) => { + const next = event.target.value; + setText(next); + const parsed = Number(next); + if (next.trim() !== '' && Number.isFinite(parsed)) { + onChange(parsed); + } + }} + /> + {suffix} +
+ {error ? ( + + ) : null} +
+ ); +} + +function PolicyCluster({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} + +export function CosmosContainerPolicyForm({ + containerName, + policy, + errors, + disabled, + onChange, +}: { + containerName: string; + policy: CosmosContainerPolicy; + errors: Partial>; + disabled?: boolean; + onChange: (patch: Partial) => void; +}) { + const base = `cosmos-policy-${idPart(containerName)}`; + const enabled = policy.enabled !== false; + const fieldsDisabled = disabled || !enabled; + + return ( +
+ onChange({ enabled: next })} + /> + + + onChange({ auto_scale_up_enabled: next })} + /> +
+ onChange({ scale_up_threshold_percent: next })} + /> + onChange({ scale_up_step_ru: next })} + /> + onChange({ scale_up_cooldown_minutes: next })} + /> +
+
+ + + onChange({ auto_scale_down_enabled: next })} + /> +
+ onChange({ scale_down_threshold_percent: next })} + /> + onChange({ scale_down_step_ru: next })} + /> + onChange({ scale_down_cooldown_minutes: next })} + /> +
+
+ + +
+
+ onChange({ min_ru: next })} + /> + onChange({ ignore_min_limit: next })} + /> +
+
+ onChange({ max_ru: next })} + /> + onChange({ ignore_max_limit: next })} + /> +
+
+
+ + + onChange({ convert_manual_to_autoscale_enabled: next })} + /> + +
+ ); +} diff --git a/application/v2_ui/src/components/admin/CosmosMaintenancePanel.tsx b/application/v2_ui/src/components/admin/CosmosMaintenancePanel.tsx new file mode 100644 index 000000000..5705e23e2 --- /dev/null +++ b/application/v2_ui/src/components/admin/CosmosMaintenancePanel.tsx @@ -0,0 +1,263 @@ +// CosmosMaintenancePanel.tsx +// What background maintenance last found in Cosmos, and on-demand runs of its two tasks. +// +// Index maintenance compares each hot container's composite indexes with what the code +// expects and can add the missing ones. Stale cleanup finds obsolete cache documents in the +// settings container and can delete one bounded batch. Both can be checked here without +// waiting for the scheduler; the two that change Cosmos ask first, with the server-rendered +// page's explanation of what is and is not touched. +// +// Shares the app maintenance status with DAI Metrics and Conversation Cache. + +import { useEffect, useRef, useState } from 'react'; +import { ScanSearch, Trash2, Zap } from 'lucide-react'; +import { ApiError, api } from '../../lib/apiClient'; +import type { AdminField } from '../../lib/adminFields'; +import { describeMaintenanceStatus, formatCount, formatTimestamp, humanizeStatus, type ToneText } from '../../lib/scaleFormat'; +import { + MAINTENANCE_RUN_PAYLOADS, + describeCleanupRun, + describeIndexApply, + findRunStep, + indexingPolicyStatus, + type AppMaintenanceRunResult, + type CosmosIndexingPolicyStatus, + type StaleCacheCleanupStatus, +} from '../../lib/scaleMaintenance'; +import { useFirstVisible } from '../../lib/useFirstVisible'; +import { toast } from '../../stores/toastStore'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { ConfirmActionModal } from './ConfirmActionModal'; +import { + OpsButton, + OpsHeading, + OpsMessage, + OpsPanel, + OpsToolbar, + Readout, + ReadoutGrid, + ReadoutGroup, + ReadoutSkeleton, +} from './OperationalReadouts'; + +const RUN_PATH = '/api/admin/settings/app-maintenance/run'; + +type MaintenanceAction = 'apply-indexes' | 'dry-run' | 'delete'; + +export function CosmosMaintenancePanel({ field }: { field: AdminField }) { + const containerRef = useRef(null); + const seen = useFirstVisible(containerRef); + const maintenance = useScaleStatusStore((state) => state.maintenance); + const loadMaintenance = useScaleStatusStore((state) => state.loadMaintenance); + const mergeMaintenance = useScaleStatusStore((state) => state.mergeMaintenance); + + const [running, setRunning] = useState(null); + const [confirm, setConfirm] = useState<'apply-indexes' | 'delete' | null>(null); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (seen) { + void loadMaintenance(); + } + }, [seen, loadMaintenance]); + + const run = async (action: MaintenanceAction) => { + setRunning(action); + setMessage({ + text: + action === 'apply-indexes' + ? 'Submitting the missing composite indexes. Cosmos may keep transforming indexes after this returns.' + : action === 'delete' + ? 'Deleting one bounded batch of stale cache documents…' + : 'Scanning for stale cache documents…', + tone: 'info', + }); + const payload = + action === 'apply-indexes' + ? MAINTENANCE_RUN_PAYLOADS.applyIndexes + : action === 'delete' + ? MAINTENANCE_RUN_PAYLOADS.staleCleanupApply + : MAINTENANCE_RUN_PAYLOADS.staleCleanupDryRun; + let result: AppMaintenanceRunResult | null = null; + let failure: string | null = null; + try { + result = await api.post(RUN_PATH, payload); + } catch (error) { + result = error instanceof ApiError ? (error.payload as AppMaintenanceRunResult | null) : null; + failure = error instanceof Error ? error.message : 'Cosmos maintenance failed.'; + } + + if (action === 'apply-indexes') { + const indexing = findRunStep(result, 'cosmos_indexing_policy_maintenance') as CosmosIndexingPolicyStatus | null; + if (indexing) { + mergeMaintenance({ cosmos_indexing_policies: indexing }); + } + if (!failure) { + const outcome = describeIndexApply(indexing); + setMessage(outcome); + toast.success(outcome.text); + } + } else { + const cleanup = findRunStep(result, 'stale_cache_document_cleanup') as StaleCacheCleanupStatus | null; + if (cleanup) { + mergeMaintenance({ stale_cache_cleanup: cleanup }); + } + if (!failure) { + const outcome = describeCleanupRun(cleanup, action === 'delete'); + setMessage(outcome); + if (outcome.tone === 'ok') { + toast.success(outcome.text); + } else { + toast.info(outcome.text); + } + } + } + + if (failure) { + setMessage({ text: failure, tone: 'danger' }); + toast.error(failure); + } + setRunning(null); + setConfirm(null); + void loadMaintenance({ force: true }); + }; + + const indexing = maintenance.data?.cosmos_indexing_policies; + const cleanup = maintenance.data?.stale_cache_cleanup; + const hasData = Boolean(indexing || cleanup); + const loadError: ToneText | null = maintenance.error ? { text: maintenance.error, tone: 'danger' } : null; + + return ( +
+ + + void loadMaintenance({ force: true })} + refreshLabel="Refresh status" + > + setConfirm('apply-indexes')}> + Apply missing indexes + + void run('dry-run')} + > + Dry run cleanup + + setConfirm('delete')}> + Delete stale cache docs + + + + + + {!hasData && maintenance.loading ? : null} + + {hasData ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + {(cleanup?.categories ?? []).length ? ( +
+ + + + + + + + + + + + {(cleanup?.categories ?? []).map((category, index) => ( + + + + + + + ))} + +
Stale cache cleanup by category
CategoryCandidatesDeletedFailed
{humanizeStatus(category.category || 'unknown')}{formatCount(category.candidate_count ?? 0)}{formatCount(category.deleted_count ?? 0)}{formatCount(category.failed_count ?? 0)}
+
+ ) : null} +
+ + ) : null} +
+ + {confirm === 'apply-indexes' ? ( + void run('apply-indexes')} + onClose={() => setConfirm(null)} + > +

+ Adds the missing expected composite indexes to the hot Cosmos containers. Existing included + and excluded paths, default indexes, TTL settings and full-text policies are kept. +

+

+ Composite indexes speed up supported lookups and ordered queries, but Cosmos maintains them + on every later write and may run an index transformation after the policy changes. +

+

+ Apply them only if the extra write-index overhead is worth the faster queries shown in the + maintenance status. +

+
+ ) : null} + + {confirm === 'delete' ? ( + void run('delete')} + onClose={() => setConfirm(null)} + > +

+ Deletes one bounded batch of allowlisted stale cache artifacts from the settings container, + such as retired conversation cache version documents and obsolete volatile cache payloads. +

+

+ App settings, active cache-version documents, DAI state, maintenance state, source documents + and user data are never deleted. Run a dry run first to see the candidate count. +

+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/CosmosThroughputConsole.tsx b/application/v2_ui/src/components/admin/CosmosThroughputConsole.tsx new file mode 100644 index 000000000..7e19a5d87 --- /dev/null +++ b/application/v2_ui/src/components/admin/CosmosThroughputConsole.tsx @@ -0,0 +1,311 @@ +// CosmosThroughputConsole.tsx +// Current Cosmos throughput, access validation and database-level capacity changes. +// +// The card opens on the snapshot automation last saved, so nothing is asked of Azure just +// because the page was opened; Refresh reads it live. When automation is on and no snapshot +// exists yet, the first check runs once the card is seen, as on the server-rendered page. +// +// Validate access runs against the values on screen, so a configuration can be proved +// before it is saved. Scaling and conversion are different: the server applies the saved +// policy, so they say so when there are unsaved throughput edits, and every capacity change +// goes through the confirmation the page draws once for this card and Cosmos Metrics. + +import { useEffect, useMemo, useRef } from 'react'; +import { ArrowDownCircle, ArrowUpCircle, CheckCircle2, ShieldCheck, SlidersHorizontal, XCircle, Zap } from 'lucide-react'; +import type { AdminField } from '../../lib/adminFields'; +import { + buildAccessValidationPayload, + databaseActions, + hasStatusData, + hasUnsavedThroughputEdits, + type CosmosThroughputStatus, +} from '../../lib/cosmosThroughput'; +import { formatPercent, formatRu, formatTimestamp, type ToneText } from '../../lib/scaleFormat'; +import type { Json } from '../../lib/types'; +import { useFirstVisible } from '../../lib/useFirstVisible'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { + OpsButton, + OpsHeading, + OpsMessage, + OpsNote, + OpsPanel, + OpsToolbar, + Readout, + ReadoutGrid, + ReadoutGroup, + StatePill, +} from './OperationalReadouts'; + +export const COSMOS_METRICS_SECTION_ID = 'cosmos-throughput-metrics-table-section'; + +export function CosmosThroughputConsole({ + field, + settings, + draft, + dirtyKeys, + onNavigate, +}: { + field: AdminField; + settings: Json; + draft: Json; + dirtyKeys: string[]; + onNavigate: (sectionId: string) => void; +}) { + const containerRef = useRef(null); + const seen = useFirstVisible(containerRef); + const throughput = useScaleStatusStore((state) => state.throughput); + const seedThroughput = useScaleStatusStore((state) => state.seedThroughput); + const refreshThroughput = useScaleStatusStore((state) => state.refreshThroughput); + const validateThroughputAccess = useScaleStatusStore((state) => state.validateThroughputAccess); + const requestCapacityAction = useScaleStatusStore((state) => state.requestCapacityAction); + const requestContainerFocus = useScaleStatusStore((state) => state.requestContainerFocus); + + const cached = settings['cosmos_throughput_cached_status'] as CosmosThroughputStatus | undefined; + const automationSaved = Boolean(settings['cosmos_throughput_autoscale_enabled']); + + useEffect(() => { + if (hasStatusData(cached)) { + seedThroughput({ ...(cached as CosmosThroughputStatus), is_cached: true }); + } + }, [cached, seedThroughput]); + + // The first check runs on its own only where automation would have run it anyway. + const firstCheckDone = useRef(false); + useEffect(() => { + if (!seen || firstCheckDone.current || !automationSaved || hasStatusData(cached)) { + return; + } + firstCheckDone.current = true; + void refreshThroughput(); + }, [seen, automationSaved, cached, refreshThroughput]); + + const read = (key: string): unknown => + Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : settings[key]; + + const status = throughput.status; + const actions = useMemo(() => databaseActions(status), [status]); + const unsavedEdits = hasUnsavedThroughputEdits(dirtyKeys); + const containerScoped = status?.capacity_scope === 'container'; + const busy = throughput.loading || throughput.validating || throughput.acting; + + const snapshotNote: ToneText | null = + throughput.source === 'saved' + ? { + text: automationSaved + ? 'Showing the last saved status. Automation refreshes it every metrics window; Refresh reads it now.' + : 'Showing the last saved status. Automation is off, so use Refresh to read it now.', + tone: 'info', + } + : null; + const capacityReason = actions.up.disabled && actions.down.disabled && actions.convert.disabled + ? actions.down.reason || actions.up.reason + : ''; + + return ( +
+ + + void refreshThroughput()} + > + void validateThroughputAccess(buildAccessValidationPayload(read))} + > + Validate access + + { + requestContainerFocus(null); + onNavigate(COSMOS_METRICS_SECTION_ID); + }} + > + Container policies + + + + + + {throughput.validation ? ( + + {(throughput.validation.checks ?? []).length ? ( +
    + {(throughput.validation.checks ?? []).map((check, index) => ( +
  • + {check.passed ? ( +
  • + ))} +
+ ) : null} +
+ ) : null} + + {status ? ( + + + + + : undefined} + /> + + ) : ( + + No status yet. Refresh reads the current throughput and utilization from Azure Resource + Manager and Azure Monitor. + + )} + +
+
+

Database capacity

+
+ requestCapacityAction({ kind: 'convert', containerName: '' })} + > + Convert to autoscale + + requestCapacityAction({ kind: 'scale', direction: 'up', containerName: '' })} + > + Scale up + + requestCapacityAction({ kind: 'scale', direction: 'down', containerName: '' })} + > + Scale down + +
+
+ {capacityReason ? ( +

+ {capacityReason} + {containerScoped ? ( + <> + {' '} + + + ) : null} +

+ ) : ( +

+ One step at a time, within the saved guardrails. Each change asks for confirmation first. +

+ )} + {unsavedEdits ? ( +

+ You have unsaved throughput changes. Validate access uses them; scaling and conversion + use the saved policy until you save. +

+ ) : null} +
+ + +
+

+ Validate access runs the same read checks automation depends on, using the values on + screen: resource identity, Azure Resource Manager throughput reads, container discovery, + and Azure Monitor metrics. +

+
+

Required Azure access

+
    +
  • + Assign roles to the App Service managed identity, not the Microsoft Entra sign-in app + registration. In the Azure portal, open the Web App, select Identity, and copy the + Object (principal) ID. +
  • +
  • + Assign the custom SimpleChat Cosmos Throughput Operator{' '} + role to that identity at the resource group that holds the Cosmos DB account, or on + the account itself. +
  • +
  • + The role must read the Cosmos account, database and containers; read and write SQL + database and container throughputSettings; run{' '} + migrateToAutoscale; read throughput operation + results; and read Microsoft.Insights/metrics. +
  • +
  • + Without the custom role, grant equivalent custom permissions. Broad built-in roles + such as Contributor pass validation but are not least privilege. +
  • +
+
+
+

Capacity scope

+
    +
  • Database mode scales the shared SimpleChat database throughput.
  • +
  • Container-targeted mode scales only containers with dedicated throughput.
  • +
  • Containers sharing database throughput are listed but cannot be scaled on their own.
  • +
  • + Manual throughput converts to native Cosmos autoscale only when conversion is enabled + globally or in the container's policy. +
  • +
+
+
+

Metrics

+
    +
  • RU utilization is the share of available throughput used over the metrics window.
  • +
  • Request units are the total consumed over the metrics window.
  • +
  • Azure Monitor metrics can lag traffic by a few minutes.
  • +
+
+ void validateThroughputAccess(buildAccessValidationPayload(read))} + > + Validate access + +
+
+
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/DocumentAccessIndexPanel.tsx b/application/v2_ui/src/components/admin/DocumentAccessIndexPanel.tsx new file mode 100644 index 000000000..1dee1d885 --- /dev/null +++ b/application/v2_ui/src/components/admin/DocumentAccessIndexPanel.tsx @@ -0,0 +1,362 @@ +// DocumentAccessIndexPanel.tsx +// Status of the document access index (DAI) for the DAI Metrics card. +// +// The index replaces expensive cross-partition document access queries and maintains +// itself, so this is mostly a readout: is the projection on, is backfill done, are reads +// served from it or falling back to the source, and is the Redis list cache hitting. +// +// The manual controls -- run one backfill batch, reset the checkpoint -- and the shadow +// validation figures appear only in diagnostics mode, as on the server-rendered page, which +// shows them only while `enable_dai_debug` is set in the settings document. +// +// While a batch is running the status is re-read every few seconds, so the card shows the +// batch finishing instead of a stale "Running". + +import { useEffect, useRef, useState } from 'react'; +import { Gauge, PlayCircle, RotateCcw } from 'lucide-react'; +import { ApiError, api } from '../../lib/apiClient'; +import type { AdminField } from '../../lib/adminFields'; +import { + describeEnabled, + describeMaintenanceStatus, + formatCount, + formatMetric, + formatPercent, + formatTimestamp, + humanizeStatus, + type ToneText, +} from '../../lib/scaleFormat'; +import { + DAI_RUNNING_POLL_MS, + MAINTENANCE_RUN_PAYLOADS, + backfillStatusFromRun, + describeAlwaysOn, + describeBackfillRun, + describeDaiCache, + formatCacheEvent, + formatCountPair, + formatLatencyWindow, + formatMetricPair, + formatReadSample, + formatSavings, + formatScopeList, + formatShadowSamples, + isBackfillRunning, + metricWindow, + type AppMaintenanceRunResult, +} from '../../lib/scaleMaintenance'; +import { useFirstVisible } from '../../lib/useFirstVisible'; +import { toast } from '../../stores/toastStore'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { ConfirmActionModal } from './ConfirmActionModal'; +import { + OpsButton, + OpsHeading, + OpsMessage, + OpsNote, + OpsPanel, + OpsToolbar, + Readout, + ReadoutGrid, + ReadoutGroup, + ReadoutSkeleton, +} from './OperationalReadouts'; + +const RUN_PATH = '/api/admin/settings/app-maintenance/run'; + +export function DocumentAccessIndexPanel({ field, diagnostics }: { field: AdminField; diagnostics: boolean }) { + const containerRef = useRef(null); + const seen = useFirstVisible(containerRef); + const maintenance = useScaleStatusStore((state) => state.maintenance); + const loadMaintenance = useScaleStatusStore((state) => state.loadMaintenance); + const mergeMaintenance = useScaleStatusStore((state) => state.mergeMaintenance); + + const [running, setRunning] = useState<'batch' | 'reset' | null>(null); + const [confirmReset, setConfirmReset] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (seen) { + void loadMaintenance(); + } + }, [seen, loadMaintenance]); + + const dai = maintenance.data?.document_access_index_backfill; + const backfillRunning = isBackfillRunning(dai); + + useEffect(() => { + if (!backfillRunning) { + return; + } + const timer = window.setInterval(() => { + void loadMaintenance({ force: true }); + }, DAI_RUNNING_POLL_MS); + return () => window.clearInterval(timer); + }, [backfillRunning, loadMaintenance]); + + const runBatch = async (reset: boolean) => { + setRunning(reset ? 'reset' : 'batch'); + setMessage({ + text: reset + ? 'Resetting the backfill checkpoint and running one batch…' + : 'Running one document access backfill batch…', + tone: 'info', + }); + try { + const result = await api.post( + RUN_PATH, + reset ? MAINTENANCE_RUN_PAYLOADS.resetBackfill : MAINTENANCE_RUN_PAYLOADS.backfillBatch, + ); + const current = backfillStatusFromRun(result); + if (current) { + mergeMaintenance({ document_access_index_backfill: current }); + } + const outcome = describeBackfillRun(current); + setMessage(outcome); + if (outcome.tone === 'warn') { + toast.info(outcome.text); + } else { + toast.success(outcome.text); + } + } catch (error) { + const payload = error instanceof ApiError ? (error.payload as AppMaintenanceRunResult | null) : null; + const current = backfillStatusFromRun(payload); + if (current) { + mergeMaintenance({ document_access_index_backfill: current }); + } + const text = error instanceof Error ? error.message : 'Document access index backfill batch failed.'; + setMessage({ text, tone: 'danger' }); + toast.error(text); + } finally { + setRunning(null); + setConfirmReset(false); + } + }; + + const settings = dai?.settings ?? {}; + const state = dai?.state ?? {}; + const maintenanceState = dai?.maintenance ?? {}; + const shadow = dai?.shadow_validation ?? {}; + const reads = metricWindow(dai?.read_metrics); + const cache = metricWindow(dai?.cache_metrics); + const rolling5m = metricWindow(shadow.rolling_metrics, '5m'); + const rolling15m = metricWindow(shadow.rolling_metrics, '15m'); + const controlsDisabled = settings.container_enabled === false; + const loadError: ToneText | null = maintenance.error ? { text: maintenance.error, tone: 'danger' } : null; + + return ( +
+ + + void loadMaintenance({ force: true })} + refreshLabel="Refresh status" + > + {diagnostics ? ( + <> + void runBatch(false)} + > + Run one backfill batch + + setConfirmReset(true)} + > + Reset checkpoint + + + ) : null} + + + + {diagnostics ? ( + + Diagnostics are showing because enable_dai_debug is set in the settings document. + + ) : null} + + {!dai && maintenance.loading ? : null} + + {dai ? ( + <> + + + + + + + {diagnostics ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {diagnostics ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + ) : null} + + + Read and cache figures are counted in this app worker only. Application Insights keeps the + fleet-wide record of fallback warnings and query failures. + + + ) : null} + + + {confirmReset ? ( + void runBatch(true)} + onClose={() => setConfirmReset(false)} + > +

+ The next batch starts again from the first source scope, then one batch runs at the saved + size. The work is idempotent, but documents that already have projection rows may be + processed again. +

+

+ Source documents and projection rows are not deleted. Only backfill progress is reset. +

+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/OperationalReadouts.tsx b/application/v2_ui/src/components/admin/OperationalReadouts.tsx new file mode 100644 index 000000000..592712fc7 --- /dev/null +++ b/application/v2_ui/src/components/admin/OperationalReadouts.tsx @@ -0,0 +1,327 @@ +// OperationalReadouts.tsx +// The visual vocabulary every live Scale readout shares. +// +// Redis metrics, the document access index, the caches, Cosmos maintenance and Cosmos +// throughput are four different dashboards inside ordinary settings cards. Built ad hoc +// they would each pick their own tile, badge and button, and an administrator would have +// to relearn every card. These pieces keep them consistent with each other and with the +// settings around them: groups look like the field groups, buttons like the connection +// test, and messages like a connection test's outcome. +// +// Tiles flow on the scroll pane's container width rather than the viewport, so they +// reflow inside a narrow card, at large text sizes, and beside the page index alike. + +import { useEffect, useState, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { + AlertTriangle, + CheckCircle2, + ChevronRight, + Info, + Loader2, + RefreshCw, + XCircle, + type LucideIcon, +} from 'lucide-react'; +import { formatRelativeTime, type ReadoutTone, type ToneText } from '../../lib/scaleFormat'; +import { Skeleton } from '../ui/primitives'; + +const PILL_TONE: Record = { + ok: 'border-ok/40 bg-ok/5 text-ok', + info: 'border-info/40 bg-info/5 text-info', + warn: 'border-warn/40 bg-warn/5 text-warn', + danger: 'border-danger/40 bg-danger/5 text-danger', + neutral: 'border-edge-strong text-text-3', +}; + +const DOT_TONE: Record = { + ok: 'bg-ok', + info: 'bg-info', + warn: 'bg-warn', + danger: 'bg-danger', + neutral: 'bg-text-3', +}; + +const MESSAGE_TONE: Record = { + ok: { className: 'border-ok/40 bg-ok/5', Icon: CheckCircle2 }, + info: { className: 'border-info/40 bg-info/5', Icon: Info }, + warn: { className: 'border-warn/40 bg-warn/5', Icon: AlertTriangle }, + danger: { className: 'border-danger/40 bg-danger/5', Icon: XCircle }, + neutral: { className: 'border-edge bg-surface-1', Icon: Info }, +}; + +const ICON_TONE: Record = { + ok: 'text-ok', + info: 'text-info', + warn: 'text-warn', + danger: 'text-danger', + neutral: 'text-text-3', +}; + +/** A state such as "Healthy" or "Disabled". The words carry the meaning; colour repeats it. */ +export function StatePill({ state, title }: { state: ToneText; title?: string }) { + return ( + + + ); +} + +/** A grid of readouts. A description list, so each label is announced with its value. */ +export function ReadoutGrid({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +/** + * One labelled value. `state` draws a pill instead of plain text. + * + * Unframed on purpose: the readouts sit inside a group that already draws the frame, and + * a box around every value inside it reads as a wall of nested cards. + */ +export function Readout({ + label, + value, + state, + detail, + wide, + testId, +}: { + label: string; + value?: ReactNode; + state?: ToneText; + detail?: ReactNode; + /** Spans two columns, for values that read better on one line. */ + wide?: boolean; + testId?: string; +}) { + return ( +
+
{label}
+
+ {state ? : value} +
+ {detail ?
{detail}
: null} +
+ ); +} + +/** Placeholder rows while the first status loads, so the layout does not jump. */ +export function ReadoutSkeleton({ rows = 4 }: { rows?: number }) { + return ( + + ); +} + +/** A titled cluster of readouts that collapses, drawn like the settings field groups. */ +export function ReadoutGroup({ + title, + description, + defaultOpen = true, + children, +}: { + title: string; + description?: string; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open ? ( +
+ {description ? ( +

{description}

+ ) : null} + {children} +
+ ) : null} +
+ ); +} + +type OpsButtonTone = 'default' | 'primary' | 'danger'; + +const BUTTON_TONE: Record = { + default: 'border-edge text-text-1 hover:border-accent hover:bg-surface-2', + primary: 'border-accent/50 text-accent hover:bg-accent-soft', + danger: 'border-danger/50 text-danger hover:bg-danger/10', +}; + +/** A compact action button. Shows a spinner in place of its icon while busy. */ +export function OpsButton({ + icon: Icon, + tone = 'default', + busy = false, + children, + className, + disabled, + ...rest +}: ButtonHTMLAttributes & { + icon?: LucideIcon; + tone?: OpsButtonTone; + busy?: boolean; + children: ReactNode; +}) { + return ( + + ); +} + +/** + * The bar above a live readout: when it was read, Refresh, and the card's own actions. + * + * The age is re-rendered every half minute so "Updated 2 min ago" stays true while the + * page sits open. + */ +export function OpsToolbar({ + loadedAt, + loading, + onRefresh, + refreshLabel = 'Refresh', + label, + children, +}: { + loadedAt: number | null; + loading: boolean; + onRefresh: () => void; + refreshLabel?: string; + /** Names the toolbar for assistive technology, e.g. "Redis monitoring actions". */ + label: string; + children?: ReactNode; +}) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 30000); + return () => window.clearInterval(timer); + }, []); + + return ( +
+ + {loading ? 'Loading…' : formatRelativeTime(loadedAt, now)} + +
+ + {refreshLabel} + + {children} +
+
+ ); +} + +/** The outcome of the last load or action, announced politely. */ +export function OpsMessage({ message, children }: { message: ToneText | null; children?: ReactNode }) { + if (!message) { + return null; + } + const { className, Icon } = MESSAGE_TONE[message.tone]; + return ( +
+
+
+ {children} +
+ ); +} + +/** Standing context beneath a readout, such as where counters come from. */ +export function OpsNote({ icon: Icon = Info, children }: { icon?: LucideIcon; children: ReactNode }) { + return ( +

+

+ ); +} + +/** A readout with nothing to show yet, and what would change that. */ +export function OpsEmptyState({ title, children }: { title: string; children?: ReactNode }) { + return ( +
+

{title}

+ {children ? ( +
{children}
+ ) : null} +
+ ); +} + +/** The heading and help a component field carries, laid out like a field's own. */ +export function OpsHeading({ label, help }: { label: string; help?: string }) { + return ( +
+

{label}

+ {help ?

{help}

: null} +
+ ); +} + +/** The live region every Scale component sits in, spaced like a field row. */ +export function OpsPanel({ children, testId }: { children: ReactNode; testId?: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/RedisExplorer.tsx b/application/v2_ui/src/components/admin/RedisExplorer.tsx new file mode 100644 index 000000000..6ea5e9c47 --- /dev/null +++ b/application/v2_ui/src/components/admin/RedisExplorer.tsx @@ -0,0 +1,324 @@ +// RedisExplorer.tsx +// A read-only browser for Redis keys, inline in the Redis Metrics card. +// +// It answers "what is actually in the cache" while troubleshooting: page through keys with +// Redis SCAN, filter by substring, and open one to see its type, TTL, memory and a +// sanitized preview. SimpleChat recognises its own keys -- the shared settings record, DAI +// scope markers and payloads -- and says what each one is, which a bare hashed key name +// never would. +// +// Previews are sanitized and size-limited by the server, and restricted outright for +// session, token, cookie, credential, password and secret-like keys. Nothing here writes to +// Redis. It loads when opened, not with the page. + +import { useCallback, useEffect, useState } from 'react'; +import { clsx } from 'clsx'; +import { ChevronLeft, ChevronRight, Search, ShieldAlert } from 'lucide-react'; +import { ApiError, api } from '../../lib/apiClient'; +import { inputClass } from './fields'; +import { OpsButton, OpsEmptyState, OpsMessage, OpsNote, Readout, ReadoutGrid } from './OperationalReadouts'; +import { formatBytes, formatCount, formatTtl, humanizeStatus, type ToneText } from '../../lib/scaleFormat'; +import { + REDIS_EXPLORER_PAGE_SIZES, + describePreviewSanitization, + describeResolutionEntity, + describeResolutionLabel, + readRedisExplorerError, + type RedisExplorerDetailField, + type RedisExplorerKey, + type RedisExplorerKeysResponse, + type RedisExplorerValueResponse, +} from '../../lib/scaleRedis'; + +const KEYS_PATH = '/api/admin/settings/redis-explorer/keys'; +const VALUE_PATH = '/api/admin/settings/redis-explorer/value'; + +const KEYS_FAILED = 'Failed to load Redis keys.'; +const PREVIEW_FAILED = 'Failed to load the key preview.'; +const KEYS_DETAIL_FIELDS: readonly RedisExplorerDetailField[] = ['last_error']; +// The classic page reads `preview` only; `last_error` follows it so an unreachable Redis is +// named rather than reported with the generic line. +const PREVIEW_DETAIL_FIELDS: readonly RedisExplorerDetailField[] = ['preview', 'last_error']; + +function explorerError( + error: unknown, + detailFields: readonly RedisExplorerDetailField[], + fallback: string, +): string { + if (error instanceof ApiError) { + return readRedisExplorerError(error.payload, detailFields, fallback); + } + return error instanceof Error ? error.message || fallback : fallback; +} + +export function RedisExplorer() { + const [filterText, setFilterText] = useState(''); + const [appliedFilter, setAppliedFilter] = useState(''); + const [pageSize, setPageSize] = useState(25); + const [cursor, setCursor] = useState('0'); + const [history, setHistory] = useState([]); + const [page, setPage] = useState<{ keys: RedisExplorerKey[]; nextCursor: string; hasMore: boolean } | null>(null); + const [loadingKeys, setLoadingKeys] = useState(false); + const [message, setMessage] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); + const [preview, setPreview] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + + const loadKeys = useCallback( + async (request: { cursor: string; filter: string; pageSize: number }) => { + setLoadingKeys(true); + setMessage(null); + setSelectedKey(null); + setPreview(null); + try { + const query = new URLSearchParams({ + cursor: request.cursor, + page_size: String(request.pageSize), + filter: request.filter, + }); + const response = await api.get(`${KEYS_PATH}?${query.toString()}`); + if (response.success === false) { + throw new Error(readRedisExplorerError(response, KEYS_DETAIL_FIELDS, KEYS_FAILED)); + } + setPage({ + keys: response.keys ?? [], + nextCursor: String(response.next_cursor ?? '0'), + hasMore: Boolean(response.has_more), + }); + } catch (error) { + setPage({ keys: [], nextCursor: '0', hasMore: false }); + setMessage({ text: explorerError(error, KEYS_DETAIL_FIELDS, KEYS_FAILED), tone: 'danger' }); + } finally { + setLoadingKeys(false); + } + }, + [], + ); + + // Opening the explorer is the request to browse; there is nothing to show until then. + useEffect(() => { + void loadKeys({ cursor: '0', filter: '', pageSize: 25 }); + }, [loadKeys]); + + const startOver = (filter: string, size = pageSize) => { + setAppliedFilter(filter); + setCursor('0'); + setHistory([]); + void loadKeys({ cursor: '0', filter, pageSize: size }); + }; + + const goNext = () => { + if (!page?.hasMore) { + return; + } + setHistory((previous) => [...previous, cursor]); + setCursor(page.nextCursor); + void loadKeys({ cursor: page.nextCursor, filter: appliedFilter, pageSize }); + }; + + const goPrevious = () => { + const previousCursor = history[history.length - 1] ?? '0'; + setHistory((previous) => previous.slice(0, -1)); + setCursor(previousCursor); + void loadKeys({ cursor: previousCursor, filter: appliedFilter, pageSize }); + }; + + const openKey = async (key: string) => { + setSelectedKey(key); + setLoadingPreview(true); + setMessage(null); + try { + const value = await api.post(VALUE_PATH, { key }); + if (value.success === false) { + throw new Error(readRedisExplorerError(value, PREVIEW_DETAIL_FIELDS, PREVIEW_FAILED)); + } + setPreview(value); + if (value.truncated) { + setMessage({ text: 'The preview is truncated to the safe display limit.', tone: 'warn' }); + } + } catch (error) { + setPreview(null); + setMessage({ text: explorerError(error, PREVIEW_DETAIL_FIELDS, PREVIEW_FAILED), tone: 'danger' }); + } finally { + setLoadingPreview(false); + } + }; + + const scope = appliedFilter ? `Filter “${appliedFilter}”` : 'All keys'; + const pageNumber = history.length + 1; + + return ( +
+ + Read-only. Previews are sanitized, and restricted for session, token, cookie, + credential, password and secret-like keys. Use it to troubleshoot cache behavior, not + to export data. + + +
{ + event.preventDefault(); + startOver(filterText.trim()); + }} + > +
+ + setFilterText(event.target.value)} + placeholder="Substring, case sensitive, e.g. APP_SETTINGS_STATE_V2" + autoComplete="off" + spellCheck={false} + className={clsx(inputClass, 'font-mono text-xs')} + /> +
+
+ + +
+ + Apply filter + + { + setFilterText(''); + startOver(''); + }} + > + Browse all + +
+ + + +
+
+
+ + {scope} · Page {pageNumber} + + + {page ? `${formatCount(page.keys.length)} key${page.keys.length === 1 ? '' : 's'}${page.hasMore ? ' · more' : ''}` : ''} + +
+
    + {page && !page.keys.length && !loadingKeys ? ( +
  • + No keys on this page{appliedFilter ? ' match the filter' : ''}. SCAN order is decided + by Redis, so the next page may still have matches. +
  • + ) : null} + {(page?.keys ?? []).map((item) => { + const selected = item.key === selectedKey; + const resolution = describeResolutionLabel(item.resolution); + return ( +
  • + +
  • + ); + })} +
+
+ + Previous + + + Next + +
+
+ +
+ {!selectedKey ? ( + + Its type, TTL, memory and a sanitized preview appear here. + + ) : loadingPreview ? ( +

Loading the preview…

+ ) : preview ? ( + <> +

{preview.key || selectedKey}

+ + + + + + + {preview.resolution ? ( +
+

+ {preview.resolution.label || humanizeStatus(preview.resolution.kind)} +

+

{describeResolutionEntity(preview.resolution)}

+ {preview.resolution.scope_key || preview.resolution.cache_hash || preview.resolution.scope_hash ? ( +

+ {preview.resolution.scope_key || + preview.resolution.cache_hash || + preview.resolution.scope_hash} +

+ ) : null} + {preview.resolution.note ? ( +

{preview.resolution.note}

+ ) : null} +
+ ) : null} +
+

Sanitized preview

+
+                                    {preview.preview || 'No preview available.'}
+                                
+
+ + ) : null} +
+
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/RedisMonitoringPanel.tsx b/application/v2_ui/src/components/admin/RedisMonitoringPanel.tsx new file mode 100644 index 000000000..bb3e82d87 --- /dev/null +++ b/application/v2_ui/src/components/admin/RedisMonitoringPanel.tsx @@ -0,0 +1,220 @@ +// RedisMonitoringPanel.tsx +// Live Redis health and capacity for the Redis Metrics card. +// +// Reads `/api/admin/settings/redis-monitoring/status`, which pings the Redis connection the +// running application actually uses and reads its INFO metrics. It is fetched when the card +// first scrolls into view rather than with the page, refreshed on request, and refreshed +// again after a successful connection test, as the server-rendered page does. +// +// The readouts are grouped by the question they answer -- is it reachable, is it running +// out of room, is the cache earning its keep, what is in it -- rather than listed as the +// INFO command returns them. The Redis Explorer sits beneath them, closed until needed. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../../lib/apiClient'; +import type { AdminField } from '../../lib/adminFields'; +import { + formatCount, + formatMetric, + formatPercent, + formatTimestamp, + formatTtl, + humanizeStatus, + type ToneText, +} from '../../lib/scaleFormat'; +import { + describeRedisConfiguration, + describeRedisHealth, + describeRedisPort, + describeRedisRefresh, + describeRedisRuntime, + formatRedisMemoryUsage, + formatRedisServiceType, + type RedisMonitoringStatus, +} from '../../lib/scaleRedis'; +import { useFirstVisible } from '../../lib/useFirstVisible'; +import { useScaleStatusStore } from '../../stores/scaleStatusStore'; +import { + OpsEmptyState, + OpsHeading, + OpsMessage, + OpsNote, + OpsPanel, + OpsToolbar, + Readout, + ReadoutGrid, + ReadoutGroup, + ReadoutSkeleton, +} from './OperationalReadouts'; +import { RedisExplorer } from './RedisExplorer'; + +const STATUS_PATH = '/api/admin/settings/redis-monitoring/status'; + +export function RedisMonitoringPanel({ field, redisEnabled }: { field: AdminField; redisEnabled: boolean }) { + const containerRef = useRef(null); + const seen = useFirstVisible(containerRef, redisEnabled); + const refreshRevision = useScaleStatusStore((state) => state.redisRefreshRevision); + + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [loadedAt, setLoadedAt] = useState(null); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const next = await api.get(STATUS_PATH); + setStatus(next); + setLoadedAt(Date.now()); + // A healthy reading needs no sentence; the timestamp already says it worked. + const outcome = describeRedisRefresh(next); + setMessage(outcome.tone === 'warn' || outcome.tone === 'danger' ? outcome : null); + } catch (error) { + setMessage({ + text: error instanceof Error ? error.message : 'Failed to load Redis monitoring status.', + tone: 'danger', + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (seen && redisEnabled) { + void load(); + } + }, [seen, redisEnabled, load]); + + // A connection test that succeeded is a reason to look again, but only once there is a + // reading on screen to update. + const lastRevision = useRef(refreshRevision); + useEffect(() => { + if (refreshRevision !== lastRevision.current) { + lastRevision.current = refreshRevision; + if (loadedAt !== null) { + void load(); + } + } + }, [refreshRevision, loadedAt, load]); + + const health = status?.health ?? {}; + const memory = status?.memory ?? {}; + const stats = status?.stats ?? {}; + const dai = status?.dai_cache ?? {}; + const lastError: ToneText | null = health.last_error ? { text: health.last_error, tone: 'danger' } : null; + + return ( +
+ + + + {!redisEnabled ? ( + + Redis Cache is off for the running application. Metrics appear here once it is + enabled and saved, and every worker has restarted. + + ) : ( + <> + void load()} + refreshLabel="Refresh status" + /> + + + {!status && loading ? : null} + + {status ? ( + <> + + + + + + + + + + + + + + + + {lastError ? : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Azure Managed Redis runs the Redis Enterprise engine, which leaves out some INFO + fields, so they read Not available. That is expected; Health and ping latency + show whether the connection works. + + + ) : null} + + + + + + )} + +
+ ); +} diff --git a/application/v2_ui/src/components/admin/SettingsSection.tsx b/application/v2_ui/src/components/admin/SettingsSection.tsx index aee198871..5439a931b 100644 --- a/application/v2_ui/src/components/admin/SettingsSection.tsx +++ b/application/v2_ui/src/components/admin/SettingsSection.tsx @@ -24,11 +24,12 @@ // // A prerequisite owned by another section is stated where it is felt. Previously an // administrator could turn File Sync on and have nothing happen, because Redis Cache was -// off two groups away and nothing said so until a flash message after saving. +// off two groups away and nothing said so until a flash message after saving. The section +// it points at says what uses it in turn, and both links move within this page. import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { clsx } from 'clsx'; -import { AlertTriangle, ChevronRight, type LucideIcon } from 'lucide-react'; +import { AlertTriangle, ChevronRight, Link2, type LucideIcon } from 'lucide-react'; import { asBoolean, groupFields, @@ -45,6 +46,7 @@ import { readSectionValue, shouldGroupStartOpen, type FieldEmphasis, + type SectionDependent, type SectionStatus, } from '../../lib/adminSections'; import { GlassPanel } from '../ui/primitives'; @@ -106,21 +108,39 @@ export interface SettingsSectionProps { forceExpanded?: boolean; /** Opt-in presentation overrides; never changes the schema's behavior. */ appearance?: SettingsSectionAppearance; + /** + * Server-resolved flags fields may depend on. The page already dropped fields whose + * flags are unmet; the card needs them too, or it would drop the rest again. + */ + runtimeFlags?: Record; + /** + * Moves the page to another section, for prerequisite links and "Used by". Absent, a + * prerequisite links to the classic page instead. + */ + onNavigate?: (sectionId: string) => void; + /** Whether a section is drawn on this page, so a link can stay on it. */ + isSectionAvailable?: (sectionId: string) => boolean; + /** Sections whose settings rely on this one. */ + dependents?: SectionDependent[]; children?: ReactNode; } function RequirementNotice({ requirement, satisfied, + onNavigate, }: { requirement: NonNullable; satisfied: boolean; + /** Present when the target section is on this page, so the link can stay in it. */ + onNavigate?: (sectionId: string) => void; }) { if (satisfied) { return null; } const blocking = (requirement.mode ?? 'block') === 'block'; + const target = requirement.target_section; return (
{requirement.description}

) : null} - {requirement.target_section ? ( + {target && onNavigate ? ( + + ) : target ? ( Configure {requirement.label} @@ -158,6 +186,44 @@ function RequirementNotice({ ); } +/** + * The sections that rely on this one, each a link to it. + * + * Redis Cache is switched on for File Sync, the conversation cache and the document list + * cache, none of which says so from here otherwise. Read from the same `requires` + * declarations that put the prerequisite notice on those sections. + */ +function DependentsLine({ + dependents, + onNavigate, +}: { + dependents: SectionDependent[]; + onNavigate?: (sectionId: string) => void; +}) { + return ( +

+

+ ); +} + /** * Lay out a run of fields, flowing consecutive independent switches into a grid. * @@ -287,6 +353,10 @@ export function SettingsSection({ renderCapability, forceExpanded, appearance, + runtimeFlags, + onNavigate, + isSectionAvailable, + dependents, children, }: SettingsSectionProps) { const capability = useMemo(() => findCapabilityField(fields), [fields]); @@ -297,8 +367,8 @@ export function SettingsSection({ ); const derivedStatus = useMemo( - () => computeSectionStatus(fields, settings, draft, statusRule), - [statusRule, fields, settings, draft], + () => computeSectionStatus(fields, settings, draft, statusRule, runtimeFlags), + [statusRule, fields, settings, draft, runtimeFlags], ); const status = statusProp ?? derivedStatus; @@ -313,13 +383,22 @@ export function SettingsSection({ // A section states each distinct prerequisite once, at the top, rather than repeating // it on every field that carries it. - const requirements = useMemo(() => collectRequirements(fields, settings, draft), [fields, settings, draft]); + const requirements = useMemo( + () => collectRequirements(fields, settings, draft, runtimeFlags), + [fields, settings, draft, runtimeFlags], + ); const groups = useMemo( - () => groupFields(bodyFields.filter((field) => isFieldVisible(field, settings, draft))), - [bodyFields, settings, draft], + () => + groupFields( + bodyFields.filter((field) => isFieldVisible(field, settings, draft, undefined, runtimeFlags)), + ), + [bodyFields, settings, draft, runtimeFlags], ); + const navigateWithin = (sectionId: string | undefined) => + Boolean(sectionId && onNavigate && (isSectionAvailable?.(sectionId) ?? true)); + const presentation = presentSectionStatus(status); const SectionIcon = appearance?.Icon ?? icon ?? FALLBACK_SECTION_ICON; @@ -428,11 +507,19 @@ export function SettingsSection({
+ {dependents?.length ? ( + + ) : null} + {requirements.map((requirement) => ( ))} diff --git a/application/v2_ui/src/components/admin/fields.tsx b/application/v2_ui/src/components/admin/fields.tsx index 55bc64924..88ebcffca 100644 --- a/application/v2_ui/src/components/admin/fields.tsx +++ b/application/v2_ui/src/components/admin/fields.tsx @@ -519,19 +519,25 @@ function RangeControl({ field, value, error, warning, disabled, onChange }: Fiel function NumberControl({ field, value, error, warning, disabled, onChange }: FieldControlProps) { const id = `admin-field-${field.key}`; + const unit = field.suffix?.trim(); return ( - onChange(Number(event.target.value))} - /> +
+ onChange(Number(event.target.value))} + /> + {/* The unit belongs beside the number: 900 reads very differently as + seconds, minutes or RU/s. */} + {unit ? {unit} : null} +
); } diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts index 308b9bc5b..110760964 100644 --- a/application/v2_ui/src/lib/adminFields.ts +++ b/application/v2_ui/src/lib/adminFields.ts @@ -84,6 +84,18 @@ export interface AdminFieldRequirement { description?: string; /** Section id to link to, so the prerequisite can be configured in full. */ target_section?: string; + /** + * Limits the prerequisite to one configuration. Redis needs Key Vault only while its + * access key is read from a vault, so the requirement carries that condition. + */ + when?: AdminFieldDependency; +} + +/** A label and help that replace the declared ones while a condition holds. */ +export interface AdminFieldLabelVariant { + when: AdminFieldDependency; + label?: string; + help?: string; } /** @@ -217,6 +229,8 @@ export interface AdminField { depends_on?: AdminFieldDependency; /** A prerequisite owned by another section. */ requires?: AdminFieldRequirement; + /** Label and help that follow another setting. See `resolveFieldPresentation`. */ + label_variants?: AdminFieldLabelVariant[]; /** `status` fields only: which server-computed readout to show. */ status_source?: string; /** @@ -311,7 +325,9 @@ export interface AdminSettingsResponse { * Server-resolved flags a navigation section may be conditional on. * * `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`. `dai_debug_enabled` + * mirrors `enable_dai_debug`, which has no control in either admin interface and + * reveals the DAI diagnostics. */ runtime_flags?: Record; /** @@ -821,11 +837,39 @@ export function fieldSearchText(field: AdminField): string { field.help ?? '', field.notice ?? '', field.component ?? '', + // A label that changes with the configuration is still the same setting, and an + // administrator may search for either name. + ...(field.label_variants ?? []).flatMap((variant) => [variant.label ?? '', variant.help ?? '']), ] .join(' ') .toLowerCase(); } +/** + * The field as it should read right now, with any `label_variants` applied. + * + * `redis_key` holds the Redis access key, or the name of the Key Vault secret holding it, + * depending on the authentication type. The first variant whose condition holds replaces + * the label and help; with none matching, the field is returned as declared. + */ +export function resolveFieldPresentation( + field: AdminField, + read: (key: string) => unknown, + runtimeFlags?: Record, +): AdminField { + const variant = (field.label_variants ?? []).find((candidate) => + evaluateDependency(candidate.when, read, runtimeFlags), + ); + if (!variant) { + return field; + } + return { + ...field, + label: variant.label ?? field.label, + help: variant.help ?? field.help, + }; +} + /** A run of fields sharing a `group`, or a single ungrouped field. */ export type SectionBlock = | { kind: 'field'; field: AdminField } diff --git a/application/v2_ui/src/lib/adminSections.ts b/application/v2_ui/src/lib/adminSections.ts index b79baa42e..7a6aecf92 100644 --- a/application/v2_ui/src/lib/adminSections.ts +++ b/application/v2_ui/src/lib/adminSections.ts @@ -67,23 +67,74 @@ export function findCapabilityField(fields: AdminField[]): AdminField | undefine * Each distinct cross-section prerequisite the section depends on. * * Deduplicated by key, because a prerequisite usually applies to several fields and - * stating it once at the top reads better than repeating it on each. + * stating it once at the top reads better than repeating it on each. A requirement with a + * `when` applies only while that condition holds, and a field gated on a runtime flag is + * judged with the flags the server sent, the same way the page decided to show it. */ -export function collectRequirements(fields: AdminField[], settings?: Json, draft: Json = {}): AdminFieldRequirement[] { +export function collectRequirements( + fields: AdminField[], + settings?: Json, + draft: Json = {}, + runtimeFlags?: Record, +): AdminFieldRequirement[] { const seen = new Map(); for (const field of fields) { - if (settings && field.role !== 'capability') { + if (!field.requires) { + continue; + } + if (settings) { const read = (key: string) => readSectionValue(settings, draft, key); - if (!evaluateDependency(field.depends_on, read)) continue; - if (field.type === 'switch' && field.key && !asBoolean(read(field.key) ?? field.default)) continue; + if (field.role !== 'capability') { + if (!evaluateDependency(field.depends_on, read, runtimeFlags)) continue; + if (field.type === 'switch' && field.key && !asBoolean(read(field.key) ?? field.default)) continue; + } + if (field.requires.when && !evaluateDependency(field.requires.when, read, runtimeFlags)) continue; } - if (field.requires && !seen.has(field.requires.key)) { + if (!seen.has(field.requires.key)) { seen.set(field.requires.key, field.requires); } } return [...seen.values()]; } +/** A section whose settings rely on another, for the target's "Used by" line. */ +export interface SectionDependent { + sectionId: string; + label: string; +} + +/** + * For each section another one points at through `requires.target_section`, the sections + * that rely on it. + * + * Read through `collectRequirements`, so "Used by" on Redis Cache lists exactly the + * sections showing a Redis prerequisite notice -- a field hidden behind a runtime flag or + * a switched-off sub-feature adds no entry, just as it adds no notice. + */ +export function buildSectionDependents( + sections: { sectionId: string; label: string; fields: AdminField[] }[], + settings: Json, + draft: Json, + runtimeFlags?: Record, +): Map { + const dependents = new Map(); + for (const section of sections) { + const targets = new Set(); + for (const requirement of collectRequirements(section.fields, settings, draft, runtimeFlags)) { + const target = requirement.target_section; + if (target && target !== section.sectionId) { + targets.add(target); + } + } + for (const target of targets) { + const list = dependents.get(target) ?? []; + list.push({ sectionId: section.sectionId, label: section.label }); + dependents.set(target, list); + } + } + return dependents; +} + /** * Summarise a section as a single status. * @@ -96,11 +147,12 @@ export function deriveSectionStatus( fields: AdminField[], settings: Json, draft: Json, + runtimeFlags?: Record, ): SectionStatus { const read = (key: string) => readSectionValue(settings, draft, key); const capability = findCapabilityField(fields); - const unmet = collectRequirements(fields, settings, draft).some( + const unmet = collectRequirements(fields, settings, draft, runtimeFlags).some( (requirement) => !asBoolean(read(requirement.key)), ); if (unmet) { @@ -115,7 +167,7 @@ export function deriveSectionStatus( // field belongs to a branch that is not in use -- the APIM endpoint while direct // access is selected -- and demanding a value for it would be permanently unmeetable. const required = fields.filter( - (field) => field.required && field.key && evaluateDependency(field.depends_on, read), + (field) => field.required && field.key && evaluateDependency(field.depends_on, read, runtimeFlags), ); if (!required.length) { @@ -146,12 +198,13 @@ export function computeSectionStatus( settings: Json, draft: Json, statusRule?: AdminSectionStatusRule, + runtimeFlags?: Record, ): SectionStatus { const declared = evaluateSectionStatus(statusRule, settings, draft); if (declared) { return DECLARED_STATUS_MAP[declared]; } - return deriveSectionStatus(fields, settings, draft); + return deriveSectionStatus(fields, settings, draft, runtimeFlags); } /** How a field sits relative to the switch that governs it. */ diff --git a/application/v2_ui/src/lib/cosmosThroughput.ts b/application/v2_ui/src/lib/cosmosThroughput.ts new file mode 100644 index 000000000..8b5c058dd --- /dev/null +++ b/application/v2_ui/src/lib/cosmosThroughput.ts @@ -0,0 +1,868 @@ +// cosmosThroughput.ts +// Cosmos DB throughput: status, container policies, and the rules the server enforces. +// +// The Scale group reads `/api/admin/settings/cosmos-throughput/*`, the same endpoints the +// server-rendered page uses. Most of this module decides what to show from what those +// return -- which actions are allowed, what a container's utilization is when Azure +// Monitor sent no percentage, which policy actually governs a container. +// +// Two pieces mirror `functions_cosmos_throughput.py` so the page can explain itself before +// anything is sent: the policy rules a save must pass, and the target a manual scale will +// land on. The server stays authoritative for both; `test_v2_admin_scale_logic.ts` runs the +// same cases the Python tests do, so a change on one side fails until the other follows. + +import { formatCount, formatRu, toNumber, type ToneText } from './scaleFormat'; + +export const COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU = 10000; +export const COSMOS_THROUGHPUT_AUTOSCALE_MIN_RU = 1000; +export const COSMOS_THROUGHPUT_MANUAL_MIN_RU = 400; +export const COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE = + 'Throughput above 10,000 RU/s is monitored only in SimpleChat. Use the Azure portal to change capacity; ' + + 'capacity changes above this level can take 4 to 6 hours.'; + +/** Mirrors `COSMOS_THROUGHPUT_SETTING_KEYS`: what access validation sends from the draft. */ +export const COSMOS_THROUGHPUT_SETTING_KEYS = [ + 'cosmos_throughput_autoscale_enabled', + 'cosmos_throughput_auto_scale_up_enabled', + 'cosmos_throughput_auto_scale_down_enabled', + 'cosmos_throughput_subscription_id', + 'cosmos_throughput_resource_group', + 'cosmos_throughput_account_name', + 'cosmos_throughput_database_name', + 'cosmos_throughput_metrics_window_minutes', + 'cosmos_throughput_scale_up_threshold_percent', + 'cosmos_throughput_scale_down_threshold_percent', + 'cosmos_throughput_scale_up_step_ru', + 'cosmos_throughput_scale_down_step_ru', + 'cosmos_throughput_scale_up_cooldown_minutes', + 'cosmos_throughput_scale_down_cooldown_minutes', + 'cosmos_throughput_min_ru', + 'cosmos_throughput_max_ru', + 'cosmos_throughput_ignore_min_limit', + 'cosmos_throughput_ignore_max_limit', + 'cosmos_throughput_convert_manual_to_autoscale_enabled', + 'cosmos_throughput_enforce_container_defaults', + 'cosmos_throughput_container_policies', +] as const; + +export const CONTAINER_POLICIES_KEY = 'cosmos_throughput_container_policies'; + +/** Written by the automation, never by an administrator; the server keeps its own copy. */ +export const COSMOS_POLICY_RUNTIME_FIELDS = [ + 'last_scale_up_at', + 'last_scale_down_at', + 'last_mode_conversion_at', +] as const; + +export interface CosmosThroughputTarget { + scope?: string; + mode?: string | null; + current_ru?: number | null; + is_scalable?: boolean; + throughput_not_found?: boolean; + message?: string; + simplechat_scaling_supported?: boolean; + portal_managed_scaling_required?: boolean; + portal_managed_message?: string; +} + +export interface CosmosContainerPolicy { + container_name?: string; + enabled?: boolean; + auto_scale_up_enabled?: boolean; + auto_scale_down_enabled?: boolean; + scale_up_threshold_percent?: number; + scale_down_threshold_percent?: number; + scale_up_step_ru?: number; + scale_down_step_ru?: number; + scale_up_cooldown_minutes?: number; + scale_down_cooldown_minutes?: number; + min_ru?: number; + max_ru?: number; + ignore_min_limit?: boolean; + ignore_max_limit?: boolean; + convert_manual_to_autoscale_enabled?: boolean; + last_scale_up_at?: string | null; + last_scale_down_at?: string | null; + last_mode_conversion_at?: string | null; +} + +export type CosmosContainerPolicies = Record; + +export interface CosmosContainerStatus extends CosmosThroughputTarget { + container_name: string; + database_name?: string; + normalized_ru_percent?: number | null; + request_units?: number | null; + error?: string; + has_normalized_ru_metric?: boolean; + has_request_units_metric?: boolean; + policy?: CosmosContainerPolicy; +} + +export interface CosmosThroughputStatus { + configured?: boolean; + error?: string; + resource?: { + subscription_id?: string; + resource_group?: string; + account_name?: string; + database_name?: string; + }; + throughput?: CosmosThroughputTarget; + capacity_scope?: string | null; + metrics?: { + window_minutes?: number | null; + normalized_ru_percent?: number | null; + total_request_units?: number | null; + }; + containers?: CosmosContainerStatus[]; + throughput_error?: string; + metric_error?: string; + container_error?: string; + last_checked_at?: string | null; + is_cached?: boolean; + cached_at?: string | null; +} + +export interface CosmosAccessCheck { + name?: string; + label?: string; + passed?: boolean; + message?: string; +} + +export interface CosmosAccessValidation { + success?: boolean; + variant?: string; + message?: string; + checks?: CosmosAccessCheck[]; + status?: CosmosThroughputStatus; +} + +export interface CosmosScaleResult { + success?: boolean; + scope?: string; + container_name?: string; + from_ru?: number | null; + to_ru?: number | null; + mode?: string; +} + +/** Above 10,000 RU/s SimpleChat only monitors; the Azure portal owns capacity changes. */ +export function isPortalManaged(target: CosmosThroughputTarget | null | undefined): boolean { + const current = toNumber(target?.current_ru); + return Boolean(target?.portal_managed_scaling_required) || ( + Boolean(target?.is_scalable) && current !== null && current > COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU + ); +} + +export function isScaleUpBlockedBySimpleChatLimit(target: CosmosThroughputTarget | null | undefined): boolean { + const current = toNumber(target?.current_ru); + return Boolean(target?.is_scalable) && current !== null && current >= COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU; +} + +export function portalManagedMessage(target: CosmosThroughputTarget | null | undefined): string { + return target?.portal_managed_message || COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE; +} + +/** Whether a saved snapshot holds anything worth showing before the first live refresh. */ +export function hasStatusData(status: CosmosThroughputStatus | null | undefined): boolean { + if (!status || typeof status !== 'object' || Array.isArray(status)) { + return false; + } + return Boolean(status.last_checked_at || status.capacity_scope || (status.containers ?? []).length); +} + +function hasContainerLevelMetrics(status: CosmosThroughputStatus): boolean { + return (status.containers ?? []).some( + (container) => + (container.normalized_ru_percent !== null && container.normalized_ru_percent !== undefined) || + (container.request_units !== null && container.request_units !== undefined), + ); +} + +function hasPortalManagedThroughput(status: CosmosThroughputStatus): boolean { + return isPortalManaged(status.throughput) || (status.containers ?? []).some((container) => isPortalManaged(container)); +} + +/** What to say about a freshly loaded status, in the classic page's order of precedence. */ +export function describeCosmosThroughputStatus(status: CosmosThroughputStatus): ToneText { + if (!status.configured) { + return { + text: status.error || 'Cosmos throughput management needs subscription, resource group, account, and database settings.', + tone: 'warn', + }; + } + if (status.throughput_error) { + return { text: `Cosmos database throughput could not be read. ${status.throughput_error}`, tone: 'danger' }; + } + const aggregate = status.metrics?.normalized_ru_percent; + if ( + status.capacity_scope === 'container' && + aggregate !== null && + aggregate !== undefined && + !hasContainerLevelMetrics(status) + ) { + return { + text: + 'Azure Monitor returned aggregate RU utilization, but not per-container metric dimensions for this window. ' + + 'Container autoscale waits for per-container utilization before scaling individual containers; refresh again after a few minutes.', + tone: 'warn', + }; + } + if (hasPortalManagedThroughput(status)) { + return { + text: + 'One or more Cosmos throughput targets are above 10,000 RU/s. SimpleChat monitors their utilization and request units only; ' + + 'use the Azure portal for capacity changes, which can take 4 to 6 hours.', + tone: 'warn', + }; + } + if (status.capacity_scope === 'container') { + return { + text: + 'Container-targeted throughput is active. Dedicated-throughput containers can be monitored and scaled individually; ' + + 'containers sharing database throughput stay view-only.', + tone: 'info', + }; + } + if (status.metric_error) { + return { + text: 'Throughput loaded, but Azure Monitor metrics are unavailable. Check the app identity permissions and Cosmos metrics availability.', + tone: 'warn', + }; + } + return { text: 'Cosmos throughput status loaded.', tone: 'ok' }; +} + +/** + * A container's RU utilization over the metrics window. + * + * Azure Monitor's normalized percentage is preferred. Without it the average is estimated + * from request units over the window against current RU/s, and marked as an estimate. + */ +export function containerUtilization( + container: CosmosContainerStatus, + windowMinutes: number | null | undefined, +): { value: number | null; estimated: boolean } { + const normalized = toNumber(container.normalized_ru_percent); + if (normalized !== null) { + return { value: normalized, estimated: false }; + } + const requestUnits = toNumber(container.request_units); + const currentRu = toNumber(container.current_ru); + const minutes = Number(windowMinutes || 0); + if (requestUnits !== null && currentRu !== null && currentRu > 0 && minutes > 0) { + return { value: (requestUnits / (minutes * 60) / currentRu) * 100, estimated: true }; + } + return { value: null, estimated: false }; +} + +export function formatContainerUtilization( + container: CosmosContainerStatus, + windowMinutes: number | null | undefined, +): string { + const { value, estimated } = containerUtilization(container, windowMinutes); + return value === null ? 'Not available' : `${value.toFixed(1)}%${estimated ? ' est.' : ''}`; +} + +/** The global policy, read from the settings an administrator edits. */ +export interface CosmosGlobalPolicy { + autoscale_enabled: boolean; + enforce_container_defaults: boolean; + metrics_window_minutes: number; + auto_scale_up_enabled: boolean; + auto_scale_down_enabled: boolean; + scale_up_threshold_percent: number; + scale_down_threshold_percent: number; + scale_up_step_ru: number; + scale_down_step_ru: number; + scale_up_cooldown_minutes: number; + scale_down_cooldown_minutes: number; + min_ru: number; + max_ru: number; + ignore_min_limit: boolean; + ignore_max_limit: boolean; + convert_manual_to_autoscale_enabled: boolean; +} + +/** Mirrors `_coerce_bool` in `functions_cosmos_throughput.py`. */ +export function coerceBool(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') { + return value; + } + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); + } + return Boolean(value); +} + +/** Mirrors `_coerce_int`: integers only, falling back rather than guessing, then clamped. */ +export function coerceInt(value: unknown, fallback: number, minimum?: number, maximum?: number): number { + let parsed = fallback; + if (typeof value === 'number' && Number.isFinite(value)) { + parsed = Math.trunc(value); + } else if (typeof value === 'boolean') { + parsed = value ? 1 : 0; + } else if (typeof value === 'string' && /^\s*[+-]?\d+\s*$/.test(value)) { + parsed = Number.parseInt(value, 10); + } + if (minimum !== undefined) { + parsed = Math.max(minimum, parsed); + } + if (maximum !== undefined) { + parsed = Math.min(maximum, parsed); + } + return parsed; +} + +/** Mirrors `normalize_ru`: Cosmos moves autoscale in 1,000 RU/s and manual in 100 RU/s. */ +export function normalizeRu(value: unknown, mode: unknown = 'autoscale', direction: 'up' | 'down' = 'up'): number { + const autoscale = String(mode ?? '').trim().toLowerCase() === 'autoscale'; + const quantum = autoscale ? 1000 : 100; + const serviceMinimum = autoscale ? COSMOS_THROUGHPUT_AUTOSCALE_MIN_RU : COSMOS_THROUGHPUT_MANUAL_MIN_RU; + const raw = coerceInt(value, serviceMinimum, serviceMinimum); + const adjusted = direction === 'down' + ? Math.floor(raw / quantum) * quantum + : Math.ceil(raw / quantum) * quantum; + return Math.max(serviceMinimum, adjusted); +} + +/** + * Read the global policy the way `normalize_cosmos_throughput_settings` does. + * + * `repair` matches its `repair_policy_relationships`: validation reads without repair so a + * broken relationship is reported, while an estimate reads with it, as the server would. + */ +export function readGlobalPolicy(read: (key: string) => unknown, repair = true): CosmosGlobalPolicy { + const upThreshold = coerceInt(read('cosmos_throughput_scale_up_threshold_percent'), 90, 1, 100); + let downThreshold = coerceInt(read('cosmos_throughput_scale_down_threshold_percent'), 70, 0, 99); + if (repair && downThreshold >= upThreshold) { + downThreshold = Math.max(0, upThreshold - 1); + } + const minRu = Math.min(normalizeRu(read('cosmos_throughput_min_ru')), COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU); + // `dict.get(key, default)` semantics: only a missing key takes the default. + const rawMax = read('cosmos_throughput_max_ru'); + let maxRu = Math.min(normalizeRu(rawMax === undefined ? COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU : rawMax), COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU); + if (repair && maxRu < minRu) { + maxRu = minRu; + } + return { + autoscale_enabled: coerceBool(read('cosmos_throughput_autoscale_enabled'), false), + enforce_container_defaults: coerceBool(read('cosmos_throughput_enforce_container_defaults'), false), + metrics_window_minutes: coerceInt(read('cosmos_throughput_metrics_window_minutes'), 5, 1, 60), + auto_scale_up_enabled: coerceBool(read('cosmos_throughput_auto_scale_up_enabled'), true), + auto_scale_down_enabled: coerceBool(read('cosmos_throughput_auto_scale_down_enabled'), true), + scale_up_threshold_percent: upThreshold, + scale_down_threshold_percent: downThreshold, + scale_up_step_ru: normalizeRu(read('cosmos_throughput_scale_up_step_ru')), + scale_down_step_ru: normalizeRu(read('cosmos_throughput_scale_down_step_ru')), + scale_up_cooldown_minutes: coerceInt(read('cosmos_throughput_scale_up_cooldown_minutes'), 5, 1, 1440), + scale_down_cooldown_minutes: coerceInt(read('cosmos_throughput_scale_down_cooldown_minutes'), 20, 1, 1440), + min_ru: minRu, + max_ru: maxRu, + ignore_min_limit: coerceBool(read('cosmos_throughput_ignore_min_limit'), false), + ignore_max_limit: coerceBool(read('cosmos_throughput_ignore_max_limit'), false), + convert_manual_to_autoscale_enabled: coerceBool(read('cosmos_throughput_convert_manual_to_autoscale_enabled'), false), + }; +} + +/** + * Normalize one container policy, mirroring `normalize_container_policy`. + * + * A value the policy leaves out inherits the global one, which is why a container with no + * saved policy of its own still behaves predictably. + */ +export function normalizeContainerPolicy( + containerName: string, + policy: CosmosContainerPolicy | null | undefined, + globals: CosmosGlobalPolicy, + repair = true, +): Required> & + Pick { + const source = policy ?? {}; + // `source.get(key, global)` semantics: only a missing key inherits the global value. + const inherit = (value: T | undefined, fallback: T): T | undefined | null => + value === undefined ? fallback : value; + const upThreshold = coerceInt(source.scale_up_threshold_percent, globals.scale_up_threshold_percent, 1, 100); + let downThreshold = coerceInt(source.scale_down_threshold_percent, globals.scale_down_threshold_percent, 0, 99); + if (repair && downThreshold >= upThreshold) { + downThreshold = Math.max(0, upThreshold - 1); + } + const minRu = Math.min(normalizeRu(inherit(source.min_ru, globals.min_ru)), COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU); + let maxRu = Math.min(normalizeRu(inherit(source.max_ru, globals.max_ru)), COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU); + if (repair && maxRu < minRu) { + maxRu = minRu; + } + return { + container_name: String(containerName || source.container_name || '').trim(), + enabled: coerceBool(source.enabled, true), + auto_scale_up_enabled: coerceBool(source.auto_scale_up_enabled, globals.auto_scale_up_enabled), + auto_scale_down_enabled: coerceBool(source.auto_scale_down_enabled, globals.auto_scale_down_enabled), + scale_up_threshold_percent: upThreshold, + scale_down_threshold_percent: downThreshold, + scale_up_step_ru: normalizeRu(inherit(source.scale_up_step_ru, globals.scale_up_step_ru)), + scale_down_step_ru: normalizeRu(inherit(source.scale_down_step_ru, globals.scale_down_step_ru)), + scale_up_cooldown_minutes: coerceInt(source.scale_up_cooldown_minutes, globals.scale_up_cooldown_minutes, 1, 1440), + scale_down_cooldown_minutes: coerceInt(source.scale_down_cooldown_minutes, globals.scale_down_cooldown_minutes, 1, 1440), + min_ru: minRu, + max_ru: maxRu, + ignore_min_limit: coerceBool(source.ignore_min_limit, globals.ignore_min_limit), + ignore_max_limit: coerceBool(source.ignore_max_limit, globals.ignore_max_limit), + convert_manual_to_autoscale_enabled: coerceBool( + source.convert_manual_to_autoscale_enabled, + globals.convert_manual_to_autoscale_enabled, + ), + last_scale_up_at: source.last_scale_up_at ?? null, + last_scale_down_at: source.last_scale_down_at ?? null, + last_mode_conversion_at: source.last_mode_conversion_at ?? null, + }; +} + +/** The global policy written out as one container's policy, for "Apply global policy". */ +export function buildGlobalContainerPolicy(globals: CosmosGlobalPolicy, containerName: string): CosmosContainerPolicy { + return { + container_name: containerName, + enabled: true, + auto_scale_up_enabled: globals.auto_scale_up_enabled, + auto_scale_down_enabled: globals.auto_scale_down_enabled, + scale_up_threshold_percent: globals.scale_up_threshold_percent, + scale_down_threshold_percent: globals.scale_down_threshold_percent, + scale_up_step_ru: globals.scale_up_step_ru, + scale_down_step_ru: globals.scale_down_step_ru, + scale_up_cooldown_minutes: globals.scale_up_cooldown_minutes, + scale_down_cooldown_minutes: globals.scale_down_cooldown_minutes, + min_ru: globals.min_ru, + max_ru: globals.max_ru, + ignore_min_limit: globals.ignore_min_limit, + ignore_max_limit: globals.ignore_max_limit, + convert_manual_to_autoscale_enabled: globals.convert_manual_to_autoscale_enabled, + }; +} + +/** Keep the automation's timestamps when a policy is replaced. */ +export function mergeRuntimePolicyFields( + policy: CosmosContainerPolicy, + existing: CosmosContainerPolicy | null | undefined, +): CosmosContainerPolicy { + const merged: CosmosContainerPolicy = { ...policy }; + for (const field of COSMOS_POLICY_RUNTIME_FIELDS) { + if (existing?.[field]) { + merged[field] = existing[field]; + } + } + return merged; +} + +/** The stored or staged policies, tolerating the JSON string the classic page may have saved. */ +export function readContainerPolicies(value: unknown): CosmosContainerPolicies { + let candidate = value; + if (typeof candidate === 'string') { + try { + candidate = JSON.parse(candidate || '{}'); + } catch { + return {}; + } + } + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + return {}; + } + return candidate as CosmosContainerPolicies; +} + +/** + * The policy that governs a container, as the classic page resolves it. + * + * With the global policy enforced, the container follows the global settings and keeps + * only its timestamps. Otherwise the staged or saved policy wins over the one the server + * reported with the status. + */ +export function effectiveContainerPolicy( + container: CosmosContainerStatus, + policies: CosmosContainerPolicies, + globals: CosmosGlobalPolicy, +): CosmosContainerPolicy { + const name = container.container_name || ''; + if (globals.enforce_container_defaults) { + return mergeRuntimePolicyFields(buildGlobalContainerPolicy(globals, name), policies[name] ?? container.policy); + } + return { ...(container.policy ?? {}), ...(policies[name] ?? {}), container_name: name }; +} + +/** The Policy column, in the classic page's words. */ +export function containerPolicyLabel( + container: CosmosContainerStatus, + policy: CosmosContainerPolicy, + globals: CosmosGlobalPolicy, +): string { + if (isPortalManaged(container)) { + return 'Monitor only'; + } + if (globals.enforce_container_defaults) { + return 'Global policy'; + } + if (policy.enabled === false) { + return 'Disabled'; + } + return `${policy.min_ru ? formatCount(policy.min_ru) : 'min'}-${policy.max_ru ? formatCount(policy.max_ru) : 'max'} RU/s`; +} + +export type ContainerSortField = 'container_name' | 'current_ru' | 'ru_utilization' | 'request_units' | 'policy'; + +export interface ContainerSort { + field: ContainerSortField; + direction: 'asc' | 'desc'; +} + +const TEXT_SORT_FIELDS = new Set(['container_name', 'policy']); + +/** Clicking a header flips its direction; a new numeric column starts with the largest first. */ +export function nextContainerSort(current: ContainerSort, field: ContainerSortField): ContainerSort { + if (current.field === field) { + return { field, direction: current.direction === 'desc' ? 'asc' : 'desc' }; + } + return { field, direction: TEXT_SORT_FIELDS.has(field) ? 'asc' : 'desc' }; +} + +export function filterContainers(containers: CosmosContainerStatus[], query: string): CosmosContainerStatus[] { + const needle = query.trim().toLowerCase(); + if (!needle) { + return [...containers]; + } + return containers.filter((container) => (container.container_name || 'database').toLowerCase().includes(needle)); +} + +/** Sort rows, keeping missing values last and breaking ties by name. */ +export function sortContainers( + containers: CosmosContainerStatus[], + sort: ContainerSort, + options: { windowMinutes?: number | null; policyLabel: (container: CosmosContainerStatus) => string }, +): CosmosContainerStatus[] { + const valueOf = (container: CosmosContainerStatus, field: ContainerSortField): string | number | null => { + switch (field) { + case 'container_name': + return (container.container_name || 'database').toLowerCase(); + case 'current_ru': + return toNumber(container.current_ru); + case 'ru_utilization': + return containerUtilization(container, options.windowMinutes).value; + case 'request_units': + return toNumber(container.request_units); + case 'policy': + return options.policyLabel(container).toLowerCase(); + default: + return null; + } + }; + const compare = (first: string | number | null, second: string | number | null, field: ContainerSortField, direction: 'asc' | 'desc') => { + const firstMissing = first === null || first === ''; + const secondMissing = second === null || second === ''; + if (firstMissing && secondMissing) { + return 0; + } + if (firstMissing) { + return 1; + } + if (secondMissing) { + return -1; + } + const multiplier = direction === 'desc' ? -1 : 1; + if (TEXT_SORT_FIELDS.has(field)) { + return String(first).localeCompare(String(second), undefined, { sensitivity: 'base', numeric: true }) * multiplier; + } + return (Number(first) - Number(second)) * multiplier; + }; + return [...containers].sort((first, second) => { + const primary = compare(valueOf(first, sort.field), valueOf(second, sort.field), sort.field, sort.direction); + if (primary !== 0) { + return primary; + } + return compare(valueOf(first, 'container_name'), valueOf(second, 'container_name'), 'container_name', 'asc'); + }); +} + +/** "Current 4,000 RU/s" style summary used in the workbench list. */ +export function describeContainerCapacity(container: CosmosContainerStatus): string { + return `${container.mode || 'unknown'} | ${formatRu(container.current_ru)}`; +} + +/** Policy fields a rule can fault on a container, for inline errors in the workbench. */ +export type ContainerPolicyField = + | 'scale_up_threshold_percent' + | 'scale_down_threshold_percent' + | 'scale_up_cooldown_minutes' + | 'scale_down_cooldown_minutes'; + +export interface CosmosPolicyValidation { + /** Errors keyed by the global settings key they belong beside. */ + fieldErrors: Record; + /** Errors per container, keyed by the policy field they belong beside. */ + containerErrors: Record>>; + /** Every message, in the order the server reports them. */ + messages: string[]; +} + +const GLOBAL_POLICY_FIELD_KEYS: Record = { + scale_up_threshold_percent: 'cosmos_throughput_scale_up_threshold_percent', + scale_down_threshold_percent: 'cosmos_throughput_scale_down_threshold_percent', + scale_up_cooldown_minutes: 'cosmos_throughput_scale_up_cooldown_minutes', + scale_down_cooldown_minutes: 'cosmos_throughput_scale_down_cooldown_minutes', +}; + +interface PolicyProblem { + fields: ContainerPolicyField[]; + message: string; + involvesWindow: boolean; +} + +/** Mirrors `_collect_policy_problems`, message for message. */ +function collectPolicyProblems( + policy: Record, + metricsWindowMinutes: number, +): PolicyProblem[] { + const problems: PolicyProblem[] = []; + if (policy.scale_up_threshold_percent <= policy.scale_down_threshold_percent) { + problems.push({ + fields: ['scale_up_threshold_percent', 'scale_down_threshold_percent'], + message: 'Scale Up At must be higher than Scale Down At.', + involvesWindow: false, + }); + } + if (policy.scale_up_cooldown_minutes < metricsWindowMinutes) { + problems.push({ + fields: ['scale_up_cooldown_minutes'], + message: `Scale Up Interval must be greater than or equal to the Metrics Window (${metricsWindowMinutes} minutes).`, + involvesWindow: true, + }); + } + if (policy.scale_down_cooldown_minutes < metricsWindowMinutes) { + problems.push({ + fields: ['scale_down_cooldown_minutes'], + message: `Scale Down Interval must be greater than or equal to the Metrics Window (${metricsWindowMinutes} minutes).`, + involvesWindow: true, + }); + } + return problems; +} + +/** + * The rules a save must pass, mirroring `collect_cosmos_throughput_policy_errors`. + * + * Nothing is checked while automation is off, and container policies are skipped while + * the global policy is enforced or a container's policy is disabled -- exactly when the + * server skips them. `read` returns the value a save would leave behind: the draft where + * there is one, otherwise the saved setting. + */ +export function validateCosmosThroughputPolicy(read: (key: string) => unknown): CosmosPolicyValidation { + const result: CosmosPolicyValidation = { fieldErrors: {}, containerErrors: {}, messages: [] }; + const globals = readGlobalPolicy(read, false); + if (!globals.autoscale_enabled) { + return result; + } + + const window = globals.metrics_window_minutes; + for (const problem of collectPolicyProblems(globals, window)) { + const message = `Cosmos throughput policy: ${problem.message}`; + result.messages.push(message); + for (const field of problem.fields) { + result.fieldErrors[GLOBAL_POLICY_FIELD_KEYS[field]] ??= message; + } + if (problem.involvesWindow) { + result.fieldErrors['cosmos_throughput_metrics_window_minutes'] ??= message; + } + } + + if (!globals.enforce_container_defaults) { + const policies = readContainerPolicies(read(CONTAINER_POLICIES_KEY)); + for (const [name, policy] of Object.entries(policies)) { + const containerName = String(name || '').trim(); + if (!containerName) { + continue; + } + const normalized = normalizeContainerPolicy(containerName, policy, globals, false); + if (normalized.enabled === false) { + continue; + } + for (const problem of collectPolicyProblems(normalized, window)) { + const message = `Container '${containerName}' policy: ${problem.message}`; + result.messages.push(message); + const errors = (result.containerErrors[containerName] ??= {}); + for (const field of problem.fields) { + errors[field] ??= problem.message; + } + result.fieldErrors[CONTAINER_POLICIES_KEY] ??= message; + } + } + } + + return result; +} + +/** The step and guardrails a manual scale uses for one target. */ +export interface ManualScalePolicy { + scale_up_step_ru: number; + scale_down_step_ru: number; + min_ru: number; + max_ru: number; + ignore_min_limit: boolean; + ignore_max_limit: boolean; +} + +export type ManualScaleEstimate = { target: number; error?: undefined } | { target?: undefined; error: string }; + +/** + * The RU/s a manual scale will land on, mirroring `calculate_manual_scale_target`. + * + * Shown in the confirmation before the request is sent. The server computes the real target + * from the saved settings, so the estimate is read from saved settings too, never the draft. + */ +export function estimateManualScaleTarget( + target: CosmosThroughputTarget | null | undefined, + policy: ManualScalePolicy, + direction: 'up' | 'down', +): ManualScaleEstimate { + const current = toNumber(target?.current_ru); + const mode = target?.mode || 'autoscale'; + if (!current) { + return { error: 'Current Cosmos throughput could not be determined.' }; + } + if (isPortalManaged(target)) { + return { error: COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE }; + } + const limit = normalizeRu(COSMOS_THROUGHPUT_SIMPLECHAT_MAX_RU, mode, 'down'); + const currentRu = Math.trunc(current); + + if (direction === 'up') { + let next = normalizeRu(currentRu + policy.scale_up_step_ru, mode, 'up'); + if (!policy.ignore_max_limit) { + next = Math.min(next, normalizeRu(policy.max_ru, mode, 'up')); + } + next = Math.min(next, limit); + if (next <= currentRu) { + return currentRu >= limit + ? { error: COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE } + : { error: 'Maximum RU/s limit is already reached.' }; + } + return { target: next }; + } + + const serviceMinimum = mode === 'autoscale' ? COSMOS_THROUGHPUT_AUTOSCALE_MIN_RU : COSMOS_THROUGHPUT_MANUAL_MIN_RU; + let next = normalizeRu(currentRu - policy.scale_down_step_ru, mode, 'down'); + if (!policy.ignore_min_limit) { + next = Math.max(next, normalizeRu(policy.min_ru, mode, 'up')); + } + next = Math.max(serviceMinimum, next); + if (next >= currentRu) { + return { error: 'Minimum RU/s limit is already reached.' }; + } + return { target: next }; +} + +/** The database-level manual scale policy, from saved settings. */ +export function databaseScalePolicy(globals: CosmosGlobalPolicy): ManualScalePolicy { + return { + scale_up_step_ru: globals.scale_up_step_ru, + scale_down_step_ru: globals.scale_down_step_ru, + min_ru: globals.min_ru, + max_ru: globals.max_ru, + ignore_min_limit: globals.ignore_min_limit, + ignore_max_limit: globals.ignore_max_limit, + }; +} + +/** A container's manual scale policy: the one the server reported with the status. */ +export function containerScalePolicy(container: CosmosContainerStatus, globals: CosmosGlobalPolicy): ManualScalePolicy { + const policy = normalizeContainerPolicy(container.container_name, container.policy, globals); + return { + scale_up_step_ru: policy.scale_up_step_ru, + scale_down_step_ru: policy.scale_down_step_ru, + min_ru: policy.min_ru, + max_ru: policy.max_ru, + ignore_min_limit: policy.ignore_min_limit, + ignore_max_limit: policy.ignore_max_limit, + }; +} + +export interface ActionAvailability { + disabled: boolean; + /** Why the action is unavailable, for its tooltip and accessible name. */ + reason: string; +} + +export interface CapacityActions { + convert: ActionAvailability; + up: ActionAvailability; + down: ActionAvailability; +} + +const AVAILABLE: ActionAvailability = { disabled: false, reason: '' }; + +/** Which database-level actions apply, by the classic page's rules. */ +export function databaseActions(status: CosmosThroughputStatus | null | undefined): CapacityActions { + if (!status?.throughput) { + const reason = 'Refresh the status before changing throughput.'; + return { convert: { disabled: true, reason }, up: { disabled: true, reason }, down: { disabled: true, reason } }; + } + const throughput = status.throughput; + const containerScope = status.capacity_scope === 'container'; + const portal = isPortalManaged(throughput); + const unscalable = containerScope + ? 'Throughput is set per container. Use the actions in Cosmos Metrics.' + : !throughput.is_scalable + ? 'The database has no scalable throughput of its own.' + : portal + ? portalManagedMessage(throughput) + : ''; + const upReason = unscalable || (isScaleUpBlockedBySimpleChatLimit(throughput) ? COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE : ''); + const convertReason = unscalable || (throughput.mode !== 'manual' ? 'Throughput already uses Cosmos autoscale.' : ''); + return { + convert: convertReason ? { disabled: true, reason: convertReason } : AVAILABLE, + up: upReason ? { disabled: true, reason: upReason } : AVAILABLE, + down: unscalable ? { disabled: true, reason: unscalable } : AVAILABLE, + }; +} + +/** Which actions apply to one container row. */ +export function containerActions(container: CosmosContainerStatus): CapacityActions { + const portal = isPortalManaged(container); + const shared = !container.is_scalable ? 'This container shares database throughput.' : ''; + const portalReason = portal ? portalManagedMessage(container) : ''; + const upReason = + shared || portalReason || (isScaleUpBlockedBySimpleChatLimit(container) ? COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE : ''); + const downReason = shared || portalReason; + const convertReason = + shared || portalReason || (container.mode !== 'manual' ? 'Throughput already uses Cosmos autoscale.' : ''); + return { + convert: convertReason ? { disabled: true, reason: convertReason } : AVAILABLE, + up: upReason ? { disabled: true, reason: upReason } : AVAILABLE, + down: downReason ? { disabled: true, reason: downReason } : AVAILABLE, + }; +} + +/** + * The body for "Validate access": every throughput setting as it would be saved. + * + * The classic page posts its current form values, so a configuration can be checked + * before it is saved. Unset keys are left out and the server keeps the stored value. + */ +export function buildAccessValidationPayload(read: (key: string) => unknown): Record { + const payload: Record = {}; + for (const key of COSMOS_THROUGHPUT_SETTING_KEYS) { + const value = read(key); + if (value === undefined) { + continue; + } + payload[key] = key === CONTAINER_POLICIES_KEY ? readContainerPolicies(value) : value; + } + return payload; +} + +/** Whether any throughput setting has an unsaved edit, which manual actions would ignore. */ +export function hasUnsavedThroughputEdits(draftKeys: string[]): boolean { + const keys = new Set(COSMOS_THROUGHPUT_SETTING_KEYS); + return draftKeys.some((key) => keys.has(key)); +} diff --git a/application/v2_ui/src/lib/scaleFormat.ts b/application/v2_ui/src/lib/scaleFormat.ts new file mode 100644 index 000000000..17c2d77e5 --- /dev/null +++ b/application/v2_ui/src/lib/scaleFormat.ts @@ -0,0 +1,170 @@ +// scaleFormat.ts +// Formatting and tone rules shared by every live readout in the Admin Settings Scale group. +// +// Redis metrics, the document access index, the caches, Cosmos maintenance and Cosmos +// throughput all report raw numbers and status codes. They are drawn by different cards, +// so the wording and the colour of "Not available", "Running" or "Failed" are decided once +// here. The rules follow the server-rendered page, which administrators already read: the +// same values come out as the same words in both interfaces. + +/** How a readout is coloured. Mirrors the Bootstrap variants the classic page uses. */ +export type ReadoutTone = 'ok' | 'info' | 'warn' | 'danger' | 'neutral'; + +/** A display string and the tone to draw it in. */ +export interface ToneText { + text: string; + tone: ReadoutTone; +} + +export const NOT_AVAILABLE = 'Not available'; + +/** A number from a JSON value, or null when there is none. */ +export function toNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '' || typeof value === 'boolean') { + return null; + } + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function formatCount(value: unknown): string { + const numeric = toNumber(value); + return numeric === null ? NOT_AVAILABLE : numeric.toLocaleString(); +} + +/** A measurement with its unit, such as `12.5 ms` or `400 RU`. */ +export function formatMetric(value: unknown, unit: string, maximumFractionDigits = 2): string { + const numeric = toNumber(value); + if (numeric === null) { + return NOT_AVAILABLE; + } + return `${numeric.toLocaleString(undefined, { maximumFractionDigits })} ${unit}`; +} + +export function formatPercent(value: unknown, maximumFractionDigits = 2): string { + const numeric = toNumber(value); + if (numeric === null) { + return NOT_AVAILABLE; + } + return `${numeric.toLocaleString(undefined, { maximumFractionDigits })}%`; +} + +export function formatRu(value: unknown): string { + const count = formatCount(value); + return count === NOT_AVAILABLE ? count : `${count} RU/s`; +} + +/** A Redis TTL, where -1 means no expiry and -2 means the key is gone. */ +export function formatTtl(seconds: unknown): string { + const numeric = toNumber(seconds); + if (numeric === null) { + return NOT_AVAILABLE; + } + if (numeric === -2) { + return 'Expired or missing'; + } + if (numeric === -1) { + return 'No expiry'; + } + return `${numeric.toLocaleString()} sec`; +} + +export function formatBytes(bytes: unknown): string { + const numeric = toNumber(bytes); + if (numeric === null || numeric < 0) { + return NOT_AVAILABLE; + } + if (numeric >= 1024 * 1024) { + return `${(numeric / (1024 * 1024)).toLocaleString(undefined, { maximumFractionDigits: 2 })} MB`; + } + if (numeric >= 1024) { + return `${(numeric / 1024).toLocaleString(undefined, { maximumFractionDigits: 2 })} KB`; + } + return `${numeric.toLocaleString()} bytes`; +} + +/** `succeeded_with_errors` becomes `Succeeded With Errors`, as on the classic page. */ +export function humanizeStatus(value: unknown, empty = 'Not loaded'): string { + const text = String(value ?? '').trim(); + if (!text) { + return empty; + } + return text.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()); +} + +/** An ISO timestamp in the reader's locale, or the raw text when it does not parse. */ +export function formatTimestamp(value: unknown, empty = NOT_AVAILABLE): string { + const text = String(value ?? '').trim(); + if (!text) { + return empty; + } + const parsed = new Date(text); + return Number.isNaN(parsed.getTime()) ? text : parsed.toLocaleString(); +} + +/** How long ago a moment was, for "Updated 3 min ago" beside a refresh button. */ +export function formatRelativeTime(at: number | null, now: number = Date.now()): string { + if (at === null || !Number.isFinite(at)) { + return 'Not loaded yet'; + } + const seconds = Math.max(0, Math.round((now - at) / 1000)); + if (seconds < 45) { + return 'Updated just now'; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `Updated ${minutes} min ago`; + } + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `Updated ${hours} h ago`; + } + return `Updated ${new Date(at).toLocaleString()}`; +} + +const OK_STATUSES = new Set([ + 'succeeded', + 'skipped_completed', + 'completed', + 'dry_run_completed', + 'reconciled', + 'matched', + 'aligned', +]); +const INFO_STATUSES = new Set(['running', 'in_progress']); +const WARN_STATUSES = new Set([ + 'succeeded_with_errors', + 'completed_with_errors', + 'reconciled_with_errors', + 'mismatch', + 'missing_expected_indexes', +]); +const DANGER_STATUSES = new Set(['failed', 'error']); + +/** The tone of a maintenance, backfill, shadow or cleanup status code. */ +export function maintenanceStatusTone(value: unknown): ReadoutTone { + const status = String(value ?? '').trim().toLowerCase(); + if (OK_STATUSES.has(status)) { + return 'ok'; + } + if (INFO_STATUSES.has(status)) { + return 'info'; + } + if (WARN_STATUSES.has(status)) { + return 'warn'; + } + if (DANGER_STATUSES.has(status)) { + return 'danger'; + } + return 'neutral'; +} + +/** A maintenance status as words and tone together. */ +export function describeMaintenanceStatus(value: unknown, empty = 'Not loaded'): ToneText { + return { text: humanizeStatus(value, empty), tone: maintenanceStatusTone(value) }; +} + +/** Enabled/Disabled pills, where "on" is good news rather than a warning. */ +export function describeEnabled(value: unknown, on = 'Enabled', off = 'Disabled'): ToneText { + return value ? { text: on, tone: 'ok' } : { text: off, tone: 'neutral' }; +} diff --git a/application/v2_ui/src/lib/scaleMaintenance.ts b/application/v2_ui/src/lib/scaleMaintenance.ts new file mode 100644 index 000000000..70bc395f8 --- /dev/null +++ b/application/v2_ui/src/lib/scaleMaintenance.ts @@ -0,0 +1,396 @@ +// scaleMaintenance.ts +// Shapes and wording for the three cards fed by the app maintenance status. +// +// The document access index (DAI), the conversation cache and Cosmos maintenance all +// report through one endpoint, `/api/admin/settings/app-maintenance/status`, exactly as on +// the server-rendered page, which refreshes the three together. Manual runs go through +// `/api/admin/settings/app-maintenance/run` and return per-step results; the helpers here +// pick the step a card cares about out of that list. + +import { + NOT_AVAILABLE, + formatCount, + formatMetric, + humanizeStatus, + maintenanceStatusTone, + type ToneText, +} from './scaleFormat'; + +/** One rolling window of counters, as the server aggregates them. */ +export interface MetricWindow { + sample_count?: number; + served_from_index_count?: number; + source_fallback_count?: number; + fallback_rate_percent?: number | null; + request_charge?: number | null; + elapsed_ms_avg?: number | null; + elapsed_ms_p95?: number | null; + hit_rate_percent?: number | null; + hit_count?: number; + miss_count?: number; + bypass_count?: number; + error_count?: number; + write_count?: number; + invalidation_count?: number; + operation_counts?: Record; + source_query_ru?: number | null; + candidate_read_ru?: number | null; + validation_index_ru?: number | null; + estimated_wave5_ru_savings?: number | null; + comparable_sample_count?: number; + matched_count?: number; + mismatch_count?: number; +} + +/** The most recent event of a kind, kept for "Last fallback" and similar readouts. */ +export interface MetricSample { + status?: string; + operation?: string; + source_scope?: string; + event_type?: string; + reason?: string; +} + +export interface DocumentAccessIndexStatus { + state?: { + status?: string; + current_source_scope?: string | null; + completed_source_scopes?: string[]; + total_documents_processed?: number; + total_documents_failed?: number; + total_rows_upserted?: number; + total_rows_deleted?: number; + last_completed_at?: string | null; + last_error?: string | null; + }; + settings?: { + container_enabled?: boolean; + write_through_enabled?: boolean; + reads_enabled?: boolean; + cache_enabled?: boolean; + cache_ttl_seconds?: number; + shadow_validation_enabled?: boolean; + }; + shadow_validation?: { + status?: string; + missing_count?: number; + extra_count?: number; + source_query_ru?: number | null; + validation_index_ru?: number | null; + candidate_read_ru?: number | null; + estimated_wave5_ru_savings?: number | null; + source_query_ms?: number | null; + candidate_read_ms?: number | null; + estimated_wave5_ms_savings?: number | null; + rolling_metrics?: { windows?: Record }; + }; + maintenance?: { + auto_maintenance_enabled?: boolean; + next_action?: string; + has_more_work?: boolean; + active_interval_seconds?: number | null; + }; + read_metrics?: { + windows?: Record; + last_sample?: MetricSample | null; + last_fallback_sample?: MetricSample | null; + }; + cache_metrics?: { + windows?: Record; + last_event?: MetricSample | null; + }; + repair_required_count?: number | null; +} + +export interface ConversationCacheStatus { + settings?: { enabled?: boolean; ttl_seconds?: number }; + metrics?: { + windows?: Record; + last_event?: MetricSample | null; + last_invalidation?: MetricSample | null; + }; +} + +export interface CosmosIndexingPolicyStatus { + mode?: string; + container_count?: number; + containers_missing_expected_indexes?: number; + updated_container_count?: number; + failed_container_count?: number; + evaluated_at?: string | null; +} + +export interface StaleCacheCleanupCategory { + category?: string; + candidate_count?: number; + deleted_count?: number; + failed_count?: number; +} + +export interface StaleCacheCleanupStatus { + status?: string; + mode?: string; + candidate_count?: number; + deleted_count?: number; + failed_count?: number; + has_more_candidates?: boolean; + categories?: StaleCacheCleanupCategory[]; + evaluated_at?: string | null; +} + +export interface AppMaintenanceStatus { + success?: boolean; + document_access_index_backfill?: DocumentAccessIndexStatus; + conversation_cache?: ConversationCacheStatus; + cosmos_indexing_policies?: CosmosIndexingPolicyStatus; + stale_cache_cleanup?: StaleCacheCleanupStatus; +} + +export interface AppMaintenanceRunStep { + name?: string; + results?: Record | null; +} + +export interface AppMaintenanceRunResult { + success?: boolean; + error?: string; + steps?: AppMaintenanceRunStep[]; +} + +/** Request bodies for the manual runs each card offers, matching the classic page. */ +export const MAINTENANCE_RUN_PAYLOADS = { + staleCleanupDryRun: { + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: false, + run_stale_cache_cleanup: true, + apply_stale_cache_cleanup: false, + }, + staleCleanupApply: { + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: false, + run_stale_cache_cleanup: true, + apply_stale_cache_cleanup: true, + }, + applyIndexes: { + apply_cosmos_indexing_policies: true, + run_document_access_index_backfill: false, + run_stale_cache_cleanup: false, + }, + backfillBatch: { + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: true, + reset_document_access_index_backfill: false, + }, + resetBackfill: { + apply_cosmos_indexing_policies: false, + run_document_access_index_backfill: true, + reset_document_access_index_backfill: true, + }, +} as const; + +/** How often the DAI card re-reads status while a backfill batch is running. */ +export const DAI_RUNNING_POLL_MS = 7500; + +export function metricWindow( + metrics: { windows?: Record } | null | undefined, + key = '15m', +): MetricWindow { + return metrics?.windows?.[key] ?? {}; +} + +/** Two counts side by side, such as hits and misses. */ +export function formatCountPair(first: unknown, second: unknown): string { + return `${formatCount(first ?? 0)} / ${formatCount(second ?? 0)}`; +} + +export function formatOperationCounts(counts: Record | null | undefined): string { + const entries = Object.entries(counts ?? {}) + .filter(([, count]) => Number(count || 0) > 0) + .sort(([left], [right]) => left.localeCompare(right)); + if (!entries.length) { + return 'No samples'; + } + return entries + .map(([operation, count]) => `${humanizeStatus(operation)}: ${formatCount(count)}`) + .join(', '); +} + +/** "Served From Index (List Documents / personal)". */ +export function formatReadSample(sample: MetricSample | null | undefined): string { + if (!sample) { + return 'No samples'; + } + const scope = sample.source_scope ? ` / ${sample.source_scope}` : ''; + return `${humanizeStatus(sample.status || 'unknown')} (${humanizeStatus(sample.operation || 'read')}${scope})`; +} + +export function formatCacheEvent(sample: MetricSample | null | undefined): string { + if (!sample) { + return 'No cache events'; + } + const reason = sample.reason ? ` / ${humanizeStatus(sample.reason)}` : ''; + return `${humanizeStatus(sample.event_type || 'unknown')} (${humanizeStatus(sample.operation || 'cache')}${reason})`; +} + +/** Average and p95 latency, or "Not available" when neither was recorded. */ +export function formatLatencyWindow(window: MetricWindow): string { + const average = formatMetric(window.elapsed_ms_avg, 'ms', 3); + const p95 = formatMetric(window.elapsed_ms_p95, 'ms', 3); + if (average === NOT_AVAILABLE && p95 === NOT_AVAILABLE) { + return NOT_AVAILABLE; + } + return `${average} / ${p95}`; +} + +/** A source value beside its index counterpart, only when both exist. */ +export function formatMetricPair(source: unknown, candidate: unknown, unit: string): string { + const first = formatMetric(source, unit, 3); + const second = formatMetric(candidate, unit, 3); + if (first === NOT_AVAILABLE || second === NOT_AVAILABLE) { + return NOT_AVAILABLE; + } + return `${first} / ${second}`; +} + +/** A signed difference stated as "12 RU less" or "3 ms slower". */ +export function formatSavings(value: unknown, unit: string, positive: string, negative: string): string { + const numeric = typeof value === 'number' ? value : Number(value); + if (value === null || value === undefined || value === '' || !Number.isFinite(numeric)) { + return NOT_AVAILABLE; + } + if (Math.abs(numeric) < 0.001) { + return `0 ${unit} difference`; + } + return `${formatMetric(Math.abs(numeric), unit, 3)} ${numeric > 0 ? positive : negative}`; +} + +export function formatShadowSamples(window: MetricWindow): string { + if (!window || Number(window.sample_count || 0) === 0) { + return 'No samples'; + } + return ( + `${formatCount(window.sample_count || 0)} samples (` + + `${formatCount(window.comparable_sample_count || 0)} comparable, ` + + `${formatCount(window.matched_count || 0)} matched, ` + + `${formatCount(window.mismatch_count || 0)} mismatch, ` + + `${formatCount(window.error_count || 0)} error)` + ); +} + +export function formatScopeList(items: string[] | null | undefined): string { + const values = (items ?? []).map((item) => String(item || '').trim()).filter(Boolean); + return values.length ? values.join(', ') : 'None'; +} + +/** Whether the conversation cache is reading and writing, and for how long it keeps entries. */ +export function describeConversationCacheRuntime( + settings: ConversationCacheStatus['settings'], +): ToneText { + if (settings?.enabled === false) { + return { text: 'Disabled', tone: 'neutral' }; + } + const ttl = Number(settings?.ttl_seconds ?? 120); + return ttl > 0 + ? { text: `Enabled / ${formatMetric(ttl, 'sec', 3)}`, tone: 'ok' } + : { text: 'Enabled / Writes disabled', tone: 'ok' }; +} + +/** The Redis list cache as the DAI status reports it. */ +export function describeDaiCache(settings: DocumentAccessIndexStatus['settings']): ToneText { + return settings?.cache_enabled + ? { text: `Enabled / ${formatMetric(settings.cache_ttl_seconds, 'sec', 3)}`, tone: 'ok' } + : { text: 'Disabled', tone: 'neutral' }; +} + +/** A flag the server forces on, which the classic page shows as a locked checkbox. */ +export function describeAlwaysOn(value: unknown): ToneText { + return value === false + ? { text: 'Disabled', tone: 'warn' } + : { text: 'Always on', tone: 'ok' }; +} + +/** Index policy health, derived the way the classic page derives it. */ +export function indexingPolicyStatus(policy: CosmosIndexingPolicyStatus | null | undefined): string { + if (!policy || !Object.keys(policy).length) { + return 'not_loaded'; + } + if (Number(policy.failed_container_count || 0) > 0) { + return 'failed'; + } + if (Number(policy.containers_missing_expected_indexes || 0) > 0) { + return 'missing_expected_indexes'; + } + return 'aligned'; +} + +/** The results a manual run reported for one of its steps. */ +export function findRunStep( + result: AppMaintenanceRunResult | null | undefined, + name: string, +): Record | null { + const step = (result?.steps ?? []).find((candidate) => candidate?.name === name); + return step?.results && typeof step.results === 'object' ? step.results : null; +} + +/** The DAI status a backfill run returned, which it nests under `current_status`. */ +export function backfillStatusFromRun( + result: AppMaintenanceRunResult | null | undefined, +): DocumentAccessIndexStatus | null { + const results = findRunStep(result, 'document_access_index_backfill'); + const current = results?.current_status; + return current && typeof current === 'object' ? (current as DocumentAccessIndexStatus) : null; +} + +export function isBackfillRunning(status: DocumentAccessIndexStatus | null | undefined): boolean { + return String(status?.state?.status || '').trim().toLowerCase() === 'running'; +} + +/** What to say after a manual backfill batch, by the state it left behind. */ +export function describeBackfillRun(status: DocumentAccessIndexStatus | null | undefined): ToneText { + const state = String(status?.state?.status || 'completed').trim().toLowerCase(); + if (state === 'running') { + return { + text: 'The backfill batch is still running. Status refreshes automatically.', + tone: 'info', + }; + } + if (state === 'in_progress') { + return { + text: 'The batch finished and more documents remain. Run another batch, or let maintenance continue on its schedule.', + tone: 'info', + }; + } + const tone = maintenanceStatusTone(state); + if (tone === 'danger' || tone === 'warn') { + return { + text: 'The batch finished with errors. Review the repair backlog and the last error.', + tone: 'warn', + }; + } + return { text: 'Document access index backfill batch completed.', tone: 'ok' }; +} + +/** What to say after a stale cleanup run, dry or real. */ +export function describeCleanupRun(cleanup: StaleCacheCleanupStatus | null, applied: boolean): ToneText { + const candidates = formatCount(cleanup?.candidate_count ?? 0); + const deleted = formatCount(cleanup?.deleted_count ?? 0); + const text = applied + ? `Stale cache cleanup deleted ${deleted} document(s) from ${candidates} candidate(s).` + : `Stale cache cleanup dry run found ${candidates} candidate document(s).`; + return { text, tone: cleanup?.has_more_candidates ? 'warn' : 'ok' }; +} + +/** What to say after applying missing indexes. */ +export function describeIndexApply(policy: CosmosIndexingPolicyStatus | null): ToneText { + const updated = Number(policy?.updated_container_count || 0); + if (updated > 0) { + return { + text: `Index updates were submitted for ${formatCount(updated)} container(s). Cosmos may keep transforming indexes for a while; refresh later to follow it.`, + tone: 'ok', + }; + } + return { + text: `Indexing policies are already aligned. Missing expected indexes: ${formatCount(policy?.containers_missing_expected_indexes ?? 0)}.`, + tone: 'ok', + }; +} diff --git a/application/v2_ui/src/lib/scaleRedis.ts b/application/v2_ui/src/lib/scaleRedis.ts new file mode 100644 index 000000000..867e8d7d1 --- /dev/null +++ b/application/v2_ui/src/lib/scaleRedis.ts @@ -0,0 +1,279 @@ +// scaleRedis.ts +// Shapes and wording for the Redis Metrics card and the Redis Explorer. +// +// Both read the existing admin APIs (`/api/admin/settings/redis-monitoring/status` and +// `/api/admin/settings/redis-explorer/*`) that the server-rendered page uses, so the types +// describe what those return. Azure Managed Redis runs the Redis Enterprise engine, which +// omits several INFO fields; every reader therefore tolerates a missing value and says +// "Not available" rather than inventing one. + +import { + NOT_AVAILABLE, + formatMetric, + formatPercent, + humanizeStatus, + type ReadoutTone, + type ToneText, +} from './scaleFormat'; + +export interface RedisMonitoringStatus { + checked_at?: string | null; + configuration?: { + enabled?: boolean; + configured?: boolean; + auth_type?: string; + service_type?: string | null; + service_type_source?: string | null; + port?: number | null; + }; + runtime?: { + app_cache_using_redis?: boolean; + session_using_redis?: boolean; + monitoring_source?: string; + client_available?: boolean; + }; + health?: { + status?: string; + ping_success?: boolean; + ping_latency_ms?: number | null; + last_error?: string | null; + }; + memory?: { + used_memory?: number | null; + used_memory_human?: string | null; + maxmemory?: number | null; + maxmemory_human?: string | null; + maxmemory_policy?: string | null; + usage_percent?: number | null; + mem_fragmentation_ratio?: number | null; + }; + clients?: { connected_clients?: number | null }; + stats?: { + instantaneous_ops_per_sec?: number | null; + keyspace_hit_rate_percent?: number | null; + expired_keys?: number | null; + evicted_keys?: number | null; + rejected_connections?: number | null; + total_error_replies?: number | null; + }; + keyspace?: { total_keys?: number | null }; + server?: { redis_version?: string | null }; + dai_cache?: { + version_marker_count?: number | null; + version_marker_no_expiry_count?: number | null; + payload_key_count?: number | null; + version_marker_ttl_seconds?: number | null; + }; +} + +/** What SimpleChat recognised a Redis key to be. */ +export interface RedisKeyResolution { + kind?: string; + label?: string; + resolved?: boolean; + resolution_status?: string; + entity_type?: string; + entity_name?: string; + entity_id?: string; + entity_status?: string; + row_count?: number | null; + scope_key?: string; + cache_hash?: string; + scope_hash?: string; + note?: string; +} + +export interface RedisExplorerKey { + key: string; + type?: string; + ttl_seconds?: number | null; + memory_usage_bytes?: number | null; + preview_restricted?: boolean; + resolution?: RedisKeyResolution | null; +} + +export interface RedisExplorerKeysResponse { + success?: boolean; + status?: string; + keys?: RedisExplorerKey[]; + next_cursor?: string; + has_more?: boolean; + error?: string; + last_error?: string | null; +} + +export interface RedisExplorerValueResponse { + success?: boolean; + status?: string; + key?: string; + type?: string; + ttl_seconds?: number | null; + memory_usage_bytes?: number | null; + preview_restricted?: boolean; + redacted?: boolean; + truncated?: boolean; + preview?: string; + error?: string; + last_error?: string | null; + resolution?: RedisKeyResolution | null; +} + +/** Fields an explorer failure may explain itself in, after `error`. */ +export type RedisExplorerDetailField = 'last_error' | 'preview'; + +/** + * The sentence to show for a failed explorer request, read in the classic page's order. + * + * `error` comes first. Without it, the key page explains an unreachable Redis in + * `last_error`, and a preview explains a key that no longer exists in `preview`, sent with a + * 404. Anything else gets the caller's fallback rather than a bare status code. + */ +export function readRedisExplorerError( + payload: unknown, + detailFields: readonly RedisExplorerDetailField[], + fallback: string, +): string { + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const record = payload as Record; + for (const field of ['error', ...detailFields]) { + const value = record[field]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + } + return fallback; +} + +export const REDIS_EXPLORER_PAGE_SIZES = [10, 25, 50, 100] as const; + +/** Health codes reported by `functions_redis_monitoring.py`. */ +export function redisHealthTone(value: unknown): ReadoutTone { + const status = String(value ?? '').trim().toLowerCase(); + if (status === 'healthy' || status === 'active') { + return 'ok'; + } + if (['degraded', 'not_configured', 'unavailable'].includes(status)) { + return 'warn'; + } + if (status === 'error') { + return 'danger'; + } + return 'neutral'; +} + +export function describeRedisHealth(value: unknown): ToneText { + return { text: humanizeStatus(value), tone: redisHealthTone(value) }; +} + +/** Whether Redis is on and has a host, which is what the classic Configuration badge says. */ +export function describeRedisConfiguration( + configuration: RedisMonitoringStatus['configuration'], +): ToneText { + if (configuration?.enabled && configuration?.configured) { + return { text: 'Enabled', tone: 'ok' }; + } + if (configuration?.enabled) { + return { text: 'Needs host', tone: 'warn' }; + } + return { text: 'Disabled', tone: 'neutral' }; +} + +/** Whether a runtime path (app cache or sessions) is using Redis right now. */ +export function describeRedisRuntime(active: unknown): ToneText { + return active ? { text: 'Active', tone: 'ok' } : { text: 'Inactive', tone: 'neutral' }; +} + +export function formatRedisServiceType(value: unknown): string { + const service = String(value ?? '').trim(); + if (service === 'azure_managed_redis') { + return 'Azure Managed Redis'; + } + if (service === 'azure_cache_for_redis') { + return 'Azure Cache for Redis'; + } + return NOT_AVAILABLE; +} + +/** The port in use and whether an administrator chose it or it was derived. */ +export function describeRedisPort(configuration: RedisMonitoringStatus['configuration']): string { + if (!configuration?.port) { + return `Port: ${NOT_AVAILABLE}`; + } + const source = configuration.service_type_source === 'setting' ? 'set by admin' : 'detected'; + return `Port ${configuration.port} (${source})`; +} + +/** Used memory against the limit, as "12 MB / 1 GB (1.2%)". */ +export function formatRedisMemoryUsage(memory: RedisMonitoringStatus['memory']): string { + const used = memory?.used_memory_human || formatMetric(memory?.used_memory, 'bytes'); + const max = memory?.maxmemory_human || formatMetric(memory?.maxmemory, 'bytes'); + const percent = formatPercent(memory?.usage_percent); + + if (used === NOT_AVAILABLE) { + return NOT_AVAILABLE; + } + if (max === NOT_AVAILABLE || Number(memory?.maxmemory || 0) === 0) { + return `${used} / no maxmemory limit`; + } + if (percent === NOT_AVAILABLE) { + return `${used} / ${max}`; + } + return `${used} / ${max} (${percent})`; +} + +/** The line shown after a refresh, matching the classic page's message. */ +export function describeRedisRefresh(status: RedisMonitoringStatus): ToneText { + const health = status.health ?? {}; + if (health.status === 'healthy') { + return { text: 'Redis monitoring status loaded.', tone: 'ok' }; + } + if (health.last_error) { + return { text: health.last_error, tone: health.status === 'error' ? 'danger' : 'warn' }; + } + return { text: 'Redis monitoring status loaded.', tone: 'info' }; +} + +/** One line naming what a key belongs to, for the key list. */ +export function describeResolutionLabel(resolution: RedisKeyResolution | null | undefined): string { + if (!resolution) { + return ''; + } + if (resolution.resolved && resolution.entity_type) { + const entity = resolution.entity_name || resolution.entity_id || 'Unknown'; + return `${resolution.label || 'SimpleChat entity'}: ${humanizeStatus(resolution.entity_type)} - ${entity}`; + } + return resolution.label || ''; +} + +/** The resolved entity's details, for the preview's resolution card. */ +export function describeResolutionEntity(resolution: RedisKeyResolution | null | undefined): string { + if (!resolution) { + return 'Not resolved'; + } + if (!resolution.resolved) { + return humanizeStatus(resolution.resolution_status || 'unresolved'); + } + const parts: string[] = []; + if (resolution.entity_type) { + parts.push(`Entity: ${humanizeStatus(resolution.entity_type)}`); + } + if (resolution.entity_name) { + parts.push(`Name: ${resolution.entity_name}`); + } + if (resolution.entity_status) { + parts.push(`Status: ${humanizeStatus(resolution.entity_status)}`); + } + if (typeof resolution.row_count === 'number' && Number.isFinite(resolution.row_count)) { + parts.push(`DAI rows: ${resolution.row_count.toLocaleString()}`); + } + return parts.join(' | ') || 'Resolved'; +} + +/** How much of a value the preview withholds. */ +export function describePreviewSanitization(value: RedisExplorerValueResponse): string { + if (value.preview_restricted) { + return 'Restricted'; + } + return value.redacted ? 'Redacted' : 'Sanitized'; +} diff --git a/application/v2_ui/src/lib/useFirstVisible.ts b/application/v2_ui/src/lib/useFirstVisible.ts new file mode 100644 index 000000000..69b0d0749 --- /dev/null +++ b/application/v2_ui/src/lib/useFirstVisible.ts @@ -0,0 +1,41 @@ +// useFirstVisible.ts +// Whether an element has scrolled into view at least once. +// +// The Scale readouts ask Redis, Cosmos and Azure Monitor for live status. "All settings" +// renders every card at once, so loading on mount would query all of them whenever an +// administrator opens the page for any reason. Waiting until a card is actually seen keeps +// those calls to the cards someone looks at. Once seen, it stays loaded. + +import { useEffect, useState, type RefObject } from 'react'; + +export function useFirstVisible(ref: RefObject, enabled = true): boolean { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!enabled || visible) { + return; + } + const element = ref.current; + if (!element) { + return; + } + if (typeof IntersectionObserver === 'undefined') { + setVisible(true); + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }, + // Start slightly before the card arrives, so it is usually ready when it does. + { rootMargin: '240px 0px' }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [ref, enabled, visible]); + + return visible; +} diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx index f5354af7b..371098289 100644 --- a/application/v2_ui/src/pages/AdminSettingsPage.tsx +++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx @@ -40,7 +40,13 @@ 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 { ConversationCacheMetrics } from '../components/admin/ConversationCacheMetrics'; +import { CosmosCapacityConfirm } from '../components/admin/CosmosCapacityConfirm'; +import { CosmosContainerMetrics } from '../components/admin/CosmosContainerMetrics'; +import { CosmosMaintenancePanel } from '../components/admin/CosmosMaintenancePanel'; +import { CosmosThroughputConsole } from '../components/admin/CosmosThroughputConsole'; import { CustomPagesTable } from '../components/admin/CustomPagesTable'; +import { DocumentAccessIndexPanel } from '../components/admin/DocumentAccessIndexPanel'; import { EnhancedCitationsStorageTest } from '../components/admin/EnhancedCitationsStorageTest'; import { EntryListEditor } from '../components/admin/EntryListEditor'; import { ExternalLinksEditor } from '../components/admin/ExternalLinksEditor'; @@ -59,6 +65,7 @@ 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 { RedisMonitoringPanel } from '../components/admin/RedisMonitoringPanel'; import { AgentDelegationManager } from '../components/agents/AgentDelegationManager'; import { GLOBAL_DELEGATION_SCOPE } from '../lib/agentDelegation'; import { SaveBar } from '../components/admin/SaveBar'; @@ -85,6 +92,7 @@ import { isRequirementSatisfied, isSectionVisible, readFieldValue, + resolveFieldPresentation, type AdminField, type AdminSettingsPatchResponse, type AdminSettingsResponse, @@ -94,10 +102,16 @@ import { type BrandingUploadResponse, } from '../lib/adminFields'; import { toast } from '../stores/toastStore'; -import { computeSectionStatus, type SectionStatus } from '../lib/adminSections'; +import { buildSectionDependents, computeSectionStatus, type SectionStatus } from '../lib/adminSections'; +import { + CONTAINER_POLICIES_KEY, + hasUnsavedThroughputEdits, + validateCosmosThroughputPolicy, +} from '../lib/cosmosThroughput'; import { hasUnsavedDiscoveryEdits } from '../lib/modelSelection'; import { PLANNER_MODEL_KEYS } from '../lib/orchestrationPlannerModel'; import { modelConnectionsChanged, requestConnectionFocus } from '../stores/modelConnectionsStore'; +import { useScaleStatusStore } from '../stores/scaleStatusStore'; import type { CatalogConnectionTarget } from '../lib/modelCatalog'; import type { AdminNavGroup, Json } from '../lib/types'; @@ -493,11 +507,54 @@ export function AdminSettingsPage() { settings, draft, sectionStatus[section.sectionId], + runtimeFlags, ), ); } return statuses; - }, [sections, settings, draft, sectionStatus]); + }, [sections, settings, draft, sectionStatus, runtimeFlags]); + + /** + * Which sections rely on which, from their `requires` declarations. + * + * Drawn on the section relied on, so Redis Cache says that File Sync and the caches + * need it -- the other half of the prerequisite notice those sections show. + */ + const dependentsBySection = useMemo( + () => + buildSectionDependents( + sections.map((section) => ({ + sectionId: section.sectionId, + label: section.label, + fields: section.allFields, + })), + settings, + draft, + runtimeFlags, + ), + [sections, settings, draft, runtimeFlags], + ); + + const availableSectionIds = useMemo( + () => new Set(sections.map((section) => section.sectionId)), + [sections], + ); + + /** + * The throughput policy rules, checked as the administrator types. + * + * The server enforces the same rules on save; running them here puts the reason next + * to the value that breaks them before a save is attempted. + */ + const cosmosValidation = useMemo( + () => + validateCosmosThroughputPolicy((key) => + Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : settings[key], + ), + [settings, draft], + ); + + const requestRedisRefresh = useScaleStatusStore((state) => state.requestRedisRefresh); /** The page index follows the same filters as the cards. */ const indexEntries = useMemo( @@ -763,17 +820,26 @@ export function AdminSettingsPage() { ); /** Render one declared field, dispatching the types the page owns. */ - const renderField = (field: AdminField) => { - if (!isFieldVisible(field, settings, draft, fieldsByKey, runtimeFlags)) { + const renderField = (declared: AdminField) => { + if (!isFieldVisible(declared, settings, draft, fieldsByKey, runtimeFlags)) { return null; } + // A label that follows another setting, such as the Redis key in Key Vault mode. + const field = resolveFieldPresentation( + declared, + (settingKey) => readFieldValue(READ_ONLY_REF(settingKey), settings, draft), + runtimeFlags, + ); + const key = field.key ?? field.component ?? field.status_source ?? field.label; const value = field.type === 'status' ? data?.status_readouts?.[field.status_source ?? ''] : readFieldValue(field, settings, draft); - const error = field.key ? fieldErrors[field.key] : undefined; + const error = field.key + ? fieldErrors[field.key] ?? cosmosValidation.fieldErrors[field.key] + : undefined; const warning = field.key ? fieldWarnings[field.key] : undefined; if (field.type === 'image') { @@ -891,6 +957,54 @@ export function AdminSettingsPage() { settings={settings} draft={draft} disabled={saving} + // A passing Redis test is a reason for Redis Metrics to look again. + onSuccess={field.test_type === 'redis' ? requestRedisRefresh : undefined} + /> + ); + case 'redis-monitoring': + // The saved value: metrics describe the Redis the running app uses. + return ( + + ); + case 'conversation-cache-metrics': + return ; + case 'document-access-index-status': + return ( + + ); + case 'cosmos-maintenance-status': + return ; + case 'cosmos-throughput-console': + return ( + + ); + case 'cosmos-container-metrics': + return ( + setValue(CONTAINER_POLICIES_KEY, next)} + onNavigate={goToSection} /> ); case 'agent-orchestration': @@ -1380,6 +1494,10 @@ export function AdminSettingsPage() { renderField={renderField} renderCapability={renderField} appearance={agentSectionAppearances[section.sectionId]} + runtimeFlags={runtimeFlags} + onNavigate={goToSection} + isSectionAvailable={(sectionId) => availableSectionIds.has(sectionId)} + dependents={dependentsBySection.get(section.sectionId)} // While a search is filtering, a match inside a // collapsed group has to be shown or the card would // appear empty. @@ -1491,6 +1609,11 @@ export function AdminSettingsPage() { ) : null} + + {saving ? (
diff --git a/application/v2_ui/src/stores/scaleStatusStore.ts b/application/v2_ui/src/stores/scaleStatusStore.ts new file mode 100644 index 000000000..b31f50c88 --- /dev/null +++ b/application/v2_ui/src/stores/scaleStatusStore.ts @@ -0,0 +1,291 @@ +// scaleStatusStore.ts +// Live status shared between the Admin Settings Scale cards. +// +// Several Scale cards read the same thing. DAI Metrics, Conversation Cache and Cosmos +// Maintenance are all drawn from one app maintenance status, and the server-rendered page +// refreshes the three together; here they share one fetch, so a refresh in any of them +// updates all three and opening the page never asks for it three times. Cosmos DB +// Throughput and Cosmos Metrics share the throughput status the same way, along with the +// confirmation in front of a capacity change, which either card can ask for. +// +// The cards are siblings rendered from a declarative schema, so a store is how they talk. +// Cosmos DB Throughput, for instance, asks Cosmos Metrics to select a container through +// `requestContainerFocus`, and either card can ask for the capacity confirmation the page +// draws once. + +import { create } from 'zustand'; +import { ApiError, api } from '../lib/apiClient'; +import { + describeCosmosThroughputStatus, + type CosmosAccessValidation, + type CosmosScaleResult, + type CosmosThroughputStatus, +} from '../lib/cosmosThroughput'; +import { formatRu, type ToneText } from '../lib/scaleFormat'; +import type { AppMaintenanceStatus } from '../lib/scaleMaintenance'; + +const MAINTENANCE_STATUS_PATH = '/api/admin/settings/app-maintenance/status'; +const THROUGHPUT_PATH = '/api/admin/settings/cosmos-throughput'; + +/** A capacity change waiting for the administrator to confirm it. */ +export interface PendingCapacityAction { + kind: 'scale' | 'convert'; + direction?: 'up' | 'down'; + /** Empty for the database; a container name otherwise. */ + containerName: string; +} + +interface MaintenanceState { + data: AppMaintenanceStatus | null; + loading: boolean; + error: string | null; + loadedAt: number | null; +} + +interface ThroughputState { + status: CosmosThroughputStatus | null; + /** `saved` while showing the snapshot automation last stored; `live` after a refresh. */ + source: 'saved' | 'live' | null; + loading: boolean; + validating: boolean; + acting: boolean; + message: ToneText | null; + validation: CosmosAccessValidation | null; + loadedAt: number | null; +} + +interface ScaleStatusState { + maintenance: MaintenanceState; + /** Read the app maintenance status. Concurrent callers share one request. */ + loadMaintenance: (options?: { force?: boolean }) => Promise; + /** Merge part of a status a manual run returned, so cards update before the next refresh. */ + mergeMaintenance: (partial: Partial) => void; + + /** Bumped to ask the Redis Metrics card to refresh, after a successful connection test. */ + redisRefreshRevision: number; + requestRedisRefresh: () => void; + + throughput: ThroughputState; + seedThroughput: (status: CosmosThroughputStatus) => void; + refreshThroughput: () => Promise; + validateThroughputAccess: (payload: Record) => Promise; + performCapacityAction: (action: PendingCapacityAction) => Promise; + + pendingCapacityAction: PendingCapacityAction | null; + requestCapacityAction: (action: PendingCapacityAction) => void; + clearCapacityAction: () => void; + + /** + * A container the Cosmos Metrics workbench should select, asked for from elsewhere -- + * the throughput card's "Container policies" link, for one. Cleared once acted on. + */ + containerFocus: { name: string | null; revision: number }; + requestContainerFocus: (name?: string | null) => void; +} + +let maintenanceRequest: Promise | null = null; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof ApiError || error instanceof Error) { + return error.message || fallback; + } + return fallback; +} + +export const useScaleStatusStore = create((set, get) => ({ + maintenance: { data: null, loading: false, error: null, loadedAt: null }, + + loadMaintenance: async (options = {}) => { + const current = get().maintenance; + if (!options.force && current.data) { + return current.data; + } + if (maintenanceRequest) { + return maintenanceRequest; + } + set((state) => ({ maintenance: { ...state.maintenance, loading: true, error: null } })); + maintenanceRequest = (async () => { + try { + const data = await api.get(MAINTENANCE_STATUS_PATH); + set({ maintenance: { data, loading: false, error: null, loadedAt: Date.now() } }); + return data; + } catch (error) { + set((state) => ({ + maintenance: { + ...state.maintenance, + loading: false, + error: errorMessage(error, 'Failed to load app maintenance status.'), + }, + })); + return null; + } finally { + maintenanceRequest = null; + } + })(); + return maintenanceRequest; + }, + + mergeMaintenance: (partial) => + set((state) => ({ + maintenance: { + ...state.maintenance, + data: { ...(state.maintenance.data ?? {}), ...partial }, + }, + })), + + redisRefreshRevision: 0, + requestRedisRefresh: () => set((state) => ({ redisRefreshRevision: state.redisRefreshRevision + 1 })), + + throughput: { + status: null, + source: null, + loading: false, + validating: false, + acting: false, + message: null, + validation: null, + loadedAt: null, + }, + + seedThroughput: (status) => + set((state) => + state.throughput.status + ? state + : { throughput: { ...state.throughput, status, source: 'saved' } }, + ), + + refreshThroughput: async () => { + set((state) => ({ + throughput: { + ...state.throughput, + loading: true, + validation: null, + message: { text: 'Loading Cosmos throughput status…', tone: 'info' }, + }, + })); + try { + const status = await api.get(`${THROUGHPUT_PATH}/status`); + set((state) => ({ + throughput: { + ...state.throughput, + status, + source: 'live', + loading: false, + loadedAt: Date.now(), + message: describeCosmosThroughputStatus(status), + }, + })); + } catch (error) { + set((state) => ({ + throughput: { + ...state.throughput, + loading: false, + message: { text: errorMessage(error, 'Failed to load Cosmos throughput status.'), tone: 'danger' }, + }, + })); + } + }, + + validateThroughputAccess: async (payload) => { + set((state) => ({ + throughput: { + ...state.throughput, + validating: true, + validation: null, + message: { text: 'Validating Cosmos throughput configuration and access…', tone: 'info' }, + }, + })); + try { + const validation = await api.post(`${THROUGHPUT_PATH}/validate-access`, payload); + // A validation reads Azure with the values on screen, which may name another + // target or carry draft policies. Its status stays with the result, so manual + // changes keep estimating from the last status read for the saved settings. + set((state) => ({ + throughput: { + ...state.throughput, + validating: false, + validation, + message: null, + }, + })); + } catch (error) { + set((state) => ({ + throughput: { + ...state.throughput, + validating: false, + message: { text: errorMessage(error, 'Failed to validate Cosmos throughput access.'), tone: 'danger' }, + }, + })); + } + }, + + performCapacityAction: async (action) => { + const target = action.containerName ? ` for ${action.containerName}` : ''; + set((state) => ({ + throughput: { + ...state.throughput, + acting: true, + message: { + text: + action.kind === 'convert' + ? `Converting manual Cosmos throughput${target} to native autoscale…` + : `Submitting the scale-${action.direction} request${target}…`, + tone: 'info', + }, + }, + })); + try { + const result = + action.kind === 'convert' + ? await api.post(`${THROUGHPUT_PATH}/convert-autoscale`, { + container_name: action.containerName, + }) + : await api.post(`${THROUGHPUT_PATH}/scale`, { + direction: action.direction, + container_name: action.containerName, + }); + const scope = result.container_name ? ` for ${result.container_name}` : ''; + const done: ToneText = + action.kind === 'convert' + ? { + text: `Cosmos throughput${scope} converted from manual ${formatRu(result.from_ru)} to autoscale with a maximum of ${formatRu(result.to_ru)}.`, + tone: 'ok', + } + : { + text: `Cosmos throughput${scope} changed from ${formatRu(result.from_ru)} to ${formatRu(result.to_ru)}.`, + tone: 'ok', + }; + // Still acting until the new status is read, so neither the dialog nor the cards + // offer another change based on the capacity this one just replaced. + await get().refreshThroughput(); + // The refresh reports the new status; the outcome of the change is the news. + set((state) => ({ throughput: { ...state.throughput, acting: false, message: done } })); + return true; + } catch (error) { + set((state) => ({ + throughput: { + ...state.throughput, + acting: false, + message: { + text: errorMessage( + error, + action.kind === 'convert' + ? 'Cosmos throughput mode conversion failed.' + : 'Cosmos throughput scale request failed.', + ), + tone: 'danger', + }, + }, + })); + return false; + } + }, + + pendingCapacityAction: null, + requestCapacityAction: (action) => set({ pendingCapacityAction: action }), + clearCapacityAction: () => set({ pendingCapacityAction: null }), + + containerFocus: { name: null, revision: 0 }, + requestContainerFocus: (name = null) => + set((state) => ({ containerFocus: { name, revision: state.containerFocus.revision + 1 } })), +})); diff --git a/docs/admin/scale.md b/docs/admin/scale.md index f9c033ab8..8e7654e33 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -1,7 +1,7 @@ --- layout: page title: "Scale settings" -description: "Scale covers Redis, conversation and search caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation." +description: "Scale covers Redis, the caches built on it, the document access index, Cosmos maintenance, and Cosmos DB throughput, with the health readouts and actions used to run them." section: "Administration" audience: admin admin_tab: scale @@ -12,11 +12,21 @@ admin_tab: scale ## What this group controls -Scale covers Redis, conversation and search caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation. +Scale covers the services that keep SimpleChat fast and affordable as usage grows: Redis, the +conversation and document-list caches built on it, the document access index, background Cosmos +maintenance, and Cosmos DB throughput. + +Most of the group is operational rather than configuration. Beside the settings, each card shows +live health and activity, and offers the actions an administrator uses to confirm that caching +and maintenance are working, or to change Cosmos capacity by hand. ## Why it matters -Scale settings trade latency, freshness, and Azure spend. Caches can make the app faster, while throughput automation can prevent throttling, but both need guardrails. +Scale settings trade latency, freshness, and Azure spend. Caches make the app faster, and +throughput automation prevents throttling, but both need guardrails. The readouts are how you +find out whether those guardrails are right: a low cache hit rate, a document access index that +keeps falling back to source queries, or a database pinned at its maximum RU/s all show up here +before users report them. {% include media.html src="admin-settings/scale.png" alt="Screenshot of the Scale group in Admin Settings." title="Scale settings" %} @@ -24,14 +34,25 @@ Scale settings trade latency, freshness, and Azure spend. Caches can make the ap ## Before you change anything -- Provision Redis before enabling shared cache behavior. -- Understand Cosmos RU baselines before enabling automatic scale changes. -- Review document access index rollout state before changing cache or repair settings. +- Provision Redis before enabling it. Changing whether Redis is on, or how SimpleChat connects + to it, takes effect only after every web worker and the scheduler restart together. +- Know your Cosmos RU baseline before turning on automatic scaling, so the thresholds and + guardrails reflect real load. +- Operations that change Azure resources take effect immediately: applying indexes, deleting + stale cache documents, resetting the index backfill, and scaling or converting throughput. + The V2 admin page states what each one will do and asks for confirmation first. Read-only + actions, such as refreshing status, validating access, or a cleanup dry run, run at once. ## Redis & Caching {#redis-caching} ### Redis Cache {#redis-cache-section} +Redis holds sessions, the shared settings record, and the shared caches, so several app +instances behave as one. Without it each worker keeps its own state, which is fine for a single +instance and wrong for anything larger. The card lists the sections that rely on Redis, such as +Conversation Cache, Redis Metrics and File Sync, and each of those sections names Redis as a +prerequisite and links back here. + SimpleChat connects to either Azure Managed Redis or Azure Cache for Redis. The two services listen on different TLS ports, so SimpleChat reads the host name suffix and picks the port for you: `*..redis.azure.net` is Azure Managed Redis on port 10000, and @@ -44,6 +65,29 @@ Azure Cache for Redis Basic, Standard, and Premium retire on September 30, 2028. deployments provision Azure Managed Redis; an existing Azure Cache for Redis instance keeps working, and moving to Azure Managed Redis is a host name change rather than a code change. +SimpleChat signs in to Redis in one of three ways. Managed identity stores no credential. An +access key is stored as a secret: the browser is never sent it, the field shows it as stored, +and it changes only when you replace it. With Key Vault authentication, the same field holds the +name of the Key Vault secret that contains the access key, and is labelled **Key Vault Secret +Name**. That mode needs Key Vault secret storage, so the card says so and links to the Key Vault +section until it is configured. + +**Test Redis connection** connects with the values on screen and writes, reads and expires a +short-lived key, so a connection can be checked before it is saved. A stored key is used as +stored; the browser sends only its placeholder. A passing test also refreshes the Redis Metrics +reading, if one is on screen. + +#### Settings + +| Setting | What it does | Default | Notes | +| --- | --- | --- | --- | +| Enable Redis Cache | Keeps sessions, settings, and shared caches in Redis so several instances share state. Recommended for production. | Off | `enable_redis_cache`; capability toggle | +| Redis Server Host Name | The Redis host. Its suffix tells SimpleChat which service and port to use. | Empty | `redis_url` | +| Redis Service | Detect from the host name, or name the service when a custom DNS name or private endpoint hides the suffix. | Detect from host name | `redis_service_type` | +| Redis Port | Overrides the port of the selected service, for a proxy or non-standard listener. Must be 1 to 65535, or blank. | Blank | `redis_port` | +| Redis Authentication Type | Access key, managed identity, or an access key read from Key Vault. | Access key | `redis_auth_type` | +| Redis Access Key / Key Vault Secret Name | The access key, or with Key Vault authentication the name of the secret holding it. Hidden for managed identity. | Empty | `redis_key`; secret | + ### Admin settings consistency Implemented in **0.261.025**. App settings no longer have a worker-local snapshot or @@ -96,42 +140,120 @@ dependency direction, and normal/optimized Python startup probes without network ### Redis Metrics {#redis-monitoring-section} -The Redis Metrics section reports the service and port SimpleChat resolved, along with live -health and capacity counters read from the Redis `INFO` command. Azure Managed Redis runs the -Redis Enterprise engine, which reports a different set of `INFO` fields than open-source -Redis, so some counters show "Not available" there. That is expected and does not indicate a -connection problem — check the Health badge and ping latency instead. +Redis Metrics answers whether the Redis the running application uses is healthy and has room: +configuration and health, whether the app cache and sessions are using Redis, ping latency, +memory used against `maxmemory`, fragmentation, connected and rejected clients, operations per +second, keyspace hit rate, expired and evicted keys, and how many document access index cache +markers and payloads Redis holds. Rising evictions or memory near the limit mean the cache is +too small for its load. + +The metrics are read from the Redis `INFO` command when the card scrolls into view, and again +on **Refresh status**, rather than with the page. They describe the saved configuration, so the +card shows nothing to monitor until Redis is enabled and saved. + +Azure Managed Redis runs the Redis Enterprise engine, which reports a different set of `INFO` +fields than open-source Redis, so some counters show "Not available" there. That is expected +and does not indicate a connection problem — check the Health badge and ping latency instead. + +#### Redis Explorer + +The Redis Explorer, collapsed at the bottom of the card, is a read-only browser for +troubleshooting what is actually in the cache. It pages through keys with Redis `SCAN`, using +**Next** and **Previous**, filters by a case-sensitive substring, and lists 10, 25, 50 or 100 +keys at a time. `SCAN` order is defined by the server, so a filter can return a short page while +more matches remain; keep paging. + +Selecting a key shows its type, TTL, memory use, and a preview. SimpleChat recognises its own +keys, such as the shared settings record and document access index scope markers, and says what +each one belongs to. Previews are sanitized and size-limited by the server, and restricted +outright for session, token, cookie, credential, password, and secret-like keys. Nothing in the +explorer writes to Redis. ### Conversation Cache {#conversation-cache-section} -The Conversation Cache section belongs to the Redis & Caching tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. +The conversation cache stores each user's conversation list, feed, and advanced-search results +in Redis, keyed by user and version, so a repeat load skips the Cosmos query. A user's change +bumps their version, so they see it at once; the TTL only bounds how long an untouched entry +lives. Without Redis the cache is bypassed and every request reads Cosmos, which is why the card +names Redis as a prerequisite. + +The card reports the last 15 minutes of activity as counted in the app worker that served the +page: hit rate, hits and misses, bypasses and errors, writes and invalidations, the mix of +operations, and the most recent cache event and invalidation. Application Insights remains the +fleet-wide record. + +Search result caching (`enable_search_result_caching`, on by default) is a separate cache for +workspace search results. It stores result payloads with document-set fingerprints, so repeated +personal, group, public, or all-scope searches reuse results until a document changes or the +TTL expires. The server-rendered admin page has no control for it; the V2 admin page currently +lists its switch with the Web Search settings under Knowledge. #### Settings | Setting | What it does | Default | Notes | | --- | --- | --- | --- | -| Enable Redis Cache | Uses Redis for shared cache/session scenarios so multiple app instances can share cached state. | Off | `enable_redis_cache`; capability toggle | -| Redis Server Host Name | Defines behavior for the related admin workflow; verify the affected feature after saving. | Empty | `redis_url` | -| Redis Service | Selects which Azure Redis offering SimpleChat is talking to, which determines the TLS port. Leave on detection unless a custom DNS name hides the Azure host name suffix. | Detect from host name | `redis_service_type` | -| Redis Port | Overrides the port derived from the selected service. Only needed for a proxy or a non-standard listener. | Empty | `redis_port` | -| Redis Authentication Type | Chooses whether SimpleChat authenticates to this service with a key, managed identity, or another supported method. | Empty | `redis_auth_type` | -| Key Vault Secret Name Redis Access Key | Provides the secret credential used when the selected authentication mode requires one. | Empty | `redis_key` | -| Enable conversation cache | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_conversation_cache`; capability toggle | -| Cache TTL Seconds | Default 120 seconds. User-scoped version invalidation refreshes changed conversations; set to 0 to skip writing new entries. | 120 | `conversation_cache_ttl_seconds` | -| Redis document list cache Wave 6 | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_document_access_index_cache`; capability toggle | -| Cache TTL Seconds | Default 900 seconds. Scope-version invalidation makes document changes visible immediately; TTL clears unreachable old entries. | 900 | `document_access_index_cache_ttl_seconds` | +| Enable conversation cache | Caches conversation lists, feeds, and advanced-search results per user. Off bypasses cache reads and writes. | On | `enable_conversation_cache`; capability toggle; needs Redis | +| Cache TTL | How long a cached payload lives. `0` stops writing new entries. | 120 seconds | `conversation_cache_ttl_seconds`; shown while the cache is on | ## Cosmos {#cosmos} ### DAI Metrics {#document-access-index-section} -The DAI Metrics section belongs to the Cosmos tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. +The document access index (DAI) is a projection of who can see which document. It replaces +expensive cross-partition queries, so document and tag lists in personal, group, and public +workspaces load quickly. The index container, write-through projection of document changes, the +index read path, and the startup backfill are always on: the application forces them on whenever +settings are read or saved, so the card shows them as **Always on** rather than as switches. + +The index converges through the background maintenance job, which repairs records whose +projection failed and then runs bounded backfill batches while work remains. The card names +**Run background maintenance** as its prerequisite and links to Cosmos Maintenance. It reports: + +- **Projection and maintenance:** the always-on components, the Redis document list cache, and + what maintenance will do next. +- **Backfill progress:** state, repair backlog, current and completed scopes, documents + processed and failed, rows written and deleted, and the last error. +- **Reads, last 15 minutes:** read attempts, how many the index served, source fallbacks and + their rate, RU charge, latency, and the most recent fallback reason. A high fallback rate + after an upgrade means the backfill has not caught up. +- **Redis list cache, last 15 minutes:** hit rate, hits and misses, bypasses and errors, and + invalidations. + +While a backfill batch is running, the card refreshes itself every few seconds. + +#### Diagnostics + +Diagnostics appear only when `enable_dai_debug` is set to true in the settings document. That +flag has no control in Admin Settings, in either interface; it is meant for support sessions, +not everyday operation. With it set, the card adds shadow-validation and rolling RU metrics, the +settings below, and two actions: **Run one backfill batch**, and **Reset checkpoint**, which +restarts the backfill from the beginning and asks for confirmation first. + +| Setting | What it does | Default | Notes | +| --- | --- | --- | --- | +| Enable shadow validation | Compares source list results with index rows and logs mismatches, without changing what users see. | Off | `enable_document_access_index_shadow_validation` | +| Backfill Batch Size | Documents processed per manual or scheduled batch, 1 to 1,000. | 200 | `document_access_index_backfill_batch_size` | +| Repair Batch Size | Failed projection records reconciled before each backfill batch, 1 to 500. | 100 | `document_access_index_repair_batch_size` | +| Redis document list cache | Read-through Redis caching for index document, tag, and count reads. Bypassed when Redis is unavailable. | On | `enable_document_access_index_cache`; needs Redis | +| Cache TTL | 60 to 900 seconds. Scope versions make changes visible at once; the TTL clears entries nothing can reach any more. | 900 seconds | `document_access_index_cache_ttl_seconds`; shown while the list cache is on | ### Cosmos Maintenance {#cosmos-maintenance-section} A recurring background job keeps Cosmos in the shape the application expects: it compares composite index policies against what the current code needs, reconciles the document access index by repairing fail-open projection records and running a bounded backfill batch, and clears stale operational cache documents. Newly upgraded deployments rely on it to converge, since the document access index read path falls back to slower source queries until backfill catches up. -The section also runs a maintenance pass on demand and reports what the last run did, which is the quickest way to confirm an upgrade has settled. +The card shows what maintenance last found. **Indexing policies** reports the mode, how many +containers were checked, how many are missing expected composite indexes, and how many were +updated or failed. **Stale cache cleanup** reports candidates, deletions, and failures, with a +breakdown by category. + +Three actions run the Cosmos tasks on demand: + +- **Apply missing indexes** adds the expected composite indexes that are missing and keeps + existing policy paths. It asks first, and Cosmos may keep transforming indexes after it + returns, so refresh later to follow it. +- **Dry run cleanup** lists stale cache documents without deleting anything. +- **Delete stale cache docs** deletes one bounded batch of obsolete cache documents. It asks + first. Both switches below default to on and should stay on. Turning off the scheduler stops repair and backfill from converging at all. Turning off the startup pass only delays convergence to the next scheduled run, which is a reasonable trade when application start time matters more than picking up new index policies immediately after a deployment. @@ -144,63 +266,139 @@ Both switches below default to on and should stay on. Turning off the scheduler ### Cosmos DB Throughput {#cosmos-throughput-section} -The Cosmos DB Throughput section belongs to the Cosmos tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. +Throughput automation reads recent RU utilization from Azure Monitor on every metrics window and +scales the SimpleChat database, or each container with dedicated throughput, up or down within +the guardrails you set. It prevents throttling under load without paying for peak capacity all +day. SimpleChat changes capacity only at 10,000 RU/s or lower. Above that it monitors, and +capacity changes belong in the Azure portal, where they can take four to six hours. -### Cosmos Metrics {#cosmos-throughput-metrics-table-section} +#### Status and manual changes + +The top of the card shows the current mode, RU/s, utilization, and when they were last checked. +Until you select **Refresh**, it shows the last status saved by automation, marked as a saved +snapshot. The card also offers: -The Cosmos Metrics section belongs to the Cosmos tab. Use it with the adjacent settings in this group so related rollout, access, and operational choices stay aligned. +- **Validate access**, which checks the values on screen, including unsaved ones, against Azure: + resource identity, throughput reads, container discovery, and metrics. It saves nothing, and + what it reads stays with its result: the status above remains the saved target's, which is + what manual changes act on. +- **Container policies**, which opens Cosmos Metrics. +- **Convert to autoscale**, **Scale up**, and **Scale down** for the database. Each opens a + confirmation that names the target, its current RU/s, and the RU/s it will most likely land + on, worked out the way the server works it out. Conversion is one way; converting back to + manual is done in the Azure portal. + +Manual changes use the saved policy, not unsaved edits. The card says when there are unsaved +throughput changes, so you can save first. + +#### Which resource is managed + +The **Saved target** readout names the Cosmos account and database that automation and manual +changes act on, and whether each value comes from the saved settings or from the App Service +settings. Leave the resource fields blank to use `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, +the account in `AZURE_COSMOS_ENDPOINT`, and `AZURE_COSMOS_DATABASE_NAME`. + +#### Rules a save must pass + +Both admin interfaces enforce the same rules while automation is on. Scale Up At must be higher +than Scale Down At, and each scale interval must be at least the metrics window. The V2 page +shows a broken rule beside the value as you type, and the server refuses the save with the same +message. With automation off, nothing blocks a save; an inverted threshold pair is corrected, and +the correction is shown beside the field. + +RU/s values are saved in the 1,000 RU/s increments Cosmos autoscale uses, rounding up, and never +above 10,000 RU/s. A value saved differently from how it was typed is reported beside its field. #### Settings | Setting | What it does | Default | Notes | | --- | --- | --- | --- | -| Key Filter | Blank filter browses all keys. Filters are case sensitive. Redis SCAN order is server-defined, so use Next Page to keep browsing. | N/A (runtime control) | Runtime UI control | -| Page Size | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | Runtime UI control | -| Search result caching | Caches workspace search-result payloads with document-set fingerprints so repeated personal, group, public, or all-scope searches can reuse results until document changes or the TTL invalidate them. | On | `enable_search_result_caching`; no visible field in `admin_settings.html` | -| Write-through projection | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_document_access_index_write_through`; capability toggle | -| Automatic repair/backfill | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_startup_document_access_index_backfill`; capability toggle | -| Enable shadow validation | Exposes the capability after required services, permissions, and rollout policy are ready. | Off | `enable_document_access_index_shadow_validation`; capability toggle | -| Backfill Batch Size | Documents processed per manual or scheduled batch. | 200 | `document_access_index_backfill_batch_size` | -| Repair Batch Size | Fail-open repair records reconciled before each backfill batch. | 100 | `document_access_index_repair_batch_size` | -| Document access index reads Wave 5B default | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_document_access_index_reads`; capability toggle | -| Cosmos Throughput Container Policies Json | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `cosmos_throughput_container_policies_json` | -| Enable Cosmos throughput automation | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `cosmos_throughput_autoscale_enabled` | -| Subscription ID | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `cosmos_throughput_subscription_id` | -| Resource Group | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `cosmos_throughput_resource_group` | -| Cosmos Account | Provides displayed text that users see in the affected interface. | Not specified in defaults | `cosmos_throughput_account_name` | -| Database | Provides displayed text that users see in the affected interface. | Not specified in defaults | `cosmos_throughput_database_name` | -| Metrics Window | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 5 | `cosmos_throughput_metrics_window_minutes` | -| Auto scale up | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `cosmos_throughput_auto_scale_up_enabled` | -| Scale Up At | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 90 | `cosmos_throughput_scale_up_threshold_percent` | -| Scale Up Step | Defines behavior for the related admin workflow; verify the affected feature after saving. | 1000 | `cosmos_throughput_scale_up_step_ru` | -| Scale Up Interval | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 5 | `cosmos_throughput_scale_up_cooldown_minutes` | -| Maximum RU/s | SimpleChat-managed scaling stops at 10,000 RU/s. Use the Azure portal above this limit. | Not specified in defaults | `cosmos_throughput_max_ru` | -| Ignore maximum guardrail | Defines a capacity or timing boundary that keeps the feature inside supported limits. | Off | `cosmos_throughput_ignore_max_limit` | -| Auto scale down | Defines behavior for the related admin workflow; verify the affected feature after saving. | On | `cosmos_throughput_auto_scale_down_enabled` | -| Scale Down At | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 70 | `cosmos_throughput_scale_down_threshold_percent` | -| Scale Down Step | Defines behavior for the related admin workflow; verify the affected feature after saving. | 1000 | `cosmos_throughput_scale_down_step_ru` | -| Scale Down Interval | Defines a capacity or timing boundary that keeps the feature inside supported limits. | 20 | `cosmos_throughput_scale_down_cooldown_minutes` | -| Minimum RU/s | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | `cosmos_throughput_min_ru` | -| Ignore minimum guardrail | Defines a capacity or timing boundary that keeps the feature inside supported limits. | Off | `cosmos_throughput_ignore_min_limit` | -| Convert manual throughput to Cosmos autoscale | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `cosmos_throughput_convert_manual_to_autoscale_enabled` | -| Enforce global policy for all containers | Defines behavior for the related admin workflow; verify the affected feature after saving. | Off | `cosmos_throughput_enforce_container_defaults` | -| Filter Containers | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | Runtime UI control | -| Filter Container Policies | Defines behavior for the related admin workflow; verify the affected feature after saving. | N/A (runtime control) | Runtime UI control | +| Enable Cosmos throughput automation | Runs the background check that scales throughput within the guardrails. | Off | `cosmos_throughput_autoscale_enabled`; capability toggle | +| Subscription ID | The subscription holding the Cosmos account. | Blank, uses `AZURE_SUBSCRIPTION_ID` | `cosmos_throughput_subscription_id` | +| Resource Group | The resource group holding the Cosmos account. | Blank, uses `AZURE_RESOURCE_GROUP` | `cosmos_throughput_resource_group` | +| Cosmos Account | The Cosmos account name. | Blank, uses the account in `AZURE_COSMOS_ENDPOINT` | `cosmos_throughput_account_name` | +| Database | The database whose throughput is managed. | `SimpleChat` | `cosmos_throughput_database_name` | +| Metrics Window | How much recent utilization each check reads, and how often automation runs. 1 to 60 minutes. | 5 minutes | `cosmos_throughput_metrics_window_minutes` | +| Auto scale up | Raises throughput when utilization crosses Scale Up At. | On | `cosmos_throughput_auto_scale_up_enabled` | +| Scale Up At | Utilization that triggers a scale up. Must be higher than Scale Down At. | 90% | `cosmos_throughput_scale_up_threshold_percent` | +| Scale Up Step | RU/s added per scale up, automatic or manual. | 1,000 RU/s | `cosmos_throughput_scale_up_step_ru` | +| Scale Up Interval | The shortest gap between scale ups. At least the metrics window. | 5 minutes | `cosmos_throughput_scale_up_cooldown_minutes` | +| Maximum RU/s | Scaling up stops here. At most 10,000 RU/s. | 10,000 RU/s | `cosmos_throughput_max_ru` | +| Ignore maximum guardrail | Scales past Maximum RU/s. The 10,000 RU/s ceiling still applies. | Off | `cosmos_throughput_ignore_max_limit` | +| Auto scale down | Lowers throughput when utilization stays under Scale Down At. | On | `cosmos_throughput_auto_scale_down_enabled` | +| Scale Down At | Utilization that allows a scale down. Must be lower than Scale Up At. | 70% | `cosmos_throughput_scale_down_threshold_percent` | +| Scale Down Step | RU/s removed per scale down, automatic or manual. | 1,000 RU/s | `cosmos_throughput_scale_down_step_ru` | +| Scale Down Interval | The shortest gap between scale downs. At least the metrics window. | 20 minutes | `cosmos_throughput_scale_down_cooldown_minutes` | +| Minimum RU/s | Scaling down stops here. | 1,000 RU/s | `cosmos_throughput_min_ru` | +| Ignore minimum guardrail | Scales below Minimum RU/s, down to the Cosmos service minimum. | Off | `cosmos_throughput_ignore_min_limit` | +| Convert manual throughput to Cosmos autoscale | Converts manual database or dedicated container throughput to native autoscale before applying the guardrails. | Off | `cosmos_throughput_convert_manual_to_autoscale_enabled` | +| Enforce global policy for all containers | Every dedicated-throughput container, including ones discovered later, uses the policy above instead of its own. | Off | `cosmos_throughput_enforce_container_defaults` | + +### Cosmos Metrics {#cosmos-throughput-metrics-table-section} + +Cosmos Metrics answers which container is hot. It lists every container with its RU utilization, +mode, RU/s, and request units over the metrics window, and can be filtered by name and sorted by +utilization, RU/s, request units, or policy. When Azure Monitor sends no utilization percentage +for a container, the utilization is estimated from request units against current RU/s and marked +**est.** + +Selecting a container shows its details, its manual capacity actions, which confirm first like +the database ones, and its automation policy. The policy is edited in place and saved with the +page's **Save changes**, like any other setting. It starts from the values that govern the +container now, so a value it does not set shows the global value it inherits. + +- **Use global values** replaces one container's policy with the global policy, and **Apply + global policy to all** does it for every listed container. Both take effect when you save. +- A container sharing database throughput has no policy of its own; database automation covers + it. A container above 10,000 RU/s is monitored only. +- While **Enforce global policy for all containers** is on, every container follows the global + policy and per-container policies cannot be edited. + +The policies are stored in `cosmos_throughput_container_policies`, which the server-rendered +page edits as `cosmos_throughput_container_policies_json`. The same rules apply to each container +as to the global policy. Each policy also records when automation last scaled or converted the +container, which its intervals depend on. Those times are always kept from the stored settings, +so saving a page that was loaded before a scale cannot reset a container's interval. ## Common tasks -1. **Enable Redis caching.** Set Redis host and authentication, then test a cache-dependent page. Outcome to verify: Distributed cache state is available. -2. **Tune cache freshness.** Set TTLs and change a test document. Outcome to verify: Users see expected freshness behavior. -3. **Guard Cosmos throughput.** Set thresholds, steps, cooldowns, and RU guardrails, then refresh metrics. Outcome to verify: Capacity changes stay within guardrails. +1. **Enable Redis.** Turn on Redis Cache, enter the host name, choose how SimpleChat signs in, and + select **Test Redis connection**. Save, then restart every web worker and the scheduler + together. Outcome to verify: Redis Metrics shows the connection as healthy and the app cache + as active. +2. **Keep the Redis key in Key Vault.** Configure Key Vault secret storage, choose Key Vault + authentication, and enter the secret's name in Key Vault Secret Name. Outcome to verify: the + connection test passes and the Redis card no longer shows a Key Vault prerequisite. +3. **Confirm an upgrade has settled.** Check that background maintenance is on, then open DAI + Metrics. Outcome to verify: the backfill state is completed and the fallback rate is near + zero. +4. **Guard Cosmos throughput.** Leave the resource blank to use the App Service settings, or + fill it in, then select **Validate access**. Set thresholds, steps, intervals, and guardrails, + turn on automation, and save. Outcome to verify: after the next metrics window, the status + shows a fresh check time. +5. **Change capacity now.** Select **Scale up** or **Scale down** on Cosmos DB Throughput, or on a + container in Cosmos Metrics, and review the target in the confirmation. Outcome to verify: the + card reports the change and the refreshed RU/s. +6. **Give one container its own limits.** In Cosmos Metrics, select the container, change its + policy, and save the page. Outcome to verify: the container's Policy reads the new range. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | -| Cosmos scaling changes too often | Thresholds or cooldowns are too aggressive. | Adjust thresholds after reviewing metrics. | +| Redis Metrics shows nothing to monitor | Redis Cache is off, or on but not yet saved. | Enable Redis Cache, save, and restart the workers and scheduler. | +| Some Redis counters read "Not available" | Azure Managed Redis omits several `INFO` fields. | Expected. Use Health and ping latency to judge the connection. | +| The Redis test fails with Key Vault authentication | The secret name is wrong, or Key Vault secret storage is not configured. | Follow the Key Vault link on the Redis card, then check the secret name. | +| Document lists are slow after an upgrade | The document access index has not converged, so reads fall back to source queries. | Keep background maintenance on and follow the backfill in DAI Metrics. | +| A save is refused with "Scale Up At must be higher than Scale Down At." | The thresholds overlap, globally or in a container policy. | Raise Scale Up At or lower Scale Down At. The field that broke the rule is marked. | +| A save is refused because an interval is shorter than the metrics window | The metrics window was raised past a scale interval. | Lengthen the interval or shorten the window. | +| Scale up is unavailable with a message about 10,000 RU/s | The target is at or above SimpleChat's ceiling. | Change capacity in the Azure portal. | +| A container has no policy to edit | It shares database throughput, is monitored only above 10,000 RU/s, or the global policy is enforced. | Use the database policy, the Azure portal, or turn off enforcement. | +| Cosmos scaling changes too often | Thresholds or intervals are too aggressive. | Widen the gap between Scale Up At and Scale Down At, or lengthen the intervals. | ## Related - [Administration settings overview]({{ '/admin/' | relative_url }}) +- [Security settings]({{ '/admin/security/' | relative_url }}) - [Operations settings]({{ '/admin/operations/' | relative_url }}) - [Backup & Recovery settings]({{ '/admin/backup-recovery/' | relative_url }}) diff --git a/docs/explanation/features/V2_ADMIN_SCALE_SETTINGS.md b/docs/explanation/features/V2_ADMIN_SCALE_SETTINGS.md new file mode 100644 index 000000000..8bcf0044a --- /dev/null +++ b/docs/explanation/features/V2_ADMIN_SCALE_SETTINGS.md @@ -0,0 +1,170 @@ +# V2 Admin Scale Settings + +## Overview + +The V2 Admin Settings surface renders from `admin_settings_fields.py`, a machine-readable +description of every control. Sections with no entry there fall back to scanning the settings +document for `enable_*` booleans, which can only produce switches. + +Scale was almost entirely undescribed. Only Cosmos Maintenance declared its two switches, so the +rest of the group was whatever the scan could guess. The Redis connection, both cache TTLs, the +Cosmos resource, and every throughput guardrail were unreachable in V2, and none of the +operational surfaces the server-rendered page offers existed there: Redis health, the Redis +Explorer, document access index status, Cosmos maintenance runs, the throughput console, and +the per-container workbench. + +This work describes all seven Scale sections, builds native panels over the existing admin APIs, +enforces the throughput policy rules on the V2 save, and adds two generic schema descriptors. + +**Implemented in version:** 0.261.260 + +**Dependencies:** `admin_settings_nav.py` for section ids; the existing admin APIs under +`/api/admin/settings/redis-monitoring`, `/redis-explorer`, `/app-maintenance`, and +`/cosmos-throughput`; the shared connection-test dispatcher; and the rules and normalizers in +`functions_cosmos_throughput.py`. + +## What was missing + +| Section | V2 before | V2 after | +| --- | --- | --- | +| Redis Cache | One guessed switch | Connection group, Key Vault-aware key field, connection test, restart notice, "Used by" | +| Redis Metrics | Not rendered | Health and capacity readouts, loaded on first view, and an inline Redis Explorer | +| Conversation Cache | One guessed switch | Switch, TTL, and the last 15 minutes of cache activity | +| DAI Metrics | Guessed switches for flags the server forces on, which reverted on save | Always-on states, backfill, read, and cache metrics; diagnostics behind `enable_dai_debug` | +| Cosmos Maintenance | Two switches | Switches, indexing and cleanup status, and three on-demand runs | +| Cosmos DB Throughput | Not rendered, because none of its keys are named `enable_*` | Console, saved target readout, resource, metrics window, guardrails | +| Cosmos Metrics | Not rendered | Container workbench with per-container policies | + +`enable_dai_debug` had also been guessed into Operations > Debug Logging, although neither +interface offers a control for it. + +## Technical specifications + +### Schema + +The seven sections use the existing vocabulary: section capabilities (`role: capability`), +groups, `depends_on` chains copied from the server-rendered page's nesting, `requires` for +cross-section prerequisites, and the `secret` type for `redis_key`. The index diagnostics are +gated on a runtime flag, `{"flag": "dai_debug_enabled", "equals": True}`, which the settings +GET derives from `enable_dai_debug`. + +Two descriptors were added for every group: + +| Descriptor | Purpose | +| --- | --- | +| `label_variants` | Replaces a field's label and help while a condition holds. `redis_key` reads **Key Vault Secret Name** with Key Vault authentication, as the classic form relabels its one input. Declaring a second field would invent a setting nothing reads. | +| `requires.when` | Limits a prerequisite to one configuration. Redis needs Key Vault secret storage only while its key is read from a vault. | + +A `requires.target_section` now also drives a **Used by** line on the target card, built from +the same rules as the prerequisite notices (`buildSectionDependents` reads `collectRequirements`), +so Redis Cache lists exactly the sections that show a Redis notice. + +The four always-on index flags and `enable_dai_debug` are in `SUPPRESSED_CAPABILITY_KEYS`. +`cosmos_throughput_container_policies` claims V1's `cosmos_throughput_container_policies_json` +through `LEGACY_FIELD_NAMES`. The schema mirrors three constants from +`functions_cosmos_throughput.py` (10,000 RU/s ceiling, 1,000 RU/s minimum, `SimpleChat` +database), and the parity test compares them. + +### Save path + +`normalize_admin_settings_updates` gained three Scale rules: + +| Rule | Behaviour | +| --- | --- | +| `_validate_redis_port` | Blank, or 1 to 65535, stored as a string because the connection test reads it with `.strip()`. | +| `_normalize_cosmos_container_policies` | Accepts V1's JSON string or the workbench's object. Each policy's automation timestamps are always taken from the stored document, so a page loaded before a scale cannot reset a cooldown. | +| `_check_cosmos_throughput_policy` | Judges the merged state of a save with `collect_cosmos_throughput_policy_errors`, places each error on the setting keys involved, then normalizes the save as V1 does and reports any value it adjusted as a warning. | + +`collect_cosmos_throughput_policy_errors` is new in `functions_cosmos_throughput.py`. It returns +each broken rule with its scope, container, rule id, fields, setting keys, and message. +`validate_cosmos_throughput_policy_settings`, which V1 calls, now wraps it and returns the same +messages as before. + +### Settings GET + +`runtime_flags.dai_debug_enabled` reports `enable_dai_debug`, and the `cosmos_throughput_resource` +status readout names the account and database automation manages, and whether each value comes +from saved settings or App Service settings. + +### Browser + +| File | Role | +| --- | --- | +| `RedisMonitoringPanel.tsx`, `RedisExplorer.tsx` | Redis health and the read-only key browser, inline in the card | +| `ConversationCacheMetrics.tsx`, `DocumentAccessIndexPanel.tsx`, `CosmosMaintenancePanel.tsx` | The three cards fed by the app maintenance status | +| `CosmosThroughputConsole.tsx`, `CosmosContainerMetrics.tsx`, `CosmosContainerPolicyForm.tsx` | Throughput status and actions, and the container workbench | +| `ConfirmActionModal.tsx`, `CosmosCapacityConfirm.tsx` | The confirmation in front of every change to a production resource | +| `OperationalReadouts.tsx` | Readouts, state pills, toolbars, and messages shared by the cards | +| `scaleFormat.ts`, `scaleRedis.ts`, `scaleMaintenance.ts`, `cosmosThroughput.ts` | Formatting, status wording, and the rules mirrored from Python | +| `stores/scaleStatusStore.ts` | Status shared between cards: one maintenance read feeds three cards, and one throughput status feeds two | + +Status loads when a card first scrolls into view, not with the page. Collections are browsed +inline, list beside detail, as the Model Catalog does; a modal is used only to confirm an action +that changes a production resource. + +The browser mirrors two server computations so it can explain a change before sending it: the +policy rules a save must pass, and the RU/s a manual scale will land on. The server stays +authoritative for both, and the mirrors are tested against cases generated from the Python +implementation. + +Two guards keep a confirmed change from being repeated or misdescribed. A capacity change stays +busy until the status has been read again, so the dialog cannot send a second step from the +capacity the first one replaced. **Validate access** keeps what it read with its result instead +of replacing the shared status, because it reads the values on screen and may describe a draft +target; manual changes always estimate from the saved target's status. The classic page shows +the validation's status in its status panel. + +### APIs + +No routes were added. The V2 panels call the admin APIs the server-rendered page uses, with the +same request bodies. + +| API | Used by | +| --- | --- | +| `GET /api/admin/settings/redis-monitoring/status` | Redis Metrics | +| `GET /api/admin/settings/redis-explorer/keys`, `POST .../value` | Redis Explorer | +| `GET /api/admin/settings/app-maintenance/status` | Conversation Cache, DAI Metrics, Cosmos Maintenance | +| `POST /api/admin/settings/app-maintenance/run` | DAI Metrics, Cosmos Maintenance | +| `GET /api/admin/settings/cosmos-throughput/status` | Cosmos DB Throughput, Cosmos Metrics | +| `POST /api/admin/settings/cosmos-throughput/validate-access`, `/scale`, `/convert-autoscale` | Cosmos DB Throughput, Cosmos Metrics | +| `POST /api/v2/admin/settings/test-connection` (`test_type: redis`) | Redis Cache | + +## Usage + +Open **Admin Settings > Scale**. The group has two tabs, **Redis & Caching** and **Cosmos**. + +- **Redis.** Enable Redis Cache, open **Connection**, and enter the host name and how + SimpleChat signs in. **Test Redis connection** checks the values on screen before saving. + Changing Redis needs a coordinated restart of every worker and the scheduler. +- **Index diagnostics.** Set `enable_dai_debug` to true in the settings document. DAI Metrics + then shows shadow validation, batch sizes, the list cache settings, and the backfill actions. +- **Throughput.** Leave the resource blank to use App Service settings, or fill it in, and use + **Validate access** before turning on automation. **Scale up**, **Scale down**, and **Convert + to autoscale** confirm the change first and use the saved policy. +- **Container policies.** In Cosmos Metrics select a container, edit its policy, and save the + page. The form starts from the values that govern the container, including inherited ones. + +The admin documentation for the group is `docs/admin/scale.md`. + +## Testing and validation + +| Test | Coverage | +| --- | --- | +| `functional_tests/test_v2_admin_scale_parity.py` | Panes match navigation; every V1 field is claimed and none invented; secret, selects, number bounds, and port range match V1; dependency chains; diagnostics follow the debug flag; always-on flags stay uneditable; defaults and mirrored constants; routes, methods, and maintenance request bodies match V1; the GET sends the debug flag and readout | +| `functional_tests/test_v2_admin_scale_normalization.py` | Port storage, selects and secret round trip, bounds, every policy rule against V1's messages, partial saves, automation off, RU/s rounding warnings, both policy shapes, stored timestamps, unrelated saves | +| `functional_tests/test_v2_admin_scale_logic.py` with `.ts` | Generates rounding, validation, container policy, and manual scale cases from the Python functions and requires the browser mirrors to agree; label variants, conditional prerequisites, "Used by", status wording, sorting, actions, formatting | +| `ui_tests/test_v2_admin_scale_settings.py` | Key Vault relabelling and prerequisite, lazy metrics, explorer paging and errors, connection test refresh, diagnostics gate, confirmations and request bodies, validate access with draft values, scale confirmation, workbench edit and save, enforced policy, no overflow at phone and desktop widths in both themes | + +`test_v2_admin_capability_placement.py` now treats Scale as fully described, and +`test_v2_admin_schema_vocabulary.py` checks `label_variants` and `requires.when`. + +### Known limitations + +- Cache activity counters are counted per app worker. Application Insights remains the + fleet-wide record. +- A manual scale's target is an estimate from saved settings. The server computes the real + target and its result is reported after the change. +- The Key Vault secret name is held in the `redis_key` secret, so V2 shows it as stored rather + than in a password box as V1 does. +- `enable_search_result_caching` has no control on the server-rendered page, and the V2 fallback + scan still places its switch with Web Search under Knowledge. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index ebc32df8c..3f7170df7 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,31 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.260)** + +#### New Features + +* **Scale Settings in V2 Admin Settings** + * The V2 Scale group now covers everything the classic page offers. Redis Cache has its connection settings, an access key field that becomes the Key Vault secret name with Key Vault authentication, and **Test Redis connection**. Redis Metrics shows health and capacity with an inline, read-only Redis Explorer, and Conversation Cache has its TTL and the last 15 minutes of cache activity. + * DAI Metrics shows the always-on index components, backfill progress, and read and cache metrics. Its diagnostics, batch sizes, and backfill actions appear only when `enable_dai_debug` is set, as on the classic page. + * Cosmos Maintenance shows indexing and stale cache status and runs its tasks on demand. Cosmos DB Throughput adds the resource, guardrails, access validation, and manual scaling, and Cosmos Metrics is a container workbench where each container's policy is edited in place and saved with the page. + * Applying indexes, deleting stale cache documents, resetting the backfill, and scaling or converting throughput each ask for confirmation first. A scale states the current RU/s and the target the server will most likely choose. + * Saves enforce the classic throughput rules, with each error beside the value that caused it, and report any RU/s value the server rounds. Container policy saves keep the automation's own timestamps, so a page loaded before a scale cannot reset a cooldown. + * (Ref: `admin_settings_fields.py`, `functions_cosmos_throughput.py`, `route_backend_v2.py`, `AdminSettingsPage.tsx`, `CosmosThroughputConsole.tsx`, `CosmosContainerMetrics.tsx`, `RedisMonitoringPanel.tsx`, `DocumentAccessIndexPanel.tsx`, [V2 Admin Scale Settings](features/V2_ADMIN_SCALE_SETTINGS.md)) + +#### User Interface Enhancements + +* **Settings Cards Show What Depends on Them** + * A section other sections rely on now lists them under **Used by**. Redis Cache, for example, lists Conversation Cache, Redis Metrics, and File Sync. The list follows the same rules as the prerequisite notices on those cards, and both link within the page when the other section is shown. + * A field's label can follow another setting, and a prerequisite can apply to one configuration only. The schema descriptors are `label_variants` and `requires.when`. + * (Ref: `SettingsSection.tsx`, `adminSections.ts`, `adminFields.ts`, `admin_settings_fields.py`) + +#### Bug Fixes + +* **Inbound MCP Settings Show With the Preview Enabled** + * With the Inbound MCP preview enabled, the V2 card hid every Inbound MCP setting and showed the "preview disabled" notice instead, because the card checked field visibility without the server's runtime flags. The card now uses the same flags as the page. + * (Ref: `SettingsSection.tsx`, `test_v2_admin_section_logic.ts`) + ### **(v0.261.259)** #### Bug Fixes diff --git a/functional_tests/test_docs_app_surface_coverage.py b/functional_tests/test_docs_app_surface_coverage.py index 343b0e707..2cfc7de5b 100644 --- a/functional_tests/test_docs_app_surface_coverage.py +++ b/functional_tests/test_docs_app_surface_coverage.py @@ -57,7 +57,10 @@ "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_dai_debug": ( + "Document access index diagnostics switch, set in the settings document for " + "support sessions; neither admin interface offers a control for it." + ), "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..b06471241 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 +Scale group coverage added in: 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 @@ -45,6 +46,13 @@ "storage" in ``data-management-storage-section`` and appeared under Backup & Recovery, while ``enable_key_vault_secret_expiration_reminders`` matched nothing at all and fell into "Other capabilities". + +Scale was described after that. The scan had drawn switches for the always-on +Document Access Index flags, which ``get_settings`` forces back to True on every +read, so each would appear to save and then revert. ``enable_dai_debug`` matched +"debug" and appeared under Operations > Debug Logging, although it is an App +Service setting the server-rendered page never offers a control for. All five are +now suppressed. """ import re @@ -68,7 +76,7 @@ # 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", "scale") # 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 @@ -120,6 +128,14 @@ "enable_enhanced_citations_mount", "enable_mixed_source_chat_search", "enable_mixed_source_conversation_continuity", + # Scale. The Document Access Index flags are forced on by + # normalize_document_access_index_required_settings on every read and write, and + # enable_dai_debug is an App Service setting V1 never offers a control for. + "enable_document_access_index_container", + "enable_document_access_index_write_through", + "enable_document_access_index_reads", + "enable_startup_document_access_index_backfill", + "enable_dai_debug", ) # Relocations with no server-rendered counterpart to check against. Both are diff --git a/functional_tests/test_v2_admin_scale_logic.py b/functional_tests/test_v2_admin_scale_logic.py new file mode 100644 index 000000000..3ef80a467 --- /dev/null +++ b/functional_tests/test_v2_admin_scale_logic.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +# test_v2_admin_scale_logic.py +""" +Functional test for the browser logic behind the Admin Settings Scale group. +Version: 0.261.260 +Implemented in: 0.261.260 + +The Scale panels mirror two pieces of ``functions_cosmos_throughput.py`` so they can +explain a change before it is sent: the throughput policy rules a save must pass, +and the RU/s a manual scale will land on. The server stays authoritative, but a +browser copy that drifts would show an administrator a reason the server never +gives, or a confirmation promising a target the scale will not reach. + +This runner generates cases from the real Python functions -- ``normalize_ru``, +``collect_cosmos_throughput_policy_errors``, ``normalize_container_policy`` and +``calculate_manual_scale_target`` -- together with the live field schema, writes +them to a temporary file, and executes ``test_v2_admin_scale_logic.ts`` against +them. A rule changed on one side therefore fails here until the other follows. +The TypeScript file also pins the presentation logic that has no server +counterpart. +""" + +import json +import os +import subprocess +import sys +import tempfile +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] +V2_DIR = REPO_ROOT / "application" / "v2_ui" +LOGIC_CHECK_TS = Path(__file__).resolve().parent / "test_v2_admin_scale_logic.ts" + +fields_module = import_app_module("admin_settings_fields") +throughput_module = import_app_module("functions_cosmos_throughput") + +POLICY_RULES = {"threshold_order", "scale_up_interval", "scale_down_interval"} + + +def _base_settings(**overrides): + """Throughput settings as stored, with automation on and default guardrails.""" + settings = { + "cosmos_throughput_autoscale_enabled": True, + "cosmos_throughput_metrics_window_minutes": 5, + "cosmos_throughput_scale_up_threshold_percent": 90, + "cosmos_throughput_scale_down_threshold_percent": 70, + "cosmos_throughput_scale_up_step_ru": 1000, + "cosmos_throughput_scale_down_step_ru": 1000, + "cosmos_throughput_scale_up_cooldown_minutes": 5, + "cosmos_throughput_scale_down_cooldown_minutes": 20, + "cosmos_throughput_min_ru": 2000, + "cosmos_throughput_max_ru": 6000, + } + settings.update(overrides) + return settings + + +def build_normalize_ru_cases(): + """Every value shape a stored setting or a status payload may hold.""" + values = [ + 0, 1, 99, 100, 101, 399, 400, 401, 450, 999, 1000, 1001, 1450, 1500, 2500, + 9999, 10000, 10001, 12345, -5, 5.7, "1500", " 2000 ", "+3000", "1.5", + "abc", "", None, True, False, + ] + modes = ["autoscale", "manual", "Autoscale", " autoscale ", None, ""] + cases = [] + for value in values: + for mode in modes: + for direction in ("up", "down"): + cases.append({ + "value": value, + "mode": mode, + "direction": direction, + "expected": throughput_module.normalize_ru(value, mode=mode, direction=direction), + }) + return cases + + +def build_policy_validation_cases(): + """Settings that pass, and settings that break each rule globally and per container.""" + broken_container = {"scale_up_threshold_percent": 50, "scale_down_threshold_percent": 60} + scenarios = ( + ("automation off ignores broken values", _base_settings( + cosmos_throughput_autoscale_enabled=False, + cosmos_throughput_scale_up_threshold_percent=50, + cosmos_throughput_scale_down_threshold_percent=60, + )), + ("valid settings", _base_settings()), + ("threshold order", _base_settings( + cosmos_throughput_scale_up_threshold_percent=70, + cosmos_throughput_scale_down_threshold_percent=70, + )), + ("window longer than both intervals", _base_settings( + cosmos_throughput_metrics_window_minutes=30, + )), + ("every global rule at once", _base_settings( + cosmos_throughput_scale_up_threshold_percent=40, + cosmos_throughput_scale_down_threshold_percent=60, + cosmos_throughput_metrics_window_minutes=60, + )), + ("form strings are coerced before judging", _base_settings( + cosmos_throughput_autoscale_enabled="on", + cosmos_throughput_scale_up_threshold_percent="80", + cosmos_throughput_scale_down_threshold_percent="85", + cosmos_throughput_metrics_window_minutes="5", + )), + ("out-of-range values are clamped before judging", _base_settings( + cosmos_throughput_scale_up_threshold_percent=150, + cosmos_throughput_scale_down_threshold_percent=120, + )), + ("missing values use the defaults", {"cosmos_throughput_autoscale_enabled": True}), + ("a container inherits a broken global policy", _base_settings( + cosmos_throughput_scale_up_threshold_percent=70, + cosmos_throughput_scale_down_threshold_percent=70, + cosmos_throughput_container_policies={"conversations": {}}, + )), + ("a container breaks its own interval", _base_settings( + cosmos_throughput_metrics_window_minutes=10, + cosmos_throughput_scale_up_cooldown_minutes=10, + cosmos_throughput_container_policies={ + "conversations": {"scale_up_cooldown_minutes": 5}, + "documents": {"scale_down_cooldown_minutes": 15}, + }, + )), + ("an enforced global policy skips containers", _base_settings( + cosmos_throughput_enforce_container_defaults=True, + cosmos_throughput_container_policies={"conversations": broken_container}, + )), + ("a disabled container policy is skipped", _base_settings( + cosmos_throughput_container_policies={ + "conversations": {**broken_container, "enabled": False}, + }, + )), + ("explicit nulls fall back to the global policy", _base_settings( + cosmos_throughput_container_policies={ + "conversations": {"scale_up_threshold_percent": None, "min_ru": None}, + }, + )), + ("a blank container name is ignored", _base_settings( + cosmos_throughput_container_policies={" ": broken_container}, + )), + ) + + cases = [] + for name, settings in scenarios: + problems = throughput_module.collect_cosmos_throughput_policy_errors(settings) + container_errors = {} + for problem in problems: + if problem["scope"] == "container": + container_errors.setdefault(problem["container_name"], set()).update(problem["fields"]) + cases.append({ + "name": name, + "settings": settings, + "rules": sorted({problem["rule"] for problem in problems}), + "messages": [problem["message"] for problem in problems], + "field_error_keys": sorted( + {key for problem in problems for key in problem["setting_keys"]} + ), + "container_errors": { + container: sorted(fields) for container, fields in container_errors.items() + }, + }) + return cases + + +def build_container_policy_cases(): + """Container policies as saved, as left blank, and as typed into the classic form.""" + settings = _base_settings() + broken = { + "scale_up_threshold_percent": 50, + "scale_down_threshold_percent": 60, + "min_ru": 8000, + "max_ru": 3000, + } + scenarios = ( + ("no saved policy inherits the global policy", "conversations", None, settings, True), + ("an empty policy inherits the global policy", "conversations", {}, settings, True), + ("own values", "conversations", { + "min_ru": 2000, + "max_ru": 6000, + "scale_up_threshold_percent": 80, + "enabled": "false", + }, settings, True), + ("explicit nulls", "conversations", { + "min_ru": None, + "max_ru": None, + "scale_up_step_ru": None, + "scale_up_threshold_percent": None, + "auto_scale_up_enabled": None, + "enabled": None, + }, settings, True), + ("broken relationships are repaired for use", "conversations", broken, settings, True), + ("broken relationships are kept for validation", "conversations", broken, settings, False), + ("values above SimpleChat's ceiling are capped", "conversations", { + "min_ru": 15000, + "max_ru": 20000, + }, settings, True), + ("strings from the classic form", "conversations", { + "min_ru": "2500", + "scale_down_cooldown_minutes": "30", + "ignore_max_limit": "on", + "convert_manual_to_autoscale_enabled": "yes", + }, settings, True), + ("the automation's timestamps pass through", "conversations", { + "last_scale_up_at": "2026-01-01T00:00:00+00:00", + "last_mode_conversion_at": "2026-01-02T00:00:00+00:00", + }, settings, True), + ("the policy's own name is used when none is given", "", {"container_name": "documents"}, settings, True), + ("global values arriving as strings", "conversations", {}, { + "cosmos_throughput_min_ru": "3000", + "cosmos_throughput_auto_scale_up_enabled": "off", + "cosmos_throughput_ignore_min_limit": "1", + }, True), + ) + + cases = [] + for name, container, policy, source, repair in scenarios: + normalized = throughput_module.normalize_cosmos_throughput_settings( + source, + repair_policy_relationships=repair, + ) + cases.append({ + "name": name, + "container": container, + "policy": policy, + "settings": source, + "repair": repair, + "expected": throughput_module.normalize_container_policy( + container, + policy, + normalized, + repair_policy_relationships=repair, + ), + }) + return cases + + +def _database(current_ru, mode="autoscale", scalable=True): + return {"throughput": {"mode": mode, "current_ru": current_ru, "is_scalable": scalable}} + + +def _containers(*containers): + return {"capacity_scope": "container", "containers": list(containers)} + + +def build_manual_scale_cases(): + """Manual scales up and down, against each guardrail that can stop them.""" + base = _base_settings() + scenarios = ( + ("database up one step", base, _database(4000), "up", ""), + ("database up stops at the maximum", base, _database(6000), "up", ""), + ("database up past the maximum when it is ignored", _base_settings( + cosmos_throughput_ignore_max_limit=True, + ), _database(6000), "up", ""), + ("database up is capped at SimpleChat's ceiling", _base_settings( + cosmos_throughput_ignore_max_limit=True, + cosmos_throughput_scale_up_step_ru=3000, + ), _database(9000), "up", ""), + ("database at the ceiling", _base_settings( + cosmos_throughput_ignore_max_limit=True, + ), _database(10000), "up", ""), + ("database above the ceiling", base, _database(20000), "down", ""), + ("database down one step", base, _database(4000), "down", ""), + ("database down stops at the minimum", base, _database(2000), "down", ""), + ("database down past the minimum when it is ignored", _base_settings( + cosmos_throughput_ignore_min_limit=True, + ), _database(2000), "down", ""), + ("autoscale cannot go below its service minimum", _base_settings( + cosmos_throughput_ignore_min_limit=True, + ), _database(1000), "down", ""), + ("manual throughput moves in 100 RU/s steps", base, _database(450, mode="manual"), "up", ""), + ("manual throughput down to its service minimum", _base_settings( + cosmos_throughput_ignore_min_limit=True, + ), _database(1450, mode="manual"), "down", ""), + ("unknown current throughput", base, _database(None), "up", ""), + ("no throughput reported at all", base, {}, "up", ""), + ("a container uses its own maximum", base, _containers({ + "container_name": "conversations", + "mode": "autoscale", + "current_ru": 4000, + "is_scalable": True, + "policy": {"max_ru": 4000}, + }), "up", "conversations"), + ("a container inherits the global step", base, _containers({ + "container_name": "conversations", + "mode": "autoscale", + "current_ru": 3000, + "is_scalable": True, + "policy": {}, + }), "up", "conversations"), + ("a container scales down within its own minimum", base, _containers({ + "container_name": "documents", + "mode": "autoscale", + "current_ru": 5000, + "is_scalable": True, + "policy": {"min_ru": 5000}, + }), "down", "documents"), + ("a container above the ceiling", base, _containers({ + "container_name": "conversations", + "mode": "autoscale", + "current_ru": 12000, + "is_scalable": True, + }), "up", "conversations"), + ) + + cases = [] + for name, settings, status, direction, container in scenarios: + try: + expected = { + "target": throughput_module.calculate_manual_scale_target( + settings, status, direction, container_name=container + ) + } + except throughput_module.CosmosThroughputError as error: + expected = {"error": str(error)} + cases.append({ + "name": name, + "settings": settings, + "status": status, + "direction": direction, + "container": container, + "expected": expected, + }) + return cases + + +def build_schema_sections(): + """Every declared section as the settings API sends it, labelled as in navigation.""" + schema = fields_module.get_admin_settings_fields() + sections = [] + for group in ADMIN_NAV: + for tab in group["tabs"]: + for section in tab["sections"]: + fields = schema.get(section["id"]) + if fields: + sections.append({ + "sectionId": section["id"], + "label": section["label"], + "fields": fields, + }) + return sections + + +def build_mirror_cases(): + return { + "normalize_ru": build_normalize_ru_cases(), + "policy_validation": build_policy_validation_cases(), + "container_policies": build_container_policy_cases(), + "manual_scale": build_manual_scale_cases(), + "sections": build_schema_sections(), + } + + +def test_generated_cases_exercise_every_rule(): + """A mirror check over cases that never break a rule proves very little.""" + print("Testing the generated mirror cases...") + + assert_app_version_at_least("0.261.260") + + cases = build_mirror_cases() + + validation = cases["policy_validation"] + broken_rules = {rule for case in validation for rule in case["rules"]} + assert broken_rules == POLICY_RULES, f"Rules never exercised: {POLICY_RULES - broken_rules}" + assert any(not case["messages"] for case in validation), "No case passes validation." + assert any(case["container_errors"] for case in validation), "No case breaks a container policy." + + outcomes = cases["manual_scale"] + errors = {case["expected"].get("error") for case in outcomes if "error" in case["expected"]} + for message in ( + "Maximum RU/s limit is already reached.", + "Minimum RU/s limit is already reached.", + "Current Cosmos throughput could not be determined.", + throughput_module.COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE, + ): + assert message in errors, f"No manual scale case produces: {message}" + assert sum("target" in case["expected"] for case in outcomes) >= 8, "Too few successful scales." + + section_ids = {section["sectionId"] for section in cases["sections"]} + for section_id in ("redis-cache-section", "conversation-cache-section", "keyvault-section"): + assert section_id in section_ids, f"{section_id} is missing from the generated schema" + + json.dumps(cases) + + print( + f" {len(cases['normalize_ru'])} rounding, {len(validation)} validation, " + f"{len(cases['container_policies'])} policy and {len(outcomes)} scale case(s)." + ) + return True + + +def test_the_typescript_logic_checks_pass(): + """Execute the browser half, skipping when the front-end toolchain is absent.""" + print("\nTesting Scale logic (TypeScript)...") + + if not (V2_DIR / "node_modules").exists(): + print(" skip application/v2_ui/node_modules is absent; run npm install to include") + return True + + assert LOGIC_CHECK_TS.exists(), "The TypeScript logic checks are missing" + + handle, cases_path = tempfile.mkstemp(prefix="scale-logic-cases-", suffix=".json") + with os.fdopen(handle, "w", encoding="utf-8") as cases_file: + json.dump(build_mirror_cases(), cases_file) + + # 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-scale-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, + env={**os.environ, "SCALE_LOGIC_CASES": cases_path}, + shell=(sys.platform == "win32"), + ) + finally: + if bundle.exists(): + bundle.unlink() + os.unlink(cases_path) + + 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") + return True + + +if __name__ == "__main__": + tests = [ + test_generated_cases_exercise_every_rule, + test_the_typescript_logic_checks_pass, + ] + + results = [] + for test in tests: + try: + results.append(bool(test())) + 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_scale_logic.ts b/functional_tests/test_v2_admin_scale_logic.ts new file mode 100644 index 000000000..7b8ae1d7f --- /dev/null +++ b/functional_tests/test_v2_admin_scale_logic.ts @@ -0,0 +1,559 @@ +// test_v2_admin_scale_logic.ts +// +// Runtime test for the browser logic behind the Admin Settings Scale group. +// Version: 0.261.260 +// Implemented in: 0.261.260 +// +// The Scale panels explain themselves before anything is sent: the throughput rules a save +// must pass, the RU/s a manual scale will land on, which capacity actions apply, and what a +// status means. None of that shows up in review or in a screenshot -- a wrong estimate is a +// plausible number in a confirmation dialog -- so it is executed here. +// +// Two kinds of check run. The mirror checks read cases that test_v2_admin_scale_logic.py +// generates from functions_cosmos_throughput.py and the live schema, and require the +// browser to reach exactly the server's answers, so a rule changed on one side fails until +// the other follows. The rest pin presentation logic with no server counterpart. +// +// Run by test_v2_admin_scale_logic.py, which bundles this with esbuild and executes it under +// node, passing the generated cases' path in SCALE_LOGIC_CASES. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE, + buildAccessValidationPayload, + containerActions, + containerPolicyLabel, + containerScalePolicy, + containerUtilization, + databaseActions, + databaseScalePolicy, + describeCosmosThroughputStatus, + effectiveContainerPolicy, + estimateManualScaleTarget, + filterContainers, + formatContainerUtilization, + hasUnsavedThroughputEdits, + mergeRuntimePolicyFields, + nextContainerSort, + normalizeContainerPolicy, + normalizeRu, + readGlobalPolicy, + sortContainers, + validateCosmosThroughputPolicy, + type CosmosContainerPolicy, + type CosmosContainerStatus, + type CosmosThroughputStatus, +} from '../application/v2_ui/src/lib/cosmosThroughput'; +import { + formatBytes, + formatRelativeTime, + formatTtl, + humanizeStatus, + maintenanceStatusTone, +} from '../application/v2_ui/src/lib/scaleFormat'; +import { + describeRedisRefresh, + readRedisExplorerError, + redisHealthTone, + type RedisMonitoringStatus, +} from '../application/v2_ui/src/lib/scaleRedis'; +import { + backfillStatusFromRun, + describeBackfillRun, + describeCleanupRun, + indexingPolicyStatus, + isBackfillRunning, + type AppMaintenanceRunResult, + type CosmosIndexingPolicyStatus, + type DocumentAccessIndexStatus, +} from '../application/v2_ui/src/lib/scaleMaintenance'; +import { resolveFieldPresentation, type AdminField } from '../application/v2_ui/src/lib/adminFields'; +import { buildSectionDependents, collectRequirements } from '../application/v2_ui/src/lib/adminSections'; + +type Json = Record; + +interface MirrorCases { + normalize_ru: { value: unknown; mode: unknown; direction: 'up' | 'down'; expected: number }[]; + policy_validation: { + name: string; + settings: Json; + messages: string[]; + field_error_keys: string[]; + container_errors: Record; + }[]; + container_policies: { + name: string; + container: string; + policy: CosmosContainerPolicy | null; + settings: Json; + repair: boolean; + expected: Json; + }[]; + manual_scale: { + name: string; + settings: Json; + status: CosmosThroughputStatus; + direction: 'up' | 'down'; + container: string; + expected: { target?: number; error?: string }; + }[]; + sections: { sectionId: string; label: string; fields: AdminField[] }[]; +} + +const casesPath = process.env.SCALE_LOGIC_CASES; +const cases: MirrorCases | null = casesPath ? JSON.parse(readFileSync(casesPath, 'utf-8')) : null; + +function requireCases(): MirrorCases { + assert.ok(cases, 'SCALE_LOGIC_CASES is not set; run this through test_v2_admin_scale_logic.py'); + return cases; +} + +/** A settings reader with `dict.get` semantics: a missing key reads as undefined. */ +function reader(values: Json): (key: string) => unknown { + return (key) => values[key]; +} + +function sectionFields(sectionId: string): AdminField[] { + const section = requireCases().sections.find((candidate) => candidate.sectionId === sectionId); + assert.ok(section, `${sectionId} is not in the generated schema`); + return section.fields; +} + +const checks: [string, () => void][] = []; +function check(name: string, fn: () => void) { + checks.push([name, fn]); +} + +// --- Mirrors of functions_cosmos_throughput.py ---------------------------------------------- + +check('normalizeRu reaches normalize_ru for every generated case', () => { + const { normalize_ru: generated } = requireCases(); + assert.ok(generated.length > 100, 'too few normalize_ru cases were generated'); + for (const item of generated) { + assert.equal( + normalizeRu(item.value, item.mode, item.direction), + item.expected, + `normalize_ru(${JSON.stringify(item.value)}, ${JSON.stringify(item.mode)}, ${item.direction})`, + ); + } +}); + +check('policy validation reports what collect_cosmos_throughput_policy_errors reports', () => { + const { policy_validation: generated } = requireCases(); + for (const item of generated) { + const result = validateCosmosThroughputPolicy(reader(item.settings)); + assert.deepEqual(result.messages, item.messages, `${item.name}: messages`); + assert.deepEqual( + Object.keys(result.fieldErrors).sort(), + [...item.field_error_keys].sort(), + `${item.name}: the controls carrying an error`, + ); + for (const message of Object.values(result.fieldErrors)) { + assert.ok(item.messages.includes(message), `${item.name}: "${message}" is not a server message`); + } + const containerFields = Object.fromEntries( + Object.entries(result.containerErrors).map(([name, errors]) => [name, Object.keys(errors).sort()]), + ); + const expectedContainerFields = Object.fromEntries( + Object.entries(item.container_errors).map(([name, fields]) => [name, [...fields].sort()]), + ); + assert.deepEqual(containerFields, expectedContainerFields, `${item.name}: container policy fields`); + } +}); + +check('container policies normalize as normalize_container_policy does', () => { + const { container_policies: generated } = requireCases(); + for (const item of generated) { + const globals = readGlobalPolicy(reader(item.settings), item.repair); + const actual = normalizeContainerPolicy(item.container, item.policy, globals, item.repair); + assert.deepEqual(actual, item.expected, item.name); + } +}); + +check('manual scale estimates land where calculate_manual_scale_target lands', () => { + const { manual_scale: generated } = requireCases(); + for (const item of generated) { + const globals = readGlobalPolicy(reader(item.settings)); + let estimate; + if (item.container) { + const container = (item.status.containers ?? []).find( + (candidate) => candidate.container_name === item.container, + ); + assert.ok(container, `${item.name}: the generated status has no ${item.container}`); + estimate = estimateManualScaleTarget(container, containerScalePolicy(container, globals), item.direction); + } else { + estimate = estimateManualScaleTarget(item.status.throughput, databaseScalePolicy(globals), item.direction); + } + const outcome = estimate.target !== undefined ? { target: estimate.target } : { error: estimate.error }; + assert.deepEqual(outcome, item.expected, item.name); + } +}); + +// --- The declared Scale schema ---------------------------------------------------------------- + +check('the Redis key reads as a Key Vault secret name only in Key Vault mode', () => { + const redisKey = sectionFields('redis-cache-section').find((field) => field.key === 'redis_key'); + assert.ok(redisKey, 'redis_key is not declared'); + + const vault = resolveFieldPresentation(redisKey, reader({ redis_auth_type: 'key_vault' })); + assert.equal(vault.label, 'Key Vault Secret Name'); + assert.notEqual(vault.help, redisKey.help); + assert.equal(vault.key, 'redis_key', 'the variant relabels the same stored setting'); + + assert.equal(resolveFieldPresentation(redisKey, reader({ redis_auth_type: 'key' })), redisKey); +}); + +check('the Key Vault prerequisite applies only while Redis reads its key from a vault', () => { + const fields = sectionFields('redis-cache-section'); + const needsVault = (settings: Json, draft: Json = {}) => + collectRequirements(fields, settings, draft).some( + (requirement) => requirement.key === 'enable_key_vault_secret_storage', + ); + + assert.equal(needsVault({ enable_redis_cache: true, redis_auth_type: 'key_vault' }), true); + assert.equal(needsVault({ enable_redis_cache: true, redis_auth_type: 'key' }), false); + assert.equal(needsVault({ enable_redis_cache: true, redis_auth_type: 'managed_identity' }), false); + // An unsaved choice counts, so the notice appears as the administrator picks Key Vault. + assert.equal(needsVault({ enable_redis_cache: true, redis_auth_type: 'key' }, { redis_auth_type: 'key_vault' }), true); +}); + +check('"Used by" lists exactly the sections that show a prerequisite notice', () => { + const { sections } = requireCases(); + const usedBy = (settings: Json, target: string, flags?: Record) => + (buildSectionDependents(sections, settings, {}, flags).get(target) ?? []).map( + (dependent) => dependent.sectionId, + ); + const settings = { enable_redis_cache: true, redis_auth_type: 'key', enable_document_access_index_cache: true }; + + const redis = usedBy(settings, 'redis-cache-section'); + for (const sectionId of ['conversation-cache-section', 'redis-monitoring-section', 'file-sync-section']) { + assert.ok(redis.includes(sectionId), `Redis Cache should be used by ${sectionId}`); + } + // The index's Redis switch is a diagnostic, hidden without the debug flag. + assert.ok(!redis.includes('document-access-index-section'), 'a hidden switch adds no "Used by" entry'); + assert.ok( + usedBy(settings, 'redis-cache-section', { dai_debug_enabled: true }).includes('document-access-index-section'), + ); + assert.ok( + !usedBy({ ...settings, enable_document_access_index_cache: false }, 'redis-cache-section', { + dai_debug_enabled: true, + }).includes('document-access-index-section'), + 'a switched-off cache does not rely on Redis', + ); + + assert.ok(!usedBy(settings, 'keyvault-section').includes('redis-cache-section')); + assert.ok(usedBy({ ...settings, redis_auth_type: 'key_vault' }, 'keyvault-section').includes('redis-cache-section')); + assert.ok(usedBy(settings, 'cosmos-maintenance-section').includes('document-access-index-section')); + + // A section never lists itself. + for (const [target, dependents] of buildSectionDependents(sections, settings, {})) { + assert.ok(!dependents.some((dependent) => dependent.sectionId === target), `${target} lists itself`); + } +}); + +// --- Throughput presentation ------------------------------------------------------------------ + +check('throughput status messages follow the classic precedence', () => { + const describe = (status: Json) => describeCosmosThroughputStatus(status as CosmosThroughputStatus); + + assert.deepEqual(describe({ configured: false }), { + text: 'Cosmos throughput management needs subscription, resource group, account, and database settings.', + tone: 'warn', + }); + assert.equal(describe({ configured: false, error: 'Missing subscription.' }).text, 'Missing subscription.'); + assert.deepEqual(describe({ configured: true, throughput_error: 'Forbidden.' }), { + text: 'Cosmos database throughput could not be read. Forbidden.', + tone: 'danger', + }); + + const aggregateOnly = { + configured: true, + capacity_scope: 'container', + metrics: { normalized_ru_percent: 40 }, + containers: [{ container_name: 'a', current_ru: 1000, is_scalable: true }], + }; + assert.equal(describe(aggregateOnly).tone, 'warn'); + assert.match(describe(aggregateOnly).text, /not per-container metric dimensions/); + const perContainer = { + ...aggregateOnly, + containers: [{ container_name: 'a', current_ru: 1000, is_scalable: true, normalized_ru_percent: 40 }], + }; + assert.equal(describe(perContainer).tone, 'info'); + + const portal = { configured: true, throughput: { mode: 'autoscale', current_ru: 20000, is_scalable: true } }; + assert.match(describe(portal).text, /above 10,000 RU\/s/); + assert.equal(describe({ ...portal, capacity_scope: 'container' }).tone, 'warn', 'portal-managed outranks the scope note'); + + const loaded = { configured: true, throughput: { mode: 'autoscale', current_ru: 4000, is_scalable: true } }; + assert.equal(describe({ ...loaded, metric_error: 'No metrics.' }).tone, 'warn'); + assert.deepEqual(describe(loaded), { text: 'Cosmos throughput status loaded.', tone: 'ok' }); +}); + +check('container utilization prefers Azure Monitor and marks estimates', () => { + const measured = { container_name: 'a', normalized_ru_percent: 37.5, request_units: 1, current_ru: 1000 }; + assert.deepEqual(containerUtilization(measured as CosmosContainerStatus, 5), { value: 37.5, estimated: false }); + + // 600,000 RU over a five-minute window against 4,000 RU/s is half the capacity. + const estimated = { container_name: 'b', request_units: 600000, current_ru: 4000 } as CosmosContainerStatus; + assert.deepEqual(containerUtilization(estimated, 5), { value: 50, estimated: true }); + assert.equal(formatContainerUtilization(estimated, 5), '50.0% est.'); + assert.equal(formatContainerUtilization(estimated, 0), 'Not available'); + assert.equal(formatContainerUtilization({ container_name: 'c' } as CosmosContainerStatus, 5), 'Not available'); +}); + +check('container sorting keeps missing values last and breaks ties by name', () => { + const rows = [ + { container_name: 'beta', current_ru: 4000, normalized_ru_percent: 20 }, + { container_name: 'alpha', current_ru: 4000, normalized_ru_percent: 80 }, + { container_name: 'gamma', current_ru: null }, + { container_name: 'Delta', current_ru: 1000, normalized_ru_percent: 20 }, + ] as CosmosContainerStatus[]; + const options = { windowMinutes: 5, policyLabel: () => '' }; + const names = (field: Parameters[1], direction: 'asc' | 'desc') => + sortContainers(rows, { field, direction }, options).map((row) => row.container_name); + + assert.deepEqual(names('current_ru', 'desc'), ['alpha', 'beta', 'Delta', 'gamma']); + assert.deepEqual(names('current_ru', 'asc'), ['Delta', 'alpha', 'beta', 'gamma']); + assert.deepEqual(names('ru_utilization', 'desc'), ['alpha', 'beta', 'Delta', 'gamma']); + assert.deepEqual(names('container_name', 'asc'), ['alpha', 'beta', 'Delta', 'gamma']); + + assert.deepEqual(nextContainerSort({ field: 'current_ru', direction: 'desc' }, 'current_ru'), { + field: 'current_ru', + direction: 'asc', + }); + assert.deepEqual(nextContainerSort({ field: 'current_ru', direction: 'desc' }, 'container_name'), { + field: 'container_name', + direction: 'asc', + }); + assert.deepEqual(nextContainerSort({ field: 'container_name', direction: 'asc' }, 'request_units'), { + field: 'request_units', + direction: 'desc', + }); + + assert.deepEqual(filterContainers(rows, ' ALP ').map((row) => row.container_name), ['alpha']); + assert.equal(filterContainers(rows, '').length, rows.length); +}); + +check('capacity actions are offered by the classic page rules', () => { + const database = (status: Json | null) => databaseActions(status as CosmosThroughputStatus | null); + + const unloaded = database(null); + assert.ok(unloaded.up.disabled && unloaded.down.disabled && unloaded.convert.disabled); + assert.equal(unloaded.up.reason, 'Refresh the status before changing throughput.'); + + const manual = database({ throughput: { mode: 'manual', current_ru: 4000, is_scalable: true } }); + assert.deepEqual( + [manual.convert.disabled, manual.up.disabled, manual.down.disabled], + [false, false, false], + ); + + const autoscale = database({ throughput: { mode: 'autoscale', current_ru: 4000, is_scalable: true } }); + assert.equal(autoscale.convert.reason, 'Throughput already uses Cosmos autoscale.'); + + const atCeiling = database({ throughput: { mode: 'autoscale', current_ru: 10000, is_scalable: true } }); + assert.equal(atCeiling.up.reason, COSMOS_THROUGHPUT_PORTAL_MANAGED_MESSAGE); + assert.equal(atCeiling.down.disabled, false, 'scaling down from the ceiling is still allowed'); + + const portal = database({ throughput: { mode: 'autoscale', current_ru: 20000, is_scalable: true } }); + assert.ok(portal.up.disabled && portal.down.disabled && portal.convert.disabled); + + const containerScope = database({ + capacity_scope: 'container', + throughput: { mode: 'container_or_serverless', is_scalable: false }, + }); + assert.equal(containerScope.up.reason, 'Throughput is set per container. Use the actions in Cosmos Metrics.'); + + const shared = containerActions({ container_name: 'a', mode: 'manual', current_ru: 400, is_scalable: false } as CosmosContainerStatus); + assert.equal(shared.up.reason, 'This container shares database throughput.'); + assert.ok(shared.convert.disabled && shared.down.disabled); + + const dedicated = containerActions({ container_name: 'b', mode: 'manual', current_ru: 4000, is_scalable: true } as CosmosContainerStatus); + assert.deepEqual( + [dedicated.convert.disabled, dedicated.up.disabled, dedicated.down.disabled], + [false, false, false], + ); +}); + +check('an enforced global policy governs every container but keeps its timestamps', () => { + const container = { + container_name: 'conversations', + policy: { min_ru: 5000, last_scale_up_at: '2026-01-01T00:00:00Z' }, + } as CosmosContainerStatus; + + const enforced = readGlobalPolicy( + reader({ cosmos_throughput_enforce_container_defaults: true, cosmos_throughput_min_ru: 2000, cosmos_throughput_max_ru: 6000 }), + ); + const governed = effectiveContainerPolicy(container, {}, enforced); + assert.equal(governed.min_ru, 2000); + assert.equal(governed.max_ru, 6000); + assert.equal(governed.last_scale_up_at, '2026-01-01T00:00:00Z'); + assert.equal(containerPolicyLabel(container, governed, enforced), 'Global policy'); + + const open = readGlobalPolicy(reader({ cosmos_throughput_min_ru: 2000, cosmos_throughput_max_ru: 6000 })); + const staged = effectiveContainerPolicy(container, { conversations: { min_ru: 3000 } }, open); + assert.equal(staged.min_ru, 3000, 'a staged edit wins over the policy the status reported'); + assert.equal( + containerPolicyLabel(container, { ...staged, max_ru: 7000 }, open), + `${(3000).toLocaleString()}-${(7000).toLocaleString()} RU/s`, + ); + assert.equal(containerPolicyLabel(container, { ...staged, enabled: false }, open), 'Disabled'); + assert.equal( + containerPolicyLabel({ ...container, current_ru: 20000, is_scalable: true }, staged, open), + 'Monitor only', + ); + + const merged = mergeRuntimePolicyFields({ container_name: 'x', min_ru: 1000 }, { last_scale_down_at: 'earlier' }); + assert.equal(merged.last_scale_down_at, 'earlier'); + assert.equal(merged.min_ru, 1000); +}); + +check('access validation sends the throughput settings as they would be saved', () => { + const values: Json = { + cosmos_throughput_account_name: 'draft-account', + cosmos_throughput_min_ru: 2000, + cosmos_throughput_container_policies: '{"conversations": {"min_ru": 2000}}', + enable_redis_cache: true, + }; + assert.deepEqual(buildAccessValidationPayload(reader(values)), { + cosmos_throughput_account_name: 'draft-account', + cosmos_throughput_min_ru: 2000, + cosmos_throughput_container_policies: { conversations: { min_ru: 2000 } }, + }); + + assert.equal(hasUnsavedThroughputEdits(['enable_redis_cache']), false); + assert.equal(hasUnsavedThroughputEdits(['enable_redis_cache', 'cosmos_throughput_max_ru']), true); + assert.equal(hasUnsavedThroughputEdits(['cosmos_throughput_container_policies']), true); +}); + +// --- Formatting, Redis and maintenance -------------------------------------------------------- + +check('Scale formatters read the way the classic page reads', () => { + assert.equal(formatTtl(-1), 'No expiry'); + assert.equal(formatTtl(-2), 'Expired or missing'); + assert.equal(formatTtl(90), `${(90).toLocaleString()} sec`); + assert.equal(formatTtl(null), 'Not available'); + + assert.equal(formatBytes(512), `${(512).toLocaleString()} bytes`); + assert.equal(formatBytes(2048), `${(2).toLocaleString()} KB`); + assert.equal(formatBytes(1.5 * 1024 * 1024), `${(1.5).toLocaleString(undefined, { maximumFractionDigits: 2 })} MB`); + assert.equal(formatBytes(-1), 'Not available'); + + assert.equal(humanizeStatus('succeeded_with_errors'), 'Succeeded With Errors'); + assert.equal(humanizeStatus(''), 'Not loaded'); + + const now = Date.UTC(2026, 0, 1, 12, 0, 0); + assert.equal(formatRelativeTime(null, now), 'Not loaded yet'); + assert.equal(formatRelativeTime(now - 10_000, now), 'Updated just now'); + assert.equal(formatRelativeTime(now - 5 * 60_000, now), 'Updated 5 min ago'); + assert.equal(formatRelativeTime(now - 3 * 3_600_000, now), 'Updated 3 h ago'); + + const tones: [string, string][] = [ + ['succeeded', 'ok'], + ['aligned', 'ok'], + ['running', 'info'], + ['completed_with_errors', 'warn'], + ['missing_expected_indexes', 'warn'], + ['failed', 'danger'], + ['something_new', 'neutral'], + ]; + for (const [status, tone] of tones) { + assert.equal(maintenanceStatusTone(status), tone, status); + } +}); + +check('explorer failures read error first, then the endpoint detail, then the fallback', () => { + assert.equal( + readRedisExplorerError({ error: 'Failed to load Redis Explorer keys.', last_error: 'detail' }, ['last_error'], 'fallback'), + 'Failed to load Redis Explorer keys.', + ); + assert.equal( + readRedisExplorerError({ success: false, last_error: 'Redis cache is disabled.' }, ['last_error'], 'fallback'), + 'Redis cache is disabled.', + ); + // A key deleted since the page loaded is a 404 explained in `preview`, with no `error`. + assert.equal( + readRedisExplorerError( + { success: false, status: 'not_found', preview: 'Redis key was not found.' }, + ['preview', 'last_error'], + 'fallback', + ), + 'Redis key was not found.', + ); + assert.equal( + readRedisExplorerError({ preview: '', last_error: 'Redis is unreachable.' }, ['preview', 'last_error'], 'fallback'), + 'Redis is unreachable.', + ); + assert.equal(readRedisExplorerError({ error: ' ' }, ['last_error'], 'fallback'), 'fallback'); + assert.equal(readRedisExplorerError('Bad gateway', ['last_error'], 'fallback'), 'fallback'); + assert.equal(readRedisExplorerError(null, [], 'fallback'), 'fallback'); +}); + +check('Redis health and refresh messages carry the classic tones', () => { + assert.equal(redisHealthTone('healthy'), 'ok'); + assert.equal(redisHealthTone('degraded'), 'warn'); + assert.equal(redisHealthTone('not_configured'), 'warn'); + assert.equal(redisHealthTone('error'), 'danger'); + assert.equal(redisHealthTone(undefined), 'neutral'); + + const refresh = (health: Json) => describeRedisRefresh({ health } as RedisMonitoringStatus); + assert.deepEqual(refresh({ status: 'healthy' }), { text: 'Redis monitoring status loaded.', tone: 'ok' }); + assert.deepEqual(refresh({ status: 'error', last_error: 'Connection refused.' }), { + text: 'Connection refused.', + tone: 'danger', + }); + assert.deepEqual(refresh({ status: 'degraded', last_error: 'Memory is nearly full.' }), { + text: 'Memory is nearly full.', + tone: 'warn', + }); +}); + +check('a manual backfill run reports the state it left behind', () => { + const run = { + success: true, + steps: [ + { name: 'stale_cache_document_cleanup', results: {} }, + { name: 'document_access_index_backfill', results: { current_status: { state: { status: 'in_progress' } } } }, + ], + } as AppMaintenanceRunResult; + const status = backfillStatusFromRun(run); + assert.deepEqual(status, { state: { status: 'in_progress' } }); + assert.equal(isBackfillRunning(status), false); + assert.equal(describeBackfillRun(status).tone, 'info'); + assert.match(describeBackfillRun(status).text, /more documents remain/); + + const running = { state: { status: 'Running' } } as DocumentAccessIndexStatus; + assert.equal(isBackfillRunning(running), true); + assert.equal(describeBackfillRun({ state: { status: 'completed_with_errors' } } as DocumentAccessIndexStatus).tone, 'warn'); + assert.deepEqual(describeBackfillRun(null), { text: 'Document access index backfill batch completed.', tone: 'ok' }); + assert.equal(backfillStatusFromRun({ steps: [] }), null); +}); + +check('cleanup and index results are described as the classic page does', () => { + assert.deepEqual(describeCleanupRun({ candidate_count: 3, deleted_count: 0 }, false), { + text: `Stale cache cleanup dry run found ${(3).toLocaleString()} candidate document(s).`, + tone: 'ok', + }); + assert.equal(describeCleanupRun({ candidate_count: 3, deleted_count: 3, has_more_candidates: true }, true).tone, 'warn'); + + const policy = (value: Json) => indexingPolicyStatus(value as CosmosIndexingPolicyStatus); + assert.equal(indexingPolicyStatus(null), 'not_loaded'); + assert.equal(policy({ failed_container_count: 1, containers_missing_expected_indexes: 2 }), 'failed'); + assert.equal(policy({ containers_missing_expected_indexes: 2 }), 'missing_expected_indexes'); + assert.equal(policy({ updated_container_count: 0 }), 'aligned'); +}); + +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_scale_normalization.py b/functional_tests/test_v2_admin_scale_normalization.py new file mode 100644 index 000000000..bf2c6d274 --- /dev/null +++ b/functional_tests/test_v2_admin_scale_normalization.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +# test_v2_admin_scale_normalization.py +""" +Functional test for what a V2 save does to the Admin Settings Scale group. +Version: 0.261.260 +Implemented in: 0.261.260 + +The Scale settings have rules that relate several values, and the server-rendered +page enforces them when its form is saved: Scale Up At must stay above Scale Down +At, each scale interval must cover the metrics window, and RU/s values round up to +the 1,000 RU/s increments Cosmos autoscale accepts. A V2 save carries only the keys +an administrator changed, so these checks pin that the merged result is what gets +judged, that each error lands on the control that caused it, and that a value the +server adjusts is reported rather than rewritten silently. + +Two Scale values also have a stored shape the V2 editor does not share. The Redis +port is stored as text, because the connection test reads it with ``.strip()`` and +blank means "use the service's port". Container policies arrive from V1 as a JSON +string and from the V2 workbench as an object, and their scale timestamps belong to +the automation, so a page loaded before a scale must not write older ones back. +""" + +import json +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.versioning import assert_app_version_at_least + + +fields_module = import_app_module("admin_settings_fields") +throughput_module = import_app_module("functions_cosmos_throughput") +secret_utils = import_app_module("admin_settings_secret_utils") +normalize = fields_module.normalize_admin_settings_updates + +UP_THRESHOLD = "cosmos_throughput_scale_up_threshold_percent" +DOWN_THRESHOLD = "cosmos_throughput_scale_down_threshold_percent" +UP_COOLDOWN = "cosmos_throughput_scale_up_cooldown_minutes" +DOWN_COOLDOWN = "cosmos_throughput_scale_down_cooldown_minutes" +METRICS_WINDOW = "cosmos_throughput_metrics_window_minutes" +POLICIES = "cosmos_throughput_container_policies" + + +def stored_settings(**overrides): + """A stored settings document with the throughput defaults and automation on.""" + settings = throughput_module.get_default_cosmos_throughput_settings() + settings["cosmos_throughput_autoscale_enabled"] = True + settings.update(overrides) + return settings + + +def v1_messages(settings): + """The messages the server-rendered form shows for the same settings.""" + return throughput_module.validate_cosmos_throughput_policy_settings(settings) + + +def test_redis_port_is_stored_as_text_or_refused(): + """The connection test calls .strip() on the port, so it must stay a string.""" + print("Testing the Redis port...") + + assert_app_version_at_least("0.261.260") + + for submitted, stored in ((" 6380 ", "6380"), (10000, "10000"), ("", ""), (None, "")): + normalized, errors, _ = normalize({"redis_port": submitted}) + assert not errors, (submitted, errors) + assert normalized["redis_port"] == stored, (submitted, normalized["redis_port"]) + + for refused in ("0", "65536", "-1", "63 80", "abc", "6379.5"): + _, errors, _ = normalize({"redis_port": refused}) + assert errors.get("redis_port") == fields_module.REDIS_PORT_ERROR, (refused, errors) + + print(" Ports save as text, blank means the service default, and bad ports are refused.") + return True + + +def test_redis_selects_and_credential(): + """An unknown option would store a value the Redis client does not understand.""" + print("\nTesting the Redis selects and credential...") + + _, errors, _ = normalize({"redis_auth_type": "password"}) + assert "redis_auth_type" in errors, errors + + normalized, errors, _ = normalize( + {"redis_auth_type": "key_vault", "redis_service_type": "azure_managed_redis"} + ) + assert not errors, errors + assert normalized["redis_auth_type"] == "key_vault", normalized + assert normalized["redis_service_type"] == "azure_managed_redis", normalized + + # The browser holds a placeholder for the stored key. It passes through the + # normalizer, and the PATCH route swaps it back for the stored key before saving, as + # it does for every declared secret. + current = {"redis_key": "stored-access-key"} + normalized, errors, _ = normalize({"redis_key": fields_module.SECRET_REDACTED_VALUE}, current) + assert not errors, errors + assert "redis_key" in fields_module.get_secret_field_keys() + restored = secret_utils.resolve_admin_settings_secret_value( + "redis_key", normalized["redis_key"], current + ) + assert restored == "stored-access-key", "The placeholder must never replace the stored key." + + normalized, errors, _ = normalize({"redis_key": " new-access-key "}) + assert not errors, errors + assert normalized["redis_key"] == "new-access-key", normalized + + print(" Unknown options are refused and an untouched key keeps its stored value.") + return True + + +def test_cache_and_index_values_keep_v1_bounds(): + """The V1 input bounds, enforced on the server by clamping as every number field is.""" + print("\nTesting cache TTL and batch size bounds...") + + cases = ( + ("conversation_cache_ttl_seconds", {0: 0, 120: 120, -1: 0}), + ("document_access_index_cache_ttl_seconds", {60: 60, 900: 900, 59: 60, 901: 900}), + ("document_access_index_backfill_batch_size", {1: 1, 1000: 1000, 0: 1, 1001: 1000}), + ("document_access_index_repair_batch_size", {1: 1, 500: 500, 0: 1, 501: 500}), + ) + for key, expectations in cases: + for submitted, stored in expectations.items(): + normalized, errors, _ = normalize({key: submitted}) + assert not errors, (key, submitted, errors) + assert normalized[key] == stored, (key, submitted, normalized[key]) + + print(f" {len(cases)} bounded value(s) stay inside the V1 range.") + return True + + +def test_threshold_order_error_sits_on_both_thresholds(): + """Either threshold can be the one to move, so both carry the reason.""" + print("\nTesting the threshold order rule...") + + current = stored_settings() + updates = {UP_THRESHOLD: 70, DOWN_THRESHOLD: 70} + _, errors, _ = normalize(updates, current) + + expected = v1_messages({**current, **updates}) + assert expected == ["Cosmos throughput policy: Scale Up At must be higher than Scale Down At."], expected + assert errors == {UP_THRESHOLD: expected[0], DOWN_THRESHOLD: expected[0]}, errors + + print(" Both thresholds carry the V1 message.") + return True + + +def test_interval_errors_sit_on_the_interval_and_the_window(): + """Raising the window can break an interval nobody touched.""" + print("\nTesting the interval rules...") + + current = stored_settings() + updates = {METRICS_WINDOW: 30} + _, errors, _ = normalize(updates, current) + + up_message, down_message = v1_messages({**current, **updates}) + assert up_message == ( + "Cosmos throughput policy: Scale Up Interval must be greater than or equal to " + "the Metrics Window (30 minutes)." + ), up_message + assert errors.get(UP_COOLDOWN) == up_message, errors + assert errors.get(DOWN_COOLDOWN) == down_message, errors + # The window is involved in both; it carries the first reason. + assert errors.get(METRICS_WINDOW) == up_message, errors + + print(" Each interval error sits on its interval and on the metrics window.") + return True + + +def test_a_partial_save_is_judged_with_the_stored_settings(): + """A save that sends one threshold can still break the pair.""" + print("\nTesting a partial save against stored settings...") + + current = stored_settings(**{DOWN_THRESHOLD: 85}) + _, errors, _ = normalize({UP_THRESHOLD: 80}, current) + + assert set(errors) == {UP_THRESHOLD, DOWN_THRESHOLD}, errors + + # The same change is accepted once the stored value no longer conflicts. + _, errors, _ = normalize({UP_THRESHOLD: 80}, stored_settings(**{DOWN_THRESHOLD: 60})) + assert not errors, errors + + print(" The merged state is what gets judged.") + return True + + +def test_rules_wait_for_automation_and_repairs_are_reported(): + """V1 enforces the rules only while automation is on, and repairs otherwise.""" + print("\nTesting saves with automation off...") + + current = stored_settings(cosmos_throughput_autoscale_enabled=False) + normalized, errors, warnings = normalize({UP_THRESHOLD: 50, DOWN_THRESHOLD: 60}, current) + + assert not errors, errors + assert normalized[DOWN_THRESHOLD] == 49, normalized[DOWN_THRESHOLD] + assert warnings.get(DOWN_THRESHOLD) == "Lowered to 49% so it stays below Scale Up At.", warnings + + # Turning automation off in the same save is the same situation. + normalized, errors, warnings = normalize( + {"cosmos_throughput_autoscale_enabled": False, UP_THRESHOLD: 50, DOWN_THRESHOLD: 60}, + stored_settings(), + ) + assert not errors, errors + assert normalized[DOWN_THRESHOLD] == 49, normalized + + print(" Nothing blocks the save; the repair is reported beside the threshold.") + return True + + +def test_ru_values_round_up_with_a_warning(): + """Cosmos autoscale moves in 1,000 RU/s steps, and SimpleChat stops at 10,000.""" + print("\nTesting RU/s rounding and caps...") + + current = stored_settings() + + normalized, errors, warnings = normalize({"cosmos_throughput_scale_up_step_ru": 1500}, current) + assert not errors, errors + assert normalized["cosmos_throughput_scale_up_step_ru"] == 2000, normalized + assert warnings["cosmos_throughput_scale_up_step_ru"] == ( + "Saved as 2,000 RU/s. Cosmos autoscale moves in 1,000 RU/s increments, so values round up." + ), warnings + + normalized, errors, warnings = normalize({"cosmos_throughput_min_ru": 12000}, current) + assert not errors, errors + assert normalized["cosmos_throughput_min_ru"] == 10000, normalized + assert warnings["cosmos_throughput_min_ru"] == ( + "Saved as 10,000 RU/s, the most SimpleChat scales to." + ), warnings + + normalized, errors, warnings = normalize( + {"cosmos_throughput_min_ru": 5000, "cosmos_throughput_max_ru": 3000}, current + ) + assert not errors, errors + assert normalized["cosmos_throughput_max_ru"] == 5000, normalized + assert warnings["cosmos_throughput_max_ru"] == ( + "Raised to 5,000 RU/s so it is not below Minimum RU/s." + ), warnings + assert "cosmos_throughput_min_ru" not in warnings, warnings + + # A value that needs no adjustment says nothing. + _, _, warnings = normalize({"cosmos_throughput_scale_down_step_ru": 3000}, current) + assert not warnings, warnings + + print(" Adjusted values are saved as the server stores them, with a reason.") + return True + + +def test_container_policies_accept_both_shapes(): + """V1 posts a JSON string and the V2 workbench an object; both mean the same.""" + print("\nTesting container policy shapes...") + + current = stored_settings() + policies = {"conversations": {"min_ru": 2000, "max_ru": 6000}, " ": {"min_ru": 1000}} + + from_object, errors, _ = normalize({POLICIES: policies}, current) + assert not errors, errors + from_json, errors, _ = normalize({POLICIES: json.dumps(policies)}, current) + assert not errors, errors + + assert from_object[POLICIES] == from_json[POLICIES], (from_object[POLICIES], from_json[POLICIES]) + assert list(from_object[POLICIES]) == ["conversations"], "A blank container name is dropped." + saved = from_object[POLICIES]["conversations"] + assert (saved["min_ru"], saved["max_ru"], saved["container_name"]) == (2000, 6000, "conversations"), saved + # A value the policy leaves out is stored as the global value it inherits, as V1 stores it. + assert saved[UP_THRESHOLD.replace("cosmos_throughput_", "")] == current[UP_THRESHOLD], saved + + for bad, message in ( + ("not json", "Container policies could not be read."), + (["conversations"], "Container policies must be an object keyed by container name."), + ({"conversations": "fast"}, "The policy for conversations must be an object."), + ): + _, errors, _ = normalize({POLICIES: bad}, current) + assert errors.get(POLICIES) == message, (bad, errors) + + print(" Both shapes save the same policies, and malformed ones are refused.") + return True + + +def test_container_policy_timestamps_come_from_the_stored_document(): + """A page loaded before a scale must not reset the cooldown it started.""" + print("\nTesting container policy timestamps...") + + current = stored_settings(**{ + POLICIES: { + "conversations": { + "container_name": "conversations", + "min_ru": 2000, + "last_scale_up_at": "2026-01-02T03:04:05+00:00", + }, + }, + }) + submitted = { + "conversations": { + "min_ru": 3000, + "last_scale_up_at": "2020-01-01T00:00:00+00:00", + "last_scale_down_at": "2020-01-01T00:00:00+00:00", + }, + } + + normalized, errors, _ = normalize({POLICIES: submitted}, current) + assert not errors, errors + saved = normalized[POLICIES]["conversations"] + assert saved["min_ru"] == 3000, saved + assert saved["last_scale_up_at"] == "2026-01-02T03:04:05+00:00", saved + assert saved["last_scale_down_at"] is None, ( + "A timestamp the automation never wrote must not be accepted from the browser." + ) + + print(" Timestamps are taken from the stored document, never the browser.") + return True + + +def test_container_policy_rules_follow_v1(): + """Container rules are skipped while the global policy is enforced, as in V1.""" + print("\nTesting container policy rules...") + + broken = {"conversations": {"scale_up_threshold_percent": 50, "scale_down_threshold_percent": 60}} + + current = stored_settings() + _, errors, _ = normalize({POLICIES: broken}, current) + expected = "Container 'conversations' policy: Scale Up At must be higher than Scale Down At." + assert errors == {POLICIES: expected}, errors + assert v1_messages({**current, POLICIES: broken}) == [expected] + + _, errors, _ = normalize( + {POLICIES: broken}, stored_settings(cosmos_throughput_enforce_container_defaults=True) + ) + assert not errors, errors + + disabled = {"conversations": {**broken["conversations"], "enabled": False}} + _, errors, _ = normalize({POLICIES: disabled}, current) + assert not errors, errors + + print(" Container errors match V1 and are skipped when V1 skips them.") + return True + + +def test_policy_errors_name_declared_settings(): + """An error keyed to an undeclared setting has no control to appear beside.""" + print("\nTesting that every policy error names a declared setting...") + + declared = { + field["key"] for _section_id, field in fields_module.iter_fields() if field.get("key") + } + broken = stored_settings(**{ + UP_THRESHOLD: 50, + DOWN_THRESHOLD: 60, + METRICS_WINDOW: 60, + POLICIES: {"conversations": {"scale_up_threshold_percent": 10, "scale_down_threshold_percent": 20}}, + }) + problems = throughput_module.collect_cosmos_throughput_policy_errors(broken) + assert {problem["rule"] for problem in problems} == { + "threshold_order", + "scale_up_interval", + "scale_down_interval", + }, problems + + undeclared = sorted( + {key for problem in problems for key in problem["setting_keys"]} - declared + ) + assert not undeclared, f"Errors keyed to undeclared settings: {undeclared}" + assert [problem["message"] for problem in problems] == v1_messages(broken) + + print(f" All {len(problems)} error(s) sit on declared settings with V1's messages.") + return True + + +def test_an_unrelated_save_is_not_blocked(): + """A stored throughput problem must not stop an administrator saving anything else.""" + print("\nTesting a save with no throughput keys...") + + current = stored_settings(**{UP_THRESHOLD: 50, DOWN_THRESHOLD: 60}) + normalized, errors, warnings = normalize({"enable_redis_cache": True}, current) + + assert not errors, errors + assert not warnings, warnings + assert normalized == {"enable_redis_cache": True}, normalized + + print(" Only saves that touch throughput settings are judged by its rules.") + return True + + +if __name__ == "__main__": + tests = [ + test_redis_port_is_stored_as_text_or_refused, + test_redis_selects_and_credential, + test_cache_and_index_values_keep_v1_bounds, + test_threshold_order_error_sits_on_both_thresholds, + test_interval_errors_sit_on_the_interval_and_the_window, + test_a_partial_save_is_judged_with_the_stored_settings, + test_rules_wait_for_automation_and_repairs_are_reported, + test_ru_values_round_up_with_a_warning, + test_container_policies_accept_both_shapes, + test_container_policy_timestamps_come_from_the_stored_document, + test_container_policy_rules_follow_v1, + test_policy_errors_name_declared_settings, + test_an_unrelated_save_is_not_blocked, + ] + + results = [] + for test in tests: + try: + results.append(bool(test())) + 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_scale_parity.py b/functional_tests/test_v2_admin_scale_parity.py new file mode 100644 index 000000000..6c30a9c3e --- /dev/null +++ b/functional_tests/test_v2_admin_scale_parity.py @@ -0,0 +1,936 @@ +#!/usr/bin/env python3 +# test_v2_admin_scale_parity.py +""" +Functional test pinning V1/V2 parity for the Admin Settings Scale group. +Version: 0.261.260 +Implemented in: 0.261.260 + +Before this, the Scale group had no declared fields. Its two tabs drew whatever +``enable_*`` booleans the fallback scan matched by word stems, so the Redis +endpoint, port and credential, both cache TTLs, the Cosmos resource target and +every throughput guardrail were unreachable in V2, and the operational surfaces -- +Redis Metrics, the Redis Explorer, Document Access Index status, Cosmos maintenance +and the throughput console -- did not exist there at all. + +These are the structural checks. They read the two V1 panes and hold the schema to +them: every submitted field is claimed and none is invented; selects, number bounds +and the Redis credential agree; the nesting V1 expresses with enclosing divs and +Jinja blocks is carried on each field, including the index diagnostics V1 shows only +while ``enable_dai_debug`` is set; the always-on index flags stay uneditable; +defaults and mirrored constants agree with ``functions_cosmos_throughput.py``; and +the V2 surface drives the same admin APIs, with the same request bodies, that V1 +does. + +What a save does is covered by ``test_v2_admin_scale_normalization.py``, and the +browser logic by ``test_v2_admin_scale_logic.py``. +""" + +import os +import re +import sys +from contextlib import contextmanager +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" +V1_ADMIN_JS = APP_ROOT / "static" / "js" / "admin" / "admin_settings.js" +V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src" +PAGE_TSX = V2_SRC / "pages" / "AdminSettingsPage.tsx" +COSMOS_TS = V2_SRC / "lib" / "cosmosThroughput.ts" +MAINTENANCE_TS = V2_SRC / "lib" / "scaleMaintenance.ts" + +SCALE_GROUP_ID = "scale" + +# The tabs that make up the Scale group, and the sections each contributes. Sourced +# from ADMIN_NAV and verified against it below. +SCALE_PANES = { + "redis-caching": ( + "redis-cache-section", + "redis-monitoring-section", + "conversation-cache-section", + ), + "cosmos": ( + "document-access-index-section", + "cosmos-maintenance-section", + "cosmos-throughput-section", + "cosmos-throughput-metrics-table-section", + ), +} +SCALE_SECTION_IDS = {section for sections in SCALE_PANES.values() for section in sections} + +EXPECTED_SECRET_KEYS = {"redis_key"} + +# V1 wraps the index diagnostics in this Jinja condition; V2 gates the same fields on +# the runtime flag the settings API derives from it. +V1_DAI_DEBUG_CONDITION = "settings.enable_dai_debug" +DAI_DEBUG_FLAG = "dai_debug_enabled" + +# Forced to True by normalize_document_access_index_required_settings on every read and +# write. A switch for one of these would appear to save and then revert. +ALWAYS_ON_DAI_KEYS = ( + "enable_document_access_index_container", + "enable_document_access_index_write_through", + "enable_document_access_index_reads", + "enable_startup_document_access_index_backfill", +) + +REDIS_ON = ("enable_redis_cache", "==", True) +DAI_DEBUG = (f"flag:{DAI_DEBUG_FLAG}", "==", True) +AUTOMATION_ON = ("cosmos_throughput_autoscale_enabled", "==", True) + +# Each entry is the full set of conditions the field carries. +EXPECTED_DEPENDENCY_CHAINS = { + # V1 wraps these in #redis_cache_settings, hidden while Redis Cache is off. + "redis_url": {REDIS_ON}, + "redis_service_type": {REDIS_ON}, + "redis_port": {REDIS_ON}, + "redis_auth_type": {REDIS_ON}, + # #redis_key_container is also hidden for managed identity, which has no key. + "redis_key": {REDIS_ON, ("redis_auth_type", "!=", "managed_identity")}, + # V1 shows these beside a switched-off cache. V2 nests them under their switch, as it + # nests every setting that only matters while its capability is on. + "conversation_cache_ttl_seconds": {("enable_conversation_cache", "==", True)}, + "document_access_index_cache_ttl_seconds": { + DAI_DEBUG, + ("enable_document_access_index_cache", "==", True), + }, + "enable_document_access_index_shadow_validation": {DAI_DEBUG}, + "document_access_index_backfill_batch_size": {DAI_DEBUG}, + "document_access_index_repair_batch_size": {DAI_DEBUG}, + "enable_document_access_index_cache": {DAI_DEBUG}, + "enable_startup_app_maintenance": {("enable_app_maintenance", "==", True)}, +} + +# Monitoring and manual scaling work with automation off, so these are not gated on it. +THROUGHPUT_KEYS_NOT_GATED_ON_AUTOMATION = { + "cosmos_throughput_autoscale_enabled", + "cosmos_throughput_container_policies", +} + +THROUGHPUT_ENVIRONMENT = ( + "AZURE_SUBSCRIPTION_ID", + "AZURE_RESOURCE_GROUP", + "AZURE_COSMOS_ACCOUNT_NAME", + "AZURE_COSMOS_DATABASE_NAME", +) + +# Each admin API the Scale surface calls: the route, its method, the V2 source that +# calls it, and the call target as written there. +SCALE_ROUTES = ( + ( + "/api/admin/settings/redis-monitoring/status", + "GET", + "components/admin/RedisMonitoringPanel.tsx", + "STATUS_PATH", + ), + ( + "/api/admin/settings/redis-explorer/keys", + "GET", + "components/admin/RedisExplorer.tsx", + "`${KEYS_PATH}?", + ), + ( + "/api/admin/settings/redis-explorer/value", + "POST", + "components/admin/RedisExplorer.tsx", + "VALUE_PATH", + ), + ( + "/api/admin/settings/app-maintenance/status", + "GET", + "stores/scaleStatusStore.ts", + "MAINTENANCE_STATUS_PATH", + ), + ( + "/api/admin/settings/app-maintenance/run", + "POST", + "components/admin/CosmosMaintenancePanel.tsx", + "RUN_PATH", + ), + ( + "/api/admin/settings/app-maintenance/run", + "POST", + "components/admin/DocumentAccessIndexPanel.tsx", + "RUN_PATH", + ), + ( + "/api/admin/settings/cosmos-throughput/status", + "GET", + "stores/scaleStatusStore.ts", + "`${THROUGHPUT_PATH}/status`", + ), + ( + "/api/admin/settings/cosmos-throughput/validate-access", + "POST", + "stores/scaleStatusStore.ts", + "`${THROUGHPUT_PATH}/validate-access`", + ), + ( + "/api/admin/settings/cosmos-throughput/scale", + "POST", + "stores/scaleStatusStore.ts", + "`${THROUGHPUT_PATH}/scale`", + ), + ( + "/api/admin/settings/cosmos-throughput/convert-autoscale", + "POST", + "stores/scaleStatusStore.ts", + "`${THROUGHPUT_PATH}/convert-autoscale`", + ), +) + +FIELD_NAME_RE = re.compile(r'\sname="([^"]+)"') +JINJA_RE = re.compile(r"\{\{|\{%") +JINJA_BLOCK_RE = re.compile(r"\{%-?\s*(if|endif|for|endfor)\b(.*?)-?%\}", re.DOTALL) + +OPTION_RE = re.compile(r'