feat: port conflict validation, autosave on Apply, extended integration tests

Port conflict validation:
- api/port_registry.py: detect_conflicts() checks all service sections for shared port values
- api/app.py: returns HTTP 409 on port conflict after existing range validation
- webui/src/pages/Settings.jsx: JS-side detectPortConflicts() with useMemo shows inline
  conflict errors and blocks Save before the request is made; catch blocks surface server
  error messages (including 409) instead of generic fallbacks

Config autosave on Apply:
- webui/src/contexts/DraftConfigContext.jsx: new context; Settings registers flush callbacks
  per section; App calls flushAll() before applyPending() when any section is dirty
- webui/src/App.jsx: wraps tree with DraftConfigProvider, handleApply shows 'saving' banner
  state and awaits flushAll()
- webui/src/pages/Settings.jsx: registers identity + per-service flushers; propagates dirty
  state into context via setDirty; uses refs to avoid stale closures

Extended integration test coverage (114 new tests):
- tests/integration/test_config_api.py: GET/PUT config, export, import, backup lifecycle
- tests/integration/test_network_services.py: DNS records + DHCP reservations CRUD
- tests/integration/test_containers.py: list, restart, logs, stats; recovery polling
- tests/integration/test_negative_scenarios.py: error-path coverage for all endpoints
- tests/test_port_conflicts.py: 20 unit tests for port_registry.detect_conflicts()

Pre-commit hook updated to skip tests/integration/ (live-stack tests require a running
stack and must be run explicitly via `make test-integration`).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 04:45:47 -04:00
parent 596b06f171
commit 768571f2b7
11 changed files with 1584 additions and 12 deletions
+9
View File
@@ -43,6 +43,7 @@ from service_bus import ServiceBus, EventType
from log_manager import LogManager
from cell_link_manager import CellLinkManager
import firewall_manager
from port_registry import PORT_FIELDS, detect_conflicts
# Context variable for request info
request_context = contextvars.ContextVar('request_context', default={})
@@ -477,6 +478,14 @@ def update_config():
raise ValueError()
except (ValueError, TypeError):
return jsonify({'error': f'{_svc}.{_f} must be an integer between 1 and 65535'}), 400
# Validate that no two service sections use the same port number
_conflicts = detect_conflicts(config_manager.configs, data)
if _conflicts:
_msgs = []
for _c in _conflicts:
_pairs = ', '.join(f"{_s}.{_f}" for _s, _f in _c['conflicts'])
_msgs.append(f"port {_c['port']} is used by {_pairs}")
return jsonify({'error': 'Port conflict: ' + '; '.join(_msgs)}), 409
# Validate WireGuard address (must be valid IP/CIDR)
if 'wireguard' in data and isinstance(data['wireguard'], dict):
_addr = data['wireguard'].get('address')