From b554bb40da773b783267b523e23ab199a5a57bbe Mon Sep 17 00:00:00 2001
From: DeepZone
Date: Wed, 20 May 2026 23:45:49 +0100
Subject: [PATCH] v0.8.1-beta stabilization: workflow tests, UX polish, upgrade
safety
---
README.md | 6 ++--
RELEASE_NOTES.md | 32 +++++++++++++++++++--
ROADMAP.md | 10 +++----
backend/app/api/routes_change_cases.py | 8 +++++-
backend/app/api/routes_system.py | 2 +-
backend/app/core/system_status.py | 10 ++++++-
backend/app/main.py | 2 +-
backend/pyproject.toml | 2 +-
backend/tests/test_api_smoke.py | 2 +-
backend/tests/test_watch_mode.py | 28 ++++++++++++++++++
docs/operations/upgrades.md | 8 ++++++
frontend/package.json | 2 +-
frontend/src/App.tsx | 8 +++---
frontend/src/components/ChangeCasesView.tsx | 3 +-
frontend/src/components/Layout.tsx | 2 +-
frontend/src/components/WatchModeView.tsx | 10 +++++--
16 files changed, 109 insertions(+), 26 deletions(-)
diff --git a/README.md b/README.md
index 667418d..a0541e6 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -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' && }
+ {active === 'about' && }
}
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.
: | Title | Status | Owner | Created | Updated |
{items.map(i=>setSelected(i)}>| {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.
: | Title | Status | Owner | Created | Updated |
{items.map(i=>setSelected(i)}>| {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
-
| created_at | previous_status | status | changed | summary | report_id |
{runs.map(r=>| {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.
:
| created_at | previous_status | status | changed | summary | report_id |
{runs.map(r=>| {r.created_at} | {r.previous_status ?? 'n/a'} | {r.status} | {String(r.changed)} | {r.summary} | {r.report_id ? {r.report_id} : 'n/a'} |
)}
}
}