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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.7.1--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.8.1--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down Expand Up @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external

## Current Alpha Status

RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.8.0-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.8.1-beta**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -276,6 +276,6 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap
- Keep `SECRET_KEY` stable; changing it invalidates existing sessions.


## BGP Visibility Details (v0.8.0-beta)
## BGP Visibility Details (v0.8.1-beta)
- Read-only BGP visibility validation for prefix and optional expected origin AS.
- Uses external RIPEstat visibility data; results are momentary snapshots and do not replace continuous monitoring.
32 changes: 30 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## v0.8.0-beta hotfix: Watch Mode UX
## v0.8.1-beta hotfix: Watch Mode UX

### Motivation
Make Watch Mode usable in production by replacing placeholder target creation with full create/edit UX.
Expand All @@ -16,7 +16,7 @@ Make Watch Mode usable in production by replacing placeholder target creation wi
### Known Limitations
- Form validation remains primarily API-driven; frontend currently forwards server-side validation errors.

## v0.8.0-beta: BGP Visibility Details
## v0.8.1-beta: BGP Visibility Details

### Motivation
Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model.
Expand Down Expand Up @@ -53,6 +53,34 @@ BGP visibility checks remain read-only and do not modify RIPE DB, RPKI objects,

# Release Notes

## v0.8.1-beta

**Stabilization, UX Polish & Upgrade Safety**

### Motivation
- Harden end-to-end operator workflows without introducing major new features.
- Improve UI clarity for loading/error/empty states and watch/change-case day-2 operations.
- Improve migration visibility and upgrade safety for selfhosted environments.

### Implemented Changes
- Added backend E2E workflow test coverage for Change Case → BGP Visibility → ROA Preflight → Watch Run → Report/Audit validation.
- Improved Change Case report ordering to newest-first.
- Improved Watch Mode UX with run-due action, summary feedback, newest-first run history, changed-run highlighting, and report links.
- Improved system migration guidance to show actionable Alembic command hints.
- Bumped versions and docs to `v0.8.1-beta` / `0.8.1`.

### Upgrade Safety Notes
- System status now emphasizes migration command sequence when DB revisions are behind.
- Added explicit SQLite/dev-mode note about `create_all` vs Alembic stamping order in upgrade operations docs.

### Testing
- `cd backend && pytest -q`
- `cd frontend && npm run build`

### Known Limitations
- `Base.metadata.create_all()` remains intentionally active only for SQLite/dev compatibility.
- Some UI areas still use free-form filter fields where predefined presets may be added later.

## v0.7.0-beta

**Audit Log UI & Session Hardening**
Expand Down
10 changes: 5 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# RouteForge Roadmap

## Current Status
v0.8.0-beta, BGP Visibility Details completed, read-only
v0.8.1-beta, BGP Visibility Details completed, read-only

## v0.8.0-beta
## v0.8.1-beta
- projects/change cases
- grouped preflight reports

## v0.8.0-beta
## v0.8.1-beta
- bgp visibility details

## v0.8.0-beta
## v0.8.1-beta
- roa planner / roa preflight

## v0.8.0-beta
## v0.8.1-beta
- watch mode / scheduled rechecks

## v0.9.0-rc
Expand Down
8 changes: 7 additions & 1 deletion backend/app/api/routes_change_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,11 @@ def delete_change_case(change_case_id: int, db: Session = Depends(get_db), user=
def list_change_case_reports(change_case_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))):
cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
if not cc: raise HTTPException(status_code=404, detail='Change Case not found')
rows = db.query(Report, Check).join(Check, Report.check_id == Check.id).filter(Check.change_case_id == change_case_id).all()
rows = (
db.query(Report, Check)
.join(Check, Report.check_id == Check.id)
.filter(Check.change_case_id == change_case_id)
.order_by(Report.created_at.desc())
.all()
)
return [{'report_id': r.id, 'check_id': c.id, 'check_type': c.check_type, 'summary': c.summary, 'status': c.status, 'created_at': r.created_at.isoformat()} for r, c in rows]
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.7.2-beta',
'version': 'v0.8.1-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
10 changes: 9 additions & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,19 @@ def build_system_status(engine: Engine | None) -> dict:
operational_warnings.append(
"Database schema is behind the application version. Run database migrations before using checks."
)
if database.get("migration_status") == "behind":
operational_warnings.extend(
[
"Run: alembic current",
"Run: alembic heads",
"Run: alembic upgrade head",
]
)

return {
"status": "ok",
"name": settings.app_name,
"version": "v0.7.1-beta",
"version": "v0.8.1-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")

app = FastAPI(title="RouteForge", version="0.8.0")
app = FastAPI(title="RouteForge", version="0.8.1")

app.add_middleware(
CORSMiddleware,
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
version = "0.7.1"
version = "0.8.1"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
assert payload.get('version') == 'v0.7.1-beta'
assert payload.get('version') == 'v0.8.1-beta'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
Expand Down
28 changes: 28 additions & 0 deletions backend/tests/test_watch_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,31 @@ def test_viewer_readonly_watch():
c.post('/api/auth/logout'); assert c.post('/api/auth/login', json={'username':'viewer','password':'ViewerPass123!'}).status_code==200
assert c.get('/api/watch-targets').status_code==200
assert c.post('/api/watch-targets', json={'name':'x','watch_type':'asn','asn':'AS3320'}).status_code==403


def test_end_to_end_change_case_workflow_with_audit_and_reports():
c=_client(); _setup(c)
cc=c.post('/api/change-cases', json={'title':'E2E Case','description':'workflow'}); assert cc.status_code==200
cc_id=cc.json()['id']

bgp=c.post('/api/check/bgp-visibility', json={'prefix':'192.0.2.0/24','expected_origin_as':'AS3320','change_case_id':cc_id}); assert bgp.status_code==200
roa=c.post('/api/check/roa-preflight', json={'prefix':'192.0.2.0/24','origin_as':'AS3320','max_length':24,'change_case_id':cc_id}); assert roa.status_code==200

wt=c.post('/api/watch-targets', json={'name':'e2e-watch','watch_type':'prefix','prefix':'192.0.2.0/24','interval_minutes':60,'is_active':True,'change_case_id':cc_id}); assert wt.status_code==200
tid=wt.json()['id']
run=c.post(f'/api/watch-targets/{tid}/run'); assert run.status_code==200

runs=c.get(f'/api/watch-targets/{tid}/runs'); assert runs.status_code==200
assert len(runs.json()) >= 1
assert runs.json()[0].get('id')

reports=c.get(f'/api/change-cases/{cc_id}/reports'); assert reports.status_code==200
report_rows=reports.json()
assert len(report_rows) >= 2
assert any(r.get('check_type') == 'bgp-visibility' for r in report_rows)
assert any(r.get('check_type') == 'roa-preflight' for r in report_rows)

audit=c.get('/api/audit-log?limit=500'); assert audit.status_code==200
actions={item.get('action') for item in audit.json().get('items', [])}
for expected in ['change_case_created','bgp_visibility_checked','roa_preflight_checked','watch_target_created','watch_target_run','report_generated','report_attached_to_change_case']:
assert expected in actions
8 changes: 8 additions & 0 deletions docs/operations/upgrades.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ docker compose -f docker-compose.prod.yml run --rm backend alembic stamp 0001_in
```

- Then run normal upgrades (`alembic upgrade head`).

## SQLite / dev mode note
- In SQLite dev mode, `Base.metadata.create_all()` can create tables before Alembic revision stamping.
- If `/api/system/status` shows tables but unknown/behind migration state, first verify schema, then use `alembic stamp <revision>` only when schema and migration baseline match.
- Recommended diagnostics sequence:
- `alembic current`
- `alembic heads`
- `alembic upgrade head`
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
"version": "0.7.1",
"version": "0.8.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default function App() {
if (authMode === 'login') return <LoginView onSubmit={onLoginSubmit} error={authError} />
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.8.0-beta · read-only preflight checks'
const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.8.1-beta · 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 proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'
Expand All @@ -89,7 +89,7 @@ export default function App() {
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.'

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.8.0-beta</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.</article>}</section>}
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.8.1-beta</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>}
{!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 />}
Expand All @@ -99,9 +99,9 @@ export default function App() {
{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 === '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.</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></article>}</section>}
{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.8.0-beta</p></section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.8.1-beta</p></section>}
</Layout>
}
3 changes: 2 additions & 1 deletion frontend/src/components/ChangeCasesView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runPrefixCheck, runPreflightCheck, runRoaPreflightCheck, updateChangeCase } from '../api'
import type { ChangeCaseItem, UserRole } from '../types'
import { StatusBadge } from './StatusBadge'

type ChangeCaseReport = { report_id:number; check_id:number; check_type:string; summary:string; status:string; created_at:string }

Expand Down Expand Up @@ -62,7 +63,7 @@ export function ChangeCasesView({ role }: { role: UserRole }) {
</div>
</article>}

{loading ? <div>Loading…</div> : items.length===0 ? <div className='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>{i.status}</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. 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>}

{selected && <article className='border rounded p-3 space-y-3'>
<div className='flex justify-between items-center'>
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 @@ -48,7 +48,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c
{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>}
<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.8.0-beta</span>
<span className='rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-700'>v0.8.1-beta</span>
<button className='rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-rose-700 hover:bg-rose-100' onClick={onLogout}>Logout</button>
</div>
</header>
Expand Down
Loading
Loading