diff --git a/README.md b/README.md index 20d12fc..58e036e 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.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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 31f748e..49e1113 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py index 694df19..b45faae 100644 --- a/backend/app/api/routes_checks.py +++ b/backend/app/api/routes_checks.py @@ -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 @@ -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 diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index dc471bd..cc4e7f3 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.6.2-beta', + 'version': 'v0.6.3-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 6dd837e..9d03244 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -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 @@ -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 @@ -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, @@ -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, diff --git a/backend/app/main.py b/backend/app/main.py index 55cbbbc..86167e0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e055520..ac11b34 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index ea5f301..ac45309 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.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 @@ -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", "")) diff --git a/docs/operations/database-migrations.md b/docs/operations/database-migrations.md index a653d48..cf4173e 100644 --- a/docs/operations/database-migrations.md +++ b/docs/operations/database-migrations.md @@ -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)' +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5679618..ae2f0ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "routeforge-frontend", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "routeforge-frontend", - "version": "0.6.2", + "version": "0.6.3", "license": "AGPL-3.0-or-later", "dependencies": { "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index b86f6a8..5bbbad7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07e7bce..7d5a4f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' @@ -20,17 +20,14 @@ export default function App() { const [system, setSystem] = useState(null) const [systemStatus, setSystemStatus] = useState(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.') }) } @@ -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('') @@ -73,18 +65,19 @@ 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.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 - {active === 'dashboard' &&

RouteForge v0.6.2-beta

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

} + return + {active === 'dashboard' &&

RouteForge v0.6.3-beta

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

{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} {active === 'asn' && } {active === 'prefix' && } {active === 'preflight' && } {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} - {active === 'system' &&
{systemStatusError &&
{systemStatusError}
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status:
}
} - {active === 'about' &&

Version: v0.6.2-beta

} + {active === '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 === 'about' &&

Version: v0.6.3-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 88bf68a..546ab20 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -25,7 +25,8 @@ async function requestJson(url: string, options: RequestInit): Promise { const detail = typeof parsedBody === 'object' && parsedBody !== null && 'detail' in parsedBody ? String((parsedBody as { detail: unknown }).detail) : response.statusText || 'Unbekannter API-Fehler' - throw new ApiError(`HTTP ${response.status}: ${detail}`, response.status, parsedBody) + const friendly = detail.includes('Database schema is not up to date') ? 'Database migration required. Please run alembic upgrade head.' : detail + throw new ApiError(`HTTP ${response.status}: ${friendly}`, response.status, parsedBody) } return parsedBody as T } @@ -57,3 +58,4 @@ export const getSetupRequired = () => requestJson<{ setup_required: boolean }>(a export const getMe = () => requestJson<{ user: { id: number; username: string; email?: string; role: string } }>(apiUrl('/api/auth/me'), { method: 'GET' }) export const setupAdmin = (payload: { username: string; email?: string; password: string; password_confirm: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/setup'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) export const login = (payload: { username: string; password: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +export const logout = () => requestJson<{ ok: boolean }>(apiUrl('/api/auth/logout'), { method: 'POST' }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6aec8f3..c9eed16 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,7 +12,7 @@ const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'about', label: 'About', desc: 'Data sources and limits' }, ] -export function Layout({ children, active, onNav, systemLine, title, demoMode }: { children: ReactNode; active: NavKey; onNav: (key: NavKey) => void; systemLine: string; title: string; demoMode: boolean }) { +export function Layout({ children, active, onNav, systemLine, title, demoMode, currentUser, onLogout }: { children: ReactNode; active: NavKey; onNav: (key: NavKey) => void; systemLine: string; title: string; demoMode: boolean; currentUser?: { username: string; role: string } | null; onLogout: () => void }) { return