From 8ab90640d1b51aa6a2623b8c4aafc9b9d60f3883 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Tue, 19 May 2026 22:58:45 +0100 Subject: [PATCH] feat(ui): modernize dashboard views and add report/system APIs --- backend/app/api/routes_reports.py | 26 +++++- backend/app/api/routes_system.py | 16 ++++ backend/app/main.py | 3 + backend/tests/test_api_smoke.py | 17 ++++ frontend/src/App.tsx | 39 ++++++--- frontend/src/api.ts | 66 +++------------- frontend/src/components/AsnCheckForm.tsx | 61 ++++---------- frontend/src/components/Layout.tsx | 21 ++++- frontend/src/components/PrefixCheckForm.tsx | 66 +++------------- frontend/src/components/ReportView.tsx | 88 ++++++--------------- frontend/src/components/StatusBadge.tsx | 10 +-- frontend/src/types.ts | 23 ++++++ 12 files changed, 195 insertions(+), 241 deletions(-) create mode 100644 backend/app/api/routes_system.py diff --git a/backend/app/api/routes_reports.py b/backend/app/api/routes_reports.py index 7f23a92..489247b 100644 --- a/backend/app/api/routes_reports.py +++ b/backend/app/api/routes_reports.py @@ -2,11 +2,35 @@ from sqlalchemy.orm import Session from app.database import get_db -from app.models import Report +from app.models import Check, Report router = APIRouter(prefix="/api/reports", tags=["reports"]) + +@router.get('') +def list_reports(db: Session = Depends(get_db)): + rows = ( + db.query(Report, Check) + .join(Check, Report.check_id == Check.id) + .order_by(Report.created_at.desc()) + .limit(50) + .all() + ) + return [ + { + 'report_id': report.id, + 'check_id': check.id, + 'check_type': check.check_type, + 'input_resource': check.input_resource, + 'origin_as': check.origin_as, + 'status': check.status, + 'summary': check.summary, + 'created_at': report.created_at.isoformat(), + } + for report, check in rows + ] + def _report_or_404(db: Session, report_id: int) -> Report: report = db.query(Report).filter(Report.id == report_id).first() if not report: diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py new file mode 100644 index 0000000..72ed184 --- /dev/null +++ b/backend/app/api/routes_system.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.config import settings + +router = APIRouter(prefix='/api/system', tags=['system']) + + +@router.get('/info') +def system_info(): + return { + 'name': 'RouteForge', + 'version': 'v0.1.0-alpha', + 'demo_mode': settings.demo_mode, + 'read_only': True, + 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], + } diff --git a/backend/app/main.py b/backend/app/main.py index ab381c0..c7ed147 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from app.api.routes_checks import router as checks_router from app.api.routes_health import router as health_router from app.api.routes_reports import router as reports_router +from app.api.routes_system import router as system_router from app.config import settings from app.database import Base, engine @@ -26,3 +27,5 @@ def startup() -> None: app.include_router(health_router) app.include_router(checks_router) app.include_router(reports_router) + +app.include_router(system_router) diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 5db5c45..9ffab55 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -56,3 +56,20 @@ def test_asn_rpki_batch() -> None: assert isinstance(details.get('rpki_summary'), dict) assert isinstance(details.get('results'), list) assert int(details.get('checked_prefixes', 0)) <= 3 + + +def test_system_info() -> None: + client = _client() + response = client.get('/api/system/info') + assert response.status_code == 200 + payload = response.json() + assert payload.get('name') == 'RouteForge' + assert payload.get('read_only') is True + + +def test_reports_list_empty_or_present() -> None: + client = _client() + response = client.get('/api/reports') + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d789fa..7ed89f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,18 +1,31 @@ -import { Layout } from './components/Layout' +import { useEffect, useMemo, useState } from 'react' +import { getReports, getSystemInfo } from './api' import { AsnCheckForm } from './components/AsnCheckForm' +import { Layout } from './components/Layout' import { PrefixCheckForm } from './components/PrefixCheckForm' +import { StatusBadge } from './components/StatusBadge' +import type { ReportListItem, SystemInfo } from './types' + +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'reports' | 'about' export default function App() { - return ( - -

RouteForge

-

- RouteForge performs read-only checks and does not modify registry, ROA or router configuration. -

-
- - -
-
- ) + const [active, setActive] = useState('dashboard') + const [reports, setReports] = useState([]) + const [system, setSystem] = useState(null) + + useEffect(() => { getReports().then(setReports).catch(() => setReports([])); getSystemInfo().then(setSystem).catch(() => null) }, []) + + const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.1.0-alpha · read-only preflight checks', [system]) + + return + {active === 'dashboard' &&
+

RouteForge v0.1.0-alpha

Read-only preflight checks for BGP, RPKI and Registry/IRR data.

{system?.demo_mode &&

Demo-Modus aktiv. Feste Beispieldaten können enthalten sein.

}

Sicherheitsmodell: ausschließlich read-only. Keine Writes, keine Deployments.

+
{['ASN prüfen','Prefix prüfen','Demo Flow'].map((x,i)=>)}
+

Letzte Reports

{reports.length===0 ?

Report history will appear here after checks.

:
    {reports.slice(0,5).map(r=>
  • {r.input_resource} ({r.check_type})
  • )}
}
+
} + {active === 'asn' && } + {active === 'prefix' && } + {active === 'reports' &&

Reports

{reports.length===0 ?

Noch keine Reports vorhanden.

:
{reports.map(r=>)}
ZeitpunktTypResourceOrigin-ASStatusKurzfassung
{new Date(r.created_at).toLocaleString()}{r.check_type}{r.input_resource}{r.origin_as || '-'}{r.summary}
}
} + {active === 'about' &&

About

RouteForge unterstützt nachvollziehbare Routing-Preflightchecks für Operator-Workflows.

} +
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 43e1639..b51b628 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { CheckResponse } from './types' +import type { CheckResponse, ReportListItem, SystemInfo } from './types' const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -15,67 +15,25 @@ export class ApiError extends Error { } async function requestJson(url: string, options: RequestInit): Promise { - let response: Response - - try { - response = await fetch(url, options) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unbekannter Netzwerkfehler' - throw new ApiError(`Netzwerkfehler beim Aufruf von ${url}: ${message}`) - } - + const response = await fetch(url, options) const rawText = await response.text() let parsedBody: unknown = rawText - if (rawText) { - try { - parsedBody = JSON.parse(rawText) - } catch { - parsedBody = rawText - } + try { parsedBody = JSON.parse(rawText) } catch { parsedBody = rawText } } - if (!response.ok) { - const detail = - typeof parsedBody === 'object' && parsedBody !== null && 'detail' in parsedBody - ? String((parsedBody as { detail: unknown }).detail) - : response.statusText || 'Unbekannter API-Fehler' - + const detail = typeof parsedBody === 'object' && parsedBody !== null && 'detail' in parsedBody + ? String((parsedBody as { detail: unknown }).detail) + : response.statusText || 'Unbekannter API-Fehler' throw new ApiError(`HTTP ${response.status}: ${detail}`, response.status, parsedBody) } - return parsedBody as T } -function apiUrl(path: string): string { - if (!API_BASE_URL) { - return path - } - - return `${API_BASE_URL}${path}` -} - -export async function checkAsn(asn: string): Promise { - return requestJson(apiUrl('/api/check/asn'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asn }), - }) -} - -export async function checkPrefix(prefix: string, origin_as?: string): Promise { - return requestJson(apiUrl('/api/check/prefix'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prefix, origin_as: origin_as || null }), - }) -} - +const apiUrl = (path: string) => (API_BASE_URL ? `${API_BASE_URL}${path}` : path) -export async function checkAsnRpki(asn: string, limit = 25): Promise { - return requestJson(apiUrl('/api/check/asn-rpki'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asn, limit }), - }) -} +export const checkAsn = (asn: string) => requestJson(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn }) }) +export const checkPrefix = (prefix: string, origin_as?: string) => requestJson(apiUrl('/api/check/prefix'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, origin_as: origin_as || null }) }) +export const checkAsnRpki = (asn: string, limit = 25) => requestJson(apiUrl('/api/check/asn-rpki'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn, limit }) }) +export const getReports = () => requestJson(apiUrl('/api/reports'), { method: 'GET' }) +export const getSystemInfo = () => requestJson(apiUrl('/api/system/info'), { method: 'GET' }) diff --git a/frontend/src/components/AsnCheckForm.tsx b/frontend/src/components/AsnCheckForm.tsx index 9946fb5..d3a4671 100644 --- a/frontend/src/components/AsnCheckForm.tsx +++ b/frontend/src/components/AsnCheckForm.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' - import { ApiError, checkAsn, checkAsnRpki } from '../api' import { ReportView } from './ReportView' import type { CheckResponse } from '../types' @@ -12,51 +11,19 @@ export function AsnCheckForm() { const [result, setResult] = useState(null) const [batchResult, setBatchResult] = useState(null) - const onSubmit = async () => { - setError(null) - setResult(null) - setBatchResult(null) - setLoading(true) - try { - const response = await checkAsn(asn) - setResult(response) - } catch (err) { - setError(err instanceof ApiError ? err : new ApiError('Unbekannter Fehler bei der ASN-Prüfung.')) - } finally { setLoading(false) } - } - - const onBatchRpki = async () => { - const sourceAsn = String(result?.details?.resource || asn) - setError(null) - setBatchLoading(true) - try { - const response = await checkAsnRpki(sourceAsn, 25) - setBatchResult(response) - } catch (err) { - setError(err instanceof ApiError ? err : new ApiError('Unbekannter Fehler bei der ASN-RPKI-Prüfung.')) - } finally { setBatchLoading(false) } - } - - const extractedPrefixes = Array.isArray(result?.details?.extracted_prefixes) ? result?.details?.extracted_prefixes : [] + const onSubmit = async () => { setError(null); setLoading(true); try { setResult(await checkAsn(asn)) } catch (e) { setError(e as ApiError) } finally { setLoading(false) } } + const onBatch = async () => { setError(null); setBatchLoading(true); try { setBatchResult(await checkAsnRpki(asn, 25)) } catch (e) { setError(e as ApiError) } finally { setBatchLoading(false) } } + const extracted = Array.isArray(result?.details?.extracted_prefixes) ? result?.details?.extracted_prefixes : [] - return ( -
-

ASN Check

- setAsn(e.target.value)} /> - - {loading &&

Prüfung läuft ...

} - {result &&

ASN wurde geprüft. Die RPKI-Bewertung erfolgt für die sichtbaren Prefixe der ASN.

} - {result &&

Extrahierte Prefixe: {extractedPrefixes.length}

} - {result && extractedPrefixes.length > 0 && ( - - )} - {batchLoading &&

ASN-RPKI-Batchprüfung läuft ...

} - {error &&

Die Prüfung konnte nicht ausgeführt werden.

{error.message}

} - {result &&
} - {batchResult &&
-

ASN-RPKI-Batchergebnis

- -
} -
- ) + return
+

ASN Check

+

Eine ASN allein kann nicht RPKI-valid oder invalid sein. RPKI bewertet Prefix-Origin-Paare.

+ setAsn(e.target.value)} /> + + {result &&

ASN: {asn}

Anzahl extrahierter Prefixe: {extracted.length}

Datenquellen/Warnungen: {JSON.stringify(result.details?.warnings ?? [])}

{extracted.length > 0 && }
} + {(loading || batchLoading) &&

Ladezustand aktiv ...

} + {error &&

{error.message}

} + {result &&
} + {batchResult &&
} +
} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 73ae7d4..eb4395e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,2 +1,21 @@ import { ReactNode } from 'react' -export function Layout({children}:{children:ReactNode}) { return
{children}
} + +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'reports' | 'about' + +export function Layout({ children, active, onNav, systemLine }: { children: ReactNode; active: NavKey; onNav: (key: NavKey) => void; systemLine: string }) { + const nav: { key: NavKey; label: string }[] = [ + { key: 'dashboard', label: 'Dashboard' }, + { key: 'asn', label: 'ASN Check' }, + { key: 'prefix', label: 'Prefix Check' }, + { key: 'reports', label: 'Reports' }, + { key: 'about', label: 'About' }, + ] + return
+
RouteForge Operator Console
+
+ +
{children}
+
+
{systemLine}
+
+} diff --git a/frontend/src/components/PrefixCheckForm.tsx b/frontend/src/components/PrefixCheckForm.tsx index a78fb4a..60eb4f1 100644 --- a/frontend/src/components/PrefixCheckForm.tsx +++ b/frontend/src/components/PrefixCheckForm.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' - import { ApiError, checkPrefix } from '../api' import { ReportView } from './ReportView' import type { CheckResponse } from '../types' @@ -11,57 +10,16 @@ export function PrefixCheckForm() { const [error, setError] = useState(null) const [result, setResult] = useState(null) - const onSubmit = async () => { - setError(null) - setResult(null) - setLoading(true) - - try { - const response = await checkPrefix(prefix, originAs || undefined) - setResult(response) - } catch (err) { - if (err instanceof ApiError) { - setError(err) - } else { - setError(new ApiError('Unbekannter Fehler bei der Prefix-Prüfung.')) - } - } finally { - setLoading(false) - } - } - - return ( -
-

Prefix Check

- setPrefix(e.target.value)} /> - setOriginAs(e.target.value)} /> - - - {loading &&

Prüfung läuft ...

} - - {error && ( -
-

Die Prüfung konnte nicht ausgeführt werden.

-

{error.message}

-
- Technische Details -
-              {JSON.stringify(
-                {
-                  status: error.status,
-                  responseBody: error.responseBody,
-                },
-                null,
-                2,
-              )}
-            
-
-
- )} - - {result &&
} -
- ) + const onSubmit = async () => { setLoading(true); setError(null); try { setResult(await checkPrefix(prefix, originAs || undefined)) } catch (e) { setError(e as ApiError) } finally { setLoading(false) } } + + return
+

Prefix Check

+

Origin-AS empfohlen für vollständige RPKI- und Registry/IRR-Bewertung.

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

Prüfung läuft ...

} + {error &&

{error.message}

} + {result &&
} +
} diff --git a/frontend/src/components/ReportView.tsx b/frontend/src/components/ReportView.tsx index ae57989..d751787 100644 --- a/frontend/src/components/ReportView.tsx +++ b/frontend/src/components/ReportView.tsx @@ -4,81 +4,37 @@ import { StatusBadge } from './StatusBadge' const order = { CRITICAL: 0, WARNING: 1, UNKNOWN: 2, OK: 3 } +const RecommendationList = ({ items }: { items: string[] }) => items.length ?
    {items.map((r, i) =>
  • {r}
  • )}
:

Keine Empfehlungen verfügbar.

+const RawSection = ({ title, raw }: { title: string; raw: unknown }) =>
{title}
{JSON.stringify(raw ?? {}, null, 2)}
+ export function ReportView({ report }: { report: CheckResponse }) { - const recs = report.recommendations ?? [] const rpki = report.checks?.rpki const registry = report.checks?.registry - const hasAnyChecks = Boolean(rpki || registry) - const rpkiExplanation = report.details?.rpki_explanation - const extractedPrefixes = Array.isArray(report.details?.extracted_prefixes) ? report.details.extracted_prefixes : [] - const rpkiSummary = report.details?.rpki_summary - const results = (Array.isArray(report.details?.results) ? report.details.results : []) as RpkiBatchResult[] - const checkedPrefixes = Number(report.details?.checked_prefixes ?? 0) - const totalPrefixesSeen = Number(report.details?.total_prefixes_seen ?? 0) - const limited = Boolean(report.details?.limited) - const sortedResults = [...results].sort((a, b) => (order[a.status as keyof typeof order] ?? 99) - (order[b.status as keyof typeof order] ?? 99)) - - return
-

Ergebnis

-

{report.summary || 'Keine Kurzfassung verfügbar.'}

-

Erklärung: {report.explanation || 'Keine Erklärung verfügbar.'}

-

Risiko: {report.risk || 'Keine Risikobewertung verfügbar.'}

- {rpkiExplanation &&

RPKI-Hinweis: {String(rpkiExplanation)}

} - {extractedPrefixes.length > 0 &&

Sichtbare Prefixe: {extractedPrefixes.length}

} - {report.details?.demo_mode &&

Demo-Modus aktiv. Es werden feste Beispieldaten verwendet. Diese Ausgabe ist nicht für echte Routing-Bewertungen geeignet.

} + const recs = report.recommendations ?? [] + const details = report.details ?? {} + const rpkiSummary = details.rpki_summary as Record | undefined + const sortedResults = ([...(Array.isArray(details.results) ? details.results : [])] as RpkiBatchResult[]).sort((a, b) => (order[a.status as keyof typeof order] ?? 99) - (order[b.status as keyof typeof order] ?? 99)) -

Empfehlungen

- {recs.length > 0 ?
    {recs.map((r, i) =>
  • {r}
  • )}
:

Keine Empfehlungen verfügbar.

} + return
+ {details.demo_mode &&
Demo-Modus aktiv. Es werden feste Beispieldaten verwendet. Diese Ausgabe ist nicht für echte Routing-Bewertungen geeignet.
} +
+

Gesamtbewertung

+

{report.summary}

Erklärung: {report.explanation || '-'}

Risiko: {report.risk || '-'}

+

Empfehlungen

+
- {hasAnyChecks &&

Gesamtbewertung

} - {hasAnyChecks &&

Der kombinierte Status fasst RPKI und Registry/IRR nachvollziehbar zusammen.

} + {(rpki || registry) &&
+ {rpki &&

RPKI

Kurzfassung: {rpki.summary || '-'}

Erklärung: {rpki.explanation || '-'}

Risiko: {rpki.risk || '-'}

} + {registry &&

Registry/IRR

Kurzfassung: {registry.summary || '-'}

Erklärung: {registry.explanation || '-'}

Risiko: {registry.risk || '-'}

} +
} -

Einzelprüfungen

- {hasAnyChecks ?
- {rpki &&
-
RPKI
-

Kurzfassung: {rpki?.summary || '-'}

-

Erklärung: {rpki?.explanation || '-'}

-

Risiko: {rpki?.risk || '-'}

-
- RPKI Rohdaten -
{JSON.stringify(rpki?.raw ?? {}, null, 2)}
-
-
} - {registry &&
-
Registry/IRR
-

Kurzfassung: {registry?.summary || '-'}

-

Erklärung: {registry?.explanation || '-'}

-

Risiko: {registry?.risk || '-'}

-
- Registry/IRR Rohdaten -
{JSON.stringify(registry?.raw ?? {}, null, 2)}
-
-
} -
:

Für diesen Check-Typ sind keine Einzelprüfungen verfügbar.

} +

Technische Details

input: {JSON.stringify(report.input ?? {})}

warnings: {JSON.stringify(details.warnings ?? [])}

source_errors: {JSON.stringify(details.source_errors ?? [])}

demo_mode: {String(Boolean(details.demo_mode))}

- {rpkiSummary &&

ASN-RPKI Zusammenfassung

-

geprüft: {checkedPrefixes} / gesehen: {totalPrefixesSeen}

- {limited &&

Hinweis: Ergebnis wurde durch das gesetzte Limit begrenzt.

} -
    -
  • valid: {Number((rpkiSummary as Record).valid ?? 0)}
  • -
  • invalid_asn: {Number((rpkiSummary as Record).invalid_asn ?? 0)}
  • -
  • invalid_length: {Number((rpkiSummary as Record).invalid_length ?? 0)}
  • -
  • unknown: {Number((rpkiSummary as Record).unknown ?? 0)}
  • -
  • errors: {Number((rpkiSummary as Record).errors ?? 0)}
  • -
-
} + {rpkiSummary &&

ASN-RPKI Batch Summary

{['checked_prefixes','total_prefixes_seen','limited','valid','invalid_asn','invalid_length','unknown','errors'].map((k) =>
{k}
{String((details as Record)[k] ?? rpkiSummary[k] ?? 0)}
)}
} - {sortedResults.length > 0 &&

ASN-RPKI Ergebnisse

-
    - {sortedResults.map((item, idx) =>
  • -
    {item.prefix}
    -

    {item.summary || '-'}

    -
  • )} -
-
} + {sortedResults.length > 0 &&

ASN-RPKI Ergebnisse

{sortedResults.map((item, idx) =>
{item.prefix}

{item.summary || '-'}

)}
} - +
} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 9e093e2..1de80c2 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,12 +1,12 @@ const STYLES: Record = { - OK: 'bg-green-100 text-green-800', - WARNING: 'bg-yellow-100 text-yellow-800', - CRITICAL: 'bg-red-100 text-red-800', - UNKNOWN: 'bg-gray-100 text-gray-800', + OK: 'bg-emerald-100 text-emerald-800 border-emerald-200', + WARNING: 'bg-amber-100 text-amber-800 border-amber-200', + CRITICAL: 'bg-rose-100 text-rose-800 border-rose-200', + UNKNOWN: 'bg-slate-100 text-slate-800 border-slate-200', } export function StatusBadge({ status }: { status: string }) { const normalized = (status || 'UNKNOWN').toUpperCase() const safeStatus = STYLES[normalized] ? normalized : 'UNKNOWN' - return {safeStatus} + return {safeStatus} } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6015792..7422b74 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,5 @@ +export type StatusValue = 'OK' | 'WARNING' | 'CRITICAL' | 'UNKNOWN' + export type CheckSection = { status?: string summary?: string @@ -34,8 +36,29 @@ export type CheckResponse = { total_prefixes_seen?: number limited?: boolean demo_mode?: boolean + source_errors?: unknown + warnings?: unknown [key: string]: unknown } markdown: string html: string } + +export type ReportListItem = { + report_id: number + check_id: number + check_type: string + input_resource: string + origin_as?: string | null + status: string + summary: string + created_at: string +} + +export type SystemInfo = { + name: string + version: string + demo_mode: boolean + read_only: boolean + data_sources: string[] +}