From f9fa85255b273aae35feb3e46c92d07869adb294 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 23:12:15 +0100 Subject: [PATCH] v0.7.2-beta: add ROA planner preflight checks --- README.md | 4 +- RELEASE_NOTES.md | 2 +- ROADMAP.md | 6 +- backend/app/api/routes_checks.py | 16 ++- backend/app/api/routes_system.py | 2 +- backend/app/schemas.py | 19 +++ backend/app/services/roa_planner_service.py | 136 ++++++++++++++++++++ backend/tests/test_api_smoke.py | 61 +++++++++ frontend/src/App.tsx | 14 +- frontend/src/api.ts | 2 + frontend/src/components/ChangeCasesView.tsx | 7 +- frontend/src/components/Layout.tsx | 7 +- frontend/src/components/RoaPlannerForm.tsx | 31 +++++ 13 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 backend/app/services/roa_planner_service.py create mode 100644 frontend/src/components/RoaPlannerForm.tsx diff --git a/README.md b/README.md index f2d4028..07df3f5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.1-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.2-beta**. ## Quickstart with Docker Compose @@ -276,6 +276,6 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap - Keep `SECRET_KEY` stable; changing it invalidates existing sessions. -## BGP Visibility Details (v0.7.1-beta) +## BGP Visibility Details (v0.7.2-beta) - Read-only BGP visibility validation for prefix and optional expected origin AS. - Uses external RIPEstat visibility data; results are momentary snapshots and do not replace continuous monitoring. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 46c1803..876c89e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -## v0.7.1-beta: BGP Visibility Details +## v0.7.2-beta: BGP Visibility Details ### Motivation Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model. diff --git a/ROADMAP.md b/ROADMAP.md index a3c7b03..db71ec3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,13 +1,13 @@ # RouteForge Roadmap ## Current Status -v0.7.1-beta, BGP Visibility Details completed, read-only +v0.7.2-beta, BGP Visibility Details completed, read-only -## v0.7.1-beta +## v0.7.2-beta - projects/change cases - grouped preflight reports -## v0.7.1-beta +## v0.7.2-beta - bgp visibility details ## v0.7.2-beta diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py index 18a7055..635b6cf 100644 --- a/backend/app/api/routes_checks.py +++ b/backend/app/api/routes_checks.py @@ -6,12 +6,13 @@ from app.core.audit import write_audit_log from app.database import get_db from app.models import ChangeCase, Check, Report -from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, BgpVisibilityCheckRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest +from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, BgpVisibilityCheckRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest, RoaPreflightCheckRequest from app.services.asn_checker import AsnChecker from app.services.bgp_visibility_service import BgpVisibilityService from app.services.prefix_checker import PrefixChecker from app.services.preflight_checker import PreflightChecker from app.services.report_renderer import render_report +from app.services.roa_planner_service import RoaPlannerService from app.services.ripe_stat_client import RipeStatClient router = APIRouter(prefix="/api/check", tags=["checks"]) @@ -65,6 +66,19 @@ def check_bgp_visibility(payload: BgpVisibilityCheckRequest, db: Session = Depen except Exception as exc: raise HTTPException(status_code=500, detail=f"BGP-Visibility-Prüfung fehlgeschlagen: {exc}") from exc +@router.post('/roa-preflight', response_model=CheckResponse) +def check_roa_preflight(payload: RoaPreflightCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: + try: + result = RoaPlannerService(RipeStatClient(db)).check(payload.prefix, payload.origin_as, payload.max_length) + response = _store_and_respond(db, "roa-preflight", payload.prefix, payload.origin_as, result, user.id, payload.change_case_id) + write_audit_log(db, user_id=user.id, action='roa_preflight_checked', target_type='check', target_id=str(response.report_id), details_json={'prefix': payload.prefix, 'origin_as': payload.origin_as, 'max_length': payload.max_length, 'change_case_id': payload.change_case_id}) + return response + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"ROA-Preflight-Prüfung fehlgeschlagen: {exc}") from exc + + @router.post('/preflight', response_model=CheckResponse) def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: try: diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index 60c65f6..6ce345d 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -12,7 +12,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.7.1-beta', + 'version': 'v0.7.2-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f5da84d..2394182 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -91,6 +91,25 @@ def valid_planned_origin(cls, v: str) -> str: + +class RoaPreflightCheckRequest(BaseModel): + change_case_id: int | None = None + prefix: str + origin_as: str + max_length: int | None = None + + @field_validator("prefix") + @classmethod + def valid_prefix(cls, v: str) -> str: + validate_prefix(v) + return v + + @field_validator("origin_as") + @classmethod + def valid_origin(cls, v: str) -> str: + normalize_asn(v) + return v + class BgpVisibilityCheckRequest(BaseModel): change_case_id: int | None = None prefix: str diff --git a/backend/app/services/roa_planner_service.py b/backend/app/services/roa_planner_service.py new file mode 100644 index 0000000..0eb25eb --- /dev/null +++ b/backend/app/services/roa_planner_service.py @@ -0,0 +1,136 @@ +import ipaddress + +from app.core.normalize import format_asn, normalize_asn, validate_prefix +from app.services.ripe_stat_client import RipeStatClient + + +class RoaPlannerService: + def __init__(self, client: RipeStatClient): + self.client = client + + def check(self, prefix: str, origin_as: str, max_length: int | None = None) -> dict: + normalized_prefix = validate_prefix(prefix) + normalized_origin = format_asn(normalize_asn(origin_as)) + network = ipaddress.ip_network(normalized_prefix, strict=False) + prefix_length = network.prefixlen + effective_max_length = max_length if max_length is not None else prefix_length + + diagnostics: list[dict] = [] + recommendations: list[str] = [] + matching_roas: list[dict] = [] + conflicting_roas: list[dict] = [] + + if effective_max_length < prefix_length: + return { + "status": "CRITICAL", + "summary": "Max Length ist kleiner als die Prefixlänge und damit ungültig.", + "recommendations": ["Setzen Sie max_length mindestens auf die Prefixlänge.", "Prüfen Sie Prefix und Eingaben auf Tippfehler."], + "details": { + "prefix": normalized_prefix, + "origin_as": normalized_origin, + "max_length": max_length, + "effective_max_length": effective_max_length, + "prefix_length": prefix_length, + "planned_validation_state": "invalid", + "matching_roas": matching_roas, + "conflicting_roas": conflicting_roas, + "suggested_roa": {"prefix": normalized_prefix, "origin_as": normalized_origin, "max_length": prefix_length}, + "max_length_risk": "invalid_too_small", + "recommendations": recommendations, + "source_diagnostics": diagnostics, + }, + } + + roas_payload, roas_diag = self.client.get_with_diagnostics("rpki-validation", {"resource": normalized_prefix}) + diagnostics.append(roas_diag) + if not roas_payload or not isinstance(roas_payload, dict): + return self._unknown(normalized_prefix, normalized_origin, max_length, effective_max_length, prefix_length, diagnostics) + + validating_roas = (((roas_payload.get("data") or {}).get("validating_roas")) or []) + if not isinstance(validating_roas, list): + validating_roas = [] + + for roa in validating_roas: + try: + roa_origin = format_asn(normalize_asn(str(roa.get("origin") or roa.get("origin_asn") or ""))) + roa_prefix = str(roa.get("prefix") or "") + roa_max_len = int(roa.get("max_length") or roa.get("maxlength") or ipaddress.ip_network(roa_prefix, strict=False).prefixlen) + except Exception: + continue + record = {"prefix": roa_prefix, "origin_as": roa_origin, "max_length": roa_max_len} + if roa_origin == normalized_origin and roa_max_len >= prefix_length: + matching_roas.append(record) + elif roa_origin != normalized_origin: + conflicting_roas.append(record) + elif roa_max_len < prefix_length: + conflicting_roas.append({**record, "reason": "max_length_too_small"}) + + planned_validation_state = "not_found" + status = "WARNING" + summary = "Keine passende ROA gefunden; ein read-only Vorschlag wurde erstellt." + max_length_risk = "none" + + if matching_roas: + planned_validation_state = "valid" + status = "OK" + summary = "Geplantes Announcement ist durch mindestens eine passende ROA abgedeckt." + recommendations.append("Bestehende ROA-Abdeckung ist vorhanden; Änderungen außerhalb von RouteForge nur bei Bedarf durchführen.") + + if conflicting_roas and not matching_roas: + planned_validation_state = "invalid" + status = "CRITICAL" + summary = "Konfliktierende ROAs deuten auf ein invalides geplantes Announcement hin." + recommendations.append("Origin-AS oder Prefixplanung prüfen; bestehende ROA-Konflikte außerhalb von RouteForge bereinigen.") + + if effective_max_length > prefix_length + 2: + max_length_risk = "broad" + if status == "OK": + status = "WARNING" + recommendations.append("Max Length ist relativ breit gewählt; reduzieren Sie die Breite, um Hijack-Risiko zu senken.") + + suggested_roa = None if matching_roas else {"prefix": normalized_prefix, "origin_as": normalized_origin, "max_length": effective_max_length} + if suggested_roa: + recommendations.append("ROA-Vorschlag extern durch LIR/Operator prüfen und außerhalb von RouteForge umsetzen.") + + return { + "status": status, + "summary": summary, + "recommendations": recommendations or ["Keine zusätzlichen Empfehlungen."], + "details": { + "prefix": normalized_prefix, + "origin_as": normalized_origin, + "max_length": max_length, + "effective_max_length": effective_max_length, + "prefix_length": prefix_length, + "planned_validation_state": planned_validation_state, + "matching_roas": matching_roas, + "conflicting_roas": conflicting_roas, + "suggested_roa": suggested_roa, + "max_length_risk": max_length_risk, + "recommendations": recommendations, + "source_diagnostics": diagnostics, + }, + "input": {"prefix": normalized_prefix, "origin_as": normalized_origin, "max_length": max_length}, + } + + def _unknown(self, prefix: str, origin_as: str, max_length: int | None, effective_max_length: int, prefix_length: int, diagnostics: list[dict]) -> dict: + return { + "status": "UNKNOWN", + "summary": "RPKI-Datenquelle liefert aktuell keine belastbare Aussage.", + "recommendations": ["Später erneut prüfen.", "Externe ROA-Quelle manuell validieren."], + "details": { + "prefix": prefix, + "origin_as": origin_as, + "max_length": max_length, + "effective_max_length": effective_max_length, + "prefix_length": prefix_length, + "planned_validation_state": "unknown", + "matching_roas": [], + "conflicting_roas": [], + "suggested_roa": {"prefix": prefix, "origin_as": origin_as, "max_length": effective_max_length}, + "max_length_risk": "unknown", + "recommendations": ["Später erneut prüfen.", "Externe ROA-Quelle manuell validieren."], + "source_diagnostics": diagnostics, + }, + "input": {"prefix": prefix, "origin_as": origin_as, "max_length": max_length}, + } diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 9be4f5a..8b3bc7f 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -502,3 +502,64 @@ def get_with_diagnostics(self, endpoint, _): return self.responses.get(endpoint, assert svc.check('198.51.100.0/24', 'AS3320')['status'] == 'CRITICAL' svc2 = BgpVisibilityService(C({'routing-status': ({'data': {'routes': [{'origin': 'AS3320'}, {'origin': 'AS64496'}]}}, {}), 'bgp-state': ({'data': {}}, {})})) assert svc2.check('198.51.100.0/24', None)['status'] == 'WARNING' + + +def test_roa_preflight_roles_and_behavior() -> None: + client = _client() + _setup_and_login(client) + assert client.post('/api/check/roa-preflight', json={'prefix':'192.0.2.0/24','origin_as':'AS3320','max_length':24}).status_code == 200 + + create_op = client.post('/api/users', json={'username': 'op_roa', 'email': 'op_roa@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}) + assert create_op.status_code == 200 + create_vw = client.post('/api/users', json={'username': 'vw_roa', 'email': 'vw_roa@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}) + assert create_vw.status_code == 200 + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username':'op_roa','password':'OperatorPass123!'}).status_code == 200 + assert client.post('/api/check/roa-preflight', json={'prefix':'192.0.2.0/24','origin_as':'AS3320'}).status_code == 200 + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username':'vw_roa','password':'ViewerPass123!'}).status_code == 200 + assert client.post('/api/check/roa-preflight', json={'prefix':'192.0.2.0/24','origin_as':'AS3320'}).status_code == 403 + + +def test_roa_preflight_change_case_and_audit() -> None: + client = _client() + _setup_and_login(client) + cc = client.post('/api/change-cases', json={'title':'ROA case','description':'x'}) + assert cc.status_code == 200 + cc_id = cc.json()['id'] + + not_found = client.post('/api/check/roa-preflight', json={'prefix':'198.51.100.0/24','origin_as':'AS64496','change_case_id':999999}) + assert not_found.status_code == 404 + + resp = client.post('/api/check/roa-preflight', json={'prefix':'198.51.100.0/24','origin_as':'AS64496','max_length':16,'change_case_id':cc_id}) + assert resp.status_code == 200 + body = resp.json() + assert body['status'] in {'OK','WARNING','CRITICAL','UNKNOWN'} + assert body['details']['prefix'] == '198.51.100.0/24' + assert body['details']['origin_as'] == 'AS64496' + assert body['details']['effective_max_length'] == 16 + assert body['details']['max_length_risk'] in {'none','broad','invalid_too_small','unknown'} + + reports = client.get(f'/api/change-cases/{cc_id}/reports') + assert reports.status_code == 200 + assert any(r.get('check_type') == 'roa-preflight' for r in reports.json()) + + audit = client.get('/api/audit-log?limit=200') + assert audit.status_code == 200 + actions = [i.get('action') for i in audit.json().get('items', [])] + assert 'roa_preflight_checked' in actions + + +def test_roa_preflight_suggested_roa_and_invalid_max_length() -> None: + client = _client() + _setup_and_login(client) + warning = client.post('/api/check/roa-preflight', json={'prefix':'203.0.113.0/24','origin_as':'AS3320'}) + assert warning.status_code == 200 + wd = warning.json()['details'] + assert 'suggested_roa' in wd + + critical = client.post('/api/check/roa-preflight', json={'prefix':'203.0.113.0/24','origin_as':'AS3320','max_length':23}) + assert critical.status_code == 200 + assert critical.json()['status'] == 'CRITICAL' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3e7945..6a1bed3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,13 +7,14 @@ import { LoginView } from './components/LoginView' import { PrefixCheckForm } from './components/PrefixCheckForm' import { PreflightCheckForm } from './components/PreflightCheckForm' import { BgpVisibilityForm } from './components/BgpVisibilityForm' +import { RoaPlannerForm } from './components/RoaPlannerForm' import { SetupView } from './components/SetupView' import { StatusBadge } from './components/StatusBadge' import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types' import { UsersView } from './components/UsersView' import { ChangeCasesView } from './components/ChangeCasesView' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -72,8 +73,8 @@ export default function App() { if (authMode === 'login') return if (authMode === 'error') return
{authError}
- const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.7.1-beta · read-only preflight checks' - const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', 'bgp-visibility': 'BGP Visibility', reports: 'Reports', 'change-cases': 'Change Cases', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] + const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.7.2-beta · read-only preflight checks' + const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', 'roa-planner': 'ROA Planner', 'bgp-visibility': 'BGP Visibility', reports: 'Reports', 'change-cases': 'Change Cases', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' @@ -82,22 +83,23 @@ export default function App() { const canAccess = (view: NavKey) => { if (role === 'admin') return true if (role === 'operator') return !['users', 'audit'].includes(view) - return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', 'about'].includes(view) + return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', 'roa-planner', 'about'].includes(view) } const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports and change cases.' return - {active === 'dashboard' &&

RouteForge v0.7.1-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} + {active === 'dashboard' &&

RouteForge v0.7.2-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} {!canAccess(active) &&
You do not have permission to access this section.
} {active === 'asn' && canAccess('asn') && } {active === 'prefix' && canAccess('prefix') && } {active === 'preflight' && canAccess('preflight') && } {active === 'bgp-visibility' && canAccess('bgp-visibility') && } + {active === 'roa-planner' && canAccess('roa-planner') && } {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} {active === 'change-cases' && canAccess('change-cases') && } {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} {active === 'users' && canAccess('users') && } {active === 'audit' && canAccess('audit') && } - {active === 'about' &&

Version: v0.7.1-beta

} + {active === 'about' &&

Version: v0.7.2-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1b954e3..0f67040 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -86,3 +86,5 @@ export const runPrefixCheck = (prefix: string, origin_as?: string, change_case_i export const runPreflightCheck = (prefix: string, planned_origin_as: string, change_case_id?: number) => checkPreflight(prefix, planned_origin_as, change_case_id) export const runBgpVisibilityCheck = (prefix: string, expected_origin_as?: string, change_case_id?: number) => requestJson(apiUrl('/api/check/bgp-visibility'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, expected_origin_as: expected_origin_as || null, change_case_id: change_case_id ?? null }) }) + +export const runRoaPreflightCheck = (prefix: string, origin_as: string, max_length?: number, change_case_id?: number) => requestJson(apiUrl('/api/check/roa-preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, origin_as, max_length: max_length ?? null, change_case_id: change_case_id ?? null }) }) diff --git a/frontend/src/components/ChangeCasesView.tsx b/frontend/src/components/ChangeCasesView.tsx index a9398d6..49080e2 100644 --- a/frontend/src/components/ChangeCasesView.tsx +++ b/frontend/src/components/ChangeCasesView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runPrefixCheck, runPreflightCheck, updateChangeCase } from '../api' +import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runPrefixCheck, runPreflightCheck, runRoaPreflightCheck, updateChangeCase } from '../api' import type { ChangeCaseItem, UserRole } from '../types' type ChangeCaseReport = { report_id:number; check_id:number; check_type:string; summary:string; status:string; created_at:string } @@ -32,6 +32,9 @@ export function ChangeCasesView({ role }: { role: UserRole }) { const [plannedOriginAs, setPlannedOriginAs] = useState('') const [bgpPrefix, setBgpPrefix] = useState('') const [bgpExpectedOriginAs, setBgpExpectedOriginAs] = useState('') + const [roaPrefix, setRoaPrefix] = useState('') + const [roaOriginAs, setRoaOriginAs] = useState('') + const [roaMaxLength, setRoaMaxLength] = useState('') const load = async () => { setLoading(true); setError(null); try { const next = await listChangeCases(); setItems(next); if (selected) { const found = next.find(i => i.id === selected.id) || null; setSelected(found) } } catch (e) { setError((e as Error).message) } finally { setLoading(false) } } const loadReports = async (id: number) => { try { setReports(await getChangeCaseReports(id)) } catch { setReports([]) } } @@ -75,6 +78,7 @@ export function ChangeCasesView({ role }: { role: UserRole }) { setEditTitle(e.target.value)} />