diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..129d4b9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: API Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Run tests + run: | + PYTHONPATH=. pytest tests/ diff --git a/README.de.md b/README.de.md index 7498ae3..027a3cd 100644 --- a/README.de.md +++ b/README.de.md @@ -232,9 +232,14 @@ Backups werden in `/home/pi/pihole-rescue-backups/` gespeichert und enthalten: --- -## 🐍 Optional: Suite REST API +## 🐍 Optional: Suite REST API & Dashboard `start_suite.py` ist eine **optionale** FastAPI-Anwendung für Monitoring. +Es enthält auch ein modernes, responsives **Dashboard** zur Überwachung Ihres DNS-Stacks. + +- **Dashboard-Zugriff**: Standardmäßig erreichbar unter `http://127.0.0.1:8090/`. +- **Fernzugriff**: Ein sicherer Fernzugriff wird über SSH-Portweiterleitung (`ssh -L 8090:127.0.0.1:8090 user@pihole-server`) oder einen Reverse-Proxy empfohlen. +- **Authentifizierung**: Für das Dashboard-Login und die API-Endpunkte ist der `SUITE_API_KEY` erforderlich. ```bash # Konfiguration diff --git a/README.md b/README.md index 8f55f65..1e5c901 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,14 @@ Backups are stored in `/home/pi/pihole-rescue-backups/` and include: --- -## 🐍 Optional: Suite REST API +## 🐍 Optional: Suite REST API & Dashboard `start_suite.py` is an **optional** FastAPI server providing a JSON API for monitoring. +It also hosts a modern, responsive, read-only web **Dashboard** for monitoring your DNS stack. + +- **Dashboard Access**: By default accessible at `http://127.0.0.1:8090/`. +- **Remote Access**: Secure remote access is recommended via SSH port forwarding (`ssh -L 8090:127.0.0.1:8090 user@pihole-server`) or a reverse proxy. +- **Authentication**: Dashboard login and API endpoints require the `SUITE_API_KEY`. ```bash # Setup diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 0000000..f9d05e7 --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,239 @@ +document.addEventListener('DOMContentLoaded', () => { + // DOM Elements + const authOverlay = document.getElementById('auth-overlay'); + const authForm = document.getElementById('auth-form'); + const apiKeyInput = document.getElementById('api-key'); + const authError = document.getElementById('auth-error'); + const dashboard = document.getElementById('dashboard'); + const logoutBtn = document.getElementById('logout-btn'); + const lastUpdateEl = document.getElementById('last-update'); + + // API Key management + let apiKey = sessionStorage.getItem('suite_api_key'); + + // Initialization + if (apiKey) { + showDashboard(); + fetchData(); + } else { + showAuth(); + } + + // Event Listeners + authForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const key = apiKeyInput.value.trim(); + if (!key) return; + + // Verify key by hitting /health + try { + const res = await fetch('/health', { + headers: { 'X-API-Key': key } + }); + + if (res.ok) { + apiKey = key; + sessionStorage.setItem('suite_api_key', key); + authError.classList.add('hidden'); + showDashboard(); + fetchData(); + } else { + authError.textContent = 'Ungültiger API-Schlüssel.'; + authError.classList.remove('hidden'); + } + } catch (err) { + authError.textContent = 'Verbindungsfehler zur API.'; + authError.classList.remove('hidden'); + } + }); + + logoutBtn.addEventListener('click', () => { + sessionStorage.removeItem('suite_api_key'); + apiKey = null; + apiKeyInput.value = ''; + showAuth(); + }); + + // UI State Management + function showAuth() { + authOverlay.classList.remove('hidden'); + dashboard.classList.add('hidden'); + apiKeyInput.focus(); + } + + function showDashboard() { + authOverlay.classList.add('hidden'); + dashboard.classList.remove('hidden'); + } + + function updateStatusIndicator(elementId, status) { + const el = document.getElementById(elementId); + if (!el) return; + el.className = 'status-indicator ' + status; + } + + function updateElementText(elementId, text) { + const el = document.getElementById(elementId); + if (el) el.textContent = text; + } + + // Data Fetching + async function apiFetch(endpoint) { + try { + const res = await fetch(endpoint, { + headers: { 'X-API-Key': apiKey } + }); + if (res.status === 401) { + logoutBtn.click(); + throw new Error('Unauthorized'); + } + if (!res.ok) throw new Error('API Error'); + return await res.json(); + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return null; + } + } + + async function fetchData() { + const now = new Date(); + updateElementText('last-update', `Letzte Aktualisierung: ${now.toLocaleTimeString()}`); + + updateStatusIndicator('overall-status', 'loading'); + updateElementText('overall-status .status-text', 'Wird geladen...'); + + const [health, urls, pihole, unbound, netalertx, stats] = await Promise.all([ + apiFetch('/health'), + apiFetch('/urls'), + apiFetch('/pihole'), + apiFetch('/unbound'), + apiFetch('/netalertx'), + apiFetch('/stats') + ]); + + let allHealthy = true; + + // Process System Health + if (health) { + updateStatusIndicator('system-status-indicator', 'healthy'); + updateElementText('system-uptime', formatUptime(health.uptime_seconds)); + updateElementText('system-version', health.version); + } else { + updateStatusIndicator('system-status-indicator', 'error'); + allHealthy = false; + } + + if (urls) { + updateElementText('system-ip', urls.host_ip); + const linkPihole = document.getElementById('link-pihole-admin'); + if (linkPihole) linkPihole.href = urls.pihole_admin; + + const linkNetalertx = document.getElementById('link-netalertx'); + if (linkNetalertx) linkNetalertx.href = urls.netalertx; + } + + // Process Pi-hole + if (pihole) { + if (pihole.ftl_active) { + updateStatusIndicator('pihole-status-indicator', 'healthy'); + updateElementText('pihole-status', 'Aktiv (FTL läuft)'); + } else { + updateStatusIndicator('pihole-status-indicator', 'error'); + updateElementText('pihole-status', 'Fehler (FTL inaktiv)'); + allHealthy = false; + } + updateElementText('pihole-version', pihole.pihole_version); + + let upstreamsStr = 'Keine / Unbekannt'; + if (pihole.upstreams && pihole.upstreams.length > 0) { + upstreamsStr = pihole.upstreams.join(', '); + + // Verify Unbound upstream if unbound is available + if (unbound && unbound.port) { + const expectedUpstream = `127.0.0.1#${unbound.port}`; + if (!pihole.upstreams.includes(expectedUpstream)) { + upstreamsStr += ' (Warnung: Unbound nicht konfiguriert!)'; + updateStatusIndicator('pihole-status-indicator', 'warning'); + allHealthy = false; + } + } + } + updateElementText('pihole-upstreams', upstreamsStr); + + } else { + updateStatusIndicator('pihole-status-indicator', 'unavailable'); + updateElementText('pihole-status', 'Nicht erreichbar'); + allHealthy = false; + } + + if (stats) { + updateElementText('pihole-recent-queries', stats.recent_queries); + } + + // Process Unbound + if (unbound) { + updateElementText('unbound-port', unbound.port); + if (unbound.service_active) { + updateElementText('unbound-status', 'Aktiv'); + if (unbound.dig_ok) { + updateStatusIndicator('unbound-status-indicator', 'healthy'); + updateElementText('unbound-dig', 'Erfolgreich'); + } else { + updateStatusIndicator('unbound-status-indicator', 'warning'); + updateElementText('unbound-dig', 'Fehlgeschlagen'); + allHealthy = false; + } + } else { + updateStatusIndicator('unbound-status-indicator', 'error'); + updateElementText('unbound-status', 'Inaktiv'); + updateElementText('unbound-dig', '-'); + allHealthy = false; + } + updateElementText('unbound-dig-result', unbound.dig_result || '-'); + } else { + updateStatusIndicator('unbound-status-indicator', 'unavailable'); + updateElementText('unbound-status', 'Nicht erreichbar'); + allHealthy = false; + } + + // Process NetAlertX (Optional) + if (netalertx) { + updateElementText('netalertx-port', netalertx.port); + if (netalertx.http_reachable) { + updateStatusIndicator('netalertx-status-indicator', 'healthy'); + updateElementText('netalertx-status', 'Erreichbar'); + } else { + updateStatusIndicator('netalertx-status-indicator', 'unavailable'); + updateElementText('netalertx-status', 'Nicht erreichbar'); + } + } else { + updateStatusIndicator('netalertx-status-indicator', 'unavailable'); + updateElementText('netalertx-status', 'Status unbekannt'); + } + + // Overall status + if (allHealthy) { + updateStatusIndicator('overall-status .status-indicator', 'healthy'); + updateElementText('overall-status .status-text', 'Alle Kernsysteme gesund'); + } else { + updateStatusIndicator('overall-status .status-indicator', 'warning'); + updateElementText('overall-status .status-text', 'Probleme erkannt'); + } + + // Auto-refresh every 30 seconds + setTimeout(fetchData, 30000); + } + + function formatUptime(seconds) { + if (seconds === undefined || seconds === null) return '-'; + const d = Math.floor(seconds / (3600*24)); + const h = Math.floor(seconds % (3600*24) / 3600); + const m = Math.floor(seconds % 3600 / 60); + + let res = []; + if (d > 0) res.push(`${d}d`); + if (h > 0) res.push(`${h}h`); + res.push(`${m}m`); + return res.join(' '); + } +}); \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..34454da --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,146 @@ + + + + + + Pi-hole Suite - Infrastruktur Übersicht + + + + +
+
+

Authentifizierung erforderlich

+

Bitte geben Sie den API-Schlüssel ein, um auf das Dashboard zuzugreifen.

+
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/dashboard/style.css b/dashboard/style.css new file mode 100644 index 0000000..73c955c --- /dev/null +++ b/dashboard/style.css @@ -0,0 +1,333 @@ +:root { + --bg-color: #121212; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --glass-bg: rgba(30, 30, 30, 0.6); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + + --color-healthy: #4caf50; + --color-warning: #ff9800; + --color-error: #f44336; + --color-unavailable: #9e9e9e; + --color-loading: #2196f3; + + --accent-color: #3f51b5; + --accent-hover: #5c6bc0; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Glassmorphism Classes */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 12px; + box-shadow: var(--glass-shadow); +} + +/* Authentication Overlay */ +.overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + background-color: var(--bg-color); +} + +.auth-panel { + padding: 2.5rem; + width: 100%; + max-width: 400px; + text-align: center; +} + +.auth-panel h2 { + margin-bottom: 1rem; + font-weight: 500; +} + +.auth-panel p { + color: var(--text-secondary); + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.auth-panel form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +input[type="password"], input[type="text"] { + width: 100%; + padding: 0.75rem 1rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(0, 0, 0, 0.2); + color: var(--text-primary); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +input[type="password"]:focus, input[type="text"]:focus { + border-color: var(--accent-color); +} + +.btn { + background-color: var(--accent-color); + color: white; + border: none; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.btn:hover { + background-color: var(--accent-hover); +} + +.btn-small { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; +} + +.btn-outline { + background-color: transparent; + border: 1px solid var(--accent-color); + color: var(--text-primary); +} + +.btn-outline:hover { + background-color: rgba(63, 81, 181, 0.2); +} + +.error-msg { + color: var(--color-error); + margin-top: 1rem; + font-size: 0.85rem; +} + +.hidden { + display: none !important; +} + +/* Dashboard Layout */ +.dashboard-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.header-title h1 { + font-size: 1.8rem; + font-weight: 400; + margin-bottom: 0.5rem; +} + +.system-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.header-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.last-update { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.5rem; +} + +/* Cards */ +.card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.card-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--glass-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-header h2 { + font-size: 1.2rem; + font-weight: 500; +} + +.card-body { + padding: 1.5rem; + flex-grow: 1; +} + +.card-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--glass-border); + background: rgba(0, 0, 0, 0.1); +} + +/* Info Rows */ +.info-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.5rem 0; + border-bottom: 1px dashed rgba(255, 255, 255, 0.05); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row .label { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.info-row .value { + font-weight: 500; + text-align: right; + word-break: break-all; + max-width: 60%; +} + +.info-row .value-code { + font-family: monospace; + font-size: 0.85rem; + background: rgba(0, 0, 0, 0.3); + padding: 0.2rem 0.4rem; + border-radius: 4px; +} + +.info-row .note { + font-size: 0.85rem; + color: var(--text-secondary); + font-style: italic; +} + +/* Status Indicators */ +.status-indicator { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; +} + +.status-indicator.healthy { + background-color: var(--color-healthy); + box-shadow: 0 0 8px var(--color-healthy); +} + +.status-indicator.warning { + background-color: var(--color-warning); + box-shadow: 0 0 8px var(--color-warning); +} + +.status-indicator.error { + background-color: var(--color-error); + box-shadow: 0 0 8px var(--color-error); +} + +.status-indicator.unavailable { + background-color: var(--color-unavailable); +} + +.status-indicator.loading { + background-color: var(--color-loading); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + opacity: 0.5; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 0.5; + transform: scale(0.8); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .dashboard-container { + padding: 1rem; + } + + .dashboard-header { + flex-direction: column; + align-items: flex-start; + } + + .header-actions { + align-items: flex-start; + margin-top: 1rem; + } + + .info-row { + flex-direction: column; + gap: 0.25rem; + } + + .info-row .value { + text-align: left; + max-width: 100%; + } +} diff --git a/install.sh b/install.sh index ac50344..f52ac5e 100755 --- a/install.sh +++ b/install.sh @@ -1119,6 +1119,14 @@ setup_python_suite() { sudo mkdir -p "$suite_app_dir" 2>/dev/null || true if [[ -f "$SCRIPT_DIR/start_suite.py" ]]; then sudo install -m 0644 "$SCRIPT_DIR/start_suite.py" "$suite_entrypoint" + + # Install dashboard assets + if [[ -d "$SCRIPT_DIR/dashboard" ]]; then + sudo mkdir -p "$suite_app_dir/dashboard" + sudo cp -r "$SCRIPT_DIR/dashboard/"* "$suite_app_dir/dashboard/" + sudo chmod -R 0644 "$suite_app_dir/dashboard/"* + sudo find "$suite_app_dir/dashboard" -type d -exec chmod 0755 {} \; + fi else log_warning "Python Suite entrypoint missing at $SCRIPT_DIR/start_suite.py (service may fail to start)" fi diff --git a/start_suite.py b/start_suite.py index 563f0a8..4f94531 100755 --- a/start_suite.py +++ b/start_suite.py @@ -30,8 +30,10 @@ from typing import Any from urllib.request import Request, urlopen -from fastapi import Depends, FastAPI, Header, HTTPException import uvicorn +from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles APP_VERSION = "1.0.0" START_TIME = time.time() @@ -56,6 +58,14 @@ def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Ke app = FastAPI(title="Pi-hole Suite API", version=APP_VERSION) +# Mount dashboard if it exists +if os.path.isdir(os.path.join(os.path.dirname(__file__), "dashboard")): + app.mount("/dashboard", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "dashboard"), html=True), name="dashboard") + +@app.get("/", include_in_schema=False) +def root_redirect(): + return RedirectResponse(url="/dashboard") + def _read_lines(path: str, limit: int) -> list[str]: try: @@ -191,7 +201,7 @@ def _read_pihole_v6_upstreams() -> list[str]: return [] # Very small parser: look for upstreams = ["127.0.0.1#5335", ...] - m = re.search(r"^upstreams\s*=\s*\[(.*?)\]", t, flags=re.M) + m = re.search(r"^upstreams\s*=\s*\[(.*?)\]", t, flags=re.MULTILINE) if not m: return [] @@ -254,7 +264,7 @@ def unbound() -> dict[str, Any]: [ "dig", "+short", - f"@127.0.0.1", + "@127.0.0.1", "-p", str(unbound_port), "+time=1", diff --git a/tests/test_suite.py b/tests/test_suite.py new file mode 100644 index 0000000..63d0d06 --- /dev/null +++ b/tests/test_suite.py @@ -0,0 +1,140 @@ +import os +from unittest.mock import patch + +from fastapi.testclient import TestClient + +# Set required env vars before importing start_suite +os.environ["SUITE_API_KEY"] = "test-key" +os.environ["SUITE_PORT"] = "8090" +os.environ["SUITE_HOST"] = "127.0.0.1" + +from start_suite import app + +client = TestClient(app) +HEADERS = {"X-API-Key": "test-key"} +BAD_HEADERS = {"X-API-Key": "wrong-key"} + +def test_root_redirect(): + # Test dashboard redirect + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/dashboard" + +def test_dashboard_static_files(): + # Only test if dashboard folder is present + if os.path.isdir(os.path.join(os.path.dirname(__file__), "..", "dashboard")): + response = client.get("/dashboard/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + +def test_auth_success(): + response = client.get("/health", headers=HEADERS) + assert response.status_code == 200 + assert response.json()["ok"] is True + +def test_auth_missing(): + response = client.get("/health") + assert response.status_code in [401, 422] # missing header error depends on dependency resolution + +def test_auth_invalid(): + response = client.get("/health", headers=BAD_HEADERS) + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized" + +@patch("start_suite._run") +@patch("start_suite._read_pihole_v6_upstreams") +def test_pihole_healthy(mock_upstreams, mock_run): + def run_side_effect(cmd, **kwargs): + if cmd == ["pihole", "-v"]: + return {"ok": True, "stdout": "Pi-hole v5.0"} + if cmd == ["systemctl", "is-active", "pihole-FTL"]: + return {"ok": True, "returncode": 0} + return {"ok": False} + + mock_run.side_effect = run_side_effect + mock_upstreams.return_value = ["1.1.1.1"] + + response = client.get("/pihole", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["ftl_active"] is True + assert data["pihole_version"] == "Pi-hole v5.0" + assert "1.1.1.1" in data["upstreams"] + +@patch("start_suite._run") +@patch("start_suite._read_pihole_v6_upstreams") +def test_pihole_unavailable(mock_upstreams, mock_run): + def run_side_effect(cmd, **kwargs): + if cmd == ["pihole", "-v"]: + return {"ok": False} + if cmd == ["systemctl", "is-active", "pihole-FTL"]: + return {"ok": True, "returncode": 3} + return {"ok": False} + + mock_run.side_effect = run_side_effect + mock_upstreams.return_value = [] + + response = client.get("/pihole", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["ftl_active"] is False + assert data["pihole_version"] == "unknown" + +@patch("start_suite._run") +def test_unbound_healthy(mock_run): + def run_side_effect(cmd, **kwargs): + if cmd[0:3] == ["systemctl", "is-active", "unbound"]: + return {"ok": True, "returncode": 0} + if cmd[0] == "dig": + return {"ok": True, "stdout": "1.1.1.1"} + return {"ok": False} + + mock_run.side_effect = run_side_effect + response = client.get("/unbound", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["service_active"] is True + assert data["dig_ok"] is True + assert data["dig_result"] == "1.1.1.1" + +@patch("start_suite._run") +def test_unbound_unavailable(mock_run): + def run_side_effect(cmd, **kwargs): + if cmd[0:3] == ["systemctl", "is-active", "unbound"]: + return {"ok": True, "returncode": 3} + if cmd[0] == "dig": + return {"ok": False, "error": "timeout"} + return {"ok": False} + + mock_run.side_effect = run_side_effect + response = client.get("/unbound", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["service_active"] is False + assert data["dig_ok"] is False + +@patch("start_suite._http_ok") +def test_netalertx_healthy(mock_http_ok): + mock_http_ok.return_value = True + response = client.get("/netalertx", headers=HEADERS) + assert response.status_code == 200 + assert response.json()["http_reachable"] is True + +@patch("start_suite._http_ok") +def test_netalertx_unavailable(mock_http_ok): + mock_http_ok.return_value = False + response = client.get("/netalertx", headers=HEADERS) + assert response.status_code == 200 + assert response.json()["http_reachable"] is False + +@patch("start_suite._run") +def test_command_timeout(mock_run): + def run_side_effect(cmd, **kwargs): + return {"ok": False, "error": "Command timed out"} + + mock_run.side_effect = run_side_effect + response = client.get("/pihole", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["ftl_active"] is False + assert data["pihole_version"] == "unknown"