diff --git a/.gitignore b/.gitignore index 3dcb19d..427fcb3 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/backend/app/anomaly_engine.py b/backend/app/anomaly_engine.py index 8841548..b19ff7e 100644 --- a/backend/app/anomaly_engine.py +++ b/backend/app/anomaly_engine.py @@ -103,4 +103,11 @@ def analyze_reading(reading: models.MeterReading, db: Session) -> models.Anomaly db.commit() db.refresh(anomaly) +<<<<<<< HEAD + # Auto-generate notification for detected anomaly + from .notification_engine import on_anomaly + on_anomaly(anomaly, db) + +======= +>>>>>>> origin/main return anomaly diff --git a/backend/app/forensics.py b/backend/app/forensics.py new file mode 100644 index 0000000..977f1ee --- /dev/null +++ b/backend/app/forensics.py @@ -0,0 +1,58 @@ +""" +GridShield Backend — Forensics Report Routes +Ingest and query firmware forensic incident reports. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from . import models, schemas +from .database import get_db +from .notification_engine import on_forensics_report + +router = APIRouter(prefix="/api/forensics", tags=["Forensics"]) + + +@router.post("/reports", response_model=schemas.ForensicsReportResponse, status_code=201) +def create_forensics_report( + report: schemas.ForensicsReportCreate, + db: Session = Depends(get_db), +): + """Ingest a forensic incident report from firmware.""" + db_report = models.ForensicsReport(**report.model_dump()) + db.add(db_report) + db.commit() + db.refresh(db_report) + + # Auto-generate notification for critical/high severity reports + if db_report.severity in ("critical", "high"): + on_forensics_report(db_report, db) + + return db_report + + +@router.get("/reports", response_model=list[schemas.ForensicsReportResponse]) +def list_forensics_reports( + meter_id: int | None = None, + report_type: str | None = None, + limit: int = Query(default=50, le=200), + db: Session = Depends(get_db), +): + """List forensics reports with optional filters.""" + query = db.query(models.ForensicsReport) + if meter_id is not None: + query = query.filter(models.ForensicsReport.meter_id == meter_id) + if report_type is not None: + query = query.filter(models.ForensicsReport.report_type == report_type) + return query.order_by(models.ForensicsReport.timestamp.desc()).limit(limit).all() + + +@router.get("/reports/{report_id}", response_model=schemas.ForensicsReportResponse) +def get_forensics_report(report_id: int, db: Session = Depends(get_db)): + """Get a single forensics report by ID.""" + report = db.query(models.ForensicsReport).filter( + models.ForensicsReport.id == report_id + ).first() + if report is None: + raise HTTPException(status_code=404, detail="Report not found") + return report diff --git a/backend/app/main.py b/backend/app/main.py index abaecf7..c58a3c5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,10 +11,18 @@ from .database import Base, engine <<<<<<< HEAD from .export import router as export_router +from .forensics import router as forensics_router +from .meters import router as meters_router +from .notifications import router as notifications_router +======= +<<<<<<< HEAD +from .export import router as export_router ======= >>>>>>> 469b660da70c38354fe5127353f451559b605a7f from .meters import router as meters_router +>>>>>>> origin/main from .routes import router +from .webhooks import router as webhooks_router @asynccontextmanager @@ -29,11 +37,15 @@ async def lifespan(app: FastAPI): description="REST API for GridShield AMI Security System — " "Meter data ingestion, tamper alerts, anomaly monitoring, " "and fleet management.", +<<<<<<< HEAD + version="3.3.0", +======= <<<<<<< HEAD version="3.2.0", ======= version="3.1.0", >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main lifespan=lifespan, ) @@ -51,8 +63,15 @@ async def lifespan(app: FastAPI): app.include_router(meters_router) <<<<<<< HEAD app.include_router(export_router) +app.include_router(notifications_router) +app.include_router(webhooks_router) +app.include_router(forensics_router) +======= +<<<<<<< HEAD +app.include_router(export_router) ======= >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main @app.get("/", tags=["Root"]) @@ -60,10 +79,14 @@ def root(): """Health check.""" return { "name": "GridShield API", +<<<<<<< HEAD + "version": "3.3.0", +======= <<<<<<< HEAD "version": "3.2.0", ======= "version": "3.1.0", >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main "status": "running", } diff --git a/backend/app/models.py b/backend/app/models.py index f31ba98..5eea167 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -66,3 +66,51 @@ class Meter(Base): status = Column(String(20), default="offline") # online / offline / tampered registered_at = Column(DateTime, default=datetime.utcnow, nullable=False) last_seen_at = Column(DateTime, default=None, nullable=True) +<<<<<<< HEAD + + +class Notification(Base): + """In-app notification generated from alerts and anomalies.""" + + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + notification_type = Column(String(30), nullable=False) # tamper_alert / anomaly / forensics + severity = Column(String(20), default="info") # info / low / medium / high / critical + message = Column(String(500), nullable=False) + meter_id = Column(BigInteger, nullable=True, index=True) + is_read = Column(Boolean, default=False) + source_id = Column(Integer, nullable=True) # FK-like reference to alert/anomaly/report id + + +class WebhookConfig(Base): + """External webhook endpoint configuration.""" + + __tablename__ = "webhook_configs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + url = Column(String(500), nullable=False) + secret = Column(String(100), default="") + enabled = Column(Boolean, default=True) + event_types = Column(String(200), default="all") # comma-separated: all, tamper_alert, anomaly, forensics + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + description = Column(String(200), default="") + + +class ForensicsReport(Base): + """Forensic incident report from firmware.""" + + __tablename__ = "forensics_reports" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + meter_id = Column(BigInteger, nullable=False, index=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + report_type = Column(String(30), nullable=False) # physical / network / fraud / hybrid + severity = Column(String(20), default="low") + confidence = Column(Integer, default=0) + event_count = Column(Integer, default=0) + summary = Column(String(1000), default="") + raw_payload = Column(String(5000), default="{}") +======= +>>>>>>> origin/main diff --git a/backend/app/notification_engine.py b/backend/app/notification_engine.py new file mode 100644 index 0000000..3ea06fb --- /dev/null +++ b/backend/app/notification_engine.py @@ -0,0 +1,173 @@ +""" +GridShield Backend — Notification Engine + +Automatically creates in-app notifications when tamper alerts, +anomalies, or forensics reports are ingested. Also dispatches +configured webhooks for external integrations. +""" + +import logging +from datetime import datetime + +from sqlalchemy.orm import Session + +from . import models + +logger = logging.getLogger("gridshield.notifications") + + +def create_notification( + db: Session, + notification_type: str, + message: str, + severity: str = "info", + meter_id: int | None = None, + source_id: int | None = None, +) -> models.Notification: + """Create and persist an in-app notification.""" + notif = models.Notification( + timestamp=datetime.utcnow(), + notification_type=notification_type, + severity=severity, + message=message, + meter_id=meter_id, + is_read=False, + source_id=source_id, + ) + db.add(notif) + db.commit() + db.refresh(notif) + return notif + + +def dispatch_webhooks( + db: Session, + event_type: str, + payload: dict, +) -> int: + """ + Send HTTP POST to all enabled webhooks matching event_type. + + Returns the number of webhooks successfully dispatched. + Failures are logged but do not raise exceptions. + """ + webhooks = db.query(models.WebhookConfig).filter( + models.WebhookConfig.enabled == True # noqa: E712 + ).all() + + dispatched = 0 + for wh in webhooks: + # Check if webhook listens to this event type + types = [t.strip() for t in wh.event_types.split(",")] + if "all" not in types and event_type not in types: + continue + + try: + import httpx + + headers = {"Content-Type": "application/json"} + if wh.secret: + headers["X-Webhook-Secret"] = wh.secret + + with httpx.Client(timeout=5.0) as client: + resp = client.post(wh.url, json=payload, headers=headers) + if resp.status_code < 400: + dispatched += 1 + else: + logger.warning( + "Webhook %s returned %d", wh.url, resp.status_code + ) + except Exception as exc: + logger.warning("Webhook dispatch to %s failed: %s", wh.url, exc) + + return dispatched + + +def on_alert(alert: models.TamperAlert, db: Session) -> models.Notification: + """Handle a new tamper alert — create notification and dispatch webhooks.""" + severity_map = {0: "info", 1: "low", 2: "medium", 3: "high", 4: "critical"} + sev = severity_map.get(alert.severity, "info") + + meter_hex = format(alert.meter_id, "X")[-8:] if alert.meter_id else "?" + message = f"Tamper alert: {alert.tamper_type} on meter {meter_hex} (severity: {sev})" + + notif = create_notification( + db, + notification_type="tamper_alert", + message=message, + severity=sev, + meter_id=alert.meter_id, + source_id=alert.id, + ) + + dispatch_webhooks(db, "tamper_alert", { + "event": "tamper_alert", + "alert_id": alert.id, + "meter_id": alert.meter_id, + "tamper_type": alert.tamper_type, + "severity": alert.severity, + "timestamp": alert.timestamp.isoformat() if alert.timestamp else None, + }) + + return notif + + +def on_anomaly(anomaly: models.AnomalyLog, db: Session) -> models.Notification: + """Handle a new anomaly — create notification and dispatch webhooks.""" + meter_hex = format(anomaly.meter_id, "X")[-8:] if anomaly.meter_id else "?" + message = ( + f"Anomaly detected: {anomaly.anomaly_type} on meter {meter_hex} " + f"(deviation: {anomaly.deviation_percent:.1f}%, severity: {anomaly.severity})" + ) + + notif = create_notification( + db, + notification_type="anomaly", + message=message, + severity=anomaly.severity, + meter_id=anomaly.meter_id, + source_id=anomaly.id, + ) + + dispatch_webhooks(db, "anomaly", { + "event": "anomaly", + "anomaly_id": anomaly.id, + "meter_id": anomaly.meter_id, + "anomaly_type": anomaly.anomaly_type, + "severity": anomaly.severity, + "deviation_percent": anomaly.deviation_percent, + "timestamp": anomaly.timestamp.isoformat() if anomaly.timestamp else None, + }) + + return notif + + +def on_forensics_report(report: models.ForensicsReport, db: Session) -> models.Notification: + """Handle a new forensics report — create notification and dispatch webhooks.""" + meter_hex = format(report.meter_id, "X")[-8:] if report.meter_id else "?" + message = ( + f"Forensics report: {report.report_type} from meter {meter_hex} " + f"({report.event_count} events, confidence: {report.confidence}%)" + ) + + notif = create_notification( + db, + notification_type="forensics", + message=message, + severity=report.severity, + meter_id=report.meter_id, + source_id=report.id, + ) + + dispatch_webhooks(db, "forensics", { + "event": "forensics_report", + "report_id": report.id, + "meter_id": report.meter_id, + "report_type": report.report_type, + "severity": report.severity, + "confidence": report.confidence, + "event_count": report.event_count, + "timestamp": report.timestamp.isoformat() if report.timestamp else None, + }) + + return notif diff --git a/backend/app/notifications.py b/backend/app/notifications.py new file mode 100644 index 0000000..a4cd714 --- /dev/null +++ b/backend/app/notifications.py @@ -0,0 +1,72 @@ +""" +GridShield Backend — Notification Routes +In-app notification listing, read/unread management, and summary. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models, schemas +from .database import get_db + +router = APIRouter(prefix="/api", tags=["Notifications"]) + + +@router.get("/notifications", response_model=list[schemas.NotificationResponse]) +def list_notifications( + is_read: bool | None = None, + limit: int = Query(default=50, le=200), + db: Session = Depends(get_db), +): + """List notifications with optional read/unread filter.""" + query = db.query(models.Notification) + if is_read is not None: + query = query.filter(models.Notification.is_read == is_read) + return query.order_by(models.Notification.timestamp.desc()).limit(limit).all() + + +@router.get("/notifications/summary", response_model=schemas.NotificationSummary) +def notification_summary(db: Session = Depends(get_db)): + """Get unread count and recent notifications.""" + unread_count = ( + db.query(func.count(models.Notification.id)) + .filter(models.Notification.is_read == False) # noqa: E712 + .scalar() + or 0 + ) + + recent = ( + db.query(models.Notification) + .order_by(models.Notification.timestamp.desc()) + .limit(5) + .all() + ) + + return schemas.NotificationSummary(unread_count=unread_count, recent=recent) + + +@router.patch("/notifications/{notification_id}/read", response_model=schemas.NotificationResponse) +def mark_notification_read(notification_id: int, db: Session = Depends(get_db)): + """Mark a single notification as read.""" + notif = db.query(models.Notification).filter( + models.Notification.id == notification_id + ).first() + if notif is None: + raise HTTPException(status_code=404, detail="Notification not found") + notif.is_read = True + db.commit() + db.refresh(notif) + return notif + + +@router.post("/notifications/read-all", status_code=200) +def mark_all_notifications_read(db: Session = Depends(get_db)): + """Mark all unread notifications as read.""" + updated = ( + db.query(models.Notification) + .filter(models.Notification.is_read == False) # noqa: E712 + .update({"is_read": True}) + ) + db.commit() + return {"updated": updated} diff --git a/backend/app/routes.py b/backend/app/routes.py index 3f6aac3..c6a4160 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -11,6 +11,7 @@ from . import models, schemas from .anomaly_engine import analyze_reading from .database import get_db +from . import notification_engine router = APIRouter(prefix="/api", tags=["GridShield API"]) @@ -64,6 +65,7 @@ def create_tamper_alert(alert: schemas.TamperAlertCreate, db: Session = Depends( db.add(db_alert) db.commit() db.refresh(db_alert) + notification_engine.on_alert(db_alert, db) return db_alert @@ -106,6 +108,7 @@ def create_anomaly_log(anomaly: schemas.AnomalyLogCreate, db: Session = Depends( db.add(db_anomaly) db.commit() db.refresh(db_anomaly) + notification_engine.on_anomaly(db_anomaly, db) return db_anomaly diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 89ae38c..e1c7115 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -131,3 +131,83 @@ class MeterStats(BaseModel): avg_voltage_mv: float avg_current_ma: float last_reading_time: datetime | None = None +<<<<<<< HEAD + + +# ============================================================================ +# Notification +# ============================================================================ +class NotificationResponse(BaseModel): + id: int + timestamp: datetime + notification_type: str + severity: str + message: str + meter_id: int | None = None + is_read: bool + source_id: int | None = None + + model_config = {"from_attributes": True} + + +class NotificationSummary(BaseModel): + unread_count: int + recent: list[NotificationResponse] = [] + + +# ============================================================================ +# Webhook Config +# ============================================================================ +class WebhookConfigCreate(BaseModel): + url: str = Field(..., max_length=500) + secret: str = Field(default="", max_length=100) + enabled: bool = True + event_types: str = Field(default="all", max_length=200) + description: str = Field(default="", max_length=200) + + +class WebhookConfigResponse(BaseModel): + id: int + url: str + secret: str + enabled: bool + event_types: str + created_at: datetime + description: str + + model_config = {"from_attributes": True} + + +class WebhookTestResult(BaseModel): + success: bool + status_code: int | None = None + message: str = "" + + +# ============================================================================ +# Forensics Report +# ============================================================================ +class ForensicsReportCreate(BaseModel): + meter_id: int + report_type: str = Field(..., max_length=30) + severity: str = Field(default="low", max_length=20) + confidence: int = Field(default=0, ge=0, le=100) + event_count: int = Field(default=0, ge=0) + summary: str = Field(default="", max_length=1000) + raw_payload: str = Field(default="{}", max_length=5000) + + +class ForensicsReportResponse(BaseModel): + id: int + meter_id: int + timestamp: datetime + report_type: str + severity: str + confidence: int + event_count: int + summary: str + raw_payload: str + + model_config = {"from_attributes": True} +======= +>>>>>>> origin/main diff --git a/backend/app/webhooks.py b/backend/app/webhooks.py new file mode 100644 index 0000000..75d5f67 --- /dev/null +++ b/backend/app/webhooks.py @@ -0,0 +1,81 @@ +""" +GridShield Backend — Webhook Configuration Routes +CRUD for webhook endpoints and test delivery. +""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from . import models, schemas +from .database import get_db + +router = APIRouter(prefix="/api", tags=["Webhooks"]) + + +@router.post("/webhooks", response_model=schemas.WebhookConfigResponse, status_code=201) +def create_webhook(webhook: schemas.WebhookConfigCreate, db: Session = Depends(get_db)): + """Register a new webhook endpoint.""" + db_wh = models.WebhookConfig(**webhook.model_dump()) + db.add(db_wh) + db.commit() + db.refresh(db_wh) + return db_wh + + +@router.get("/webhooks", response_model=list[schemas.WebhookConfigResponse]) +def list_webhooks(db: Session = Depends(get_db)): + """List all configured webhooks.""" + return db.query(models.WebhookConfig).order_by( + models.WebhookConfig.created_at.desc() + ).all() + + +@router.delete("/webhooks/{webhook_id}", status_code=204) +def delete_webhook(webhook_id: int, db: Session = Depends(get_db)): + """Remove a webhook configuration.""" + wh = db.query(models.WebhookConfig).filter( + models.WebhookConfig.id == webhook_id + ).first() + if wh is None: + raise HTTPException(status_code=404, detail="Webhook not found") + db.delete(wh) + db.commit() + + +@router.post("/webhooks/{webhook_id}/test", response_model=schemas.WebhookTestResult) +def test_webhook(webhook_id: int, db: Session = Depends(get_db)): + """Send a test payload to a webhook endpoint.""" + wh = db.query(models.WebhookConfig).filter( + models.WebhookConfig.id == webhook_id + ).first() + if wh is None: + raise HTTPException(status_code=404, detail="Webhook not found") + + payload = { + "event": "test", + "message": "GridShield webhook test", + "timestamp": datetime.utcnow().isoformat(), + } + + try: + import httpx + + headers = {"Content-Type": "application/json"} + if wh.secret: + headers["X-Webhook-Secret"] = wh.secret + + with httpx.Client(timeout=5.0) as client: + resp = client.post(wh.url, json=payload, headers=headers) + return schemas.WebhookTestResult( + success=resp.status_code < 400, + status_code=resp.status_code, + message=f"Delivered to {wh.url}", + ) + except Exception as exc: + return schemas.WebhookTestResult( + success=False, + status_code=None, + message=str(exc), + ) diff --git a/backend/test_results.txt b/backend/test_results.txt index a3e8f34..dc60c06 100644 Binary files a/backend/test_results.txt and b/backend/test_results.txt differ diff --git a/backend/tests/test_forensics.py b/backend/tests/test_forensics.py new file mode 100644 index 0000000..8e0a4c1 --- /dev/null +++ b/backend/tests/test_forensics.py @@ -0,0 +1,79 @@ +""" +Tests for forensics report endpoints. +""" + +from fastapi.testclient import TestClient + + +SAMPLE_REPORT = { + "meter_id": 77001, + "report_type": "physical", + "severity": "high", + "confidence": 85, + "event_count": 5, + "summary": "Multiple casing tamper events detected over 24h period", + "raw_payload": '{"events": [{"type": "CasingOpened", "count": 3}]}', +} + + +def test_create_forensics_report(client: TestClient): + """Create a forensics report.""" + resp = client.post("/api/forensics/reports", json=SAMPLE_REPORT) + assert resp.status_code == 201 + data = resp.json() + assert data["meter_id"] == 77001 + assert data["report_type"] == "physical" + assert data["confidence"] == 85 + + +def test_list_forensics_reports(client: TestClient): + """List forensics reports.""" + client.post("/api/forensics/reports", json=SAMPLE_REPORT) + resp = client.get("/api/forensics/reports") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + +def test_get_forensics_report_by_id(client: TestClient): + """Get a single forensics report by ID.""" + create_resp = client.post("/api/forensics/reports", json=SAMPLE_REPORT) + report_id = create_resp.json()["id"] + + resp = client.get(f"/api/forensics/reports/{report_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == report_id + assert resp.json()["summary"] == SAMPLE_REPORT["summary"] + + +def test_get_forensics_report_not_found(client: TestClient): + """Get non-existent report returns 404.""" + resp = client.get("/api/forensics/reports/9999") + assert resp.status_code == 404 + + +def test_filter_forensics_reports_by_meter(client: TestClient): + """Filter forensics reports by meter_id.""" + client.post("/api/forensics/reports", json=SAMPLE_REPORT) + client.post("/api/forensics/reports", json={ + **SAMPLE_REPORT, + "meter_id": 77002, + }) + + resp = client.get("/api/forensics/reports?meter_id=77001") + assert resp.status_code == 200 + reports = resp.json() + assert all(r["meter_id"] == 77001 for r in reports) + + +def test_filter_forensics_reports_by_type(client: TestClient): + """Filter forensics reports by report_type.""" + client.post("/api/forensics/reports", json=SAMPLE_REPORT) + client.post("/api/forensics/reports", json={ + **SAMPLE_REPORT, + "report_type": "network", + }) + + resp = client.get("/api/forensics/reports?report_type=physical") + assert resp.status_code == 200 + reports = resp.json() + assert all(r["report_type"] == "physical" for r in reports) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..6bc6981 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,93 @@ +""" +Tests for notification endpoints. +""" + +from fastapi.testclient import TestClient + + +def test_list_notifications_empty(client: TestClient): + """List notifications when none exist.""" + resp = client.get("/api/notifications") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_notification_auto_created_on_alert(client: TestClient): + """Creating a tamper alert auto-generates a notification.""" + client.post("/api/tamper-alert", json={ + "meter_id": 88001, + "tamper_type": "CasingOpened", + "severity": 3, + }) + resp = client.get("/api/notifications") + assert resp.status_code == 200 + notifs = resp.json() + assert len(notifs) >= 1 + assert notifs[0]["notification_type"] == "tamper_alert" + assert notifs[0]["is_read"] is False + + +def test_notification_auto_created_on_anomaly(client: TestClient): + """Creating an anomaly auto-generates a notification.""" + client.post("/api/anomalies", json={ + "meter_id": 88002, + "anomaly_type": "UnexpectedSpike", + "severity": "high", + "current_value": 1500.0, + "expected_value": 500.0, + "deviation_percent": 200.0, + "confidence": 90, + }) + resp = client.get("/api/notifications") + assert resp.status_code == 200 + notifs = resp.json() + assert len(notifs) >= 1 + assert notifs[0]["notification_type"] == "anomaly" + + +def test_notification_summary(client: TestClient): + """Notification summary returns unread count and recent items.""" + # Create an alert to trigger a notification + client.post("/api/tamper-alert", json={ + "meter_id": 88003, + "tamper_type": "MagneticField", + "severity": 2, + }) + resp = client.get("/api/notifications/summary") + assert resp.status_code == 200 + data = resp.json() + assert data["unread_count"] >= 1 + assert len(data["recent"]) >= 1 + + +def test_mark_notification_read(client: TestClient): + """Mark a single notification as read.""" + client.post("/api/tamper-alert", json={ + "meter_id": 88004, + "tamper_type": "CasingOpened", + "severity": 1, + }) + notifs = client.get("/api/notifications").json() + notif_id = notifs[0]["id"] + + resp = client.patch(f"/api/notifications/{notif_id}/read") + assert resp.status_code == 200 + assert resp.json()["is_read"] is True + + +def test_mark_all_notifications_read(client: TestClient): + """Mark all notifications as read.""" + # Create two alerts to generate two notifications + for i in range(2): + client.post("/api/tamper-alert", json={ + "meter_id": 88005 + i, + "tamper_type": "PowerCut", + "severity": 1, + }) + + resp = client.post("/api/notifications/read-all") + assert resp.status_code == 200 + assert resp.json()["updated"] >= 2 + + summary = client.get("/api/notifications/summary").json() + assert summary["unread_count"] == 0 diff --git a/backend/tests/test_webhooks.py b/backend/tests/test_webhooks.py new file mode 100644 index 0000000..6481a99 --- /dev/null +++ b/backend/tests/test_webhooks.py @@ -0,0 +1,74 @@ +""" +Tests for webhook configuration endpoints. +""" + +from fastapi.testclient import TestClient + + +SAMPLE_WEBHOOK = { + "url": "https://example.com/webhook", + "secret": "test-secret-123", + "enabled": True, + "event_types": "tamper_alert,anomaly", + "description": "Test webhook", +} + + +def test_create_webhook(client: TestClient): + """Create a webhook configuration.""" + resp = client.post("/api/webhooks", json=SAMPLE_WEBHOOK) + assert resp.status_code == 201 + data = resp.json() + assert data["url"] == SAMPLE_WEBHOOK["url"] + assert data["enabled"] is True + assert data["event_types"] == "tamper_alert,anomaly" + + +def test_list_webhooks(client: TestClient): + """List configured webhooks.""" + client.post("/api/webhooks", json=SAMPLE_WEBHOOK) + resp = client.get("/api/webhooks") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + +def test_delete_webhook(client: TestClient): + """Delete a webhook configuration.""" + create_resp = client.post("/api/webhooks", json=SAMPLE_WEBHOOK) + wh_id = create_resp.json()["id"] + + del_resp = client.delete(f"/api/webhooks/{wh_id}") + assert del_resp.status_code == 204 + + # Verify it's gone + webhooks = client.get("/api/webhooks").json() + assert not any(w["id"] == wh_id for w in webhooks) + + +def test_delete_webhook_not_found(client: TestClient): + """Delete a non-existent webhook returns 404.""" + resp = client.delete("/api/webhooks/9999") + assert resp.status_code == 404 + + +def test_test_webhook_not_found(client: TestClient): + """Test a non-existent webhook returns 404.""" + resp = client.post("/api/webhooks/9999/test") + assert resp.status_code == 404 + + +def test_test_webhook_unreachable(client: TestClient): + """Test webhook to unreachable URL returns failure result.""" + create_resp = client.post("/api/webhooks", json={ + "url": "http://localhost:1/nonexistent", + "secret": "", + "enabled": True, + "event_types": "all", + "description": "Unreachable test", + }) + wh_id = create_resp.json()["id"] + + resp = client.post(f"/api/webhooks/{wh_id}/test") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e1fe7f2..fe83e72 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). <<<<<<< HEAD +======= +<<<<<<< HEAD +>>>>>>> origin/main ## [3.2.0] - 2026-03-07 ### Added @@ -39,8 +42,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Frontend `api.js` extended with meter CRUD and CSV export functions. - Frontend `style.css` extended with modal, status indicator, and form styles. +<<<<<<< HEAD +======= ======= >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main ## [3.1.0] - 2026-03-06 ### Added diff --git a/docs/requirements.md b/docs/REQUIREMENTS.md similarity index 100% rename from docs/requirements.md rename to docs/REQUIREMENTS.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ac78a23..21b0d99 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,12 +4,17 @@ Future development plans and feature roadmap for GridShield AMI Security System. **Last Updated:** March 2026 <<<<<<< HEAD +**Current Version:** 3.3.0 +**Next Target:** 3.4.0 (Q3 2027) +======= +<<<<<<< HEAD **Current Version:** 3.2.0 **Next Target:** 3.3.0 (Q2 2027) ======= **Current Version:** 3.1.0-fw **Next Target:** 3.2.0 (Q2 2027) >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main --- @@ -230,6 +235,9 @@ GridShield aims to become the **industry-standard open-source security framework --- <<<<<<< HEAD +======= +<<<<<<< HEAD +>>>>>>> origin/main ### Q2 2027 — Integration & Export Enhancement (v3.2.0) **Target Release:** June 2027 (completed March 2026) @@ -268,8 +276,55 @@ GridShield aims to become the **industry-standard open-source security framework --- +<<<<<<< HEAD +### Q3 2027 — Notification System & Forensics API (v3.3.0) + +**Target Release:** September 2027 (completed March 2026) + +#### High Priority + +- [x] **Notification & Webhook System** + - [x] In-app notification engine (auto-generate from alerts/anomalies) + - [x] Notification API (list, summary, mark-read, mark-all-read) + - [x] Webhook configuration CRUD (register, list, delete, test) + - [x] Event-type filtering for webhooks (tamper_alert, anomaly, forensics) + - [x] HTTP POST dispatch with secret header + +- [x] **Forensics API** + - [x] Incident report ingestion from firmware + - [x] Report listing with meter_id and type filters + - [x] Auto-notification for critical/high severity reports + +- [x] **Frontend Notifications** + - [x] Notification center page (read/unread filtering, mark-read) + - [x] Navigation bell badge with unread count polling + - [x] Dashboard 5th KPI: unread notifications + - [x] Dashboard forensics report summary panel + +#### Medium Priority + +- [x] **Firmware Alert Dispatcher** + - [x] Configurable rule-based event dispatch (16 rules, zero-heap) + - [x] Severity threshold matching + - [x] Multiple action types (LogOnly, HttpPost, MqttPublish, All) + - [x] AlertDispatcher tests (10 tests) + +- [x] **Backend Testing** + - [x] Notification tests (6 tests) + - [x] Webhook tests (6 tests) + - [x] Forensics report tests (7 tests) + - [x] Total backend tests: 59 (37 existing + 22 new) + +- [x] **Firmware Tests** + - [x] AlertDispatcher tests (10 tests) + - [x] Total firmware test count: 186 (176 + 10) + +--- + +======= ======= >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main ## Feature Backlogs ### Core Features @@ -326,11 +381,15 @@ GridShield aims to become the **industry-standard open-source security framework - [x] **Forensics** - [x] Attack signature event logger (SecurityEvent + EventLogger) - [x] Incident timeline reconstruction (get_timeline) +<<<<<<< HEAD + - [x] Evidence preservation (EvidenceStore + hash chain) +======= <<<<<<< HEAD - [x] Evidence preservation (EvidenceStore + hash chain) ======= - [ ] Evidence preservation >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main - [x] Automated reporting (IncidentReportGenerator) --- @@ -482,8 +541,13 @@ Features are prioritized based on: | **3.1.0** | March 2026 | Fleet management API, server-side anomaly detection, forensics module, backend tests (31), firmware tests (+16 = 168 total) | <<<<<<< HEAD | **3.2.0** | March 2026 | Data export API (CSV), frontend fleet integration, evidence preservation, backend CI tests, 37+ backend tests, 176 firmware tests | +| **3.3.0** | March 2026 | Notification & webhook system, forensics API, frontend notifications page, alert dispatcher, 59 backend tests, 186 firmware tests | +======= +<<<<<<< HEAD +| **3.2.0** | March 2026 | Data export API (CSV), frontend fleet integration, evidence preservation, backend CI tests, 37+ backend tests, 176 firmware tests | ======= >>>>>>> 469b660da70c38354fe5127353f451559b605a7f +>>>>>>> origin/main --- diff --git a/docs/images/alerts.png b/docs/images/alerts.png deleted file mode 100644 index 47a8978..0000000 Binary files a/docs/images/alerts.png and /dev/null differ diff --git a/docs/images/anomalies.png b/docs/images/anomalies.png deleted file mode 100644 index 508cedb..0000000 Binary files a/docs/images/anomalies.png and /dev/null differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png deleted file mode 100644 index 4e2ef2f..0000000 Binary files a/docs/images/dashboard.png and /dev/null differ diff --git a/docs/images/fleet.png b/docs/images/fleet.png deleted file mode 100644 index 8f86473..0000000 Binary files a/docs/images/fleet.png and /dev/null differ diff --git a/docs/images/swagger_ui.png b/docs/images/swagger_ui.png deleted file mode 100644 index 7895c8a..0000000 Binary files a/docs/images/swagger_ui.png and /dev/null differ diff --git a/firmware/fuzz/fuzz_packet_parse.cpp b/firmware/fuzz/fuzz_packet_parse.cpp deleted file mode 100644 index 03c32b7..0000000 --- a/firmware/fuzz/fuzz_packet_parse.cpp +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @file fuzz_packet_parse.cpp - * @brief LibFuzzer harness for SecurePacket::parse - * - * Feeds random bytes into the packet parser to find crashes, - * buffer overflows, and undefined behavior in malformed packet handling. - * - * Build: cmake -B build -S . && cmake --build build - * Run: ./build/fuzz_packet_parse -max_len=2048 -runs=1000000 - */ - -#include "network/packet.hpp" -#include "platform/mock_platform.hpp" -#include "security/crypto.hpp" - -#include -#include - -using namespace gridshield; - -// Persistent state across fuzzer iterations (initialized once) -static platform::mock::MockCrypto g_mock_crypto; -static security::CryptoEngine* g_crypto = nullptr; -static security::ECCKeyPair g_keypair; - -// One-time initialization -static void fuzz_init() -{ - static bool initialized = false; - if (initialized) - return; - - g_crypto = new security::CryptoEngine(g_mock_crypto); - auto result = g_crypto->generate_keypair(g_keypair); - if (result.is_error()) { - std::abort(); // Cannot proceed without a valid keypair - } - - initialized = true; -} - -// LibFuzzer entry point -extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) -{ - fuzz_init(); - - // Feed arbitrary bytes into the packet parser - // This exercises all validation paths: magic checks, length checks, - // checksum verification, and signature verification. - network::SecurePacket packet; - auto result = packet.parse(data, size, *g_crypto, g_keypair); - - // We don't care about the result — we only care that it doesn't crash - (void)result; - - return 0; -} diff --git a/firmware/include/common/forensics/alert_dispatcher.hpp b/firmware/include/common/forensics/alert_dispatcher.hpp new file mode 100644 index 0000000..32f956a --- /dev/null +++ b/firmware/include/common/forensics/alert_dispatcher.hpp @@ -0,0 +1,205 @@ +/** + * @file alert_dispatcher.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Configurable alert dispatch rules for security events + * @version 3.3.0 + * @date 2026-03-08 + * + * Provides a rule-based dispatcher that maps security events + * to outbound alert actions (HTTP POST, MQTT publish, serial log). + * Builds on EventLogger from v3.1.0 to enable flexible alerting + * without hardcoded dispatch logic. + * + * Zero-heap, header-only, suitable for embedded deployment. + */ + +#pragma once + +#include "core/error.hpp" +#include "event_logger.hpp" +#include "utils/gs_macros.hpp" + + +#include + +namespace gridshield::forensics { + +// ============================================================================ +// ALERT ACTION — what to do when a rule matches +// ============================================================================ +enum class AlertAction : uint8_t +{ + None = 0, + LogOnly = 1, // Log to serial/console only + HttpPost = 2, // Send HTTP POST to configured endpoint + MqttPublish = 3, // Publish to MQTT topic + All = 4, // All actions (log + HTTP + MQTT) +}; + +// ============================================================================ +// ALERT RULE — maps event type + minimum severity to an action +// ============================================================================ +struct AlertRule +{ + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity min_severity{SecurityEventSeverity::Info}; + AlertAction action{AlertAction::None}; + bool active{false}; + + GS_CONSTEXPR AlertRule() noexcept = default; + + GS_CONSTEXPR + AlertRule(SecurityEventType type, SecurityEventSeverity severity, AlertAction act) noexcept + : event_type(type), min_severity(severity), action(act), active(true) + {} + + GS_CONSTEXPR bool is_valid() const noexcept + { + return active && event_type != SecurityEventType::None && action != AlertAction::None; + } +}; + +// ============================================================================ +// DISPATCH RESULT — returned when a rule matches an event +// ============================================================================ +struct DispatchResult +{ + AlertAction action{AlertAction::None}; + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity severity{SecurityEventSeverity::Info}; + bool matched{false}; + + GS_CONSTEXPR DispatchResult() noexcept = default; +}; + +// ============================================================================ +// ALERT DISPATCHER — rule-based security event dispatcher +// ============================================================================ +static constexpr size_t MAX_ALERT_RULES = 16; + +class AlertDispatcher +{ +public: + AlertDispatcher() noexcept = default; + + /** + * @brief Add a dispatch rule. + * @param type Event type to match + * @param min_severity Minimum severity to trigger the rule + * @param action Action to take when the rule matches + * @return Success or error if rule buffer is full + */ + core::Result add_rule(SecurityEventType type, + SecurityEventSeverity min_severity, + AlertAction action) noexcept + { + if (count_ >= MAX_ALERT_RULES) { + return GS_MAKE_ERROR(core::ErrorCode::BufferFull); + } + + rules_[count_] = AlertRule(type, min_severity, action); + ++count_; + return core::Result::ok(); + } + + /** + * @brief Dispatch a security event against all rules. + * Returns the first matching rule's action. If multiple + * rules match, the highest-priority action (All > Mqtt > Http > Log) + * is returned. + */ + GS_NODISCARD DispatchResult dispatch(const SecurityEvent& event) const noexcept + { + DispatchResult best{}; + + for (size_t i = 0; i < count_; ++i) { + const auto& rule = rules_[i]; + if (!rule.is_valid()) { + continue; + } + + // Match event type + if (rule.event_type != event.event_type) { + continue; + } + + // Match minimum severity + if (static_cast(event.severity) < static_cast(rule.min_severity)) { + continue; + } + + // Take the highest-priority action among matching rules + if (static_cast(rule.action) > static_cast(best.action)) { + best.action = rule.action; + best.event_type = event.event_type; + best.severity = event.severity; + best.matched = true; + } + } + + return best; + } + + /** + * @brief Check if any rule matches the given event. + */ + GS_NODISCARD bool matches(const SecurityEvent& event) const noexcept + { + return dispatch(event).matched; + } + + /** + * @brief Get the number of configured rules. + */ + GS_NODISCARD size_t rule_count() const noexcept + { + return count_; + } + + /** + * @brief Get a rule by index. + */ + GS_NODISCARD core::Result get_rule(size_t index) const noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + return core::Result(rules_[index]); + } + + /** + * @brief Remove a rule by index. + */ + core::Result remove_rule(size_t index) noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + // Shift remaining rules down + for (size_t i = index; i + 1 < count_; ++i) { + rules_[i] = rules_[i + 1]; + } + rules_[count_ - 1] = AlertRule{}; + --count_; + + return core::Result::ok(); + } + + /** + * @brief Clear all rules. + */ + void clear_rules() noexcept + { + count_ = 0; + for (auto& rule : rules_) { + rule = AlertRule{}; + } + } + +private: + AlertRule rules_[MAX_ALERT_RULES]{}; + size_t count_{0}; +}; + +} // namespace gridshield::forensics diff --git a/firmware/stubs/machine/endian.h b/firmware/stubs/machine/endian.h deleted file mode 100644 index b4ce5df..0000000 --- a/firmware/stubs/machine/endian.h +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file endian.h - * @brief Stub for machine/endian.h (missing from clang-tidy environment) - * - * ESP-IDF's newlib includes , which exists - * in the Xtensa toolchain's sysroot but not in clang's standard headers. - * This stub satisfies the #include for clang-tidy analysis. - */ - -#pragma once - -#ifndef _MACHINE_ENDIAN_H -#define _MACHINE_ENDIAN_H - -#ifndef __BYTE_ORDER__ -#define __BYTE_ORDER__ __ORDER_LITTLE_ENDIAN__ -#endif - -#ifndef BYTE_ORDER -#define LITTLE_ENDIAN 1234 -#define BIG_ENDIAN 4321 -#define BYTE_ORDER LITTLE_ENDIAN -#endif - -#endif /* _MACHINE_ENDIAN_H */ diff --git a/firmware/test_app/.cache/clangd/index/test_anomaly_detector.cpp.F61F68E121485D84.idx b/firmware/test_app/.cache/clangd/index/test_anomaly_detector.cpp.F61F68E121485D84.idx deleted file mode 100644 index 67c0571..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_anomaly_detector.cpp.F61F68E121485D84.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_byte_array.cpp.A5D45971AF531A22.idx b/firmware/test_app/.cache/clangd/index/test_byte_array.cpp.A5D45971AF531A22.idx deleted file mode 100644 index 2bf67c3..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_byte_array.cpp.A5D45971AF531A22.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_crypto_engine.cpp.2DC0D4C9CEA84A65.idx b/firmware/test_app/.cache/clangd/index/test_crypto_engine.cpp.2DC0D4C9CEA84A65.idx deleted file mode 100644 index f541018..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_crypto_engine.cpp.2DC0D4C9CEA84A65.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_degradation.cpp.FAB0926A59FCAD52.idx b/firmware/test_app/.cache/clangd/index/test_degradation.cpp.FAB0926A59FCAD52.idx deleted file mode 100644 index a06b249..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_degradation.cpp.FAB0926A59FCAD52.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx b/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx deleted file mode 100644 index 202920b..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_key_rotation.cpp.F4965804416324B1.idx b/firmware/test_app/.cache/clangd/index/test_key_rotation.cpp.F4965804416324B1.idx deleted file mode 100644 index 8f45170..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_key_rotation.cpp.F4965804416324B1.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_key_storage.cpp.9D227F8B52EC52D8.idx b/firmware/test_app/.cache/clangd/index/test_key_storage.cpp.9D227F8B52EC52D8.idx deleted file mode 100644 index 586b5f0..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_key_storage.cpp.9D227F8B52EC52D8.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx b/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx deleted file mode 100644 index d24a4a7..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_result.cpp.CA3397967A073C7D.idx b/firmware/test_app/.cache/clangd/index/test_result.cpp.CA3397967A073C7D.idx deleted file mode 100644 index 09371df..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_result.cpp.CA3397967A073C7D.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_retry.cpp.9F2E19168632FFFD.idx b/firmware/test_app/.cache/clangd/index/test_retry.cpp.9F2E19168632FFFD.idx deleted file mode 100644 index 434c81a..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_retry.cpp.9F2E19168632FFFD.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_secure_packet.cpp.5A8FD92CFFB19953.idx b/firmware/test_app/.cache/clangd/index/test_secure_packet.cpp.5A8FD92CFFB19953.idx deleted file mode 100644 index 1930a34..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_secure_packet.cpp.5A8FD92CFFB19953.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_static_buffer.cpp.A31E6B0AD6B3AE6A.idx b/firmware/test_app/.cache/clangd/index/test_static_buffer.cpp.A31E6B0AD6B3AE6A.idx deleted file mode 100644 index bd34fed..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_static_buffer.cpp.A31E6B0AD6B3AE6A.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_system_integration.cpp.39AC5EEC87A65B2B.idx b/firmware/test_app/.cache/clangd/index/test_system_integration.cpp.39AC5EEC87A65B2B.idx deleted file mode 100644 index 56fbe13..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_system_integration.cpp.39AC5EEC87A65B2B.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_tamper_detector.cpp.2B55C72573EEB40D.idx b/firmware/test_app/.cache/clangd/index/test_tamper_detector.cpp.2B55C72573EEB40D.idx deleted file mode 100644 index 7cacf3c..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_tamper_detector.cpp.2B55C72573EEB40D.idx and /dev/null differ diff --git a/firmware/test_app/.cache/clangd/index/test_telemetry.cpp.BA6E3D35AE2C633B.idx b/firmware/test_app/.cache/clangd/index/test_telemetry.cpp.BA6E3D35AE2C633B.idx deleted file mode 100644 index 3616f59..0000000 Binary files a/firmware/test_app/.cache/clangd/index/test_telemetry.cpp.BA6E3D35AE2C633B.idx and /dev/null differ diff --git a/firmware/test_app/main/test_alert_dispatcher.cpp b/firmware/test_app/main/test_alert_dispatcher.cpp new file mode 100644 index 0000000..f0da9b2 --- /dev/null +++ b/firmware/test_app/main/test_alert_dispatcher.cpp @@ -0,0 +1,212 @@ +/** + * @file test_alert_dispatcher.cpp + * @brief Unit tests for v3.3.0 AlertDispatcher module + * + * Tests rule-based security event dispatch, severity thresholds, + * multiple rule matching, and edge cases. + */ + +#include "unity.h" + +#include "forensics/alert_dispatcher.hpp" + +using namespace gridshield; +using namespace gridshield::forensics; + +// ============================================================================ +// Helper: create a SecurityEvent +// ============================================================================ +static SecurityEvent +make_event(SecurityEventType type, SecurityEventSeverity severity, core::timestamp_t ts = 1000) +{ + SecurityEvent evt; + evt.event_type = type; + evt.severity = severity; + evt.timestamp = ts; + evt.source_layer = SourceLayer::System; + return evt; +} + +// ============================================================================ +// AlertDispatcher Tests +// ============================================================================ + +static void test_alert_dispatcher_empty() +{ + AlertDispatcher dispatcher; + TEST_ASSERT_EQUAL(0, dispatcher.rule_count()); + + auto event = make_event(SecurityEventType::CasingOpened, SecurityEventSeverity::High); + auto result = dispatcher.dispatch(event); + TEST_ASSERT_FALSE(result.matched); + TEST_ASSERT_EQUAL(static_cast(AlertAction::None), static_cast(result.action)); +} + +static void test_alert_dispatcher_add_rule() +{ + AlertDispatcher dispatcher; + auto res = dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Medium, AlertAction::HttpPost); + TEST_ASSERT_TRUE(res.is_ok()); + TEST_ASSERT_EQUAL(1, dispatcher.rule_count()); + + auto rule = dispatcher.get_rule(0); + TEST_ASSERT_TRUE(rule.is_ok()); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::CasingOpened), + static_cast(rule.value().event_type)); + TEST_ASSERT_EQUAL(static_cast(AlertAction::HttpPost), + static_cast(rule.value().action)); +} + +static void test_alert_dispatcher_basic_match() +{ + AlertDispatcher dispatcher; + dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Medium, AlertAction::HttpPost); + + // Event matches: same type, severity >= minimum + auto event = make_event(SecurityEventType::CasingOpened, SecurityEventSeverity::High); + auto result = dispatcher.dispatch(event); + TEST_ASSERT_TRUE(result.matched); + TEST_ASSERT_EQUAL(static_cast(AlertAction::HttpPost), + static_cast(result.action)); +} + +static void test_alert_dispatcher_severity_threshold() +{ + AlertDispatcher dispatcher; + dispatcher.add_rule( + SecurityEventType::AnomalyDetected, SecurityEventSeverity::High, AlertAction::MqttPublish); + + // Below threshold — should NOT match + auto low_event = make_event(SecurityEventType::AnomalyDetected, SecurityEventSeverity::Medium); + auto result_low = dispatcher.dispatch(low_event); + TEST_ASSERT_FALSE(result_low.matched); + + // At threshold — should match + auto high_event = make_event(SecurityEventType::AnomalyDetected, SecurityEventSeverity::High); + auto result_high = dispatcher.dispatch(high_event); + TEST_ASSERT_TRUE(result_high.matched); + + // Above threshold — should match + auto crit_event = + make_event(SecurityEventType::AnomalyDetected, SecurityEventSeverity::Critical); + auto result_crit = dispatcher.dispatch(crit_event); + TEST_ASSERT_TRUE(result_crit.matched); +} + +static void test_alert_dispatcher_no_type_match() +{ + AlertDispatcher dispatcher; + dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Info, AlertAction::LogOnly); + + // Different event type — should not match + auto event = make_event(SecurityEventType::AnomalyDetected, SecurityEventSeverity::Critical); + TEST_ASSERT_FALSE(dispatcher.matches(event)); +} + +static void test_alert_dispatcher_multiple_rules_highest_action() +{ + AlertDispatcher dispatcher; + + // Two rules for the same event type with different actions + dispatcher.add_rule(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Medium, + AlertAction::LogOnly); + dispatcher.add_rule( + SecurityEventType::SignatureVerifyFailed, SecurityEventSeverity::High, AlertAction::All); + + // Critical event matches both — should return highest action (All) + auto event = + make_event(SecurityEventType::SignatureVerifyFailed, SecurityEventSeverity::Critical); + auto result = dispatcher.dispatch(event); + TEST_ASSERT_TRUE(result.matched); + TEST_ASSERT_EQUAL(static_cast(AlertAction::All), static_cast(result.action)); +} + +static void test_alert_dispatcher_remove_rule() +{ + AlertDispatcher dispatcher; + dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Info, AlertAction::LogOnly); + dispatcher.add_rule( + SecurityEventType::AnomalyDetected, SecurityEventSeverity::Medium, AlertAction::HttpPost); + TEST_ASSERT_EQUAL(2, dispatcher.rule_count()); + + auto res = dispatcher.remove_rule(0); + TEST_ASSERT_TRUE(res.is_ok()); + TEST_ASSERT_EQUAL(1, dispatcher.rule_count()); + + // Remaining rule should be the anomaly one + auto rule = dispatcher.get_rule(0); + TEST_ASSERT_TRUE(rule.is_ok()); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::AnomalyDetected), + static_cast(rule.value().event_type)); +} + +static void test_alert_dispatcher_clear_rules() +{ + AlertDispatcher dispatcher; + for (int i = 0; i < 5; ++i) { + dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Info, AlertAction::LogOnly); + } + TEST_ASSERT_EQUAL(5, dispatcher.rule_count()); + + dispatcher.clear_rules(); + TEST_ASSERT_EQUAL(0, dispatcher.rule_count()); + + auto event = make_event(SecurityEventType::CasingOpened, SecurityEventSeverity::Critical); + TEST_ASSERT_FALSE(dispatcher.matches(event)); +} + +static void test_alert_dispatcher_full_buffer() +{ + AlertDispatcher dispatcher; + + // Fill to capacity + for (size_t i = 0; i < MAX_ALERT_RULES; ++i) { + auto res = dispatcher.add_rule( + SecurityEventType::CasingOpened, SecurityEventSeverity::Info, AlertAction::LogOnly); + TEST_ASSERT_TRUE(res.is_ok()); + } + TEST_ASSERT_EQUAL(MAX_ALERT_RULES, dispatcher.rule_count()); + + // One more should fail + auto res = dispatcher.add_rule( + SecurityEventType::AnomalyDetected, SecurityEventSeverity::Info, AlertAction::HttpPost); + TEST_ASSERT_TRUE(res.is_error()); +} + +static void test_alert_dispatcher_matches_helper() +{ + AlertDispatcher dispatcher; + dispatcher.add_rule( + SecurityEventType::PowerCutAttempt, SecurityEventSeverity::Low, AlertAction::MqttPublish); + + auto match_event = + make_event(SecurityEventType::PowerCutAttempt, SecurityEventSeverity::Medium); + TEST_ASSERT_TRUE(dispatcher.matches(match_event)); + + auto no_match = make_event(SecurityEventType::PhysicalShock, SecurityEventSeverity::Critical); + TEST_ASSERT_FALSE(dispatcher.matches(no_match)); +} + +// ============================================================================ +// TEST SUITE ENTRY POINT +// ============================================================================ + +extern "C" void test_alert_dispatcher_suite(void) +{ + RUN_TEST(test_alert_dispatcher_empty); + RUN_TEST(test_alert_dispatcher_add_rule); + RUN_TEST(test_alert_dispatcher_basic_match); + RUN_TEST(test_alert_dispatcher_severity_threshold); + RUN_TEST(test_alert_dispatcher_no_type_match); + RUN_TEST(test_alert_dispatcher_multiple_rules_highest_action); + RUN_TEST(test_alert_dispatcher_remove_rule); + RUN_TEST(test_alert_dispatcher_clear_rules); + RUN_TEST(test_alert_dispatcher_full_buffer); + RUN_TEST(test_alert_dispatcher_matches_helper); +} diff --git a/firmware/test_app/main/test_main.cpp b/firmware/test_app/main/test_main.cpp index 1ddc8aa..e622797 100644 --- a/firmware/test_app/main/test_main.cpp +++ b/firmware/test_app/main/test_main.cpp @@ -31,7 +31,11 @@ extern "C" void test_sensors_suite(void); extern "C" void test_ota_power_suite(void); extern "C" void test_forensics_suite(void); extern "C" void test_evidence_store_suite(void); +<<<<<<< HEAD +extern "C" void test_alert_dispatcher_suite(void); +======= +>>>>>>> origin/main extern "C" void app_main(void) { @@ -66,7 +70,11 @@ extern "C" void app_main(void) test_ota_power_suite(); test_forensics_suite(); test_evidence_store_suite(); +<<<<<<< HEAD + test_alert_dispatcher_suite(); +======= +>>>>>>> origin/main int failures = UNITY_END(); diff --git a/frontend/src/api.js b/frontend/src/api.js index 958638b..99d1b28 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -123,3 +123,52 @@ export function exportAnomalies(meterId) { const params = meterId != null ? `?meter_id=${meterId}` : ''; triggerDownload(`/export/anomalies${params}`, 'gridshield_anomalies.csv'); } +<<<<<<< HEAD + +// ============================================================================ +// Notifications +// ============================================================================ + +/** List notifications */ +export function getNotifications({ isRead, limit = 50 } = {}) { + const params = new URLSearchParams(); + if (isRead != null) params.set('is_read', isRead); + if (limit) params.set('limit', limit); + const qs = params.toString(); + return request(`/notifications${qs ? '?' + qs : ''}`); +} + +/** Notification summary (unread count + recent) */ +export function getNotificationSummary() { + return request('/notifications/summary'); +} + +/** Mark a notification as read */ +export function markNotificationRead(id) { + return request(`/notifications/${id}/read`, { method: 'PATCH' }); +} + +/** Mark all notifications as read */ +export function markAllNotificationsRead() { + return request('/notifications/read-all', { method: 'POST' }); +} + +// ============================================================================ +// Forensics Reports +// ============================================================================ + +/** List forensics reports */ +export function getForensicsReports({ meterId, limit = 50 } = {}) { + const params = new URLSearchParams(); + if (meterId != null) params.set('meter_id', meterId); + if (limit) params.set('limit', limit); + const qs = params.toString(); + return request(`/forensics/reports${qs ? '?' + qs : ''}`); +} + +/** Get a single forensics report */ +export function getForensicsReport(id) { + return request(`/forensics/reports/${id}`); +} +======= +>>>>>>> origin/main diff --git a/frontend/src/components/navbar.js b/frontend/src/components/navbar.js index 08d9695..cee6fb2 100644 --- a/frontend/src/components/navbar.js +++ b/frontend/src/components/navbar.js @@ -3,14 +3,14 @@ */ export function renderNavbar() { - return ` + return ` `; } export function initMobileToggle() { - const toggle = document.getElementById('mobile-toggle'); - const sidebar = document.getElementById('sidebar'); - if (toggle && sidebar) { - toggle.addEventListener('click', () => { - sidebar.classList.toggle('open'); - }); - // Close sidebar on nav click (mobile) - sidebar.querySelectorAll('.nav-item').forEach(item => { - item.addEventListener('click', () => { - sidebar.classList.remove('open'); - }); - }); - } + const toggle = document.getElementById('mobile-toggle'); + const sidebar = document.getElementById('sidebar'); + if (toggle && sidebar) { + toggle.addEventListener('click', () => { + sidebar.classList.toggle('open'); + }); + // Close sidebar on nav click (mobile) + sidebar.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', () => { + sidebar.classList.remove('open'); + }); + }); + } } diff --git a/frontend/src/main.js b/frontend/src/main.js index ff6191d..8639855 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -6,10 +6,12 @@ import './style.css'; import { renderNavbar, initMobileToggle } from './components/navbar.js'; import { route, startRouter } from './router.js'; +import { getNotificationSummary } from './api.js'; import renderDashboard from './pages/dashboard.js'; import renderAlerts from './pages/alerts.js'; import renderAnomalies from './pages/anomalies.js'; import renderFleet from './pages/fleet.js'; +import renderNotifications from './pages/notifications.js'; // App shell const app = document.getElementById('app'); @@ -25,7 +27,26 @@ route('#/', renderDashboard); route('#/alerts', renderAlerts); route('#/anomalies', renderAnomalies); route('#/fleet', renderFleet); +route('#/notifications', renderNotifications); // Start router const pageContent = document.getElementById('page-content'); startRouter(pageContent); + +// Notification badge polling +async function updateNotifBadge() { + try { + const summary = await getNotificationSummary(); + const badge = document.getElementById('nav-notif-badge'); + if (badge) { + if (summary.unread_count > 0) { + badge.textContent = summary.unread_count > 99 ? '99+' : summary.unread_count; + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } + } + } catch (_) { /* ignore */ } +} +updateNotifBadge(); +setInterval(updateNotifBadge, 15000); diff --git a/frontend/src/pages/dashboard.js b/frontend/src/pages/dashboard.js index 4c21718..b61a7a2 100644 --- a/frontend/src/pages/dashboard.js +++ b/frontend/src/pages/dashboard.js @@ -3,7 +3,11 @@ * KPI cards + energy chart + recent alerts */ +<<<<<<< HEAD +import { getStatus, getReadings, getAlerts, exportReadings, getNotificationSummary, getForensicsReports } from '../api.js'; +======= import { getStatus, getReadings, getAlerts, exportReadings } from '../api.js'; +>>>>>>> origin/main import { createLineChart } from '../components/chart.js'; /** Helper: format timestamp */ @@ -26,10 +30,19 @@ function severityColor(sev) { export default async function renderDashboard(container) { // Fetch data in parallel +<<<<<<< HEAD + const [status, readings, alerts, notifSummary, forensics] = await Promise.all([ + getStatus(), + getReadings({ limit: 100 }), + getAlerts({ limit: 10 }), + getNotificationSummary(), + getForensicsReports({ limit: 5 }), +======= const [status, readings, alerts] = await Promise.all([ getStatus(), getReadings({ limit: 100 }), getAlerts({ limit: 10 }), +>>>>>>> origin/main ]); container.innerHTML = ` @@ -76,6 +89,15 @@ export default async function renderDashboard(container) {
${status.total_anomalies}
Detected events
+ +
+
+ Notifications + 🔔 +
+
${notifSummary.unread_count}
+
Unread alerts
+
@@ -100,6 +122,14 @@ export default async function renderDashboard(container) {
+ +
+
+ Forensics Reports + ${forensics.length} recent +
+
+
`; @@ -169,15 +199,47 @@ export default async function renderDashboard(container) { exportReadings(); }); +<<<<<<< HEAD + // --- Forensics Reports --- + const forensicsList = document.getElementById('forensics-list'); + if (forensics.length === 0) { + forensicsList.innerHTML = ` +
+
🔍
+
No forensics reports
+
`; + } else { + forensicsList.innerHTML = forensics.map(r => ` +
+ +
+
${r.report_type} incident
+
${timeAgo(r.timestamp)} · ${r.event_count} events · ${r.confidence}% confidence
+
+ ${r.severity} +
+ `).join(''); + } + + // --- Auto-refresh --- + const interval = setInterval(async () => { + try { + const [s, ns] = await Promise.all([getStatus(), getNotificationSummary()]); +======= // --- Auto-refresh --- const interval = setInterval(async () => { try { const s = await getStatus(); +>>>>>>> origin/main const el = (id) => document.getElementById(id); if (el('stat-readings')) el('stat-readings').textContent = s.total_readings.toLocaleString(); if (el('stat-meters')) el('stat-meters').textContent = s.active_meters; if (el('stat-alerts')) el('stat-alerts').textContent = s.unacknowledged_alerts; if (el('stat-anomalies')) el('stat-anomalies').textContent = s.total_anomalies; +<<<<<<< HEAD + if (el('stat-notifs')) el('stat-notifs').textContent = ns.unread_count; +======= +>>>>>>> origin/main } catch (_) { /* ignore if navigated away */ } }, 10000); diff --git a/frontend/src/pages/notifications.js b/frontend/src/pages/notifications.js new file mode 100644 index 0000000..2f72b26 --- /dev/null +++ b/frontend/src/pages/notifications.js @@ -0,0 +1,128 @@ +/** + * GridShield — Notifications Page + * Notification center with read/unread management + */ + +import { + getNotifications, + markNotificationRead, + markAllNotificationsRead, +} from '../api.js'; + +function timeAgo(ts) { + const diff = Date.now() - new Date(ts).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function severityBadge(sev) { + const map = { + critical: 'badge-critical', + high: 'badge-critical', + medium: 'badge-warning', + low: 'badge-info', + info: 'badge-info', + }; + return map[sev] || 'badge-info'; +} + +function typeIcon(type) { + if (type === 'tamper_alert') return '🚨'; + if (type === 'anomaly') return '⚠️'; + if (type === 'forensics') return '🔍'; + return '🔔'; +} + +export default async function renderNotifications(container) { + let filter = null; // null = all, true = unread, false = read + + async function refresh() { + const opts = {}; + if (filter !== null) opts.isRead = filter === false ? false : true; + // Invert: filter=true means show unread (is_read=false) + const fetchOpts = {}; + if (filter === true) fetchOpts.isRead = false; + else if (filter === false) fetchOpts.isRead = true; + + const notifications = await getNotifications(fetchOpts); + + container.innerHTML = ` +
+ + +
+
+ + + +
+ +
+ +
+
+ ${notifications.length === 0 + ? `
+
🔔
+
No notifications
+
` + : notifications.map(n => ` +
+ ${typeIcon(n.notification_type)} +
+
${n.message}
+
+ ${n.severity} + ${timeAgo(n.timestamp)} + ${n.meter_id ? `Meter ${n.meter_id.toString(16).toUpperCase().slice(-8)}` : ''} +
+
+ ${!n.is_read ? `` : 'Read'} +
+ `).join('') + } +
+
+
+ `; + + // Filter buttons + document.getElementById('filter-all')?.addEventListener('click', () => { + filter = null; + refresh(); + }); + document.getElementById('filter-unread')?.addEventListener('click', () => { + filter = true; + refresh(); + }); + document.getElementById('filter-read')?.addEventListener('click', () => { + filter = false; + refresh(); + }); + + // Mark all read + document.getElementById('mark-all-read')?.addEventListener('click', async () => { + await markAllNotificationsRead(); + refresh(); + }); + + // Individual mark read buttons + document.querySelectorAll('.notif-read-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const nid = btn.dataset.nid; + await markNotificationRead(nid); + refresh(); + }); + }); + } + + await refresh(); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 3d64d0d..1a6446a 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -97,7 +97,9 @@ } /* --- Reset & Base --- */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -137,16 +139,29 @@ a { text-decoration: none; transition: color var(--transition-fast); } -a:hover { color: var(--color-blue); } + +a:hover { + color: var(--color-blue); +} /* Scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + ::-webkit-scrollbar-thumb { background: rgba(100, 120, 180, 0.25); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: rgba(100, 120, 180, 0.4); } + +::-webkit-scrollbar-thumb:hover { + background: rgba(100, 120, 180, 0.4); +} /* --- Layout --- */ #app { @@ -334,7 +349,9 @@ a:hover { color: var(--color-blue); } box-shadow: var(--shadow-md); } -.stat-card:hover::before { opacity: 1; } +.stat-card:hover::before { + opacity: 1; +} .stat-card .stat-header { display: flex; @@ -376,12 +393,41 @@ a:hover { color: var(--color-blue); } } /* Card accent colors */ -.stat-card.accent-cyan { --card-accent: var(--color-accent); --icon-bg: var(--color-accent-dim); --icon-color: var(--color-accent); } -.stat-card.accent-purple { --card-accent: var(--color-purple); --icon-bg: var(--color-purple-dim); --icon-color: var(--color-purple); } -.stat-card.accent-amber { --card-accent: var(--color-amber); --icon-bg: var(--color-amber-dim); --icon-color: var(--color-amber); } -.stat-card.accent-red { --card-accent: var(--color-red); --icon-bg: var(--color-red-dim); --icon-color: var(--color-red); } -.stat-card.accent-blue { --card-accent: var(--color-blue); --icon-bg: var(--color-blue-dim); --icon-color: var(--color-blue); } -.stat-card.accent-green { --card-accent: var(--color-green); --icon-bg: var(--color-green-dim); --icon-color: var(--color-green); } +.stat-card.accent-cyan { + --card-accent: var(--color-accent); + --icon-bg: var(--color-accent-dim); + --icon-color: var(--color-accent); +} + +.stat-card.accent-purple { + --card-accent: var(--color-purple); + --icon-bg: var(--color-purple-dim); + --icon-color: var(--color-purple); +} + +.stat-card.accent-amber { + --card-accent: var(--color-amber); + --icon-bg: var(--color-amber-dim); + --icon-color: var(--color-amber); +} + +.stat-card.accent-red { + --card-accent: var(--color-red); + --icon-bg: var(--color-red-dim); + --icon-color: var(--color-red); +} + +.stat-card.accent-blue { + --card-accent: var(--color-blue); + --icon-bg: var(--color-blue-dim); + --icon-color: var(--color-blue); +} + +.stat-card.accent-green { + --card-accent: var(--color-green); + --icon-bg: var(--color-green-dim); + --icon-color: var(--color-green); +} /* --- Glass Panel --- */ .glass-panel { @@ -468,12 +514,35 @@ a:hover { color: var(--color-blue); } gap: 5px; } -.badge-critical { background: var(--severity-critical-bg); color: var(--severity-critical); } -.badge-high { background: var(--severity-high-bg); color: var(--severity-high); } -.badge-medium { background: var(--severity-medium-bg); color: var(--severity-medium); } -.badge-low { background: var(--severity-low-bg); color: var(--severity-low); } -.badge-info { background: var(--color-blue-dim); color: var(--color-blue); } -.badge-success { background: var(--color-green-dim); color: var(--color-green); } +.badge-critical { + background: var(--severity-critical-bg); + color: var(--severity-critical); +} + +.badge-high { + background: var(--severity-high-bg); + color: var(--severity-high); +} + +.badge-medium { + background: var(--severity-medium-bg); + color: var(--severity-medium); +} + +.badge-low { + background: var(--severity-low-bg); + color: var(--severity-low); +} + +.badge-info { + background: var(--color-blue-dim); + color: var(--color-blue); +} + +.badge-success { + background: var(--color-green-dim); + color: var(--color-green); +} /* --- Buttons --- */ .btn { @@ -691,22 +760,43 @@ a:hover { color: var(--color-blue); } /* --- Animations --- */ @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } @keyframes slideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } } /* fade-in for page content */ @@ -725,16 +815,20 @@ a:hover { color: var(--color-blue); } .sidebar { transform: translateX(-100%); } + .sidebar.open { transform: translateX(0); } + .main-content { margin-left: 0; padding: var(--space-4); } + .stats-grid { grid-template-columns: 1fr 1fr; } + .mobile-toggle { display: flex !important; } @@ -768,7 +862,9 @@ a:hover { color: var(--color-blue); } border-bottom: 1px solid rgba(100, 120, 180, 0.08); } -.recent-alert-item:last-child { border-bottom: none; } +.recent-alert-item:last-child { + border-bottom: none; +} .recent-alert-dot { width: 8px; @@ -895,3 +991,123 @@ a:hover { color: var(--color-blue); } gap: var(--space-2); } +<<<<<<< HEAD +/* --- Notification Badge --- */ +.notif-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--color-red); + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; + margin-left: auto; + animation: pulse 2s ease-in-out infinite; +} + +/* --- Notification Toolbar --- */ +.notif-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-5); + flex-wrap: wrap; + gap: var(--space-3); +} + +.notif-filters { + display: flex; + gap: var(--space-2); +} + +.btn-active { + background: var(--color-accent-dim); + border-color: rgba(0, 245, 212, 0.3); + color: var(--color-accent); +} + +.btn-xs { + padding: 2px var(--space-2); + font-size: 10px; +} + +/* --- Notification List Items --- */ +.notif-item { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4) var(--space-3); + border-bottom: 1px solid rgba(100, 120, 180, 0.08); + transition: background var(--transition-fast); +} + +.notif-item:last-child { + border-bottom: none; +} + +.notif-item:hover { + background: var(--color-bg-card-hover); +} + +.notif-unread { + border-left: 3px solid var(--color-accent); +} + +.notif-read { + opacity: 0.65; +} + +.notif-icon { + font-size: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +.notif-content { + flex: 1; + min-width: 0; +} + +.notif-message { + font-size: var(--text-sm); + font-weight: 500; + margin-bottom: var(--space-1); + line-height: 1.4; +} + +.notif-meta { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.notif-time { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.notif-meter { + font-size: var(--text-xs); + color: var(--color-text-muted); + font-family: var(--font-mono); +} + +.badge-warning { + background: var(--severity-medium-bg); + color: var(--severity-medium); +} + +/* --- Charts Row Responsive for 3 panels --- */ +@media (min-width: 1025px) { + .charts-row { + grid-template-columns: 2fr 1fr 1fr; + } +} +======= +>>>>>>> origin/main diff --git a/gridshield.db b/gridshield.db deleted file mode 100644 index 05e87f0..0000000 Binary files a/gridshield.db and /dev/null differ