From 955d943d0cc5fa3914a3320f550f046b006739f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:29:59 +0000 Subject: [PATCH 1/6] Initial plan From b1361e5ae6fa4cd57af22d518e175f75be18841d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:42:03 +0000 Subject: [PATCH 2/6] Add refactoring standards and replace debug statements with proper logging Co-authored-by: goranjovic55 <83976007+goranjovic55@users.noreply.github.com> --- backend/app/api/v1/endpoints/agents.py | 16 +- backend/app/api/v1/endpoints/assets.py | 7 +- backend/app/api/v1/endpoints/traffic.py | 13 +- backend/app/services/SnifferService.py | 2 +- backend/app/services/agent_data_service.py | 27 +- backend/app/utils/__init__.py | 7 + backend/app/utils/logging.py | 47 ++ docs/INDEX.md | 1 + docs/development/REFACTORING_STANDARDS.md | 764 +++++++++++++++++++++ frontend/src/pages/Assets.tsx | 2 +- frontend/src/services/agentService.ts | 8 - frontend/src/utils/logger.ts | 50 ++ 12 files changed, 905 insertions(+), 39 deletions(-) create mode 100644 backend/app/utils/logging.py create mode 100644 docs/development/REFACTORING_STANDARDS.md create mode 100644 frontend/src/utils/logger.ts diff --git a/backend/app/api/v1/endpoints/agents.py b/backend/app/api/v1/endpoints/agents.py index 30ea94f8..aa0bd8dd 100644 --- a/backend/app/api/v1/endpoints/agents.py +++ b/backend/app/api/v1/endpoints/agents.py @@ -325,7 +325,7 @@ async def generate_agent( except Exception as e: # Fallback to source code if compilation fails - print(f"Compilation failed: {e}, falling back to source") + logger.warning("Compilation failed: %s, falling back to source", e) content = source_code filename = f"nop_agent_{agent.name.replace(' ', '_')}.go" is_binary = False @@ -465,7 +465,7 @@ async def agent_websocket( working_agent.name = f"{template_name}@{hostname}" await db.commit() - print(f"Agent {working_agent.name} registered: {message}") + logger.info("Agent %s registered: %s", working_agent.name, message) # Extract agent IP and auto-generate /24 network for discovery # Prefer internal network IPs (10.x, 192.168.x) over Docker bridge IPs (172.x) @@ -523,7 +523,7 @@ async def agent_websocket( # Handle discovered assets assets = message.get('assets', []) count = await AgentDataService.ingest_asset_data(db, working_agent.id, assets) - print(f"Agent {working_agent.name} discovered {count} assets") + logger.debug("Agent %s discovered %d assets", working_agent.name, count) await websocket.send_json({ "type": "asset_ack", "count": count, @@ -537,13 +537,13 @@ async def agent_websocket( if 'flows' in message: traffic['flows'] = message.get('flows', []) success = await AgentDataService.ingest_traffic_data(db, working_agent.id, traffic) - print(f"Agent {working_agent.name} traffic data: {success}") + logger.debug("Agent %s traffic data: %s", working_agent.name, success) elif msg_type == "host_data": # Handle host information host_info = message.get('host', {}) success = await AgentDataService.ingest_host_data(db, working_agent.id, host_info) - print(f"Agent {working_agent.name} host data: {success}") + logger.debug("Agent %s host data: %s", working_agent.name, success) # Store host_info in agent_metadata for POV interface access if host_info and working_agent: @@ -592,12 +592,12 @@ async def agent_websocket( })) except json.JSONDecodeError: - print(f"Invalid JSON from agent {working_agent.name}") + logger.warning("Invalid JSON from agent %s", working_agent.name) except WebSocketDisconnect: - print(f"Agent {working_agent.name if working_agent else agent.name} disconnected") + logger.info("Agent %s disconnected", working_agent.name if working_agent else agent.name) except Exception as e: - print(f"WebSocket error for agent {working_agent.name if working_agent else agent.name}: {e}") + logger.error("WebSocket error for agent %s: %s", working_agent.name if working_agent else agent.name, e) finally: # Cleanup - use working_agent.id if available cleanup_agent_id = str(working_agent.id) if working_agent else str(agent_id) diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index 069def03..e3f40875 100644 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -2,6 +2,7 @@ Asset management endpoints """ +import logging from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func @@ -14,6 +15,8 @@ from app.services.asset_service import AssetService from app.models.asset import Asset +logger = logging.getLogger(__name__) + router = APIRouter() @@ -34,7 +37,7 @@ async def get_assets( - In POV view: agent_id filter overrides exclude_agent_assets """ agent_pov = get_agent_pov(request) - print(f"[ASSETS DEBUG] X-Agent-POV header: {request.headers.get('X-Agent-POV')}, agent_pov: {agent_pov}") + logger.debug("Agent POV header: %s, agent_pov: %s", request.headers.get('X-Agent-POV'), agent_pov) asset_service = AssetService(db) result = await asset_service.get_assets( page=page, @@ -45,7 +48,7 @@ async def get_assets( agent_id=agent_pov, exclude_agent_assets=exclude_agent_assets if not agent_pov else False ) - print(f"[ASSETS DEBUG] Returning {result.total} assets for agent_pov={agent_pov}") + logger.debug("Returning %d assets for agent_pov=%s", result.total, agent_pov) return result diff --git a/backend/app/api/v1/endpoints/traffic.py b/backend/app/api/v1/endpoints/traffic.py index 26df20cb..c5c019bd 100644 --- a/backend/app/api/v1/endpoints/traffic.py +++ b/backend/app/api/v1/endpoints/traffic.py @@ -1,3 +1,4 @@ +import logging from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, HTTPException, Request from fastapi.responses import FileResponse from typing import List, Dict, Optional @@ -15,6 +16,8 @@ import subprocess import time +logger = logging.getLogger(__name__) + router = APIRouter() class PingRequest(BaseModel): @@ -88,9 +91,9 @@ def packet_callback(packet_data): await websocket.send_json(packet) except WebSocketDisconnect: - print("Traffic WebSocket disconnected") + logger.debug("Traffic WebSocket disconnected") except Exception as e: - print(f"WebSocket error: {e}") + logger.error("WebSocket error: %s", e) finally: # Only stop sniffing if NOT in persistent capture mode if not sniffer_service.persistent_capture: @@ -256,7 +259,7 @@ async def get_traffic_flows( return {"flows": flows_list, "total": len(flows_list)} except Exception as e: - print(f"Error getting flows: {e}") + logger.error("Error getting flows: %s", e) return {"flows": [], "total": 0} @router.get("/stats") @@ -340,9 +343,7 @@ async def get_traffic_stats( "agent_id": str(agent_pov) } except Exception as e: - print(f"Error getting agent traffic stats: {e}") - import traceback - traceback.print_exc() + logger.exception("Error getting agent traffic stats: %s", e) return { "total_packets": 0, "total_bytes": 0, diff --git a/backend/app/services/SnifferService.py b/backend/app/services/SnifferService.py index 005acb00..cdb86eb7 100644 --- a/backend/app/services/SnifferService.py +++ b/backend/app/services/SnifferService.py @@ -641,7 +641,7 @@ def _run_sniff(self): stop_filter=lambda p: not self.is_sniffing ) except Exception as e: - print(f"Sniffing error: {e}") + logger.error("Sniffing error: %s", e) self.is_sniffing = False def start_sniffing(self, interface: str, callback: Optional[Callable], filter_str: Optional[str] = None, persistent: bool = False): diff --git a/backend/app/services/agent_data_service.py b/backend/app/services/agent_data_service.py index dd16fd96..56d573d5 100644 --- a/backend/app/services/agent_data_service.py +++ b/backend/app/services/agent_data_service.py @@ -5,6 +5,7 @@ with proper agent_id tagging. """ +import logging from typing import List, Dict, Any from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +17,8 @@ from app.models.asset import Asset, AssetStatus from app.models.flow import Flow +logger = logging.getLogger(__name__) + class AgentDataService: """Service for ingesting data from agents""" @@ -78,7 +81,7 @@ async def ingest_asset_data( processed += 1 except Exception as e: - print(f"Error processing asset {asset_data}: {e}") + logger.warning("Error processing asset %s: %s", asset_data, e) continue await db.commit() @@ -119,24 +122,24 @@ async def ingest_traffic_data( agent.agent_metadata = {} # Store interfaces for POV mode - print(f"[TRAFFIC INGEST] Traffic data keys: {traffic.keys()}") + logger.debug("Traffic data keys: %s", traffic.keys()) if 'interfaces' in traffic: - print(f"[TRAFFIC INGEST] Storing {len(traffic['interfaces'])} interfaces") + logger.debug("Storing %d interfaces", len(traffic['interfaces'])) agent.agent_metadata['interfaces'] = traffic['interfaces'] agent.agent_metadata['last_traffic_update'] = datetime.utcnow().isoformat() # Mark JSONB field as modified for SQLAlchemy flag_modified(agent, 'agent_metadata') else: - print(f"[TRAFFIC INGEST] No 'interfaces' key in traffic data") + logger.debug("No 'interfaces' key in traffic data") # Store flows in database - print(f"[TRAFFIC INGEST] Checking for flows. 'flows' in traffic: {'flows' in traffic}") + logger.debug("Checking for flows. 'flows' in traffic: %s", 'flows' in traffic) if 'flows' in traffic: - print(f"[TRAFFIC INGEST] flows value type: {type(traffic['flows'])}, length: {len(traffic['flows']) if traffic['flows'] else 0}") + logger.debug("flows value type: %s, length: %d", type(traffic['flows']), len(traffic['flows']) if traffic['flows'] else 0) if 'flows' in traffic and traffic['flows']: flows_data = traffic['flows'] - print(f"[TRAFFIC INGEST] Processing {len(flows_data)} flows from agent {agent.name}") + logger.debug("Processing %d flows from agent %s", len(flows_data), agent.name) for flow_data in flows_data: try: @@ -157,17 +160,15 @@ async def ingest_traffic_data( ) db.add(flow) except Exception as fe: - print(f"[TRAFFIC INGEST] Error creating flow: {fe}") + logger.warning("Error creating flow: %s", fe) - print(f"[TRAFFIC INGEST] Agent {agent.name} stored {len(flows_data)} flows") + logger.debug("Agent %s stored %d flows", agent.name, len(flows_data)) await db.commit() return True except Exception as e: - print(f"Error ingesting traffic data: {e}") - import traceback - traceback.print_exc() + logger.exception("Error ingesting traffic data: %s", e) return False @staticmethod @@ -212,7 +213,7 @@ async def ingest_host_data( return False except Exception as e: - print(f"Error ingesting host data: {e}") + logger.exception("Error ingesting host data: %s", e) return False @staticmethod diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index e69de29b..42fba36b 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Utility modules for the NOP backend. +""" + +from app.utils.logging import get_logger + +__all__ = ["get_logger"] diff --git a/backend/app/utils/logging.py b/backend/app/utils/logging.py new file mode 100644 index 00000000..d62322ef --- /dev/null +++ b/backend/app/utils/logging.py @@ -0,0 +1,47 @@ +""" +Structured logging utilities for the NOP backend. + +Usage: + from app.utils.logging import get_logger + + logger = get_logger(__name__) + logger.info("Processing request", extra={"asset_id": asset_id}) +""" + +import logging +import sys +from typing import Optional + +from app.core.config import settings + + +def get_logger(name: str, level: Optional[str] = None) -> logging.Logger: + """ + Get a configured logger instance. + + Args: + name: The logger name, typically __name__. + level: Optional override for log level. + + Returns: + A configured Logger instance. + """ + logger = logging.getLogger(name) + + # Set level from settings or override + log_level = level or settings.LOG_LEVEL + logger.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + # Only add handler if not already configured + if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger diff --git a/docs/INDEX.md b/docs/INDEX.md index ceb60f42..1a9f0d6e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -58,6 +58,7 @@ UI/UX specifications and component library. Contributing, testing, and development workflow. - [Contributing to NOP](development/CONTRIBUTING.md) +- [Refactoring Standards](development/REFACTORING_STANDARDS.md) ⭐ *Code quality and best practices* - [Development Roadmap](development/ROADMAP.md) - [Automation Scripts Reference](development/SCRIPTS.md) - [Testing Guide](development/TESTING.md) diff --git a/docs/development/REFACTORING_STANDARDS.md b/docs/development/REFACTORING_STANDARDS.md new file mode 100644 index 00000000..4d2c9b24 --- /dev/null +++ b/docs/development/REFACTORING_STANDARDS.md @@ -0,0 +1,764 @@ +# NOP Codebase Refactoring Standards + +## Overview + +This document defines industry best practices and community standards for refactoring the Network Observatory Platform (NOP) codebase. It addresses current patterns, identifies areas for improvement, and establishes guidelines for consistent, maintainable code. + +**Last Updated:** 2026-01-10 +**Status:** Active Standards Document + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Backend Standards (Python/FastAPI)](#backend-standards) +3. [Frontend Standards (React/TypeScript)](#frontend-standards) +4. [Architecture Standards](#architecture-standards) +5. [Code Quality Standards](#code-quality-standards) +6. [Testing Standards](#testing-standards) +7. [Security Standards](#security-standards) +8. [Priority Refactoring Items](#priority-refactoring-items) + +--- + +## Executive Summary + +### Current State Assessment + +The NOP codebase demonstrates good foundational architecture with: +- ✅ Clean separation between frontend (React/TypeScript) and backend (FastAPI/Python) +- ✅ Proper use of Pydantic schemas for API validation +- ✅ Zustand for state management (appropriate for this scale) +- ✅ Async SQLAlchemy patterns for database operations +- ✅ Service layer abstraction in backend + +### Areas for Improvement + +| Area | Issue | Priority | +|------|-------|----------| +| Logging | Debug `print()` statements in production code | High | +| Error Handling | Inconsistent error responses across endpoints | High | +| Type Safety | Missing TypeScript strict mode, `any` types | Medium | +| Testing | Limited test coverage (only 3 test files) | Medium | +| Documentation | API docstrings incomplete | Low | + +--- + +## Backend Standards + +### 1. Remove Debug Statements + +**Current Issue:** Production code contains `print()` statements for debugging. + +```python +# ❌ Bad - Found in assets.py, traffic.py, agents.py +print(f"[ASSETS DEBUG] X-Agent-POV header: {request.headers.get('X-Agent-POV')}") + +# ✅ Good - Use structured logging +import logging +logger = logging.getLogger(__name__) +logger.debug("Agent POV header: %s", request.headers.get('X-Agent-POV')) +``` + +**Files to Refactor:** +- `backend/app/api/v1/endpoints/assets.py` +- `backend/app/api/v1/endpoints/traffic.py` +- `backend/app/api/v1/endpoints/agents.py` +- `backend/app/services/SnifferService.py` +- `backend/app/services/agent_service.py` +- `backend/app/services/agent_data_service.py` + +### 2. Consistent Error Handling + +**Standard Pattern:** + +```python +from fastapi import HTTPException, status +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# Define custom exception classes +class ResourceNotFoundError(Exception): + """Raised when a requested resource is not found.""" + pass + +class ValidationError(Exception): + """Raised when input validation fails.""" + pass + +# Endpoint pattern +@router.get("/{resource_id}", response_model=ResourceResponse) +async def get_resource( + resource_id: str, + db: AsyncSession = Depends(get_db) +) -> ResourceResponse: + """ + Get a specific resource by ID. + + Args: + resource_id: The unique identifier of the resource. + db: Database session dependency. + + Returns: + ResourceResponse: The requested resource. + + Raises: + HTTPException: 404 if resource not found, 400 if invalid ID format. + """ + try: + resource = await service.get_by_id(resource_id) + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": "resource_not_found", "message": f"Resource {resource_id} not found"} + ) + return resource + except ValueError as e: + logger.warning("Invalid resource ID format: %s", resource_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "invalid_id", "message": str(e)} + ) + except Exception as e: + logger.exception("Unexpected error fetching resource %s", resource_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": "internal_error", "message": "An unexpected error occurred"} + ) +``` + +### 3. Service Layer Pattern + +**Standard Pattern:** + +```python +# backend/app/services/base_service.py +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Optional, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from uuid import UUID + +ModelType = TypeVar("ModelType") +CreateSchemaType = TypeVar("CreateSchemaType") +UpdateSchemaType = TypeVar("UpdateSchemaType") +ResponseSchemaType = TypeVar("ResponseSchemaType") + +class BaseService(ABC, Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]): + """Base service class with common CRUD operations.""" + + def __init__(self, db: AsyncSession, model: type[ModelType]): + self.db = db + self.model = model + self.logger = logging.getLogger(self.__class__.__name__) + + async def get_by_id(self, id: UUID) -> Optional[ModelType]: + """Get a single record by ID.""" + query = select(self.model).where(self.model.id == id) + result = await self.db.execute(query) + return result.scalar_one_or_none() + + async def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]: + """Get all records with pagination.""" + query = select(self.model).offset(skip).limit(limit) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def create(self, data: CreateSchemaType) -> ModelType: + """Create a new record.""" + obj = self.model(**data.model_dump()) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def update(self, id: UUID, data: UpdateSchemaType) -> Optional[ModelType]: + """Update an existing record.""" + obj = await self.get_by_id(id) + if not obj: + return None + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(obj, field, value) + await self.db.commit() + await self.db.refresh(obj) + return obj + + async def delete(self, id: UUID) -> bool: + """Delete a record by ID.""" + obj = await self.get_by_id(id) + if not obj: + return False + await self.db.delete(obj) + await self.db.commit() + return True +``` + +### 4. Response Model Consistency + +**Always use `response_model` parameter:** + +```python +# ❌ Bad - Missing response_model +@router.get("/online") +async def get_online_assets(db: AsyncSession = Depends(get_db)): + ... + return [{"ip_address": asset.ip_address, ...}] + +# ✅ Good - Explicit response_model +class OnlineAssetResponse(BaseModel): + ip_address: str + hostname: str + status: str + +@router.get("/online", response_model=List[OnlineAssetResponse]) +async def get_online_assets(db: AsyncSession = Depends(get_db)) -> List[OnlineAssetResponse]: + ... +``` + +### 5. Dependency Injection Best Practices + +```python +# backend/app/core/dependencies.py +from typing import Annotated +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User + +# Type aliases for common dependencies +DBSession = Annotated[AsyncSession, Depends(get_db)] +CurrentUser = Annotated[User, Depends(get_current_user)] + +# Usage in endpoints +@router.get("/protected") +async def protected_endpoint( + db: DBSession, + user: CurrentUser +): + ... +``` + +--- + +## Frontend Standards + +### 1. Remove Console.log Statements + +**Current Issue:** Production code contains `console.log()` for debugging. + +```typescript +// ❌ Bad - Found in multiple files +console.log('Debug info:', data); + +// ✅ Good - Use conditional logging or remove +if (process.env.NODE_ENV === 'development') { + console.log('Debug info:', data); +} + +// ✅ Better - Create a logger utility +// frontend/src/utils/logger.ts +const isDev = process.env.NODE_ENV === 'development'; + +export const logger = { + debug: (...args: unknown[]) => isDev && console.log('[DEBUG]', ...args), + info: (...args: unknown[]) => isDev && console.info('[INFO]', ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', ...args), + error: (...args: unknown[]) => console.error('[ERROR]', ...args), +}; +``` + +**Files to Refactor:** +- `frontend/src/components/ProtocolConnection.tsx` +- `frontend/src/components/ScanSettingsModal.tsx` +- `frontend/src/pages/Assets.tsx` +- `frontend/src/pages/Access.tsx` +- `frontend/src/pages/Topology.tsx` +- `frontend/src/pages/Agents.tsx` +- `frontend/src/services/agentService.ts` + +### 2. TypeScript Strict Mode + +**Enable strict mode in tsconfig.json:** + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true + } +} +``` + +### 3. Avoid `any` Type + +```typescript +// ❌ Bad +const headers: any = { Authorization: `Bearer ${token}` }; +const data: any = response.data; + +// ✅ Good - Use proper types +interface RequestHeaders { + Authorization: string; + 'X-Agent-POV'?: string; +} + +const headers: RequestHeaders = { Authorization: `Bearer ${token}` }; + +// ✅ Good - Use generics for API responses +interface ApiResponse { + data: T; + status: number; +} +``` + +### 4. Custom Hooks for API Calls + +```typescript +// frontend/src/hooks/useAssets.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { assetService, Asset } from '../services/assetService'; +import { useAuthStore } from '../store/authStore'; +import { usePOV } from '../context/POVContext'; + +export function useAssets(status?: string) { + const { token } = useAuthStore(); + const { activeAgent } = usePOV(); + + return useQuery({ + queryKey: ['assets', status, activeAgent?.id], + queryFn: () => assetService.getAssets(token!, status, activeAgent?.id), + enabled: !!token, + staleTime: 30000, // 30 seconds + }); +} + +export function useDeleteAllAssets() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => assetService.deleteAllAssets(token!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['assets'] }); + }, + }); +} +``` + +### 5. Component Structure + +```typescript +// Standard component structure +import React, { FC, useCallback, useMemo } from 'react'; + +// Types first +interface ComponentProps { + id: string; + title: string; + onAction?: (id: string) => void; +} + +// Constants +const DEFAULT_TITLE = 'Untitled'; + +// Component +export const Component: FC = ({ + id, + title = DEFAULT_TITLE, + onAction +}) => { + // Hooks at the top + const memoizedValue = useMemo(() => { + return computeExpensiveValue(title); + }, [title]); + + // Event handlers + const handleClick = useCallback(() => { + onAction?.(id); + }, [id, onAction]); + + // Render + return ( +
+ {memoizedValue} +
+ ); +}; +``` + +--- + +## Architecture Standards + +### 1. Directory Structure + +``` +backend/ +├── app/ +│ ├── api/ +│ │ ├── v1/ +│ │ │ ├── endpoints/ # Route handlers +│ │ │ └── router.py # Route aggregation +│ │ └── websockets/ # WebSocket handlers +│ ├── core/ # Core configuration +│ │ ├── config.py +│ │ ├── database.py +│ │ ├── dependencies.py # NEW: Shared dependencies +│ │ └── security.py +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Business logic +│ │ ├── base_service.py # NEW: Base service class +│ │ └── ... +│ └── utils/ # NEW: Utility functions +│ ├── logging.py +│ └── validators.py +└── tests/ + ├── unit/ + ├── integration/ + └── fixtures/ + +frontend/ +├── src/ +│ ├── components/ # Reusable components +│ │ ├── common/ # NEW: Shared UI components +│ │ └── ... +│ ├── context/ # React contexts +│ ├── hooks/ # NEW: Custom hooks +│ ├── pages/ # Page components +│ ├── services/ # API service layer +│ ├── store/ # Zustand stores +│ ├── types/ # NEW: TypeScript types +│ └── utils/ # NEW: Utility functions +│ └── logger.ts +└── tests/ # NEW: Test directory +``` + +### 2. API Versioning + +**Always version APIs:** + +```python +# Current: /api/v1/assets +# Future: /api/v2/assets (breaking changes) + +# router.py +api_router_v1 = APIRouter(prefix="/api/v1") +api_router_v2 = APIRouter(prefix="/api/v2") # For future versions +``` + +### 3. Environment Configuration + +```python +# backend/app/core/config.py +from pydantic_settings import BaseSettings +from functools import lru_cache + +class Settings(BaseSettings): + # Use descriptive names + DATABASE_URL: str + REDIS_URL: str + SECRET_KEY: str + + # Environment-specific + DEBUG: bool = False + LOG_LEVEL: str = "INFO" + + # Feature flags + ENABLE_OFFENSIVE_TOOLS: bool = False + + class Config: + env_file = ".env" + case_sensitive = True + +@lru_cache() +def get_settings() -> Settings: + """Cached settings loader.""" + return Settings() + +settings = get_settings() +``` + +--- + +## Code Quality Standards + +### 1. Import Organization + +```python +# Standard library imports +import logging +from datetime import datetime +from typing import Optional, List, Dict + +# Third-party imports +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +# Local imports +from app.core.database import get_db +from app.services.asset_service import AssetService +from app.schemas.asset import AssetResponse +``` + +### 2. Docstring Standards + +```python +def calculate_confidence_score( + arp_evidence: bool, + flow_evidence: bool, + traceroute_evidence: bool +) -> float: + """ + Calculate confidence score for network connection. + + Confidence is determined by weighted evidence from multiple sources. + The final score is clamped between 0.0 and 1.0. + + Args: + arp_evidence: Whether ARP table shows this connection. + flow_evidence: Whether network flows confirm the connection. + traceroute_evidence: Whether traceroute confirms the path. + + Returns: + float: Confidence score between 0.0 and 1.0. + + Example: + >>> calculate_confidence_score(True, True, False) + 0.7 + """ + score = 0.0 + if arp_evidence: + score += 0.4 + if flow_evidence: + score += 0.3 + if traceroute_evidence: + score += 0.25 + return min(score, 1.0) +``` + +### 3. Error Messages + +```python +# ❌ Bad - Vague error +raise HTTPException(status_code=500, detail="Error") + +# ❌ Bad - Exposes internal details +raise HTTPException(status_code=500, detail=str(e)) + +# ✅ Good - Structured, informative, safe +raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "asset_not_found", + "message": "The requested asset could not be found", + "asset_id": asset_id + } +) +``` + +--- + +## Testing Standards + +### 1. Test File Organization + +``` +tests/ +├── conftest.py # Shared fixtures +├── unit/ +│ ├── services/ +│ │ ├── test_asset_service.py +│ │ └── test_traffic_service.py +│ └── utils/ +│ └── test_validators.py +├── integration/ +│ ├── api/ +│ │ ├── test_assets_api.py +│ │ └── test_auth_api.py +│ └── test_database.py +└── e2e/ + └── test_discovery_flow.py +``` + +### 2. Test Naming Convention + +```python +# test___ +def test_asset_service_get_by_id_returns_asset_when_exists(): + ... + +def test_asset_service_get_by_id_returns_none_when_not_found(): + ... + +def test_asset_service_create_raises_on_duplicate_ip(): + ... +``` + +### 3. Fixture Pattern + +```python +# conftest.py +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from app.core.database import Base + +@pytest_asyncio.fixture +async def db_session(): + """Create a test database session.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with AsyncSession(engine) as session: + yield session + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture +def sample_asset(): + """Create a sample asset for testing.""" + return { + "ip_address": "192.168.1.100", + "mac_address": "00:11:22:33:44:55", + "hostname": "test-host", + "asset_type": "host" + } +``` + +--- + +## Security Standards + +### 1. Input Validation + +```python +from pydantic import BaseModel, Field, field_validator +import re + +class AssetCreate(BaseModel): + ip_address: str = Field(..., description="IPv4 or IPv6 address") + hostname: Optional[str] = Field(None, max_length=255) + + @field_validator('ip_address') + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate IP address format.""" + import ipaddress + try: + ipaddress.ip_address(v) + return v + except ValueError: + raise ValueError('Invalid IP address format') + + @field_validator('hostname') + @classmethod + def validate_hostname(cls, v: Optional[str]) -> Optional[str]: + """Validate hostname format and prevent injection.""" + if v is None: + return v + # Only allow valid hostname characters + if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})*$', v): + raise ValueError('Invalid hostname format') + return v +``` + +### 2. Authentication + +```python +# Always require authentication for sensitive endpoints +@router.delete("/{asset_id}") +async def delete_asset( + asset_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) # Required! +): + # Check permissions + if not current_user.has_permission("assets:delete"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + ... +``` + +### 3. Secrets Management + +```python +# ❌ Bad - Hardcoded secrets +SECRET_KEY = "your-secret-key-change-this" + +# ✅ Good - Environment variables with validation +class Settings(BaseSettings): + SECRET_KEY: str + + @field_validator('SECRET_KEY') + @classmethod + def validate_secret_key(cls, v: str) -> str: + if v == "your-secret-key-change-this": + raise ValueError("SECRET_KEY must be changed from default") + if len(v) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters") + return v +``` + +--- + +## Priority Refactoring Items + +### Immediate (High Priority) + +| Item | Files | Effort | +|------|-------|--------| +| Replace `print()` with logging | 6 backend files | 1 hour | +| Replace `console.log()` with logger | 7 frontend files | 1 hour | +| Add `response_model` to all endpoints | 5 endpoint files | 2 hours | + +### Short-term (Medium Priority) + +| Item | Files | Effort | +|------|-------|--------| +| Create base service class | New file + refactor services | 4 hours | +| Add TypeScript strict mode | tsconfig.json + fix errors | 4 hours | +| Create custom hooks for API calls | New hooks directory | 3 hours | +| Add unit tests for services | New test files | 8 hours | + +### Long-term (Low Priority) + +| Item | Files | Effort | +|------|-------|--------| +| Complete API documentation | All endpoint files | 8 hours | +| Add integration tests | New test files | 16 hours | +| Implement error boundary in React | New component | 2 hours | + +--- + +## Implementation Checklist + +- [ ] Create `backend/app/utils/logger.py` with structured logging +- [ ] Create `frontend/src/utils/logger.ts` with development-only logging +- [ ] Replace all `print()` statements in backend +- [ ] Replace all `console.log()` statements in frontend +- [ ] Add `response_model` to endpoints missing them +- [ ] Create `backend/app/services/base_service.py` +- [ ] Create `frontend/src/hooks/` directory with custom hooks +- [ ] Enable TypeScript strict mode +- [ ] Add comprehensive unit tests +- [ ] Update API documentation + +--- + +## References + +- [FastAPI Best Practices](https://fastapi.tiangolo.com/tutorial/best-practices/) +- [SQLAlchemy 2.0 Patterns](https://docs.sqlalchemy.org/en/20/orm/quickstart.html) +- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/) +- [Zustand Best Practices](https://github.com/pmndrs/zustand#best-practices) +- [Pydantic V2 Migration](https://docs.pydantic.dev/latest/migration/) diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx index 59faf49e..be6c0aa8 100644 --- a/frontend/src/pages/Assets.tsx +++ b/frontend/src/pages/Assets.tsx @@ -152,7 +152,7 @@ const Assets: React.FC = () => { passiveDiscoveryEnabled: discovery.passive_discovery || false })); } catch (err) { - console.log('Could not fetch agent settings, using defaults'); + // Agent settings not available, using defaults } }; diff --git a/frontend/src/services/agentService.ts b/frontend/src/services/agentService.ts index 44927250..287ec922 100644 --- a/frontend/src/services/agentService.ts +++ b/frontend/src/services/agentService.ts @@ -98,10 +98,6 @@ export const agentService = { }, async createAgent(token: string, agentData: AgentCreate): Promise { - console.log('[agentService] Creating agent with data:', agentData); - console.log('[agentService] Token:', token ? `${token.substring(0, 20)}...` : 'MISSING'); - console.log('[agentService] API URL:', `${API_BASE_URL}/api/v1/agents/`); - const response = await fetch(`${API_BASE_URL}/api/v1/agents/`, { method: 'POST', headers: { @@ -111,16 +107,12 @@ export const agentService = { body: JSON.stringify(agentData), }); - console.log('[agentService] Response status:', response.status, response.statusText); - if (!response.ok) { const errorText = await response.text(); - console.error('[agentService] Error response:', errorText); throw new Error(`Failed to create agent: ${response.status} ${errorText}`); } const result = await response.json(); - console.log('[agentService] Agent created successfully:', result); return result; }, diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts new file mode 100644 index 00000000..91e50964 --- /dev/null +++ b/frontend/src/utils/logger.ts @@ -0,0 +1,50 @@ +/** + * Logger utility for the NOP frontend. + * + * In development mode, logs to console. + * In production mode, only logs warnings and errors. + * + * Usage: + * import { logger } from '../utils/logger'; + * logger.debug('Processing data', { id: 123 }); + */ + +const isDev = process.env.NODE_ENV === 'development'; + +type LogArgs = unknown[]; + +export const logger = { + /** + * Debug-level logging. Only outputs in development. + */ + debug: (...args: LogArgs): void => { + if (isDev) { + console.log('[DEBUG]', ...args); + } + }, + + /** + * Info-level logging. Only outputs in development. + */ + info: (...args: LogArgs): void => { + if (isDev) { + console.info('[INFO]', ...args); + } + }, + + /** + * Warning-level logging. Always outputs. + */ + warn: (...args: LogArgs): void => { + console.warn('[WARN]', ...args); + }, + + /** + * Error-level logging. Always outputs. + */ + error: (...args: LogArgs): void => { + console.error('[ERROR]', ...args); + }, +}; + +export default logger; From 8fa753d099b35d2c6dd35ceebfb44d08f852ec8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:45:14 +0000 Subject: [PATCH 3/6] Add workflow log for codebase refactoring analysis session Co-authored-by: goranjovic55 <83976007+goranjovic55@users.noreply.github.com> --- .../2026-01-10_143049_codebase_refactoring.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 log/workflow/2026-01-10_143049_codebase_refactoring.md diff --git a/log/workflow/2026-01-10_143049_codebase_refactoring.md b/log/workflow/2026-01-10_143049_codebase_refactoring.md new file mode 100644 index 00000000..b76a8ec9 --- /dev/null +++ b/log/workflow/2026-01-10_143049_codebase_refactoring.md @@ -0,0 +1,88 @@ +# Workflow Log: Codebase Refactoring Analysis + +**Date:** 2026-01-10 +**Task:** Analyze codebase and implement refactoring standards +**Complexity:** Complex (6+ files) +**Status:** ✓ Completed + +--- + +## Task Summary + +Conducted comprehensive analysis of the NOP (Network Observatory Platform) codebase to identify areas for improvement and establish industry best practices. Implemented priority refactoring changes. + +## Work Completed + +### ✓ Analysis Phase +- Reviewed project structure (backend: Python/FastAPI, frontend: React/TypeScript) +- Analyzed existing architecture documentation +- Identified code quality issues (debug statements, missing logging) +- Documented current patterns and improvement opportunities + +### ✓ Documentation Created +- **docs/development/REFACTORING_STANDARDS.md** - Comprehensive refactoring guide covering: + - Backend standards (Python/FastAPI) + - Frontend standards (React/TypeScript) + - Architecture standards + - Code quality standards + - Testing standards + - Security standards + - Priority refactoring checklist + +### ✓ Logging Utilities Created +- **backend/app/utils/logging.py** - Structured logging for Python backend +- **frontend/src/utils/logger.ts** - Development-only logging for TypeScript frontend + +### ✓ Code Improvements +- Replaced `print()` statements with proper `logger` calls in 5 backend files +- Replaced/removed `console.log()` statements in 2 frontend files +- Updated docs/INDEX.md to include new refactoring standards + +## Files Modified + +| File | Change | +|------|--------| +| `backend/app/api/v1/endpoints/assets.py` | Added logger, replaced 2 print() calls | +| `backend/app/api/v1/endpoints/traffic.py` | Added logger, replaced 4 print() calls | +| `backend/app/api/v1/endpoints/agents.py` | Replaced 8 print() calls with logger | +| `backend/app/services/SnifferService.py` | Replaced 1 print() call with logger | +| `backend/app/services/agent_data_service.py` | Added logger, replaced 11 print() calls | +| `frontend/src/services/agentService.ts` | Removed 5 console.log() calls | +| `frontend/src/pages/Assets.tsx` | Removed 1 console.log() call | +| `docs/INDEX.md` | Added link to refactoring standards | + +## Files Created + +| File | Purpose | +|------|---------| +| `docs/development/REFACTORING_STANDARDS.md` | Industry best practices guide | +| `backend/app/utils/__init__.py` | Utils package init | +| `backend/app/utils/logging.py` | Structured logging utility | +| `frontend/src/utils/logger.ts` | Frontend logging utility | + +## Validation + +- ✓ Python syntax validation passed (all 6 backend files) +- ✓ CodeQL security scan: 0 alerts (Python and JavaScript) +- ✓ Changes are minimal and surgical + +## Skills Loaded + +- backend-api (for Python/FastAPI patterns) +- frontend-react (for React/TypeScript patterns) +- documentation (for docs structure) + +## Next Steps (for future work) + +As documented in REFACTORING_STANDARDS.md: + +### Short-term +- [ ] Create base service class for CRUD operations +- [ ] Add TypeScript strict mode +- [ ] Create custom React hooks for API calls +- [ ] Add unit tests for services + +### Long-term +- [ ] Complete API documentation +- [ ] Add integration tests +- [ ] Implement error boundary in React From 51776998e7d46d31ddb55aa31fd5a525feeedab0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:08:38 +0000 Subject: [PATCH 4/6] Implement refactoring standards: replace all console/print statements with logger, add response models Co-authored-by: goranjovic55 <83976007+goranjovic55@users.noreply.github.com> --- backend/app/api/v1/endpoints/assets.py | 19 +++-- backend/app/schemas/asset.py | 20 +++++ backend/app/services/agent_service.py | 2 +- frontend/src/components/PacketCrafting.tsx | 3 +- .../src/components/ProtocolConnection.tsx | 83 ++++++++++--------- frontend/src/components/ScanSettingsModal.tsx | 7 +- frontend/src/pages/Access.tsx | 9 +- frontend/src/pages/AccessHub.tsx | 5 +- frontend/src/pages/Agents.tsx | 35 ++++---- frontend/src/pages/Assets.tsx | 8 +- frontend/src/pages/Dashboard.tsx | 3 +- frontend/src/pages/Exploit.tsx | 3 +- frontend/src/pages/Host.tsx | 27 +++--- frontend/src/pages/Scans.tsx | 3 +- frontend/src/pages/Settings.tsx | 13 +-- frontend/src/pages/Storm.tsx | 13 +-- frontend/src/pages/Topology.tsx | 9 +- frontend/src/pages/Traffic.tsx | 17 ++-- frontend/src/services/accessService.ts | 3 +- frontend/src/services/assetService.ts | 3 +- frontend/src/services/hostService.ts | 3 +- 21 files changed, 164 insertions(+), 124 deletions(-) diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index e3f40875..a9374793 100644 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -11,7 +11,10 @@ from app.core.database import get_db from app.core.pov_middleware import get_agent_pov -from app.schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetList, AssetStats +from app.schemas.asset import ( + AssetCreate, AssetUpdate, AssetResponse, AssetList, AssetStats, + OnlineAssetResponse, AssetClassificationResponse +) from app.services.asset_service import AssetService from app.models.asset import Asset @@ -67,7 +70,7 @@ async def get_asset_stats( ) -@router.get("/online", response_model=List[dict]) +@router.get("/online", response_model=List[OnlineAssetResponse]) async def get_online_assets(db: AsyncSession = Depends(get_db)): """Get list of all assets (online and offline) for dropdown""" asset_service = AssetService(db) @@ -78,16 +81,16 @@ async def get_online_assets(db: AsyncSession = Depends(get_db)): ) # Return simplified list with IP, hostname, and status return [ - { - "ip_address": asset.ip_address, - "hostname": asset.hostname or asset.ip_address, - "status": asset.status - } + OnlineAssetResponse( + ip_address=str(asset.ip_address), + hostname=asset.hostname or str(asset.ip_address), + status=str(asset.status) + ) for asset in result.assets ] -@router.get("/classification") +@router.get("/classification", response_model=AssetClassificationResponse) async def get_asset_classification(db: AsyncSession = Depends(get_db)): """Get asset classification breakdown by OS type""" try: diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py index da7754d8..8dba81bf 100644 --- a/backend/app/schemas/asset.py +++ b/backend/app/schemas/asset.py @@ -90,3 +90,23 @@ class AssetStats(BaseModel): by_type: Dict[str, int] by_vendor: Dict[str, int] recently_discovered: int + + +class OnlineAssetResponse(BaseModel): + """Simple asset response for dropdowns""" + ip_address: str + hostname: str + status: str + + +class AssetClassificationCategory(BaseModel): + """Classification category""" + category: str + count: int + percentage: float + + +class AssetClassificationResponse(BaseModel): + """Asset classification response""" + total: int + categories: List[AssetClassificationCategory] diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 6f60a07b..0ba03aec 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -173,7 +173,7 @@ async def update_agent(db: AsyncSession, agent_id: UUID, agent_data: AgentUpdate "settings": agent.settings or {} }) except Exception as e: - print(f"Failed to send settings update to agent {agent_id}: {e}") + logger.warning("Failed to send settings update to agent %s: %s", agent_id, e) return agent diff --git a/frontend/src/components/PacketCrafting.tsx b/frontend/src/components/PacketCrafting.tsx index 9772a18d..469b131e 100644 --- a/frontend/src/components/PacketCrafting.tsx +++ b/frontend/src/components/PacketCrafting.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useAuthStore } from '../store/authStore'; import { Asset } from '../services/assetService'; import { CyberSectionHeader } from './CyberUI'; +import { logger } from '../utils/logger'; interface PacketCraftingProps { onBack?: () => void; @@ -188,7 +189,7 @@ const PacketCrafting: React.FC = ({ onBack, assets = [] }) const data = await res.json(); setResponse(data); } catch (err) { - console.error('Failed to send packet:', err); + logger.error('Failed to send packet:', err); setResponse({ success: false, error: 'Failed to send packet. Check network permissions or destination IP.' }); } finally { setIsSending(false); diff --git a/frontend/src/components/ProtocolConnection.tsx b/frontend/src/components/ProtocolConnection.tsx index a72fd2e3..57d9611f 100644 --- a/frontend/src/components/ProtocolConnection.tsx +++ b/frontend/src/components/ProtocolConnection.tsx @@ -3,6 +3,7 @@ import { ConnectionTab, useAccessStore } from '../store/accessStore'; import { accessService, Credential } from '../services/accessService'; import { useAuthStore } from '../store/authStore'; import Guacamole from 'guacamole-common-js'; +import { logger } from '../utils/logger'; interface ProtocolConnectionProps { tab: ConnectionTab; @@ -31,15 +32,15 @@ const ProtocolConnection: React.FC = ({ tab }) => { // Use useLayoutEffect to attach display immediately after DOM update useLayoutEffect(() => { - console.log('[GUACAMOLE-CLIENT] useLayoutEffect triggered, connectionStatus:', connectionStatus); + logger.debug('[GUACAMOLE-CLIENT] useLayoutEffect triggered, connectionStatus:', connectionStatus); if (connectionStatus === 'connected' && (tab.protocol === 'rdp' || tab.protocol === 'vnc')) { - console.log('[GUACAMOLE-CLIENT] useLayoutEffect: checking for display attachment'); - console.log('[GUACAMOLE-CLIENT] displayRef.current:', !!displayRef.current); - console.log('[GUACAMOLE-CLIENT] clientRef.current:', !!clientRef.current); - console.log('[GUACAMOLE-CLIENT] displayAttached:', displayAttached); + logger.debug('[GUACAMOLE-CLIENT] useLayoutEffect: checking for display attachment'); + logger.debug('[GUACAMOLE-CLIENT] displayRef.current:', !!displayRef.current); + logger.debug('[GUACAMOLE-CLIENT] clientRef.current:', !!clientRef.current); + logger.debug('[GUACAMOLE-CLIENT] displayAttached:', displayAttached); if (displayRef.current && clientRef.current && !displayAttached) { - console.log('[GUACAMOLE-CLIENT] Attaching display...'); + logger.debug('[GUACAMOLE-CLIENT] Attaching display...'); try { const display = clientRef.current.getDisplay(); const displayElement = display.getElement(); @@ -48,7 +49,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { displayRef.current.innerHTML = ''; displayRef.current.appendChild(displayElement); setDisplayAttached(true); - console.log('[GUACAMOLE-CLIENT] ✓ Display element attached to DOM successfully'); + logger.debug('[GUACAMOLE-CLIENT] ✓ Display element attached to DOM successfully'); // Scale the display to fit the container const containerWidth = displayRef.current.clientWidth || 1024; @@ -57,7 +58,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { const displayHeight = display.getHeight() || 768; const scale = Math.min(containerWidth / displayWidth, containerHeight / displayHeight, 1); display.scale(scale); - console.log('[GUACAMOLE-CLIENT] Display scaled to:', scale); + logger.debug('[GUACAMOLE-CLIENT] Display scaled to:', scale); // Set up keyboard input const keyboard = new Guacamole.Keyboard(document); @@ -81,23 +82,23 @@ const ProtocolConnection: React.FC = ({ tab }) => { displayElement.addEventListener('click', () => { if (tab.protocol === 'rdp' || tab.protocol === 'vnc') { displayElement.requestPointerLock(); - console.log('[GUACAMOLE-CLIENT] Pointer lock requested'); + logger.debug('[GUACAMOLE-CLIENT] Pointer lock requested'); } }); // Exit pointer lock with ESC key hint document.addEventListener('pointerlockchange', () => { if (document.pointerLockElement === displayElement) { - console.log('[GUACAMOLE-CLIENT] Pointer locked (press ESC to release)'); + logger.debug('[GUACAMOLE-CLIENT] Pointer locked (press ESC to release)'); } else { - console.log('[GUACAMOLE-CLIENT] Pointer unlocked'); + logger.debug('[GUACAMOLE-CLIENT] Pointer unlocked'); } }); - console.log('[GUACAMOLE-CLIENT] Keyboard and mouse handlers attached'); + logger.debug('[GUACAMOLE-CLIENT] Keyboard and mouse handlers attached'); } } catch (e) { - console.error('[GUACAMOLE-CLIENT] Error attaching display:', e); + logger.error('[GUACAMOLE-CLIENT] Error attaching display:', e); } } } @@ -277,9 +278,9 @@ const ProtocolConnection: React.FC = ({ tab }) => { // HTTP Tunnel implementation for environments where WebSocket doesn't work const setupHTTPTunnel = async () => { - console.log('[HTTP-TUNNEL] Setting up HTTP tunnel connection (fallback mode)'); - console.log('[HTTP-TUNNEL] Target:', tab.ip); - console.log('[HTTP-TUNNEL] Protocol:', tab.protocol); + logger.debug('[HTTP-TUNNEL] Setting up HTTP tunnel connection (fallback mode)'); + logger.debug('[HTTP-TUNNEL] Target:', tab.ip); + logger.debug('[HTTP-TUNNEL] Protocol:', tab.protocol); const port = tab.protocol === 'rdp' ? 3389 : 5900; const width = displayRef.current?.clientWidth || 1024; @@ -298,7 +299,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { dpi: '96' }).toString(); - console.log('[HTTP-TUNNEL] Connecting...'); + logger.debug('[HTTP-TUNNEL] Connecting...'); const connectResponse = await fetch(connectUrl, { method: 'POST' }); if (!connectResponse.ok) { @@ -307,7 +308,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { const connectData = await connectResponse.json(); const sessionId = connectData.session_id; - console.log('[HTTP-TUNNEL] Session created:', sessionId); + logger.debug('[HTTP-TUNNEL] Session created:', sessionId); // Step 2: Create display canvas if (displayRef.current) { @@ -328,21 +329,21 @@ const ProtocolConnection: React.FC = ({ tab }) => { const eventSource = new EventSource(`/api/v1/access/http-tunnel/read/${sessionId}`); eventSource.onmessage = (event) => { - console.log('[HTTP-TUNNEL] Received:', event.data.substring(0, 100)); + logger.debug('[HTTP-TUNNEL] Received:', event.data.substring(0, 100)); // TODO: Parse Guacamole instructions and render to canvas }; eventSource.onerror = (error) => { - console.error('[HTTP-TUNNEL] EventSource error:', error); + logger.error('[HTTP-TUNNEL] EventSource error:', error); eventSource.close(); }; updateTabStatus(tab.id, 'connected'); setConnectionStatus('connected'); - console.log('[HTTP-TUNNEL] ✓ Connection established'); + logger.debug('[HTTP-TUNNEL] ✓ Connection established'); } catch (error) { - console.error('[HTTP-TUNNEL] Connection failed:', error); + logger.error('[HTTP-TUNNEL] Connection failed:', error); updateTabStatus(tab.id, 'failed'); setConnectionStatus('failed'); if (displayRef.current) { @@ -357,11 +358,11 @@ const ProtocolConnection: React.FC = ({ tab }) => { }; const setupGuacamole = () => { - console.log('[GUACAMOLE-CLIENT] Setting up Guacamole connection'); - console.log('[GUACAMOLE-CLIENT] Target:', tab.ip); - console.log('[GUACAMOLE-CLIENT] Protocol:', tab.protocol); - console.log('[GUACAMOLE-CLIENT] Username:', username); - console.log('[GUACAMOLE-CLIENT] Port:', (tab.protocol === 'rdp' ? 3389 : 5900)); + logger.debug('[GUACAMOLE-CLIENT] Setting up Guacamole connection'); + logger.debug('[GUACAMOLE-CLIENT] Target:', tab.ip); + logger.debug('[GUACAMOLE-CLIENT] Protocol:', tab.protocol); + logger.debug('[GUACAMOLE-CLIENT] Username:', username); + logger.debug('[GUACAMOLE-CLIENT] Port:', (tab.protocol === 'rdp' ? 3389 : 5900)); const params = new URLSearchParams({ host: tab.ip, @@ -380,17 +381,17 @@ const ProtocolConnection: React.FC = ({ tab }) => { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${wsProtocol}//${window.location.host}/api/v1/access/tunnel?${params.toString()}`; - console.log('[GUACAMOLE-CLIENT] WebSocket URL:', wsUrl.replace(/password=[^&]*/, 'password=***')); - console.log('[GUACAMOLE-CLIENT] Display dimensions:', displayRef.current?.clientWidth, 'x', displayRef.current?.clientHeight); + logger.debug('[GUACAMOLE-CLIENT] WebSocket URL:', wsUrl.replace(/password=[^&]*/, 'password=***')); + logger.debug('[GUACAMOLE-CLIENT] Display dimensions:', displayRef.current?.clientWidth, 'x', displayRef.current?.clientHeight); // Create tunnel with explicit subprotocol if needed, but Guacamole.WebSocketTunnel usually handles it const tunnel = new Guacamole.WebSocketTunnel(wsUrl); // Add tunnel event handlers for debugging (tunnel as any).onerror = (status: any) => { - console.error('[GUACAMOLE-CLIENT] Tunnel error:', status); + logger.error('[GUACAMOLE-CLIENT] Tunnel error:', status); // Fallback to HTTP tunnel if WebSocket fails - // console.log('[GUACAMOLE-CLIENT] WebSocket failed, falling back to HTTP tunnel...'); + // logger.debug('[GUACAMOLE-CLIENT] WebSocket failed, falling back to HTTP tunnel...'); // setupHTTPTunnel(); // Show error directly instead of falling back to incomplete HTTP tunnel @@ -407,7 +408,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { (tunnel as any).onstatechange = (state: number) => { const stateNames = ['IDLE', 'CONNECTING', 'WAITING', 'CONNECTED', 'DISCONNECTING', 'DISCONNECTED']; - console.log('[GUACAMOLE-CLIENT] Tunnel state changed:', stateNames[state] || state); + logger.debug('[GUACAMOLE-CLIENT] Tunnel state changed:', stateNames[state] || state); }; const client = new Guacamole.Client(tunnel); @@ -417,13 +418,13 @@ const ProtocolConnection: React.FC = ({ tab }) => { displayRef.current.innerHTML = ''; const displayElement = client.getDisplay().getElement(); displayRef.current.appendChild(displayElement); - console.log('[GUACAMOLE-CLIENT] Display element attached to DOM'); + logger.debug('[GUACAMOLE-CLIENT] Display element attached to DOM'); } client.onerror = (error) => { - console.error('[GUACAMOLE-CLIENT] Client error:', error); + logger.error('[GUACAMOLE-CLIENT] Client error:', error); const errorMsg = error?.message || JSON.stringify(error); - console.error('[GUACAMOLE-CLIENT] Error details:', errorMsg); + logger.error('[GUACAMOLE-CLIENT] Error details:', errorMsg); updateTabStatus(tab.id, 'failed'); // Show user-friendly error message with debugging info @@ -451,14 +452,14 @@ const ProtocolConnection: React.FC = ({ tab }) => { client.onstatechange = (state) => { const stateNames = ['IDLE', 'CONNECTING', 'WAITING', 'CONNECTED', 'DISCONNECTING', 'DISCONNECTED']; - console.log('[GUACAMOLE-CLIENT] Client state changed:', stateNames[state] || state); + logger.debug('[GUACAMOLE-CLIENT] Client state changed:', stateNames[state] || state); if (state === 3) { // CONNECTED - console.log('[GUACAMOLE-CLIENT] ✓ Successfully connected to remote host'); + logger.debug('[GUACAMOLE-CLIENT] ✓ Successfully connected to remote host'); updateTabStatus(tab.id, 'connected'); setConnectionStatus('connected'); } else if (state === 5) { // DISCONNECTED - console.log('[GUACAMOLE-CLIENT] Disconnected from remote host'); + logger.debug('[GUACAMOLE-CLIENT] Disconnected from remote host'); updateTabStatus(tab.id, 'disconnected'); setConnectionStatus('disconnected'); } @@ -478,9 +479,9 @@ const ProtocolConnection: React.FC = ({ tab }) => { client.sendKeyEvent(0, keysym); }; - console.log('[GUACAMOLE-CLIENT] Initiating connection...'); + logger.debug('[GUACAMOLE-CLIENT] Initiating connection...'); client.connect(''); - console.log('[GUACAMOLE-CLIENT] Connection initiated'); + logger.debug('[GUACAMOLE-CLIENT] Connection initiated'); }; @@ -496,7 +497,7 @@ const ProtocolConnection: React.FC = ({ tab }) => { protocol: tab.protocol, username, password - }).catch(err => console.error('Failed to save credential:', err)); + }).catch(err => logger.error('Failed to save credential:', err)); } if (tab.protocol === 'exploit') { diff --git a/frontend/src/components/ScanSettingsModal.tsx b/frontend/src/components/ScanSettingsModal.tsx index 927d0c5d..5ab04302 100644 --- a/frontend/src/components/ScanSettingsModal.tsx +++ b/frontend/src/components/ScanSettingsModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { logger } from '../utils/logger'; interface ScanSettings { autoScanEnabled: boolean; @@ -67,7 +68,7 @@ const ScanSettingsModal: React.FC = ({ passiveDiscoveryEnabled: discovery.passive_discovery || false }); } catch (err) { - console.log('Could not load agent settings, using defaults'); + logger.debug('Could not load agent settings, using defaults'); setLocalSettings(settings); } finally { setIsLoading(false); @@ -94,9 +95,9 @@ const ScanSettingsModal: React.FC = ({ 'X-Agent-POV': activeAgent.id } }); - console.log('Agent settings saved successfully'); + logger.debug('Agent settings saved successfully'); } catch (err) { - console.error('Failed to save agent settings:', err); + logger.error('Failed to save agent settings:', err); } finally { setIsSaving(false); } diff --git a/frontend/src/pages/Access.tsx b/frontend/src/pages/Access.tsx index 8ca4a5ff..4300d85c 100644 --- a/frontend/src/pages/Access.tsx +++ b/frontend/src/pages/Access.tsx @@ -8,6 +8,7 @@ import { assetService, Asset } from '../services/assetService'; import { Vulnerability } from '../store/scanStore'; import ProtocolConnection from '../components/ProtocolConnection'; import { CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; type AccessMode = 'login' | 'exploit'; @@ -328,7 +329,7 @@ const Access: React.FC = () => { try { setVaultCredentialsRaw(JSON.parse(stored)); } catch (e) { - console.error('Failed to load vault credentials', e); + logger.error('Failed to load vault credentials', e); } } }, []); @@ -345,15 +346,15 @@ const Access: React.FC = () => { try { const authToken = token || localStorage.getItem('token') || ''; if (!authToken) { - console.warn('No auth token available'); + logger.warn('No auth token available'); setLoading(false); return; } const allAssets = await assetService.getAssets(authToken, undefined, activeAgent?.id); - console.log('Fetched assets:', allAssets.length, 'POV agent:', activeAgent?.id || 'none'); + logger.debug('Fetched assets:', allAssets.length, 'POV agent:', activeAgent?.id || 'none'); setAssets(allAssets); } catch (error) { - console.error('Failed to fetch assets:', error); + logger.error('Failed to fetch assets:', error); } finally { setLoading(false); } diff --git a/frontend/src/pages/AccessHub.tsx b/frontend/src/pages/AccessHub.tsx index 0503e495..1c24783c 100644 --- a/frontend/src/pages/AccessHub.tsx +++ b/frontend/src/pages/AccessHub.tsx @@ -5,6 +5,7 @@ import { CyberPageTitle } from '../components/CyberUI'; import { assetService } from '../services/assetService'; import { useAuthStore } from '../store/authStore'; import { usePOV } from '../context/POVContext'; +import { logger } from '../utils/logger'; interface Asset { id: string; @@ -57,7 +58,7 @@ const AccessHub: React.FC = () => { const assets = await assetService.getAssets(token, undefined, activeAgent?.id); setDiscoveredAssets(assets.filter((a: Asset) => a.status === 'online')); } catch (e) { - console.error('Failed to fetch assets', e); + logger.error('Failed to fetch assets', e); } }; fetchAssets(); @@ -72,7 +73,7 @@ const AccessHub: React.FC = () => { try { setVaultCredentialsRaw(JSON.parse(stored)); } catch (e) { - console.error('Failed to load vault credentials', e); + logger.error('Failed to load vault credentials', e); } } }, []); diff --git a/frontend/src/pages/Agents.tsx b/frontend/src/pages/Agents.tsx index 45782cf3..8169a05e 100644 --- a/frontend/src/pages/Agents.tsx +++ b/frontend/src/pages/Agents.tsx @@ -4,6 +4,7 @@ import { useAuthStore } from '../store/authStore'; import { usePOV } from '../context/POVContext'; import { agentService, Agent, AgentCreate } from '../services/agentService'; import { CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; const Agents: React.FC = () => { const { token, logout, _hasHydrated } = useAuthStore(); @@ -100,7 +101,7 @@ const Agents: React.FC = () => { setPublicIP(data.ip); } } catch (error) { - console.error('Failed to detect public IP:', error); + logger.error('Failed to detect public IP:', error); } } }; @@ -119,17 +120,17 @@ const Agents: React.FC = () => { const loadAgents = async () => { if (!token) { - console.warn('[Agents] No token available, skipping agent load'); + logger.warn('[Agents] No token available, skipping agent load'); return; } try { const data = await agentService.getAgents(token); setAgents(data); } catch (error: any) { - console.error('Failed to load agents:', error); + logger.error('Failed to load agents:', error); // If 401, session expired - logout to trigger redirect to login if (error?.response?.status === 401 || (error instanceof Error && error.message.includes('401'))) { - console.error('[Agents] Authentication failed - session expired, logging out'); + logger.error('[Agents] Authentication failed - session expired, logging out'); logout(); return; } @@ -137,7 +138,7 @@ const Agents: React.FC = () => { }; const handleCreateAgent = async () => { - console.log('handleCreateAgent called, token:', token ? 'exists' : 'MISSING', 'name:', newAgent.name); + logger.debug('handleCreateAgent called, token:', token ? 'exists' : 'MISSING', 'name:', newAgent.name); if (!token) { alert('Not authenticated! Please log in first.'); return; @@ -150,14 +151,14 @@ const Agents: React.FC = () => { let createdAgent: Agent | null = null; try { - console.log('Creating agent...', newAgent); + logger.debug('Creating agent...', newAgent); createdAgent = await agentService.createAgent(token, newAgent); - console.log('Agent created:', createdAgent); + logger.debug('Agent created:', createdAgent); // Auto-download agent file with platform for Go try { const platform = newAgent.agent_type === 'go' ? selectedPlatform : undefined; - console.log('Generating agent file...'); + logger.debug('Generating agent file...'); const { content, filename, is_binary } = await agentService.generateAgent(token, createdAgent.id, platform); let blob: Blob; @@ -182,14 +183,14 @@ const Agents: React.FC = () => { document.body.removeChild(a); window.URL.revokeObjectURL(url); - console.log('Agent file downloaded successfully'); + logger.debug('Agent file downloaded successfully'); } catch (generateError) { - console.error('Failed to generate/download agent file:', generateError); + logger.error('Failed to generate/download agent file:', generateError); // Agent was created successfully, but file generation failed alert(`Agent created successfully, but failed to generate file: ${generateError instanceof Error ? generateError.message : 'Unknown error'}\n\nYou can download the agent file from the agent template card.`); } - console.log('Reloading agents...'); + logger.debug('Reloading agents...'); // Reload agents to get the fresh agent with download token await loadAgents(); @@ -219,9 +220,9 @@ const Agents: React.FC = () => { }); setUsePublicIP(false); setSelectedPlatform('linux-amd64'); - console.log('Agent creation flow complete!'); + logger.debug('Agent creation flow complete!'); } catch (error) { - console.error('Failed to create agent:', error); + logger.error('Failed to create agent:', error); // Only show this error if agent creation actually failed (not file generation) if (!createdAgent) { alert(`Failed to create agent: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -240,7 +241,7 @@ const Agents: React.FC = () => { setActiveAgent(null); } } catch (error) { - console.error('Failed to delete agent:', error); + logger.error('Failed to delete agent:', error); } }; @@ -256,7 +257,7 @@ const Agents: React.FC = () => { } alert(`Agent killed and deleted: ${result.status}`); } catch (error) { - console.error('Failed to kill agent:', error); + logger.error('Failed to kill agent:', error); alert('Failed to kill agent'); } }; @@ -288,7 +289,7 @@ const Agents: React.FC = () => { document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (error) { - console.error('Failed to generate agent:', error); + logger.error('Failed to generate agent:', error); } }; @@ -299,7 +300,7 @@ const Agents: React.FC = () => { setSourceCode({ code: source_code, filename, language }); setShowSourceModal(true); } catch (error) { - console.error('Failed to get agent source:', error); + logger.error('Failed to get agent source:', error); alert('Failed to load agent source code'); } }; diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx index be6c0aa8..5014d238 100644 --- a/frontend/src/pages/Assets.tsx +++ b/frontend/src/pages/Assets.tsx @@ -234,7 +234,7 @@ const Assets: React.FC = () => { }, 5000); } } catch (err) { - console.error('Discovery failed:', err); + logger.error('Discovery failed:', err); setIsScanning(false); setIsDiscovering(false); } @@ -256,7 +256,7 @@ const Assets: React.FC = () => { } } } catch (err) { - console.error("Failed to poll scan status", err); + logger.error("Failed to poll scan status", err); setIsScanning(false); setIsDiscovering(false); setActiveScanId(null); @@ -297,7 +297,7 @@ const Assets: React.FC = () => { await assetService.importPassiveDiscovery(token); fetchAssets(false); } catch (err) { - console.error('Failed to import passive discovery:', err); + logger.error('Failed to import passive discovery:', err); } }, [token, scanSettings.passiveDiscoveryEnabled, fetchAssets]); @@ -505,7 +505,7 @@ const Assets: React.FC = () => { await assetService.deleteAllAssets(token || ''); fetchAssets(true); } catch (err) { - console.error('Failed to clear assets:', err); + logger.error('Failed to clear assets:', err); } } }} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cd42fc6c..5afc5a7a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { dashboardService, SystemEvent } from '../services/dashboardService'; import { useAuthStore } from '../store/authStore'; import { usePOV } from '../context/POVContext'; import { CyberCard } from '../components/CyberUI'; +import { logger } from '../utils/logger'; // Time ago helper const formatTimeAgo = (timestamp: string): string => { @@ -167,7 +168,7 @@ const Dashboard: React.FC = () => { } } catch (error) { - console.error('Error fetching dashboard data:', error); + logger.error('Error fetching dashboard data:', error); } finally { setLoading(false); } diff --git a/frontend/src/pages/Exploit.tsx b/frontend/src/pages/Exploit.tsx index b6dd953d..ceb6489d 100644 --- a/frontend/src/pages/Exploit.tsx +++ b/frontend/src/pages/Exploit.tsx @@ -6,6 +6,7 @@ import { assetService, Asset } from '../services/assetService'; import { usePOV } from '../context/POVContext'; import { useNavigate } from 'react-router-dom'; import { CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; interface ExploitModule { id: string; @@ -84,7 +85,7 @@ const Exploit: React.FC = () => { // Show all assets (both scanned and unscanned) setAssets(allAssets); } catch (error) { - console.error('Failed to fetch assets:', error); + logger.error('Failed to fetch assets:', error); } finally { setLoading(false); } diff --git a/frontend/src/pages/Host.tsx b/frontend/src/pages/Host.tsx index 59bdde81..008bdb94 100644 --- a/frontend/src/pages/Host.tsx +++ b/frontend/src/pages/Host.tsx @@ -7,6 +7,7 @@ import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import ProtocolConnection from '../components/ProtocolConnection'; import { useAccessStore, Protocol } from '../store/accessStore'; +import { logger } from '../utils/logger'; const Host: React.FC = () => { const { token, logout, _hasHydrated } = useAuthStore(); @@ -141,7 +142,7 @@ const Host: React.FC = () => { ws.onerror = (error) => { term.writeln('\x1b[1;31m✗ Connection error\x1b[0m'); - console.error('WebSocket error:', error); + logger.error('WebSocket error:', error); }; ws.onclose = (event) => { @@ -198,7 +199,7 @@ const Host: React.FC = () => { setSystemInfo(info); setLoading(false); } catch (error: any) { - console.error('Failed to fetch system info:', error); + logger.error('Failed to fetch system info:', error); setLoading(false); if (error?.response?.status === 401) { // Session expired - auto logout and let app redirect to login @@ -218,7 +219,7 @@ const Host: React.FC = () => { setMetrics(data); setError(null); } catch (error: any) { - console.error('Failed to fetch metrics:', error); + logger.error('Failed to fetch metrics:', error); if (error?.response?.status === 401) { // Session expired - auto logout and let app redirect to login logout(); @@ -233,7 +234,7 @@ const Host: React.FC = () => { const data = await hostService.getProcesses(token, 15, activeAgent?.id); setProcesses(data); } catch (error: any) { - console.error('Failed to fetch processes:', error); + logger.error('Failed to fetch processes:', error); if (error?.response?.status === 401) { logout(); return; @@ -247,7 +248,7 @@ const Host: React.FC = () => { const data = await hostService.getNetworkConnections(token, 20, activeAgent?.id); setConnections(data); } catch (error: any) { - console.error('Failed to fetch connections:', error); + logger.error('Failed to fetch connections:', error); if (error?.response?.status === 401) { logout(); return; @@ -261,7 +262,7 @@ const Host: React.FC = () => { const data = await hostService.getDiskIO(token, activeAgent?.id); setDiskIO(data); } catch (error: any) { - console.error('Failed to fetch disk I/O:', error); + logger.error('Failed to fetch disk I/O:', error); if (error?.response?.status === 401) { logout(); return; @@ -276,7 +277,7 @@ const Host: React.FC = () => { setCurrentPath(data.current_path); setFileItems(data.items); } catch (error: any) { - console.error('Failed to browse directory:', error); + logger.error('Failed to browse directory:', error); if (error?.response?.status === 401) { logout(); return; @@ -294,7 +295,7 @@ const Host: React.FC = () => { setFileContent(content.content); setEditMode(false); } catch (error) { - console.error('Failed to read file:', error); + logger.error('Failed to read file:', error); } } }; @@ -306,7 +307,7 @@ const Host: React.FC = () => { setEditMode(false); alert('File saved successfully'); } catch (error) { - console.error('Failed to save file:', error); + logger.error('Failed to save file:', error); alert('Failed to save file'); } }; @@ -333,7 +334,7 @@ const Host: React.FC = () => { setTransferStatus('⚠ Directory access not supported in this browser'); } } catch (err) { - console.error('Directory picker cancelled or failed:', err); + logger.error('Directory picker cancelled or failed:', err); } }; @@ -455,7 +456,7 @@ const Host: React.FC = () => { [id]: { ...prev[id], status: 'paused' } })); } else { - console.error('Upload failed:', error); + logger.error('Upload failed:', error); setUploadProgress(prev => ({ ...prev, [id]: { ...prev[id], status: 'failed' } @@ -534,7 +535,7 @@ const Host: React.FC = () => { await writable.close(); setTransferStatus(`✓ Saved ${data.filename} to local directory`); } catch (err) { - console.error('Direct save failed, falling back to download:', err); + logger.error('Direct save failed, falling back to download:', err); triggerBrowserDownload(bytes, data.filename); } } else { @@ -558,7 +559,7 @@ const Host: React.FC = () => { throw new Error('Download failed'); } } catch (error) { - console.error('Download failed:', error); + logger.error('Download failed:', error); setDownloadProgress(prev => ({ ...prev, [downloadId]: { ...prev[downloadId], status: 'failed' } diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index f464999b..2f88f5fd 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '../store/authStore'; import { useScanStore, Vulnerability } from '../store/scanStore'; import { usePOV } from '../context/POVContext'; import { CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; interface AssetListItemProps { asset: Asset & { has_been_scanned?: boolean; last_detailed_scan?: string | null }; @@ -103,7 +104,7 @@ const Scans: React.FC = () => { setLoadingAssets(true); assetService.getAssets(token, undefined, activeAgent?.id) .then((resp) => setAssets(resp)) - .catch((err) => console.error('Failed to load assets', err)) + .catch((err) => logger.error('Failed to load assets', err)) .finally(() => setLoadingAssets(false)); }, [token, activeAgent]); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 719ae38f..77e318b1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { CyberPageTitle } from '../components/CyberUI'; import { usePOV, getPOVHeaders } from '../context/POVContext'; +import { logger } from '../utils/logger'; interface ScanSettings { profile_name: string; @@ -120,7 +121,7 @@ const Settings: React.FC = () => { }); setInterfaces(response.data); } catch (error) { - console.error('Error fetching interfaces:', error); + logger.error('Error fetching interfaces:', error); } }; @@ -138,7 +139,7 @@ const Settings: React.FC = () => { }); setInterfaces(response.data); } catch (error) { - console.error('Error fetching interfaces:', error); + logger.error('Error fetching interfaces:', error); } }; @@ -173,7 +174,7 @@ const Settings: React.FC = () => { }; setSettings(mergedSettings); } catch (error) { - console.error('Error fetching agent settings:', error); + logger.error('Error fetching agent settings:', error); setAgentSettings(null); setSettings(response.data); } @@ -185,7 +186,7 @@ const Settings: React.FC = () => { throw new Error('Invalid settings response structure'); } } catch (error) { - console.error('Error fetching settings:', error); + logger.error('Error fetching settings:', error); showMessage('error', 'Failed to load settings'); } finally { setLoading(false); @@ -222,7 +223,7 @@ const Settings: React.FC = () => { showMessage('success', `${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} settings saved successfully`); } } catch (error) { - console.error('Error saving settings:', error); + logger.error('Error saving settings:', error); showMessage('error', 'Failed to save settings'); } finally { setSaving(false); @@ -238,7 +239,7 @@ const Settings: React.FC = () => { setSettings(prev => prev ? { ...prev, [activeTab]: response.data.config } : null); showMessage('success', `${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} settings reset to defaults`); } catch (error) { - console.error('Error resetting settings:', error); + logger.error('Error resetting settings:', error); showMessage('error', 'Failed to reset settings'); } finally { setSaving(false); diff --git a/frontend/src/pages/Storm.tsx b/frontend/src/pages/Storm.tsx index 72a81285..560e9143 100644 --- a/frontend/src/pages/Storm.tsx +++ b/frontend/src/pages/Storm.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useAuthStore } from '../store/authStore'; import { CyberSectionHeader, CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; interface StormMetrics { packets_sent: number; @@ -89,7 +90,7 @@ const Storm: React.FC = () => { const data = await response.json(); setInterfaces(data); } catch (err) { - console.error('Failed to fetch interfaces:', err); + logger.error('Failed to fetch interfaces:', err); } }; @@ -101,7 +102,7 @@ const Storm: React.FC = () => { const data = await response.json(); setAssets(data); } catch (err) { - console.error('Failed to fetch assets:', err); + logger.error('Failed to fetch assets:', err); } }; @@ -119,7 +120,7 @@ const Storm: React.FC = () => { setPingStatus(data); return data.reachable; } catch (err) { - console.error('Failed to ping host:', err); + logger.error('Failed to ping host:', err); return false; } }; @@ -146,7 +147,7 @@ const Storm: React.FC = () => { }); } } catch (err) { - console.error('Failed to fetch metrics:', err); + logger.error('Failed to fetch metrics:', err); } }; @@ -223,7 +224,7 @@ const Storm: React.FC = () => { alert(`Failed to start storm: ${error.detail || 'Unknown error'}`); } } catch (err) { - console.error('Failed to start storm:', err); + logger.error('Failed to start storm:', err); alert('Failed to start storm: Network error'); } }; @@ -254,7 +255,7 @@ const Storm: React.FC = () => { await fetchMetrics(); } } catch (err) { - console.error('Failed to stop storm:', err); + logger.error('Failed to stop storm:', err); } }; diff --git a/frontend/src/pages/Topology.tsx b/frontend/src/pages/Topology.tsx index c56d1c57..ddeb183f 100644 --- a/frontend/src/pages/Topology.tsx +++ b/frontend/src/pages/Topology.tsx @@ -9,6 +9,7 @@ import { usePOV } from '../context/POVContext'; import { CyberPageTitle } from '../components/CyberUI'; import HostContextMenu from '../components/HostContextMenu'; import ConnectionContextMenu from '../components/ConnectionContextMenu'; +import { logger } from '../utils/logger'; interface GraphNode { id: string; @@ -105,7 +106,7 @@ const calculateLinkOpacity = ( const secondsAgo = serverCurrentTime - lastSeenTime; // Debug: log for troubleshooting (remove after fix confirmed) - // console.log('Link opacity calc:', { lastSeen, lastSeenTime, serverCurrentTime, secondsAgo, packetCount }); + // logger.debug('Link opacity calc:', { lastSeen, lastSeenTime, serverCurrentTime, secondsAgo, packetCount }); // Thresholds based on refresh rate for 3 clear levels const refreshSec = refreshRateMs / 1000; @@ -258,7 +259,7 @@ const Topology: React.FC = () => { setIsFullscreen(false); } } catch (err) { - console.error('Fullscreen error:', err); + logger.error('Fullscreen error:', err); } }; @@ -476,7 +477,7 @@ const Topology: React.FC = () => { }; setCaptureStatus(`${burstResult.connection_count}c`); } catch (burstError) { - console.warn('Burst capture failed, using stats:', burstError); + logger.warn('Burst capture failed, using stats:', burstError); trafficStats = await dashboardService.getTrafficStats(token, activeAgent?.id); setCaptureStatus(''); } @@ -715,7 +716,7 @@ const Topology: React.FC = () => { links: links }); } catch (err) { - console.error("Failed to fetch topology data", err); + logger.error("Failed to fetch topology data", err); } finally { setLoading(false); } diff --git a/frontend/src/pages/Traffic.tsx b/frontend/src/pages/Traffic.tsx index 351ec0f8..83624ff8 100644 --- a/frontend/src/pages/Traffic.tsx +++ b/frontend/src/pages/Traffic.tsx @@ -7,6 +7,7 @@ import { trafficService } from '../services/trafficService'; import PacketCrafting from '../components/PacketCrafting'; import Storm from './Storm'; import { CyberTabs, CyberPageTitle } from '../components/CyberUI'; +import { logger } from '../utils/logger'; interface Packet { id: string; @@ -241,7 +242,7 @@ const Traffic: React.FC = () => { const data = await response.json(); setInterfaces(data); } catch (err) { - console.error('Failed to fetch interfaces:', err); + logger.error('Failed to fetch interfaces:', err); } }; @@ -312,11 +313,11 @@ const Traffic: React.FC = () => { ws.onclose = () => { // WebSocket closed but capture may still be running }; - ws.onerror = (err) => console.error('WS Error:', err); + ws.onerror = (err) => logger.error('WS Error:', err); wsRef.current = ws; } } catch (err) { - console.error('Failed to check capture status:', err); + logger.error('Failed to check capture status:', err); } }; if (token) { @@ -385,7 +386,7 @@ const Traffic: React.FC = () => { setOnlineAssets(data); } } catch (err) { - console.error('Failed to fetch online assets:', err); + logger.error('Failed to fetch online assets:', err); } }; @@ -395,7 +396,7 @@ const Traffic: React.FC = () => { try { await trafficService.stopCapture(token!); } catch (err) { - console.error('Failed to stop capture:', err); + logger.error('Failed to stop capture:', err); } if (wsRef.current) wsRef.current.close(); setIsSniffing(false); @@ -427,10 +428,10 @@ const Traffic: React.FC = () => { // WebSocket closed but capture may still be running // Don't update isSniffing here - let it reflect backend state }; - ws.onerror = (err) => console.error('WS Error:', err); + ws.onerror = (err) => logger.error('WS Error:', err); wsRef.current = ws; } catch (err) { - console.error('Failed to start capture:', err); + logger.error('Failed to start capture:', err); setIsSniffing(false); setIsCapturing(false); } @@ -455,7 +456,7 @@ const Traffic: React.FC = () => { a.remove(); } } catch (err) { - console.error('Export failed:', err); + logger.error('Export failed:', err); } finally { setIsExporting(false); } diff --git a/frontend/src/services/accessService.ts b/frontend/src/services/accessService.ts index ace4f660..146513fb 100644 --- a/frontend/src/services/accessService.ts +++ b/frontend/src/services/accessService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { logger } from '../utils/logger'; const API_URL = '/api/v1/access'; @@ -17,7 +18,7 @@ export const accessService = { }); return response.data.credentials || []; } catch (error) { - console.error('Credential fetch error:', error); + logger.error('Credential fetch error:', error); return []; } }, diff --git a/frontend/src/services/assetService.ts b/frontend/src/services/assetService.ts index ee0b8c6b..2b7f516c 100644 --- a/frontend/src/services/assetService.ts +++ b/frontend/src/services/assetService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { logger } from '../utils/logger'; // Use relative path so Nginx proxy handles it const API_URL = '/api/v1'; @@ -41,7 +42,7 @@ export const assetService = { }); return response.data.assets || []; } catch (error) { - console.error('Asset fetch error:', error); + logger.error('Asset fetch error:', error); throw error; } }, diff --git a/frontend/src/services/hostService.ts b/frontend/src/services/hostService.ts index 6c220c7e..01203efa 100644 --- a/frontend/src/services/hostService.ts +++ b/frontend/src/services/hostService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { logger } from '../utils/logger'; // Use relative URL to go through the React dev server proxy const API_URL = '/api/v1/host'; @@ -135,7 +136,7 @@ export const hostService = { const response = await axios.get(url, { headers }); return response.data; } catch (error: any) { - console.error('[hostService.getSystemInfo] Error:', error.response?.status, error.message); + logger.error('[hostService.getSystemInfo] Error:', error.response?.status, error.message); throw error; } }, From 0de02e556d55341b4c095cbe0d91ab4fdc427347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:09:10 +0000 Subject: [PATCH 5/6] Update refactoring checklist to reflect completed items Co-authored-by: goranjovic55 <83976007+goranjovic55@users.noreply.github.com> --- docs/development/REFACTORING_STANDARDS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/development/REFACTORING_STANDARDS.md b/docs/development/REFACTORING_STANDARDS.md index 4d2c9b24..da8f466d 100644 --- a/docs/development/REFACTORING_STANDARDS.md +++ b/docs/development/REFACTORING_STANDARDS.md @@ -742,11 +742,11 @@ class Settings(BaseSettings): ## Implementation Checklist -- [ ] Create `backend/app/utils/logger.py` with structured logging -- [ ] Create `frontend/src/utils/logger.ts` with development-only logging -- [ ] Replace all `print()` statements in backend -- [ ] Replace all `console.log()` statements in frontend -- [ ] Add `response_model` to endpoints missing them +- [x] Create `backend/app/utils/logging.py` with structured logging ✅ +- [x] Create `frontend/src/utils/logger.ts` with development-only logging ✅ +- [x] Replace all `print()` statements in backend ✅ +- [x] Replace all `console.log()` statements in frontend ✅ +- [x] Add `response_model` to endpoints missing them ✅ - [ ] Create `backend/app/services/base_service.py` - [ ] Create `frontend/src/hooks/` directory with custom hooks - [ ] Enable TypeScript strict mode From 00ea77055adc3cabd1ea5f4bdebf39b2ee936490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:43:20 +0000 Subject: [PATCH 6/6] Implement remaining refactoring items: base service class and custom React hooks Co-authored-by: goranjovic55 <83976007+goranjovic55@users.noreply.github.com> --- backend/app/services/base_service.py | 208 +++++++++++++++++++++ docs/development/REFACTORING_STANDARDS.md | 6 +- frontend/src/hooks/index.ts | 12 ++ frontend/src/hooks/useAgents.ts | 217 ++++++++++++++++++++++ frontend/src/hooks/useAssets.ts | 150 +++++++++++++++ 5 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/base_service.py create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/useAgents.ts create mode 100644 frontend/src/hooks/useAssets.ts diff --git a/backend/app/services/base_service.py b/backend/app/services/base_service.py new file mode 100644 index 00000000..9d653a5c --- /dev/null +++ b/backend/app/services/base_service.py @@ -0,0 +1,208 @@ +""" +Base service class with reusable CRUD operations. + +All service classes should inherit from this base class to ensure +consistent patterns for database operations, error handling, and logging. +""" + +import logging +from typing import TypeVar, Generic, Optional, List, Type, Any, Dict +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, func +from sqlalchemy.orm import DeclarativeBase + +# Type variable for the SQLAlchemy model +ModelType = TypeVar("ModelType", bound=DeclarativeBase) + +logger = logging.getLogger(__name__) + + +class BaseService(Generic[ModelType]): + """ + Base service class providing common CRUD operations. + + Usage: + class AssetService(BaseService[Asset]): + def __init__(self, db: AsyncSession): + super().__init__(db, Asset) + """ + + def __init__(self, db: AsyncSession, model: Type[ModelType]): + """ + Initialize the base service. + + Args: + db: AsyncSession for database operations + model: The SQLAlchemy model class + """ + self.db = db + self.model = model + self.logger = logging.getLogger(self.__class__.__name__) + + async def get_by_id(self, id: UUID) -> Optional[ModelType]: + """ + Get a single record by its UUID. + + Args: + id: The UUID of the record + + Returns: + The record if found, None otherwise + """ + try: + query = select(self.model).where(self.model.id == id) + result = await self.db.execute(query) + return result.scalar_one_or_none() + except Exception as e: + self.logger.error("Error getting %s by id %s: %s", self.model.__name__, id, e) + raise + + async def get_all( + self, + skip: int = 0, + limit: int = 100, + order_by: Optional[Any] = None + ) -> List[ModelType]: + """ + Get all records with pagination. + + Args: + skip: Number of records to skip + limit: Maximum number of records to return + order_by: Column to order by (optional) + + Returns: + List of records + """ + try: + query = select(self.model).offset(skip).limit(limit) + if order_by is not None: + query = query.order_by(order_by) + result = await self.db.execute(query) + return list(result.scalars().all()) + except Exception as e: + self.logger.error("Error getting all %s: %s", self.model.__name__, e) + raise + + async def count(self) -> int: + """ + Get the total count of records. + + Returns: + Total number of records + """ + try: + query = select(func.count()).select_from(self.model) + result = await self.db.execute(query) + return result.scalar() or 0 + except Exception as e: + self.logger.error("Error counting %s: %s", self.model.__name__, e) + raise + + async def create(self, data: Dict[str, Any]) -> ModelType: + """ + Create a new record. + + Args: + data: Dictionary of field values + + Returns: + The created record + """ + try: + obj = self.model(**data) + self.db.add(obj) + await self.db.commit() + await self.db.refresh(obj) + self.logger.debug("Created %s with id %s", self.model.__name__, obj.id) + return obj + except Exception as e: + await self.db.rollback() + self.logger.error("Error creating %s: %s", self.model.__name__, e) + raise + + async def update(self, id: UUID, data: Dict[str, Any]) -> Optional[ModelType]: + """ + Update an existing record. + + Args: + id: The UUID of the record to update + data: Dictionary of field values to update + + Returns: + The updated record if found, None otherwise + """ + try: + obj = await self.get_by_id(id) + if not obj: + return None + + for field, value in data.items(): + if hasattr(obj, field): + setattr(obj, field, value) + + await self.db.commit() + await self.db.refresh(obj) + self.logger.debug("Updated %s with id %s", self.model.__name__, id) + return obj + except Exception as e: + await self.db.rollback() + self.logger.error("Error updating %s with id %s: %s", self.model.__name__, id, e) + raise + + async def delete(self, id: UUID) -> bool: + """ + Delete a record by its UUID. + + Args: + id: The UUID of the record to delete + + Returns: + True if deleted, False if not found + """ + try: + query = delete(self.model).where(self.model.id == id) + result = await self.db.execute(query) + await self.db.commit() + deleted = result.rowcount > 0 + if deleted: + self.logger.debug("Deleted %s with id %s", self.model.__name__, id) + return deleted + except Exception as e: + await self.db.rollback() + self.logger.error("Error deleting %s with id %s: %s", self.model.__name__, id, e) + raise + + async def delete_all(self) -> int: + """ + Delete all records. + + Returns: + Number of records deleted + """ + try: + query = delete(self.model) + result = await self.db.execute(query) + await self.db.commit() + count = result.rowcount + self.logger.info("Deleted %d %s records", count, self.model.__name__) + return count + except Exception as e: + await self.db.rollback() + self.logger.error("Error deleting all %s: %s", self.model.__name__, e) + raise + + async def exists(self, id: UUID) -> bool: + """ + Check if a record exists. + + Args: + id: The UUID of the record + + Returns: + True if exists, False otherwise + """ + query = select(func.count()).select_from(self.model).where(self.model.id == id) + result = await self.db.execute(query) + return (result.scalar() or 0) > 0 diff --git a/docs/development/REFACTORING_STANDARDS.md b/docs/development/REFACTORING_STANDARDS.md index da8f466d..8a92be5c 100644 --- a/docs/development/REFACTORING_STANDARDS.md +++ b/docs/development/REFACTORING_STANDARDS.md @@ -747,9 +747,9 @@ class Settings(BaseSettings): - [x] Replace all `print()` statements in backend ✅ - [x] Replace all `console.log()` statements in frontend ✅ - [x] Add `response_model` to endpoints missing them ✅ -- [ ] Create `backend/app/services/base_service.py` -- [ ] Create `frontend/src/hooks/` directory with custom hooks -- [ ] Enable TypeScript strict mode +- [x] Create `backend/app/services/base_service.py` ✅ +- [x] Create `frontend/src/hooks/` directory with custom hooks ✅ +- [x] Enable TypeScript strict mode ✅ (already enabled in tsconfig.json) - [ ] Add comprehensive unit tests - [ ] Update API documentation diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 00000000..9c85696c --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,12 @@ +/** + * Custom React Hooks for API calls + * + * These hooks encapsulate API logic with @tanstack/react-query for: + * - Automatic caching + * - Background refetching + * - Loading/error states + * - Mutation handling + */ + +export { useAssets, useDeleteAllAssets, useStartScan, useScanStatus } from './useAssets'; +export { useAgents, useCreateAgent, useDeleteAgent, useKillAgent } from './useAgents'; diff --git a/frontend/src/hooks/useAgents.ts b/frontend/src/hooks/useAgents.ts new file mode 100644 index 00000000..5ea056c1 --- /dev/null +++ b/frontend/src/hooks/useAgents.ts @@ -0,0 +1,217 @@ +/** + * Custom hooks for Agent API operations + * + * Provides React Query hooks for fetching and mutating agent data + * with automatic caching, refetching, and error handling. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { agentService, Agent, AgentCreate, AgentUpdate } from '../services/agentService'; +import { useAuthStore } from '../store/authStore'; +import { logger } from '../utils/logger'; + +// Query key factory for consistent cache invalidation +export const agentKeys = { + all: ['agents'] as const, + lists: () => [...agentKeys.all, 'list'] as const, + list: () => [...agentKeys.lists()] as const, + details: () => [...agentKeys.all, 'detail'] as const, + detail: (id: string) => [...agentKeys.details(), id] as const, +}; + +/** + * Hook for fetching all agents + * + * @param options - Additional React Query options + * @returns Query result with agents data, loading state, and error + * + * @example + * const { data: agents, isLoading, error } = useAgents(); + */ +export function useAgents(options?: { enabled?: boolean }) { + const { token } = useAuthStore(); + + return useQuery({ + queryKey: agentKeys.list(), + queryFn: async () => { + if (!token) throw new Error('No authentication token'); + return agentService.getAgents(token); + }, + enabled: !!token && (options?.enabled !== false), + staleTime: 10000, // Consider data stale after 10 seconds + refetchInterval: 30000, // Refetch every 30 seconds to check agent status + refetchOnWindowFocus: true, + }); +} + +/** + * Hook for fetching a single agent by ID + * + * @param agentId - The agent ID to fetch + * @returns Query result with agent data + * + * @example + * const { data: agent } = useAgent(agentId); + */ +export function useAgent(agentId: string | null) { + const { token } = useAuthStore(); + + return useQuery({ + queryKey: agentKeys.detail(agentId || ''), + queryFn: async () => { + if (!token || !agentId) throw new Error('Missing token or agent ID'); + return agentService.getAgent(token, agentId); + }, + enabled: !!token && !!agentId, + staleTime: 10000, + }); +} + +/** + * Hook for creating a new agent + * + * @returns Mutation object with mutate function that accepts agent data + * + * @example + * const { mutate: createAgent, isLoading } = useCreateAgent(); + * createAgent({ name: 'Agent1', agent_type: 'python', ... }); + */ +export function useCreateAgent() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (agentData: AgentCreate) => { + if (!token) throw new Error('No authentication token'); + return agentService.createAgent(token, agentData); + }, + onSuccess: (newAgent) => { + // Invalidate agents list to trigger refetch + queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); + logger.debug('Agent created:', newAgent.id); + }, + onError: (error) => { + logger.error('Failed to create agent:', error); + }, + }); +} + +/** + * Hook for updating an agent + * + * @returns Mutation object with mutate function + * + * @example + * const { mutate: updateAgent } = useUpdateAgent(); + * updateAgent({ id: agentId, data: { name: 'New Name' } }); + */ +export function useUpdateAgent() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, data }: { id: string; data: AgentUpdate }) => { + if (!token) throw new Error('No authentication token'); + return agentService.updateAgent(token, id, data); + }, + onSuccess: (updatedAgent, { id }) => { + // Update cache for this specific agent + queryClient.setQueryData(agentKeys.detail(id), updatedAgent); + // Invalidate list to reflect changes + queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); + logger.debug('Agent updated:', id); + }, + onError: (error) => { + logger.error('Failed to update agent:', error); + }, + }); +} + +/** + * Hook for deleting an agent + * + * @returns Mutation object with mutate function that accepts agent ID + * + * @example + * const { mutate: deleteAgent } = useDeleteAgent(); + * deleteAgent(agentId); + */ +export function useDeleteAgent() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (agentId: string) => { + if (!token) throw new Error('No authentication token'); + await agentService.deleteAgent(token, agentId); + return agentId; + }, + onSuccess: (agentId) => { + // Invalidate agents list + queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); + // Remove from cache + queryClient.removeQueries({ queryKey: agentKeys.detail(agentId) }); + logger.debug('Agent deleted:', agentId); + }, + onError: (error) => { + logger.error('Failed to delete agent:', error); + }, + }); +} + +/** + * Hook for killing an agent (terminate and delete) + * + * @returns Mutation object with mutate function that accepts agent ID + * + * @example + * const { mutate: killAgent } = useKillAgent(); + * killAgent(agentId); + */ +export function useKillAgent() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (agentId: string) => { + if (!token) throw new Error('No authentication token'); + return agentService.killAgent(token, agentId); + }, + onSuccess: (result, agentId) => { + // Invalidate agents list + queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); + // Remove from cache + queryClient.removeQueries({ queryKey: agentKeys.detail(agentId) }); + logger.debug('Agent killed:', agentId); + }, + onError: (error) => { + logger.error('Failed to kill agent:', error); + }, + }); +} + +/** + * Hook for generating agent file + * + * @returns Mutation object with mutate function + * + * @example + * const { mutate: generateAgent } = useGenerateAgent(); + * generateAgent({ agentId, platform: 'linux-amd64' }); + */ +export function useGenerateAgent() { + const { token } = useAuthStore(); + + return useMutation({ + mutationFn: async ({ agentId, platform }: { agentId: string; platform?: string }) => { + if (!token) throw new Error('No authentication token'); + return agentService.generateAgent(token, agentId, platform); + }, + onSuccess: () => { + logger.debug('Agent file generated'); + }, + onError: (error) => { + logger.error('Failed to generate agent:', error); + }, + }); +} diff --git a/frontend/src/hooks/useAssets.ts b/frontend/src/hooks/useAssets.ts new file mode 100644 index 00000000..6e0e7b1f --- /dev/null +++ b/frontend/src/hooks/useAssets.ts @@ -0,0 +1,150 @@ +/** + * Custom hooks for Asset API operations + * + * Provides React Query hooks for fetching and mutating asset data + * with automatic caching, refetching, and error handling. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { assetService, Asset } from '../services/assetService'; +import { useAuthStore } from '../store/authStore'; +import { usePOV } from '../context/POVContext'; +import { logger } from '../utils/logger'; + +// Query key factory for consistent cache invalidation +export const assetKeys = { + all: ['assets'] as const, + lists: () => [...assetKeys.all, 'list'] as const, + list: (filters: { status?: string; agentPOV?: string }) => + [...assetKeys.lists(), filters] as const, + details: () => [...assetKeys.all, 'detail'] as const, + detail: (id: string) => [...assetKeys.details(), id] as const, +}; + +/** + * Hook for fetching assets with optional filters + * + * @param status - Optional status filter ('online', 'offline', 'unknown') + * @param options - Additional React Query options + * @returns Query result with assets data, loading state, and error + * + * @example + * const { data: assets, isLoading, error } = useAssets('online'); + */ +export function useAssets(status?: string, options?: { enabled?: boolean }) { + const { token } = useAuthStore(); + const { activeAgent } = usePOV(); + + return useQuery({ + queryKey: assetKeys.list({ status, agentPOV: activeAgent?.id }), + queryFn: async () => { + if (!token) throw new Error('No authentication token'); + return assetService.getAssets(token, status, activeAgent?.id); + }, + enabled: !!token && (options?.enabled !== false), + staleTime: 30000, // Consider data stale after 30 seconds + refetchInterval: 60000, // Refetch every 60 seconds in background + refetchOnWindowFocus: true, + }); +} + +/** + * Hook for deleting all assets + * + * @returns Mutation object with mutate function and status + * + * @example + * const { mutate: deleteAll, isLoading } = useDeleteAllAssets(); + * deleteAll(); // Clears all assets and invalidates cache + */ +export function useDeleteAllAssets() { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!token) throw new Error('No authentication token'); + return assetService.deleteAllAssets(token); + }, + onSuccess: () => { + // Invalidate all asset queries to trigger refetch + queryClient.invalidateQueries({ queryKey: assetKeys.all }); + logger.debug('All assets deleted, cache invalidated'); + }, + onError: (error) => { + logger.error('Failed to delete assets:', error); + }, + }); +} + +/** + * Hook for starting a network scan + * + * @returns Mutation object with mutate function that accepts scan parameters + * + * @example + * const { mutate: startScan } = useStartScan(); + * startScan({ network: '192.168.1.0/24', scanType: 'arp' }); + */ +export function useStartScan() { + const { token } = useAuthStore(); + const { activeAgent } = usePOV(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + network = '172.21.0.0/24', + scanType = 'basic' + }: { + network?: string; + scanType?: string; + }) => { + if (!token) throw new Error('No authentication token'); + return assetService.startScan(token, network, scanType, activeAgent?.id); + }, + onSuccess: (data) => { + logger.debug('Scan started:', data); + // Invalidate assets after scan completes + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: assetKeys.all }); + }, 5000); + }, + onError: (error) => { + logger.error('Failed to start scan:', error); + }, + }); +} + +/** + * Hook for polling scan status + * + * @param scanId - The scan ID to check status for + * @param enabled - Whether to enable polling + * @returns Query result with scan status + * + * @example + * const { data: status } = useScanStatus(scanId, isScanning); + */ +export function useScanStatus(scanId: string | null, enabled: boolean = true) { + const { token } = useAuthStore(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['scanStatus', scanId], + queryFn: async () => { + if (!token || !scanId) throw new Error('Missing token or scan ID'); + return assetService.getScanStatus(token, scanId); + }, + enabled: !!token && !!scanId && enabled, + refetchInterval: (query) => { + // Stop polling when scan is complete + const status = query.state.data?.status; + if (status === 'completed' || status === 'failed') { + // Invalidate assets to refresh list + queryClient.invalidateQueries({ queryKey: assetKeys.all }); + return false; + } + return 2000; // Poll every 2 seconds + }, + }); +}