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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: pip install -r requirements.txt

- name: Run tests
run: python -m pytest tests/ -v

- name: Verify app imports
run: python -c "from app.main import app; print('App loaded OK')"

Expand Down Expand Up @@ -58,6 +61,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Build
run: npm run build

Expand Down
44 changes: 44 additions & 0 deletions backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Alembic migration environment configuration
# See https://alembic.sqlalchemy.org/en/latest/tutorial.html

[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:///./netai.db

[post_write_hooks]

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
54 changes: 54 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Alembic environment configuration."""
from __future__ import annotations

import os
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from app.core.database_sql import Base, DATABASE_URL

# Alembic Config object, which provides access to .ini values
config = context.config

# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)

# Override the sqlalchemy.url from the environment if set
config.set_main_option("sqlalchemy.url", DATABASE_URL)

target_metadata = Base.metadata


def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
27 changes: 27 additions & 0 deletions backend/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Empty file.
42 changes: 34 additions & 8 deletions backend/app/api/routes/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@

from datetime import datetime, timezone

from fastapi import APIRouter, Body, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Query

from app.core import database as db
from app.core.auth import get_current_user
from app.core.models import Alert, AlertStats, ThreatSeverity

router = APIRouter(prefix="/api/alerts", tags=["alerts"])


@router.get("")
async def get_all_alerts():
"""Return all alerts, newest first."""
return sorted(db.alerts_db, key=lambda a: a.timestamp, reverse=True)
@router.get("", summary="List all alerts")
async def get_all_alerts(
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
limit: int = Query(default=50, ge=1, le=1000, description="Maximum records to return"),
):
"""Return paginated alerts, newest first."""
alerts = sorted(db.alerts_db, key=lambda a: a.timestamp, reverse=True)
return alerts[skip : skip + limit]
Comment on lines +15 to +22
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skip/limit are used directly for slicing without any validation. Negative values (e.g. skip=-1) will return unexpected results due to Python slice semantics, and very large limits can increase response sizes. Consider using Query(ge=0) for skip and Query(ge=1, le=...) for limit to enforce sane pagination inputs.

Copilot uses AI. Check for mistakes.


@router.get("/stats", response_model=AlertStats)
@router.get("/stats", response_model=AlertStats, summary="Get alert statistics")
async def get_alert_stats():
"""Return aggregate alert statistics."""
alerts = db.alerts_db
Expand All @@ -35,12 +40,18 @@ async def get_alert_stats():
)


@router.post("/{alert_id}/acknowledge", response_model=Alert)
@router.post(
"/{alert_id}/acknowledge",
response_model=Alert,
summary="Acknowledge an alert",
responses={401: {"description": "Not authenticated"}, 404: {"description": "Alert not found"}},
)
async def acknowledge_alert(
alert_id: str,
acknowledged_by: str = Body("noc-operator", embed=True),
_: str = Depends(get_current_user),
):
"""Acknowledge an alert."""
"""Acknowledge an alert by ID."""
for i, alert in enumerate(db.alerts_db):
if alert.id == alert_id:
db.alerts_db[i] = alert.model_copy(
Expand All @@ -52,3 +63,18 @@ async def acknowledge_alert(
)
return db.alerts_db[i]
raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found")


@router.delete(
"/{alert_id}",
status_code=204,
summary="Delete an alert",
responses={401: {"description": "Not authenticated"}, 404: {"description": "Alert not found"}},
)
async def delete_alert(alert_id: str, _: str = Depends(get_current_user)):
"""Permanently delete an alert by ID."""
for i, alert in enumerate(db.alerts_db):
if alert.id == alert_id:
db.alerts_db.pop(i)
return
raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found")
27 changes: 27 additions & 0 deletions backend/app/api/routes/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Audit log routes β€” exposes paginated config change history."""
from __future__ import annotations

from typing import List

from fastapi import APIRouter, Query

from app.core import database as db
from app.core.models import ConfigChange

router = APIRouter(prefix="/api/audit-log", tags=["audit"])


@router.get(
"",
response_model=List[ConfigChange],
summary="Retrieve paginated audit log",
description="Returns all configuration change events, newest first.",
responses={200: {"description": "Paginated audit log entries"}},
)
async def get_audit_log(
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
limit: int = Query(default=50, ge=1, le=1000, description="Maximum records to return"),
):
"""Return paginated configuration audit log."""
events = sorted(db.config_changes_db, key=lambda c: c.timestamp, reverse=True)
return events[skip : skip + limit]
Comment on lines +14 to +27
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route returns a list of ConfigChange events but does not declare a response_model, so the generated OpenAPI schema will be generic/less useful (and inconsistent with other routes that declare response models). Consider adding response_model=list[ConfigChange] (or List[ConfigChange]) and documenting 200/401 responses if applicable.

Copilot uses AI. Check for mistakes.
49 changes: 49 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""JWT authentication routes."""
from __future__ import annotations

from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm

from app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_user,
)

router = APIRouter(prefix="/api/auth", tags=["auth"])


@router.post(
"/login",
summary="Authenticate and obtain a JWT access token",
responses={401: {"description": "Invalid credentials"}},
)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
):
"""Authenticate with username/password and return a signed JWT bearer token."""
username = authenticate_user(form_data.username, form_data.password)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(
subject=username,
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": token, "token_type": "bearer"}


@router.get(
"/me",
summary="Return the currently authenticated user",
responses={401: {"description": "Not authenticated"}},
)
async def get_me(current_user: str = Depends(get_current_user)):
"""Return the username of the bearer-token owner."""
return {"username": current_user}
13 changes: 8 additions & 5 deletions backend/app/api/routes/bgp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query

router = APIRouter(prefix="/api/bgp", tags=["bgp"])

Expand Down Expand Up @@ -120,10 +120,13 @@ def _dt(hours_ago: float = 0) -> str:
]


@router.get("/sessions")
async def get_bgp_sessions() -> List[Dict[str, Any]]:
"""List all BGP sessions."""
return _BGP_SESSIONS
@router.get("/sessions", summary="List BGP sessions")
async def get_bgp_sessions(
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
limit: int = Query(default=50, ge=1, le=1000, description="Maximum records to return"),
) -> List[Dict[str, Any]]:
"""List all BGP sessions (paginated)."""
return _BGP_SESSIONS[skip : skip + limit]


@router.get("/hijacks")
Expand Down
13 changes: 8 additions & 5 deletions backend/app/api/routes/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from typing import Any, Dict, List

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query

router = APIRouter(prefix="/api/circuits", tags=["circuits"])

Expand Down Expand Up @@ -95,10 +95,13 @@
]


@router.get("")
async def get_circuits() -> List[Dict[str, Any]]:
"""List all WAN/NTTN/ISP circuits."""
return _CIRCUITS
@router.get("", summary="List all circuits")
async def get_circuits(
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
limit: int = Query(default=50, ge=1, le=1000, description="Maximum records to return"),
) -> List[Dict[str, Any]]:
"""List all WAN/NTTN/ISP circuits (paginated)."""
return _CIRCUITS[skip : skip + limit]


@router.get("/{circuit_id}")
Expand Down
Loading
Loading