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: 2 additions & 2 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.6.1--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.6.3--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.6.2-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.3-beta**.

## Quickstart with Docker Compose

Expand Down
13 changes: 13 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,16 @@ Key capabilities in v0.2.x:
- Export & sharing (Summary, Markdown, HTML)
- ASN-RPKI batch availability explanations
- Demo mode and CI-backed test workflows

## v0.6.3-beta

**Migration UX & Auth Visibility Hotfix**

### Highlights

- Improved visibility for database migration status.
- Added clearer troubleshooting for missing `created_by_user_id` columns after v0.6 upgrade.
- Added logged-in user and role display in the UI.
- Added visible logout action.
- Improved migration warnings in Dashboard/System views.
- Improved handling of stale database schemas.
27 changes: 17 additions & 10 deletions backend/app/api/routes_checks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session

from app.core.auth import require_operator_or_admin
Expand Down Expand Up @@ -60,13 +61,19 @@ def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db


def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict, user_id: int | None = None) -> CheckResponse:
check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id)
db.add(check)
db.commit()
db.refresh(check)
report_json, md, html = render_report({"check_id": check.id, "input_check_type": ctype, **result})
report = Report(check_id=check.id, created_by_user_id=user_id, json_data=report_json, markdown=md, html=html)
db.add(report)
db.commit()
db.refresh(report)
return CheckResponse(report_id=report.id, status=result["status"], summary=result["summary"], explanation=result.get("explanation"), risk=result.get("risk"), recommendations=result["recommendations"], input=result.get("input"), checks=result.get("checks"), details=result["details"], markdown=md, html=html)
try:
check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id)
db.add(check)
db.commit()
db.refresh(check)
report_json, md, html = render_report({"check_id": check.id, "input_check_type": ctype, **result})
report = Report(check_id=check.id, created_by_user_id=user_id, json_data=report_json, markdown=md, html=html)
db.add(report)
db.commit()
db.refresh(report)
return CheckResponse(report_id=report.id, status=result["status"], summary=result["summary"], explanation=result.get("explanation"), risk=result.get("risk"), recommendations=result["recommendations"], input=result.get("input"), checks=result.get("checks"), details=result["details"], markdown=md, html=html)
except OperationalError as exc:
db.rollback()
if "no column named created_by_user_id" in str(exc).lower():
raise HTTPException(status_code=503, detail="Database schema is not up to date. Please run migrations: docker compose exec backend alembic upgrade head") from exc
raise
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.6.2-beta',
'version': 'v0.6.3-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
31 changes: 27 additions & 4 deletions backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import text
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine

from app.config import settings
Expand Down Expand Up @@ -73,8 +73,23 @@ def _migration_snapshot(engine: Engine) -> dict:
payload["migration_status"] = "up_to_date"
else:
payload["migration_status"] = "behind"
except Exception:
except Exception as exc:
payload["migration_status"] = "unknown"
payload["migration_message"] = _safe_error_message(exc)
try:
with engine.connect() as connection:
inspector = inspect(connection)
tables = inspector.get_table_names()
has_alembic_version = "alembic_version" in tables
if not has_alembic_version and tables:
payload["schema_version"] = "unknown"
if payload["migration_head"] != "unknown":
payload["migration_status"] = "behind"
payload["migration_message"] = "alembic_version table is missing while database tables exist"
else:
payload["migration_status"] = "unknown"
except Exception:
pass
return payload


Expand Down Expand Up @@ -114,14 +129,21 @@ def _security_warnings() -> list[str]:


def build_system_status(engine: Engine | None) -> dict:
database = get_database_status(engine)
operational_warnings: list[str] = []
if database.get("migration_status") != "up_to_date":
operational_warnings.append(
"Database schema is behind the application version. Run database migrations before using checks."
)

return {
"status": "ok",
"name": settings.app_name,
"version": "v0.6.2-beta",
"version": "v0.6.3-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
"database": get_database_status(engine),
"database": database,
"api_proxy": {"status": "ok", "mode": "same-origin", "frontend_proxy_expected": True},
"ripestat": {
"cache_ttl_seconds": settings.cache_ttl_seconds,
Expand All @@ -131,6 +153,7 @@ def build_system_status(engine: Engine | None) -> dict:
"use_stale_cache_on_error": settings.ripestat_use_stale_cache_on_error,
},
"security_warnings": _security_warnings(),
"operational_warnings": operational_warnings,
"features": {
"asn_check": True,
"prefix_check": True,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")

app = FastAPI(title="RouteForge", version="0.6.2")
app = FastAPI(title="RouteForge", version="0.6.3")

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.6.2"
version = "0.6.3"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
Expand Down
33 changes: 32 additions & 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.6.2-beta'
assert payload.get('version') == 'v0.6.3-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 Expand Up @@ -247,3 +247,34 @@ def test_asn_check_allowed_for_operator() -> None:
assert client.post('/api/auth/login', json={'username': 'operator1', 'password': 'OperatorPass123!'}).status_code == 200
response = client.post('/api/check/asn', json={'asn': 'AS3320'})
assert response.status_code == 200


def test_system_status_warns_when_migration_behind(monkeypatch) -> None:
from app.core import system_status as ss

monkeypatch.setattr(ss, "get_database_status", lambda _engine: {"status": "ok", "migration_status": "behind", "schema_version": "0001", "migration_head": "0002"})
payload = ss.build_system_status(None)
assert payload.get("database", {}).get("migration_status") == "behind"
assert payload.get("operational_warnings")


def test_check_store_operational_error_message() -> None:
from sqlalchemy.exc import OperationalError
from app.api import routes_checks as rc

class FakeDB:
def add(self, _):
return None
def commit(self):
raise OperationalError("insert", {}, Exception("no column named created_by_user_id"))
def refresh(self, _):
return None
def rollback(self):
return None

try:
rc._store_and_respond(FakeDB(), "asn", "AS3320", None, {"status": "OK", "summary": "ok", "recommendations": [], "details": {}}, 1)
assert False
except Exception as exc:
assert getattr(exc, "status_code", None) == 503
assert "Database schema is not up to date" in str(getattr(exc, "detail", ""))
31 changes: 31 additions & 0 deletions docs/operations/database-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,34 @@ alembic stamp 0001_initial_schema
## Auto migration
`ROUTEFORGE_AUTO_MIGRATE` is not enabled by default in this beta.
Run migrations manually before production upgrades.

## Troubleshooting: missing `created_by_user_id` after v0.6 upgrade

Fehlerbild:

`sqlite3.OperationalError: table checks has no column named created_by_user_id`

Ursache:

Die Migration `0002_users_and_report_ownership` wurde auf einer bestehenden Datenbank nicht ausgeführt.

Fix:

```bash
docker compose exec backend alembic current
docker compose exec backend alembic heads
docker compose exec backend alembic upgrade head
```

Wenn bestehende Pre-Alembic DB:

```bash
docker compose exec backend alembic stamp 0001_initial_schema
docker compose exec backend alembic upgrade head
```

Vorher Backup:

```bash
docker compose exec backend sh -c 'cp /app/data/routeforge.db /app/data/routeforge.db.bak.$(date +%s)'
```
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.6.2",
"version": "0.6.3",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
43 changes: 18 additions & 25 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ApiError, getMe, getReportHtml, getReportMarkdown, getReportSummary, getReports, getSetupRequired, getSystemInfo, getSystemStatus, login, setupAdmin } from './api'
import { ApiError, getMe, getReportHtml, getReportMarkdown, getReportSummary, getReports, getSetupRequired, getSystemInfo, getSystemStatus, login, logout, setupAdmin } from './api'
import { AsnCheckForm } from './components/AsnCheckForm'
import { Layout } from './components/Layout'
import { LoginView } from './components/LoginView'
Expand All @@ -20,17 +20,14 @@ export default function App() {
const [system, setSystem] = useState<SystemInfo | null>(null)
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [systemStatusError, setSystemStatusError] = useState('')
const [currentUser, setCurrentUser] = useState<{ username: string; role: string } | null>(null)

const loadAppData = () => {
getReports().then(setReports).catch(() => setReports([]))
getSystemInfo().then(setSystem).catch(() => null)
getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch((err: unknown) => {
if (err instanceof ApiError && err.status === 401) {
setAuthMode('login'); setAuthError('Your session has expired. Please log in again.'); return
}
if (err instanceof ApiError && err.status === 403) {
setSystemStatusError('You do not have permission to perform this action.'); return
}
if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); setAuthError('Your session has expired. Please log in again.'); return }
if (err instanceof ApiError && err.status === 403) { setSystemStatusError('You do not have permission to perform this action.'); return }
setSystemStatusError('System status could not be loaded.')
})
}
Expand All @@ -39,29 +36,24 @@ export default function App() {
setAuthMode('loading'); setAuthError('')
try {
const setup = await getSetupRequired()
if (setup.setup_required) { setAuthMode('setup'); return }
if (setup.setup_required) { setAuthMode('setup'); setCurrentUser(null); return }
try {
await getMe()
setAuthMode('app')
loadAppData()
const me = await getMe()
setCurrentUser({ username: me.user.username, role: me.user.role })
setAuthMode('app'); loadAppData()
} catch (err: unknown) {
if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); return }
if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); setCurrentUser(null); return }
setAuthMode('error'); setAuthError('Authentication state could not be loaded.')
}
} catch {
setAuthMode('error'); setAuthError('Setup state could not be loaded.')
}
} catch { setAuthMode('error'); setAuthError('Setup state could not be loaded.') }
}

useEffect(() => { bootstrapAuth() }, [])
const handleLogout = async () => { await logout(); setCurrentUser(null); setAuthMode('login') }

const onSetupSubmit = async (payload: { username: string; email?: string; password: string; password_confirm: string }) => {
setAuthError('')
try {
const res = await setupAdmin(payload)
if (res.user) { await bootstrapAuth(); return }
setAuthMode('login')
} catch (err: unknown) { setAuthError(err instanceof Error ? err.message : 'Setup failed') }
try { const res = await setupAdmin(payload); if (res.user) { await bootstrapAuth(); return }; setAuthMode('login') } catch (err: unknown) { setAuthError(err instanceof Error ? err.message : 'Setup failed') }
}
const onLoginSubmit = async (username: string, password: string) => {
setAuthError('')
Expand All @@ -73,18 +65,19 @@ 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.6.2-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.6.3-beta · read-only preflight checks'
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active]
const proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'
const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error'

return <Layout active={active} onNav={setActive} systemLine={systemLine} title={title} demoMode={Boolean(system?.demo_mode)}>
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.6.2-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></section>}
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.6.3-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>{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 === 'asn' && <AsnCheckForm />}
{active === 'prefix' && <PrefixCheckForm />}
{active === 'preflight' && <PreflightCheckForm />}
{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 === 'system' && <section className='space-y-3'>{systemStatusError && <article className='rf-card p-4 text-rose-700'>{systemStatusError}</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: <StatusBadge status={migrationStatus === 'up_to_date' ? 'OK' : migrationStatus === 'behind' ? 'WARNING' : migrationStatus === 'error' ? 'CRITICAL' : 'UNKNOWN'} /></div></article>}</section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.6.2-beta</p></section>}
{active === '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 === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.6.3-beta</p></section>}
</Layout>
}
Loading
Loading