diff --git a/README.md b/README.md index 667418d..a0541e6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -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 @@ -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. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f58bdf8..26f9351 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. @@ -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. @@ -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** diff --git a/ROADMAP.md b/ROADMAP.md index c1522f5..64f32b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/app/api/routes_change_cases.py b/backend/app/api/routes_change_cases.py index 7eee137..fd55a7b 100644 --- a/backend/app/api/routes_change_cases.py +++ b/backend/app/api/routes_change_cases.py @@ -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] diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index 6ce345d..591c688 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -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'], diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 0f8a9fa..c71168e 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -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, diff --git a/backend/app/main.py b/backend/app/main.py index c222244..0191601 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c7d986b..cacd797 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index 8b3bc7f..5819815 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -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 diff --git a/backend/tests/test_watch_mode.py b/backend/tests/test_watch_mode.py index 66088b3..e1f86d7 100644 --- a/backend/tests/test_watch_mode.py +++ b/backend/tests/test_watch_mode.py @@ -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 diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md index 1c5ffe1..f0db106 100644 --- a/docs/operations/upgrades.md +++ b/docs/operations/upgrades.md @@ -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 ` only when schema and migration baseline match. +- Recommended diagnostics sequence: + - `alembic current` + - `alembic heads` + - `alembic upgrade head` diff --git a/frontend/package.json b/frontend/package.json index 9e46031..020693a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 475434f..4d02e9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,7 +74,7 @@ export default function App() { if (authMode === 'login') return if (authMode === 'error') return

{authError}
- 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' @@ -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 - {active === 'dashboard' &&

RouteForge v0.8.0-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} + {active === 'dashboard' &&

RouteForge v0.8.1-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge. Run: alembic current, alembic heads, alembic upgrade head.
}
} {!canAccess(active) &&
You do not have permission to access this section.
} {active === 'asn' && canAccess('asn') && } {active === 'prefix' && canAccess('prefix') && } @@ -99,9 +99,9 @@ export default function App() { {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} {active === 'watch-mode' && canAccess('watch-mode') && } {active === 'change-cases' && canAccess('change-cases') && } - {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} + {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge. Run: alembic current, alembic heads, alembic upgrade head.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
DB Current Revision: {systemStatus.database?.schema_version || 'unknown'}
DB Head Revision: {systemStatus.database?.migration_head || 'unknown'}
}
} {active === 'users' && canAccess('users') && } {active === 'audit' && canAccess('audit') && } - {active === 'about' &&

Version: v0.8.0-beta

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

Version: v0.8.1-beta

}
} diff --git a/frontend/src/components/ChangeCasesView.tsx b/frontend/src/components/ChangeCasesView.tsx index 49080e2..d25d284 100644 --- a/frontend/src/components/ChangeCasesView.tsx +++ b/frontend/src/components/ChangeCasesView.tsx @@ -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 } @@ -62,7 +63,7 @@ export function ChangeCasesView({ role }: { role: UserRole }) { } - {loading ?
Loading…
: items.length===0 ?
No change cases yet.
: {items.map(i=>setSelected(i)}>)}
TitleStatusOwnerCreatedUpdated
{i.title}{i.status}{i.created_by_user_id ?? '—'}{new Date(i.created_at).toLocaleString()}{new Date(i.updated_at).toLocaleString()}
} + {loading ?
Loading change cases…
: items.length===0 ?
No change cases yet. Create one to attach checks and reports.
: {items.map(i=>setSelected(i)}>)}
TitleStatusOwnerCreatedUpdated
{i.title}{i.created_by_user_id ?? '—'}{new Date(i.created_at).toLocaleString()}{new Date(i.updated_at).toLocaleString()}
} {selected &&
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bb736be..c9d3e7e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -48,7 +48,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c {currentUser && Angemeldet als {currentUser.username} · {currentUser.role}} {demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.8.0-beta + v0.8.1-beta
diff --git a/frontend/src/components/WatchModeView.tsx b/frontend/src/components/WatchModeView.tsx index 6117eff..8bda428 100644 --- a/frontend/src/components/WatchModeView.tsx +++ b/frontend/src/components/WatchModeView.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { createWatchTarget, deleteWatchTarget, getWatchTargetRuns, listWatchTargets, runWatchTarget, updateWatchTarget } from '../api' +import { createWatchTarget, deleteWatchTarget, getWatchTargetRuns, listWatchTargets, runDueWatchTargets, runWatchTarget, updateWatchTarget } from '../api' import type { UserRole, WatchRun, WatchTarget } from '../types' type WatchType = 'asn' | 'prefix' | 'bgp_visibility' | 'roa_preflight' @@ -49,6 +49,7 @@ export function WatchModeView({ role }: { role: UserRole }) { const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) + const [runDueSummary, setRunDueSummary] = useState(null) const canEdit = role !== 'viewer' @@ -70,7 +71,7 @@ export function WatchModeView({ role }: { role: UserRole }) { useEffect(() => { loadTargets() }, []) useEffect(() => { if (!selected) return - getWatchTargetRuns(selected.id).then(setRuns).catch((e) => setError(e instanceof Error ? e.message : 'Failed to load runs')) + getWatchTargetRuns(selected.id).then((data)=>setRuns([...data].sort((a,b)=>new Date(b.created_at).getTime()-new Date(a.created_at).getTime()))).catch((e) => setError(e instanceof Error ? e.message : 'Failed to load runs')) }, [selected?.id]) const typeHints = useMemo(() => ({ @@ -115,12 +116,15 @@ export function WatchModeView({ role }: { role: UserRole }) { return

Watch Mode

{!canEdit &&
Viewer role: read-only access. Create/Edit/Delete/Run actions are disabled.
} + {canEdit && } + {runDueSummary &&
{runDueSummary}
} {loading &&
Loading targets…
} {error &&
{error}
} {success &&
{success}
}
+ {targets.length === 0 &&
No watch targets yet. Create one to start scheduled monitoring.
} {targets.map(t => )}
{selected &&
@@ -150,7 +154,7 @@ export function WatchModeView({ role }: { role: UserRole }) {
}

Runs History

- {runs.map(r=>)}
created_atprevious_statusstatuschangedsummaryreport_id
{r.created_at}{r.previous_status ?? 'n/a'}{r.status}{String(r.changed)}{r.summary}{r.report_id ?? 'n/a'}
+ {runs.length === 0 ?
No runs yet for this watch target.
: {runs.map(r=>)}
created_atprevious_statusstatuschangedsummaryreport_id
{r.created_at}{r.previous_status ?? 'n/a'}{r.status}{String(r.changed)}{r.summary}{r.report_id ? {r.report_id} : 'n/a'}
}
}