Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
075b0ec297 | ||
|
|
49be935b5d |
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **System Stats API** - Host system resource monitoring for dashboard widgets
|
||||||
|
- `GET /tools/system/stats` - Real-time host system statistics
|
||||||
|
- CPU: usage percentage, core count, load averages
|
||||||
|
- Memory: usage percentage, total/used/available bytes
|
||||||
|
- Disks: all mounted filesystems with usage stats (auto-discovers mounts)
|
||||||
|
- Network: total bytes sent/received
|
||||||
|
- GPU/VRAM: NVIDIA GPU memory usage (via nvidia-smi if available)
|
||||||
|
- `psutil` dependency for cross-platform system metrics
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `link_type` field to quick links for iframe vs new tab behavior
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-03
|
## [1.6.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add_link_type_to_quick_links
|
||||||
|
|
||||||
|
Revision ID: f0349c95aa5d
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-01-03 13:14:46.911770+00:00
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f0349c95aa5d'
|
||||||
|
down_revision: Union[str, None] = '003'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('quick_links', sa.Column('link_type', sa.String(length=20), nullable=True, server_default='iframe'))
|
||||||
|
# Update existing rows to have the default value
|
||||||
|
op.execute("UPDATE quick_links SET link_type = 'iframe' WHERE link_type IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('quick_links', 'link_type')
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.6.0"
|
version = "1.7.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ python-dotenv~=1.0.0
|
|||||||
python-json-logger~=2.0.0
|
python-json-logger~=2.0.0
|
||||||
pytz~=2024.1
|
pytz~=2024.1
|
||||||
dnspython~=2.7.0
|
dnspython~=2.7.0
|
||||||
|
psutil~=6.1.0
|
||||||
|
|
||||||
# Authentication & Security
|
# Authentication & Security
|
||||||
PyJWT[crypto]>=2.9.0
|
PyJWT[crypto]>=2.9.0
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class QuickLink(Base):
|
|||||||
# Ordering and display
|
# Ordering and display
|
||||||
position = Column(Integer, default=0)
|
position = Column(Integer, default=0)
|
||||||
is_visible = Column(Boolean, default=True)
|
is_visible = Column(Boolean, default=True)
|
||||||
|
link_type = Column(String(20), default="iframe") # "iframe", "new_tab", etc.
|
||||||
|
|
||||||
# Styling
|
# Styling
|
||||||
color = Column(String(20), nullable=True) # Hex color for the link card
|
color = Column(String(20), nullable=True) # Hex color for the link card
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class QuickLinkCreate(QuickLinkBase):
|
|||||||
"""Schema for creating a quick link"""
|
"""Schema for creating a quick link"""
|
||||||
position: Optional[int] = Field(0, ge=0, description="Display position")
|
position: Optional[int] = Field(0, ge=0, description="Display position")
|
||||||
is_visible: Optional[bool] = Field(True, description="Whether link is visible")
|
is_visible: Optional[bool] = Field(True, description="Whether link is visible")
|
||||||
|
link_type: Optional[str] = Field("iframe", max_length=20, description="Link type: iframe, new_tab")
|
||||||
|
|
||||||
|
|
||||||
class QuickLinkUpdate(BaseSchema):
|
class QuickLinkUpdate(BaseSchema):
|
||||||
@@ -37,6 +38,7 @@ class QuickLinkUpdate(BaseSchema):
|
|||||||
category: Optional[str] = Field(None, max_length=50)
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
position: Optional[int] = Field(None, ge=0)
|
position: Optional[int] = Field(None, ge=0)
|
||||||
is_visible: Optional[bool] = None
|
is_visible: Optional[bool] = None
|
||||||
|
link_type: Optional[str] = Field(None, max_length=20)
|
||||||
color: Optional[str] = Field(None, max_length=20)
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
background_color: Optional[str] = Field(None, max_length=20)
|
background_color: Optional[str] = Field(None, max_length=20)
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ class QuickLinkResponse(QuickLinkBase):
|
|||||||
user_id: Optional[str] = None
|
user_id: Optional[str] = None
|
||||||
position: int
|
position: int
|
||||||
is_visible: bool
|
is_visible: bool
|
||||||
|
link_type: str = "iframe"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Tools Domain
|
Tools Domain
|
||||||
|
|
||||||
Provides utility tool endpoints including DNS lookups.
|
Provides utility tool endpoints including DNS lookups and system stats.
|
||||||
"""
|
"""
|
||||||
from src.domains.tools.controller import tools_controller
|
from src.domains.tools.controller import tools_controller
|
||||||
from src.domains.tools.dns import DNSService, DNSQueryError
|
from src.domains.tools.dns import DNSService, DNSQueryError
|
||||||
|
from src.domains.tools.system import SystemStatsService, SystemStatsResponse
|
||||||
|
|
||||||
__all__ = ["tools_controller", "DNSService", "DNSQueryError"]
|
__all__ = [
|
||||||
|
"tools_controller",
|
||||||
|
"DNSService",
|
||||||
|
"DNSQueryError",
|
||||||
|
"SystemStatsService",
|
||||||
|
"SystemStatsResponse",
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Tools Controller
|
|||||||
|
|
||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
|
- System stats
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ from src.shared.logging import get_logger
|
|||||||
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||||
from src.domains.tools.dns.service import DNSService
|
from src.domains.tools.dns.service import DNSService
|
||||||
from src.domains.tools.dns.exceptions import DNSQueryError
|
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||||
|
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||||
|
from src.domains.tools.system.service import SystemStatsService
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -21,11 +24,13 @@ class ToolsController(BaseController):
|
|||||||
|
|
||||||
Provides endpoints for:
|
Provides endpoints for:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
|
- System stats
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
|
self.system_stats_service = SystemStatsService()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -95,6 +100,52 @@ class ToolsController(BaseController):
|
|||||||
detail="An unexpected error occurred during DNS lookup"
|
detail="An unexpected error occurred during DNS lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/system/stats",
|
||||||
|
response_model=SystemStatsResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get host system statistics",
|
||||||
|
description="""
|
||||||
|
Get real-time host system resource statistics.
|
||||||
|
|
||||||
|
Returns CPU, memory, disk, network, and GPU/VRAM usage for the host machine
|
||||||
|
(not Docker container metrics).
|
||||||
|
|
||||||
|
**Metrics Returned:**
|
||||||
|
- **CPU:** Usage percentage, core count, load averages
|
||||||
|
- **Memory:** Usage percentage, total/used/available bytes
|
||||||
|
- **Disk:** Usage percentage, total/used/free bytes (root partition)
|
||||||
|
- **Network:** Total bytes sent/received
|
||||||
|
- **GPU:** VRAM usage (if NVIDIA GPU available via nvidia-smi)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard system monitoring widgets
|
||||||
|
- Health checks and alerting
|
||||||
|
- Capacity planning
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_system_stats() -> SystemStatsResponse:
|
||||||
|
"""
|
||||||
|
Get current host system statistics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
System statistics including CPU, memory, disk, network, and GPU
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Fetching system stats")
|
||||||
|
result = await self.system_stats_service.get_stats()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get system stats: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to collect system stats: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""System stats module for host system resource monitoring."""
|
||||||
|
|
||||||
|
from src.domains.tools.system.service import SystemStatsService
|
||||||
|
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||||
|
|
||||||
|
__all__ = ["SystemStatsService", "SystemStatsResponse"]
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for system stats module
|
||||||
|
"""
|
||||||
|
from pydantic import Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class CpuStats(BaseSchema):
|
||||||
|
"""CPU usage statistics"""
|
||||||
|
|
||||||
|
usage_percent: float = Field(
|
||||||
|
...,
|
||||||
|
description="CPU usage percentage (0-100)",
|
||||||
|
ge=0,
|
||||||
|
le=100
|
||||||
|
)
|
||||||
|
|
||||||
|
cores: int = Field(
|
||||||
|
...,
|
||||||
|
description="Number of CPU cores"
|
||||||
|
)
|
||||||
|
|
||||||
|
load_1m: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="1-minute load average"
|
||||||
|
)
|
||||||
|
|
||||||
|
load_5m: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="5-minute load average"
|
||||||
|
)
|
||||||
|
|
||||||
|
load_15m: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="15-minute load average"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryStats(BaseSchema):
|
||||||
|
"""Memory usage statistics"""
|
||||||
|
|
||||||
|
usage_percent: float = Field(
|
||||||
|
...,
|
||||||
|
description="Memory usage percentage (0-100)",
|
||||||
|
ge=0,
|
||||||
|
le=100
|
||||||
|
)
|
||||||
|
|
||||||
|
total_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Total memory in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Used memory in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
available_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Available memory in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiskStats(BaseSchema):
|
||||||
|
"""Disk usage statistics for a single mount point"""
|
||||||
|
|
||||||
|
mount_point: str = Field(
|
||||||
|
...,
|
||||||
|
description="Mount point path"
|
||||||
|
)
|
||||||
|
|
||||||
|
device: str = Field(
|
||||||
|
...,
|
||||||
|
description="Device name (e.g., /dev/sda1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
fstype: str = Field(
|
||||||
|
...,
|
||||||
|
description="Filesystem type (e.g., ext4, xfs)"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage_percent: float = Field(
|
||||||
|
...,
|
||||||
|
description="Disk usage percentage (0-100)",
|
||||||
|
ge=0,
|
||||||
|
le=100
|
||||||
|
)
|
||||||
|
|
||||||
|
total_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Total disk space in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Used disk space in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
free_bytes: int = Field(
|
||||||
|
...,
|
||||||
|
description="Free disk space in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkStats(BaseSchema):
|
||||||
|
"""Network I/O statistics"""
|
||||||
|
|
||||||
|
bytes_sent: int = Field(
|
||||||
|
...,
|
||||||
|
description="Total bytes sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
bytes_recv: int = Field(
|
||||||
|
...,
|
||||||
|
description="Total bytes received"
|
||||||
|
)
|
||||||
|
|
||||||
|
bytes_total: int = Field(
|
||||||
|
...,
|
||||||
|
description="Total bytes (sent + received)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GpuStats(BaseSchema):
|
||||||
|
"""GPU/VRAM statistics (if available)"""
|
||||||
|
|
||||||
|
available: bool = Field(
|
||||||
|
...,
|
||||||
|
description="Whether GPU stats are available"
|
||||||
|
)
|
||||||
|
|
||||||
|
name: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="GPU name"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage_percent: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="VRAM usage percentage (0-100)"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_bytes: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Total VRAM in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_bytes: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Used VRAM in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
free_bytes: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Free VRAM in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatsResponse(BaseSchema):
|
||||||
|
"""Response model for system stats"""
|
||||||
|
|
||||||
|
cpu: CpuStats = Field(
|
||||||
|
...,
|
||||||
|
description="CPU statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
memory: MemoryStats = Field(
|
||||||
|
...,
|
||||||
|
description="Memory statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
disks: List[DiskStats] = Field(
|
||||||
|
...,
|
||||||
|
description="Disk statistics for all mounted filesystems"
|
||||||
|
)
|
||||||
|
|
||||||
|
network: NetworkStats = Field(
|
||||||
|
...,
|
||||||
|
description="Network I/O statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
gpu: GpuStats = Field(
|
||||||
|
...,
|
||||||
|
description="GPU/VRAM statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
hostname: str = Field(
|
||||||
|
...,
|
||||||
|
description="System hostname"
|
||||||
|
)
|
||||||
|
|
||||||
|
queried_at: datetime = Field(
|
||||||
|
...,
|
||||||
|
description="UTC timestamp when stats were collected"
|
||||||
|
)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
System stats service for collecting host system metrics
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import socket
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from src.domains.tools.system.schemas import (
|
||||||
|
SystemStatsResponse,
|
||||||
|
CpuStats,
|
||||||
|
MemoryStats,
|
||||||
|
DiskStats,
|
||||||
|
NetworkStats,
|
||||||
|
GpuStats,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filesystem types to exclude (virtual/system filesystems)
|
||||||
|
EXCLUDED_FSTYPES = {
|
||||||
|
"tmpfs", "devtmpfs", "devfs", "squashfs", "overlay",
|
||||||
|
"aufs", "proc", "sysfs", "cgroup", "cgroup2",
|
||||||
|
"debugfs", "tracefs", "securityfs", "pstore",
|
||||||
|
"hugetlbfs", "mqueue", "binfmt_misc", "autofs",
|
||||||
|
"fuse.lxcfs", "nsfs", "efivarfs",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatsService:
|
||||||
|
"""Service for collecting host system statistics"""
|
||||||
|
|
||||||
|
async def get_stats(self) -> SystemStatsResponse:
|
||||||
|
"""
|
||||||
|
Collect current system statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SystemStatsResponse with CPU, memory, disks, network, and GPU stats
|
||||||
|
"""
|
||||||
|
cpu = self._get_cpu_stats()
|
||||||
|
memory = self._get_memory_stats()
|
||||||
|
disks = self._get_all_disk_stats()
|
||||||
|
network = self._get_network_stats()
|
||||||
|
gpu = self._get_gpu_stats()
|
||||||
|
|
||||||
|
return SystemStatsResponse(
|
||||||
|
cpu=cpu,
|
||||||
|
memory=memory,
|
||||||
|
disks=disks,
|
||||||
|
network=network,
|
||||||
|
gpu=gpu,
|
||||||
|
hostname=socket.gethostname(),
|
||||||
|
queried_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_cpu_stats(self) -> CpuStats:
|
||||||
|
"""Get CPU usage statistics"""
|
||||||
|
# Get CPU percentage (blocking call with interval for accuracy)
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||||
|
cpu_count = psutil.cpu_count()
|
||||||
|
|
||||||
|
# Get load averages (Unix only)
|
||||||
|
try:
|
||||||
|
load_1, load_5, load_15 = psutil.getloadavg()
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
load_1 = load_5 = load_15 = None
|
||||||
|
|
||||||
|
return CpuStats(
|
||||||
|
usage_percent=cpu_percent,
|
||||||
|
cores=cpu_count or 1,
|
||||||
|
load_1m=load_1,
|
||||||
|
load_5m=load_5,
|
||||||
|
load_15m=load_15,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_memory_stats(self) -> MemoryStats:
|
||||||
|
"""Get memory usage statistics"""
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
|
||||||
|
return MemoryStats(
|
||||||
|
usage_percent=mem.percent,
|
||||||
|
total_bytes=mem.total,
|
||||||
|
used_bytes=mem.used,
|
||||||
|
available_bytes=mem.available,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_all_disk_stats(self) -> List[DiskStats]:
|
||||||
|
"""Get disk usage statistics for all mounted real filesystems"""
|
||||||
|
disks = []
|
||||||
|
seen_devices = set()
|
||||||
|
|
||||||
|
for partition in psutil.disk_partitions(all=False):
|
||||||
|
# Skip excluded filesystem types
|
||||||
|
if partition.fstype.lower() in EXCLUDED_FSTYPES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip duplicate devices (same device mounted multiple times)
|
||||||
|
if partition.device in seen_devices:
|
||||||
|
continue
|
||||||
|
seen_devices.add(partition.device)
|
||||||
|
|
||||||
|
# Skip Docker/container overlays
|
||||||
|
if partition.mountpoint.startswith("/var/lib/docker"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
usage = psutil.disk_usage(partition.mountpoint)
|
||||||
|
disks.append(DiskStats(
|
||||||
|
mount_point=partition.mountpoint,
|
||||||
|
device=partition.device,
|
||||||
|
fstype=partition.fstype,
|
||||||
|
usage_percent=usage.percent,
|
||||||
|
total_bytes=usage.total,
|
||||||
|
used_bytes=usage.used,
|
||||||
|
free_bytes=usage.free,
|
||||||
|
))
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
logger.debug(f"Skipping {partition.mountpoint}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by mount point for consistent ordering
|
||||||
|
disks.sort(key=lambda d: d.mount_point)
|
||||||
|
|
||||||
|
return disks
|
||||||
|
|
||||||
|
def _get_network_stats(self) -> NetworkStats:
|
||||||
|
"""Get network I/O statistics"""
|
||||||
|
net_io = psutil.net_io_counters()
|
||||||
|
|
||||||
|
return NetworkStats(
|
||||||
|
bytes_sent=net_io.bytes_sent,
|
||||||
|
bytes_recv=net_io.bytes_recv,
|
||||||
|
bytes_total=net_io.bytes_sent + net_io.bytes_recv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_gpu_stats(self) -> GpuStats:
|
||||||
|
"""Get GPU/VRAM statistics using nvidia-smi"""
|
||||||
|
try:
|
||||||
|
# Query nvidia-smi for GPU memory info
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=name,memory.total,memory.used,memory.free",
|
||||||
|
"--format=csv,noheader,nounits",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.debug("nvidia-smi not available or failed")
|
||||||
|
return GpuStats(available=False)
|
||||||
|
|
||||||
|
# Parse output: "NVIDIA GeForce RTX 3080, 10240, 2048, 8192"
|
||||||
|
line = result.stdout.strip().split("\n")[0] # First GPU
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
|
||||||
|
if len(parts) >= 4:
|
||||||
|
name = parts[0]
|
||||||
|
total_mb = int(parts[1])
|
||||||
|
used_mb = int(parts[2])
|
||||||
|
free_mb = int(parts[3])
|
||||||
|
|
||||||
|
total_bytes = total_mb * 1024 * 1024
|
||||||
|
used_bytes = used_mb * 1024 * 1024
|
||||||
|
free_bytes = free_mb * 1024 * 1024
|
||||||
|
usage_percent = (used_mb / total_mb * 100) if total_mb > 0 else 0
|
||||||
|
|
||||||
|
return GpuStats(
|
||||||
|
available=True,
|
||||||
|
name=name,
|
||||||
|
usage_percent=round(usage_percent, 1),
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
used_bytes=used_bytes,
|
||||||
|
free_bytes=free_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.debug("nvidia-smi not found - no NVIDIA GPU available")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("nvidia-smi timed out")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get GPU stats: {e}")
|
||||||
|
|
||||||
|
return GpuStats(available=False)
|
||||||
Reference in New Issue
Block a user