From 5f977f2f594a5b6bd23e80de8cdad1f78c8eaf6c Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 22:57:43 +0100 Subject: [PATCH] v0.7.1-beta: add BGP visibility details check --- README.md | 9 ++- RELEASE_NOTES.md | 28 +++++++ ROADMAP.md | 4 +- backend/app/api/routes_checks.py | 17 +++- backend/app/api/routes_system.py | 2 +- backend/app/core/system_status.py | 2 +- backend/app/main.py | 2 +- backend/app/schemas.py | 20 +++++ .../app/services/bgp_visibility_service.py | 78 +++++++++++++++++++ backend/pyproject.toml | 2 +- backend/tests/test_api_smoke.py | 49 +++++++++++- frontend/package.json | 2 +- frontend/src/App.tsx | 14 ++-- frontend/src/api.ts | 2 + frontend/src/components/BgpVisibilityForm.tsx | 37 +++++++++ frontend/src/components/ChangeCasesView.tsx | 5 +- frontend/src/components/Layout.tsx | 7 +- 17 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 backend/app/services/bgp_visibility_service.py create mode 100644 frontend/src/components/BgpVisibilityForm.tsx diff --git a/README.md b/README.md index db7bcad..f2d4028 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -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.0-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.1-beta**. ## Quickstart with Docker Compose @@ -274,3 +274,8 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap - User management is **admin-only**. - Viewers cannot execute checks. - Keep `SECRET_KEY` stable; changing it invalidates existing sessions. + + +## BGP Visibility Details (v0.7.1-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 c86cb20..46c1803 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,31 @@ +## v0.7.1-beta: BGP Visibility Details + +### Motivation +Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model. + +### Implemented Changes +- Added dedicated backend service and API endpoint `POST /api/check/bgp-visibility`. +- Added frontend BGP Visibility page and Change Case integration action. +- Added audit event `bgp_visibility_checked` plus existing case attachment events when linked to a Change Case. +- Extended report output to include structured BGP visibility details via generic detail rendering. + +### BGP Visibility Logic +- `OK`: prefix visible and expected origin (if provided) is seen. +- `WARNING`: prefix visible with multiple or unexpected origins (without strict expectation). +- `CRITICAL`: prefix not visible with expected origin requirement, or expected origin not visible. +- `UNKNOWN`: no reliable source data. + +### Security Notes +BGP visibility checks remain read-only and do not modify RIPE DB, RPKI objects, or routers. Results are external visibility snapshots. + +### Testing +- backend: `pytest -q` +- frontend: `npm run build` + +### Known Limitations +- Visibility depends on external RIPEstat data quality and timing. +- Results are point-in-time and not a substitute for continuous monitoring. + ## v0.7.0-beta - Added Projects / Change Cases lifecycle (draft, in_review, approved, closed). - Added Change Case API, UI navigation and detail workflow. diff --git a/ROADMAP.md b/ROADMAP.md index 44f53cf..a3c7b03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,9 @@ # RouteForge Roadmap ## Current Status -v0.7.0-beta, audit log UI/API and session hardening completed, read-only +v0.7.1-beta, BGP Visibility Details completed, read-only -## v0.7.0-beta +## v0.7.1-beta - projects/change cases - grouped preflight reports diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py index f700168..18a7055 100644 --- a/backend/app/api/routes_checks.py +++ b/backend/app/api/routes_checks.py @@ -6,8 +6,9 @@ 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, CheckResponse, PrefixCheckRequest, PreflightCheckRequest +from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, BgpVisibilityCheckRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest 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 @@ -50,6 +51,20 @@ def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), use + + +@router.post('/bgp-visibility', response_model=CheckResponse) +def check_bgp_visibility(payload: BgpVisibilityCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse: + try: + result = BgpVisibilityService(RipeStatClient(db)).check(payload.prefix, payload.expected_origin_as) + response = _store_and_respond(db, "bgp-visibility", payload.prefix, payload.expected_origin_as, result, user.id, payload.change_case_id) + write_audit_log(db, user_id=user.id, action='bgp_visibility_checked', target_type='check', target_id=str(response.report_id), details_json={'prefix': payload.prefix, 'expected_origin_as': payload.expected_origin_as, 'change_case_id': payload.change_case_id}) + return response + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"BGP-Visibility-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 5dd6e8c..60c65f6 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.0-beta', + 'version': 'v0.7.1-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 4fb5d83..0f8a9fa 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.7.0-beta", + "version": "v0.7.1-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, diff --git a/backend/app/main.py b/backend/app/main.py index 2379f29..002bbb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,7 +19,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.7.0") +app = FastAPI(title="RouteForge", version="0.7.1") app.add_middleware( CORSMiddleware, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 62717e2..f5da84d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -90,6 +90,26 @@ def valid_planned_origin(cls, v: str) -> str: return v + +class BgpVisibilityCheckRequest(BaseModel): + change_case_id: int | None = None + prefix: str + expected_origin_as: str | None = None + + @field_validator("prefix") + @classmethod + def valid_prefix(cls, v: str) -> str: + validate_prefix(v) + return v + + @field_validator("expected_origin_as") + @classmethod + def valid_expected_origin(cls, v: str | None) -> str | None: + if v is not None: + normalize_asn(v) + return v + + class CheckResponse(BaseModel): report_id: int status: str diff --git a/backend/app/services/bgp_visibility_service.py b/backend/app/services/bgp_visibility_service.py new file mode 100644 index 0000000..ba7e1af --- /dev/null +++ b/backend/app/services/bgp_visibility_service.py @@ -0,0 +1,78 @@ +from app.core.normalize import format_asn, normalize_asn, validate_prefix +from app.core.status import CheckStatus +from app.services.ripe_stat_client import RipeStatClient + + +class BgpVisibilityService: + def __init__(self, client: RipeStatClient): + self.client = client + + def check(self, prefix: str, expected_origin_as: str | None) -> dict: + normalized_prefix = validate_prefix(prefix) + normalized_expected = format_asn(normalize_asn(expected_origin_as)) if expected_origin_as else None + + routing_payload, routing_diag = self.client.get_with_diagnostics("routing-status", {"resource": normalized_prefix}) + bgp_state_payload, bgp_state_diag = self.client.get_with_diagnostics("bgp-state", {"resource": normalized_prefix}) + routing_payload = routing_payload or {} + bgp_state_payload = bgp_state_payload or {} + + routing_data = routing_payload.get("data", {}) if isinstance(routing_payload, dict) else {} + bgp_data = bgp_state_payload.get("data", {}) if isinstance(bgp_state_payload, dict) else {} + + origins = sorted({str(item.get("origin", "")).upper() for item in (routing_data.get("routes") or []) if isinstance(item, dict) and item.get("origin")}) + visible = bool(origins or routing_data.get("visibility") or bgp_data.get("bgp_state")) + expected_seen = normalized_expected in origins if normalized_expected else None + multiple_origins = len(origins) > 1 + peer_count = routing_data.get("num_peers_seeing") or bgp_data.get("num_peers_seeing") + more_specifics = routing_data.get("more_specifics") or bgp_data.get("more_specifics") or [] + less_specifics = routing_data.get("less_specifics") or bgp_data.get("less_specifics") or [] + + data_unreliable = (not routing_data and not bgp_data) or (routing_payload.get("error") and bgp_state_payload.get("error")) + + if data_unreliable: + status = CheckStatus.UNKNOWN.value + summary = "Keine belastbaren BGP-Sichtbarkeitsdaten für das Prefix verfügbar." + recommendations = ["Später erneut prüfen und ein externes Monitoring zur Gegenprüfung verwenden."] + elif not visible: + status = CheckStatus.CRITICAL.value if normalized_expected else CheckStatus.WARNING.value + summary = "Das Prefix ist aktuell nicht sichtbar." + recommendations = ["Ankündigungspfad und Upstream-Policy prüfen.", "Route-Propagation in mehreren Looking-Glasses validieren."] + elif normalized_expected and not expected_seen: + status = CheckStatus.CRITICAL.value + summary = f"Das erwartete Origin {normalized_expected} ist für {normalized_prefix} nicht sichtbar." + recommendations = ["Origin-AS Konfiguration prüfen.", "Mögliche Route-Leaks/Hijacks gegenprüfen."] + elif multiple_origins: + status = CheckStatus.WARNING.value + summary = "Prefix sichtbar, aber mit mehreren Origin-ASNs (MOAS)." + recommendations = ["Mehrfach-Origin fachlich bestätigen oder unbeabsichtigte Ankündigung beheben."] + else: + status = CheckStatus.OK.value + summary = "Prefix sichtbar und erwartete Origin-AS (falls angegeben) wird gesehen." + recommendations = ["Weiter beobachten; Ergebnis ist eine Momentaufnahme externer Sichtbarkeitsdaten."] + + return { + "status": status, + "summary": summary, + "explanation": "BGP Visibility basiert auf RIPEstat-Daten und ist read-only.", + "risk": "Externe Sichtbarkeitsdaten können zeitversetzt oder unvollständig sein.", + "recommendations": recommendations, + "input": {"prefix": normalized_prefix, "expected_origin_as": normalized_expected}, + "checks": None, + "details": { + "prefix": normalized_prefix, + "visible": visible, + "origins": origins, + "expected_origin_as": normalized_expected, + "expected_origin_seen": expected_seen, + "multiple_origins": multiple_origins, + "peer_count": peer_count, + "more_specifics": more_specifics, + "less_specifics": less_specifics, + "source_diagnostics": [d for d in [routing_diag, bgp_state_diag] if isinstance(d, dict)], + "source_errors": { + "routing_status": routing_payload.get("error") if isinstance(routing_payload, dict) else None, + "bgp_state": bgp_state_payload.get("error") if isinstance(bgp_state_payload, dict) else None, + }, + }, + "sources": ["RIPEstat routing-status", "RIPEstat bgp-state"], + } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 13fb7c6..c7d986b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routeforge-backend" -version = "0.6.6" +version = "0.7.1" description = "RouteForge backend" license = "AGPL-3.0-or-later" requires-python = ">=3.12" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 0622218..9be4f5a 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None: response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() - assert payload.get('version') == 'v0.7.0-beta' + assert payload.get('version') == 'v0.7.1-beta' assert payload.get('read_only') is True assert payload.get('database', {}).get('status') assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None @@ -455,3 +455,50 @@ def test_change_case_delete_operator_allowed_viewer_forbidden() -> None: client.post('/api/auth/logout') assert client.post('/api/auth/login', json={'username': 'vw4', 'password': 'ViewerPass123!'}).status_code == 200 assert client.delete(f'/api/change-cases/{cid2v}').status_code == 403 + +def test_bgp_visibility_roles_status_change_case_and_audit(monkeypatch) -> None: + client = _client() + _setup_and_login(client) + + from app.services import bgp_visibility_service as svc + + def fake_ok(self, prefix: str, expected_origin_as: str | None): + return { + 'status': 'OK', 'summary': 'ok', 'explanation': 'x', 'risk': 'r', 'recommendations': ['a'], + 'input': {'prefix': prefix, 'expected_origin_as': expected_origin_as}, 'checks': None, + 'details': {'prefix': prefix, 'visible': True, 'origins': ['AS3320'], 'expected_origin_as': expected_origin_as, 'expected_origin_seen': True, 'multiple_origins': False, 'peer_count': 10, 'more_specifics': [], 'less_specifics': [], 'source_diagnostics': []} + } + + monkeypatch.setattr(svc.BgpVisibilityService, 'check', fake_ok) + + cid = client.post('/api/change-cases', json={'title': 'BGP Case', 'description': ''}).json()['id'] + ok = client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24', 'expected_origin_as': 'AS3320', 'change_case_id': cid}) + assert ok.status_code == 200 + assert ok.json().get('status') == 'OK' + + missing = client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24', 'change_case_id': 999999}) + assert missing.status_code == 404 + + client.post('/api/users', json={'username': 'opbgp', 'email': 'opbgp@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}) + client.post('/api/users', json={'username': 'vwbgp', 'email': 'vwbgp@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}) + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'opbgp', 'password': 'OperatorPass123!'}).status_code == 200 + assert client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24'}).status_code == 200 + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'vwbgp', 'password': 'ViewerPass123!'}).status_code == 200 + assert client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24'}).status_code == 403 + + +def test_bgp_visibility_status_mapping(): + from app.services.bgp_visibility_service import BgpVisibilityService + + class C: + def __init__(self, responses): self.responses = responses + def get_with_diagnostics(self, endpoint, _): return self.responses.get(endpoint, ({}, {})) + + svc = BgpVisibilityService(C({'routing-status': ({'data': {'routes': [{'origin': 'AS64496'}]}}, {}), 'bgp-state': ({'data': {}}, {})})) + 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' diff --git a/frontend/package.json b/frontend/package.json index 3c354dc..9e46031 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.7.0", + "version": "0.7.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90042b6..e3e7945 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,13 +6,14 @@ import { Layout } from './components/Layout' import { LoginView } from './components/LoginView' import { PrefixCheckForm } from './components/PrefixCheckForm' import { PreflightCheckForm } from './components/PreflightCheckForm' +import { BgpVisibilityForm } from './components/BgpVisibilityForm' 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' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -71,8 +72,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.0-beta · read-only preflight checks' - const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', 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.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 proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' @@ -81,21 +82,22 @@ 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', 'about'].includes(view) + return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', '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.0-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.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.
}
} {!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 === '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.0-beta

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

Version: v0.7.1-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c23fff2..1b954e3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -84,3 +84,5 @@ export const runAsnCheck = (asn: string, change_case_id?: number) => requestJson export const deleteChangeCase = (id: number) => requestJson<{ ok: boolean; detached_checks: number }>(apiUrl(`/api/change-cases/${id}`), { method: 'DELETE' }) export const runPrefixCheck = (prefix: string, origin_as?: string, change_case_id?: number) => checkPrefix(prefix, origin_as, change_case_id) 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 }) }) diff --git a/frontend/src/components/BgpVisibilityForm.tsx b/frontend/src/components/BgpVisibilityForm.tsx new file mode 100644 index 0000000..96c3c4f --- /dev/null +++ b/frontend/src/components/BgpVisibilityForm.tsx @@ -0,0 +1,37 @@ +import { useMemo, useState } from 'react' +import { ApiError, runBgpVisibilityCheck } from '../api' +import type { CheckResponse, UserRole } from '../types' +import { StatusBadge } from './StatusBadge' + +export function BgpVisibilityForm({ role }: { role: UserRole }) { + const [prefix, setPrefix] = useState('') + const [expectedOriginAs, setExpectedOriginAs] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const canRun = role === 'admin' || role === 'operator' + const details = useMemo(()=> (result?.details || {}) as Record, [result]) + + const onSubmit = async () => { + if (!canRun) return + setLoading(true); setError(null) + try { setResult(await runBgpVisibilityCheck(prefix, expectedOriginAs || undefined)) } catch (e) { setError(e as ApiError) } finally { setLoading(false) } + } + + return
+

BGP Visibility

+ {!canRun &&

Viewer role: Seite ist sichtbar, aber Checks dürfen nicht ausgeführt werden.

} + setPrefix(e.target.value)} /> + setExpectedOriginAs(e.target.value)} /> + + {error &&

{error.message}

} + {result &&
+
{result.summary}
+
Origins: {String((details.origins as string[] | undefined)?.join(', ') || '-')}
+
Expected Origin Seen: {String(details.expected_origin_seen ?? '-')}
+
Multiple Origins: {String(details.multiple_origins ?? '-')}
+
Recommendations:
    {(result.recommendations||[]).map((r,i)=>
  • - {r}
  • )}
+
Details
{JSON.stringify(details, null, 2)}
+
} +
+} diff --git a/frontend/src/components/ChangeCasesView.tsx b/frontend/src/components/ChangeCasesView.tsx index 17b989e..a9398d6 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, runPrefixCheck, runPreflightCheck, updateChangeCase } from '../api' +import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runPrefixCheck, runPreflightCheck, 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 } @@ -30,6 +30,8 @@ export function ChangeCasesView({ role }: { role: UserRole }) { const [originAs, setOriginAs] = useState('') const [preflightPrefix, setPreflightPrefix] = useState('') const [plannedOriginAs, setPlannedOriginAs] = useState('') + const [bgpPrefix, setBgpPrefix] = useState('') + const [bgpExpectedOriginAs, setBgpExpectedOriginAs] = 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([]) } } @@ -83,6 +85,7 @@ export function ChangeCasesView({ role }: { role: UserRole }) { {canEdit &&

Run ASN Check

setAsn(e.target.value)} />

Run Prefix Check

setPrefix(e.target.value)} />setOriginAs(e.target.value)} />
+

Run BGP Visibility Check

setBgpPrefix(e.target.value)} />setBgpExpectedOriginAs(e.target.value)} />

Run Preflight Check

setPreflightPrefix(e.target.value)} />setPlannedOriginAs(e.target.value)} />
} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9e9ef15..1202a7b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,13 +1,14 @@ import { ReactNode } from 'react' import type { UserRole } from '../types' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'dashboard', label: 'Dashboard', desc: 'Overview & quick actions' }, { key: 'asn', label: 'ASN Check', desc: 'Read-only ASN preflight' }, { key: 'prefix', label: 'Prefix Check', desc: 'Prefix + origin validation' }, { key: 'preflight', label: 'Preflight', desc: 'Planned prefix-origin validation' }, + { key: 'bgp-visibility', label: 'BGP Visibility', desc: 'Detailed external BGP visibility' }, { key: 'reports', label: 'Reports', desc: 'History and outcomes' }, { key: 'change-cases', label: 'Change Cases', desc: 'Project-bound routing cases' }, { key: 'system', label: 'System', desc: 'Operational checks' }, @@ -21,7 +22,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c if (!currentUser) return ['dashboard', 'about'].includes(item.key) if (currentUser.role === 'admin') return true if (currentUser.role === 'operator') return !['users', 'audit'].includes(item.key) - return ['dashboard', 'reports', 'change-cases', 'about'].includes(item.key) + return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', 'about'].includes(item.key) }) return
@@ -45,7 +46,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c {currentUser && Angemeldet als {currentUser.username} · {currentUser.role}} {demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.7.0-beta + v0.7.1-beta