mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 21:43:05 +09:00
Plan 73 item 9. Three blind spots with one theme: the panel trusted a signal that did not mean what the UI claimed it meant. The doctor's service sweep only asked `systemctl is-active`. nginx keeps reporting active while serving a config it can no longer parse, so a box that was one `nginx -t` away from explaining itself got a clean bill of health. A new nginx.config check runs that test and fails with the file and line nginx named. It is deliberately not repairable: config repair is operator territory and a wrong guess takes every site on the host down. A missing nginx binary warns rather than fails, since a profile without nginx is legitimate. NginxService.restart now tests the config first, the way reload always has. The asymmetry is what made this worth fixing: a reload against a broken config fails safe -- nginx rejects it and keeps serving the old one -- while a restart stops nginx before starting it, so the same broken config becomes a full outage with nginx dead until someone fixes it by hand. Testing first turns that outage back into an error message. get_services_status substring-matched psutil process names while the start/stop/restart buttons went through systemctl, and the two disagreed in both directions. `mysql` matched the process `mysqld`, so a MariaDB box whose unit is `mariadb` showed a running `mysql` row whose buttons systemctl could not honour; `docker` matched `dockerd` and `redis` matched `redis-server`, listing one daemon twice and three times; and any unrelated process whose name merely contained a service name reported that service up. It now asks systemd -- the same authority the control path uses -- so the list cannot claim a state the buttons are unable to act on. That query is one `systemctl show` for the whole list rather than one call per unit, because this backs a polled list. Units the host does not have are dropped instead of shown as stopped: MONITORED_SERVICES carries several spellings of each daemon so whichever one a distro uses is found, and the misses were noise. Aliases resolving to the same unit collapse to one row via the Id property. If systemctl is absent, times out, exits non-zero, or returns a block count that does not match the units asked about, the old process-name heuristic still answers rather than the list going silently empty -- and it remains the path for non-Linux dev boxes, which have no systemd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
317 lines
13 KiB
Python
317 lines
13 KiB
Python
import psutil
|
|
import subprocess
|
|
import platform
|
|
from typing import List, Dict, Optional
|
|
|
|
from app.utils.system import run_privileged
|
|
|
|
|
|
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]
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
return {}
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
blocks: List[Dict[str, str]] = []
|
|
current: Dict[str, str] = {}
|
|
for line in (result.stdout or '').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 = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
return {
|
|
'success': result.returncode == 0,
|
|
'message': result.stdout if result.returncode == 0 else result.stderr
|
|
}
|
|
|
|
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 using systemctl."""
|
|
system = platform.system()
|
|
if system != 'Linux':
|
|
return {'success': True, 'status': 'unknown', 'active': False}
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['systemctl', 'is-active', unit_name],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
active = result.returncode == 0 and result.stdout.strip() == 'active'
|
|
status = result.stdout.strip() if result.stdout else 'unknown'
|
|
return {'success': True, 'status': status, 'active': active}
|
|
except FileNotFoundError:
|
|
return {'success': False, 'error': 'systemctl 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_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}
|