diff --git a/biome.json b/biome.json index d503df20..2b175287 100644 --- a/biome.json +++ b/biome.json @@ -6,6 +6,7 @@ "packages/**", "modules/*/*/pages/**", "modules/*/*/components/**", + "modules/*/tests-js/**", "!host/client_app/modules.generated.ts", "!host/client_app/modules.manifest.json", "!host/client_app/modules.generated.css", diff --git a/docs/modules/background_tasks.md b/docs/modules/background_tasks.md index 0772e693..29c10a9a 100644 --- a/docs/modules/background_tasks.md +++ b/docs/modules/background_tasks.md @@ -148,8 +148,8 @@ DB-backed; defaults are in `BackgroundTasksSettings`. Several are marked `requir | `stuck_after_seconds` | `300` | heartbeat-staleness threshold | | `stuck_sweep_interval_seconds` | `60` | beat cadence for the stuck sweep | | `purge_interval_seconds` | `86_400` | beat cadence for old-row purge | -| `retention_days` | `14` | how long to keep terminal rows | -| `max_retries` | `3` | informational; tasks define their own policies | +| `retention_days` | `14` | how long to keep terminal rows; range 1-3650 | +| `max_retries` | `3` | informational; tasks define their own policies; range 0-100 | Bootstrap env-var equivalents (`SM_BG_TASKS_*`) only seed pydantic defaults at first boot — once a value lives in the DB, it's authoritative. diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index 63d84a6d..d052ea83 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -63,8 +63,18 @@ class BackgroundTasksSettings(BaseSettings): stuck_sweep_interval_seconds: int = DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS purge_interval_seconds: int = DEFAULT_PURGE_INTERVAL_SECONDS - retention_days: int = DEFAULT_RETENTION_DAYS - max_retries: int = DEFAULT_MAX_RETRIES + retention_days: int = Field( + default=DEFAULT_RETENTION_DAYS, + ge=1, + le=3650, + description="Days to keep terminal task execution records (1-3650).", + ) + max_retries: int = Field( + default=DEFAULT_MAX_RETRIES, + ge=0, + le=100, + description="Configured retry ceiling; individual tasks define their own policies (0-100).", + ) @model_validator(mode="after") def _forbid_localhost_broker_in_production(self) -> BackgroundTasksSettings: diff --git a/modules/background_tasks/tests/test_background_tasks_settings.py b/modules/background_tasks/tests/test_background_tasks_settings.py new file mode 100644 index 00000000..d488fa41 --- /dev/null +++ b/modules/background_tasks/tests/test_background_tasks_settings.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest +from background_tasks.settings import BackgroundTasksSettings +from pydantic import ValidationError + + +@pytest.mark.parametrize("retention_days", [1, 3650]) +def test_retention_days_accepts_supported_boundaries(retention_days: int) -> None: + assert BackgroundTasksSettings(retention_days=retention_days).retention_days == retention_days + + +@pytest.mark.parametrize("retention_days", [0, -5, 3651, 999_999_999]) +def test_retention_days_rejects_values_outside_supported_range(retention_days: int) -> None: + with pytest.raises(ValidationError): + BackgroundTasksSettings(retention_days=retention_days) + + +@pytest.mark.parametrize("max_retries", [0, 100]) +def test_max_retries_accepts_supported_boundaries(max_retries: int) -> None: + assert BackgroundTasksSettings(max_retries=max_retries).max_retries == max_retries + + +@pytest.mark.parametrize("max_retries", [-1, 101, 999_999_999]) +def test_max_retries_rejects_values_outside_supported_range(max_retries: int) -> None: + with pytest.raises(ValidationError): + BackgroundTasksSettings(max_retries=max_retries) diff --git a/modules/settings/package.json b/modules/settings/package.json index 52830f7a..3b8975a1 100644 --- a/modules/settings/package.json +++ b/modules/settings/package.json @@ -12,5 +12,7 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "sonner": "^2.0.7" + } } diff --git a/modules/settings/settings/locales/en.json b/modules/settings/settings/locales/en.json index 13562939..5f4499e1 100644 --- a/modules/settings/settings/locales/en.json +++ b/modules/settings/settings/locales/en.json @@ -88,6 +88,7 @@ "modules_form": { "save": "Save", "saving": "Saving…", + "saved_toast": "Settings saved", "default_group": "General", "requires_restart": "Requires restart", "reset_to_default": "Reset to default", diff --git a/modules/settings/settings/pages/components/ModuleForm.tsx b/modules/settings/settings/pages/components/ModuleForm.tsx index 50122046..2d860c6f 100644 --- a/modules/settings/settings/pages/components/ModuleForm.tsx +++ b/modules/settings/settings/pages/components/ModuleForm.tsx @@ -36,23 +36,27 @@ export function ModuleForm({ module: m, testable = false }: Props) { }, [m.fields]); const [values, setValues] = useState>(initial); + const [baseline, setBaseline] = useState>(initial); const [errors, setErrors] = useState>({}); const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); // Reset the edit buffer whenever the underlying module changes (package // switch, or server-reloaded props after a save/reset). useEffect(() => { setValues(initial); + setBaseline(initial); setErrors({}); + setSaved(false); }, [initial]); const modifiedFields = useMemo(() => { const s = new Set(); for (const name of Object.keys(values)) { - if (notEqual(values[name], initial[name])) s.add(name); + if (notEqual(values[name], baseline[name])) s.add(name); } return s; - }, [values, initial]); + }, [values, baseline]); const defaultByName = useMemo(() => { const o: Record = {}; @@ -74,6 +78,7 @@ export function ModuleForm({ module: m, testable = false }: Props) { async function onSave() { setBusy(true); + setSaved(false); setErrors({}); const changed: Record = {}; for (const name of modifiedFields) changed[name] = values[name]; @@ -90,12 +95,16 @@ export function ModuleForm({ module: m, testable = false }: Props) { } setErrors(fieldErrs); } else if (resp.ok) { - router.reload({ only: ['modules'] }); + // Keeping the form mounted lets the confirmation remain visible instead + // of being discarded by an immediate Inertia reload. + setBaseline({ ...values }); + setSaved(true); } setBusy(false); } async function onReset(name: string) { + setSaved(false); await fetch(`/api/settings/modules/${m.package}/${name}`, { method: 'DELETE' }); router.reload({ only: ['modules'] }); } @@ -107,7 +116,12 @@ export function ModuleForm({ module: m, testable = false }: Props) {

{m.module_name}

{m.package}

-
+
+ {saved && ( +

+ {t(keys.settings.modules_form.saved_toast)} +

+ )} {testable && }