feat: add system diagnostics endpoint.
This commit is contained in:
parent
376e439115
commit
434d3e981d
9 changed files with 1905 additions and 1 deletions
49
API.md
49
API.md
|
|
@ -99,6 +99,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [POST /api/notifications/test](#post-apinotificationstest)
|
- [POST /api/notifications/test](#post-apinotificationstest)
|
||||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||||
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
||||||
|
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
|
||||||
- [GET /api/system/limits](#get-apisystemlimits)
|
- [GET /api/system/limits](#get-apisystemlimits)
|
||||||
- [POST /api/system/terminal](#post-apisystemterminal)
|
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||||
- [GET /api/system/terminal](#get-apisystemterminal)
|
- [GET /api/system/terminal](#get-apisystemterminal)
|
||||||
|
|
@ -2632,6 +2633,54 @@ or an error:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### GET /api/system/diagnostics
|
||||||
|
**Purpose**: View system information.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"generated_at": 1713000000,
|
||||||
|
"summary": {
|
||||||
|
"total": 10,
|
||||||
|
"pass": 5,
|
||||||
|
"fail": 2,
|
||||||
|
"warn": 1,
|
||||||
|
"skip": 2,
|
||||||
|
"required_failed": 2
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"app_version": "1.0.0",
|
||||||
|
"app_branch": "main",
|
||||||
|
"app_commit_sha": "abcdef12",
|
||||||
|
"app_build_date": "20260526",
|
||||||
|
"started": 1712999900,
|
||||||
|
"uptime_seconds": 100,
|
||||||
|
"platform": "linux",
|
||||||
|
"platform_release": "6.8.0",
|
||||||
|
"platform_machine": "x86_64",
|
||||||
|
"python_version": "3.13.1",
|
||||||
|
"python_minimum": "3.13",
|
||||||
|
"is_native": false,
|
||||||
|
"console_enabled": false
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"python": {
|
||||||
|
"current": "3.13.1",
|
||||||
|
"required": "3.13",
|
||||||
|
"supported": true,
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- Unexpected collection errors are returned as an `error`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### GET /api/system/limits
|
### GET /api/system/limits
|
||||||
**Purpose**: Get the system limits.
|
**Purpose**: Get the system limits.
|
||||||
|
|
||||||
|
|
|
||||||
760
app/library/diagnostics.py
Normal file
760
app/library/diagnostics.py
Normal file
|
|
@ -0,0 +1,760 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.metadata
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.httpx_client import resolve_curl_transport
|
||||||
|
|
||||||
|
CheckStatus = Literal["pass", "fail", "warn", "skip"]
|
||||||
|
ReportStatus = Literal["ok", "degraded", "error"]
|
||||||
|
|
||||||
|
MIN_PYTHON: tuple[int, int] = (3, 13)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class DiagnosticCheck:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
group: str
|
||||||
|
required: bool
|
||||||
|
status: CheckStatus
|
||||||
|
description: str
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"group": self.group,
|
||||||
|
"required": self.required,
|
||||||
|
"status": self.status,
|
||||||
|
"description": self.description,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class CheckMeta:
|
||||||
|
group: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
BINARY_META: dict[str, CheckMeta] = {
|
||||||
|
"ffmpeg": CheckMeta(
|
||||||
|
group="core", description="Used for thumbnails and streaming and various media processing tasks."
|
||||||
|
),
|
||||||
|
"ffprobe": CheckMeta(group="core", description="Used for media inspection and metadata extraction."),
|
||||||
|
"deno": CheckMeta(group="youtube", description="Used for yt-dlp YouTube support."),
|
||||||
|
"yt_dlp_cli": CheckMeta(group="core", description="Used by the built-in terminal."),
|
||||||
|
"aria2c": CheckMeta(group="advanced", description="External downloader for yt-dlp."),
|
||||||
|
"mkvpropedit": CheckMeta(group="advanced", description="Edit Matroska container properties without remux."),
|
||||||
|
"mkvextract": CheckMeta(group="advanced", description="Extract tracks from Matroska containers."),
|
||||||
|
"mp4box": CheckMeta(group="advanced", description="MP4 container manipulation tool."),
|
||||||
|
}
|
||||||
|
|
||||||
|
DIRECTORY_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"config_path": "Directory for config files and app data.",
|
||||||
|
"download_path": "Directory where downloads are saved.",
|
||||||
|
"temp_path": "Directory used for temporary files.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_line(*parts: str) -> str:
|
||||||
|
for part in parts:
|
||||||
|
for line in part.splitlines():
|
||||||
|
if line := line.strip():
|
||||||
|
return line
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _bin_version(value: str) -> str:
|
||||||
|
if not (line := value.strip()):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if match := re.match(r"^deno\s+([0-9][^\s]*)", line, flags=re.IGNORECASE):
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
if match := re.match(r"^mkv\w+\s+v([0-9][^\s]*)", line, flags=re.IGNORECASE):
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
if match := re.search(r"\bversion\s+(.+?)(?:\s+Copyright\b|$)", line, flags=re.IGNORECASE):
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_browser_url(value: str) -> tuple[str, str]:
|
||||||
|
if value.startswith(("selenium+http://", "selenium+https://")):
|
||||||
|
return "selenium", value.removeprefix("selenium+")
|
||||||
|
|
||||||
|
if value.startswith(("playwright+ws://", "playwright+wss://")):
|
||||||
|
return "playwright", value.removeprefix("playwright+")
|
||||||
|
|
||||||
|
if value.startswith("playwright+cdp://"):
|
||||||
|
return "playwright", f"http://{value.removeprefix('playwright+cdp://')}"
|
||||||
|
|
||||||
|
if value.startswith("playwright+cdp+"):
|
||||||
|
return "playwright", value.removeprefix("playwright+cdp+")
|
||||||
|
|
||||||
|
return "unknown", value
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_url(url: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
except Exception:
|
||||||
|
return "***"
|
||||||
|
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return "***"
|
||||||
|
|
||||||
|
netloc = parsed.netloc
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
netloc = f"***:***@{host}" if host else "***:***"
|
||||||
|
|
||||||
|
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
|
||||||
|
query = "***" if parsed.query else ""
|
||||||
|
fragment = "***" if parsed.fragment else ""
|
||||||
|
return urllib.parse.urlunsplit((parsed.scheme, netloc, path, query, fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def _package_version(name: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version(name)
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _package_check(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
group: str,
|
||||||
|
required: bool,
|
||||||
|
description: str,
|
||||||
|
present_message: str = "Installed.",
|
||||||
|
missing_message: str = "Missing.",
|
||||||
|
missing_status: CheckStatus = "skip",
|
||||||
|
check_id: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> DiagnosticCheck:
|
||||||
|
version = _package_version(name)
|
||||||
|
installed = version is not None
|
||||||
|
base_details: dict[str, Any] = {"package": name, "version": version}
|
||||||
|
if details:
|
||||||
|
base_details.update(details)
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id=check_id or name.replace("-", "_"),
|
||||||
|
label=label,
|
||||||
|
group=group,
|
||||||
|
required=required,
|
||||||
|
status="pass" if installed else ("fail" if required else missing_status),
|
||||||
|
description=description,
|
||||||
|
message=present_message if installed else missing_message,
|
||||||
|
details=base_details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ytdlp_package() -> DiagnosticCheck:
|
||||||
|
version = Config._ytdlp_version()
|
||||||
|
installed = version != "0.0.0"
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="yt_dlp_package",
|
||||||
|
label="yt-dlp package",
|
||||||
|
group="core",
|
||||||
|
required=True,
|
||||||
|
status="pass" if installed else "fail",
|
||||||
|
description="Main downloader library used by the app.",
|
||||||
|
message="Installed." if installed else "Missing.",
|
||||||
|
details={"version": version},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_apprise_package(config: Config) -> DiagnosticCheck:
|
||||||
|
configured = Path(config.apprise_config).exists()
|
||||||
|
return _package_check(
|
||||||
|
"apprise",
|
||||||
|
label="Apprise",
|
||||||
|
group="notifications",
|
||||||
|
required=False,
|
||||||
|
description="Sends notification targets.",
|
||||||
|
present_message="Installed.",
|
||||||
|
missing_message="Missing." if configured else "Not installed.",
|
||||||
|
missing_status="warn" if configured else "skip",
|
||||||
|
check_id="apprise",
|
||||||
|
details={"config": str(config.apprise_config) if configured else None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_curl_transport() -> DiagnosticCheck:
|
||||||
|
available = resolve_curl_transport(use_curl=True)
|
||||||
|
version = _package_version("httpx-curl-cffi")
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="curl_transport",
|
||||||
|
label="curl-cffi transport",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="pass" if available else "skip",
|
||||||
|
description="Transport for impersonation support.",
|
||||||
|
message="Installed." if available else "Not installed.",
|
||||||
|
details={"package": "httpx-curl-cffi", "version": version},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_pot_provider_package() -> DiagnosticCheck:
|
||||||
|
return _package_check(
|
||||||
|
"bgutil-ytdlp-pot-provider",
|
||||||
|
check_id="pot_provider_plugin",
|
||||||
|
label="POT provider plugin",
|
||||||
|
group="youtube",
|
||||||
|
required=False,
|
||||||
|
description="Optional plugin for external POT token providers for youtube.",
|
||||||
|
present_message="Installed.",
|
||||||
|
missing_message="Not installed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_configured_pip_packages(config: Config) -> list[DiagnosticCheck]:
|
||||||
|
checks: list[DiagnosticCheck] = []
|
||||||
|
|
||||||
|
for raw_pkg in config.pip_packages.split(" "):
|
||||||
|
pkg = raw_pkg.strip()
|
||||||
|
if not pkg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = pkg.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0].strip()
|
||||||
|
installed = False
|
||||||
|
version = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version(name)
|
||||||
|
installed = True
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
installed = False
|
||||||
|
|
||||||
|
checks.append(
|
||||||
|
DiagnosticCheck(
|
||||||
|
id=f"pip_{name.replace('-', '_')}",
|
||||||
|
label=name,
|
||||||
|
group="custom",
|
||||||
|
required=False,
|
||||||
|
status="pass" if installed else "warn",
|
||||||
|
description="Configured extra pip package.",
|
||||||
|
message="Installed." if installed else "Missing.",
|
||||||
|
details={"package": name, "requested": pkg, "version": version},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _check_browser_endpoint() -> DiagnosticCheck:
|
||||||
|
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
|
||||||
|
if not raw_url:
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_endpoint",
|
||||||
|
label="Remote browser",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="skip",
|
||||||
|
description="Endpoint used by the browser extractor.",
|
||||||
|
message="Not configured.",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
engine, parsed_url = _parse_browser_url(raw_url)
|
||||||
|
if engine == "unknown":
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_endpoint",
|
||||||
|
label="Remote browser",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="warn",
|
||||||
|
description="Endpoint used by the browser extractor.",
|
||||||
|
message="Invalid URL.",
|
||||||
|
details={"endpoint": _safe_url(raw_url)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_endpoint",
|
||||||
|
label="Remote browser",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="pass",
|
||||||
|
description="Endpoint used by the browser extractor.",
|
||||||
|
message="Configured.",
|
||||||
|
details={
|
||||||
|
"engine": engine,
|
||||||
|
"endpoint": _safe_url(parsed_url),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_browser_client() -> DiagnosticCheck:
|
||||||
|
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
|
||||||
|
if not raw_url:
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_client",
|
||||||
|
label="Browser client",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="skip",
|
||||||
|
description="Client package required for the configured browser backend.",
|
||||||
|
message="Not configured.",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
engine, _ = _parse_browser_url(raw_url)
|
||||||
|
if engine == "unknown":
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_client",
|
||||||
|
label="Browser client",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="skip",
|
||||||
|
description="Client package required for the configured browser backend.",
|
||||||
|
message="Invalid URL.",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
package_name = "selenium" if engine == "selenium" else "playwright"
|
||||||
|
version = _package_version(package_name)
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="browser_client",
|
||||||
|
label="Browser client",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="pass" if version else "warn",
|
||||||
|
description="Client package required for the configured browser backend.",
|
||||||
|
message="Installed." if version else "Missing.",
|
||||||
|
details={"package": package_name, "version": version},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_flaresolverr(config: Config) -> DiagnosticCheck:
|
||||||
|
endpoint = (config.flaresolverr_url or "").strip()
|
||||||
|
if not endpoint:
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="flaresolverr",
|
||||||
|
label="FlareSolverr",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="skip",
|
||||||
|
description="Optional Cloudflare challenge bypass service.",
|
||||||
|
message="Not configured.",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlsplit(endpoint)
|
||||||
|
valid = parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="flaresolverr",
|
||||||
|
label="FlareSolverr",
|
||||||
|
group="advanced",
|
||||||
|
required=False,
|
||||||
|
status="pass" if valid else "warn",
|
||||||
|
description="Optional Cloudflare challenge bypass service.",
|
||||||
|
message="Configured." if valid else "Invalid URL.",
|
||||||
|
details={"endpoint": _safe_url(endpoint)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_directory(path: Path) -> tuple[CheckStatus, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return "fail", "Directory does not exist."
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return "fail", "Path is not a directory."
|
||||||
|
|
||||||
|
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
|
||||||
|
return "fail", "Not readable or writable."
|
||||||
|
|
||||||
|
return "pass", "Writable."
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_directory(path: Path, *, check_id: str, label: str) -> DiagnosticCheck:
|
||||||
|
status, message = await asyncio.to_thread(_probe_directory, path)
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id=check_id,
|
||||||
|
label=label,
|
||||||
|
group="core",
|
||||||
|
required=True,
|
||||||
|
status=status,
|
||||||
|
description=DIRECTORY_DESCRIPTIONS.get(check_id, "Directory check."),
|
||||||
|
message=message,
|
||||||
|
details={"path": str(path)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_db_file(path: Path) -> tuple[CheckStatus, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return "fail", "Missing."
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
return "fail", "Invalid path."
|
||||||
|
|
||||||
|
if not os.access(path, os.R_OK):
|
||||||
|
return "fail", "Not readable."
|
||||||
|
|
||||||
|
if not os.access(path, os.W_OK):
|
||||||
|
return "fail", "Not writable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
uri = f"file:{urllib.parse.quote(str(path))}?mode=ro"
|
||||||
|
with sqlite3.connect(uri, timeout=3, uri=True) as conn:
|
||||||
|
conn.execute("SELECT name FROM sqlite_master LIMIT 1")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return "fail", f"SQLite error. {exc!s}"
|
||||||
|
|
||||||
|
return "pass", "Ready."
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_db_file(path: Path) -> DiagnosticCheck:
|
||||||
|
status, message = await asyncio.to_thread(_probe_db_file, path)
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id="database",
|
||||||
|
label="SQLite database",
|
||||||
|
group="core",
|
||||||
|
required=True,
|
||||||
|
status=status,
|
||||||
|
description="Local database used for app state and history.",
|
||||||
|
message=message,
|
||||||
|
details={"path": str(path)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_command(command: str, *args: str, wait_seconds: float = 5.0) -> tuple[int, str, str]:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
command,
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=wait_seconds)
|
||||||
|
except TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.communicate()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_meta(check_id: str) -> CheckMeta:
|
||||||
|
return BINARY_META.get(check_id, CheckMeta(group="core", description="Command line tool."))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_binary_check(
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
check_id: str,
|
||||||
|
label: str,
|
||||||
|
required: bool,
|
||||||
|
status: CheckStatus,
|
||||||
|
message: str,
|
||||||
|
path: str | None = None,
|
||||||
|
version: str | None = None,
|
||||||
|
) -> DiagnosticCheck:
|
||||||
|
meta = _binary_meta(check_id)
|
||||||
|
details: dict[str, Any] = {"command": command}
|
||||||
|
|
||||||
|
if path:
|
||||||
|
details["path"] = path
|
||||||
|
if version:
|
||||||
|
details["version"] = version
|
||||||
|
|
||||||
|
return DiagnosticCheck(
|
||||||
|
id=check_id,
|
||||||
|
label=label,
|
||||||
|
group=meta.group,
|
||||||
|
required=required,
|
||||||
|
status=status,
|
||||||
|
description=meta.description,
|
||||||
|
message=message,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_binary(*names: str) -> tuple[str, str | None]:
|
||||||
|
for name in names:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return name, path
|
||||||
|
return names[0], None
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_binary(
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
check_id: str,
|
||||||
|
label: str,
|
||||||
|
required: bool,
|
||||||
|
args: tuple[str, ...] = ("--version",),
|
||||||
|
enabled: bool = True,
|
||||||
|
disabled_message: str = "Check is not applicable.",
|
||||||
|
missing_status: CheckStatus | None = None,
|
||||||
|
aliases: tuple[str, ...] = (),
|
||||||
|
) -> DiagnosticCheck:
|
||||||
|
if not enabled:
|
||||||
|
return _make_binary_check(
|
||||||
|
command,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status="skip",
|
||||||
|
message=disabled_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_name, command_path = _resolve_binary(command, *aliases)
|
||||||
|
not_found_status: CheckStatus = missing_status or ("fail" if required else "warn")
|
||||||
|
|
||||||
|
if not command_path:
|
||||||
|
return _make_binary_check(
|
||||||
|
resolved_name,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status=not_found_status,
|
||||||
|
message="Missing." if not_found_status == "fail" else "Not installed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return_code, stdout, stderr = await _run_command(resolved_name, *args)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return _make_binary_check(
|
||||||
|
resolved_name,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status=not_found_status,
|
||||||
|
message="Missing." if not_found_status == "fail" else "Not installed.",
|
||||||
|
path=command_path,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
return _make_binary_check(
|
||||||
|
resolved_name,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status="fail" if required else "warn",
|
||||||
|
message="Timed out.",
|
||||||
|
path=command_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return _make_binary_check(
|
||||||
|
resolved_name,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status="fail" if required else "warn",
|
||||||
|
message=f"Error. {exc!s}",
|
||||||
|
path=command_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
version: str = _bin_version(_first_line(stdout, stderr))
|
||||||
|
status: CheckStatus = "pass" if return_code == 0 else ("fail" if required else "warn")
|
||||||
|
message: str = "Available." if return_code == 0 else f"Exit code {return_code}."
|
||||||
|
|
||||||
|
return _make_binary_check(
|
||||||
|
resolved_name,
|
||||||
|
check_id=check_id,
|
||||||
|
label=label,
|
||||||
|
required=required,
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
path=command_path,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runtime(config: Config) -> dict[str, Any]:
|
||||||
|
started = int(config.started) if config.started else 0
|
||||||
|
uptime_seconds = max(int(time.time()) - started, 0) if started else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_version": config.app_version,
|
||||||
|
"app_branch": config.app_branch,
|
||||||
|
"app_commit_sha": config.app_commit_sha,
|
||||||
|
"app_build_date": config.app_build_date,
|
||||||
|
"started": started,
|
||||||
|
"uptime_seconds": uptime_seconds,
|
||||||
|
"platform": platform.system().lower(),
|
||||||
|
"platform_release": platform.release(),
|
||||||
|
"platform_machine": platform.machine(),
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"python_minimum": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
|
||||||
|
"is_native": config.is_native,
|
||||||
|
"console_enabled": config.console_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _requirements() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"python": {
|
||||||
|
"current": platform.python_version(),
|
||||||
|
"required": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
|
||||||
|
"supported": sys.version_info[:2] >= MIN_PYTHON,
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize(checks: list[DiagnosticCheck]) -> tuple[ReportStatus, dict[str, int]]:
|
||||||
|
summary = {"total": len(checks), "pass": 0, "fail": 0, "warn": 0, "skip": 0, "required_failed": 0}
|
||||||
|
|
||||||
|
for check in checks:
|
||||||
|
summary[check.status] += 1
|
||||||
|
if check.required and check.status == "fail":
|
||||||
|
summary["required_failed"] += 1
|
||||||
|
|
||||||
|
if summary["required_failed"] > 0:
|
||||||
|
status: ReportStatus = "error"
|
||||||
|
elif summary["warn"] > 0 or any(check.status == "fail" for check in checks):
|
||||||
|
status = "degraded"
|
||||||
|
else:
|
||||||
|
status = "ok"
|
||||||
|
|
||||||
|
return status, summary
|
||||||
|
|
||||||
|
|
||||||
|
def _make_report(config: Config, checks: list[DiagnosticCheck]) -> dict[str, Any]:
|
||||||
|
status, summary = _summarize(checks)
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"generated_at": int(time.time()),
|
||||||
|
"summary": summary,
|
||||||
|
"runtime": _make_runtime(config),
|
||||||
|
"requirements": _requirements(),
|
||||||
|
"checks": [check.to_dict() for check in checks],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics_error_report(config: Config) -> dict[str, Any]:
|
||||||
|
return _make_report(
|
||||||
|
config,
|
||||||
|
[
|
||||||
|
DiagnosticCheck(
|
||||||
|
id="diagnostics",
|
||||||
|
label="Diagnostics",
|
||||||
|
group="core",
|
||||||
|
required=True,
|
||||||
|
status="fail",
|
||||||
|
description="Diagnostics collector.",
|
||||||
|
message="Diagnostics collection failed.",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_diagnostics(config: Config) -> dict[str, Any]:
|
||||||
|
checks: list[DiagnosticCheck] = [
|
||||||
|
_check_ytdlp_package(),
|
||||||
|
_check_apprise_package(config),
|
||||||
|
_check_curl_transport(),
|
||||||
|
_check_pot_provider_package(),
|
||||||
|
_check_browser_endpoint(),
|
||||||
|
_check_browser_client(),
|
||||||
|
_check_flaresolverr(config),
|
||||||
|
]
|
||||||
|
checks.extend(_check_configured_pip_packages(config))
|
||||||
|
|
||||||
|
storage_checks, binary_checks = await asyncio.gather(
|
||||||
|
asyncio.gather(
|
||||||
|
_check_directory(Path(config.config_path), check_id="config_path", label="Config directory"),
|
||||||
|
_check_directory(Path(config.download_path), check_id="download_path", label="Download directory"),
|
||||||
|
_check_directory(Path(config.temp_path), check_id="temp_path", label="Temp directory"),
|
||||||
|
_check_db_file(Path(config.db_file)),
|
||||||
|
),
|
||||||
|
asyncio.gather(
|
||||||
|
_check_binary(
|
||||||
|
"ffmpeg",
|
||||||
|
check_id="ffmpeg",
|
||||||
|
label="ffmpeg",
|
||||||
|
required=True,
|
||||||
|
args=("-version",),
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"ffprobe",
|
||||||
|
check_id="ffprobe",
|
||||||
|
label="ffprobe",
|
||||||
|
required=True,
|
||||||
|
args=("-version",),
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"deno",
|
||||||
|
check_id="deno",
|
||||||
|
label="deno",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"yt-dlp",
|
||||||
|
check_id="yt_dlp_cli",
|
||||||
|
label="yt-dlp CLI",
|
||||||
|
required=True,
|
||||||
|
enabled=config.console_enabled,
|
||||||
|
disabled_message="Disabled.",
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"aria2c",
|
||||||
|
check_id="aria2c",
|
||||||
|
label="aria2c",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"mkvpropedit",
|
||||||
|
check_id="mkvpropedit",
|
||||||
|
label="mkvpropedit",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"mkvextract",
|
||||||
|
check_id="mkvextract",
|
||||||
|
label="mkvextract",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
),
|
||||||
|
_check_binary(
|
||||||
|
"MP4Box",
|
||||||
|
check_id="mp4box",
|
||||||
|
label="MP4Box",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
args=("-version",),
|
||||||
|
aliases=("mp4box",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
checks.extend(storage_checks)
|
||||||
|
checks.extend(binary_checks)
|
||||||
|
|
||||||
|
return _make_report(config, checks)
|
||||||
|
|
@ -10,7 +10,9 @@ from aiohttp.web_runner import GracefulExit
|
||||||
|
|
||||||
from app.features.dl_fields.service import DLFields
|
from app.features.dl_fields.service import DLFields
|
||||||
from app.features.presets.service import Presets
|
from app.features.presets.service import Presets
|
||||||
|
from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
from app.library.diagnostics import collect_diagnostics, diagnostics_error_report
|
||||||
from app.library.downloads import DownloadQueue
|
from app.library.downloads import DownloadQueue
|
||||||
from app.library.downloads.core import Download
|
from app.library.downloads.core import Download
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
|
|
@ -21,6 +23,8 @@ from app.library.UpdateChecker import UpdateChecker
|
||||||
from app.library.Utils import list_folders
|
from app.library.Utils import list_folders
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
|
||||||
|
DIAGNOSTICS_CACHE_TTL = 5.0
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/system/configuration", "system.configuration")
|
@route("GET", "api/system/configuration", "system.configuration")
|
||||||
|
|
@ -225,6 +229,29 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("GET", "api/system/diagnostics", "system.diagnostics")
|
||||||
|
async def system_diagnostics(
|
||||||
|
config: Config, encoder: Encoder, request: Request | None = None, cache: Cache | None = None
|
||||||
|
) -> Response:
|
||||||
|
"""Return user-facing runtime diagnostics for standalone installs."""
|
||||||
|
cache_key = "system:diagnostics"
|
||||||
|
cache = cache or Cache.get_instance()
|
||||||
|
use_cache: bool = request is None or request.query.get("refresh") not in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
if use_cache and (data := cache.get(cache_key)) is not None:
|
||||||
|
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await collect_diagnostics(config)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("Diagnostics collection failed.")
|
||||||
|
data = diagnostics_error_report(config)
|
||||||
|
else:
|
||||||
|
cache.set(cache_key, data, ttl=60)
|
||||||
|
|
||||||
|
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
||||||
async def _validate_terminal_command_request(request: Request) -> str | Response:
|
async def _validate_terminal_command_request(request: Request) -> str | Response:
|
||||||
if not request.can_read_body:
|
if not request.can_read_body:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
from app.library.cache import Cache
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.UpdateChecker import UpdateChecker
|
from app.library.UpdateChecker import UpdateChecker
|
||||||
from app.routes.api.system import check_updates, system_limits
|
from app.routes.api.system import check_updates, system_diagnostics, system_limits
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -193,3 +195,203 @@ class TestSystemLimitsEndpoint:
|
||||||
config = Config.get_instance()
|
config = Config.get_instance()
|
||||||
|
|
||||||
assert config.prevent_live_premiere is False
|
assert config.prevent_live_premiere is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemDiagnosticsEndpoint:
|
||||||
|
def setup_method(self):
|
||||||
|
Config._reset_singleton()
|
||||||
|
Cache.get_instance().clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_always_200(self, tmp_path: Path):
|
||||||
|
config = Config.get_instance()
|
||||||
|
config.config_path = str(tmp_path / "config")
|
||||||
|
config.download_path = str(tmp_path / "downloads")
|
||||||
|
config.temp_path = str(tmp_path / "tmp")
|
||||||
|
config.db_file = str(tmp_path / "config" / "ytptube.db")
|
||||||
|
config.console_enabled = False
|
||||||
|
|
||||||
|
Path(config.config_path).mkdir(parents=True)
|
||||||
|
Path(config.download_path).mkdir(parents=True)
|
||||||
|
Path(config.temp_path).mkdir(parents=True)
|
||||||
|
Path(config.db_file).touch()
|
||||||
|
|
||||||
|
encoder = Encoder()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
|
||||||
|
patch("app.library.diagnostics.shutil.which") as mock_which,
|
||||||
|
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
|
||||||
|
):
|
||||||
|
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}" if cmd in {"ffmpeg", "ffprobe", "deno"} else None
|
||||||
|
mock_run.side_effect = [
|
||||||
|
(0, "ffmpeg version 7.0", ""),
|
||||||
|
(0, "ffprobe version 7.0", ""),
|
||||||
|
(0, "deno 2.3.0", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await system_diagnostics(config, encoder)
|
||||||
|
|
||||||
|
assert 200 == response.status
|
||||||
|
body = json.loads(response.body.decode("utf-8"))
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["summary"]["required_failed"] == 0
|
||||||
|
|
||||||
|
checks = {item["id"]: item for item in body["checks"]}
|
||||||
|
assert checks["deno"]["status"] == "pass"
|
||||||
|
assert checks["deno"]["details"]["version"] == "2.3.0"
|
||||||
|
assert checks["yt_dlp_cli"]["status"] == "skip"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_error_payload(self):
|
||||||
|
config = Config.get_instance()
|
||||||
|
encoder = Encoder()
|
||||||
|
|
||||||
|
with patch("app.routes.api.system.collect_diagnostics", new_callable=AsyncMock) as mock_collect:
|
||||||
|
mock_collect.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
response = await system_diagnostics(config, encoder)
|
||||||
|
|
||||||
|
assert 200 == response.status
|
||||||
|
body = json.loads(response.body.decode("utf-8"))
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["summary"]["required_failed"] >= 1
|
||||||
|
assert len(body["checks"]) == 1
|
||||||
|
assert body["checks"][0]["status"] == "fail"
|
||||||
|
|
||||||
|
def test_distribution_package_detection(self):
|
||||||
|
from app.library.diagnostics import _check_pot_provider_package
|
||||||
|
|
||||||
|
with patch("app.library.diagnostics._package_version", return_value="1.3.1"):
|
||||||
|
check = _check_pot_provider_package()
|
||||||
|
|
||||||
|
assert check.status == "pass"
|
||||||
|
assert check.details["version"] == "1.3.1"
|
||||||
|
assert "description" not in check.details
|
||||||
|
|
||||||
|
def test_safe_details(self):
|
||||||
|
from app.library.diagnostics import _safe_url
|
||||||
|
|
||||||
|
assert _safe_url("https://user:pass@example.test/token/path?x=1#frag") == (
|
||||||
|
"https://***:***@example.test/***?***#***"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_binary_timeout(self):
|
||||||
|
from app.library.diagnostics import _check_binary
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.library.diagnostics.shutil.which", return_value="/usr/local/bin/deno"),
|
||||||
|
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
|
||||||
|
):
|
||||||
|
mock_run.side_effect = TimeoutError
|
||||||
|
|
||||||
|
check = await _check_binary("deno", check_id="deno", label="deno", required=True)
|
||||||
|
|
||||||
|
assert check.status == "fail"
|
||||||
|
assert check.details["command"] == "deno"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_marks_required_missing(self, tmp_path: Path):
|
||||||
|
config = Config.get_instance()
|
||||||
|
config.config_path = str(tmp_path / "config")
|
||||||
|
config.download_path = str(tmp_path / "downloads")
|
||||||
|
config.temp_path = str(tmp_path / "tmp")
|
||||||
|
config.db_file = str(tmp_path / "config" / "ytptube.db")
|
||||||
|
config.console_enabled = True
|
||||||
|
|
||||||
|
Path(config.config_path).mkdir(parents=True)
|
||||||
|
Path(config.download_path).mkdir(parents=True)
|
||||||
|
Path(config.temp_path).mkdir(parents=True)
|
||||||
|
Path(config.db_file).touch()
|
||||||
|
|
||||||
|
encoder = Encoder()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
|
||||||
|
patch("app.library.diagnostics.shutil.which") as mock_which,
|
||||||
|
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
|
||||||
|
):
|
||||||
|
mock_which.side_effect = lambda cmd: (
|
||||||
|
"/usr/bin/ffprobe" if cmd == "ffprobe" else "/usr/bin/yt-dlp" if cmd == "yt-dlp" else None
|
||||||
|
)
|
||||||
|
mock_run.side_effect = [
|
||||||
|
(0, "ffprobe version 7.0", ""),
|
||||||
|
(0, "yt-dlp 2026.01.01", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await system_diagnostics(config, encoder)
|
||||||
|
|
||||||
|
assert 200 == response.status
|
||||||
|
body = json.loads(response.body.decode("utf-8"))
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["summary"]["required_failed"] >= 1
|
||||||
|
|
||||||
|
checks = {item["id"]: item for item in body["checks"]}
|
||||||
|
assert checks["ffmpeg"]["status"] == "fail"
|
||||||
|
assert checks["deno"]["status"] == "fail"
|
||||||
|
assert checks["yt_dlp_cli"]["status"] == "pass"
|
||||||
|
assert checks["aria2c"]["status"] == "skip"
|
||||||
|
assert checks["mkvpropedit"]["status"] == "skip"
|
||||||
|
assert checks["mkvextract"]["status"] == "skip"
|
||||||
|
assert checks["mp4box"]["status"] == "skip"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alias_fallback_mp4box(self):
|
||||||
|
from app.library.diagnostics import _check_binary
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.library.diagnostics.shutil.which") as mock_which,
|
||||||
|
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
|
||||||
|
):
|
||||||
|
mock_which.side_effect = lambda cmd: "/usr/bin/mp4box" if cmd == "mp4box" else None
|
||||||
|
mock_run.return_value = (0, "MP4Box 2.2.1", "")
|
||||||
|
|
||||||
|
check = await _check_binary(
|
||||||
|
"MP4Box",
|
||||||
|
check_id="mp4box",
|
||||||
|
label="MP4Box",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
aliases=("mp4box",),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check.status == "pass"
|
||||||
|
assert check.details["command"] == "mp4box"
|
||||||
|
assert check.details["version"] == "MP4Box 2.2.1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_optional_binary_present(self):
|
||||||
|
from app.library.diagnostics import _check_binary
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.library.diagnostics.shutil.which", return_value="/usr/bin/aria2c"),
|
||||||
|
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
|
||||||
|
):
|
||||||
|
mock_run.return_value = (0, "aria2 version 1.37.0", "")
|
||||||
|
|
||||||
|
check = await _check_binary(
|
||||||
|
"aria2c",
|
||||||
|
check_id="aria2c",
|
||||||
|
label="aria2c",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check.status == "pass"
|
||||||
|
assert check.details["version"] == "1.37.0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_optional_binary_missing_skip(self):
|
||||||
|
from app.library.diagnostics import _check_binary
|
||||||
|
|
||||||
|
with patch("app.library.diagnostics.shutil.which", return_value=None):
|
||||||
|
check = await _check_binary(
|
||||||
|
"mkvpropedit",
|
||||||
|
check_id="mkvpropedit",
|
||||||
|
label="mkvpropedit",
|
||||||
|
required=False,
|
||||||
|
missing_status="skip",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check.status == "skip"
|
||||||
|
|
|
||||||
100
ui/app/composables/useDiagnostics.ts
Normal file
100
ui/app/composables/useDiagnostics.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { computed, readonly, ref } from 'vue';
|
||||||
|
import { useNotification } from '~/composables/useNotification';
|
||||||
|
import type { DiagnosticCheck, DiagnosticsResponse } from '~/types/diagnostics';
|
||||||
|
import { parse_api_error, parse_api_response, request } from '~/utils';
|
||||||
|
|
||||||
|
const diagnostics = ref<DiagnosticsResponse | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const lastError = ref<string | null>(null);
|
||||||
|
const throwInstead = ref(false);
|
||||||
|
|
||||||
|
const groupedChecks = computed<Record<string, Array<DiagnosticCheck>>>(() => {
|
||||||
|
return (diagnostics.value?.checks ?? []).reduce<Record<string, Array<DiagnosticCheck>>>(
|
||||||
|
(acc, check) => {
|
||||||
|
if (!acc[check.group]) {
|
||||||
|
acc[check.group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[check.group]?.push(check);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupOrder = computed<Array<string>>(() => Object.keys(groupedChecks.value));
|
||||||
|
|
||||||
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
return await response.clone().json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJson(response);
|
||||||
|
throw new Error(await parse_api_error(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: unknown): void => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load diagnostics.';
|
||||||
|
lastError.value = message;
|
||||||
|
useNotification().error(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDiagnostics = async (force: boolean = false): Promise<DiagnosticsResponse | null> => {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return diagnostics.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.value && !force) {
|
||||||
|
return diagnostics.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
lastError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/system/diagnostics${force ? '?refresh=1' : ''}`);
|
||||||
|
await ensureSuccess(response);
|
||||||
|
|
||||||
|
diagnostics.value = await parse_api_response<DiagnosticsResponse>(response.json());
|
||||||
|
return diagnostics.value;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = (): void => {
|
||||||
|
lastError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const __resetForTesting = (): void => {
|
||||||
|
diagnostics.value = null;
|
||||||
|
isLoading.value = false;
|
||||||
|
lastError.value = null;
|
||||||
|
throwInstead.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDiagnostics = () => ({
|
||||||
|
diagnostics: readonly(diagnostics),
|
||||||
|
isLoading: readonly(isLoading),
|
||||||
|
lastError: readonly(lastError),
|
||||||
|
groupedChecks,
|
||||||
|
groupOrder,
|
||||||
|
loadDiagnostics,
|
||||||
|
clearError,
|
||||||
|
throwInstead,
|
||||||
|
__resetForTesting,
|
||||||
|
});
|
||||||
551
ui/app/pages/diagnostics.vue
Normal file
551
ui/app/pages/diagnostics.vue
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
<template>
|
||||||
|
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||||
|
>
|
||||||
|
<UIcon :name="pageShell.icon" class="size-5" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||||
|
>
|
||||||
|
<span>{{ pageShell.sectionLabel }}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{{ pageShell.pageLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
:disabled="isLoading || !report"
|
||||||
|
@click="copyDiagnostics"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="void load(true)"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!report && isLoading"
|
||||||
|
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-default">Loading</p>
|
||||||
|
<p class="text-xs">Reading tools, paths, and configured extras.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="!report && lastError"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
title="Diagnostics failed"
|
||||||
|
:description="lastError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else-if="report">
|
||||||
|
<UAlert
|
||||||
|
v-if="showRequiredAlert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-octagon-alert"
|
||||||
|
title="Required items missing"
|
||||||
|
:description="requiredAlertDescription"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="rounded-md border border-default bg-default shadow-sm">
|
||||||
|
<div class="border-b border-default px-4 py-3 sm:px-5">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
||||||
|
<span>Overview</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-4">
|
||||||
|
<article
|
||||||
|
v-for="item in summaryCards"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-toned">
|
||||||
|
{{ item.label }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-toned">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="size-4 text-toned" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p :class="['mt-4 text-2xl font-semibold', item.valueClass]">{{ item.value }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-md border border-default bg-default shadow-sm">
|
||||||
|
<div class="border-b border-default px-4 py-3 sm:px-5">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon name="i-lucide-server" class="size-4 text-toned" />
|
||||||
|
<span>Runtime</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-3">
|
||||||
|
<article
|
||||||
|
v-for="row in runtimeRows"
|
||||||
|
:key="row.label"
|
||||||
|
class="rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
|
||||||
|
>
|
||||||
|
<UIcon :name="row.icon" class="size-4 text-toned" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-toned">
|
||||||
|
{{ row.label }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-default">{{ row.value }}</p>
|
||||||
|
<p class="mt-1 text-xs text-toned">{{ row.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-for="section in featureSections" :key="section.id" class="space-y-3">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon :name="section.icon" class="size-4 text-toned" />
|
||||||
|
<span>{{ section.label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-toned">{{ section.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs text-toned">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
|
||||||
|
<span class="font-medium text-default">Checks:</span>
|
||||||
|
<span>{{ section.items.length }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
|
||||||
|
<span class="font-medium text-default">Required fails:</span>
|
||||||
|
<span>{{ requiredFails(section.items) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<article v-for="item in section.items" :key="item.id" :class="cardClass(item.status)">
|
||||||
|
<div class="min-w-0 space-y-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span :class="tagDotClass(item.status)"></span>
|
||||||
|
<p class="text-base font-semibold text-default">{{ item.label }}</p>
|
||||||
|
<span :class="badgeClass(item.status)">{{ statusLabel(item.status) }}</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-md border border-default px-2 py-1 text-xs text-toned"
|
||||||
|
>
|
||||||
|
{{ item.required ? 'Required' : 'Optional' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="item.description" class="text-sm text-toned">{{ item.description }}</p>
|
||||||
|
<p v-if="showMessage(item)" class="text-sm leading-6 text-default">
|
||||||
|
{{ item.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="Object.keys(item.details || {}).length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="(value, key) in item.details"
|
||||||
|
:key="`${item.id}-${key}`"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-toned"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-default">{{ keyLabel(key) }}:</span>
|
||||||
|
<span>{{ formatValue(value) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import moment from 'moment';
|
||||||
|
import type { DiagnosticCheck, DiagnosticStatus } from '~/types/diagnostics';
|
||||||
|
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||||
|
import { copyText } from '~/utils';
|
||||||
|
|
||||||
|
type SummaryCard = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
value: number;
|
||||||
|
icon: string;
|
||||||
|
valueClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DetailRow = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureMeta = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureSection = FeatureMeta & {
|
||||||
|
id: string;
|
||||||
|
items: Array<DiagnosticCheck>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_META: Record<string, FeatureMeta> = {
|
||||||
|
core: {
|
||||||
|
label: 'Core setup',
|
||||||
|
description: 'Main tools and local paths.',
|
||||||
|
icon: 'i-lucide-wrench',
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
label: 'YouTube',
|
||||||
|
description: 'Support needed for YouTube downloads.',
|
||||||
|
icon: 'i-lucide-video',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
label: 'Notifications',
|
||||||
|
description: 'Apprise support.',
|
||||||
|
icon: 'i-lucide-bell',
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
label: 'Advanced',
|
||||||
|
description: 'Optional tools, transports, browser extractor, and plugins.',
|
||||||
|
icon: 'i-lucide-plug-zap',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
label: 'Custom pip packages',
|
||||||
|
description: 'Packages requested through config.',
|
||||||
|
icon: 'i-lucide-package-plus',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_ORDER = ['core', 'youtube', 'notifications', 'advanced', 'custom'];
|
||||||
|
|
||||||
|
const pageShell = requirePageShell('diagnostics');
|
||||||
|
const diagnosticsState = useDiagnostics();
|
||||||
|
|
||||||
|
const report = diagnosticsState.diagnostics;
|
||||||
|
const isLoading = diagnosticsState.isLoading;
|
||||||
|
const lastError = diagnosticsState.lastError;
|
||||||
|
const groupedChecks = diagnosticsState.groupedChecks;
|
||||||
|
|
||||||
|
const showRequiredAlert = computed(() => {
|
||||||
|
return (report.value?.summary.required_failed ?? 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredAlertDescription = computed(() => {
|
||||||
|
const count = report.value?.summary.required_failed ?? 0;
|
||||||
|
return `${count} required fail${count === 1 ? '' : 's'}.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const featureSections = computed<Array<FeatureSection>>(() => {
|
||||||
|
return FEATURE_ORDER.filter((id) => (groupedChecks.value[id] ?? []).length > 0).map((id) => ({
|
||||||
|
id,
|
||||||
|
...FEATURE_META[id]!,
|
||||||
|
items: groupedChecks.value[id] ?? [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryCards = computed<Array<SummaryCard>>(() => {
|
||||||
|
const current = report.value;
|
||||||
|
if (!current) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Passing',
|
||||||
|
description: 'Checks that passed.',
|
||||||
|
value: current.summary.pass,
|
||||||
|
icon: 'i-lucide-badge-check',
|
||||||
|
valueClass: current.summary.pass > 0 ? 'text-success' : 'text-default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Required fails',
|
||||||
|
description: 'Items that block core use.',
|
||||||
|
value: current.summary.required_failed,
|
||||||
|
icon: 'i-lucide-octagon-alert',
|
||||||
|
valueClass: current.summary.required_failed > 0 ? 'text-error' : 'text-default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Warnings',
|
||||||
|
description: 'Optional or incomplete items.',
|
||||||
|
value: current.summary.warn,
|
||||||
|
icon: 'i-lucide-triangle-alert',
|
||||||
|
valueClass: current.summary.warn > 0 ? 'text-warning' : 'text-default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Skipped',
|
||||||
|
description: 'Not configured or not needed.',
|
||||||
|
value: current.summary.skip,
|
||||||
|
icon: 'i-lucide-minus',
|
||||||
|
valueClass: current.summary.skip > 0 ? 'text-toned' : 'text-default',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeRows = computed<Array<DetailRow>>(() => {
|
||||||
|
const runtime = report.value?.runtime;
|
||||||
|
const generatedAt = report.value?.generated_at;
|
||||||
|
const python = report.value?.requirements.python;
|
||||||
|
|
||||||
|
if (!runtime || !python) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'App',
|
||||||
|
description: 'Current build.',
|
||||||
|
value: runtime.app_version || 'Unknown',
|
||||||
|
icon: 'i-lucide-package',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Host',
|
||||||
|
description: 'OS and machine.',
|
||||||
|
value: `${runtime.platform} ${runtime.platform_release} (${runtime.platform_machine})`,
|
||||||
|
icon: 'i-lucide-server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Python',
|
||||||
|
description: `${python.note} Minimum ${python.required}+`,
|
||||||
|
value: python.current,
|
||||||
|
icon: 'i-lucide-square-terminal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Started',
|
||||||
|
description: 'Process start time.',
|
||||||
|
value: runtime.started ? moment.unix(runtime.started).fromNow() : 'Unknown',
|
||||||
|
icon: 'i-lucide-power',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Uptime',
|
||||||
|
description: 'Current process uptime.',
|
||||||
|
value: moment.duration(runtime.uptime_seconds, 'seconds').humanize(),
|
||||||
|
icon: 'i-lucide-timer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Snapshot',
|
||||||
|
description: 'Diagnostics timestamp.',
|
||||||
|
value: generatedAt ? moment.unix(generatedAt).fromNow() : 'Unknown',
|
||||||
|
icon: 'i-lucide-clock-3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareText = computed(() => {
|
||||||
|
const current = report.value;
|
||||||
|
if (!current) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'YTPTube diagnostics',
|
||||||
|
`Generated: ${formatIsoTimestamp(current.generated_at)}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
lines.push('Overview');
|
||||||
|
for (const item of summaryCards.value) {
|
||||||
|
lines.push(`- ${item.label}: ${item.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'Runtime');
|
||||||
|
lines.push(`- App: ${current.runtime.app_version || 'Unknown'}`);
|
||||||
|
lines.push(
|
||||||
|
`- Host: ${current.runtime.platform} ${current.runtime.platform_release} (${current.runtime.platform_machine})`,
|
||||||
|
);
|
||||||
|
lines.push(`- Python: ${current.requirements.python.current}`);
|
||||||
|
lines.push(`- Started: ${formatIsoTimestamp(current.runtime.started)}`);
|
||||||
|
lines.push(`- Uptime: ${formatIsoDuration(current.runtime.uptime_seconds)}`);
|
||||||
|
lines.push(`- Snapshot: ${formatIsoTimestamp(current.generated_at)}`);
|
||||||
|
|
||||||
|
for (const section of featureSections.value) {
|
||||||
|
lines.push('', section.label);
|
||||||
|
|
||||||
|
for (const item of section.items) {
|
||||||
|
const versionSuffix = formatShareVersion(item);
|
||||||
|
lines.push(
|
||||||
|
`- ${item.label} (${item.required ? 'Required' : 'Optional'}) (${statusLabel(item.status)})${versionSuffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = async (force: boolean = false): Promise<void> => {
|
||||||
|
await diagnosticsState.loadDiagnostics(force);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyDiagnostics = (): void => {
|
||||||
|
if (!shareText.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(shareText.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredFails = (items: Array<DiagnosticCheck>): number => {
|
||||||
|
return items.filter((item) => item.required && item.status === 'fail').length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMessage = (item: DiagnosticCheck): boolean => {
|
||||||
|
if (item.status === 'pass') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(item.message?.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: DiagnosticStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pass':
|
||||||
|
return 'Pass';
|
||||||
|
case 'fail':
|
||||||
|
return 'Fail';
|
||||||
|
case 'warn':
|
||||||
|
return 'Warn';
|
||||||
|
case 'skip':
|
||||||
|
default:
|
||||||
|
return 'Skip';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardClass = (status: DiagnosticStatus): string => {
|
||||||
|
const base = 'rounded-md border border-default bg-default px-4 py-4 shadow-sm ring-1 ring-inset';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'pass':
|
||||||
|
return `${base} ring-success/10`;
|
||||||
|
case 'fail':
|
||||||
|
return `${base} ring-error/10`;
|
||||||
|
case 'warn':
|
||||||
|
return `${base} ring-warning/10`;
|
||||||
|
case 'skip':
|
||||||
|
default:
|
||||||
|
return `${base} ring-default/40`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagDotClass = (status: DiagnosticStatus): string => {
|
||||||
|
const base = 'inline-flex size-2.5 shrink-0 rounded-full';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'pass':
|
||||||
|
return `${base} bg-success`;
|
||||||
|
case 'fail':
|
||||||
|
return `${base} bg-error`;
|
||||||
|
case 'warn':
|
||||||
|
return `${base} bg-warning`;
|
||||||
|
case 'skip':
|
||||||
|
default:
|
||||||
|
return `${base} bg-muted`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeClass = (status: DiagnosticStatus): string => {
|
||||||
|
const base = 'inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'pass':
|
||||||
|
return `${base} border-success/30 bg-success/10 text-success`;
|
||||||
|
case 'fail':
|
||||||
|
return `${base} border-error/30 bg-error/10 text-error`;
|
||||||
|
case 'warn':
|
||||||
|
return `${base} border-warning/30 bg-warning/10 text-warning`;
|
||||||
|
case 'skip':
|
||||||
|
default:
|
||||||
|
return `${base} border-default bg-muted/40 text-toned`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: DiagnosticCheck['details'][string]): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyLabel = (value: string): string => {
|
||||||
|
return value.replace(/_/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatIsoTimestamp = (value: number | undefined): string => {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment.unix(value).utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatIsoDuration = (value: number | undefined): string => {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment.duration(value, 'seconds').toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShareVersion = (item: DiagnosticCheck): string => {
|
||||||
|
const version = item.details?.version;
|
||||||
|
if (version === null || version === undefined || version === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ` [${String(version)}]`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load(true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
56
ui/app/types/diagnostics.ts
Normal file
56
ui/app/types/diagnostics.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
export type DiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
|
||||||
|
export type DiagnosticReportStatus = 'ok' | 'degraded' | 'error';
|
||||||
|
|
||||||
|
export type DiagnosticCheck = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
required: boolean;
|
||||||
|
status: DiagnosticStatus;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
details: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiagnosticSummary = {
|
||||||
|
total: number;
|
||||||
|
pass: number;
|
||||||
|
fail: number;
|
||||||
|
warn: number;
|
||||||
|
skip: number;
|
||||||
|
required_failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiagnosticRuntime = {
|
||||||
|
app_version: string;
|
||||||
|
app_branch: string;
|
||||||
|
app_commit_sha: string;
|
||||||
|
app_build_date: string;
|
||||||
|
started: number;
|
||||||
|
uptime_seconds: number;
|
||||||
|
platform: string;
|
||||||
|
platform_release: string;
|
||||||
|
platform_machine: string;
|
||||||
|
python_version: string;
|
||||||
|
python_minimum: string;
|
||||||
|
is_native: boolean;
|
||||||
|
console_enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiagnosticRequirements = {
|
||||||
|
python: {
|
||||||
|
current: string;
|
||||||
|
required: string;
|
||||||
|
supported: boolean;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiagnosticsResponse = {
|
||||||
|
status: DiagnosticReportStatus;
|
||||||
|
generated_at: number;
|
||||||
|
summary: DiagnosticSummary;
|
||||||
|
runtime: DiagnosticRuntime;
|
||||||
|
requirements: DiagnosticRequirements;
|
||||||
|
checks: Array<DiagnosticCheck>;
|
||||||
|
};
|
||||||
|
|
@ -186,6 +186,17 @@ const NavItems: Array<NavDefinition> = [
|
||||||
matchPath: '/console',
|
matchPath: '/console',
|
||||||
requires: 'console_enabled',
|
requires: 'console_enabled',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'diagnostics',
|
||||||
|
section: 'tools',
|
||||||
|
group: 'tools',
|
||||||
|
label: 'Diagnostics',
|
||||||
|
pageLabel: 'Diagnostics',
|
||||||
|
description: 'View system information.',
|
||||||
|
icon: 'i-lucide-stethoscope',
|
||||||
|
to: '/diagnostics',
|
||||||
|
matchPath: '/diagnostics',
|
||||||
|
},
|
||||||
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
|
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
section: 'docs',
|
section: 'docs',
|
||||||
|
|
|
||||||
148
ui/tests/composables/useDiagnostics.test.ts
Normal file
148
ui/tests/composables/useDiagnostics.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
|
||||||
|
const successMock = mock(() => {});
|
||||||
|
const errorMock = mock(() => {});
|
||||||
|
|
||||||
|
mock.module('~/composables/useNotification', () => ({
|
||||||
|
useNotification: () => ({ success: successMock, error: errorMock }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type MockResponseInput = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
jsonData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockResponse = ({ ok, status, jsonData }: MockResponseInput): Response => {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
redirected: false,
|
||||||
|
statusText: ok ? 'OK' : 'Error',
|
||||||
|
type: 'basic',
|
||||||
|
url: '',
|
||||||
|
body: null,
|
||||||
|
bodyUsed: false,
|
||||||
|
clone() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
async json() {
|
||||||
|
return jsonData;
|
||||||
|
},
|
||||||
|
text: async () => JSON.stringify(jsonData),
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
blob: async () => new Blob(),
|
||||||
|
formData: async () => new FormData(),
|
||||||
|
} as Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
let utils: Awaited<typeof import('~/utils/index')>;
|
||||||
|
let useDiagnostics: typeof import('~/composables/useDiagnostics').useDiagnostics;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
utils = await import('~/utils/index');
|
||||||
|
({ useDiagnostics } = await import('~/composables/useDiagnostics'));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
successMock.mockClear();
|
||||||
|
errorMock.mockClear();
|
||||||
|
useDiagnostics().__resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useDiagnostics().__resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDiagnostics', () => {
|
||||||
|
it('load_diagnostics', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request');
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
status: 'error',
|
||||||
|
generated_at: 1,
|
||||||
|
summary: { total: 2, pass: 0, fail: 1, warn: 0, skip: 1, required_failed: 1 },
|
||||||
|
runtime: {
|
||||||
|
app_version: '1.0.0',
|
||||||
|
app_branch: 'main',
|
||||||
|
app_commit_sha: 'abc123',
|
||||||
|
app_build_date: '20260526',
|
||||||
|
started: 1,
|
||||||
|
uptime_seconds: 15,
|
||||||
|
platform: 'linux',
|
||||||
|
platform_release: '6.8',
|
||||||
|
platform_machine: 'x86_64',
|
||||||
|
python_version: '3.13.1',
|
||||||
|
python_minimum: '3.13',
|
||||||
|
is_native: false,
|
||||||
|
console_enabled: false,
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
python: {
|
||||||
|
current: '3.13.1',
|
||||||
|
required: '3.13',
|
||||||
|
supported: true,
|
||||||
|
note: 'Python runtime context.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: 'deno',
|
||||||
|
label: 'deno',
|
||||||
|
required: true,
|
||||||
|
group: 'youtube',
|
||||||
|
status: 'fail',
|
||||||
|
description: 'Used for yt-dlp YouTube support.',
|
||||||
|
message: 'Missing.',
|
||||||
|
details: { command: 'deno' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'browser_endpoint',
|
||||||
|
label: 'Remote browser',
|
||||||
|
group: 'browser',
|
||||||
|
status: 'skip',
|
||||||
|
description: 'Endpoint used by the browser extractor.',
|
||||||
|
message: 'Not configured.',
|
||||||
|
details: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const diagnostics = useDiagnostics();
|
||||||
|
const result = await diagnostics.loadDiagnostics(true);
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith('/api/system/diagnostics?refresh=1');
|
||||||
|
expect(result?.status).toBe('error');
|
||||||
|
expect(diagnostics.diagnostics.value?.summary.required_failed).toBe(1);
|
||||||
|
expect(diagnostics.groupOrder.value).toEqual(['youtube', 'browser']);
|
||||||
|
expect(diagnostics.groupedChecks.value.youtube?.[0]?.id).toBe('deno');
|
||||||
|
|
||||||
|
requestSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('store_load_error', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request');
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
jsonData: { error: 'Backend exploded' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const diagnostics = useDiagnostics();
|
||||||
|
const result = await diagnostics.loadDiagnostics(true);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(diagnostics.lastError.value).toBe('Backend exploded');
|
||||||
|
expect(errorMock).toHaveBeenCalledWith('Backend exploded');
|
||||||
|
|
||||||
|
requestSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue