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
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## v0.9.1-rc (2026-05-21)

- UI Cleanup & English-only polish

## v0.9.1-rc (2026-05-20)

### Motivation
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default function App() {
if (authMode === 'error') return <div className='p-8 text-center text-rose-700'>{authError}</div>

const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.9.1-rc · read-only preflight checks'
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', 'roa-planner': 'ROA Planner', 'bgp-visibility': 'BGP Visibility', reports: 'Reports', 'watch-mode': 'Watch Mode', 'change-cases': 'Change Cases', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active]
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight', 'roa-planner': 'ROA Planner', 'bgp-visibility': 'BGP Visibility', reports: 'Reports', 'watch-mode': 'Watch Mode', 'change-cases': 'Change Cases', system: 'System', users: 'Users', audit: 'Audit Log', about: 'About' }[active]
const proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'
const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error'
Expand All @@ -86,22 +86,22 @@ export default function App() {
if (role === 'operator') return !['users', 'audit'].includes(view)
return ['dashboard', 'reports', 'watch-mode', 'change-cases', 'bgp-visibility', 'roa-planner', '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.'
const readOnlyLabel = system?.read_only ? 'Enabled' : 'Disabled'

return <Layout active={active} onNav={setActive} systemLine={systemLine} title={title} demoMode={Boolean(system?.demo_mode)} currentUser={currentUser} onLogout={handleLogout}>
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.9.1-rc</h1><p className='mt-2 text-slate-600'>Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.</p></article><article className='rf-card p-4 text-sm'><div><b>Logged in as:</b> {currentUser?.username}</div><div><b>Role:</b> {currentUser?.role}</div><div><b>Allowed actions:</b> {allowedActions}</div></article>{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge. Run: <code>alembic current</code>, <code>alembic heads</code>, <code>alembic upgrade head</code>.</article>}</section>}
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6 space-y-2'><h1 className='text-2xl font-bold'>{system?.name || 'RouteForge'} {system?.version || 'v0.9.1-rc'}</h1><div className='grid gap-1 text-sm md:grid-cols-2'><p><b>Current user:</b> {currentUser?.username || '-'}</p><p><b>Role:</b> {currentUser?.role || '-'}</p><p><b>Read-only:</b> {readOnlyLabel}</p><p><b>Mode:</b> {system?.demo_mode ? 'DEMO' : 'LIVE'}</p></div>{system?.demo_mode && <p className='rf-alert border-amber-200 bg-amber-50 text-amber-800'>Demo mode is active.</p>}</article><article className='rf-card p-4'><h3 className='mb-2 text-sm font-semibold'>Quick Actions</h3><div className='flex flex-wrap gap-2'><button className='rf-btn-secondary' onClick={() => setActive('preflight')}>Preflight</button><button className='rf-btn-secondary' onClick={() => setActive('reports')}>Reports</button>{role === 'admin' && <button className='rf-btn-secondary' onClick={() => setActive('system')}>System</button>}</div></article>{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge. Run: <code>alembic current</code>, <code>alembic heads</code>, <code>alembic upgrade head</code>.</article>}</section>}
{!canAccess(active) && <article className='rf-card p-4 text-amber-800 bg-amber-50 border border-amber-200'>You do not have permission to access this section.</article>}
{active === 'asn' && canAccess('asn') && <AsnCheckForm />}
{active === 'prefix' && canAccess('prefix') && <PrefixCheckForm />}
{active === 'preflight' && canAccess('preflight') && <PreflightCheckForm />}
{active === 'bgp-visibility' && canAccess('bgp-visibility') && <BgpVisibilityForm role={role} />}
{active === 'roa-planner' && canAccess('roa-planner') && <RoaPlannerForm role={role} />}
{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'><tbody>{reports.map(r=><tr key={r.report_id}><td>{r.summary}</td><td><button className='rf-btn-secondary' onClick={async ()=>navigator.clipboard?.writeText(await getReportSummary(r.report_id))}>Copy Summary</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportMarkdown(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/markdown'}));a.download=`routeforge-report-${r.report_id}.md`;a.click()}}>Download Markdown</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportHtml(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/html'}));a.download=`routeforge-report-${r.report_id}.html`;a.click()}}>Download HTML</button></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'>No reports yet.</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><tbody>{reports.map(r=><tr key={r.report_id}><td>{r.summary}</td><td><button className='rf-btn-secondary' onClick={async ()=>navigator.clipboard?.writeText(await getReportSummary(r.report_id))}>Copy Summary</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportMarkdown(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/markdown'}));a.download=`routeforge-report-${r.report_id}.md`;a.click()}}>Download Markdown</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportHtml(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/html'}));a.download=`routeforge-report-${r.report_id}.html`;a.click()}}>Download HTML</button></td></tr>)}</tbody></table></div>}</section>}
{active === 'watch-mode' && canAccess('watch-mode') && <WatchModeView role={role} />}
{active === 'change-cases' && canAccess('change-cases') && <ChangeCasesView role={role} />}
{active === 'system' && canAccess('system') && <section className='space-y-3'>{systemStatusError && <article className='rf-card p-4 text-rose-700'>{systemStatusError}</article>}{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge. Run: <code>alembic current</code>, <code>alembic heads</code>, <code>alembic upgrade head</code>.</article>}{systemStatus && <article className='rf-card p-4 grid gap-2 md:grid-cols-2 text-sm'><div>Version: <b>{systemStatus.version}</b></div><div>Mode: <b>{systemStatus.mode}</b></div><div>API Proxy: <b>{proxyStatus}</b></div><div>Migration Status: <b>{migrationStatus}</b> <StatusBadge status={migrationStatus === 'up_to_date' ? 'OK' : migrationStatus === 'behind' ? 'WARNING' : migrationStatus === 'error' ? 'CRITICAL' : 'UNKNOWN'} /></div><div>DB Current Revision: <b>{systemStatus.database?.schema_version || 'unknown'}</b></div><div>DB Head Revision: <b>{systemStatus.database?.migration_head || 'unknown'}</b></div></article>}</section>}
{active === 'users' && canAccess('users') && <UsersView />}
{active === 'audit' && canAccess('audit') && <AuditLogView />}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.9.1</p></section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p>RouteForge is a read-only routing operations console for validation workflows.</p><p><b>Read-only:</b> All checks are non-destructive.</p><p><b>Version:</b> v0.9.1-rc</p></section>}
</Layout>
}
2 changes: 1 addition & 1 deletion frontend/src/components/AsnCheckForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function AsnCheckForm() {
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) } }
return <section className='space-y-4'>
<article className='rf-card p-5 space-y-3'><h3 className='text-lg font-semibold'>ASN Check</h3><p className='text-sm text-slate-600'>RPKI bewertet Prefix-Origin-Paare, nicht die ASN isoliert.</p><label className='text-sm font-medium'>ASN</label><input className='rf-input' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} /><div className='flex gap-2'><button onClick={onSubmit} disabled={loading} className='rf-btn-primary'>ASN prüfen</button>{result && <button onClick={onBatch} disabled={batchLoading || !batchAvailable} className='rf-btn-secondary' title={!batchAvailable ? 'RPKI-Batch nicht möglich: keine auswertbaren Prefixe' : ''}>RPKI-Batch für sichtbare Prefixe starten</button>}</div>{result && <p className='text-sm text-slate-700'>Extrahierte Prefixe: <b>{extracted.length}</b></p>}{result && !batchAvailable && <div className='rf-alert border-amber-200 bg-amber-50 text-amber-800'><p className='font-medium'>RPKI-Batch nicht möglich</p><p>{rpkiBatch?.message || 'Für diese ASN konnten keine auswertbaren Prefixe gefunden werden.'}</p><p className='text-xs mt-1'>Reason: {rpkiBatch?.reason_code || 'unknown'} · Prefix count: {rpkiBatch?.prefix_count ?? 0}</p></div>}{(loading || batchLoading) && <p className='text-sm text-blue-700'>Prüfung läuft…</p>}{error && <p className='rf-alert border-rose-200 bg-rose-50 text-rose-700'>{error.message}</p>}</article>
<article className='rf-card p-5 space-y-3'><h3 className='text-lg font-semibold'>ASN Check</h3><p className='text-sm text-slate-600'>RPKI evaluates prefix-origin pairs, not an ASN in isolation.</p><label className='text-sm font-medium'>ASN</label><input className='rf-input' placeholder='AS3320' value={asn} onChange={e => setAsn(e.target.value)} /><div className='flex gap-2'><button onClick={onSubmit} disabled={loading} className='rf-btn-primary'>Run ASN Check</button>{result && <button onClick={onBatch} disabled={batchLoading || !batchAvailable} className='rf-btn-secondary' title={!batchAvailable ? 'RPKI batch unavailable: no evaluable prefixes found.' : ''}>Run RPKI Batch</button>}</div>{result && <p className='text-sm text-slate-700'>Extracted prefixes: <b>{extracted.length}</b></p>}{result && !batchAvailable && <div className='rf-alert border-amber-200 bg-amber-50 text-amber-800'><p className='font-medium'>RPKI batch unavailable</p><p>{rpkiBatch?.message || 'No evaluable prefixes were found for this ASN.'}</p><p className='text-xs mt-1'>Reason: {rpkiBatch?.reason_code || 'unknown'} · Prefix count: {rpkiBatch?.prefix_count ?? 0}</p></div>}{(loading || batchLoading) && <p className='text-sm text-blue-700'>Running check…</p>}{error && <p className='rf-alert border-rose-200 bg-rose-50 text-rose-700'>{error.message}</p>}</article>
{result && <ReportView report={result} />}
{batchResult && <ReportView report={batchResult} />}
</section>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AuditLogView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function AuditLogView() {
</div>
{loading && <div className='text-sm text-slate-500'>Loading audit log…</div>}
{error && <div className='text-sm text-rose-700'>{error}</div>}
{!loading && !error && items.length === 0 && <div className='rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500'>No audit events yet.</div>}
{!loading && !error && items.length === 0 && <div className='rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500'>No audit entries found.</div>}
{!loading && !error && items.length > 0 && <div className='overflow-x-auto'><table className='w-full text-xs'><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Target</th><th>IP</th><th>User Agent</th><th>Details</th></tr></thead><tbody>{items.map((a)=><tr key={a.id}><td>{a.created_at}</td><td>{a.username || a.user_id || '-'}</td><td>{a.action}</td><td>{a.target_type || '-'}:{a.target_id || '-'}</td><td>{a.ip_address || '-'}</td><td>{a.user_agent || '-'}</td><td><pre className='whitespace-pre-wrap'>{JSON.stringify(a.details_json || {}, null, 2)}</pre></td></tr>)}</tbody></table></div>}
</section>
}
2 changes: 1 addition & 1 deletion frontend/src/components/BgpVisibilityForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function BgpVisibilityForm({ role }: { role: UserRole }) {

return <section className='rf-card p-4 space-y-3'>
<h3 className='text-lg font-semibold'>BGP Visibility</h3>
{!canRun && <p className='rf-alert border-amber-200 bg-amber-50 text-amber-800'>Viewer role: Seite ist sichtbar, aber Checks dürfen nicht ausgeführt werden.</p>}
{!canRun && <p className='rf-alert border-amber-200 bg-amber-50 text-amber-800'>Viewer role: this page is visible, but checks cannot be run.</p>}
<input className='rf-input' placeholder='203.0.113.0/24' value={prefix} onChange={e=>setPrefix(e.target.value)} />
<input className='rf-input' placeholder='Expected Origin AS (optional)' value={expectedOriginAs} onChange={e=>setExpectedOriginAs(e.target.value)} />
<button className='rf-btn-primary' disabled={loading || !prefix.trim() || !canRun} onClick={onSubmit}>{loading ? 'Checking…' : 'Run BGP Visibility Check'}</button>
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/ChangeCasesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function ChangeCasesView({ role }: { role: UserRole }) {
</div>
</article>}

{loading ? <div className='text-sm text-slate-500'>Loading change cases…</div> : items.length===0 ? <div className='rounded border border-dashed p-3 text-sm text-slate-500'>No change cases yet. Create one to attach checks and reports.</div> : <table className='w-full text-sm'><thead><tr><th>Title</th><th>Status</th><th>Owner</th><th>Created</th><th>Updated</th></tr></thead><tbody>{items.map(i=><tr key={i.id} className='border-t cursor-pointer' onClick={()=>setSelected(i)}><td>{i.title}</td><td><StatusBadge status={i.status === 'approved' ? 'OK' : i.status === 'in_review' ? 'WARNING' : i.status === 'closed' ? 'UNKNOWN' : 'UNKNOWN'} /></td><td>{i.created_by_user_id ?? '—'}</td><td>{new Date(i.created_at).toLocaleString()}</td><td>{new Date(i.updated_at).toLocaleString()}</td></tr>)}</tbody></table>}
{loading ? <div className='text-sm text-slate-500'>Loading change cases…</div> : items.length===0 ? <div className='rounded border border-dashed p-3 text-sm text-slate-500'>No change cases yet.</div> : <table className='w-full text-sm'><thead><tr><th>Title</th><th>Status</th><th>Owner</th><th>Created</th><th>Updated</th></tr></thead><tbody>{items.map(i=><tr key={i.id} className='border-t cursor-pointer' onClick={()=>setSelected(i)}><td>{i.title}</td><td><StatusBadge status={i.status === 'approved' ? 'OK' : i.status === 'in_review' ? 'WARNING' : i.status === 'closed' ? 'UNKNOWN' : 'UNKNOWN'} /></td><td>{i.created_by_user_id ?? '—'}</td><td>{new Date(i.created_at).toLocaleString()}</td><td>{new Date(i.updated_at).toLocaleString()}</td></tr>)}</tbody></table>}

{selected && <article className='border rounded p-3 space-y-3'>
<div className='flex justify-between items-center'>
Expand Down Expand Up @@ -97,11 +97,11 @@ export function ChangeCasesView({ role }: { role: UserRole }) {

<div className='border-t pt-3'>
<h4 className='font-medium mb-2'>Reports</h4>
{reports.length === 0 ? <p className='text-sm text-slate-500'>No reports for this Change Case yet.</p> : <table className='w-full text-sm'><thead><tr><th>Type</th><th>Status</th><th>Summary</th><th>Created</th><th>Open</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-t'><td>{r.check_type}</td><td>{r.status}</td><td>{r.summary}</td><td>{new Date(r.created_at).toLocaleString()}</td><td className='space-x-1'><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportSummary(r.report_id)}, 'Summary loaded.')}>Summary</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportMarkdown(r.report_id)}, 'Markdown loaded.')}>Markdown</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportHtml(r.report_id)}, 'HTML loaded.')}>HTML</button></td></tr>)}</tbody></table>}
{reports.length === 0 ? <p className='text-sm text-slate-500'>No reports yet.</p> : <table className='w-full text-sm'><thead><tr><th>Type</th><th>Status</th><th>Summary</th><th>Created</th><th>Open</th></tr></thead><tbody>{reports.map(r=><tr key={r.report_id} className='border-t'><td>{r.check_type}</td><td>{r.status}</td><td>{r.summary}</td><td>{new Date(r.created_at).toLocaleString()}</td><td className='space-x-1'><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportSummary(r.report_id)}, 'Summary loaded.')}>Summary</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportMarkdown(r.report_id)}, 'Markdown loaded.')}>Markdown</button><button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await getReportHtml(r.report_id)}, 'HTML loaded.')}>HTML</button></td></tr>)}</tbody></table>}
</div>

{canEdit && <div className='border-t pt-3'>
<button className='rf-btn-secondary' onClick={()=>runAction(async()=>{if (!confirm('Delete this Change Case? Checks/Reports stay and will be detached.')) return; await deleteChangeCase(selected.id); setSelected(null); await load()}, 'Change Case deleted.')}>Delete Change Case</button>
<button className='rf-btn-secondary' onClick={()=>runAction(async()=>{await deleteChangeCase(selected.id); setSelected(null); await load()}, 'Change case deleted.')}>Delete Change Case</button>
</div>}
</article>}
</section>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c
<header className='rf-card flex flex-wrap items-center justify-between gap-3 p-4'>
<h2 className='text-xl font-semibold'>{title}</h2>
<div className='flex flex-wrap items-center gap-2 text-xs font-semibold'>
{currentUser && <span className='rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-violet-700'>Angemeldet als {currentUser.username} · {currentUser.role}</span>}
{currentUser && <span className='rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-violet-700'>Signed in as {currentUser.username} · {currentUser.role}</span>}
<span className={`rounded-full border px-3 py-1 ${demoMode ? 'border-amber-300 bg-amber-50 text-amber-700' : 'border-emerald-300 bg-emerald-50 text-emerald-700'}`}>{demoMode ? 'DEMO' : 'LIVE'}</span>
<span className='rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-blue-700'>READ-ONLY</span>
<span className='rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-700'>v0.9.1</span>
Expand Down
Loading
Loading