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)
|
||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
||||
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
|
||||
- [GET /api/system/limits](#get-apisystemlimits)
|
||||
- [POST /api/system/terminal](#post-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
|
||||
**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.presets.service import Presets
|
||||
from app.library.cache import Cache
|
||||
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.core import Download
|
||||
from app.library.encoder import Encoder
|
||||
|
|
@ -21,6 +23,8 @@ from app.library.UpdateChecker import UpdateChecker
|
|||
from app.library.Utils import list_folders
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
|
||||
DIAGNOSTICS_CACHE_TTL = 5.0
|
||||
|
||||
|
||||
@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:
|
||||
if not request.can_read_body:
|
||||
return web.json_response(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.cache import Cache
|
||||
from app.library.encoder import Encoder
|
||||
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
|
||||
|
|
@ -193,3 +195,203 @@ class TestSystemLimitsEndpoint:
|
|||
config = Config.get_instance()
|
||||
|
||||
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',
|
||||
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) => ({
|
||||
id: entry.id,
|
||||
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