Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion backend/app/api/routes_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
@@ -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'],
}
3 changes: 3 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
17 changes: 17 additions & 0 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
39 changes: 26 additions & 13 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Layout>
<h1 className='text-3xl font-bold mb-2'>RouteForge</h1>
<p className='mb-6 text-slate-700'>
RouteForge performs read-only checks and does not modify registry, ROA or router configuration.
</p>
<div className='grid md:grid-cols-2 gap-4'>
<AsnCheckForm />
<PrefixCheckForm />
</div>
</Layout>
)
const [active, setActive] = useState<NavKey>('dashboard')
const [reports, setReports] = useState<ReportListItem[]>([])
const [system, setSystem] = useState<SystemInfo | null>(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 <Layout active={active} onNav={setActive} systemLine={systemLine}>
{active === 'dashboard' && <section className='space-y-4'>
<div className='bg-white border rounded-lg p-4'><h1 className='text-2xl font-bold'>RouteForge v0.1.0-alpha</h1><p className='mt-2 text-slate-700'>Read-only preflight checks for BGP, RPKI and Registry/IRR data.</p>{system?.demo_mode && <p className='mt-3 p-2 rounded border border-amber-300 bg-amber-50 text-amber-900 text-sm'>Demo-Modus aktiv. Feste Beispieldaten können enthalten sein.</p>}<p className='mt-3 text-sm'>Sicherheitsmodell: ausschließlich read-only. Keine Writes, keine Deployments.</p></div>
<div className='grid md:grid-cols-3 gap-3'>{['ASN prüfen','Prefix prüfen','Demo Flow'].map((x,i)=><button key={x} onClick={()=>setActive(i===0?'asn':i===1?'prefix':'reports')} className='bg-white border rounded-lg p-4 text-left hover:bg-slate-50'><h3 className='font-semibold'>{x}</h3></button>)}</div>
<div className='bg-white border rounded-lg p-4'><h2 className='font-semibold mb-2'>Letzte Reports</h2>{reports.length===0 ? <p className='text-sm text-slate-600'>Report history will appear here after checks.</p> : <ul className='space-y-2'>{reports.slice(0,5).map(r=><li key={r.report_id} className='text-sm border rounded p-2 flex items-center justify-between'><span>{r.input_resource} ({r.check_type})</span><StatusBadge status={r.status} /></li>)}</ul>}</div>
</section>}
{active === 'asn' && <AsnCheckForm />}
{active === 'prefix' && <PrefixCheckForm />}
{active === 'reports' && <section className='bg-white border rounded-lg p-4'><h2 className='text-xl font-semibold mb-3'>Reports</h2>{reports.length===0 ? <p>Noch keine Reports vorhanden.</p> : <div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='text-left border-b'><th>Zeitpunkt</th><th>Typ</th><th>Resource</th><th>Origin-AS</th><th>Status</th><th>Kurzfassung</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-b'><td>{new Date(r.created_at).toLocaleString()}</td><td>{r.check_type}</td><td>{r.input_resource}</td><td>{r.origin_as || '-'}</td><td><StatusBadge status={r.status} /></td><td>{r.summary}</td></tr>)}</tbody></table></div>}</section>}
{active === 'about' && <section className='bg-white border rounded-lg p-4'><h2 className='text-xl font-semibold'>About</h2><p className='mt-2 text-sm'>RouteForge unterstützt nachvollziehbare Routing-Preflightchecks für Operator-Workflows.</p></section>}
</Layout>
}
66 changes: 12 additions & 54 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -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(/\/$/, '')

Expand All @@ -15,67 +15,25 @@ export class ApiError extends Error {
}

async function requestJson<T>(url: string, options: RequestInit): Promise<T> {
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<CheckResponse> {
return requestJson<CheckResponse>(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<CheckResponse> {
return requestJson<CheckResponse>(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<CheckResponse> {
return requestJson<CheckResponse>(apiUrl('/api/check/asn-rpki'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asn, limit }),
})
}
export const checkAsn = (asn: string) => requestJson<CheckResponse>(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn }) })
export const checkPrefix = (prefix: string, origin_as?: string) => requestJson<CheckResponse>(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<CheckResponse>(apiUrl('/api/check/asn-rpki'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn, limit }) })
export const getReports = () => requestJson<ReportListItem[]>(apiUrl('/api/reports'), { method: 'GET' })
export const getSystemInfo = () => requestJson<SystemInfo>(apiUrl('/api/system/info'), { method: 'GET' })
61 changes: 14 additions & 47 deletions frontend/src/components/AsnCheckForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState } from 'react'

import { ApiError, checkAsn, checkAsnRpki } from '../api'
import { ReportView } from './ReportView'
import type { CheckResponse } from '../types'
Expand All @@ -12,51 +11,19 @@ export function AsnCheckForm() {
const [result, setResult] = useState<CheckResponse | null>(null)
const [batchResult, setBatchResult] = useState<CheckResponse | null>(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 (
<div className='p-4 border rounded bg-white'>
<h2 className='font-bold mb-2'>ASN Check</h2>
<input className='border p-2 w-full' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} />
<button disabled={loading} className='mt-2 px-3 py-2 bg-blue-600 text-white rounded' onClick={onSubmit}>Prüfen</button>
{loading && <p className='mt-3 text-sm text-slate-700'>Prüfung läuft ...</p>}
{result && <p className='mt-2 text-sm text-slate-700'>ASN wurde geprüft. Die RPKI-Bewertung erfolgt für die sichtbaren Prefixe der ASN.</p>}
{result && <p className='mt-1 text-sm text-slate-700'>Extrahierte Prefixe: {extractedPrefixes.length}</p>}
{result && extractedPrefixes.length > 0 && (
<button disabled={batchLoading} className='mt-2 ml-2 px-3 py-2 bg-indigo-600 text-white rounded' onClick={onBatchRpki}>RPKI für sichtbare Prefixe prüfen</button>
)}
{batchLoading && <p className='mt-2 text-sm text-slate-700'>ASN-RPKI-Batchprüfung läuft ...</p>}
{error && <div className='mt-3 p-3 rounded border border-red-300 bg-red-50 text-red-800'><p className='font-semibold'>Die Prüfung konnte nicht ausgeführt werden.</p><p className='text-sm mt-1'>{error.message}</p></div>}
{result && <div className='mt-4'><ReportView report={result} /></div>}
{batchResult && <div className='mt-4'>
<h3 className='text-md font-bold mb-2'>ASN-RPKI-Batchergebnis</h3>
<ReportView report={batchResult} />
</div>}
</div>
)
return <section className='bg-white border rounded-lg p-4'>
<h2 className='text-xl font-semibold'>ASN Check</h2>
<p className='text-sm text-slate-600 mt-1'>Eine ASN allein kann nicht RPKI-valid oder invalid sein. RPKI bewertet Prefix-Origin-Paare.</p>
<input className='border p-2 w-full mt-3 rounded' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} />
<button onClick={onSubmit} disabled={loading} className='mt-2 px-3 py-2 bg-blue-700 text-white rounded-md'>ASN prüfen</button>
{result && <div className='text-sm mt-3 p-3 rounded border bg-slate-50'><p><strong>ASN:</strong> {asn}</p><p><strong>Anzahl extrahierter Prefixe:</strong> {extracted.length}</p><p><strong>Datenquellen/Warnungen:</strong> {JSON.stringify(result.details?.warnings ?? [])}</p>{extracted.length > 0 && <button onClick={onBatch} disabled={batchLoading} className='mt-2 px-3 py-2 bg-indigo-700 text-white rounded-md'>RPKI-Batch für sichtbare Prefixe starten</button>}</div>}
{(loading || batchLoading) && <p className='mt-2 text-sm'>Ladezustand aktiv ...</p>}
{error && <p className='mt-2 text-sm text-rose-700'>{error.message}</p>}
{result && <div className='mt-4'><ReportView report={result} /></div>}
{batchResult && <div className='mt-4'><ReportView report={batchResult} /></div>}
</section>
}
21 changes: 20 additions & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,21 @@
import { ReactNode } from 'react'
export function Layout({children}:{children:ReactNode}) { return <div className='min-h-screen bg-slate-50 p-6'><div className='max-w-5xl mx-auto'>{children}</div></div> }

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 <div className='min-h-screen bg-slate-100 text-slate-900'>
<header className='bg-slate-900 text-white px-6 py-4 font-semibold'>RouteForge Operator Console</header>
<div className='max-w-7xl mx-auto p-4 grid md:grid-cols-[220px_1fr] gap-4'>
<aside className='bg-white border rounded-lg p-2 h-fit'>{nav.map(n => <button key={n.key} onClick={() => onNav(n.key)} className={`w-full text-left px-3 py-2 rounded-md text-sm ${active === n.key ? 'bg-slate-900 text-white' : 'hover:bg-slate-100'}`}>{n.label}</button>)}</aside>
<main>{children}</main>
</div>
<footer className='border-t bg-white px-6 py-2 text-xs text-slate-600'>{systemLine}</footer>
</div>
}
66 changes: 12 additions & 54 deletions frontend/src/components/PrefixCheckForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState } from 'react'

import { ApiError, checkPrefix } from '../api'
import { ReportView } from './ReportView'
import type { CheckResponse } from '../types'
Expand All @@ -11,57 +10,16 @@ export function PrefixCheckForm() {
const [error, setError] = useState<ApiError | null>(null)
const [result, setResult] = useState<CheckResponse | null>(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 (
<div className='p-4 border rounded bg-white'>
<h2 className='font-bold mb-2'>Prefix Check</h2>
<input className='border p-2 w-full mb-2' placeholder='193.0.22.0/23' value={prefix} onChange={e => setPrefix(e.target.value)} />
<input className='border p-2 w-full' placeholder='Optional: AS3333' value={originAs} onChange={e => setOriginAs(e.target.value)} />
<button disabled={loading} className='mt-2 px-3 py-2 bg-blue-600 text-white rounded' onClick={onSubmit}>
Prüfen
</button>

{loading && <p className='mt-3 text-sm text-slate-700'>Prüfung läuft ...</p>}

{error && (
<div className='mt-3 p-3 rounded border border-red-300 bg-red-50 text-red-800'>
<p className='font-semibold'>Die Prüfung konnte nicht ausgeführt werden.</p>
<p className='text-sm mt-1'>{error.message}</p>
<details className='mt-2'>
<summary className='cursor-pointer text-sm'>Technische Details</summary>
<pre className='text-xs whitespace-pre-wrap mt-1'>
{JSON.stringify(
{
status: error.status,
responseBody: error.responseBody,
},
null,
2,
)}
</pre>
</details>
</div>
)}

{result && <div className='mt-4'><ReportView report={result} /></div>}
</div>
)
const onSubmit = async () => { setLoading(true); setError(null); try { setResult(await checkPrefix(prefix, originAs || undefined)) } catch (e) { setError(e as ApiError) } finally { setLoading(false) } }

return <section className='bg-white border rounded-lg p-4'>
<h2 className='text-xl font-semibold'>Prefix Check</h2>
<p className='text-sm text-slate-600'>Origin-AS empfohlen für vollständige RPKI- und Registry/IRR-Bewertung.</p>
<input className='border p-2 w-full rounded mt-3' placeholder='193.0.22.0/23' value={prefix} onChange={e => setPrefix(e.target.value)} />
<input className='border p-2 w-full rounded mt-2' placeholder='AS3333 (optional)' value={originAs} onChange={e => setOriginAs(e.target.value)} />
<button onClick={onSubmit} disabled={loading} className='mt-2 px-3 py-2 bg-blue-700 text-white rounded-md'>Prefix prüfen</button>
{loading && <p className='text-sm mt-2'>Prüfung läuft ...</p>}
{error && <p className='text-sm mt-2 text-rose-700'>{error.message}</p>}
{result && <div className='mt-4'><ReportView report={result} /></div>}
</section>
}
Loading
Loading