From fed1eaaece101572afca50c6e9d7daa7ef05e3a8 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 13:19:20 +0100 Subject: [PATCH] Add summary export and report sharing actions --- README.md | 11 +++++ RELEASE_NOTES.md | 1 + backend/app/api/routes_reports.py | 23 +++++++++- backend/app/services/report_renderer.py | 58 +++++++++++++++++++++++++ backend/tests/test_api_smoke.py | 22 ++++++++++ frontend/src/App.tsx | 22 +++++++++- frontend/src/api.ts | 12 +++++ frontend/src/components/ReportView.tsx | 33 +++++++++++++- 8 files changed, 177 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b926eb..2d3cb6b 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 662ed00..03032aa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/backend/app/api/routes_reports.py b/backend/app/api/routes_reports.py index 94ec6a9..77b44f8 100644 --- a/backend/app/api/routes_reports.py +++ b/backend/app/api/routes_reports.py @@ -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"]) @@ -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"'}, + ) diff --git a/backend/app/services/report_renderer.py b/backend/app/services/report_renderer.py index 5fef750..90ab00f 100644 --- a/backend/app/services/report_renderer.py +++ b/backend/app/services/report_renderer.py @@ -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" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index c57d7e3..7281db7 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -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', '') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b2e660..498ea35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' @@ -12,10 +12,28 @@ type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'about' export default function App() { const [active, setActive] = useState('dashboard') const [reports, setReports] = useState([]) + const [copyMessage, setCopyMessage] = 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.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 {active === 'dashboard' &&
@@ -26,7 +44,7 @@ export default function App() { {active === 'asn' && } {active === 'prefix' && } {active === 'preflight' && } - {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
ZeitpunktTypResourceOrigin-ASHolderStatusKurzfassung
{new Date(r.created_at).toLocaleString()}{r.check_type === 'preflight' ? 'Preflight' : r.check_type}{r.input_resource}{r.origin_as || '-'}{r.holder || 'Unknown'}{r.summary}
}
} + {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
ZeitpunktTypResourceOrigin-ASHolderStatusKurzfassungActions
{new Date(r.created_at).toLocaleString()}{r.check_type === 'preflight' ? 'Preflight' : r.check_type}{r.input_resource}{r.origin_as || '-'}{r.holder || 'Unknown'}{r.summary}
}{copyMessage &&

{copyMessage}

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

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Datenquellen: RPKI Validator APIs und Registry/IRR Quellen.

Modell: read-only Betrieb.

Limitations: Externe Datenquellen können unvollständig oder verzögert sein.

Version: v0.2.0-alpha

} } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 29d8e47..80bfe5b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -30,6 +30,15 @@ async function requestJson(url: string, options: RequestInit): Promise { return parsedBody as T } +async function requestText(url: string, options: RequestInit): Promise { + 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(apiUrl('/api/check/asn'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asn }) }) @@ -37,5 +46,8 @@ export const checkPrefix = (prefix: string, origin_as?: string) => requestJson 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' }) +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(apiUrl('/api/check/preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, planned_origin_as }) }) diff --git a/frontend/src/components/ReportView.tsx b/frontend/src/components/ReportView.tsx index a298fc9..7913ef8 100644 --- a/frontend/src/components/ReportView.tsx +++ b/frontend/src/components/ReportView.tsx @@ -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' @@ -6,6 +8,7 @@ const order = { CRITICAL: 0, WARNING: 1, UNKNOWN: 2, OK: 3 } const decisionByStatus: Record = { OK: 'GO', WARNING: 'CAUTION', CRITICAL: 'NO-GO', UNKNOWN: 'UNKNOWN' } export function ReportView({ report }: { report: CheckResponse }) { + const [copyMessage, setCopyMessage] = useState('') 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' @@ -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
@@ -39,7 +61,16 @@ export function ReportView({ report }: { report: CheckResponse }) {
Technische Details
{JSON.stringify({ input: report.input, holder: details.resource_holder, warnings: details.warnings, source_errors: details.source_errors }, null, 2)}
{sortedResults.length > 0 &&

Batch Results

{sortedResults.map((item, idx) => )}
StatusPrefixSummary
{item.prefix}{item.summary || '-'}
} - +
+

Export

+
+ + + + +
+ {copyMessage &&

{copyMessage}

} +
}