mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
The previous commit ran SELECT 1 directly in the route, a new persistence crossing in the API layer that the controller-boundary ratchet rejects.
489 lines
17 KiB
Python
489 lines
17 KiB
Python
import platform
|
|
import psutil
|
|
import subprocess
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from app.utils.formatting import format_bytes
|
|
from app.utils.system import run_checked, run_privileged
|
|
|
|
|
|
class SystemService:
|
|
"""Service for collecting system metrics and information."""
|
|
|
|
@staticmethod
|
|
def database_reachable():
|
|
"""True when the panel database answers ``SELECT 1``."""
|
|
from sqlalchemy import text
|
|
from app import db
|
|
try:
|
|
db.session.execute(text('SELECT 1'))
|
|
return True
|
|
except Exception:
|
|
db.session.rollback()
|
|
return False
|
|
|
|
@staticmethod
|
|
def get_size(bytes_val):
|
|
"""Convert bytes to human readable format."""
|
|
return format_bytes(bytes_val, suffix_sep='')
|
|
|
|
@classmethod
|
|
def get_cpu_metrics(cls):
|
|
"""Get CPU usage metrics."""
|
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
|
cpu_count = psutil.cpu_count()
|
|
cpu_count_logical = psutil.cpu_count(logical=True)
|
|
cpu_freq = psutil.cpu_freq()
|
|
|
|
# Per-core usage
|
|
per_cpu = psutil.cpu_percent(interval=0.1, percpu=True)
|
|
|
|
return {
|
|
'percent': cpu_percent,
|
|
'count_physical': cpu_count,
|
|
'count_logical': cpu_count_logical,
|
|
'frequency': {
|
|
# None when the platform can't report frequency — 0 MHz would
|
|
# read as a real measurement.
|
|
'current': cpu_freq.current if cpu_freq else None,
|
|
'min': cpu_freq.min if cpu_freq else None,
|
|
'max': cpu_freq.max if cpu_freq else None
|
|
},
|
|
'per_cpu': per_cpu
|
|
}
|
|
|
|
@classmethod
|
|
def get_memory_metrics(cls):
|
|
"""Get memory usage metrics."""
|
|
memory = psutil.virtual_memory()
|
|
swap = psutil.swap_memory()
|
|
|
|
# Get cached memory (available on Linux only; None = not determined)
|
|
cached = getattr(memory, 'cached', None)
|
|
|
|
return {
|
|
'ram': {
|
|
'total': memory.total,
|
|
'available': memory.available,
|
|
'used': memory.used,
|
|
'cached': cached,
|
|
'percent': memory.percent,
|
|
'total_human': cls.get_size(memory.total),
|
|
'available_human': cls.get_size(memory.available),
|
|
'used_human': cls.get_size(memory.used),
|
|
'cached_human': cls.get_size(cached) if cached is not None else None
|
|
},
|
|
'swap': {
|
|
'total': swap.total,
|
|
'used': swap.used,
|
|
'free': swap.free,
|
|
'percent': swap.percent,
|
|
'total_human': cls.get_size(swap.total),
|
|
'used_human': cls.get_size(swap.used)
|
|
}
|
|
}
|
|
|
|
@classmethod
|
|
def get_disk_metrics(cls):
|
|
"""Get disk usage metrics for all partitions."""
|
|
partitions = psutil.disk_partitions()
|
|
disks = []
|
|
|
|
for partition in partitions:
|
|
try:
|
|
usage = psutil.disk_usage(partition.mountpoint)
|
|
disks.append({
|
|
'device': partition.device,
|
|
'mountpoint': partition.mountpoint,
|
|
'fstype': partition.fstype,
|
|
'total': usage.total,
|
|
'used': usage.used,
|
|
'free': usage.free,
|
|
'percent': usage.percent,
|
|
'total_human': cls.get_size(usage.total),
|
|
'used_human': cls.get_size(usage.used),
|
|
'free_human': cls.get_size(usage.free)
|
|
})
|
|
except (PermissionError, OSError):
|
|
continue
|
|
|
|
# Disk I/O
|
|
try:
|
|
disk_io = psutil.disk_io_counters()
|
|
io_stats = {
|
|
'read_bytes': disk_io.read_bytes,
|
|
'write_bytes': disk_io.write_bytes,
|
|
'read_bytes_human': cls.get_size(disk_io.read_bytes),
|
|
'write_bytes_human': cls.get_size(disk_io.write_bytes)
|
|
}
|
|
except Exception:
|
|
io_stats = None
|
|
|
|
return {
|
|
'partitions': disks,
|
|
'io': io_stats
|
|
}
|
|
|
|
@classmethod
|
|
def get_network_metrics(cls):
|
|
"""Get network usage metrics.
|
|
|
|
Probe honesty: mirrors the disk I/O block — when
|
|
net_io_counters/net_if_* fail or return nothing, the corresponding
|
|
section is None/empty rather than raising (which would kill the whole
|
|
live metrics broadcast) or fabricating zeros.
|
|
"""
|
|
try:
|
|
net_io = psutil.net_io_counters()
|
|
if net_io is not None:
|
|
io_stats = {
|
|
'bytes_sent': net_io.bytes_sent,
|
|
'bytes_recv': net_io.bytes_recv,
|
|
'packets_sent': net_io.packets_sent,
|
|
'packets_recv': net_io.packets_recv,
|
|
'bytes_sent_human': cls.get_size(net_io.bytes_sent),
|
|
'bytes_recv_human': cls.get_size(net_io.bytes_recv),
|
|
}
|
|
else:
|
|
io_stats = None
|
|
except Exception:
|
|
io_stats = None
|
|
|
|
try:
|
|
net_if = psutil.net_if_addrs()
|
|
except Exception:
|
|
net_if = {}
|
|
try:
|
|
net_stats = psutil.net_if_stats()
|
|
except Exception:
|
|
net_stats = {}
|
|
|
|
interfaces = []
|
|
for name, addrs in net_if.items():
|
|
stats = net_stats.get(name)
|
|
interface = {
|
|
'name': name,
|
|
# None = could not determine; False/0 would assert "down"/"0 Mbps"
|
|
'is_up': stats.isup if stats else None,
|
|
'speed': stats.speed if stats else None,
|
|
'addresses': []
|
|
}
|
|
for addr in addrs:
|
|
interface['addresses'].append({
|
|
'family': str(addr.family),
|
|
'address': addr.address,
|
|
'netmask': addr.netmask
|
|
})
|
|
interfaces.append(interface)
|
|
|
|
result = {
|
|
'io': io_stats,
|
|
'interfaces': interfaces
|
|
}
|
|
if io_stats is not None:
|
|
# Keep flat values for backwards compatibility
|
|
result.update(io_stats)
|
|
return result
|
|
|
|
@classmethod
|
|
def get_load_average(cls):
|
|
"""Get system load average."""
|
|
try:
|
|
load = psutil.getloadavg()
|
|
return {
|
|
'1min': round(load[0], 2),
|
|
'5min': round(load[1], 2),
|
|
'15min': round(load[2], 2)
|
|
}
|
|
except (AttributeError, OSError):
|
|
# Windows doesn't support getloadavg — None = not determined,
|
|
# never a fake zero load.
|
|
return {'1min': None, '5min': None, '15min': None}
|
|
|
|
@classmethod
|
|
def get_system_info(cls):
|
|
"""Get general system information."""
|
|
boot_time = datetime.fromtimestamp(psutil.boot_time())
|
|
uptime = datetime.now() - boot_time
|
|
|
|
# Get primary IP address
|
|
ip_address = cls._get_primary_ip()
|
|
|
|
# Get kernel version (on Linux, platform.release() gives kernel version)
|
|
kernel = platform.release()
|
|
|
|
# Get CPU model info
|
|
cpu_model = platform.processor() or 'Unknown'
|
|
# Try to get more detailed CPU info on Linux
|
|
if platform.system() == 'Linux':
|
|
try:
|
|
with open('/proc/cpuinfo', 'r') as f:
|
|
for line in f:
|
|
if line.startswith('model name'):
|
|
cpu_model = line.split(':')[1].strip()
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
'platform': platform.system(),
|
|
'platform_release': platform.release(),
|
|
'platform_version': platform.version(),
|
|
'architecture': platform.machine(),
|
|
'hostname': platform.node(),
|
|
'processor': platform.processor(),
|
|
'python_version': platform.python_version(),
|
|
'boot_time': boot_time.isoformat(),
|
|
'uptime_seconds': int(uptime.total_seconds()),
|
|
'uptime_human': cls._format_uptime(uptime),
|
|
# Additional fields for dashboard
|
|
'ip_address': ip_address,
|
|
'kernel': kernel,
|
|
'cpu': {
|
|
'model': cpu_model,
|
|
'architecture': platform.machine()
|
|
}
|
|
}
|
|
|
|
@staticmethod
|
|
def _get_primary_ip():
|
|
"""Get the primary IP address of the server."""
|
|
import socket
|
|
try:
|
|
# Create a socket to determine the primary IP
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.settimeout(0)
|
|
# Connect to a public address (doesn't actually send data)
|
|
s.connect(('8.8.8.8', 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
return ip
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: try to get from network interfaces
|
|
try:
|
|
hostname = socket.gethostname()
|
|
ip = socket.gethostbyname(hostname)
|
|
if ip != '127.0.0.1':
|
|
return ip
|
|
except Exception:
|
|
pass
|
|
|
|
# Last resort: check network interfaces via psutil
|
|
try:
|
|
net_if = psutil.net_if_addrs()
|
|
for name, addrs in net_if.items():
|
|
if name == 'lo':
|
|
continue
|
|
for addr in addrs:
|
|
if addr.family == socket.AF_INET and not addr.address.startswith('127.'):
|
|
return addr.address
|
|
except Exception:
|
|
pass
|
|
|
|
return 'Unknown'
|
|
|
|
@staticmethod
|
|
def _format_uptime(delta):
|
|
"""Format timedelta to human readable string."""
|
|
days = delta.days
|
|
hours, remainder = divmod(delta.seconds, 3600)
|
|
minutes, _ = divmod(remainder, 60)
|
|
|
|
parts = []
|
|
if days > 0:
|
|
parts.append(f"{days}d")
|
|
if hours > 0:
|
|
parts.append(f"{hours}h")
|
|
if minutes > 0:
|
|
parts.append(f"{minutes}m")
|
|
|
|
return ' '.join(parts) if parts else '0m'
|
|
|
|
# Plain @staticmethod. It was @classmethod stacked on @staticmethod, which
|
|
# only ever worked because 3.9 let classmethod chain to another descriptor —
|
|
# deprecated in 3.11, REMOVED in 3.13, where the chain stops collapsing and
|
|
# `cls._is_iana_timezone(tz)` starts passing cls as the first argument:
|
|
# "takes 1 positional argument but 2 were given". Calling a staticmethod
|
|
# through `cls.` is fine, so the call sites need no change.
|
|
@staticmethod
|
|
def _is_iana_timezone(value):
|
|
"""True if `value` looks like an IANA zone key (Area/Location or a small
|
|
set of bare names), i.e. something Intl/zoneinfo will accept. Excludes
|
|
platform display names like 'Eastern Daylight Time' (they contain spaces
|
|
and no slash)."""
|
|
if not value or not isinstance(value, str):
|
|
return False
|
|
v = value.strip()
|
|
if v in ('UTC', 'GMT', 'Zulu'):
|
|
return True
|
|
return '/' in v and ' ' not in v
|
|
|
|
@classmethod
|
|
def get_server_time(cls):
|
|
"""Get current server time and timezone info."""
|
|
import time as time_module
|
|
|
|
now = datetime.now()
|
|
utc_now = datetime.utcnow()
|
|
|
|
# Get timezone name
|
|
tz_name = time_module.tzname[time_module.daylight] if time_module.daylight else time_module.tzname[0]
|
|
|
|
# Try to get more detailed timezone info on Linux
|
|
timezone_file = '/etc/timezone'
|
|
timezone_id = None
|
|
if os.path.exists(timezone_file):
|
|
try:
|
|
with open(timezone_file, 'r') as f:
|
|
timezone_id = f.read().strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: try timedatectl on systemd systems
|
|
if not timezone_id:
|
|
try:
|
|
result = run_checked(
|
|
['timedatectl', 'show', '--property=Timezone', '--value'], timeout=5)
|
|
if result['success']:
|
|
timezone_id = result['output'].strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Cross-platform fallback (works on Windows/macOS dev boxes that have
|
|
# neither /etc/timezone nor timedatectl): resolve the local IANA zone.
|
|
if not timezone_id:
|
|
try:
|
|
from tzlocal import get_localzone_name
|
|
timezone_id = get_localzone_name()
|
|
except Exception:
|
|
pass
|
|
|
|
# Only ever expose an IANA-style id. Python's time.tzname yields a
|
|
# platform *display* name on Windows ("Eastern Daylight Time") which is
|
|
# NOT a valid Intl/zoneinfo key and would crash time-formatting in the
|
|
# UI. Keep the display name in timezone_name; leave timezone_id null when
|
|
# we can't produce a real IANA zone (the UI then uses the local zone).
|
|
if timezone_id and not cls._is_iana_timezone(timezone_id):
|
|
timezone_id = None
|
|
|
|
# Calculate UTC offset
|
|
utc_offset_seconds = (now - utc_now).total_seconds()
|
|
utc_offset_hours = int(utc_offset_seconds // 3600)
|
|
utc_offset_minutes = int((abs(utc_offset_seconds) % 3600) // 60)
|
|
utc_offset_str = f"UTC{'+' if utc_offset_hours >= 0 else ''}{utc_offset_hours:+d}:{utc_offset_minutes:02d}"
|
|
|
|
return {
|
|
'current_time': now.isoformat(),
|
|
'current_time_formatted': now.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'utc_time': utc_now.isoformat(),
|
|
'timezone_name': tz_name,
|
|
'timezone_id': timezone_id, # IANA or null (never the display name)
|
|
'utc_offset': utc_offset_str,
|
|
'utc_offset_seconds': int(utc_offset_seconds)
|
|
}
|
|
|
|
@classmethod
|
|
def get_available_timezones(cls):
|
|
"""Get list of available timezones."""
|
|
try:
|
|
# Use timedatectl to list timezones on systemd systems
|
|
result = run_checked(['timedatectl', 'list-timezones'], timeout=10)
|
|
if result['success']:
|
|
return result['output'].strip().split('\n')
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: return common timezones
|
|
return [
|
|
'UTC',
|
|
'America/New_York',
|
|
'America/Chicago',
|
|
'America/Denver',
|
|
'America/Los_Angeles',
|
|
'America/Toronto',
|
|
'America/Vancouver',
|
|
'America/Mexico_City',
|
|
'America/Sao_Paulo',
|
|
'Europe/London',
|
|
'Europe/Paris',
|
|
'Europe/Berlin',
|
|
'Europe/Madrid',
|
|
'Europe/Rome',
|
|
'Europe/Amsterdam',
|
|
'Europe/Moscow',
|
|
'Asia/Tokyo',
|
|
'Asia/Shanghai',
|
|
'Asia/Hong_Kong',
|
|
'Asia/Singapore',
|
|
'Asia/Dubai',
|
|
'Asia/Kolkata',
|
|
'Australia/Sydney',
|
|
'Australia/Melbourne',
|
|
'Pacific/Auckland',
|
|
]
|
|
|
|
@classmethod
|
|
def set_timezone(cls, timezone_id):
|
|
"""Set server timezone (requires root/sudo)."""
|
|
# Validate timezone exists
|
|
available = cls.get_available_timezones()
|
|
if timezone_id not in available:
|
|
return {'success': False, 'error': f'Invalid timezone: {timezone_id}'}
|
|
|
|
try:
|
|
# Try timedatectl first (systemd)
|
|
result = run_privileged(
|
|
['timedatectl', 'set-timezone', timezone_id],
|
|
timeout=10
|
|
)
|
|
if result.returncode == 0:
|
|
return {'success': True, 'message': f'Timezone set to {timezone_id}'}
|
|
|
|
# Fallback: symlink method
|
|
result = run_privileged(
|
|
['ln', '-sf', f'/usr/share/zoneinfo/{timezone_id}', '/etc/localtime'],
|
|
timeout=10
|
|
)
|
|
if result.returncode == 0:
|
|
# Also update /etc/timezone
|
|
run_privileged(
|
|
['bash', '-c', f'echo "{timezone_id}" > /etc/timezone'],
|
|
timeout=10
|
|
)
|
|
return {'success': True, 'message': f'Timezone set to {timezone_id}'}
|
|
|
|
return {'success': False, 'error': result.stderr or 'Failed to set timezone'}
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Timeout while setting timezone'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def get_all_metrics(cls):
|
|
"""Get all system metrics at once.
|
|
|
|
Each section is collected independently: one failed probe blanks only
|
|
its own section (None), never the others — a single psutil failure
|
|
must not freeze every chart.
|
|
"""
|
|
sections = (
|
|
('cpu', cls.get_cpu_metrics),
|
|
('memory', cls.get_memory_metrics),
|
|
('disk', cls.get_disk_metrics),
|
|
('network', cls.get_network_metrics),
|
|
('load_average', cls.get_load_average),
|
|
('system', cls.get_system_info),
|
|
('time', cls.get_server_time),
|
|
)
|
|
metrics = {}
|
|
for key, collector in sections:
|
|
try:
|
|
metrics[key] = collector()
|
|
except Exception:
|
|
metrics[key] = None
|
|
metrics['timestamp'] = datetime.utcnow().isoformat()
|
|
return metrics
|