Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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/
7 changes: 6 additions & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
239 changes: 239 additions & 0 deletions dashboard/app.js
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +215 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Target the overall status children without replacing the container

On every refresh, the overall status remains stuck at “Wird geladen...” regardless of whether the services are healthy or failing. Both update helpers use getElementById, so arguments such as overall-status .status-indicator and overall-status .status-text never resolve to elements. Additionally, the loading update replaces the wrapper's system-status class with status-indicator loading, applying the small indicator styling to the entire summary. Target the actual child elements, using dedicated IDs or selector-aware helpers, and preserve the wrapper's class.

Useful? React with 👍 / 👎.

}

// Auto-refresh every 30 seconds
setTimeout(fetchData, 30000);
Comment on lines +223 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the refresh loop when the user logs out

After a user logs out, the pending timeout still calls fetchData, sends all six requests with a null API key, and schedules another timeout. Logging in again starts an additional independent refresh loop, so each logout/login cycle permanently increases polling and the associated backend subprocess work. Store and clear the timeout on logout, guard refreshes against an unauthenticated state, and prevent an in-flight refresh from rescheduling after logout.

Useful? React with 👍 / 👎.

}

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(' ');
}
});
Loading
Loading