feat: add service status monitoring to dashboard with parallel health checks for 14 services
Add SERVICE_DEFINITIONS list with Radarr, Sonarr, SABnzbd, Jellyfin, Seerr, Prowlarr, Easynews as Indexer, qBittorrent, Bazarr, Lingarr, LibreTranslate, Sonarr-Prowlarr Proxy, Radarr-Prowlarr Proxy, and Dashboard. Add check_service() to perform HTTP/API health checks with configurable timeout. Add refresh_services() to run checks in parallel using service_check_executor ThreadPoolExecutor with 16 workers.
This commit is contained in:
@@ -15,5 +15,20 @@ FILM_MEDIA_VOLUME=/nesflix/filme
|
|||||||
# SAB_URL=http://sabnzbd:8080
|
# SAB_URL=http://sabnzbd:8080
|
||||||
# SAB_API_KEY=CHANGE_ME
|
# SAB_API_KEY=CHANGE_ME
|
||||||
|
|
||||||
|
# Dienststatus im Dashboard
|
||||||
|
JELLYFIN_URL=http://jellyfin:8096
|
||||||
|
SEERR_URL=http://seerr:5055
|
||||||
|
PROWLARR_URL=http://prowlarr:9696
|
||||||
|
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
||||||
|
# qBittorrent läuft außerhalb des Compose-Stacks: URL hier anpassen
|
||||||
|
# QBITTORRENT_URL=http://host.docker.internal:8080
|
||||||
|
BAZARR_URL=http://bazarr:6767
|
||||||
|
LINGARR_URL=http://lingarr:9876
|
||||||
|
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||||
|
SONARR_PROWLARR_PROXY_URL=http://sonarr-prowlarr-proxy:8080
|
||||||
|
RADARR_PROWLARR_PROXY_URL=http://radarr-prowlarr-proxy:8080
|
||||||
|
SERVICE_CHECK_TIMEOUT=3
|
||||||
|
SERVICE_REFRESH_SECONDS=30
|
||||||
|
|
||||||
DB_PATH=/data/cache.db
|
DB_PATH=/data/cache.db
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ volumes:
|
|||||||
- /nesflix/filme:/media/filme:ro
|
- /nesflix/filme:/media/filme:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Dienststatus
|
||||||
|
|
||||||
|
Der Dashboard-Tab prüft zusätzlich Jellyfin, Seerr, Prowlarr, Easynews as Indexer,
|
||||||
|
qBittorrent, Bazarr, Lingarr, LibreTranslate sowie die Sonarr-/Radarr-Prowlarr-Proxies.
|
||||||
|
Die Prüfungen laufen parallel im Hintergrund und blockieren die Seite nicht.
|
||||||
|
|
||||||
|
Die Standardnamen funktionieren, wenn die Container im selben Docker-Netzwerk liegen.
|
||||||
|
Für qBittorrent muss `QBITTORRENT_URL` auf eine aus dem Dashboard-Container erreichbare
|
||||||
|
URL zeigen, zum Beispiel `http://host.docker.internal:8080` oder die Host-IP.
|
||||||
|
|
||||||
|
```env
|
||||||
|
JELLYFIN_URL=http://jellyfin:8096
|
||||||
|
SEERR_URL=http://seerr:5055
|
||||||
|
PROWLARR_URL=http://prowlarr:9696
|
||||||
|
EASYNEWS_INDEXER_URL=http://easynews-as-indexer:8081
|
||||||
|
QBITTORRENT_URL=http://HOST_ODER_IP:8080
|
||||||
|
BAZARR_URL=http://bazarr:6767
|
||||||
|
LINGARR_URL=http://lingarr:9876
|
||||||
|
LIBRETRANSLATE_URL=http://libretranslate:5000
|
||||||
|
SONARR_PROWLARR_PROXY_URL=http://sonarr-prowlarr-proxy:8080
|
||||||
|
RADARR_PROWLARR_PROXY_URL=http://radarr-prowlarr-proxy:8080
|
||||||
|
SERVICE_CHECK_TIMEOUT=3
|
||||||
|
SERVICE_REFRESH_SECONDS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
Für die bereits vorhandenen Container sind keine zusätzlichen Compose-Services nötig;
|
||||||
|
sie müssen im gemeinsamen `media-stack_default`-Netzwerk erreichbar sein. Falls
|
||||||
|
qBittorrent nicht über `host.docker.internal` erreichbar ist, kann unter Linux je nach
|
||||||
|
Docker-Konfiguration dieser Eintrag ergänzt werden:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
Ein Radarr-Pfad wie:
|
Ein Radarr-Pfad wie:
|
||||||
|
|
||||||
`/data/filme/Avatar (2009)/Avatar.mkv`
|
`/data/filme/Avatar (2009)/Avatar.mkv`
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ SONARR_MEDIA_PATH = os.environ.get("SONARR_MEDIA_PATH", "/data/serien").rstrip("
|
|||||||
LOCAL_SERIES_PATH = os.environ.get("LOCAL_SERIES_PATH", "/media/serien").rstrip("/")
|
LOCAL_SERIES_PATH = os.environ.get("LOCAL_SERIES_PATH", "/media/serien").rstrip("/")
|
||||||
SAB_URL = os.environ.get("SAB_URL", "").rstrip("/")
|
SAB_URL = os.environ.get("SAB_URL", "").rstrip("/")
|
||||||
SAB_API_KEY = os.environ.get("SAB_API_KEY", "")
|
SAB_API_KEY = os.environ.get("SAB_API_KEY", "")
|
||||||
|
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://jellyfin:8096").rstrip("/")
|
||||||
|
SEERR_URL = os.environ.get("SEERR_URL", "http://seerr:5055").rstrip("/")
|
||||||
|
PROWLARR_URL = os.environ.get("PROWLARR_URL", "http://prowlarr:9696").rstrip("/")
|
||||||
|
EASYNEWS_INDEXER_URL = os.environ.get("EASYNEWS_INDEXER_URL", "http://easynews-as-indexer:8081").rstrip("/")
|
||||||
|
QBITTORRENT_URL = os.environ.get("QBITTORRENT_URL", "").rstrip("/")
|
||||||
|
BAZARR_URL = os.environ.get("BAZARR_URL", "http://bazarr:6767").rstrip("/")
|
||||||
|
LINGARR_URL = os.environ.get("LINGARR_URL", "http://lingarr:9876").rstrip("/")
|
||||||
|
LIBRETRANSLATE_URL = os.environ.get("LIBRETRANSLATE_URL", "http://libretranslate:5000").rstrip("/")
|
||||||
|
SONARR_PROWLARR_PROXY_URL = os.environ.get("SONARR_PROWLARR_PROXY_URL", "http://sonarr-prowlarr-proxy:8080").rstrip("/")
|
||||||
|
RADARR_PROWLARR_PROXY_URL = os.environ.get("RADARR_PROWLARR_PROXY_URL", "http://radarr-prowlarr-proxy:8080").rstrip("/")
|
||||||
|
SERVICE_CHECK_TIMEOUT = float(os.environ.get("SERVICE_CHECK_TIMEOUT", "3"))
|
||||||
|
SERVICE_REFRESH_SECONDS = int(os.environ.get("SERVICE_REFRESH_SECONDS", "30"))
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
|
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
|
||||||
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
|
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "20"))
|
||||||
|
|
||||||
@@ -36,10 +48,30 @@ jobs = {"radarr": {"state": "idle", "total": 0, "done": 0, "error": None}, "sona
|
|||||||
job_lock = threading.Lock()
|
job_lock = threading.Lock()
|
||||||
system_lock = threading.Lock()
|
system_lock = threading.Lock()
|
||||||
previous_cpu = None
|
previous_cpu = None
|
||||||
|
service_status_lock = threading.Lock()
|
||||||
|
service_check_executor = ThreadPoolExecutor(max_workers=16)
|
||||||
|
|
||||||
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
||||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
||||||
|
|
||||||
|
SERVICE_DEFINITIONS = [
|
||||||
|
{"key": "radarr", "name": "Radarr", "url": f"{RADARR_URL}/api/v3/system/status", "api_key": RADARR_API_KEY, "kind": "api"},
|
||||||
|
{"key": "sonarr", "name": "Sonarr", "url": f"{SONARR_URL}/api/v3/system/status" if SONARR_URL else "", "api_key": SONARR_API_KEY, "kind": "api"},
|
||||||
|
{"key": "sabnzbd", "name": "SABnzbd", "url": f"{SAB_URL}/api?mode=version&output=json&apikey={SAB_API_KEY}" if SAB_URL else "", "api_key": "", "kind": "api"},
|
||||||
|
{"key": "jellyfin", "name": "Jellyfin", "url": JELLYFIN_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "seerr", "name": "Seerr", "url": SEERR_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "prowlarr", "name": "Prowlarr", "url": PROWLARR_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "easynews", "name": "Easynews as Indexer", "url": EASYNEWS_INDEXER_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "qbittorrent", "name": "qBittorrent", "url": QBITTORRENT_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "bazarr", "name": "Bazarr", "url": BAZARR_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "lingarr", "name": "Lingarr", "url": LINGARR_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "libretranslate", "name": "LibreTranslate", "url": LIBRETRANSLATE_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "sonarr_prowlarr_proxy", "name": "Sonarr-Prowlarr Proxy", "url": SONARR_PROWLARR_PROXY_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "radarr_prowlarr_proxy", "name": "Radarr-Prowlarr Proxy", "url": RADARR_PROWLARR_PROXY_URL, "api_key": "", "kind": "http"},
|
||||||
|
{"key": "dashboard", "name": "Language Dashboard", "url": "", "api_key": "", "kind": "self"},
|
||||||
|
]
|
||||||
|
service_status = {service["key"]: {"key": service["key"], "name": service["name"], "status": "checking", "label": "Prüfe …"} for service in SERVICE_DEFINITIONS}
|
||||||
|
|
||||||
def db():
|
def db():
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
con = sqlite3.connect(DB_PATH)
|
con = sqlite3.connect(DB_PATH)
|
||||||
@@ -54,6 +86,52 @@ def api_get(base, key, endpoint):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def check_service(service):
|
||||||
|
result = {"key": service["key"], "name": service["name"]}
|
||||||
|
if service["kind"] == "self":
|
||||||
|
result.update(status="online", label="Aktiv", detail="Dashboard läuft")
|
||||||
|
return result
|
||||||
|
if not service["url"]:
|
||||||
|
result.update(status="error", label="Nicht konfiguriert", detail="URL fehlt")
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
headers = {"X-Api-Key": service["api_key"]} if service["api_key"] else {}
|
||||||
|
response = requests.get(service["url"], headers=headers, timeout=SERVICE_CHECK_TIMEOUT, allow_redirects=False)
|
||||||
|
if 200 <= response.status_code < 400:
|
||||||
|
result.update(status="online", label="Aktiv", detail=f"HTTP {response.status_code}")
|
||||||
|
elif response.status_code in (401, 403) or response.status_code >= 500:
|
||||||
|
result.update(status="error", label="Fehler", detail=f"HTTP {response.status_code}")
|
||||||
|
else:
|
||||||
|
result.update(status="online", label="Erreichbar", detail=f"HTTP {response.status_code}")
|
||||||
|
except requests.Timeout:
|
||||||
|
result.update(status="timeout", label="Timeout", detail=f"> {SERVICE_CHECK_TIMEOUT:g}s")
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
result.update(status="offline", label="Offline", detail=exc.__class__.__name__)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def refresh_services():
|
||||||
|
futures = [service_check_executor.submit(check_service, service) for service in SERVICE_DEFINITIONS]
|
||||||
|
results = [future.result() for future in futures]
|
||||||
|
with service_status_lock:
|
||||||
|
service_status.update({result["key"]: result for result in results})
|
||||||
|
|
||||||
|
def service_monitor():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
refresh_services()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Dienststatus konnte nicht aktualisiert werden")
|
||||||
|
time.sleep(max(5, SERVICE_REFRESH_SECONDS))
|
||||||
|
|
||||||
|
threading.Thread(target=service_monitor, name="service-monitor", daemon=True).start()
|
||||||
|
|
||||||
|
@app.get("/api/services")
|
||||||
|
def services():
|
||||||
|
with service_status_lock:
|
||||||
|
values = list(service_status.values())
|
||||||
|
online = sum(1 for service in values if service["status"] == "online")
|
||||||
|
return jsonify({"services": values, "online": online, "total": len(values), "updated_at": time.time()})
|
||||||
|
|
||||||
def sab_get(mode, **params):
|
def sab_get(mode, **params):
|
||||||
if not SAB_URL or not SAB_API_KEY:
|
if not SAB_URL or not SAB_API_KEY:
|
||||||
raise RuntimeError("SAB_URL oder SAB_API_KEY ist nicht gesetzt.")
|
raise RuntimeError("SAB_URL oder SAB_API_KEY ist nicht gesetzt.")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099"
|
- "8099:8099"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<div class="settings-backdrop hidden" id="settingsBackdrop"><section class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle"><div class="settings-head"><div><h2 id="settingsTitle" data-de="Einstellungen" data-en="Settings">Einstellungen</h2><p data-de="Deine Anzeige- und Sprachpräferenzen" data-en="Your display and language preferences">Deine Anzeige- und Sprachpräferenzen</p></div><button class="settings-close" id="settingsClose" aria-label="Schließen">×</button></div><label for="preferredAudio" data-de="Bevorzugte Audio-Sprache" data-en="Preferred audio language">Bevorzugte Audio-Sprache</label><select id="preferredAudio"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><label for="preferredSubs" data-de="Bevorzugte Untertitel-Sprache" data-en="Preferred subtitle language">Bevorzugte Untertitel-Sprache</label><select id="preferredSubs"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><div class="settings-actions"><button id="settingsCancel" data-de="Abbrechen" data-en="Cancel">Abbrechen</button><button class="settings-save" id="settingsSave" data-de="Speichern" data-en="Save">Speichern</button></div></section></div>
|
<div class="settings-backdrop hidden" id="settingsBackdrop"><section class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle"><div class="settings-head"><div><h2 id="settingsTitle" data-de="Einstellungen" data-en="Settings">Einstellungen</h2><p data-de="Deine Anzeige- und Sprachpräferenzen" data-en="Your display and language preferences">Deine Anzeige- und Sprachpräferenzen</p></div><button class="settings-close" id="settingsClose" aria-label="Schließen">×</button></div><label for="preferredAudio" data-de="Bevorzugte Audio-Sprache" data-en="Preferred audio language">Bevorzugte Audio-Sprache</label><select id="preferredAudio"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><label for="preferredSubs" data-de="Bevorzugte Untertitel-Sprache" data-en="Preferred subtitle language">Bevorzugte Untertitel-Sprache</label><select id="preferredSubs"><option value="" data-de="Keine Präferenz" data-en="No preference">Keine Präferenz</option><option value="Deutsch" data-de="Deutsch" data-en="German">Deutsch</option><option value="Englisch" data-de="Englisch" data-en="English">Englisch</option><option value="Japanisch" data-de="Japanisch" data-en="Japanese">Japanisch</option><option value="Koreanisch" data-de="Koreanisch" data-en="Korean">Koreanisch</option></select><div class="settings-actions"><button id="settingsCancel" data-de="Abbrechen" data-en="Cancel">Abbrechen</button><button class="settings-save" id="settingsSave" data-de="Speichern" data-en="Save">Speichern</button></div></section></div>
|
||||||
<script>
|
<script>
|
||||||
const dashboardView=document.querySelector('#dashboard');if(dashboardView){dashboardView.classList.add('dashboard-view');const stats={{ dashboard_stats|tojson }};const insights=document.createElement('div');insights.className='dashboard-insights';insights.innerHTML=`<div class="insight-card"><span class="insight-icon">▣</span><div><strong>${stats.movie_files} von ${stats.movie_total} Filmen vorhanden</strong><small>${stats.movie_missing} fehlen · ${stats.movie_downloading} werden geladen</small></div></div><div class="insight-card"><span class="insight-icon">▤</span><div><strong>${stats.episode_files} von ${stats.episode_total} Episoden vorhanden</strong><small>${stats.episode_missing} fehlen in ${stats.series_total} Serien</small></div></div><div class="insight-card"><span class="insight-icon">◌</span><div><strong>Medienbestand geprüft</strong><small>Audio, Untertitel und Qualität werden überwacht</small></div></div><div class="insight-card"><span class="insight-icon">✓</span><div><strong>Live-Überwachung aktiv</strong><small>Systemwerte und Downloads aktualisieren sich automatisch</small></div></div>`;const columns=dashboardView.querySelector('.dashboard-columns');if(columns)dashboardView.insertBefore(insights,columns)}
|
const dashboardView=document.querySelector('#dashboard');if(dashboardView){dashboardView.classList.add('dashboard-view');const stats={{ dashboard_stats|tojson }};const insights=document.createElement('div');insights.className='dashboard-insights';insights.innerHTML=`<div class="insight-card"><span class="insight-icon">▣</span><div><strong>${stats.movie_files} von ${stats.movie_total} Filmen vorhanden</strong><small>${stats.movie_missing} fehlen · ${stats.movie_downloading} werden geladen</small></div></div><div class="insight-card"><span class="insight-icon">▤</span><div><strong>${stats.episode_files} von ${stats.episode_total} Episoden vorhanden</strong><small>${stats.episode_missing} fehlen in ${stats.series_total} Serien</small></div></div><div class="insight-card"><span class="insight-icon">◌</span><div><strong>Medienbestand geprüft</strong><small>Audio, Untertitel und Qualität werden überwacht</small></div></div><div class="insight-card"><span class="insight-icon">✓</span><div><strong>Live-Überwachung aktiv</strong><small>Systemwerte und Downloads aktualisieren sich automatisch</small></div></div>`;const columns=dashboardView.querySelector('.dashboard-columns');if(columns)dashboardView.insertBefore(insights,columns)}
|
||||||
|
const servicePanel=document.querySelector('.service-panel');let serviceList=null;const serviceSummary=document.createElement('span');serviceSummary.className='service-summary';if(servicePanel){servicePanel.querySelectorAll('.service-row').forEach(row=>row.remove());serviceList=document.createElement('div');serviceList.className='service-list';servicePanel.appendChild(serviceList);const heading=servicePanel.querySelector('.panel-heading');if(heading)heading.appendChild(serviceSummary)}function renderServices(data){if(!serviceList)return;const values=data.services||[];serviceSummary.textContent=`${data.online||0} / ${data.total||values.length} online`;serviceList.innerHTML=values.map(service=>`<div class="service-row service-${service.status}"><span class="service-logo service-icon-${service.key}">${service.key==='dashboard'?'⌂':service.name.slice(0,1)}</span><div><strong>${html(service.name)}</strong><small>${html(service.detail||'Keine Antwort')}</small></div><span class="service-status ${service.status}">${html(service.label)}</span></div>`).join('')}async function fetchServices(){try{renderServices(await (await fetch('/api/services')).json())}catch(error){serviceSummary.textContent='Status nicht verfügbar'}}if(servicePanel){fetchServices();setInterval(fetchServices,30000)}
|
||||||
const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
||||||
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
|
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
|
||||||
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');const rescan=document.querySelector('#rescan'),sourceSelect=document.querySelector('#scanSource'),canRescan=['radarr','sonarr'].includes(b.dataset.view);if(canRescan)sourceSelect.value=b.dataset.view;rescan.disabled=false;rescan.title='Bibliothek neu scannen';sessionStorage.setItem('dashboard-active-view',b.dataset.view)});const rememberedView=sessionStorage.getItem('dashboard-active-view');if(rememberedView)document.querySelector(`.nav button[data-view="${rememberedView}"]`)?.click();
|
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');const rescan=document.querySelector('#rescan'),sourceSelect=document.querySelector('#scanSource'),canRescan=['radarr','sonarr'].includes(b.dataset.view);if(canRescan)sourceSelect.value=b.dataset.view;rescan.disabled=false;rescan.title='Bibliothek neu scannen';sessionStorage.setItem('dashboard-active-view',b.dataset.view)});const rememberedView=sessionStorage.getItem('dashboard-active-view');if(rememberedView)document.querySelector(`.nav button[data-view="${rememberedView}"]`)?.click();
|
||||||
@@ -48,7 +49,7 @@ if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.ser
|
|||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
#dashboard.view.active{display:flex}.view.dashboard-view:not(.active){display:none}.meter:first-child .meter-top span::before{content:'◔';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(96,169,230,.18);color:#8bc9f2;font-size:13px}.meter:nth-child(2) .meter-top span::before{content:'▤';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(146,87,194,.2);color:#d7adf3;font-size:13px}.disk-name::before{content:'▰';display:grid;grid-column:1;grid-row:1 / span 2;place-items:center;width:20px;height:20px;margin:0;border-radius:6px;background:rgba(244,196,48,.16);color:#f4c430;font-size:11px}.meter-top span,.disk-name{display:flex;align-items:center}.disk-name{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:92px;line-height:1.1}.disk-name:not(:has(.disk-path)){grid-template-rows:auto}.disk-name .disk-path{grid-column:2;grid-row:2;white-space:nowrap}.disk-name .disk-path:empty{display:none}
|
#dashboard.view.active{display:flex}.view.dashboard-view:not(.active){display:none}.meter:first-child .meter-top span::before{content:'◔';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(96,169,230,.18);color:#8bc9f2;font-size:13px}.meter:nth-child(2) .meter-top span::before{content:'▤';display:inline-grid;place-items:center;width:20px;height:20px;margin-right:7px;border-radius:6px;background:rgba(146,87,194,.2);color:#d7adf3;font-size:13px}.disk-name::before{content:'▰';display:grid;grid-column:1;grid-row:1 / span 2;place-items:center;width:20px;height:20px;margin:0;border-radius:6px;background:rgba(244,196,48,.16);color:#f4c430;font-size:11px}.meter-top span,.disk-name{display:flex;align-items:center}.disk-name{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:92px;line-height:1.1}.disk-name:not(:has(.disk-path)){grid-template-rows:auto}.disk-name .disk-path{grid-column:2;grid-row:2;white-space:nowrap}.disk-name .disk-path:empty{display:none}
|
||||||
</style><style>.nav-count.download-nav-count{background:#f4c430!important;border-color:#ffd95a!important;color:#17191d!important;box-shadow:0 0 10px rgba(244,196,48,.28)}</style></body></html>
|
</style><style>.nav-count.download-nav-count{background:#f4c430!important;border-color:#ffd95a!important;color:#17191d!important;box-shadow:0 0 10px rgba(244,196,48,.28)}.service-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px}.service-summary{margin-left:auto;color:#9da5b2;font-size:12px;font-weight:700}.service-row{min-width:0}.service-row strong,.service-row small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-status.timeout,.service-status.error{background:#806b25;color:#fff3bd}.service-status.offline{background:#7a3030;color:#ffd9d9}.service-timeout .service-logo,.service-error .service-logo{background:#806b25!important}.service-offline .service-logo{background:#7a3030!important}@media(max-width:700px){.service-list{grid-template-columns:1fr}.service-summary{font-size:10px}}</style></body></html>
|
||||||
<style>
|
<style>
|
||||||
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
|
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
|
||||||
details.series:last-child{margin-bottom:0}
|
details.series:last-child{margin-bottom:0}
|
||||||
|
|||||||
Reference in New Issue
Block a user