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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,14 @@ curl -X POST http://localhost:8000/api/check/preflight \
RouteForge stellt Ergebnisse mit klarer Ergebnis-Zusammenfassung dar und versucht den Holder (Ressource-Inhaber) aus vorhandenen AS-/Prefix-/Whois-/Registry-Daten abzuleiten.
Wenn keine belastbare Quelle vorhanden ist, zeigt RouteForge **"Unknown"** an.
Die Holder-Erkennung ist **read-only**, rein informativ und führt keine Schreiboperationen (keine ROA-Erstellung, keine RIPE-DB-Änderungen, kein Deployment) aus.

## Export and sharing

RouteForge Reports können als **Markdown**, **HTML** oder als kurze **Plain-Text Summary** exportiert werden.
Die Summary ist für Change-Tickets, Maintenance-Dokumentation oder interne Reviews gedacht.

```bash
curl http://localhost:8000/api/reports/1/summary
curl http://localhost:8000/api/reports/1/markdown -o routeforge-report.md
curl http://localhost:8000/api/reports/1/html -o routeforge-report.html
```
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Diese Alpha-Version ist für Demos, frühes Feedback und nachvollziehbare Erstbe
- Kombinierte Prefix-Gesamtbewertung mit Einzelprüfungen
- Routing Visibility Check als zusätzliche read-only Alpha-Prüfung
- Reports in JSON, Markdown und HTML
- Export/Share Verbesserungen (alpha): Plain-Text Summary Export sowie verbesserte Download-/Sharing-Workflows für Markdown und HTML
- Demo-Modus mit festen Beispieldaten
- Robuste Parser und CI-Basis

Expand Down
23 changes: 21 additions & 2 deletions backend/app/api/routes_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from app.database import get_db
from app.models import Check, Report
from app.services.report_renderer import render_plain_summary

router = APIRouter(prefix="/api/reports", tags=["reports"])

Expand Down Expand Up @@ -48,10 +49,28 @@ def get_report(report_id: int, db: Session = Depends(get_db)):
@router.get('/{report_id}/markdown')
def get_report_markdown(report_id: int, db: Session = Depends(get_db)):
r = _report_or_404(db, report_id)
return Response(content=r.markdown, media_type="text/markdown")
return Response(
content=r.markdown,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.md"'},
)


@router.get('/{report_id}/html')
def get_report_html(report_id: int, db: Session = Depends(get_db)):
r = _report_or_404(db, report_id)
return Response(content=r.html, media_type="text/html")
return Response(
content=r.html,
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.html"'},
)


@router.get('/{report_id}/summary')
def get_report_summary(report_id: int, db: Session = Depends(get_db)):
r = _report_or_404(db, report_id)
return Response(
content=render_plain_summary(r.json_data or {}),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-summary-{report_id}.txt"'},
)
58 changes: 58 additions & 0 deletions backend/app/services/report_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,61 @@ def render_report(payload: dict) -> tuple[dict, str, str]:
md = env.get_template("report.md.j2").render(report=report_json)
html = env.get_template("report.html.j2").render(report=report_json)
return report_json, md, html


def render_plain_summary(report_json: dict) -> str:
data = report_json or {}
details = data.get("details") or {}
input_data = data.get("input") or {}
holder = ((details.get("resource_holder") or {}).get("holder")) or "Unknown"
status = data.get("status") or "Unknown"
summary = data.get("summary") or "Unknown"
risk = data.get("risk") or "Unknown"
recommendations = data.get("recommendations") or []
if not isinstance(recommendations, list):
recommendations = [str(recommendations)]
recommendations = [str(item) for item in recommendations if str(item).strip()]
if not recommendations:
recommendations = ["Unknown"]

is_preflight = bool(input_data.get("planned_origin_as")) or str(data.get("check_type", "")).lower() == "preflight"
check_type = data.get("check_type")
if not check_type:
if input_data.get("planned_origin_as"):
check_type = "Preflight"
elif input_data.get("asn"):
check_type = "ASN"
elif input_data.get("prefix"):
check_type = "Prefix"
else:
check_type = "Unknown"
check_type_label = str(check_type).replace("-", " ").title()

lines = ["RouteForge Preflight Summary" if is_preflight else "RouteForge Result Summary"]
if is_preflight:
planned_prefix = input_data.get("prefix") or "Unknown"
planned_origin = input_data.get("planned_origin_as") or "Unknown"
lines.append(f"Planned Change: {planned_prefix} -> {planned_origin}")
else:
lines.append(f"Check Type: {check_type_label}")
lines.append(f"Resource: {input_data.get('prefix') or input_data.get('asn') or data.get('input_resource') or 'Unknown'}")
lines.append(f"Origin-AS: {input_data.get('origin_as') or 'Unknown'}")
lines.append(f"Holder: {holder}")
preflight_decision = details.get("preflight_decision")
if preflight_decision:
lines.append(f"Decision: {preflight_decision}")
lines.append(f"Status: {status}")
lines.extend(
[
"",
"Summary:",
str(summary),
"",
"Risk:",
str(risk),
"",
"Recommendations:",
]
)
lines.extend([f"- {item}" for item in recommendations])
return "\n".join(lines).strip() + "\n"
22 changes: 22 additions & 0 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,25 @@ def test_preflight_check() -> None:
assert payload.get('details', {}).get('preflight_mode') is True
assert payload.get('details', {}).get('resource_holder')
assert payload.get('details', {}).get('preflight_decision') in {'GO', 'CAUTION', 'NO-GO', 'UNKNOWN'}


def test_report_export_endpoints() -> None:
client = _client()
check_response = client.post('/api/check/prefix', json={'prefix': '193.0.6.0/24'})
assert check_response.status_code == 200
report_id = check_response.json().get('report_id')
assert report_id

summary_response = client.get(f'/api/reports/{report_id}/summary')
assert summary_response.status_code == 200
assert 'text/plain' in summary_response.headers.get('content-type', '')
assert 'RouteForge' in summary_response.text
assert 'Status:' in summary_response.text

markdown_response = client.get(f'/api/reports/{report_id}/markdown')
assert markdown_response.status_code == 200
assert 'text/markdown' in markdown_response.headers.get('content-type', '')

html_response = client.get(f'/api/reports/{report_id}/html')
assert html_response.status_code == 200
assert 'text/html' in html_response.headers.get('content-type', '')
22 changes: 20 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { getReports, getSystemInfo } from './api'
import { getReportHtml, getReportMarkdown, getReportSummary, getReports, getSystemInfo } from './api'
import { AsnCheckForm } from './components/AsnCheckForm'
import { Layout } from './components/Layout'
import { PrefixCheckForm } from './components/PrefixCheckForm'
Expand All @@ -12,10 +12,28 @@ type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'about'
export default function App() {
const [active, setActive] = useState<NavKey>('dashboard')
const [reports, setReports] = useState<ReportListItem[]>([])
const [copyMessage, setCopyMessage] = useState('')
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.2.0-alpha · read-only preflight checks', [system])
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', about: 'About RouteForge' }[active]
const notify = (message: string) => {
setCopyMessage(message)
window.setTimeout(() => setCopyMessage(''), 2000)
}
const copyText = async (text: string, success: string) => {
if (!navigator.clipboard?.writeText) return notify('Copy failed')
try { await navigator.clipboard.writeText(text); notify(success) } catch { notify('Copy failed') }
}
const downloadText = (filename: string, text: string, mimeType: string) => {
const blob = new Blob([text], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}

return <Layout active={active} onNav={setActive} systemLine={systemLine} title={title} demoMode={Boolean(system?.demo_mode)}>
{active === 'dashboard' && <section className='space-y-4'>
Expand All @@ -26,7 +44,7 @@ export default function App() {
{active === 'asn' && <AsnCheckForm />}
{active === 'prefix' && <PrefixCheckForm />}
{active === 'preflight' && <PreflightCheckForm />}
{active === 'reports' && <section className='rf-card p-4'><h2 className='mb-3 text-xl font-semibold'>Reports</h2>{reports.length===0 ? <div className='rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500'>Noch keine Reports vorhanden.</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='border-b text-left'><th>Zeitpunkt</th><th>Typ</th><th>Resource</th><th>Origin-AS</th><th>Holder</th><th>Status</th><th>Kurzfassung</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-b border-slate-100'><td className='py-2'>{new Date(r.created_at).toLocaleString()}</td><td>{r.check_type === 'preflight' ? 'Preflight' : r.check_type}</td><td>{r.input_resource}</td><td>{r.origin_as || '-'}</td><td>{r.holder || 'Unknown'}</td><td><StatusBadge status={r.status} /></td><td>{r.summary}</td></tr>)}</tbody></table></div>}</section>}
{active === 'reports' && <section className='rf-card p-4'><h2 className='mb-3 text-xl font-semibold'>Reports</h2>{reports.length===0 ? <div className='rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500'>Noch keine Reports vorhanden.</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='border-b text-left'><th>Zeitpunkt</th><th>Typ</th><th>Resource</th><th>Origin-AS</th><th>Holder</th><th>Status</th><th>Kurzfassung</th><th>Actions</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-b border-slate-100'><td className='py-2'>{new Date(r.created_at).toLocaleString()}</td><td>{r.check_type === 'preflight' ? 'Preflight' : r.check_type}</td><td>{r.input_resource}</td><td>{r.origin_as || '-'}</td><td>{r.holder || 'Unknown'}</td><td><StatusBadge status={r.status} /></td><td>{r.summary}</td><td><div className='flex flex-wrap gap-1'><button className='rf-btn-secondary' onClick={() => window.open(`/api/reports/${r.report_id}`, '_blank')}>Open</button><button className='rf-btn-secondary' onClick={async ()=>copyText(await getReportSummary(r.report_id), 'Summary copied')}>Copy Summary</button><button className='rf-btn-secondary' onClick={async ()=>downloadText(`routeforge-report-${r.report_id}.md`, await getReportMarkdown(r.report_id), 'text/markdown;charset=utf-8')}>Download Markdown</button><button className='rf-btn-secondary' onClick={async ()=>downloadText(`routeforge-report-${r.report_id}.html`, await getReportHtml(r.report_id), 'text/html;charset=utf-8')}>Download HTML</button></div></td></tr>)}</tbody></table></div>}{copyMessage && <p className='mt-2 text-sm text-slate-600'>{copyMessage}</p>}</section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p>RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.</p><p><b>Datenquellen:</b> RPKI Validator APIs und Registry/IRR Quellen.</p><p><b>Modell:</b> read-only Betrieb.</p><p><b>Limitations:</b> Externe Datenquellen können unvollständig oder verzögert sein.</p><p><b>Version:</b> v0.2.0-alpha</p></section>}
</Layout>
}
12 changes: 12 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,24 @@ async function requestJson<T>(url: string, options: RequestInit): Promise<T> {
return parsedBody as T
}

async function requestText(url: string, options: RequestInit): Promise<string> {
const response = await fetch(url, options)
const text = await response.text()
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}: ${response.statusText || 'Request failed'}`, response.status, text)
}
return text
}

const apiUrl = (path: string) => (API_BASE_URL ? `${API_BASE_URL}${path}` : path)

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' })
export const getReportMarkdown = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/markdown`), { method: 'GET' })
export const getReportHtml = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/html`), { method: 'GET' })
export const getReportSummary = (reportId: number) => requestText(apiUrl(`/api/reports/${reportId}/summary`), { method: 'GET' })

export const checkPreflight = (prefix: string, planned_origin_as: string) => requestJson<CheckResponse>(apiUrl('/api/check/preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, planned_origin_as }) })
33 changes: 32 additions & 1 deletion frontend/src/components/ReportView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState } from 'react'
import { getReportHtml, getReportMarkdown, getReportSummary } from '../api'
import type { CheckResponse, RpkiBatchResult } from '../types'
import { RawDataPanel } from './RawDataPanel'
import { StatusBadge } from './StatusBadge'
Expand All @@ -6,6 +8,7 @@ const order = { CRITICAL: 0, WARNING: 1, UNKNOWN: 2, OK: 3 }
const decisionByStatus: Record<string, string> = { OK: 'GO', WARNING: 'CAUTION', CRITICAL: 'NO-GO', UNKNOWN: 'UNKNOWN' }

export function ReportView({ report }: { report: CheckResponse }) {
const [copyMessage, setCopyMessage] = useState<string>('')
const details = report.details ?? {}
const holder = (details.resource_holder as { holder?: string } | undefined)?.holder || 'Unknown'
const checkType = report.input?.planned_origin_as ? 'Preflight' : report.input?.asn ? 'ASN Check' : 'Prefix Check'
Expand All @@ -15,6 +18,25 @@ export function ReportView({ report }: { report: CheckResponse }) {
const routingVisibility = report.checks?.routing_visibility
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))
const recommendationsTitle = report.status === 'CRITICAL' ? 'Sofort prüfen' : report.status === 'WARNING' ? 'Empfohlen' : report.status === 'OK' ? 'Hinweis' : 'Datenlage prüfen'
const reportId = report.report_id

const notify = (message: string) => {
setCopyMessage(message)
window.setTimeout(() => setCopyMessage(''), 2000)
}
const copyText = async (text: string, success: string) => {
if (!navigator.clipboard?.writeText) return notify('Copy failed')
try { await navigator.clipboard.writeText(text); notify(success) } catch { notify('Copy failed') }
}
const downloadText = (filename: string, text: string, mimeType: string) => {
const blob = new Blob([text], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}

return <div className='space-y-4'>
<section className='rf-card p-5 space-y-2 border-l-4 border-l-blue-500'>
Expand All @@ -39,7 +61,16 @@ export function ReportView({ report }: { report: CheckResponse }) {

<details className='rf-card p-4'><summary className='cursor-pointer text-sm font-semibold'>Technische Details</summary><pre className='mt-3 overflow-auto rounded-xl bg-slate-50 p-3 text-xs'>{JSON.stringify({ input: report.input, holder: details.resource_holder, warnings: details.warnings, source_errors: details.source_errors }, null, 2)}</pre></details>
{sortedResults.length > 0 && <section className='rf-card p-4'><h4 className='mb-2 font-semibold'>Batch Results</h4><div className='overflow-x-auto'><table className='w-full text-sm'><thead><tr className='border-b text-left'><th className='py-2'>Status</th><th>Prefix</th><th>Summary</th></tr></thead><tbody>{sortedResults.map((item, idx) => <tr key={`${item.prefix}-${idx}`} className='border-b border-slate-100'><td className='py-2'><StatusBadge status={item.status || 'UNKNOWN'} /></td><td className='font-mono'>{item.prefix}</td><td>{item.summary || '-'}</td></tr>)}</tbody></table></div></section>}
<button className='rf-btn-secondary' onClick={() => navigator.clipboard.writeText(report.markdown)}>Markdown-Report kopieren</button>
<section className='rf-card p-4 space-y-2'>
<h4 className='font-semibold'>Export</h4>
<div className='flex flex-wrap gap-2'>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && copyText(await getReportSummary(reportId), 'Summary copied')}>Copy Summary</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && copyText(await getReportMarkdown(reportId), 'Markdown copied')}>Copy Markdown</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && downloadText(`routeforge-report-${reportId}.md`, await getReportMarkdown(reportId), 'text/markdown;charset=utf-8')}>Download Markdown</button>
<button className='rf-btn-secondary' disabled={!reportId} onClick={async () => reportId && downloadText(`routeforge-report-${reportId}.html`, await getReportHtml(reportId), 'text/html;charset=utf-8')}>Download HTML</button>
</div>
{copyMessage && <p className='text-sm text-slate-600'>{copyMessage}</p>}
</section>
<RawDataPanel data={report.details} />
</div>
}
Loading