mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Removing WROLPi admin functionality that is now in Controller.
This commit is contained in:
parent
7183cf5e85
commit
78251b067e
15 changed files with 159 additions and 1332 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -140,3 +140,4 @@ app/cypress/videos
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
*.md
|
*.md
|
||||||
|
/.mcp.json
|
||||||
|
|
|
||||||
|
|
@ -1640,21 +1640,31 @@ export async function deleteOutdatedZims() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postShutdown() {
|
export async function postShutdown() {
|
||||||
const response = await apiPost(`${API_URI}/shutdown`);
|
// Call Controller directly for system shutdown
|
||||||
if (response.status !== 204) {
|
const {shutdownSystem} = await import('./api/controller');
|
||||||
// Return the error.
|
try {
|
||||||
return await response.json();
|
const result = await shutdownSystem();
|
||||||
|
if (result.success) {
|
||||||
|
return null; // Success
|
||||||
|
}
|
||||||
|
return {code: 'SHUTDOWN_FAILED', error: result.error};
|
||||||
|
} catch (e) {
|
||||||
|
return {code: 'SHUTDOWN_FAILED', error: e.message};
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRestart() {
|
export async function postRestart() {
|
||||||
const response = await apiPost(`${API_URI}/restart`);
|
// Call Controller directly for system reboot
|
||||||
if (response.status !== 204) {
|
const {rebootSystem} = await import('./api/controller');
|
||||||
// Return the error.
|
try {
|
||||||
return await response.json();
|
const result = await rebootSystem();
|
||||||
|
if (result.success) {
|
||||||
|
return null; // Success
|
||||||
|
}
|
||||||
|
return {code: 'SHUTDOWN_FAILED', error: result.error};
|
||||||
|
} catch (e) {
|
||||||
|
return {code: 'SHUTDOWN_FAILED', error: e.message};
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postVideoFileFormat(video_file_format) {
|
export async function postVideoFileFormat(video_file_format) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import enum
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from controller.lib.config import get_config, is_docker_mode
|
from controller.lib.config import is_docker_mode, get_config_value
|
||||||
|
|
||||||
|
# Constants - use controller's own config instead of wrolpi module
|
||||||
|
DEFAULT_CPU_FREQUENCY = get_config_value('throttle.default_governor', 'ondemand')
|
||||||
|
|
||||||
|
|
||||||
class HotspotStatus(enum.Enum):
|
class HotspotStatus(enum.Enum):
|
||||||
|
|
@ -28,11 +31,13 @@ class GovernorStatus(enum.Enum):
|
||||||
unknown = enum.auto()
|
unknown = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
def get_current_ssid(interface: str = 'wlan0') -> str | None:
|
def get_current_ssid(interface: str = None) -> str | None:
|
||||||
"""
|
"""
|
||||||
Returns the name of the SSID that is currently connected.
|
Returns the name of the SSID that is currently connected.
|
||||||
Returns None if Hotspot is active, or no Wi-Fi network is being used.
|
Returns None if Hotspot is active, or no Wi-Fi network is being used.
|
||||||
"""
|
"""
|
||||||
|
if interface is None:
|
||||||
|
interface = get_config_value('hotspot.device', 'wlan0')
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['iwgetid', interface, '--raw'],
|
['iwgetid', interface, '--raw'],
|
||||||
|
|
@ -62,9 +67,7 @@ def get_hotspot_status() -> HotspotStatus:
|
||||||
if is_docker_mode():
|
if is_docker_mode():
|
||||||
return HotspotStatus.unknown
|
return HotspotStatus.unknown
|
||||||
|
|
||||||
config = get_config()
|
device = get_config_value('hotspot.device', 'wlan0')
|
||||||
hotspot_config = config.get("hotspot", {})
|
|
||||||
device = hotspot_config.get("device", "wlan0")
|
|
||||||
|
|
||||||
# Check if device is connected to a Wi-Fi network (not hotspot)
|
# Check if device is connected to a Wi-Fi network (not hotspot)
|
||||||
if get_current_ssid(device):
|
if get_current_ssid(device):
|
||||||
|
|
@ -111,9 +114,7 @@ def get_hotspot_status_dict() -> dict:
|
||||||
ssid: Optional[str] - Hotspot SSID when enabled
|
ssid: Optional[str] - Hotspot SSID when enabled
|
||||||
device: Optional[str] - WiFi device name
|
device: Optional[str] - WiFi device name
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
device = get_config_value('hotspot.device', 'wlan0')
|
||||||
hotspot_config = config.get("hotspot", {})
|
|
||||||
device = hotspot_config.get("device", "wlan0")
|
|
||||||
status = get_hotspot_status()
|
status = get_hotspot_status()
|
||||||
|
|
||||||
# Map enum status to enabled/available flags
|
# Map enum status to enabled/available flags
|
||||||
|
|
@ -132,7 +133,7 @@ def get_hotspot_status_dict() -> dict:
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"available": available,
|
"available": available,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"ssid": hotspot_config.get("ssid", "WROLPi") if enabled else None,
|
"ssid": get_config_value('hotspot.ssid', 'WROLPi') if enabled else None,
|
||||||
"device": device,
|
"device": device,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,12 +148,9 @@ def enable_hotspot() -> dict:
|
||||||
if is_docker_mode():
|
if is_docker_mode():
|
||||||
return {"success": False, "error": "Not available in Docker mode"}
|
return {"success": False, "error": "Not available in Docker mode"}
|
||||||
|
|
||||||
config = get_config()
|
device = get_config_value('hotspot.device', 'wlan0')
|
||||||
hotspot_config = config.get("hotspot", {})
|
ssid = get_config_value('hotspot.ssid', 'WROLPi')
|
||||||
|
password = get_config_value('hotspot.password', 'wrolpi hotspot')
|
||||||
device = hotspot_config.get("device", "wlan0")
|
|
||||||
ssid = hotspot_config.get("ssid", "WROLPi")
|
|
||||||
password = hotspot_config.get("password", "wrolpi hotspot")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First ensure radio is on
|
# First ensure radio is on
|
||||||
|
|
@ -348,8 +346,7 @@ def disable_throttle() -> dict:
|
||||||
if is_docker_mode():
|
if is_docker_mode():
|
||||||
return {"success": False, "error": "Not available in Docker mode"}
|
return {"success": False, "error": "Not available in Docker mode"}
|
||||||
|
|
||||||
config = get_config()
|
default_governor = DEFAULT_CPU_FREQUENCY
|
||||||
default_governor = config.get("throttle", {}).get("default_governor", "ondemand")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -378,40 +375,68 @@ def disable_throttle() -> dict:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def shutdown_system() -> dict:
|
def shutdown_system(delay: int = 10) -> dict:
|
||||||
"""
|
"""
|
||||||
Shut down the system.
|
Shut down the system after a delay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delay: Seconds to wait before shutdown (default 10). Allows API to return response.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with success status
|
dict with success status
|
||||||
"""
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
if is_docker_mode():
|
if is_docker_mode():
|
||||||
return {"success": False, "error": "Cannot shutdown from Docker container"}
|
return {"success": False, "error": "Cannot shutdown from Docker container"}
|
||||||
|
|
||||||
try:
|
# Check if shutdown command exists before starting thread
|
||||||
subprocess.Popen(["shutdown", "-h", "now"])
|
if not shutil.which("shutdown"):
|
||||||
return {"success": True, "message": "System shutting down"}
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {"success": False, "error": "shutdown command not found"}
|
return {"success": False, "error": "shutdown command not found"}
|
||||||
|
|
||||||
|
def _delayed_shutdown():
|
||||||
|
import time
|
||||||
|
time.sleep(delay)
|
||||||
|
subprocess.Popen(["shutdown", "-h", "now"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=_delayed_shutdown, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return {"success": True, "message": f"System shutting down in {delay} seconds"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def reboot_system() -> dict:
|
def reboot_system(delay: int = 10) -> dict:
|
||||||
"""
|
"""
|
||||||
Reboot the system.
|
Reboot the system after a delay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delay: Seconds to wait before reboot (default 10). Allows API to return response.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with success status
|
dict with success status
|
||||||
"""
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
if is_docker_mode():
|
if is_docker_mode():
|
||||||
return {"success": False, "error": "Cannot reboot from Docker container"}
|
return {"success": False, "error": "Cannot reboot from Docker container"}
|
||||||
|
|
||||||
try:
|
# Check if reboot command exists before starting thread
|
||||||
subprocess.Popen(["reboot"])
|
if not shutil.which("reboot"):
|
||||||
return {"success": True, "message": "System rebooting"}
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {"success": False, "error": "reboot command not found"}
|
return {"success": False, "error": "reboot command not found"}
|
||||||
|
|
||||||
|
def _delayed_reboot():
|
||||||
|
import time
|
||||||
|
time.sleep(delay)
|
||||||
|
subprocess.Popen(["reboot"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=_delayed_reboot, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return {"success": True, "message": f"System rebooting in {delay} seconds"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pyyaml>=6.0
|
||||||
|
|
||||||
# Docker management (optional, for Docker mode)
|
# Docker management (optional, for Docker mode)
|
||||||
docker>=6.0.0
|
docker>=6.0.0
|
||||||
|
aiohttp==3.12.14
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,38 @@ class TestEnableHotspot:
|
||||||
def test_handles_nmcli_not_found(self):
|
def test_handles_nmcli_not_found(self):
|
||||||
"""Should handle nmcli not being available."""
|
"""Should handle nmcli not being available."""
|
||||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
with mock.patch("controller.lib.admin.get_config", return_value={}):
|
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||||
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
result = enable_hotspot()
|
||||||
result = enable_hotspot()
|
assert result["success"] is False
|
||||||
assert result["success"] is False
|
assert "nmcli" in result.get("error", "").lower()
|
||||||
assert "nmcli" in result.get("error", "").lower()
|
|
||||||
|
def test_uses_config_values(self):
|
||||||
|
"""Should use get_config_value for device, ssid, and password."""
|
||||||
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
|
# Mock get_config_value to return custom values
|
||||||
|
def mock_get_config(key, default=None):
|
||||||
|
config_map = {
|
||||||
|
'hotspot.device': 'wlan1',
|
||||||
|
'hotspot.ssid': 'TestSSID',
|
||||||
|
'hotspot.password': 'testpassword123',
|
||||||
|
}
|
||||||
|
return config_map.get(key, default)
|
||||||
|
|
||||||
|
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
|
||||||
|
mock_run = mock.Mock()
|
||||||
|
mock_run.returncode = 0
|
||||||
|
with mock.patch("subprocess.run", return_value=mock_run) as mock_subprocess:
|
||||||
|
enable_hotspot()
|
||||||
|
|
||||||
|
# Verify the hotspot was created with config values
|
||||||
|
calls = mock_subprocess.call_args_list
|
||||||
|
# Find the hotspot creation call (nmcli device wifi hotspot)
|
||||||
|
hotspot_call = [c for c in calls if len(c[0][0]) > 2 and 'hotspot' in c[0][0]]
|
||||||
|
if hotspot_call:
|
||||||
|
cmd_args = hotspot_call[0][0][0]
|
||||||
|
assert "wlan1" in cmd_args
|
||||||
|
assert "TestSSID" in cmd_args
|
||||||
|
assert "testpassword123" in cmd_args
|
||||||
|
|
||||||
|
|
||||||
class TestDisableHotspot:
|
class TestDisableHotspot:
|
||||||
|
|
@ -165,19 +192,25 @@ class TestShutdownSystem:
|
||||||
def test_handles_shutdown_not_found(self):
|
def test_handles_shutdown_not_found(self):
|
||||||
"""Should handle shutdown command not being available."""
|
"""Should handle shutdown command not being available."""
|
||||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
|
with mock.patch("shutil.which", return_value=None):
|
||||||
result = shutdown_system()
|
result = shutdown_system()
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result.get("error", "").lower()
|
assert "not found" in result.get("error", "").lower()
|
||||||
|
|
||||||
def test_calls_shutdown_command(self):
|
def test_calls_shutdown_command(self):
|
||||||
"""Should call shutdown command with correct args."""
|
"""Should call shutdown command with correct args after delay."""
|
||||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
mock_popen = mock.Mock()
|
with mock.patch("shutil.which", return_value="/sbin/shutdown"):
|
||||||
with mock.patch("subprocess.Popen", mock_popen):
|
mock_popen = mock.Mock()
|
||||||
result = shutdown_system()
|
with mock.patch("subprocess.Popen", mock_popen):
|
||||||
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
|
# Use delay=0 for testing to avoid waiting
|
||||||
assert result["success"] is True
|
result = shutdown_system(delay=0)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "shutting down" in result["message"].lower()
|
||||||
|
# Give thread time to execute
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
|
||||||
|
|
||||||
|
|
||||||
class TestRebootSystem:
|
class TestRebootSystem:
|
||||||
|
|
@ -193,19 +226,25 @@ class TestRebootSystem:
|
||||||
def test_handles_reboot_not_found(self):
|
def test_handles_reboot_not_found(self):
|
||||||
"""Should handle reboot command not being available."""
|
"""Should handle reboot command not being available."""
|
||||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
|
with mock.patch("shutil.which", return_value=None):
|
||||||
result = reboot_system()
|
result = reboot_system()
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result.get("error", "").lower()
|
assert "not found" in result.get("error", "").lower()
|
||||||
|
|
||||||
def test_calls_reboot_command(self):
|
def test_calls_reboot_command(self):
|
||||||
"""Should call reboot command."""
|
"""Should call reboot command after delay."""
|
||||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||||
mock_popen = mock.Mock()
|
with mock.patch("shutil.which", return_value="/sbin/reboot"):
|
||||||
with mock.patch("subprocess.Popen", mock_popen):
|
mock_popen = mock.Mock()
|
||||||
result = reboot_system()
|
with mock.patch("subprocess.Popen", mock_popen):
|
||||||
mock_popen.assert_called_once_with(["reboot"])
|
# Use delay=0 for testing to avoid waiting
|
||||||
assert result["success"] is True
|
result = reboot_system(delay=0)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "rebooting" in result["message"].lower()
|
||||||
|
# Give thread time to execute
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
mock_popen.assert_called_once_with(["reboot"])
|
||||||
|
|
||||||
|
|
||||||
class TestRestartAllServices:
|
class TestRestartAllServices:
|
||||||
|
|
|
||||||
22
main.py
22
main.py
|
|
@ -12,7 +12,7 @@ from sanic.signals import Event
|
||||||
from modules.inventory import init_inventory
|
from modules.inventory import init_inventory
|
||||||
from modules.inventory.common import import_inventories_config
|
from modules.inventory.common import import_inventories_config
|
||||||
from modules.videos.lib import import_channels_config, get_videos_downloader_config
|
from modules.videos.lib import import_channels_config, get_videos_downloader_config
|
||||||
from wrolpi import flags, admin
|
from wrolpi import flags
|
||||||
from modules.archive.lib import import_domains_config
|
from modules.archive.lib import import_domains_config
|
||||||
from wrolpi import root_api # noqa
|
from wrolpi import root_api # noqa
|
||||||
from wrolpi import tags
|
from wrolpi import tags
|
||||||
|
|
@ -284,25 +284,7 @@ async def start_single_tasks(app: Sanic):
|
||||||
# Set all downloads to new.
|
# Set all downloads to new.
|
||||||
download_manager.retry_downloads()
|
download_manager.retry_downloads()
|
||||||
|
|
||||||
# Hotspot/throttle are not supported in Docker containers.
|
# Hotspot/throttle on startup are now handled by the Controller service.
|
||||||
if not DOCKERIZED:
|
|
||||||
if get_wrolpi_config().hotspot_on_startup:
|
|
||||||
logger.info('Starting hotspot...')
|
|
||||||
try:
|
|
||||||
admin.enable_hotspot()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Failed to enable hotspot', exc_info=e)
|
|
||||||
else:
|
|
||||||
logger.info('Hotspot on startup is disabled.')
|
|
||||||
|
|
||||||
if get_wrolpi_config().throttle_on_startup:
|
|
||||||
logger.info('Throttling CPU...')
|
|
||||||
try:
|
|
||||||
admin.throttle_cpu_on()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Failed to throttle CPU', exc_info=e)
|
|
||||||
else:
|
|
||||||
logger.info('CPU throttle on startup is disabled')
|
|
||||||
|
|
||||||
if get_wrolpi_config().download_on_startup and not wrol_mode_enabled():
|
if get_wrolpi_config().download_on_startup and not wrol_mode_enabled():
|
||||||
# Only start downloading when prerequisites have been met.
|
# Only start downloading when prerequisites have been met.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
import wrolpi.files.api # noqa Import from files so the blueprint is attached.
|
import wrolpi.files.api # noqa Import from files so the blueprint is attached.
|
||||||
import wrolpi.scrape_downloader # noqa
|
import wrolpi.scrape_downloader # noqa
|
||||||
import wrolpi.status # noqa
|
|
||||||
|
|
|
||||||
225
wrolpi/admin.py
225
wrolpi/admin.py
|
|
@ -1,225 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import enum
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from wrolpi.cmd import NMCLI_BIN, CPUFREQ_INFO_BIN, SUDO_BIN, CPUFREQ_SET_BIN, run_command
|
|
||||||
from wrolpi.common import logger, WROLPI_CONFIG, get_warn_once, background_task
|
|
||||||
from wrolpi.errors import ShutdownFailed
|
|
||||||
from wrolpi.events import Events
|
|
||||||
from wrolpi.vars import DEFAULT_CPU_FREQUENCY, DOCKERIZED
|
|
||||||
|
|
||||||
logger = logger.getChild(__name__)
|
|
||||||
|
|
||||||
POWER_SAVE_FREQ = 'powersave' # noqa
|
|
||||||
|
|
||||||
nmcli_warn_once = get_warn_once('Unable to use nmcli', logger)
|
|
||||||
iwgetid_warn_once = get_warn_once('Unable to use iwgetid', logger)
|
|
||||||
|
|
||||||
|
|
||||||
class HotspotStatus(enum.Enum):
|
|
||||||
disconnected = enum.auto() # Radio is on, but Hotspot is not connected.
|
|
||||||
unavailable = enum.auto() # Radio is off.
|
|
||||||
connected = enum.auto() # Radio is on, Hotspot is on.
|
|
||||||
unknown = enum.auto() # Unknown status. Hotspot may not be supported on this hardware.
|
|
||||||
in_use = enum.auto() # WI-FI device is in use, and cannot be used for Hotspot.
|
|
||||||
|
|
||||||
|
|
||||||
def hotspot_status() -> HotspotStatus:
|
|
||||||
if NMCLI_BIN is None:
|
|
||||||
return HotspotStatus.unknown
|
|
||||||
|
|
||||||
device = WROLPI_CONFIG.hotspot_device
|
|
||||||
|
|
||||||
if get_current_ssid(device):
|
|
||||||
# Hotspot device is currently connected to a WI-FI network.
|
|
||||||
return HotspotStatus.in_use
|
|
||||||
|
|
||||||
cmd = (NMCLI_BIN,)
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode().strip()
|
|
||||||
except Exception as e:
|
|
||||||
nmcli_warn_once(e)
|
|
||||||
return HotspotStatus.unknown
|
|
||||||
|
|
||||||
for line in output.splitlines():
|
|
||||||
if line.startswith(f'{device}: connected'):
|
|
||||||
return HotspotStatus.connected
|
|
||||||
elif line.startswith(f'{device}: disconnected'):
|
|
||||||
return HotspotStatus.disconnected
|
|
||||||
elif line.startswith(f'{device}: unavailable'):
|
|
||||||
return HotspotStatus.unavailable
|
|
||||||
|
|
||||||
return HotspotStatus.unknown
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_ssid(interface='wlan0') -> str | None:
|
|
||||||
"""Returns the name of the SSID that is currently connected. Returns None if Hotspot, or no WI-FI network is being
|
|
||||||
used."""
|
|
||||||
try:
|
|
||||||
# Run the iwgetid command and capture the output
|
|
||||||
cmd = ('iwgetid', interface, '--raw')
|
|
||||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
||||||
|
|
||||||
# Check if the command was successful
|
|
||||||
if result.returncode == 0:
|
|
||||||
ssid = result.stdout.strip()
|
|
||||||
return ssid
|
|
||||||
elif result.returncode == 255:
|
|
||||||
# Hotspot is in use.
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
logger.error(f'Failed to get current ssid: {result.stderr}')
|
|
||||||
return None
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
iwgetid_warn_once(e)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Failed to get current ssid', exc_info=e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def enable_hotspot(depth: int = 10, overwrite: bool = False):
|
|
||||||
"""Turn the Wi-Fi interface into a hotspot.
|
|
||||||
|
|
||||||
If Wi-Fi is already running, replace that with a hotspot.
|
|
||||||
"""
|
|
||||||
if depth < 0:
|
|
||||||
raise RuntimeError(f'Failed to start hotspot!')
|
|
||||||
|
|
||||||
status = hotspot_status()
|
|
||||||
logger.info(f'Hotspot status: {status}')
|
|
||||||
|
|
||||||
if overwrite is False and (current_ssid := get_current_ssid(WROLPI_CONFIG.hotspot_device)):
|
|
||||||
raise RuntimeError(f'Refusing to start hotspot because Wi-Fi device is already being used for {current_ssid}.')
|
|
||||||
|
|
||||||
if status == HotspotStatus.connected:
|
|
||||||
logger.info('Hotspot already connected. Rebooting hotspot.')
|
|
||||||
disable_hotspot()
|
|
||||||
return enable_hotspot(depth=depth - 1)
|
|
||||||
elif status == HotspotStatus.in_use:
|
|
||||||
logger.info('Wifi already in use. Rebooting hotspot.')
|
|
||||||
disable_hotspot()
|
|
||||||
return enable_hotspot(depth=depth - 1)
|
|
||||||
elif status == HotspotStatus.disconnected:
|
|
||||||
# Radio is on, but not connected. Good, turn it into a hotspot.
|
|
||||||
cmd = (SUDO_BIN, NMCLI_BIN, 'device', 'wifi', 'hotspot', 'ifname', WROLPI_CONFIG.hotspot_device,
|
|
||||||
'ssid', WROLPI_CONFIG.hotspot_ssid, 'password', WROLPI_CONFIG.hotspot_password)
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
logger.info('Enabling hotspot successful')
|
|
||||||
return True
|
|
||||||
elif status == HotspotStatus.unavailable:
|
|
||||||
# Radio is not on, turn it on.
|
|
||||||
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on')
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
logger.info('Turned on hotspot radio.')
|
|
||||||
return enable_hotspot(depth=depth - 1)
|
|
||||||
elif status == HotspotStatus.unknown:
|
|
||||||
logger.error('Cannot enable hotspot with unknown status!')
|
|
||||||
|
|
||||||
|
|
||||||
def disable_hotspot():
|
|
||||||
"""Turn off the Wi-Fi interface."""
|
|
||||||
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off')
|
|
||||||
try:
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
logger.info('Disabling hotspot successful')
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Failed to disable hotspot', exc_info=e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class GovernorStatus(enum.Enum):
|
|
||||||
ondemand = enum.auto()
|
|
||||||
powersave = enum.auto()
|
|
||||||
unknown = enum.auto()
|
|
||||||
# These are unused by WROLPi.
|
|
||||||
# performance = enum.auto()
|
|
||||||
# schedutil = enum.auto()
|
|
||||||
# userspace = enum.auto()
|
|
||||||
# conservative = enum.auto()
|
|
||||||
|
|
||||||
|
|
||||||
GOVERNOR_MAP = {
|
|
||||||
'governor "ondemand"': GovernorStatus.ondemand,
|
|
||||||
'governor "powersave"': GovernorStatus.powersave,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def throttle_status() -> GovernorStatus:
|
|
||||||
if not CPUFREQ_INFO_BIN:
|
|
||||||
return GovernorStatus.unknown
|
|
||||||
|
|
||||||
cmd = (CPUFREQ_INFO_BIN,)
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(cmd)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
if not DOCKERIZED:
|
|
||||||
logger.debug(f'Could not get CPU throttle info', exc_info=e)
|
|
||||||
return GovernorStatus.unknown
|
|
||||||
output = output.decode().strip()
|
|
||||||
|
|
||||||
for line in output.splitlines():
|
|
||||||
for governor, status in GOVERNOR_MAP.items():
|
|
||||||
if governor in line:
|
|
||||||
return status
|
|
||||||
|
|
||||||
return GovernorStatus.unknown
|
|
||||||
|
|
||||||
|
|
||||||
def throttle_cpu_on() -> bool:
|
|
||||||
"""Call cpufreq-set to throttle CPU."""
|
|
||||||
try:
|
|
||||||
cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', POWER_SAVE_FREQ)
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
return True
|
|
||||||
except FileNotFoundError:
|
|
||||||
if not DOCKERIZED:
|
|
||||||
logger.error('Could not enable CPU throttle', exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def throttle_cpu_off() -> bool:
|
|
||||||
"""Call cpufreq-set to un-throttle CPU."""
|
|
||||||
try:
|
|
||||||
cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', DEFAULT_CPU_FREQUENCY)
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
return True
|
|
||||||
except FileNotFoundError:
|
|
||||||
if not DOCKERIZED:
|
|
||||||
logger.error('Could not disable CPU throttle', exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def shutdown(reboot: bool = False, delay: int = 10):
|
|
||||||
"""Shutdown or reboot the WROLPi. Schedules an asyncio sleep to delay the shutdown.
|
|
||||||
|
|
||||||
@raise ShutdownFailed: If not possible, or if sudo command failed."""
|
|
||||||
if DOCKERIZED:
|
|
||||||
raise ShutdownFailed('Cannot Reboot because we are dockerized')
|
|
||||||
|
|
||||||
cmd = (SUDO_BIN, 'shutdown', '--reboot', '0') if reboot else (SUDO_BIN, 'shutdown', '0')
|
|
||||||
|
|
||||||
async def _():
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Call shutdown (or reboot) with no delay now that we have slept.
|
|
||||||
result = await run_command(cmd)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
if reboot:
|
|
||||||
Events.send_shutdown_failed('Reboot failed')
|
|
||||||
else:
|
|
||||||
Events.send_shutdown_failed('Shutdown failed')
|
|
||||||
raise ShutdownFailed() from e
|
|
||||||
|
|
||||||
if result.return_code != 0:
|
|
||||||
if reboot:
|
|
||||||
Events.send_shutdown_failed('Reboot failed')
|
|
||||||
else:
|
|
||||||
Events.send_shutdown_failed('Shutdown failed')
|
|
||||||
raise ShutdownFailed(f'Got return code {result.return_code}')
|
|
||||||
|
|
||||||
background_task(_())
|
|
||||||
if reboot:
|
|
||||||
Events.send_shutdown(f'Reboot in {delay} seconds')
|
|
||||||
else:
|
|
||||||
Events.send_shutdown(f'Shutdown in {delay} seconds')
|
|
||||||
|
|
@ -818,8 +818,3 @@ def mock_downloader_download_file():
|
||||||
yield set_contents
|
yield set_contents
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def start_status_worker():
|
|
||||||
"""Enable the status worker for testing."""
|
|
||||||
with mock.patch('wrolpi.status.DISABLE_STATUS_WORKER', False):
|
|
||||||
yield
|
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ from modules.map.api import map_bp
|
||||||
from modules.otp.api import otp_bp
|
from modules.otp.api import otp_bp
|
||||||
from modules.videos.api import videos_bp
|
from modules.videos.api import videos_bp
|
||||||
from modules.zim.api import zim_bp
|
from modules.zim.api import zim_bp
|
||||||
from wrolpi import admin, flags, schema, dates
|
from wrolpi import flags, schema, dates
|
||||||
from wrolpi import tags
|
from wrolpi import tags
|
||||||
from wrolpi.admin import HotspotStatus
|
|
||||||
from wrolpi.api_utils import json_response, api_app
|
from wrolpi.api_utils import json_response, api_app
|
||||||
from wrolpi.collections.api import collection_bp
|
from wrolpi.collections.api import collection_bp
|
||||||
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
|
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
|
||||||
|
|
@ -28,8 +27,7 @@ from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_medi
|
||||||
from wrolpi.config_api import config_bp
|
from wrolpi.config_api import config_bp
|
||||||
from wrolpi.dates import now
|
from wrolpi.dates import now
|
||||||
from wrolpi.downloader import download_manager
|
from wrolpi.downloader import download_manager
|
||||||
from wrolpi.errors import WROLModeEnabled, HotspotError, HotspotPasswordTooShort, InvalidConfig, \
|
from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError
|
||||||
ValidationError
|
|
||||||
from wrolpi.events import get_events, Events
|
from wrolpi.events import get_events, Events
|
||||||
from wrolpi.files import files_bp
|
from wrolpi.files import files_bp
|
||||||
from wrolpi.files.lib import get_file_statistics, search_file_suggestion_count
|
from wrolpi.files.lib import get_file_statistics, search_file_suggestion_count
|
||||||
|
|
@ -161,7 +159,7 @@ def get_settings(_: Request):
|
||||||
'hotspot_on_startup': wrolpi_config.hotspot_on_startup,
|
'hotspot_on_startup': wrolpi_config.hotspot_on_startup,
|
||||||
'hotspot_password': wrolpi_config.hotspot_password,
|
'hotspot_password': wrolpi_config.hotspot_password,
|
||||||
'hotspot_ssid': wrolpi_config.hotspot_ssid,
|
'hotspot_ssid': wrolpi_config.hotspot_ssid,
|
||||||
'hotspot_status': admin.hotspot_status().name,
|
# Hotspot/throttle status now from Controller (/controller/api/hotspot/status, /controller/api/throttle/status)
|
||||||
'check_for_upgrades': wrolpi_config.check_for_upgrades,
|
'check_for_upgrades': wrolpi_config.check_for_upgrades,
|
||||||
'ignore_outdated_zims': wrolpi_config.ignore_outdated_zims,
|
'ignore_outdated_zims': wrolpi_config.ignore_outdated_zims,
|
||||||
'ignored_directories': ignored_directories,
|
'ignored_directories': ignored_directories,
|
||||||
|
|
@ -170,7 +168,6 @@ def get_settings(_: Request):
|
||||||
'nav_color': wrolpi_config.nav_color,
|
'nav_color': wrolpi_config.nav_color,
|
||||||
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
|
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
|
||||||
'throttle_on_startup': wrolpi_config.throttle_on_startup,
|
'throttle_on_startup': wrolpi_config.throttle_on_startup,
|
||||||
'throttle_status': admin.throttle_status().name,
|
|
||||||
'version': __version__,
|
'version': __version__,
|
||||||
'videos_destination': wrolpi_config.videos_destination,
|
'videos_destination': wrolpi_config.videos_destination,
|
||||||
'wrol_mode': wrolpi_config.wrol_mode,
|
'wrol_mode': wrolpi_config.wrol_mode,
|
||||||
|
|
@ -200,7 +197,7 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
|
||||||
return response.empty()
|
return response.empty()
|
||||||
|
|
||||||
if body.hotspot_password and len(body.hotspot_password) < 8:
|
if body.hotspot_password and len(body.hotspot_password) < 8:
|
||||||
raise HotspotPasswordTooShort()
|
raise InvalidConfig('Hotspot password must be at least 8 characters')
|
||||||
|
|
||||||
# Remove any keys with None values, then save the config.
|
# Remove any keys with None values, then save the config.
|
||||||
new_config = {k: v for k, v in body.__dict__.items() if v is not None}
|
new_config = {k: v for k, v in body.__dict__.items() if v is not None}
|
||||||
|
|
@ -235,21 +232,9 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
|
||||||
# Persist log level to config
|
# Persist log level to config
|
||||||
wrolpi_config.log_level = log_level_int_to_name(log_level)
|
wrolpi_config.log_level = log_level_int_to_name(log_level)
|
||||||
|
|
||||||
old_password = wrolpi_config.hotspot_password
|
# Save config settings (hotspot control is now through Controller endpoints)
|
||||||
wrolpi_config.update(new_config)
|
wrolpi_config.update(new_config)
|
||||||
|
|
||||||
# If the password was changed, we need to restart the hotspot.
|
|
||||||
password_changed = (new_password := new_config.get('hotspot_password')) and old_password != new_password
|
|
||||||
|
|
||||||
if body.hotspot_status is True or (password_changed and admin.hotspot_status() == HotspotStatus.connected):
|
|
||||||
# Turn on Hotspot
|
|
||||||
if admin.enable_hotspot() is False:
|
|
||||||
raise HotspotError('Could not turn on hotspot')
|
|
||||||
elif body.hotspot_status is False:
|
|
||||||
# Turn off Hotspot
|
|
||||||
if admin.disable_hotspot() is False:
|
|
||||||
raise HotspotError('Could not turn off hotspot')
|
|
||||||
|
|
||||||
return response.empty()
|
return response.empty()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -425,46 +410,6 @@ async def get_downloaders(_: Request):
|
||||||
return json_response(ret)
|
return json_response(ret)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/hotspot/on')
|
|
||||||
@openapi.description('Turn on the hotspot')
|
|
||||||
@native_only
|
|
||||||
async def hotspot_on(_: Request):
|
|
||||||
result = admin.enable_hotspot(overwrite=True)
|
|
||||||
if result:
|
|
||||||
return response.empty()
|
|
||||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/hotspot/off')
|
|
||||||
@openapi.description('Turn off the hotspot')
|
|
||||||
@native_only
|
|
||||||
async def hotspot_off(_: Request):
|
|
||||||
result = admin.disable_hotspot()
|
|
||||||
if result:
|
|
||||||
return response.empty()
|
|
||||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/throttle/on')
|
|
||||||
@openapi.description('Turn on CPU throttling')
|
|
||||||
@native_only
|
|
||||||
async def throttle_on(_: Request):
|
|
||||||
result = admin.throttle_cpu_on()
|
|
||||||
if result:
|
|
||||||
return response.empty()
|
|
||||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/throttle/off')
|
|
||||||
@openapi.description('Turn off CPU throttling')
|
|
||||||
@native_only
|
|
||||||
async def throttle_off(_: Request):
|
|
||||||
result = admin.throttle_cpu_off()
|
|
||||||
if result:
|
|
||||||
return response.empty()
|
|
||||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.get('/status')
|
@api_bp.get('/status')
|
||||||
@openapi.definition(
|
@openapi.definition(
|
||||||
description='Get the status of CPU/load/etc.',
|
description='Get the status of CPU/load/etc.',
|
||||||
|
|
@ -491,13 +436,11 @@ async def get_status(request: Request):
|
||||||
dockerized=DOCKERIZED,
|
dockerized=DOCKERIZED,
|
||||||
downloads=downloads,
|
downloads=downloads,
|
||||||
flags=flags.get_flags(),
|
flags=flags.get_flags(),
|
||||||
hotspot_ssid=admin.get_current_ssid(get_wrolpi_config().hotspot_device),
|
# Hotspot and throttle status now come from Controller (/controller/api/stats)
|
||||||
hotspot_status=admin.hotspot_status().name,
|
|
||||||
is_rpi4=IS_RPI4,
|
is_rpi4=IS_RPI4,
|
||||||
is_rpi5=IS_RPI5,
|
is_rpi5=IS_RPI5,
|
||||||
is_rpi=IS_RPI,
|
is_rpi=IS_RPI,
|
||||||
sanic_workers=sanic_workers,
|
sanic_workers=sanic_workers,
|
||||||
throttle_status=admin.throttle_status().name,
|
|
||||||
version=__version__,
|
version=__version__,
|
||||||
wrol_mode=wrol_mode_enabled(),
|
wrol_mode=wrol_mode_enabled(),
|
||||||
# Include all stats from status worker (includes upgrade info and git_branch).
|
# Include all stats from status worker (includes upgrade info and git_branch).
|
||||||
|
|
@ -612,22 +555,6 @@ async def post_vin_number_decoder(_: Request, body: schema.VINDecoderRequest):
|
||||||
return json_response(dict(vin=vin))
|
return json_response(dict(vin=vin))
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/restart')
|
|
||||||
@openapi.definition(description='Restart the system')
|
|
||||||
@native_only
|
|
||||||
async def post_restart(_: Request):
|
|
||||||
await admin.shutdown(reboot=True)
|
|
||||||
return response.empty(HTTPStatus.NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.post('/shutdown')
|
|
||||||
@openapi.definition(description='Shutdown the system')
|
|
||||||
@native_only
|
|
||||||
async def post_shutdown(_: Request):
|
|
||||||
await admin.shutdown()
|
|
||||||
return response.empty(HTTPStatus.NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.get('/upgrade/check')
|
@api_bp.get('/upgrade/check')
|
||||||
@openapi.definition(description='Check for available WROLPi updates')
|
@openapi.definition(description='Check for available WROLPi updates')
|
||||||
@native_only
|
@native_only
|
||||||
|
|
|
||||||
650
wrolpi/status.py
650
wrolpi/status.py
|
|
@ -1,650 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import multiprocessing
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
import statistics
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from decimal import Decimal
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional, Dict
|
|
||||||
|
|
||||||
import jc
|
|
||||||
|
|
||||||
from wrolpi.api_utils import api_app, perpetual_signal
|
|
||||||
from wrolpi.cmd import which, run_command
|
|
||||||
from wrolpi.common import logger, get_warn_once, unique_by_predicate, partition, TRACE_LEVEL
|
|
||||||
from wrolpi.dates import now
|
|
||||||
from wrolpi.vars import PYTEST, DOCKERIZED
|
|
||||||
|
|
||||||
try:
|
|
||||||
import psutil
|
|
||||||
except ImportError:
|
|
||||||
logger.warning('Unable to import psutil!')
|
|
||||||
psutil = None
|
|
||||||
|
|
||||||
logger = logger.getChild(__name__)
|
|
||||||
|
|
||||||
UPTIME_BIN = which('uptime', '/usr/bin/uptime')
|
|
||||||
|
|
||||||
warn_once = get_warn_once('Unable to use psutil', logger)
|
|
||||||
warn_cpu_once = get_warn_once('Cannot read CPU stats', logger)
|
|
||||||
warn_cpu_frequency_once = get_warn_once('Cannot read CPU frequency paths', logger)
|
|
||||||
warn_cpu_temperature_once = get_warn_once('Cannot read CPU Temperature stats', logger)
|
|
||||||
warn_iostat_once = get_warn_once('Cannot get iostat stats', logger)
|
|
||||||
status_worker_warn_once = get_warn_once('Status worker encountered error', logger)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SystemLoad:
|
|
||||||
minute_1: Decimal = 0
|
|
||||||
minute_5: Decimal = 0
|
|
||||||
minute_15: Decimal = 0
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
minute_1=str(round(self.minute_1, 2)),
|
|
||||||
minute_5=str(round(self.minute_5, 2)),
|
|
||||||
minute_15=str(round(self.minute_15, 2)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
LOAD_REGEX = re.compile(r'.+?load average: (.+?), (.+?), (.*)')
|
|
||||||
LOAD_AVG_FILE = pathlib.Path('/proc/loadavg')
|
|
||||||
|
|
||||||
|
|
||||||
async def get_load_stats() -> SystemLoad:
|
|
||||||
"""Read loadavg file and return the 1, 5, and 15 minute system loads."""
|
|
||||||
if LOAD_AVG_FILE.exists():
|
|
||||||
# Read load information from proc file.
|
|
||||||
# Example: 0.99 1.06 1.02 1/2305 43879
|
|
||||||
loadavg = LOAD_AVG_FILE.read_text()
|
|
||||||
load_1, load_5, load_15, *_ = loadavg.split(' ')
|
|
||||||
else:
|
|
||||||
load_1, load_5, load_15 = os.getloadavg()
|
|
||||||
load = SystemLoad(Decimal(load_1), Decimal(load_5), Decimal(load_15))
|
|
||||||
return load
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IostatInfo:
|
|
||||||
percent_idle: float = None
|
|
||||||
percent_iowait: float = None
|
|
||||||
percent_nice: float = None
|
|
||||||
percent_steal: float = None
|
|
||||||
percent_system: float = None
|
|
||||||
percent_user: float = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
percent_idle=self.percent_idle,
|
|
||||||
percent_iowait=self.percent_iowait,
|
|
||||||
percent_nice=self.percent_nice,
|
|
||||||
percent_steal=self.percent_steal,
|
|
||||||
percent_system=self.percent_system,
|
|
||||||
percent_user=self.percent_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_iostat_stats() -> IostatInfo:
|
|
||||||
try:
|
|
||||||
result = await run_command(
|
|
||||||
('iostat', '3', '2'), # 3 second interval to gather more data, get 2 count.
|
|
||||||
log_command=False,
|
|
||||||
)
|
|
||||||
if result.stdout and result.return_code == 0:
|
|
||||||
iostat_stats = jc.parse('iostat', result.stdout.decode(), quiet=True)
|
|
||||||
if iostat_stats:
|
|
||||||
cpu_statuses, _ = partition(lambda i: i['type'] == 'cpu', iostat_stats)
|
|
||||||
_, cpu_stats = cpu_statuses # Use the second status because the first is a lie.
|
|
||||||
|
|
||||||
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
|
||||||
logger.trace(
|
|
||||||
f'get_iostat_stats'
|
|
||||||
f' percent_iowait={cpu_stats["percent_iowait"]}'
|
|
||||||
f' percent_idle={cpu_stats["percent_idle"]}')
|
|
||||||
# Return only the CPU stats.
|
|
||||||
return IostatInfo(
|
|
||||||
percent_idle=cpu_stats['percent_idle'],
|
|
||||||
percent_iowait=cpu_stats['percent_iowait'],
|
|
||||||
percent_nice=cpu_stats['percent_nice'],
|
|
||||||
percent_steal=cpu_stats['percent_steal'],
|
|
||||||
percent_system=cpu_stats['percent_system'],
|
|
||||||
percent_user=cpu_stats['percent_user'],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
|
||||||
logger.trace(f'get_iostat_stats failed: {e}')
|
|
||||||
warn_iostat_once(e)
|
|
||||||
|
|
||||||
return IostatInfo()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessInfo:
|
|
||||||
pid: int = None
|
|
||||||
percent_cpu: int = None
|
|
||||||
percent_mem: int = None
|
|
||||||
command: str = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
pid=self.pid,
|
|
||||||
percent_cpu=self.percent_cpu,
|
|
||||||
percent_mem=self.percent_mem,
|
|
||||||
command=self.command,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CPUInfo:
|
|
||||||
cores: int = None
|
|
||||||
cur_frequency: int = None
|
|
||||||
max_frequency: int = None
|
|
||||||
min_frequency: int = None
|
|
||||||
percent: int = None
|
|
||||||
temperature: Optional[int] = None
|
|
||||||
high_temperature: Optional[float] = None
|
|
||||||
critical_temperature: Optional[float] = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
cores=self.cores,
|
|
||||||
cur_frequency=self.cur_frequency,
|
|
||||||
max_frequency=self.max_frequency,
|
|
||||||
min_frequency=self.min_frequency,
|
|
||||||
percent=self.percent,
|
|
||||||
temperature=self.temperature,
|
|
||||||
high_temperature=self.high_temperature,
|
|
||||||
critical_temperature=self.critical_temperature,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
MIN_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq')
|
|
||||||
MAX_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq')
|
|
||||||
CUR_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
|
|
||||||
TEMPERATURE_PATH = Path('/sys/class/thermal/thermal_zone0/temp')
|
|
||||||
TOP_REGEX = re.compile(r'^%Cpu\(s\):\s+(\d+\.\d+)', re.MULTILINE)
|
|
||||||
|
|
||||||
CORE_COUNT = multiprocessing.cpu_count()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cpu_stats() -> CPUInfo:
|
|
||||||
"""Get core count, max freq, min freq, current freq, cpu temperature."""
|
|
||||||
percent = int(psutil.cpu_percent())
|
|
||||||
|
|
||||||
min_frequency = max_frequency = cur_frequency = None
|
|
||||||
try:
|
|
||||||
min_frequency = int(MIN_FREQUENCY_PATH.read_text())
|
|
||||||
max_frequency = int(MAX_FREQUENCY_PATH.read_text())
|
|
||||||
cur_frequency = int(CUR_FREQUENCY_PATH.read_text())
|
|
||||||
except Exception as e:
|
|
||||||
warn_cpu_frequency_once(e)
|
|
||||||
|
|
||||||
# Get temperature using psutil.
|
|
||||||
temperature = high_temperature = critical_temperature = None
|
|
||||||
try:
|
|
||||||
temp = psutil.sensors_temperatures()
|
|
||||||
name = 'coretemp' if 'coretemp' in temp else list(temp.keys())[0]
|
|
||||||
# Temperatures may be None. Get average temperatures from CPU because one core may be hot.
|
|
||||||
temperatures = temp.get(name)
|
|
||||||
temperature = temperature or statistics.median([i.current or 0 for i in temperatures])
|
|
||||||
high_temperature = statistics.median([i.high or 0 for i in temperatures])
|
|
||||||
critical_temperature = statistics.median([i.critical or 0 for i in temperatures])
|
|
||||||
|
|
||||||
# Temperatures may not exist.
|
|
||||||
high_temperature = high_temperature or 60
|
|
||||||
critical_temperature = critical_temperature or 95
|
|
||||||
|
|
||||||
if high_temperature and high_temperature == critical_temperature:
|
|
||||||
# Display yellow warning before red warning.
|
|
||||||
high_temperature = critical_temperature - 25
|
|
||||||
except Exception as e:
|
|
||||||
warn_cpu_temperature_once(e)
|
|
||||||
|
|
||||||
info = CPUInfo(
|
|
||||||
cores=CORE_COUNT,
|
|
||||||
critical_temperature=int(critical_temperature) if critical_temperature else None,
|
|
||||||
# Current frequency is between minimum and maximum.
|
|
||||||
cur_frequency=cur_frequency - min_frequency if cur_frequency else None,
|
|
||||||
high_temperature=int(high_temperature) if high_temperature else None,
|
|
||||||
max_frequency=max_frequency,
|
|
||||||
min_frequency=min_frequency,
|
|
||||||
percent=percent,
|
|
||||||
temperature=int(temperature) if temperature else None,
|
|
||||||
)
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
PS_CMD = ('ps', 'aux', '--sort=-%cpu', '--cols=512')
|
|
||||||
IGNORED_PROCESS_COMMANDS = {' '.join(PS_CMD), '<defunct>'}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_processes_stats() -> List[ProcessInfo]:
|
|
||||||
result = await run_command(PS_CMD, log_command=False)
|
|
||||||
# Do not include `ps` in the top processes.
|
|
||||||
ps_pid = result.pid
|
|
||||||
|
|
||||||
ps_info = jc.parse('ps', result.stdout.decode(), quiet=True)
|
|
||||||
processes = sorted(ps_info, key=lambda i: i['cpu_percent'], reverse=True)
|
|
||||||
processes = [i for i in processes if i['pid'] != ps_pid
|
|
||||||
and i['cpu_percent'] > 1
|
|
||||||
and not any(j in i['command'] for j in IGNORED_PROCESS_COMMANDS)]
|
|
||||||
processes = unique_by_predicate(processes, lambda i: i['command'][:30])
|
|
||||||
processes = processes[:10]
|
|
||||||
processes_info = [ProcessInfo(
|
|
||||||
pid=int(i['pid']),
|
|
||||||
percent_cpu=int(i['cpu_percent']),
|
|
||||||
percent_mem=int(i['mem_percent']),
|
|
||||||
command=i['command'],
|
|
||||||
) for i in processes]
|
|
||||||
|
|
||||||
return processes_info
|
|
||||||
|
|
||||||
|
|
||||||
MEMORY_TOTAL = re.compile(r'^MemTotal:\s+(\d+)\s+kB$', re.MULTILINE)
|
|
||||||
MEMORY_FREE = re.compile(r'^MemFree:\s+(\d+)\s+kB$', re.MULTILINE)
|
|
||||||
MEMORY_CACHED = re.compile(r'^Cached:\s+(\d+)\s+kB$', re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MemoryStats:
|
|
||||||
total: int = None
|
|
||||||
used: int = None
|
|
||||||
free: int = None
|
|
||||||
cached: int = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
total=self.total,
|
|
||||||
used=self.used,
|
|
||||||
free=self.free,
|
|
||||||
cached=self.cached,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_memory_stats_psutil():
|
|
||||||
mem = psutil.virtual_memory()
|
|
||||||
mem = MemoryStats(
|
|
||||||
total=mem.total,
|
|
||||||
used=mem.used,
|
|
||||||
free=mem.free,
|
|
||||||
cached=mem.cached,
|
|
||||||
)
|
|
||||||
return mem
|
|
||||||
|
|
||||||
|
|
||||||
async def get_memory_stats():
|
|
||||||
try:
|
|
||||||
return get_memory_stats_psutil()
|
|
||||||
except Exception as e:
|
|
||||||
warn_once(e)
|
|
||||||
|
|
||||||
meminfo = pathlib.Path('/proc/meminfo')
|
|
||||||
if meminfo.is_file() and (contents := meminfo.read_text()):
|
|
||||||
total = int(i[0]) if (i := MEMORY_TOTAL.findall(contents)) else None
|
|
||||||
free = int(i[0]) if (i := MEMORY_FREE.findall(contents)) else None
|
|
||||||
cached = int(i[0]) if (i := MEMORY_CACHED.findall(contents)) else None
|
|
||||||
mem = MemoryStats(
|
|
||||||
total=int(total * 1024) if total else None,
|
|
||||||
free=int(free * 1024) if free else None,
|
|
||||||
cached=int(cached * 1024) if cached else None,
|
|
||||||
used=int(total - free) if total and free else None,
|
|
||||||
)
|
|
||||||
return mem
|
|
||||||
|
|
||||||
return MemoryStats()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DriveInfo:
|
|
||||||
mount: str = None
|
|
||||||
percent: int = 0
|
|
||||||
size: int = 0
|
|
||||||
used: int = 0
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
mount=self.mount,
|
|
||||||
percent=self.percent,
|
|
||||||
size=self.size,
|
|
||||||
used=self.used,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DRIVE_REGEX = re.compile(r'^.+?' # filesystem
|
|
||||||
r'\s+(\d+)K' # size
|
|
||||||
r'\s+(\d+)K' # used
|
|
||||||
r'\s+(\d+)K' # available
|
|
||||||
r'\s+(\d+)%' # use %
|
|
||||||
r'\s+(.*)', # mount
|
|
||||||
re.MULTILINE)
|
|
||||||
IGNORED_DRIVES = ['/boot', '/etc']
|
|
||||||
VALID_FORMATS = {'btrfs', 'ext4', 'ext3', 'ext2', 'vfat', 'exfat', 'apfs'}
|
|
||||||
|
|
||||||
|
|
||||||
def get_drives_info_psutil() -> List[DriveInfo]:
|
|
||||||
info = {}
|
|
||||||
|
|
||||||
disks = psutil.disk_partitions()
|
|
||||||
for disk in disks:
|
|
||||||
if any(disk.mountpoint.startswith(i) for i in IGNORED_DRIVES):
|
|
||||||
continue
|
|
||||||
if disk.fstype not in VALID_FORMATS:
|
|
||||||
continue
|
|
||||||
if disk.device not in info:
|
|
||||||
# Only use the first partition.
|
|
||||||
usage = psutil.disk_usage(disk.mountpoint)
|
|
||||||
info[disk.device] = DriveInfo(
|
|
||||||
mount=disk.mountpoint,
|
|
||||||
percent=int(usage.percent),
|
|
||||||
size=int(usage.total),
|
|
||||||
used=int(usage.used),
|
|
||||||
)
|
|
||||||
|
|
||||||
info = sorted(info.values(), key=lambda i: i.mount)
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
async def get_drives_stats() -> List[DriveInfo]:
|
|
||||||
try:
|
|
||||||
return get_drives_info_psutil()
|
|
||||||
except Exception as e:
|
|
||||||
warn_once(e)
|
|
||||||
|
|
||||||
# Fallback to using `df` to fetch disk information.
|
|
||||||
proc = await asyncio.subprocess.create_subprocess_shell(
|
|
||||||
'df --type btrfs --type ext4 --type ext3 --type ext2 --type vfat -Bk',
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await proc.communicate()
|
|
||||||
drives = []
|
|
||||||
for size, used, _, percent, mount in DRIVE_REGEX.findall(stdout.decode()):
|
|
||||||
mount = mount.strip()
|
|
||||||
if any(mount.startswith(i) for i in IGNORED_DRIVES):
|
|
||||||
continue
|
|
||||||
|
|
||||||
size = int(size) * 1024
|
|
||||||
used = int(used) * 1024
|
|
||||||
|
|
||||||
drives.append(DriveInfo(
|
|
||||||
mount=mount,
|
|
||||||
percent=int(percent),
|
|
||||||
size=size,
|
|
||||||
used=used,
|
|
||||||
))
|
|
||||||
|
|
||||||
drives = sorted(drives, key=lambda i: i.mount)
|
|
||||||
return drives
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NICBandwidthInfo:
|
|
||||||
bytes_recv: int = None
|
|
||||||
bytes_sent: int = None
|
|
||||||
elapsed: int = None
|
|
||||||
name: str = None
|
|
||||||
speed: int = None
|
|
||||||
now: float = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
bytes_recv=self.bytes_recv,
|
|
||||||
bytes_sent=self.bytes_sent,
|
|
||||||
elapsed=self.elapsed,
|
|
||||||
name=self.name,
|
|
||||||
speed=self.speed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
IGNORED_NIC_NAMES = {
|
|
||||||
'lo',
|
|
||||||
'veth',
|
|
||||||
'tun',
|
|
||||||
'docker',
|
|
||||||
'br-',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DiskBandwidthInfo:
|
|
||||||
bytes_recv: int = None
|
|
||||||
bytes_sent: int = None
|
|
||||||
bytes_read_ps: int = None
|
|
||||||
bytes_write_ps: int = None
|
|
||||||
elapsed: int = None
|
|
||||||
name: str = None
|
|
||||||
now: float = None
|
|
||||||
max_read_ps: int = None
|
|
||||||
max_write_ps: int = None
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
bytes_recv=self.bytes_recv,
|
|
||||||
bytes_sent=self.bytes_sent,
|
|
||||||
bytes_read_ps=self.bytes_read_ps,
|
|
||||||
bytes_write_ps=self.bytes_write_ps,
|
|
||||||
elapsed=self.elapsed,
|
|
||||||
name=self.name,
|
|
||||||
now=self.now,
|
|
||||||
max_read_ps=self.max_read_ps,
|
|
||||||
max_write_ps=self.max_write_ps,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
IGNORED_DISK_NAMES = (
|
|
||||||
'loop',
|
|
||||||
'ram',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_bytes_per_second(old_stats: dict, new_stats: dict) -> Dict[str, int]:
|
|
||||||
"""Calculate the bytes-per-second between the oldest and newest counter."""
|
|
||||||
try:
|
|
||||||
# NICs
|
|
||||||
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_recv'], old_stats['bytes_sent']
|
|
||||||
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_recv'], new_stats['bytes_sent']
|
|
||||||
except KeyError:
|
|
||||||
# Disks
|
|
||||||
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_read'], old_stats['bytes_write']
|
|
||||||
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_read'], new_stats['bytes_write']
|
|
||||||
elapsed = int(new_now - old_now)
|
|
||||||
if elapsed == 0:
|
|
||||||
return dict(bytes_recv_ps=0, bytes_sent_ps=0, elapsed=0)
|
|
||||||
bytes_recv_ps = int((new_recv - old_recv) // elapsed)
|
|
||||||
bytes_sent_ps = int((new_sent - old_sent) // elapsed)
|
|
||||||
return dict(bytes_recv_ps=bytes_recv_ps, bytes_sent_ps=bytes_sent_ps, elapsed=elapsed)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nic_counters() -> dict:
|
|
||||||
"""
|
|
||||||
Get the instant network statistics for the provided NIC.
|
|
||||||
"""
|
|
||||||
timestamp = now().timestamp()
|
|
||||||
counters = dict()
|
|
||||||
if_stats = psutil.net_if_stats()
|
|
||||||
for name, counter in sorted(psutil.net_io_counters(pernic=True, nowrap=True).items(), key=lambda i: i[0]):
|
|
||||||
if name in IGNORED_NIC_NAMES:
|
|
||||||
continue
|
|
||||||
stats = if_stats[name]
|
|
||||||
counters[name] = {
|
|
||||||
'name': name,
|
|
||||||
'now': timestamp,
|
|
||||||
'bytes_recv': counter.bytes_recv,
|
|
||||||
'bytes_sent': counter.bytes_sent,
|
|
||||||
'speed': int(stats.speed),
|
|
||||||
}
|
|
||||||
return counters
|
|
||||||
|
|
||||||
|
|
||||||
async def get_nic_counters(shared_status):
|
|
||||||
new_nic_stats = _get_nic_counters()
|
|
||||||
old_nic_stats = shared_status['nic_bandwidth_stats']
|
|
||||||
nic_bandwidth_stats = dict()
|
|
||||||
for name, old_stats in old_nic_stats.items():
|
|
||||||
new_stats = new_nic_stats[name]
|
|
||||||
new_stats.update(_calculate_bytes_per_second(old_stats, new_stats))
|
|
||||||
nic_bandwidth_stats[name] = new_stats
|
|
||||||
return nic_bandwidth_stats
|
|
||||||
|
|
||||||
|
|
||||||
def _get_disk_counters() -> dict:
|
|
||||||
"""
|
|
||||||
Get the instant disk statistics for the all disks.
|
|
||||||
"""
|
|
||||||
timestamp = now().timestamp()
|
|
||||||
counters = dict()
|
|
||||||
|
|
||||||
for name, counter in sorted(psutil.disk_io_counters(perdisk=True).items(), key=lambda i: i[0]):
|
|
||||||
if any(name.startswith(i) for i in counters):
|
|
||||||
# Report only the first disk. Do not report its partitions. Disks are sorted to make this work!
|
|
||||||
# i.e. report "sda" but not "sda1" or "sda2".
|
|
||||||
continue
|
|
||||||
if any(name.startswith(i) for i in IGNORED_DISK_NAMES):
|
|
||||||
continue
|
|
||||||
counters[name] = {
|
|
||||||
'name': name,
|
|
||||||
'now': timestamp,
|
|
||||||
'bytes_read': counter.read_bytes,
|
|
||||||
'bytes_write': counter.write_bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
return counters
|
|
||||||
|
|
||||||
|
|
||||||
async def get_disk_counters(shared_status):
|
|
||||||
"""Get stats about bandwidth per disk."""
|
|
||||||
new_disk_stats = _get_disk_counters()
|
|
||||||
old_disk_stats = shared_status['disk_bandwidth_stats']
|
|
||||||
disk_bandwidth_stats = dict()
|
|
||||||
for name, old_stats in old_disk_stats.items():
|
|
||||||
new_stats = new_disk_stats[name]
|
|
||||||
bps = _calculate_bytes_per_second(old_stats, new_stats)
|
|
||||||
new_stats.update(dict(
|
|
||||||
elapsed=bps['elapsed'],
|
|
||||||
bytes_read_ps=bps['bytes_recv_ps'],
|
|
||||||
bytes_write_ps=bps['bytes_sent_ps'],
|
|
||||||
max_read_ps=max(bps['bytes_recv_ps'], old_stats['max_read_ps']),
|
|
||||||
max_write_ps=max(bps['bytes_sent_ps'], old_stats['max_write_ps']),
|
|
||||||
))
|
|
||||||
disk_bandwidth_stats[name] = new_stats
|
|
||||||
return disk_bandwidth_stats
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PowerStats:
|
|
||||||
under_voltage: bool = False
|
|
||||||
over_current: bool = False
|
|
||||||
|
|
||||||
def __json__(self) -> dict:
|
|
||||||
return dict(
|
|
||||||
under_voltage=self.under_voltage,
|
|
||||||
over_current=self.over_current,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_power_stats() -> PowerStats:
|
|
||||||
logger.trace(f'get_power_stats called')
|
|
||||||
|
|
||||||
try:
|
|
||||||
cmd = ('dmesg',)
|
|
||||||
result = await run_command(cmd, log_command=False)
|
|
||||||
if result.stdout and result.return_code == 0:
|
|
||||||
under_voltage = b' Undervoltage detected' in result.stdout
|
|
||||||
over_current = b' over-current change ' in result.stdout
|
|
||||||
return PowerStats(
|
|
||||||
under_voltage=under_voltage,
|
|
||||||
over_current=over_current,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
warn_iostat_once(e)
|
|
||||||
|
|
||||||
return PowerStats()
|
|
||||||
|
|
||||||
|
|
||||||
DISABLE_STATUS_WORKER = bool(PYTEST) # Do not run status worker while testing, unless explicitly needed.
|
|
||||||
|
|
||||||
|
|
||||||
# Disable status_worker in favor of status from Controller.
|
|
||||||
# @perpetual_signal(sleep=5)
|
|
||||||
async def status_worker(count: int = None, sleep_time: int = 5):
|
|
||||||
"""A background process which will gather historical data about system statistics."""
|
|
||||||
shared_status = api_app.shared_ctx.status
|
|
||||||
|
|
||||||
if PYTEST and DISABLE_STATUS_WORKER:
|
|
||||||
# Tests are running, status worker has not been enabled by `start_status_worker` fixture.
|
|
||||||
return
|
|
||||||
|
|
||||||
if count is not None and count <= 0:
|
|
||||||
logger.debug('Status worker ran out of count')
|
|
||||||
return dict(**shared_status)
|
|
||||||
|
|
||||||
load_stats = None
|
|
||||||
try:
|
|
||||||
# Update global `status` dict with stats that are gathered instantly.
|
|
||||||
cpu_stats, load_stats, drives_stats, memory_stats, processes_stats, iostat_stats, power_stats = \
|
|
||||||
await asyncio.gather(
|
|
||||||
asyncio.create_task(get_cpu_stats(), name='get_cpu_stats'),
|
|
||||||
asyncio.create_task(get_load_stats(), name='get_load_stats'),
|
|
||||||
asyncio.create_task(get_drives_stats(), name='get_drives_stats'),
|
|
||||||
asyncio.create_task(get_memory_stats(), name='get_memory_stats'),
|
|
||||||
asyncio.create_task(get_processes_stats(), name='get_processes_stats'),
|
|
||||||
asyncio.create_task(get_iostat_stats(), name='get_iostat_stats'),
|
|
||||||
asyncio.create_task(get_power_stats(), name='get_power_stats'),
|
|
||||||
)
|
|
||||||
from wrolpi.upgrade import get_current_branch
|
|
||||||
shared_status.update({
|
|
||||||
'cpu_stats': cpu_stats.__json__(),
|
|
||||||
'load_stats': load_stats.__json__(),
|
|
||||||
'drives_stats': [i.__json__() for i in drives_stats],
|
|
||||||
'processes_stats': [i.__json__() for i in processes_stats],
|
|
||||||
'memory_stats': memory_stats.__json__(),
|
|
||||||
'last_status': now().isoformat(),
|
|
||||||
'iostat_stats': iostat_stats.__json__(),
|
|
||||||
'power_stats': power_stats.__json__(),
|
|
||||||
'git_branch': get_current_branch() if not DOCKERIZED else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
if 'disk_bandwidth_stats' not in shared_status:
|
|
||||||
# Worker has just been started, fill it with some starting bandwidth data.
|
|
||||||
disk_info = _get_disk_counters()
|
|
||||||
for name in disk_info.keys():
|
|
||||||
disk_info[name].update({'max_read_ps': 500_000, 'max_write_ps': 500_000})
|
|
||||||
shared_status.update({
|
|
||||||
'nic_bandwidth_stats': _get_nic_counters(),
|
|
||||||
'disk_bandwidth_stats': disk_info,
|
|
||||||
})
|
|
||||||
# Break out and call the `finally` code below.
|
|
||||||
return
|
|
||||||
|
|
||||||
# Calculate the difference between the previous status worker's bandwidth count and now.
|
|
||||||
shared_status.update({
|
|
||||||
'nic_bandwidth_stats': await get_nic_counters(shared_status),
|
|
||||||
'disk_bandwidth_stats': await get_disk_counters(shared_status),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
status_worker_warn_once(e)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
# Always sleep so this does not become a busy loop with errors.
|
|
||||||
if load_stats and load_stats.minute_1 and load_stats.minute_1 > multiprocessing.cpu_count():
|
|
||||||
# System is stressed, slow down status updates.
|
|
||||||
await asyncio.sleep((sleep_time * float(load_stats.minute_1)) / multiprocessing.cpu_count())
|
|
||||||
|
|
||||||
if count:
|
|
||||||
# Running in testing, or __main__.
|
|
||||||
return await status_worker(count - 1, sleep_time)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from wrolpi.contexts import attach_shared_contexts
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
attach_shared_contexts(api_app)
|
|
||||||
loop.run_until_complete(status_worker(2, sleep_time=2))
|
|
||||||
print(json.dumps(dict(api_app.shared_ctx.status), indent=2))
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
from pathlib import PosixPath
|
|
||||||
from unittest import mock
|
|
||||||
from unittest.mock import call
|
|
||||||
|
|
||||||
from wrolpi import admin
|
|
||||||
from wrolpi.admin import HotspotStatus
|
|
||||||
from wrolpi.common import WROLPI_CONFIG
|
|
||||||
from wrolpi.test.common import skip_circleci, skip_macos
|
|
||||||
|
|
||||||
|
|
||||||
@skip_circleci
|
|
||||||
@skip_macos
|
|
||||||
def test_hotspot_status():
|
|
||||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
|
|
||||||
mock_subprocess.check_output.return_value = b'wlan0: unavailable'
|
|
||||||
assert admin.hotspot_status() == HotspotStatus.unavailable
|
|
||||||
mock_subprocess.check_output.return_value = b'wlan0: disconnected'
|
|
||||||
assert admin.hotspot_status() == HotspotStatus.disconnected
|
|
||||||
mock_subprocess.check_output.return_value = b'wlan0: connected'
|
|
||||||
assert admin.hotspot_status() == HotspotStatus.connected
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_hotspot_disconnected():
|
|
||||||
"""Enable hotspot with wifi on."""
|
|
||||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
|
||||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
|
||||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
|
||||||
mock_subprocess.check_output.return_value = 0
|
|
||||||
assert admin.enable_hotspot() is True
|
|
||||||
mock_subprocess.check_call.assert_called_once_with(
|
|
||||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
|
||||||
'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_hotspot_unavailable():
|
|
||||||
"""Enable hotspot with wifi off."""
|
|
||||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
|
||||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
|
||||||
mock_hotspot_status.side_effect = [HotspotStatus.unavailable, HotspotStatus.disconnected]
|
|
||||||
mock_subprocess.check_output.return_value = 0
|
|
||||||
assert admin.enable_hotspot() is True
|
|
||||||
mock_subprocess.check_call.assert_has_calls(
|
|
||||||
[
|
|
||||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
|
|
||||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
|
|
||||||
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_enable_hotspot_connected():
|
|
||||||
"""Enable hotspot with the hotspot alaready enabled."""
|
|
||||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
|
||||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
|
||||||
mock_hotspot_status.side_effect = [
|
|
||||||
HotspotStatus.connected,
|
|
||||||
HotspotStatus.unavailable,
|
|
||||||
HotspotStatus.disconnected,
|
|
||||||
]
|
|
||||||
mock_subprocess.check_output.return_value = 0
|
|
||||||
assert admin.enable_hotspot() is True
|
|
||||||
mock_subprocess.check_call.assert_has_calls(
|
|
||||||
[
|
|
||||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off')),
|
|
||||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
|
|
||||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
|
|
||||||
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_hotspot(test_wrolpi_config, async_client):
|
|
||||||
"""The hotspot can be configured."""
|
|
||||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
|
||||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
|
||||||
mock_subprocess.check_output.return_value = 0
|
|
||||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
|
||||||
assert admin.enable_hotspot() is True
|
|
||||||
mock_subprocess.check_call.assert_called_once_with(
|
|
||||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
|
||||||
'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))
|
|
||||||
|
|
||||||
WROLPI_CONFIG.hotspot_ssid = 'New SSID'
|
|
||||||
WROLPI_CONFIG.hotspot_password = '$uper Secure *#'
|
|
||||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
|
||||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
|
||||||
mock_subprocess.check_output.return_value = 0
|
|
||||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
|
||||||
assert admin.enable_hotspot() is True
|
|
||||||
mock_subprocess.check_call.assert_called_once_with(
|
|
||||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
|
||||||
'ssid', 'New SSID', 'password', '$uper Secure *#'))
|
|
||||||
|
|
||||||
|
|
||||||
def test_throttle_on():
|
|
||||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.CPUFREQ_SET_BIN', PosixPath('/usr/bin/cpufreq-set')):
|
|
||||||
assert admin.throttle_cpu_on()
|
|
||||||
mock_subprocess.check_call.assert_called_once_with(
|
|
||||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/cpufreq-set'), '-g', 'powersave')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_throttle_off():
|
|
||||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.CPUFREQ_SET_BIN', PosixPath('/usr/bin/cpufreq-set')):
|
|
||||||
assert admin.throttle_cpu_off()
|
|
||||||
mock_subprocess.check_call.assert_called_once_with(
|
|
||||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/cpufreq-set'), '-g', 'ondemand')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@skip_circleci
|
|
||||||
@skip_macos
|
|
||||||
def test_hotspot_device(async_client):
|
|
||||||
"""Changing the hotspot device changes what device is turned on."""
|
|
||||||
from wrolpi.common import WROLPI_CONFIG
|
|
||||||
WROLPI_CONFIG.hotspot_device = 'wlp2s0'
|
|
||||||
|
|
||||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
|
|
||||||
mock_subprocess.check_output.return_value = b'wlp2s0: connected'
|
|
||||||
assert admin.hotspot_status() == HotspotStatus.connected
|
|
||||||
|
|
@ -6,12 +6,11 @@ import pytest
|
||||||
from mock import mock
|
from mock import mock
|
||||||
from sanic import Request
|
from sanic import Request
|
||||||
|
|
||||||
from wrolpi.admin import HotspotStatus
|
|
||||||
from wrolpi.api_utils import json_error_handler
|
from wrolpi.api_utils import json_error_handler
|
||||||
from wrolpi.common import get_wrolpi_config
|
from wrolpi.common import get_wrolpi_config
|
||||||
from wrolpi.downloader import Download, get_download_manager_config
|
from wrolpi.downloader import Download, get_download_manager_config
|
||||||
from wrolpi.errors import ValidationError, SearchEmpty
|
from wrolpi.errors import ValidationError, SearchEmpty
|
||||||
from wrolpi.test.common import skip_circleci, assert_dict_contains, skip_macos
|
from wrolpi.test.common import skip_circleci, assert_dict_contains
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -52,11 +51,8 @@ async def test_valid_regex(test_session, async_client):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_settings(test_session, async_client):
|
async def test_get_settings(test_session, async_client):
|
||||||
get_wrolpi_config().ignored_directories = list()
|
get_wrolpi_config().ignored_directories = list()
|
||||||
|
request, response = await async_client.get('/api/settings')
|
||||||
with mock.patch('wrolpi.admin.CPUFREQ_INFO_BIN', '/usr/bin/cpufreq-info'), \
|
assert response.status_code == HTTPStatus.OK
|
||||||
mock.patch('wrolpi.admin.NMCLI_BIN', '/usr/bin/nmcli'):
|
|
||||||
request, response = await async_client.get('/api/settings')
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -208,83 +204,25 @@ async def test_echo(async_client):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hotspot_settings(test_session, async_client, test_wrolpi_config):
|
async def test_hotspot_settings(test_session, async_client, test_wrolpi_config):
|
||||||
"""
|
"""
|
||||||
The User can toggle the Hotspot via /settings. The Hotspot can be automatically started on startup.
|
The User can configure hotspot settings (ssid, password, device) via /settings.
|
||||||
|
Hotspot control (on/off) is now done through Controller endpoints.
|
||||||
"""
|
"""
|
||||||
config = get_wrolpi_config()
|
config = get_wrolpi_config()
|
||||||
assert config.hotspot_on_startup is True
|
assert config.hotspot_on_startup is True
|
||||||
|
|
||||||
with mock.patch('wrolpi.root_api.admin') as mock_admin:
|
# Hotspot password can be changed.
|
||||||
# Turning on the hotspot succeeds.
|
content = {'hotspot_password': 'new password', 'hotspot_ssid': 'new ssid'}
|
||||||
mock_admin.enable_hotspot.return_value = True
|
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': True}))
|
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
assert config.hotspot_password == 'new password'
|
||||||
mock_admin.enable_hotspot.assert_called_once()
|
assert config.hotspot_ssid == 'new ssid'
|
||||||
mock_admin.reset_mock()
|
|
||||||
|
|
||||||
# Turning on the hotspot fails.
|
# Hotspot password must be at least 8 characters.
|
||||||
mock_admin.enable_hotspot.return_value = False
|
content = {'hotspot_password': '1234567', 'hotspot_ssid': 'new ssid'}
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': True}))
|
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, response.json
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||||
assert response.json['code'] == 'HOTSPOT_ERROR'
|
assert response.json['code'] == 'INVALID_CONFIG'
|
||||||
mock_admin.enable_hotspot.assert_called_once()
|
assert '8 characters' in response.json['error']
|
||||||
|
|
||||||
# Turning off the hotspot succeeds.
|
|
||||||
mock_admin.disable_hotspot.return_value = True
|
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': False}))
|
|
||||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
|
||||||
mock_admin.disable_hotspot.assert_called_once()
|
|
||||||
mock_admin.reset_mock()
|
|
||||||
|
|
||||||
# Turning off the hotspot succeeds.
|
|
||||||
mock_admin.disable_hotspot.return_value = False
|
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': False}))
|
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, response.json
|
|
||||||
assert response.json['code'] == 'HOTSPOT_ERROR'
|
|
||||||
mock_admin.disable_hotspot.assert_called_once()
|
|
||||||
|
|
||||||
mock_admin.enable_hotspot.reset_mock()
|
|
||||||
mock_admin.enable_hotspot.return_value = True
|
|
||||||
mock_admin.hotspot_status.return_value = HotspotStatus.connected
|
|
||||||
|
|
||||||
# Hotspot password can be changed.
|
|
||||||
mock_admin.disable_hotspot.return_value = True
|
|
||||||
content = {'hotspot_password': 'new password', 'hotspot_ssid': 'new ssid'}
|
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
|
||||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
|
||||||
assert config.hotspot_password == 'new password'
|
|
||||||
assert config.hotspot_ssid == 'new ssid'
|
|
||||||
# Changing the password restarts the hotspot.
|
|
||||||
mock_admin.enable_hotspot.assert_called_once()
|
|
||||||
|
|
||||||
# Hotspot password must be at least 8 characters.
|
|
||||||
mock_admin.disable_hotspot.return_value = True
|
|
||||||
content = {'hotspot_password': '1234567', 'hotspot_ssid': 'new ssid'}
|
|
||||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
|
||||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
||||||
assert response.json == {'code': 'HOTSPOT_PASSWORD_TOO_SHORT',
|
|
||||||
'error': 'Bad Request',
|
|
||||||
'message': 'Hotspot password must be at least 8 characters'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@skip_macos
|
|
||||||
@skip_circleci
|
|
||||||
async def test_throttle_toggle(test_session, async_client, test_wrolpi_config):
|
|
||||||
get_wrolpi_config().ignored_directories = list()
|
|
||||||
|
|
||||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
|
||||||
mock.patch('wrolpi.admin.CPUFREQ_INFO_BIN', "this value isn't even used"):
|
|
||||||
mock_subprocess.check_output.side_effect = [
|
|
||||||
b'wlan0: unavailable',
|
|
||||||
b'The governor "ondemand" may decide ',
|
|
||||||
]
|
|
||||||
request, response = await async_client.get('/api/settings')
|
|
||||||
|
|
||||||
# Throttle is off by default.
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
|
||||||
assert response.json['throttle_on_startup'] is False
|
|
||||||
assert response.json['throttle_status'] == 'ondemand'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -413,8 +351,6 @@ async def test_get_status(async_client, test_session):
|
||||||
assert isinstance(response.json.get('load_stats'), dict), 'load_stats should be a dict'
|
assert isinstance(response.json.get('load_stats'), dict), 'load_stats should be a dict'
|
||||||
assert isinstance(response.json.get('drives_stats'), list), 'drive_stats should be a dict'
|
assert isinstance(response.json.get('drives_stats'), list), 'drive_stats should be a dict'
|
||||||
assert isinstance(response.json.get('downloads'), dict), 'downloads should be a dict'
|
assert isinstance(response.json.get('downloads'), dict), 'downloads should be a dict'
|
||||||
assert isinstance(response.json.get('hotspot_status'), str), 'hotspot_status should be a str'
|
|
||||||
assert isinstance(response.json.get('throttle_status'), str), 'throttle_status should be a str'
|
|
||||||
assert isinstance(response.json.get('version'), str), 'version should be a str'
|
assert isinstance(response.json.get('version'), str), 'version should be a str'
|
||||||
assert isinstance(response.json.get('memory_stats'), dict), 'memory_stats should be a dict'
|
assert isinstance(response.json.get('memory_stats'), dict), 'memory_stats should be a dict'
|
||||||
assert isinstance(response.json.get('flags'), dict), 'flags should be a dict'
|
assert isinstance(response.json.get('flags'), dict), 'flags should be a dict'
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
from decimal import Decimal
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrolpi import status
|
|
||||||
from wrolpi.test.common import skip_circleci, skip_macos
|
|
||||||
from wrolpi.vars import IS_MACOS
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_load(async_client):
|
|
||||||
"""Test reading load information from /proc/loadavg."""
|
|
||||||
load = await status.get_load_stats()
|
|
||||||
assert isinstance(load, status.SystemLoad)
|
|
||||||
|
|
||||||
assert isinstance(load.minute_1, Decimal) and load.minute_1 >= 0
|
|
||||||
assert isinstance(load.minute_5, Decimal) and load.minute_5 >= 0
|
|
||||||
assert isinstance(load.minute_15, Decimal) and load.minute_15 >= 0
|
|
||||||
|
|
||||||
|
|
||||||
@skip_circleci
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_cpu_stats(async_client):
|
|
||||||
"""Minimum CPU info testing because this will fail in docker, etc."""
|
|
||||||
info = await status.get_cpu_stats()
|
|
||||||
assert isinstance(info, status.CPUInfo)
|
|
||||||
assert isinstance(info.percent, int)
|
|
||||||
if not IS_MACOS:
|
|
||||||
assert isinstance(info.temperature, int)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@skip_macos
|
|
||||||
async def test_iostat_stats(async_client):
|
|
||||||
info = await status.get_iostat_stats()
|
|
||||||
assert isinstance(info, status.IostatInfo)
|
|
||||||
assert isinstance(info.percent_idle, float)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_power_stats(async_client):
|
|
||||||
info = await status.get_power_stats()
|
|
||||||
assert isinstance(info, status.PowerStats)
|
|
||||||
assert info.under_voltage is False
|
|
||||||
assert info.over_current is False
|
|
||||||
|
|
||||||
|
|
||||||
@skip_circleci
|
|
||||||
@skip_macos
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_drives_stats(async_client):
|
|
||||||
"""Minimum drives info testing because this will fail in docker, etc."""
|
|
||||||
info = await status.get_drives_stats()
|
|
||||||
assert isinstance(info, list)
|
|
||||||
assert len(info) >= 1
|
|
||||||
assert isinstance(info[0], status.DriveInfo)
|
|
||||||
|
|
||||||
with mock.patch('wrolpi.status.get_drives_info_psutil') as mock_get_drives_stats_psutil:
|
|
||||||
# Fallback to subprocess when psutil is not available.
|
|
||||||
mock_get_drives_stats_psutil.side_effect = Exception('testing no psutil')
|
|
||||||
info = await status.get_drives_stats()
|
|
||||||
assert isinstance(info, list)
|
|
||||||
assert len(info) >= 1
|
|
||||||
assert isinstance(info[0], status.DriveInfo)
|
|
||||||
|
|
||||||
|
|
||||||
@skip_circleci
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_status_worker(async_client, start_status_worker):
|
|
||||||
"""Status worker calls itself perpetually, but can be limited during testing."""
|
|
||||||
from wrolpi.api_utils import api_app
|
|
||||||
assert async_client.sanic_app.shared_ctx.status['cpu_stats'] == dict()
|
|
||||||
|
|
||||||
# Status worker fills out stats as it goes along.
|
|
||||||
await status.status_worker(count=2)
|
|
||||||
assert api_app.shared_ctx.status['nic_bandwidth_stats']
|
|
||||||
assert 'bytes_recv' in list(api_app.shared_ctx.status['nic_bandwidth_stats'].values())[0]
|
|
||||||
assert api_app.shared_ctx.status['disk_bandwidth_stats']
|
|
||||||
assert 'bytes_read' in list(api_app.shared_ctx.status['disk_bandwidth_stats'].values())[0]
|
|
||||||
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('cpu_stats'), dict)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('load_stats'), dict)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('drives_stats'), list)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('processes_stats'), list)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('memory_stats'), dict)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('iostat_stats'), dict)
|
|
||||||
assert isinstance(api_app.shared_ctx.status.get('power_stats'), dict)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue