diff --git a/README.md b/README.md index 07df3f5..667418d 100644 --- a/README.md +++ b/README.md @@ -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.7.2-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.8.0-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.7.2-beta) +## BGP Visibility Details (v0.8.0-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 876c89e..da62d14 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -## v0.7.2-beta: BGP Visibility Details +## v0.8.0-beta: BGP Visibility Details ### Motivation Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model. diff --git a/ROADMAP.md b/ROADMAP.md index db71ec3..c1522f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,16 +1,16 @@ # RouteForge Roadmap ## Current Status -v0.7.2-beta, BGP Visibility Details completed, read-only +v0.8.0-beta, BGP Visibility Details completed, read-only -## v0.7.2-beta +## v0.8.0-beta - projects/change cases - grouped preflight reports -## v0.7.2-beta +## v0.8.0-beta - bgp visibility details -## v0.7.2-beta +## v0.8.0-beta - roa planner / roa preflight ## v0.8.0-beta diff --git a/backend/alembic/versions/0004_watch_mode.py b/backend/alembic/versions/0004_watch_mode.py new file mode 100644 index 0000000..7152621 --- /dev/null +++ b/backend/alembic/versions/0004_watch_mode.py @@ -0,0 +1,55 @@ +"""watch mode + +Revision ID: 0004_watch_mode +Revises: 0003_change_cases +Create Date: 2026-05-20 +""" +from alembic import op +import sqlalchemy as sa + +revision = '0004_watch_mode' +down_revision = '0003_change_cases' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'watch_targets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('watch_type', sa.String(length=30), nullable=False), + sa.Column('prefix', sa.String(length=120), nullable=True), + sa.Column('asn', sa.String(length=20), nullable=True), + sa.Column('origin_as', sa.String(length=20), nullable=True), + sa.Column('expected_origin_as', sa.String(length=20), nullable=True), + sa.Column('max_length', sa.Integer(), nullable=True), + sa.Column('interval_minutes', sa.Integer(), nullable=False, server_default='60'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('change_case_id', sa.Integer(), sa.ForeignKey('change_cases.id'), nullable=True), + sa.Column('created_by_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('last_run_at', sa.DateTime(), nullable=True), + sa.Column('next_run_at', sa.DateTime(), nullable=True), + sa.Column('last_status', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + ) + op.create_index(op.f('ix_watch_targets_id'), 'watch_targets', ['id']) + + op.create_table( + 'watch_runs', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('watch_target_id', sa.Integer(), sa.ForeignKey('watch_targets.id'), nullable=False), + sa.Column('report_id', sa.Integer(), sa.ForeignKey('reports.id'), nullable=True), + sa.Column('previous_status', sa.String(length=20), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('changed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + ) + op.create_index(op.f('ix_watch_runs_id'), 'watch_runs', ['id']) + +def downgrade() -> None: + op.drop_index(op.f('ix_watch_runs_id'), table_name='watch_runs') + op.drop_table('watch_runs') + op.drop_index(op.f('ix_watch_targets_id'), table_name='watch_targets') + op.drop_table('watch_targets') diff --git a/backend/app/api/routes_watch.py b/backend/app/api/routes_watch.py new file mode 100644 index 0000000..7721114 --- /dev/null +++ b/backend/app/api/routes_watch.py @@ -0,0 +1,77 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.auth import require_authenticated_user, require_operator_or_admin +from app.core.audit import write_audit_log +from app.database import get_db +from app.models import ChangeCase, WatchRun, WatchTarget +from app.schemas import WatchRunRead, WatchTargetCreate, WatchTargetRead, WatchTargetUpdate +from app.services.watch_service import WatchService + +router = APIRouter(prefix='/api/watch-targets', tags=['watch']) + +@router.get('', response_model=list[WatchTargetRead]) +def list_targets(db: Session = Depends(get_db), user=Depends(require_authenticated_user)): + return db.query(WatchTarget).order_by(WatchTarget.created_at.desc()).all() + +@router.post('', response_model=WatchTargetRead) +def create_target(payload: WatchTargetCreate, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)): + if payload.change_case_id is not None and not db.query(ChangeCase).filter(ChangeCase.id == payload.change_case_id).first(): + raise HTTPException(status_code=404, detail='Change Case not found') + target = WatchTarget(**payload.model_dump(), created_by_user_id=user.id) + db.add(target); db.commit(); db.refresh(target) + write_audit_log(db, user_id=user.id, action='watch_target_created', target_type='watch_target', target_id=str(target.id), details_json={'watch_type': target.watch_type}) + return target + +@router.get('/{target_id}', response_model=WatchTargetRead) +def get_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_authenticated_user)): + t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first() + if not t: raise HTTPException(status_code=404, detail='Watch target not found') + return t + +@router.patch('/{target_id}', response_model=WatchTargetRead) +def patch_target(target_id:int,payload:WatchTargetUpdate,db:Session=Depends(get_db),user=Depends(require_operator_or_admin)): + t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first() + if not t: raise HTTPException(status_code=404, detail='Watch target not found') + for k,v in payload.model_dump(exclude_unset=True).items(): setattr(t,k,v) + db.commit(); db.refresh(t) + write_audit_log(db, user_id=user.id, action='watch_target_updated', target_type='watch_target', target_id=str(t.id), details_json={}) + return t + +@router.delete('/{target_id}') +def delete_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_operator_or_admin)): + t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first() + if not t: raise HTTPException(status_code=404, detail='Watch target not found') + db.delete(t); db.commit() + write_audit_log(db, user_id=user.id, action='watch_target_deleted', target_type='watch_target', target_id=str(target_id), details_json={}) + return {'ok': True} + +@router.get('/{target_id}/runs', response_model=list[WatchRunRead]) +def list_runs(target_id:int, db:Session=Depends(get_db), user=Depends(require_authenticated_user)): + return db.query(WatchRun).filter(WatchRun.watch_target_id==target_id).order_by(WatchRun.created_at.desc()).all() + +@router.post('/{target_id}/run', response_model=WatchRunRead) +def run_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_operator_or_admin)): + t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first() + if not t: raise HTTPException(status_code=404, detail='Watch target not found') + run=WatchService(db).run_target(t, user.id) + write_audit_log(db, user_id=user.id, action='watch_target_run', target_type='watch_target', target_id=str(target_id), details_json={'run_id': run.id}) + if run.changed: write_audit_log(db, user_id=user.id, action='watch_target_status_changed', target_type='watch_target', target_id=str(target_id), details_json={'previous_status': run.previous_status, 'status': run.status}) + return run + +@router.post('/run-due') +def run_due(db:Session=Depends(get_db), user=Depends(require_operator_or_admin)): + now=datetime.utcnow(); targets=db.query(WatchTarget).filter(WatchTarget.is_active==True).all() + due=[t for t in targets if t.next_run_at is None or t.next_run_at <= now] + results=[]; changed=0; failed=0 + for t in due: + try: + run=WatchService(db).run_target(t, user.id) + results.append({'watch_target_id': t.id, 'run_id': run.id, 'status': run.status, 'changed': run.changed}) + if run.changed: changed += 1 + except Exception as exc: + failed += 1; results.append({'watch_target_id': t.id, 'error': str(exc)}) + write_audit_log(db, user_id=user.id, action='watch_targets_run_due', target_type='watch_target', target_id=None, details_json={'executed': len(due), 'changed': changed, 'failed': failed}) + return {'executed': len(due), 'changed': changed, 'failed': failed, 'results': results} diff --git a/backend/app/main.py b/backend/app/main.py index 002bbb0..c222244 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.api.routes_reports import router as reports_router from app.api.routes_system import router as system_router from app.api.routes_users import router as users_router +from app.api.routes_watch import router as watch_router from app.config import settings from app.core.system_status import database_type_from_url from app.database import Base, engine @@ -19,7 +20,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.7.1") +app = FastAPI(title="RouteForge", version="0.8.0") app.add_middleware( CORSMiddleware, @@ -56,3 +57,5 @@ def startup() -> None: app.include_router(auth_router) app.include_router(users_router) app.include_router(audit_router) + +app.include_router(watch_router) diff --git a/backend/app/models.py b/backend/app/models.py index 3409fc5..512606c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -78,3 +78,36 @@ class AuditLog(Base): user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + +class WatchTarget(Base): + __tablename__ = "watch_targets" + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + watch_type: Mapped[str] = mapped_column(String(30), nullable=False) + prefix: Mapped[str | None] = mapped_column(String(120), nullable=True) + asn: Mapped[str | None] = mapped_column(String(20), nullable=True) + origin_as: Mapped[str | None] = mapped_column(String(20), nullable=True) + expected_origin_as: Mapped[str | None] = mapped_column(String(20), nullable=True) + max_length: Mapped[int | None] = mapped_column(Integer, nullable=True) + interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + change_case_id: Mapped[int | None] = mapped_column(ForeignKey("change_cases.id"), nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class WatchRun(Base): + __tablename__ = "watch_runs" + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + watch_target_id: Mapped[int] = mapped_column(ForeignKey("watch_targets.id"), nullable=False) + report_id: Mapped[int | None] = mapped_column(ForeignKey("reports.id"), nullable=True) + previous_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False) + changed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + summary: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2394182..e99d592 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -153,3 +153,60 @@ class ReportRead(BaseModel): class Config: from_attributes = True + + +class WatchTargetCreate(BaseModel): + name: str + watch_type: str + prefix: str | None = None + asn: str | None = None + origin_as: str | None = None + expected_origin_as: str | None = None + max_length: int | None = None + interval_minutes: int = Field(default=60, ge=1) + is_active: bool = True + change_case_id: int | None = None + +class WatchTargetUpdate(BaseModel): + name: str | None = None + prefix: str | None = None + asn: str | None = None + origin_as: str | None = None + expected_origin_as: str | None = None + max_length: int | None = None + interval_minutes: int | None = Field(default=None, ge=1) + is_active: bool | None = None + change_case_id: int | None = None + +class WatchTargetRead(BaseModel): + id: int + name: str + watch_type: str + prefix: str | None + asn: str | None + origin_as: str | None + expected_origin_as: str | None + max_length: int | None + interval_minutes: int + is_active: bool + change_case_id: int | None + created_by_user_id: int | None + last_run_at: datetime | None + next_run_at: datetime | None + last_status: str | None + created_at: datetime + updated_at: datetime + class Config: + from_attributes = True + +class WatchRunRead(BaseModel): + id: int + watch_target_id: int + report_id: int | None + previous_status: str | None + status: str + changed: bool + summary: str + created_at: datetime + class Config: + from_attributes = True diff --git a/backend/app/services/watch_service.py b/backend/app/services/watch_service.py new file mode 100644 index 0000000..ac77da1 --- /dev/null +++ b/backend/app/services/watch_service.py @@ -0,0 +1,52 @@ +from datetime import datetime, timedelta + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models import ChangeCase, Check, Report, WatchRun, WatchTarget +from app.services.asn_checker import AsnChecker +from app.services.bgp_visibility_service import BgpVisibilityService +from app.services.prefix_checker import PrefixChecker +from app.services.report_renderer import render_report +from app.services.roa_planner_service import RoaPlannerService +from app.services.ripe_stat_client import RipeStatClient + + +class WatchService: + def __init__(self, db: Session): + self.db = db + + def run_target(self, target: WatchTarget, user_id: int | None = None) -> WatchRun: + result, ctype, resource, origin = self._execute_check(target) + check = Check(check_type=ctype, input_resource=resource, origin_as=origin, status=result["status"], summary=result["summary"], created_by_user_id=user_id, change_case_id=target.change_case_id) + self.db.add(check) + self.db.commit() + self.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) + self.db.add(report) + self.db.commit() + self.db.refresh(report) + + prev = target.last_status + changed = prev is not None and prev != result["status"] + run = WatchRun(watch_target_id=target.id, report_id=report.id, previous_status=prev, status=result["status"], changed=changed, summary=result["summary"]) + self.db.add(run) + target.last_status = result["status"] + target.last_run_at = datetime.utcnow() + target.next_run_at = target.last_run_at + timedelta(minutes=target.interval_minutes) + self.db.commit() + self.db.refresh(run) + return run + + def _execute_check(self, target: WatchTarget): + client = RipeStatClient(self.db) + if target.watch_type == "bgp_visibility": + return BgpVisibilityService(client).check(target.prefix or "", target.expected_origin_as), "bgp-visibility", target.prefix or "", target.expected_origin_as + if target.watch_type == "roa_preflight": + return RoaPlannerService(client).check(target.prefix or "", target.origin_as or "", target.max_length), "roa-preflight", target.prefix or "", target.origin_as + if target.watch_type == "prefix": + return PrefixChecker(client).check(target.prefix or "", target.origin_as), "prefix", target.prefix or "", target.origin_as + if target.watch_type == "asn": + return AsnChecker(client).check(target.asn or ""), "asn", target.asn or "", None + raise HTTPException(status_code=400, detail="Unsupported watch_type") diff --git a/backend/tests/test_watch_mode.py b/backend/tests/test_watch_mode.py new file mode 100644 index 0000000..66088b3 --- /dev/null +++ b/backend/tests/test_watch_mode.py @@ -0,0 +1,35 @@ +import importlib +from fastapi.testclient import TestClient +import app.config as config + + +def _client() -> TestClient: + config.settings = config.Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=True) + import app.main as main_module + import app.database as database + importlib.reload(main_module) + database.Base.metadata.drop_all(bind=database.engine) + database.Base.metadata.create_all(bind=database.engine) + return TestClient(main_module.app) + +def _setup(client, u='admin', p='AdminPass123!'): + r=client.post('/api/auth/setup', json={'username':u,'email':'a@b.c','password':p,'password_confirm':p}) + if r.status_code==403: + assert client.post('/api/auth/login', json={'username':u,'password':p}).status_code==200 + +def test_watch_create_and_run_and_run_due(): + c=_client(); _setup(c) + resp=c.post('/api/watch-targets', json={'name':'t1','watch_type':'prefix','prefix':'192.0.2.0/24','interval_minutes':60,'is_active':True}) + assert resp.status_code==200 + tid=resp.json()['id'] + assert c.get('/api/watch-targets').status_code==200 + run=c.post(f'/api/watch-targets/{tid}/run'); assert run.status_code==200 + assert run.json()['changed'] is False + due=c.post('/api/watch-targets/run-due'); assert due.status_code==200 + +def test_viewer_readonly_watch(): + c=_client(); _setup(c) + assert c.post('/api/users', json={'username':'viewer','email':'v@e.c','password':'ViewerPass123!','role':'viewer'}).status_code==200 + 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a1bed3..475434f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,9 @@ import { StatusBadge } from './components/StatusBadge' import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types' import { UsersView } from './components/UsersView' import { ChangeCasesView } from './components/ChangeCasesView' +import { WatchModeView } from './components/WatchModeView' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'watch-mode' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -73,8 +74,8 @@ 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.7.2-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', 'change-cases': 'Change Cases', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] + 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 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' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' @@ -83,12 +84,12 @@ export default function App() { const canAccess = (view: NavKey) => { if (role === 'admin') return true if (role === 'operator') return !['users', 'audit'].includes(view) - return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', 'roa-planner', 'about'].includes(view) + return ['dashboard', 'reports', 'watch-mode', 'change-cases', 'bgp-visibility', 'roa-planner', 'about'].includes(view) } 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.7.2-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.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.
}
} {!canAccess(active) &&
You do not have permission to access this section.
} {active === 'asn' && canAccess('asn') && } {active === 'prefix' && canAccess('prefix') && } @@ -96,10 +97,11 @@ export default function App() { {active === 'bgp-visibility' && canAccess('bgp-visibility') && } {active === 'roa-planner' && canAccess('roa-planner') && } {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 === 'users' && canAccess('users') && } {active === 'audit' && canAccess('audit') && } - {active === 'about' &&

Version: v0.7.2-beta

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

Version: v0.8.0-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0f67040..910941f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AuditLogEntry, ChangeCaseItem, CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' +import type { AuditLogEntry, ChangeCaseItem, CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload, WatchRun, WatchTarget } from './types' const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -88,3 +88,11 @@ export const runPreflightCheck = (prefix: string, planned_origin_as: string, cha export const runBgpVisibilityCheck = (prefix: string, expected_origin_as?: string, change_case_id?: number) => requestJson(apiUrl('/api/check/bgp-visibility'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, expected_origin_as: expected_origin_as || null, change_case_id: change_case_id ?? null }) }) export const runRoaPreflightCheck = (prefix: string, origin_as: string, max_length?: number, change_case_id?: number) => requestJson(apiUrl('/api/check/roa-preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, origin_as, max_length: max_length ?? null, change_case_id: change_case_id ?? null }) }) + +export const listWatchTargets = () => requestJson(apiUrl('/api/watch-targets'), { method: 'GET' }) +export const createWatchTarget = (payload: any) => requestJson(apiUrl('/api/watch-targets'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +export const updateWatchTarget = (id: number, payload: any) => requestJson(apiUrl(`/api/watch-targets/${id}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +export const deleteWatchTarget = (id: number) => requestJson<{ok:boolean}>(apiUrl(`/api/watch-targets/${id}`), { method: 'DELETE' }) +export const getWatchTargetRuns = (id: number) => requestJson(apiUrl(`/api/watch-targets/${id}/runs`), { method: 'GET' }) +export const runWatchTarget = (id: number) => requestJson(apiUrl(`/api/watch-targets/${id}/run`), { method: 'POST' }) +export const runDueWatchTargets = () => requestJson(apiUrl('/api/watch-targets/run-due'), { method: 'POST' }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6655186..bb736be 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' import type { UserRole } from '../types' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'watch-mode' | 'change-cases' | 'system' | 'users' | 'audit' | 'about' const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'dashboard', label: 'Dashboard', desc: 'Overview & quick actions' }, @@ -11,6 +11,7 @@ const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'roa-planner', label: 'ROA Planner', desc: 'ROA preflight & suggestions' }, { key: 'bgp-visibility', label: 'BGP Visibility', desc: 'Detailed external BGP visibility' }, { key: 'reports', label: 'Reports', desc: 'History and outcomes' }, + { key: 'watch-mode', label: 'Watch Mode', desc: 'Scheduled rechecks' }, { key: 'change-cases', label: 'Change Cases', desc: 'Project-bound routing cases' }, { key: 'system', label: 'System', desc: 'Operational checks' }, { key: 'users', label: 'Users', desc: 'Admin user management' }, @@ -23,7 +24,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c if (!currentUser) return ['dashboard', 'about'].includes(item.key) if (currentUser.role === 'admin') return true if (currentUser.role === 'operator') return !['users', 'audit'].includes(item.key) - return ['dashboard', 'reports', 'change-cases', 'bgp-visibility', 'roa-planner', 'about'].includes(item.key) + return ['dashboard', 'reports', 'watch-mode', 'change-cases', 'bgp-visibility', 'roa-planner', 'about'].includes(item.key) }) return
@@ -47,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.7.2-beta + v0.8.0-beta
diff --git a/frontend/src/components/WatchModeView.tsx b/frontend/src/components/WatchModeView.tsx new file mode 100644 index 0000000..ce3750f --- /dev/null +++ b/frontend/src/components/WatchModeView.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react' +import { createWatchTarget, deleteWatchTarget, getWatchTargetRuns, listWatchTargets, runWatchTarget, updateWatchTarget } from '../api' +import type { UserRole, WatchRun, WatchTarget } from '../types' + +export function WatchModeView({ role }: { role: UserRole }) { + const [targets, setTargets] = useState([]) + const [selected, setSelected] = useState(null) + const [runs, setRuns] = useState([]) + const canEdit = role !== 'viewer' + const load = () => listWatchTargets().then(setTargets) + useEffect(() => { load() }, []) + useEffect(() => { if (selected) getWatchTargetRuns(selected.id).then(setRuns) }, [selected?.id]) + return

Watch Mode

{targets.map(t => )}
{selected &&
{selected.name}
{canEdit && }{canEdit && }{canEdit && }{runs.map(r=>)}
{r.created_at}{r.previous_status}{r.status}{String(r.changed)}
}
{canEdit && }
+} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7d6b5bb..168f4ec 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -135,3 +135,7 @@ export type AuditLogEntry = { export type ChangeCaseStatus = 'draft' | 'in_review' | 'approved' | 'closed' export type ChangeCaseItem = { id:number; title:string; description?:string|null; status:ChangeCaseStatus; created_by_user_id?:number|null; created_at:string; updated_at:string } + + +export type WatchTarget = { id:number; name:string; watch_type:string; prefix?:string|null; asn?:string|null; origin_as?:string|null; expected_origin_as?:string|null; max_length?:number|null; interval_minutes:number; is_active:boolean; change_case_id?:number|null; created_by_user_id?:number|null; last_run_at?:string|null; next_run_at?:string|null; last_status?:string|null; created_at:string; updated_at:string } +export type WatchRun = { id:number; watch_target_id:number; report_id?:number|null; previous_status?:string|null; status:string; changed:boolean; summary:string; created_at:string }