Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
922 changes: 919 additions & 3 deletions application/single_app/admin_settings_fields.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.273"
VERSION = "0.261.274"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
141 changes: 102 additions & 39 deletions application/single_app/functions_cosmos_throughput.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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 {}
Expand Down
4 changes: 3 additions & 1 deletion application/single_app/functions_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1813,7 +1813,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': '',

Expand Down
49 changes: 48 additions & 1 deletion application/single_app/route_backend_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,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 are_swagger_routes_registered, get_auth_security, swagger_route

Expand Down Expand Up @@ -1462,9 +1463,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.

Expand Down
67 changes: 67 additions & 0 deletions application/v2_ui/src/components/admin/ConfirmActionModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminModal
title={title}
// A request in flight cannot be called back, so the dialog stays until it lands.
onClose={busy ? () => undefined : onClose}
footer={
<>
<GlassButton type="button" variant="ghost" size="sm" disabled={busy} onClick={onClose}>
{cancelLabel}
</GlassButton>
<GlassButton
type="button"
size="sm"
variant={tone === 'danger' ? 'danger' : 'primary'}
disabled={busy || confirmDisabled}
aria-busy={busy || undefined}
onClick={onConfirm}
>
{busy ? <Loader2 size={14} aria-hidden="true" className="animate-spin" /> : null}
{confirmLabel}
</GlassButton>
</>
}
>
<div className="space-y-3 text-sm leading-relaxed text-text-2">{children}</div>
</AdminModal>
);
}
9 changes: 8 additions & 1 deletion application/v2_ui/src/components/admin/ConnectionTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,16 @@ export function ConnectionTest({
draft,
fieldsByKey,
disabled,
onSuccess,
}: {
field: AdminField;
settings: Json;
draft: Json;
/** Every declared field by key, so a value saved at a nested path is found. */
fieldsByKey: Map<string, AdminField>;
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<TestOutcome | null>(null);
Expand All @@ -93,7 +96,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(
Expand Down
Loading
Loading