diff --git a/Dockerfile b/Dockerfile index 9b83e87..cae5664 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # Multi-stage build for IPMI Fan Control # Backend stage -FROM python:3.11-slim as backend-builder +FROM python:3.11-slim AS backend-builder -WORKDIR /app/backend +WORKDIR /app # Install system dependencies for ipmitool RUN apt-get update && apt-get install -y \ @@ -18,16 +18,16 @@ COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy backend source -COPY backend/ . +COPY backend/ ./backend/ # Frontend stage -FROM node:20-alpine as frontend-builder +FROM node:20-alpine AS frontend-builder WORKDIR /app/frontend # Install dependencies COPY frontend/package*.json ./ -RUN npm ci +RUN npm install # Copy frontend source and build COPY frontend/ . @@ -56,10 +56,9 @@ COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist # Create data directory RUN mkdir -p /app/data -# Environment variables -ENV PYTHONPATH=/app/backend +# Set Python path to include backend directory +ENV PYTHONPATH=/app ENV DATA_DIR=/app/data -ENV SECRET_KEY=change-me-in-production # Expose port EXPOSE 8000 @@ -68,5 +67,5 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 -# Start command -CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Start command - use absolute module path +CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..870924f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 IPMI Fan Control + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/auth.py b/backend/auth.py index 29304a1..52f4dbe 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -12,7 +12,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from config import settings +from backend.config import settings logger = logging.getLogger(__name__) diff --git a/backend/database.py b/backend/database.py index fa26585..04f9093 100644 --- a/backend/database.py +++ b/backend/database.py @@ -10,7 +10,7 @@ from sqlalchemy import ( from sqlalchemy.orm import declarative_base, sessionmaker, relationship, Session from sqlalchemy.pool import StaticPool -from config import settings, DATA_DIR +from backend.config import settings, DATA_DIR # Create engine if settings.DATABASE_URL.startswith("sqlite"): diff --git a/backend/fan_control.py b/backend/fan_control.py index 81b5606..fdc4eb4 100644 --- a/backend/fan_control.py +++ b/backend/fan_control.py @@ -9,12 +9,12 @@ from enum import Enum from sqlalchemy.orm import Session -from database import ( +from backend.database import ( Server, FanCurve, SensorData, FanData, SystemLog, get_db, SessionLocal ) -from ipmi_client import IPMIClient, TemperatureReading -from config import settings +from backend.ipmi_client import IPMIClient, TemperatureReading +from backend.config import settings logger = logging.getLogger(__name__) @@ -168,7 +168,7 @@ class FanController: return # Create IPMI client - from auth import decrypt_password + from backend.auth import decrypt_password client = IPMIClient( host=server.host, username=server.username, diff --git a/backend/ipmi_client.py b/backend/ipmi_client.py index 7f226f9..6598598 100644 --- a/backend/ipmi_client.py +++ b/backend/ipmi_client.py @@ -7,7 +7,7 @@ from typing import List, Dict, Optional, Tuple, Any from dataclasses import dataclass from datetime import datetime -from config import settings +from backend.config import settings logger = logging.getLogger(__name__) diff --git a/backend/main.py b/backend/main.py index 3d3d3e3..5dfe843 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,15 +11,15 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from config import settings -from database import init_db, get_db, is_setup_complete, set_setup_complete, User, Server, FanCurve, SystemLog, SensorData, FanData -from auth import ( +from backend.config import settings +from backend.database import init_db, get_db, is_setup_complete, set_setup_complete, User, Server, FanCurve, SystemLog, SensorData, FanData +from backend.auth import ( verify_password, get_password_hash, create_access_token, decode_access_token, encrypt_password, decrypt_password ) -from ipmi_client import IPMIClient -from fan_control import fan_controller, FanCurveManager, initialize_fan_controller, shutdown_fan_controller -from schemas import ( +from backend.ipmi_client import IPMIClient +from backend.fan_control import fan_controller, FanCurveManager, initialize_fan_controller, shutdown_fan_controller +from backend.schemas import ( UserCreate, UserLogin, UserResponse, Token, TokenData, ServerCreate, ServerUpdate, ServerResponse, ServerDetailResponse, ServerStatusResponse, FanCurveCreate, FanCurveResponse, FanCurvePoint, diff --git a/backend/requirements.txt b/backend/requirements.txt index 6a3f5e7..1eba13a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +cryptography==42.0.0 python-multipart==0.0.6 aiofiles==23.2.1 httpx==0.26.0 @@ -12,3 +13,5 @@ apscheduler==3.10.4 psutil==5.9.8 asyncpg==0.29.0 aiosqlite==0.19.0 +pytest==7.4.4 +pytest-asyncio==0.23.3 diff --git a/backend/run.py b/backend/run.py index ade372b..3c78771 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """Entry point for running the application directly.""" import uvicorn -from config import settings +from backend.config import settings if __name__ == "__main__": uvicorn.run( - "main:app", + "backend.main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG, diff --git a/docker-compose.yml b/docker-compose.yml index da216a8..1512613 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,18 +8,18 @@ services: ports: - "8000:8000" environment: - - SECRET_KEY=${SECRET_KEY:-your-secure-secret-key-change-in-production} + - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production} - DATA_DIR=/app/data - PANIC_TIMEOUT_SECONDS=${PANIC_TIMEOUT_SECONDS:-60} - PANIC_FAN_SPEED=${PANIC_FAN_SPEED:-100} + - DEBUG=${DEBUG:-true} + - PYTHONUNBUFFERED=1 volumes: - ./data:/app/data networks: - ipmi-network - # Required for IPMI access to the local network - # If your servers are on a different network, adjust accordingly - extra_hosts: - - "host.docker.internal:host-gateway" + # For Windows Docker Desktop, we need this for proper shutdown + stop_grace_period: 10s networks: ipmi-network: diff --git a/docker-config/nginx.conf b/docker-config/nginx.conf new file mode 100644 index 0000000..a8a4366 --- /dev/null +++ b/docker-config/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name localhost; + + client_max_body_size 50M; + + location / { + proxy_pass http://ipmi-fan-control:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /api { + proxy_pass http://ipmi-fan-control:8000/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 1215fd3..3a71fea 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,7 +12,6 @@ import { Typography, IconButton, Divider, - Avatar, Menu, MenuItem, } from '@mui/material'; diff --git a/frontend/src/hooks/.gitkeep b/frontend/src/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ef4c0ba..1fc93be 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -26,7 +26,7 @@ import { NavigateNext as NextIcon, } from '@mui/icons-material'; import { dashboardApi } from '../utils/api'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'; + export default function Dashboard() { const navigate = useNavigate(); diff --git a/frontend/src/pages/FanCurves.tsx b/frontend/src/pages/FanCurves.tsx index 6cb8196..d683634 100644 --- a/frontend/src/pages/FanCurves.tsx +++ b/frontend/src/pages/FanCurves.tsx @@ -7,8 +7,6 @@ import { Paper, Grid, Button, - Card, - CardContent, List, ListItem, ListItemText, diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx index f5eba52..0ac23dd 100644 --- a/frontend/src/pages/Logs.tsx +++ b/frontend/src/pages/Logs.tsx @@ -25,7 +25,7 @@ import { Speed as SpeedIcon, } from '@mui/icons-material'; import { logsApi, serversApi } from '../utils/api'; -import type { SystemLog } from '../types'; + const LOGS_PER_PAGE = 25; diff --git a/frontend/src/pages/ServerDetail.tsx b/frontend/src/pages/ServerDetail.tsx index 3cb3030..d73b9eb 100644 --- a/frontend/src/pages/ServerDetail.tsx +++ b/frontend/src/pages/ServerDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { @@ -29,11 +29,10 @@ import { Speed as SpeedIcon, Thermostat as TempIcon, Power as PowerIcon, - Refresh as RefreshIcon, } from '@mui/icons-material'; import { serversApi, fanControlApi, dashboardApi } from '../utils/api'; -import type { TemperatureReading, FanReading } from '../types'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'; + + interface TabPanelProps { children?: React.ReactNode; @@ -66,7 +65,7 @@ export default function ServerDetail() { }, }); - const { data: sensors, isLoading: isSensorsLoading, refetch: refetchSensors } = useQuery({ + const { data: sensors } = useQuery({ queryKey: ['sensors', serverId], queryFn: async () => { const response = await serversApi.getSensors(serverId); @@ -124,7 +123,7 @@ export default function ServerDetail() { } const cpuTemps = sensors?.temperatures.filter((t) => t.location.startsWith('cpu')) || []; - const otherTemps = sensors?.temperatures.filter((t) => !t.location.startsWith('cpu')) || []; + return ( diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..094a07a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for IPMI Fan Control diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..363cf33 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,49 @@ +"""Tests for authentication module.""" +import pytest +from backend.auth import ( + get_password_hash, + verify_password, + create_access_token, + decode_access_token, + encrypt_password, + decrypt_password, +) + + +def test_password_hashing(): + """Test password hashing and verification.""" + password = "testpassword123" + hashed = get_password_hash(password) + + assert hashed != password + assert verify_password(password, hashed) + assert not verify_password("wrongpassword", hashed) + + +def test_jwt_tokens(): + """Test JWT token creation and decoding.""" + data = {"sub": "testuser"} + token = create_access_token(data) + + assert token is not None + + decoded = decode_access_token(token) + assert decoded is not None + assert decoded["sub"] == "testuser" + + +def test_invalid_token(): + """Test decoding invalid token.""" + decoded = decode_access_token("invalid.token.here") + assert decoded is None + + +def test_password_encryption(): + """Test password encryption and decryption.""" + password = "secret_password" + encrypted = encrypt_password(password) + + assert encrypted != password + + decrypted = decrypt_password(encrypted) + assert decrypted == password diff --git a/tests/test_fan_curve.py b/tests/test_fan_curve.py new file mode 100644 index 0000000..d0c4dd7 --- /dev/null +++ b/tests/test_fan_curve.py @@ -0,0 +1,83 @@ +"""Tests for fan curve logic.""" +import pytest +from backend.fan_control import FanCurveManager, FanCurvePoint + + +def test_parse_curve(): + """Test parsing fan curve from JSON.""" + json_data = '[{"temp": 30, "speed": 20}, {"temp": 50, "speed": 50}, {"temp": 70, "speed": 100}]' + curve = FanCurveManager.parse_curve(json_data) + + assert len(curve) == 3 + assert curve[0].temp == 30 + assert curve[0].speed == 20 + + +def test_parse_invalid_curve(): + """Test parsing invalid curve returns default.""" + curve = FanCurveManager.parse_curve("invalid json") + + assert len(curve) == 6 # Default curve has 6 points + assert curve[0].temp == 30 + assert curve[0].speed == 10 + + +def test_calculate_speed_below_min(): + """Test speed calculation below minimum temperature.""" + curve = [ + FanCurvePoint(30, 10), + FanCurvePoint(50, 50), + FanCurvePoint(70, 100), + ] + + speed = FanCurveManager.calculate_speed(curve, 20) + assert speed == 10 + + +def test_calculate_speed_above_max(): + """Test speed calculation above maximum temperature.""" + curve = [ + FanCurvePoint(30, 10), + FanCurvePoint(50, 50), + FanCurvePoint(70, 100), + ] + + speed = FanCurveManager.calculate_speed(curve, 80) + assert speed == 100 + + +def test_calculate_speed_interpolation(): + """Test speed calculation with interpolation.""" + curve = [ + FanCurvePoint(30, 10), + FanCurvePoint(50, 50), + FanCurvePoint(70, 100), + ] + + # At 40°C, should be halfway between 10% and 50% + speed = FanCurveManager.calculate_speed(curve, 40) + assert speed == 30 + + +def test_calculate_speed_exact_point(): + """Test speed calculation at exact curve point.""" + curve = [ + FanCurvePoint(30, 10), + FanCurvePoint(50, 50), + ] + + speed = FanCurveManager.calculate_speed(curve, 50) + assert speed == 50 + + +def test_serialize_curve(): + """Test serializing curve to JSON.""" + points = [ + FanCurvePoint(30, 10), + FanCurvePoint(50, 50), + ] + + json_data = FanCurveManager.serialize_curve(points) + assert "30" in json_data + assert "10" in json_data + assert "50" in json_data diff --git a/tests/test_ipmi_client.py b/tests/test_ipmi_client.py new file mode 100644 index 0000000..686c101 --- /dev/null +++ b/tests/test_ipmi_client.py @@ -0,0 +1,85 @@ +"""Tests for IPMI client.""" +import pytest +from unittest.mock import Mock, patch +from backend.ipmi_client import IPMIClient, TemperatureReading + + +def test_fan_mapping(): + """Test fan ID mapping.""" + assert IPMIClient.FAN_MAPPING["0x00"] == 1 + assert IPMIClient.FAN_MAPPING["0x06"] == 7 + + +def test_hex_to_percent_conversion(): + """Test hex to percent conversion mapping.""" + assert IPMIClient.HEX_TO_PERCENT["0x00"] == 0 + assert IPMIClient.HEX_TO_PERCENT["0x64"] == 100 + assert IPMIClient.PERCENT_TO_HEX[50] == "0x32" + assert IPMIClient.PERCENT_TO_HEX[100] == "0x64" + + +def test_determine_temp_location(): + """Test temperature location detection.""" + client = IPMIClient("192.168.1.1", "user", "pass") + + assert client._determine_temp_location("CPU1 Temp") == "cpu1" + assert client._determine_temp_location("CPU2 Temp") == "cpu2" + assert client._determine_temp_location("Processor 1") == "cpu1" + assert client._determine_temp_location("Inlet Temp") == "inlet" + assert client._determine_temp_location("Exhaust Temp") == "exhaust" + assert client._determine_temp_location("DIMM 1") == "memory" + assert client._determine_temp_location("Unknown Sensor") == "other" + + +def test_determine_sensor_type(): + """Test sensor type detection.""" + client = IPMIClient("192.168.1.1", "user", "pass") + + assert client._determine_sensor_type("CPU Temp") == "temperature" + assert client._determine_sensor_type("Fan 1 RPM") == "fan" + assert client._determine_sensor_type("12V") == "voltage" + assert client._determine_sensor_type("Power Supply") == "power" + + +def test_parse_sensor_value(): + """Test sensor value parsing.""" + client = IPMIClient("192.168.1.1", "user", "pass") + + value, unit = client._parse_sensor_value("45 degrees C") + assert value == 45.0 + assert unit == "°C" + + value, unit = client._parse_sensor_value("4200 RPM") + assert value == 4200.0 + assert unit == "RPM" + + value, unit = client._parse_sensor_value("12.05 Volts") + assert value == 12.05 + assert unit == "V" + + value, unit = client._parse_sensor_value("250 Watts") + assert value == 250.0 + assert unit == "W" + + +@patch('backend.ipmi_client.subprocess.run') +def test_test_connection(mock_run): + """Test connection test method.""" + mock_run.return_value = Mock(returncode=0, stdout="", stderr="") + + client = IPMIClient("192.168.1.1", "user", "pass") + result = client.test_connection() + + assert result is True + mock_run.assert_called_once() + + +@patch('backend.ipmi_client.subprocess.run') +def test_test_connection_failure(mock_run): + """Test connection test with failure.""" + mock_run.return_value = Mock(returncode=1, stdout="", stderr="Error") + + client = IPMIClient("192.168.1.1", "user", "pass") + result = client.test_connection() + + assert result is False