feat(settings): expose tunable runtime settings with server-side validation
Move hardcoded constants into GlobalSettings and surface them in the Settings UI: task retention, auth rate limit, assistant history retention, translate baseline expiry, default environment, and extended logging fields. - consolidated settings API: new fields in GET/PATCH with re-validation through GlobalSettings (422 on out-of-range instead of silent persist) - rate limiter policy read live from settings with 60s cache + lock-free fast path; cache invalidated centrally in ConfigManager on auth policy change (covers PATCH /settings/global and /consolidated) - shared settings_provider.get_global_settings() replaces three copies of the fallback pattern; scheduler baseline fallback derives from model default - remove dead GlobalSettings fields (pagination_limit, ff_dataset_*, LLM_*_RETENTION_DAYS, GLOBAL_VALIDATION_WORKER_LIMIT, AppAsyncRuntimeConfig) - SystemSettings blocks save on out-of-range values; LoggingSettings gains max_bytes/backup_count/agent_view/hide_routine_infra/log_level_for_agents; EnvironmentsTab gains default environment selector - tests: rate limiter settings-driven policy, consolidated PATCH 422 paths, System tab save-blocking UX test
This commit is contained in:
@@ -14,6 +14,7 @@ import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.core.settings_provider import get_global_settings
|
||||
from src.models.assistant import (
|
||||
AssistantAuditRecord,
|
||||
AssistantConfirmationRecord,
|
||||
@@ -297,12 +298,31 @@ def _resolve_or_create_conversation(
|
||||
# #endregion Api.History.ResolveOrCreateConversation
|
||||
|
||||
|
||||
# #region Api.History.AssistantRetentionSettings [C:2] [TYPE Function]
|
||||
# @BRIEF Read assistant history retention policy from GlobalSettings with fallback to module constants.
|
||||
# @RATIONALE _schemas.py must stay free of ConfigManager dependencies (architectural purity), so the
|
||||
# retention constants remain there as fallback defaults and this module resolves live values.
|
||||
# @POST Returns (archive_after_days, message_ttl_days) from settings, or constants when config is unavailable.
|
||||
def _assistant_retention_days() -> tuple[int, int]:
|
||||
settings = get_global_settings()
|
||||
if settings is None:
|
||||
return ASSISTANT_ARCHIVE_AFTER_DAYS, ASSISTANT_MESSAGE_TTL_DAYS
|
||||
return (
|
||||
int(settings.assistant_archive_after_days),
|
||||
int(settings.assistant_message_ttl_days),
|
||||
)
|
||||
|
||||
|
||||
# #endregion Api.History.AssistantRetentionSettings
|
||||
|
||||
|
||||
# #region Api.History.CleanupHistoryTtl [C:2] [TYPE Function]
|
||||
# @BRIEF Enforce assistant message retention window by deleting expired rows and in-memory records.
|
||||
# @PRE db session is available and user_id references current actor scope.
|
||||
# @POST Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors.
|
||||
# @POST Messages older than the message TTL (GlobalSettings.assistant_message_ttl_days) are removed.
|
||||
def _cleanup_history_ttl(db: Session, user_id: str):
|
||||
cutoff = datetime.now() - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS)
|
||||
_, ttl_days = _assistant_retention_days()
|
||||
cutoff = datetime.now() - timedelta(days=ttl_days)
|
||||
try:
|
||||
query = db.query(AssistantMessageRecord).filter(
|
||||
AssistantMessageRecord.user_id == user_id,
|
||||
@@ -348,7 +368,8 @@ def _cleanup_history_ttl(db: Session, user_id: str):
|
||||
def _is_conversation_archived(updated_at: datetime | None) -> bool:
|
||||
if not updated_at:
|
||||
return False
|
||||
cutoff = datetime.now() - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS)
|
||||
archive_days, _ = _assistant_retention_days()
|
||||
cutoff = datetime.now() - timedelta(days=archive_days)
|
||||
# Ensure both operands are naive (DB stores naive datetimes)
|
||||
ref = updated_at.replace(tzinfo=None) if updated_at.tzinfo else updated_at
|
||||
return ref < cutoff
|
||||
|
||||
@@ -455,7 +455,10 @@ async def update_logging_config(
|
||||
|
||||
# Merge: admin UI only sends level / task_log_level / enable_belief_state.
|
||||
# Pydantic fills other LoggingConfig fields with defaults (file_path=None),
|
||||
# which would wipe file logging if we replaced wholesale.
|
||||
# which would wipe file logging if we replaced wholesale. exclude_unset
|
||||
# keeps only fields the caller actually provided, so extended fields
|
||||
# (max_bytes, backup_count, agent_view, hide_routine_infra,
|
||||
# log_level_for_agents) are preserved when not sent.
|
||||
settings = config_manager.get_config().settings
|
||||
existing = settings.logging
|
||||
if isinstance(existing, LoggingConfig):
|
||||
@@ -474,11 +477,11 @@ async def update_logging_config(
|
||||
"hide_routine_infra": getattr(existing, "hide_routine_infra", True),
|
||||
"log_level_for_agents": getattr(existing, "log_level_for_agents", "INFO"),
|
||||
}
|
||||
merged["level"] = config.level
|
||||
merged["task_log_level"] = config.task_log_level
|
||||
merged["enable_belief_state"] = config.enable_belief_state
|
||||
if config.file_path:
|
||||
merged["file_path"] = config.file_path
|
||||
provided = config.model_dump(exclude_unset=True)
|
||||
# Keep the original file_path unless the caller explicitly sent a non-empty one
|
||||
if "file_path" in provided and not provided.get("file_path"):
|
||||
provided.pop("file_path")
|
||||
merged.update(provided)
|
||||
settings.logging = LoggingConfig(**merged)
|
||||
config_manager.update_global_settings(settings)
|
||||
|
||||
@@ -521,6 +524,15 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
session_idle_timeout_minutes: int = 0
|
||||
session_absolute_timeout_minutes: int = 0
|
||||
session_warning_minutes: int = 5
|
||||
default_environment_id: str | None = None
|
||||
task_retention_days: int = 30
|
||||
task_retention_limit: int = 100
|
||||
auth_max_attempts: int = 10
|
||||
auth_attempt_window: int = 300
|
||||
auth_ban_duration: int = 900
|
||||
assistant_archive_after_days: int = 14
|
||||
assistant_message_ttl_days: int = 90
|
||||
translate_baseline_expiry_days: int = 90
|
||||
|
||||
|
||||
# #endregion Api.Settings.ConsolidatedSettingsResponse
|
||||
@@ -605,6 +617,15 @@ async def get_consolidated_settings(
|
||||
session_idle_timeout_minutes=config.settings.session_idle_timeout_minutes,
|
||||
session_absolute_timeout_minutes=config.settings.session_absolute_timeout_minutes,
|
||||
session_warning_minutes=config.settings.session_warning_minutes,
|
||||
default_environment_id=config.settings.default_environment_id,
|
||||
task_retention_days=config.settings.task_retention_days,
|
||||
task_retention_limit=config.settings.task_retention_limit,
|
||||
auth_max_attempts=config.settings.auth_max_attempts,
|
||||
auth_attempt_window=config.settings.auth_attempt_window,
|
||||
auth_ban_duration=config.settings.auth_ban_duration,
|
||||
assistant_archive_after_days=config.settings.assistant_archive_after_days,
|
||||
assistant_message_ttl_days=config.settings.assistant_message_ttl_days,
|
||||
translate_baseline_expiry_days=config.settings.translate_baseline_expiry_days,
|
||||
)
|
||||
logger.reflect(
|
||||
"Consolidated settings payload assembled",
|
||||
@@ -712,6 +733,50 @@ async def update_consolidated_settings(
|
||||
if "allowed_languages" in settings_patch:
|
||||
current_settings.allowed_languages = settings_patch["allowed_languages"]
|
||||
|
||||
# Update task retention if provided
|
||||
if "task_retention_days" in settings_patch:
|
||||
current_settings.task_retention_days = settings_patch["task_retention_days"]
|
||||
if "task_retention_limit" in settings_patch:
|
||||
current_settings.task_retention_limit = settings_patch["task_retention_limit"]
|
||||
|
||||
# Update default environment if provided
|
||||
if "default_environment_id" in settings_patch:
|
||||
new_default = settings_patch["default_environment_id"]
|
||||
current_settings.default_environment_id = new_default or None
|
||||
|
||||
# Update auth rate limiting policy if provided
|
||||
if "auth_max_attempts" in settings_patch:
|
||||
current_settings.auth_max_attempts = settings_patch["auth_max_attempts"]
|
||||
if "auth_attempt_window" in settings_patch:
|
||||
current_settings.auth_attempt_window = settings_patch["auth_attempt_window"]
|
||||
if "auth_ban_duration" in settings_patch:
|
||||
current_settings.auth_ban_duration = settings_patch["auth_ban_duration"]
|
||||
|
||||
# Update assistant history retention if provided
|
||||
if "assistant_archive_after_days" in settings_patch:
|
||||
current_settings.assistant_archive_after_days = settings_patch["assistant_archive_after_days"]
|
||||
if "assistant_message_ttl_days" in settings_patch:
|
||||
current_settings.assistant_message_ttl_days = settings_patch["assistant_message_ttl_days"]
|
||||
|
||||
# Update translate baseline expiry if provided
|
||||
if "translate_baseline_expiry_days" in settings_patch:
|
||||
current_settings.translate_baseline_expiry_days = settings_patch["translate_baseline_expiry_days"]
|
||||
|
||||
# Re-validate through the model: raw dict assignments above bypass Pydantic's
|
||||
# ge/le constraints (validate_assignment is off), so reconstructing via
|
||||
# model_validate enforces Field bounds and rejects out-of-range values with 422
|
||||
# instead of silently persisting a config that would fall back to defaults on
|
||||
# restart (environment loss) or disable the auth rate limiter.
|
||||
from pydantic import ValidationError
|
||||
|
||||
try:
|
||||
current_settings = GlobalSettings.model_validate(current_settings.model_dump())
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"message": "Invalid settings value", "errors": exc.errors()},
|
||||
) from exc
|
||||
|
||||
config_manager.update_global_settings(current_settings)
|
||||
return {"status": "success", "message": "Settings updated"}
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ from .config_models import AppConfig, Environment, GlobalSettings
|
||||
from .database import SessionLocal
|
||||
from .encryption import EncryptionManager, is_fernet_token
|
||||
from .logger import belief_scope, configure_logger, logger
|
||||
from .rate_limiter import invalidate_rate_limiter_config
|
||||
|
||||
# Auth-policy fields whose change must invalidate the rate limiter cache.
|
||||
_AUTH_POLICY_FIELDS = ("auth_max_attempts", "auth_attempt_window", "auth_ban_duration")
|
||||
|
||||
|
||||
# #region Core.ConfigManager [C:5] [TYPE Class]
|
||||
@@ -547,7 +551,16 @@ class ConfigManager:
|
||||
"task_log_level": getattr(settings.logging, "task_log_level", None),
|
||||
},
|
||||
)
|
||||
# Auth policy changed → drop the rate limiter's cached policy so the
|
||||
# new values apply immediately on every write path (consolidated PATCH,
|
||||
# PATCH /global, PATCH /logging all funnel through this method).
|
||||
auth_policy_changed = any(
|
||||
getattr(self.config.settings, field, None) != getattr(settings, field, None)
|
||||
for field in _AUTH_POLICY_FIELDS
|
||||
)
|
||||
self.config.settings = settings
|
||||
if auth_policy_changed:
|
||||
invalidate_rate_limiter_config()
|
||||
# Hot-apply logging policy immediately (FR-005 / Settings → Logging).
|
||||
# Without this, DB updates while live logger stays at boot-time level.
|
||||
configure_logger(settings.logging)
|
||||
|
||||
@@ -77,41 +77,6 @@ class Environment(BaseModel):
|
||||
# #endregion Core.ConfigModels.Environment
|
||||
|
||||
|
||||
# #region Core.ConfigModels.AppAsyncRuntimeConfig [TYPE DataClass]
|
||||
# @ingroup Core
|
||||
# @BRIEF Global application-level async runtime configuration.
|
||||
# @RATIONALE Separated from EnvironmentConfig because these settings are not per-env:
|
||||
# executor workers, shutdown timeout, blocking queue timeout are app-wide.
|
||||
class AppAsyncRuntimeConfig(BaseModel):
|
||||
db_executor_workers: int = Field(
|
||||
default=10, ge=1, le=50,
|
||||
description="Max worker threads for database blocking operations"
|
||||
)
|
||||
file_executor_workers: int = Field(
|
||||
default=10, ge=1, le=50,
|
||||
description="Max worker threads for file I/O blocking operations"
|
||||
)
|
||||
git_executor_workers: int = Field(
|
||||
default=5, ge=1, le=20,
|
||||
description="Max worker threads for Git blocking operations"
|
||||
)
|
||||
graceful_shutdown_timeout: int = Field(
|
||||
default=30, ge=10, le=120,
|
||||
description="Graceful shutdown timeout in seconds"
|
||||
)
|
||||
blocking_queue_timeout: int = Field(
|
||||
default=30, ge=5, le=120,
|
||||
description="Timeout for acquiring blocking executor queue slot"
|
||||
)
|
||||
event_bus_maxsize: int = Field(
|
||||
default=10000, ge=100, le=100000,
|
||||
description="Max pending events in EventBus per subscriber queue"
|
||||
)
|
||||
|
||||
|
||||
# #endregion Core.ConfigModels.AppAsyncRuntimeConfig
|
||||
|
||||
|
||||
# #region Core.ConfigModels.LoggingConfig [TYPE DataClass]
|
||||
# @ingroup Core
|
||||
# @BRIEF Defines the configuration for the application's logging system.
|
||||
@@ -280,24 +245,44 @@ class GlobalSettings(BaseModel):
|
||||
return v
|
||||
|
||||
# Task retention settings
|
||||
task_retention_days: int = 30
|
||||
task_retention_days: int = Field(
|
||||
default=30, ge=1, le=3650,
|
||||
description="Tasks older than this many days are deleted by cleanup"
|
||||
)
|
||||
task_retention_limit: int = 100
|
||||
pagination_limit: int = 10
|
||||
|
||||
# Migration sync settings
|
||||
migration_sync_cron: str = "0 2 * * *"
|
||||
|
||||
# Dataset Review Feature Flags
|
||||
ff_dataset_auto_review: bool = True
|
||||
ff_dataset_clarification: bool = True
|
||||
ff_dataset_execution: bool = True
|
||||
# Auth rate limiting policy (failed login attempts per IP)
|
||||
auth_max_attempts: int = Field(
|
||||
default=10, ge=1, le=100,
|
||||
description="Max failed auth attempts per window before IP ban"
|
||||
)
|
||||
auth_attempt_window: int = Field(
|
||||
default=300, ge=30, le=3600,
|
||||
description="Failed-attempts window in seconds"
|
||||
)
|
||||
auth_ban_duration: int = Field(
|
||||
default=900, ge=60, le=86400,
|
||||
description="IP ban duration after limit is reached (seconds)"
|
||||
)
|
||||
|
||||
# LLM validation retention (days)
|
||||
LLM_SCREENSHOT_RETENTION_DAYS: int = 30
|
||||
LLM_RAW_RESPONSE_RETENTION_DAYS: int = 30
|
||||
# Assistant (agent chat) history retention
|
||||
assistant_archive_after_days: int = Field(
|
||||
default=14, ge=1, le=365,
|
||||
description="Days of inactivity before a conversation is archived"
|
||||
)
|
||||
assistant_message_ttl_days: int = Field(
|
||||
default=90, ge=1, le=3650,
|
||||
description="Message TTL in days for assistant history"
|
||||
)
|
||||
|
||||
# Global worker limit for concurrent validation runs
|
||||
GLOBAL_VALIDATION_WORKER_LIMIT: int = 3
|
||||
# Translation baseline expiry (days) before new_key_only falls back to full translation
|
||||
translate_baseline_expiry_days: int = Field(
|
||||
default=90, ge=1, le=3650,
|
||||
description="Baseline expiry in days for new_key_only translation mode"
|
||||
)
|
||||
|
||||
# Session timeout enforcement
|
||||
session_idle_timeout_minutes: int = Field(
|
||||
|
||||
@@ -12,17 +12,71 @@ from collections import defaultdict
|
||||
import threading
|
||||
import time
|
||||
|
||||
# ── Configuration ──
|
||||
from src.core.settings_provider import get_global_settings
|
||||
|
||||
# ── Configuration (fallback defaults; overridable via GlobalSettings) ──
|
||||
# These constants mirror the GlobalSettings field defaults (auth_max_attempts /
|
||||
# auth_attempt_window / auth_ban_duration) and are used only when the live
|
||||
# config is unavailable. Keep them in sync with core/config_models.py.
|
||||
MAX_ATTEMPTS: int = 10 # Max failed attempts per window
|
||||
ATTEMPT_WINDOW: int = 300 # Window in seconds (5 min)
|
||||
BAN_DURATION: int = 900 # Ban duration after limit reached (15 min)
|
||||
|
||||
_CONFIG_CACHE_TTL = 60.0
|
||||
_config_cache: dict | None = None
|
||||
_config_cache_at: float = 0.0
|
||||
_config_lock = threading.Lock()
|
||||
|
||||
|
||||
def invalidate_rate_limiter_config() -> None:
|
||||
"""Drop cached settings so the next call re-reads GlobalSettings."""
|
||||
global _config_cache, _config_cache_at
|
||||
with _config_lock:
|
||||
_config_cache = None
|
||||
_config_cache_at = 0.0
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
"""Read rate limiting policy from GlobalSettings with fallback to module defaults.
|
||||
|
||||
Lock-free fast path: reference reads are atomic in CPython, so a fresh cache
|
||||
hit returns without touching the global mutex. The lock is acquired only for
|
||||
the miss/refill path (bounded by TTL or explicit invalidation).
|
||||
"""
|
||||
global _config_cache, _config_cache_at
|
||||
now = time.monotonic()
|
||||
cached = _config_cache
|
||||
if cached is not None and (now - _config_cache_at) < _CONFIG_CACHE_TTL:
|
||||
return cached
|
||||
with _config_lock:
|
||||
# Re-check under the lock — another thread may have refilled meanwhile.
|
||||
now = time.monotonic()
|
||||
if _config_cache is not None and (now - _config_cache_at) < _CONFIG_CACHE_TTL:
|
||||
return _config_cache
|
||||
settings = get_global_settings()
|
||||
config = {
|
||||
"max_attempts": MAX_ATTEMPTS,
|
||||
"attempt_window": ATTEMPT_WINDOW,
|
||||
"ban_duration": BAN_DURATION,
|
||||
}
|
||||
if settings is not None:
|
||||
config = {
|
||||
"max_attempts": int(settings.auth_max_attempts),
|
||||
"attempt_window": int(settings.auth_attempt_window),
|
||||
"ban_duration": int(settings.auth_ban_duration),
|
||||
}
|
||||
_config_cache = config
|
||||
_config_cache_at = now
|
||||
return config
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""In-memory sliding-window rate limiter keyed by IP address.
|
||||
|
||||
Thread-safe. Tracks failed attempts. After MAX_ATTEMPTS failures within
|
||||
ATTEMPT_WINDOW seconds, the IP is banned for BAN_DURATION seconds.
|
||||
Defaults come from GlobalSettings (auth_max_attempts / auth_attempt_window /
|
||||
auth_ban_duration) and fall back to module constants.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -54,16 +108,17 @@ class RateLimiter:
|
||||
# @PRE ip is a valid IP string.
|
||||
# @POST If MAX_ATTEMPTS exceeded within window, IP is banned.
|
||||
def record_attempt(self, ip: str) -> None:
|
||||
config = _read_config()
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
window_start = now - ATTEMPT_WINDOW
|
||||
window_start = now - config["attempt_window"]
|
||||
|
||||
# Prune old attempts
|
||||
self._attempts[ip] = [t for t in self._attempts[ip] if t > window_start]
|
||||
self._attempts[ip].append(now)
|
||||
|
||||
if len(self._attempts[ip]) > MAX_ATTEMPTS:
|
||||
self._bans[ip] = now + BAN_DURATION
|
||||
if len(self._attempts[ip]) > config["max_attempts"]:
|
||||
self._bans[ip] = now + config["ban_duration"]
|
||||
del self._attempts[ip]
|
||||
# #endregion Core.RateLimiter.RecordAttempt
|
||||
|
||||
|
||||
35
backend/src/core/settings_provider.py
Normal file
35
backend/src/core/settings_provider.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# #region Core.SettingsProvider [C:2] [TYPE Module] [SEMANTICS settings,config,access,fallback]
|
||||
# @defgroup Core Module group.
|
||||
# @BRIEF Single chokepoint for reading live GlobalSettings outside request context.
|
||||
# @LAYER Core
|
||||
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetConfigManager]
|
||||
# @RATIONALE Previously each consumer (rate limiter, assistant history, translate
|
||||
# scheduler) duplicated the lazy get_config_manager() import + exception
|
||||
# handling. A single provider keeps the fallback decision in one place
|
||||
# and makes field renames loud instead of silently swallowed.
|
||||
# @POST Returns the live GlobalSettings instance, or None when the config
|
||||
# manager is unavailable (uninitialized, broken, mocked) — callers then
|
||||
# apply their own documented defaults.
|
||||
# @SIDE_EFFECT None — read-only accessor.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# #region Core.SettingsProvider.GetGlobalSettings [C:2] [TYPE Function] [SEMANTICS settings,access,read]
|
||||
# @ingroup Core
|
||||
# @BRIEF Resolve the current GlobalSettings instance with graceful degradation.
|
||||
# @PRE None.
|
||||
# @POST Returns GlobalSettings instance or None. Never raises.
|
||||
def get_global_settings() -> Any | None:
|
||||
try:
|
||||
from src.dependencies import get_config_manager
|
||||
|
||||
return get_config_manager().get_config().settings
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# #endregion Core.SettingsProvider.GetGlobalSettings
|
||||
|
||||
# #endregion Core.SettingsProvider
|
||||
@@ -18,15 +18,40 @@ from datetime import UTC, datetime, timedelta
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ss_tools.shared.cot_logger import seed_trace_id
|
||||
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ss_tools.shared.cot_logger import seed_trace_id
|
||||
from ...core.config_models import GlobalSettings
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import TranslationJob, TranslationRun, TranslationSchedule
|
||||
from ...services.notifications.service import NotificationService
|
||||
from .events import TranslationEventLog
|
||||
|
||||
|
||||
# #region Plugin.Scheduler.BaselineExpiryDays [C:2] [TYPE Function] [SEMANTICS settings,baseline,expiry]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Resolve translate baseline expiry (days) from GlobalSettings with a safe fallback.
|
||||
# @RATIONALE Config managers may be mocked in tests or partially initialised, so the value
|
||||
# is type-checked and falls back to the GlobalSettings model default, keeping
|
||||
# the fallback in sync with the source of truth instead of a detached literal.
|
||||
# @POST Returns an int >= 1.
|
||||
def _baseline_expiry_days(config_manager) -> int:
|
||||
if config_manager is not None:
|
||||
try:
|
||||
raw = config_manager.get_config().settings.translate_baseline_expiry_days
|
||||
if isinstance(raw, int) and raw > 0:
|
||||
return raw
|
||||
except Exception:
|
||||
pass
|
||||
return _DEFAULT_BASELINE_EXPIRY_DAYS
|
||||
|
||||
|
||||
_DEFAULT_BASELINE_EXPIRY_DAYS: int = GlobalSettings().translate_baseline_expiry_days
|
||||
|
||||
|
||||
# #endregion Plugin.Scheduler.BaselineExpiryDays
|
||||
|
||||
|
||||
# #region Plugin.Scheduler.EnsureAware [C:2] [TYPE Function] [SEMANTICS datetime,normalize,utc]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Convert naive DB datetime to UTC-aware for safe arithmetic.
|
||||
@@ -387,7 +412,7 @@ def execute_scheduled_translation(
|
||||
age = datetime.now(UTC) - recent_created
|
||||
else:
|
||||
age = timedelta(0)
|
||||
if age > timedelta(days=90):
|
||||
if age > timedelta(days=_baseline_expiry_days(config_manager)):
|
||||
baseline_expired = True
|
||||
logger.reason("Baseline expired — full translation", {
|
||||
"job_id": job_id,
|
||||
|
||||
@@ -612,4 +612,46 @@ def test_get_connection_service_dependency():
|
||||
assert isinstance(result, ConnectionService)
|
||||
#endregion Test.SettingsConsolidated.TestGetConnectionServiceDependency
|
||||
|
||||
|
||||
#region Test.SettingsConsolidated.TestPatchOutOfRangeRejected [C:2] [TYPE Function]
|
||||
# @BRIEF Out-of-range auth/task values via consolidated PATCH are rejected with 422.
|
||||
# @TEST_EDGE: out_of_range_rejected -> raw dict assignment must not bypass Field ge/le
|
||||
def test_patch_out_of_range_rejected(mock_deps):
|
||||
"""auth_max_attempts=5000 must be rejected — otherwise the rate limiter would
|
||||
silently accept it, the persisted config would fail validation on restart and
|
||||
fall back to defaults (environment loss)."""
|
||||
payload = {"auth_max_attempts": 5000}
|
||||
response = client.patch("/api/settings/consolidated", json=payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
# Nothing may be persisted when the patch is invalid
|
||||
mock_deps["config"].update_global_settings.assert_not_called()
|
||||
#endregion Test.SettingsConsolidated.TestPatchOutOfRangeRejected
|
||||
|
||||
|
||||
#region Test.SettingsConsolidated.TestPatchInRangeAccepted [C:2] [TYPE Function]
|
||||
# @BRIEF In-range values still pass through and reach update_global_settings.
|
||||
def test_patch_in_range_accepted(mock_deps):
|
||||
"""auth_max_attempts=25 is within 1..100 and must be applied."""
|
||||
payload = {"auth_max_attempts": 25}
|
||||
response = client.patch("/api/settings/consolidated", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
mock_deps["config"].update_global_settings.assert_called_once()
|
||||
args, _ = mock_deps["config"].update_global_settings.call_args
|
||||
assert args[0].auth_max_attempts == 25
|
||||
#endregion Test.SettingsConsolidated.TestPatchInRangeAccepted
|
||||
|
||||
|
||||
#region Test.SettingsConsolidated.TestPatchZeroTaskRetentionRejected [C:2] [TYPE Function]
|
||||
# @BRIEF task_retention_days=0 would delete all tasks on next cleanup — must be rejected.
|
||||
# @TEST_EDGE: zero_retention_rejected -> lower bound enforced
|
||||
def test_patch_zero_task_retention_rejected(mock_deps):
|
||||
"""task_retention_days=0 violates ge=1 and must 422."""
|
||||
payload = {"task_retention_days": 0}
|
||||
response = client.patch("/api/settings/consolidated", json=payload)
|
||||
assert response.status_code == 422
|
||||
mock_deps["config"].update_global_settings.assert_not_called()
|
||||
#endregion Test.SettingsConsolidated.TestPatchZeroTaskRetentionRejected
|
||||
|
||||
#endregion Test.SettingsConsolidated.TestSettingsConsolidated
|
||||
|
||||
@@ -16,11 +16,10 @@ import threading
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.core.rate_limiter import RateLimiter
|
||||
|
||||
from src.core.rate_limiter import RateLimiter, _read_config as _original_read_config
|
||||
|
||||
# ── Constant overrides for fast, deterministic tests ──
|
||||
# MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`)
|
||||
@@ -29,10 +28,19 @@ from src.core.rate_limiter import RateLimiter
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_constants(monkeypatch):
|
||||
"""Monkeypatch module constants to small values for fast testing."""
|
||||
"""Monkeypatch module constants to small values for fast testing.
|
||||
|
||||
The limiter resolves policy from GlobalSettings with a fallback to the
|
||||
module constants, so the settings reader is patched to return the fast
|
||||
values — keeping the sliding-window logic deterministic.
|
||||
"""
|
||||
monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2)
|
||||
monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600)
|
||||
monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600)
|
||||
monkeypatch.setattr(
|
||||
"src.core.rate_limiter._read_config",
|
||||
lambda: {"max_attempts": 2, "attempt_window": 3600, "ban_duration": 3600},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -224,4 +232,63 @@ class TestRateLimiterSingleton:
|
||||
assert hasattr(rate_limiter, "record_success")
|
||||
# #endregion Test.RateLimiter.TestSingletonExists
|
||||
|
||||
|
||||
class TestRateLimiterSettingsDriven:
|
||||
"""Rate limiting policy resolves from GlobalSettings with constant fallback."""
|
||||
|
||||
# #region Test.RateLimiter.TestSettingsOverrideConstants [C:2] [TYPE Function]
|
||||
# @BRIEF GlobalSettings auth_* fields override module constants when available.
|
||||
def test_settings_override_constants(self, monkeypatch):
|
||||
import src.core.rate_limiter as rl
|
||||
|
||||
class FakeSettings:
|
||||
auth_max_attempts = 1
|
||||
auth_attempt_window = 60
|
||||
auth_ban_duration = 120
|
||||
|
||||
class FakeConfig:
|
||||
settings = FakeSettings()
|
||||
|
||||
class FakeConfigManager:
|
||||
def get_config(self):
|
||||
return FakeConfig()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.dependencies.get_config_manager", lambda: FakeConfigManager(), raising=False
|
||||
)
|
||||
monkeypatch.setattr(rl, "_read_config", _original_read_config)
|
||||
monkeypatch.setattr(rl, "_config_cache", None)
|
||||
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
|
||||
|
||||
config = rl._read_config()
|
||||
assert config == {"max_attempts": 1, "attempt_window": 60, "ban_duration": 120}
|
||||
|
||||
limiter = rl.RateLimiter()
|
||||
with patch("src.core.rate_limiter.time") as mock_time:
|
||||
mock_time.monotonic.return_value = 1000.0
|
||||
limiter.record_attempt("10.0.0.1")
|
||||
limiter.record_attempt("10.0.0.1")
|
||||
# max_attempts=1 → 2nd attempt exceeds limit → ban
|
||||
assert limiter.is_banned("10.0.0.1") is True
|
||||
# #endregion Test.RateLimiter.TestSettingsOverrideConstants
|
||||
|
||||
# #region Test.RateLimiter.TestFallbackOnConfigError [C:2] [TYPE Function]
|
||||
# @BRIEF Missing/broken config manager falls back to module constants.
|
||||
def test_fallback_on_config_error(self, monkeypatch):
|
||||
import src.core.rate_limiter as rl
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("no config")
|
||||
|
||||
monkeypatch.setattr(rl, "_read_config", _original_read_config)
|
||||
monkeypatch.setattr(rl, "_config_cache", None)
|
||||
monkeypatch.setattr(rl, "_config_cache_at", 0.0)
|
||||
monkeypatch.setattr("src.dependencies.get_config_manager", boom, raising=False)
|
||||
|
||||
config = rl._read_config()
|
||||
assert config["max_attempts"] == rl.MAX_ATTEMPTS
|
||||
assert config["attempt_window"] == rl.ATTEMPT_WINDOW
|
||||
assert config["ban_duration"] == rl.BAN_DURATION
|
||||
# #endregion Test.RateLimiter.TestFallbackOnConfigError
|
||||
|
||||
# #endregion Test.RateLimiter
|
||||
|
||||
@@ -312,5 +312,42 @@
|
||||
"session_timeout_logout": "Log out now",
|
||||
"session_expired_title": "Session expired",
|
||||
"session_expired_message": "Your session has expired. Please log in again.",
|
||||
"session_extending": "Extending session..."
|
||||
"session_extending": "Extending session...",
|
||||
"default_environment": "Default environment",
|
||||
"default_environment_hint": "Environment used by default when no explicit environment is selected",
|
||||
"env_default_saved": "Default environment saved",
|
||||
"task_retention": "Task retention",
|
||||
"task_retention_description": "Automatic cleanup policy for finished task records",
|
||||
"task_retention_days": "Retention (days)",
|
||||
"task_retention_days_hint": "Tasks older than this many days are deleted",
|
||||
"task_retention_limit": "Max stored tasks",
|
||||
"task_retention_limit_hint": "Keep at most this many most recent tasks",
|
||||
"auth_rate_limit": "Auth rate limit",
|
||||
"auth_rate_limit_description": "Failed login attempt policy per IP address",
|
||||
"auth_max_attempts": "Max failed attempts",
|
||||
"auth_max_attempts_hint": "Ban the IP after this many failed attempts per window",
|
||||
"auth_attempt_window": "Attempt window (seconds)",
|
||||
"auth_attempt_window_hint": "Failed attempts are counted within this sliding window",
|
||||
"auth_ban_duration": "Ban duration (seconds)",
|
||||
"auth_ban_duration_hint": "How long the IP stays banned after the limit is reached",
|
||||
"assistant_retention": "Assistant history retention",
|
||||
"assistant_retention_description": "Retention policy for agent chat conversations and messages",
|
||||
"assistant_archive_after_days": "Archive after (days)",
|
||||
"assistant_archive_after_days_hint": "Conversations inactive longer than this are archived",
|
||||
"assistant_message_ttl_days": "Message TTL (days)",
|
||||
"assistant_message_ttl_days_hint": "Messages older than this are deleted from history",
|
||||
"translate_baseline_expiry": "Translation baseline",
|
||||
"translate_baseline_expiry_days": "Baseline expiry (days)",
|
||||
"translate_baseline_expiry_hint": "After this many days without a successful run, new_key_only falls back to full translation",
|
||||
"log_max_bytes": "Log file max size (bytes)",
|
||||
"log_max_bytes_hint": "Rotate the log file after it reaches this size",
|
||||
"log_backup_count": "Log backup count",
|
||||
"log_backup_count_hint": "Number of rotated log files to keep",
|
||||
"log_agent_view": "Agent view",
|
||||
"log_agent_view_hint": "Prefer higher-level intents for LLM agent traces",
|
||||
"log_hide_routine_infra": "Hide routine infrastructure logs",
|
||||
"log_hide_routine_infra_hint": "Suppress repetitive per-request auth/client noise",
|
||||
"log_level_for_agents": "Agent log level",
|
||||
"log_level_for_agents_hint": "Filtering level for agent-friendly log consumers",
|
||||
"validation_errors": "Fix invalid values before saving"
|
||||
}
|
||||
|
||||
@@ -312,5 +312,42 @@
|
||||
"session_timeout_logout": "Выйти сейчас",
|
||||
"session_expired_title": "Сессия истекла",
|
||||
"session_expired_message": "Ваша сессия истекла. Пожалуйста, войдите снова.",
|
||||
"session_extending": "Продление сессии..."
|
||||
"session_extending": "Продление сессии...",
|
||||
"default_environment": "Окружение по умолчанию",
|
||||
"default_environment_hint": "Окружение, используемое по умолчанию, когда явное не выбрано",
|
||||
"env_default_saved": "Окружение по умолчанию сохранено",
|
||||
"task_retention": "Хранение задач",
|
||||
"task_retention_description": "Политика автоматической очистки завершённых задач",
|
||||
"task_retention_days": "Срок хранения (дни)",
|
||||
"task_retention_days_hint": "Задачи старше указанного количества дней удаляются",
|
||||
"task_retention_limit": "Максимум задач",
|
||||
"task_retention_limit_hint": "Хранить не более этого количества последних задач",
|
||||
"auth_rate_limit": "Лимит попыток входа",
|
||||
"auth_rate_limit_description": "Политика неудачных попыток входа на IP-адрес",
|
||||
"auth_max_attempts": "Максимум неудачных попыток",
|
||||
"auth_max_attempts_hint": "Блокировать IP после этого числа неудачных попыток за окно",
|
||||
"auth_attempt_window": "Окно попыток (секунды)",
|
||||
"auth_attempt_window_hint": "Неудачные попытки считаются в скользящем окне",
|
||||
"auth_ban_duration": "Длительность блокировки (секунды)",
|
||||
"auth_ban_duration_hint": "Сколько IP остаётся заблокированным после достижения лимита",
|
||||
"assistant_retention": "Хранение истории ассистента",
|
||||
"assistant_retention_description": "Политика хранения диалогов и сообщений агент-чата",
|
||||
"assistant_archive_after_days": "Архивация после (дни)",
|
||||
"assistant_archive_after_days_hint": "Диалоги без активности дольше этого срока архивируются",
|
||||
"assistant_message_ttl_days": "Срок жизни сообщений (дни)",
|
||||
"assistant_message_ttl_days_hint": "Сообщения старше этого срока удаляются из истории",
|
||||
"translate_baseline_expiry": "Базовая линия перевода",
|
||||
"translate_baseline_expiry_days": "Истечение базовой линии (дни)",
|
||||
"translate_baseline_expiry_hint": "После этого числа дней без успешного запуска new_key_only переходит к полному переводу",
|
||||
"log_max_bytes": "Макс. размер файла лога (байты)",
|
||||
"log_max_bytes_hint": "Ротация файла лога по достижении этого размера",
|
||||
"log_backup_count": "Число резервных логов",
|
||||
"log_backup_count_hint": "Сколько ротированных файлов лога хранить",
|
||||
"log_agent_view": "Режим агента",
|
||||
"log_agent_view_hint": "Предпочитать намерения высокого уровня для трасс LLM-агента",
|
||||
"log_hide_routine_infra": "Скрывать инфраструктурные логи",
|
||||
"log_hide_routine_infra_hint": "Подавлять повторяющийся шум авторизации/клиентов на запрос",
|
||||
"log_level_for_agents": "Уровень лога для агентов",
|
||||
"log_level_for_agents_hint": "Уровень фильтрации для потребителей логов агента",
|
||||
"validation_errors": "Исправьте некорректные значения перед сохранением"
|
||||
}
|
||||
|
||||
@@ -138,6 +138,18 @@
|
||||
showDeleteEnvConfirm = true;
|
||||
}
|
||||
|
||||
async function handleDefaultEnvChange(event) {
|
||||
const value = event.currentTarget.value || null;
|
||||
log("EnvironmentsTab", "REASON", "Setting default environment", { id: value });
|
||||
try {
|
||||
await api.updateConsolidatedSettings({ default_environment_id: value });
|
||||
notifications.success($t.settings?.env_default_saved || "Default environment saved");
|
||||
} catch (error) {
|
||||
log("EnvironmentsTab", "EXPLORE", "Failed to save default environment", {}, String(error));
|
||||
notifications.error(error.message || $t.settings?.env_save_failed);
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmDeleteEnv() {
|
||||
const id = deleteEnvTargetId;
|
||||
deleteEnvTargetId = null;
|
||||
@@ -162,6 +174,26 @@
|
||||
</p>
|
||||
|
||||
{#if !editingEnvId && !isAddingEnv}
|
||||
<div class="bg-surface-muted p-6 rounded-lg mb-6 border border-border">
|
||||
<label for="default_environment" class="block text-sm font-medium text-text">
|
||||
{$t.settings?.default_environment || "Default environment"}
|
||||
</label>
|
||||
<p class="text-xs text-text-muted mb-2">
|
||||
{$t.settings?.default_environment_hint || "Environment used by default when no explicit environment is selected"}
|
||||
</p>
|
||||
<select
|
||||
id="default_environment"
|
||||
bind:value={settings.default_environment_id}
|
||||
onchange={handleDefaultEnvChange}
|
||||
class="mt-1 block w-full max-w-md border border-border-strong rounded-md shadow-sm p-2"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each settings.environments || [] as env (env.id)}
|
||||
<option value={env.id}>{env.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -74,6 +74,88 @@
|
||||
{$t.settings?.belief_state_hint}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="log_level_for_agents"
|
||||
class="block text-sm font-medium text-text"
|
||||
>{$t.settings?.log_level_for_agents || "Agent log level"}</label
|
||||
>
|
||||
<select
|
||||
id="log_level_for_agents"
|
||||
bind:value={settings.logging.log_level_for_agents}
|
||||
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
|
||||
>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_level_for_agents_hint || "Filtering level for agent-friendly log consumers"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="log_max_bytes"
|
||||
class="block text-sm font-medium text-text"
|
||||
>{$t.settings?.log_max_bytes || "Log file max size (bytes)"}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="log_max_bytes"
|
||||
min="1048576"
|
||||
step="1048576"
|
||||
bind:value={settings.logging.max_bytes}
|
||||
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_max_bytes_hint || "Rotate the log file after it reaches this size"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="log_backup_count"
|
||||
class="block text-sm font-medium text-text"
|
||||
>{$t.settings?.log_backup_count || "Log backup count"}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="log_backup_count"
|
||||
min="1"
|
||||
step="1"
|
||||
bind:value={settings.logging.backup_count}
|
||||
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2"
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.log_backup_count_hint || "Number of rotated log files to keep"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agent_view"
|
||||
bind:checked={settings.logging.agent_view}
|
||||
class="h-4 w-4 text-primary border-border-strong rounded"
|
||||
/>
|
||||
<span class="ml-2 block text-sm text-text"
|
||||
>{$t.settings?.log_agent_view || "Agent view"}</span
|
||||
>
|
||||
</label>
|
||||
<p class="text-xs text-text-muted mt-1 ml-6">
|
||||
{$t.settings?.log_agent_view_hint || "Prefer higher-level intents for LLM agent traces"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hide_routine_infra"
|
||||
bind:checked={settings.logging.hide_routine_infra}
|
||||
class="h-4 w-4 text-primary border-border-strong rounded"
|
||||
/>
|
||||
<span class="ml-2 block text-sm text-text"
|
||||
>{$t.settings?.log_hide_routine_infra || "Hide routine infrastructure logs"}</span
|
||||
>
|
||||
</label>
|
||||
<p class="text-xs text-text-muted mt-1 ml-6">
|
||||
{$t.settings?.log_hide_routine_infra_hint || "Suppress repetitive per-request auth/client noise"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import KeyRecoveryWizard from "$lib/components/security/KeyRecoveryWizard.svelte";
|
||||
import { appTimezone } from "$lib/stores/timezone.svelte.js";
|
||||
import { getEncryptionHealth } from "$lib/api";
|
||||
import { notifications } from "$lib/toasts.svelte.js";
|
||||
import type { EncryptionHealthResponse } from "../../types/encryptionRecovery";
|
||||
|
||||
let { settings = $bindable(), onSave } = $props();
|
||||
@@ -28,7 +29,34 @@
|
||||
}
|
||||
$effect(() => { loadEncHealth(); });
|
||||
|
||||
// Numeric bounds mirror the Field(ge/le) constraints in backend core/config_models.py.
|
||||
// Inline `error` props only display the problem — saving must be blocked too,
|
||||
// otherwise out-of-range values would be persisted and could reset the whole
|
||||
// config on restart or disable the auth rate limiter.
|
||||
const NUMERIC_FIELDS = [
|
||||
{ key: "task_retention_days", min: 1, max: Infinity },
|
||||
{ key: "task_retention_limit", min: 1, max: Infinity },
|
||||
{ key: "auth_max_attempts", min: 1, max: 100 },
|
||||
{ key: "auth_attempt_window", min: 30, max: 3600 },
|
||||
{ key: "auth_ban_duration", min: 60, max: 86400 },
|
||||
{ key: "assistant_archive_after_days", min: 1, max: 365 },
|
||||
{ key: "assistant_message_ttl_days", min: 1, max: 3650 },
|
||||
{ key: "translate_baseline_expiry_days", min: 1, max: 3650 },
|
||||
] as const;
|
||||
|
||||
const hasValidationErrors = $derived(
|
||||
NUMERIC_FIELDS.some(({ key, min, max }) => {
|
||||
const value = settings[key];
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return false;
|
||||
return value < min || value > max;
|
||||
}),
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (hasValidationErrors) {
|
||||
notifications.error($t.settings?.validation_errors || "Fix invalid values before saving");
|
||||
return;
|
||||
}
|
||||
// Sync app timezone to global store before persisting
|
||||
if (settings.app_timezone) {
|
||||
appTimezone.current = settings.app_timezone;
|
||||
@@ -169,6 +197,159 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Retention Section -->
|
||||
<div class="border-t border-border pt-8">
|
||||
<h2 class="text-xl font-bold mb-4">{$t.settings?.task_retention || "Task retention"}</h2>
|
||||
<p class="text-text-muted mb-6">{$t.settings?.task_retention_description || "Automatic cleanup policy for finished task records"}</p>
|
||||
|
||||
<div class="bg-surface-muted p-6 rounded-lg border border-border">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
label={$t.settings?.task_retention_days || "Retention (days)"}
|
||||
bind:value={settings.task_retention_days}
|
||||
error={settings.task_retention_days < 1
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "infinity")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.task_retention_days_hint || "Tasks older than this many days are deleted"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
label={$t.settings?.task_retention_limit || "Max stored tasks"}
|
||||
bind:value={settings.task_retention_limit}
|
||||
error={settings.task_retention_limit < 1
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "infinity")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.task_retention_limit_hint || "Keep at most this many most recent tasks"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Rate Limit Section -->
|
||||
<div class="border-t border-border pt-8">
|
||||
<h2 class="text-xl font-bold mb-4">{$t.settings?.auth_rate_limit || "Auth rate limit"}</h2>
|
||||
<p class="text-text-muted mb-6">{$t.settings?.auth_rate_limit_description || "Failed login attempt policy per IP address"}</p>
|
||||
|
||||
<div class="bg-surface-muted p-6 rounded-lg border border-border">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
label={$t.settings?.auth_max_attempts || "Max failed attempts"}
|
||||
bind:value={settings.auth_max_attempts}
|
||||
error={settings.auth_max_attempts < 1 || settings.auth_max_attempts > 100
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "100")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_max_attempts_hint || "Ban the IP after this many failed attempts per window"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="30"
|
||||
max="3600"
|
||||
step="1"
|
||||
label={$t.settings?.auth_attempt_window || "Attempt window (seconds)"}
|
||||
bind:value={settings.auth_attempt_window}
|
||||
error={settings.auth_attempt_window < 30 || settings.auth_attempt_window > 3600
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "30").replace("{max}", "3600")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_attempt_window_hint || "Failed attempts are counted within this sliding window"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
step="1"
|
||||
label={$t.settings?.auth_ban_duration || "Ban duration (seconds)"}
|
||||
bind:value={settings.auth_ban_duration}
|
||||
error={settings.auth_ban_duration < 60 || settings.auth_ban_duration > 86400
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "60").replace("{max}", "86400")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.auth_ban_duration_hint || "How long the IP stays banned after the limit is reached"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant Retention Section -->
|
||||
<div class="border-t border-border pt-8">
|
||||
<h2 class="text-xl font-bold mb-4">{$t.settings?.assistant_retention || "Assistant history retention"}</h2>
|
||||
<p class="text-text-muted mb-6">{$t.settings?.assistant_retention_description || "Retention policy for agent chat conversations and messages"}</p>
|
||||
|
||||
<div class="bg-surface-muted p-6 rounded-lg border border-border">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
label={$t.settings?.assistant_archive_after_days || "Archive after (days)"}
|
||||
bind:value={settings.assistant_archive_after_days}
|
||||
error={settings.assistant_archive_after_days < 1 || settings.assistant_archive_after_days > 365
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "365")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.assistant_archive_after_days_hint || "Conversations inactive longer than this are archived"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
step="1"
|
||||
label={$t.settings?.assistant_message_ttl_days || "Message TTL (days)"}
|
||||
bind:value={settings.assistant_message_ttl_days}
|
||||
error={settings.assistant_message_ttl_days < 1 || settings.assistant_message_ttl_days > 3650
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "3650")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.assistant_message_ttl_days_hint || "Messages older than this are deleted from history"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Translation Baseline Section -->
|
||||
<div class="border-t border-border pt-8">
|
||||
<h2 class="text-xl font-bold mb-4">{$t.settings?.translate_baseline_expiry || "Translation baseline"}</h2>
|
||||
|
||||
<div class="bg-surface-muted p-6 rounded-lg border border-border">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
step="1"
|
||||
label={$t.settings?.translate_baseline_expiry_days || "Baseline expiry (days)"}
|
||||
bind:value={settings.translate_baseline_expiry_days}
|
||||
error={settings.translate_baseline_expiry_days < 1 || settings.translate_baseline_expiry_days > 3650
|
||||
? ($t.settings?.logout_timeout_invalid || "Value must be between {min} and {max}").replace("{min}", "1").replace("{max}", "3650")
|
||||
: ""}
|
||||
/>
|
||||
<p class="text-xs text-text-muted mt-1">{$t.settings?.translate_baseline_expiry_hint || "After this many days without a successful run, new_key_only falls back to full translation"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<div class="border-t border-border pt-8">
|
||||
<ApiKeysTab />
|
||||
|
||||
@@ -60,6 +60,12 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
return () => { };
|
||||
}
|
||||
},
|
||||
locale: {
|
||||
subscribe: (fn) => {
|
||||
fn('en');
|
||||
return () => { };
|
||||
}
|
||||
},
|
||||
_: vi.fn((key) => key)
|
||||
}));
|
||||
|
||||
@@ -209,6 +215,41 @@ describe('SettingsPage UX Contracts', () => {
|
||||
expect(notifications.error).toHaveBeenCalledWith('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
// @UX_STATE: Error -> Save blocked when numeric settings are out of range
|
||||
it('should block saving when numeric settings are out of range', async () => {
|
||||
const invalidSettings = {
|
||||
...mockSettings,
|
||||
app_timezone: 'Europe/Moscow',
|
||||
session_idle_timeout_minutes: 0,
|
||||
session_absolute_timeout_minutes: 0,
|
||||
session_warning_minutes: 5,
|
||||
auth_max_attempts: 5000,
|
||||
auth_attempt_window: 300,
|
||||
auth_ban_duration: 900,
|
||||
task_retention_days: 30,
|
||||
task_retention_limit: 100,
|
||||
assistant_archive_after_days: 14,
|
||||
assistant_message_ttl_days: 90,
|
||||
translate_baseline_expiry_days: 90,
|
||||
};
|
||||
api.getConsolidatedSettings.mockResolvedValue(invalidSettings);
|
||||
api.requestApi.mockResolvedValue(mockMigrationSettings);
|
||||
api.updateConsolidatedSettings.mockResolvedValue({ status: 'success' });
|
||||
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
|
||||
|
||||
// Navigate to the System tab (out-of-range auth_max_attempts lives there)
|
||||
await fireEvent.click(screen.getAllByText('System')[0]);
|
||||
|
||||
const saveBtn = screen.getByText('Save Logging Config');
|
||||
await fireEvent.click(saveBtn);
|
||||
|
||||
// Out-of-range value must not reach the API
|
||||
expect(api.updateConsolidatedSettings).not.toHaveBeenCalled();
|
||||
expect(notifications.error).toHaveBeenCalledWith('Fix invalid values before saving');
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion Tests.SettingsPage.SettingsPageUxTestModule
|
||||
|
||||
Reference in New Issue
Block a user