mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 08:36:55 +09:00
Comments cited plan sections as "(§G3)" or "(§8 unification)", which point into the same git-ignored plans the plan-number citations did. References next to a real document (docs/REDESIGN_MAP.md §3) stay. Code and runtime strings are unchanged (AST and token comparison).
309 lines
12 KiB
Python
309 lines
12 KiB
Python
import psutil
|
|
import subprocess
|
|
import platform
|
|
from typing import List, Dict, Optional
|
|
|
|
from app.utils.system import run_checked, run_privileged, unit_is_active
|
|
|
|
|
|
class ProcessService:
|
|
"""Service for process and service management."""
|
|
|
|
# Common services to monitor
|
|
MONITORED_SERVICES = [
|
|
'nginx',
|
|
'mysql', 'mysqld', 'mariadb',
|
|
'postgresql', 'postgres',
|
|
'redis', 'redis-server',
|
|
'docker', 'dockerd',
|
|
'php-fpm', 'php-fpm8.2', 'php-fpm8.1', 'php-fpm8.0',
|
|
'gunicorn',
|
|
'supervisor', 'supervisord'
|
|
]
|
|
|
|
@classmethod
|
|
def get_processes(cls, limit: int = 50, sort_by: str = 'cpu') -> List[Dict]:
|
|
"""Get list of running processes sorted by resource usage."""
|
|
processes = []
|
|
|
|
for proc in psutil.process_iter(['pid', 'name', 'username', 'cpu_percent',
|
|
'memory_percent', 'status', 'create_time']):
|
|
try:
|
|
pinfo = proc.info
|
|
# Filter out idle processes
|
|
if pinfo['cpu_percent'] > 0 or pinfo['memory_percent'] > 0.1:
|
|
processes.append({
|
|
'pid': pinfo['pid'],
|
|
'name': pinfo['name'],
|
|
'username': pinfo['username'],
|
|
'cpu_percent': round(pinfo['cpu_percent'], 1),
|
|
'memory_percent': round(pinfo['memory_percent'], 2),
|
|
'status': pinfo['status'],
|
|
'create_time': pinfo['create_time']
|
|
})
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
continue
|
|
|
|
# Sort by specified field
|
|
sort_key = 'cpu_percent' if sort_by == 'cpu' else 'memory_percent'
|
|
processes.sort(key=lambda x: x[sort_key], reverse=True)
|
|
|
|
return processes[:limit]
|
|
|
|
@classmethod
|
|
def get_process_details(cls, pid: int) -> Optional[Dict]:
|
|
"""Get detailed information about a specific process."""
|
|
try:
|
|
proc = psutil.Process(pid)
|
|
with proc.oneshot():
|
|
return {
|
|
'pid': proc.pid,
|
|
'name': proc.name(),
|
|
'status': proc.status(),
|
|
'username': proc.username(),
|
|
'cpu_percent': proc.cpu_percent(),
|
|
'memory_percent': round(proc.memory_percent(), 2),
|
|
'memory_info': {
|
|
'rss': proc.memory_info().rss,
|
|
'vms': proc.memory_info().vms
|
|
},
|
|
'create_time': proc.create_time(),
|
|
'cmdline': proc.cmdline(),
|
|
'cwd': proc.cwd() if hasattr(proc, 'cwd') else None,
|
|
'num_threads': proc.num_threads(),
|
|
'connections': len(proc.connections()) if hasattr(proc, 'connections') else 0
|
|
}
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
return None
|
|
|
|
@classmethod
|
|
def kill_process(cls, pid: int, force: bool = False) -> Dict:
|
|
"""Kill a process by PID."""
|
|
try:
|
|
proc = psutil.Process(pid)
|
|
name = proc.name()
|
|
|
|
if force:
|
|
proc.kill()
|
|
else:
|
|
proc.terminate()
|
|
|
|
return {'success': True, 'message': f'Process {name} (PID: {pid}) terminated'}
|
|
except psutil.NoSuchProcess:
|
|
return {'success': False, 'error': f'Process {pid} not found'}
|
|
except psutil.AccessDenied:
|
|
return {'success': False, 'error': f'Access denied to kill process {pid}'}
|
|
|
|
# Properties fetched per unit. Id is what the name actually resolves to,
|
|
# so two aliases of one unit collapse into a single row.
|
|
_SHOW_PROPERTIES = ('Id', 'LoadState', 'ActiveState', 'MainPID')
|
|
|
|
@classmethod
|
|
def _systemd_unit_states(cls, units: List[str]) -> Dict[str, Dict[str, str]]:
|
|
"""``systemctl show`` output for *units*, keyed by the requested name.
|
|
|
|
One subprocess for the whole list rather than one per unit: this backs
|
|
a polled list, and MONITORED_SERVICES is long. systemctl emits one
|
|
property block per unit, in argument order, separated by blank lines.
|
|
Returns ``{}`` on anything unexpected so the caller can fall back
|
|
instead of reporting a wrong state.
|
|
"""
|
|
cmd = ['systemctl', 'show', *units]
|
|
cmd += [f'--property={p}' for p in cls._SHOW_PROPERTIES]
|
|
result = run_checked(cmd, timeout=15)
|
|
if not result['success']:
|
|
return {}
|
|
|
|
blocks: List[Dict[str, str]] = []
|
|
current: Dict[str, str] = {}
|
|
for line in result['output'].splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
if current:
|
|
blocks.append(current)
|
|
current = {}
|
|
continue
|
|
key, _, value = line.partition('=')
|
|
current[key] = value
|
|
if current:
|
|
blocks.append(current)
|
|
|
|
# Index-matched by contract; if the block count disagrees the parse is
|
|
# not trustworthy and the caller falls back rather than guessing.
|
|
if len(blocks) != len(units):
|
|
return {}
|
|
return dict(zip(units, blocks))
|
|
|
|
@classmethod
|
|
def get_services_status(cls) -> List[Dict]:
|
|
"""Status of the monitored services, as systemd sees them.
|
|
|
|
This used to substring-match psutil process names, which disagreed
|
|
with control_service() -- which acts through systemctl -- in both
|
|
directions. `mysql` matched the process `mysqld`, so a MariaDB box
|
|
whose unit is `mariadb` showed a running `mysql` row whose
|
|
start/stop/restart buttons systemctl could not honour; `docker`
|
|
matched `dockerd` and `redis` matched `redis-server`, listing the same
|
|
daemon two and three times; and any unrelated process whose name
|
|
merely contained a service name (say `php-fpm` inside a build script's
|
|
command) reported that service up. Asking systemd directly means the
|
|
list cannot claim a state the buttons are unable to act on.
|
|
|
|
Units the host does not have are dropped rather than listed as
|
|
stopped: MONITORED_SERVICES deliberately carries several spellings of
|
|
the same daemon so that whichever one a distro uses is found, and
|
|
showing all of them as stopped was noise. Aliases that resolve to one
|
|
unit collapse to a single row.
|
|
"""
|
|
if platform.system() != 'Linux':
|
|
return cls._process_name_status()
|
|
|
|
states = cls._systemd_unit_states(cls.MONITORED_SERVICES)
|
|
if not states:
|
|
return cls._process_name_status()
|
|
|
|
services = []
|
|
seen_units = set()
|
|
for name in cls.MONITORED_SERVICES:
|
|
info = states.get(name) or {}
|
|
if info.get('LoadState') != 'loaded':
|
|
continue
|
|
unit_id = info.get('Id') or name
|
|
if unit_id in seen_units:
|
|
continue
|
|
seen_units.add(unit_id)
|
|
try:
|
|
pid = int(info.get('MainPID') or 0) or None
|
|
except ValueError:
|
|
pid = None
|
|
services.append({
|
|
'name': name,
|
|
'status': 'running' if info.get('ActiveState') == 'active' else 'stopped',
|
|
'pid': pid,
|
|
})
|
|
return services
|
|
|
|
@classmethod
|
|
def _process_name_status(cls) -> List[Dict]:
|
|
"""Legacy process-name heuristic, kept for hosts without systemd.
|
|
|
|
Only reached off Linux (a Windows/macOS dev box) or when systemctl
|
|
cannot be queried at all. It matches loosely and can report a service
|
|
as running on a coincidental process name -- which is precisely why it
|
|
is no longer the Linux path.
|
|
"""
|
|
services = []
|
|
running_procs = {p.name().lower(): p for p in psutil.process_iter(['name', 'pid'])}
|
|
|
|
for service_name in cls.MONITORED_SERVICES:
|
|
status = 'stopped'
|
|
pid = None
|
|
|
|
# Check if service is running
|
|
for proc_name, proc in running_procs.items():
|
|
if service_name.lower() in proc_name.lower():
|
|
status = 'running'
|
|
pid = proc.pid
|
|
break
|
|
|
|
services.append({
|
|
'name': service_name,
|
|
'status': status,
|
|
'pid': pid
|
|
})
|
|
|
|
return services
|
|
|
|
@classmethod
|
|
def control_service(cls, service_name: str, action: str) -> Dict:
|
|
"""Control a system service (start, stop, restart, reload)."""
|
|
valid_actions = ['start', 'stop', 'restart', 'reload', 'status']
|
|
if action not in valid_actions:
|
|
return {'success': False, 'error': f'Invalid action. Must be one of: {valid_actions}'}
|
|
|
|
system = platform.system()
|
|
|
|
try:
|
|
if system == 'Linux':
|
|
# Try systemctl first (systemd)
|
|
result = run_privileged(
|
|
['systemctl', action, service_name], timeout=30
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
# Fall back to service command
|
|
result = run_privileged(
|
|
['service', service_name, action], timeout=30
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {'success': True, 'message': f'Service {service_name} {action} successful'}
|
|
else:
|
|
return {'success': False, 'error': result.stderr or 'Command failed'}
|
|
|
|
elif system == 'Windows':
|
|
if action == 'status':
|
|
cmd = ['sc', 'query', service_name]
|
|
else:
|
|
action_map = {'start': 'start', 'stop': 'stop', 'restart': 'restart'}
|
|
cmd = ['net', action_map.get(action, action), service_name]
|
|
|
|
result = run_checked(cmd, timeout=30)
|
|
return {
|
|
'success': result['success'],
|
|
'message': result['output'] if result['success'] else result['error']
|
|
}
|
|
|
|
else:
|
|
return {'success': False, 'error': f'Unsupported platform: {system}'}
|
|
|
|
except FileNotFoundError:
|
|
return {'success': False, 'error': 'systemctl/service command not found'}
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Command timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def get_service_logs(cls, service_name: str, lines: int = 100) -> Dict:
|
|
"""Get recent logs for a service via LogService fallback chain."""
|
|
from app.services.log_service import LogService
|
|
return LogService.get_journalctl_logs(unit=service_name, lines=lines)
|
|
|
|
@classmethod
|
|
def get_systemd_unit_status(cls, unit_name: str) -> Dict:
|
|
"""Check whether a systemd unit is active — via ``unit_is_active``.
|
|
|
|
The probe itself was re-inlined in four other services; this keeps the
|
|
dict shape its own callers read and lets one implementation answer.
|
|
"""
|
|
if platform.system() != 'Linux':
|
|
return {'success': True, 'status': 'unknown', 'active': False}
|
|
|
|
active = unit_is_active(unit_name)
|
|
if active is None:
|
|
return {'success': False, 'error': 'could not query systemd'}
|
|
return {'success': True,
|
|
'status': 'active' if active else 'inactive',
|
|
'active': active}
|
|
|
|
@classmethod
|
|
def get_compose_project_status(cls, project_path: str, compose_file: str = None) -> Dict:
|
|
"""Return running/stopped status for a Docker Compose project directory."""
|
|
from app.services.docker_service import DockerService
|
|
containers = DockerService.compose_ps(project_path, compose_file=compose_file)
|
|
running = sum(
|
|
1 for c in containers
|
|
if (c.get('Status', c.get('status', '')).startswith('Up'))
|
|
)
|
|
total = len(containers)
|
|
if total == 0:
|
|
actual = 'stopped'
|
|
elif running == total:
|
|
actual = 'running'
|
|
elif running > 0:
|
|
actual = 'partial'
|
|
else:
|
|
actual = 'stopped'
|
|
return {'success': True, 'status': actual, 'running': running, 'total': total, 'containers': containers}
|