feat: add human-readable RAID recovery formatting with German time units and improved member detection from mdstat

Add format_transfer_rate() to convert mdstat rates (e.g., 133628K/sec) into readable values like "130.5 MB/s" using 1024-based units. Add format_remaining_time() to convert mdstat durations (e.g., 450.1min) into compact German format like "7 Std. 30 Min." with day/hour/minute components. Update raid_status() to parse member devices directly from array_line in /proc/mdstat to avoid cross
This commit is contained in:
2026-08-16 12:10:17 +02:00
parent 5ddf506f20
commit 0c922d4f5d
3 changed files with 67 additions and 9 deletions
+60 -4
View File
@@ -240,6 +240,45 @@ def _flatten_lsblk(nodes):
result.extend(_flatten_lsblk(node.get("children")))
return result
def format_transfer_rate(value):
"""Convert mdstat rates such as 133628K/sec into a readable value."""
if not value or value == "":
return ""
match = re.match(r"([\d.]+)\s*([KMGTP]?)(?:B)?/sec", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).upper()
multiplier = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4, "P": 1024 ** 5}.get(unit, 1)
bytes_per_second = amount * multiplier
units = ((1024 ** 3, "GB/s"), (1024 ** 2, "MB/s"), (1024, "KB/s"))
for threshold, label in units:
if bytes_per_second >= threshold:
return f"{bytes_per_second / threshold:.1f} {label}"
return f"{bytes_per_second:.0f} B/s"
def format_remaining_time(value):
"""Convert mdstat values such as 450.1min into a compact German duration."""
if not value or value == "":
return ""
match = re.match(r"([\d.]+)(min|sec|hours?|days?)", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).lower()
total_minutes = amount / 60 if unit.startswith("sec") else amount if unit.startswith("min") else amount * 60 if unit.startswith("hour") else amount * 1440
total_minutes = max(0, round(total_minutes))
days, remainder = divmod(total_minutes, 1440)
hours, minutes = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days} T")
if hours or days:
parts.append(f"{hours} Std.")
if minutes and len(parts) < 2:
parts.append(f"{minutes} Min.")
return " ".join(parts) or "< 1 Min."
def raid_status():
mdstat, mdstat_error = "", ""
try:
@@ -249,7 +288,15 @@ def raid_status():
array_name = Path(RAID_DEVICE).name
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
profile_match = re.search(r"(raid\d+)", array_line)
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", mdstat)
# Match the member count on this RAID's own line. Searching all of
# /proc/mdstat can accidentally pick a different array's [x/y] value.
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", array_line)
mdstat_members = []
for member_name, slot in re.findall(r"\b([A-Za-z0-9][A-Za-z0-9._-]*)\[(\d+)\]", array_line):
member_suffix = re.search(rf"\b{re.escape(member_name)}\[{re.escape(slot)}\]\(([^)]+)\)", array_line)
member_state = member_suffix.group(1).lower() if member_suffix else "active"
member_status = "failed" if "f" in member_state else ("spare" if "s" in member_state else "active")
mdstat_members.append({"device": f"/dev/{member_name}", "slot": slot, "state": member_state, "status": member_status})
progress_match = re.search(r"(?:recovery|resync|reshape|check)\s*=\s*([\d.]+)%", mdstat, re.IGNORECASE)
finish_match = re.search(r"finish=([^\s]+)", mdstat)
speed_match = re.search(r"speed=([^\s]+)", mdstat)
@@ -272,6 +319,10 @@ def raid_status():
if match:
state_text, device = match.group(2), match.group(3)
devices.append({"device": device, "slot": match.group(1), "state": state_text, "status": "active" if "active" in state_text.lower() else ("spare" if "spare" in state_text.lower() else "failed")})
known_devices = {Path(disk["device"]).name for disk in devices}
for disk in mdstat_members:
if Path(disk["device"]).name not in known_devices:
devices.append(disk)
lsblk_output, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH"])
try:
block_devices = {item.get("path") or f"/dev/{item.get('name')}": item for item in _flatten_lsblk(json.loads(lsblk_output).get("blockdevices", []))}
@@ -308,11 +359,16 @@ def raid_status():
status = "ok"
else:
status = "failed"
mdstat_active = int(members_match.group(1)) if members_match else 0
mdstat_total = int(members_match.group(2)) if members_match else 0
active_devices = mdstat_active or detail.get("active_devices", 0)
raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices)
failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices)
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
"level": detail.get("level", ""), "raid_devices": detail.get("raid_devices") or (int(members_match.group(2)) if members_match else 0),
"active_devices": detail.get("active_devices") or (int(members_match.group(1)) if members_match else 0), "failed_devices": detail.get("failed_devices", 0),
"level": detail.get("level", ""), "raid_devices": raid_devices,
"active_devices": active_devices, "failed_devices": failed_devices,
"devices": devices, "recovery": {"active": rebuilding, "percent": float(progress_match.group(1)) if progress_match else 0,
"finish": finish_match.group(1) if finish_match else "", "speed": speed_match.group(1) if speed_match else ""},
"finish": format_remaining_time(finish_match.group(1)) if finish_match else "", "speed": format_transfer_rate(speed_match.group(1)) if speed_match else ""},
"error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()}
def datastore_snapshot():
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'media-dashboard-shell-v1';
const CACHE_NAME = 'media-max-shell-v2';
const SHELL = ['/static/media-mark.svg', '/static/manifest.json'];
self.addEventListener('install', event => {
+6 -4
View File
@@ -1,4 +1,5 @@
<!doctype html>
<script>document.addEventListener('DOMContentLoaded',()=>{const icons={total:'mdi:harddisk',used:'mdi:database',free:'mdi:database-outline',percent:'mdi:chart-donut'};document.querySelectorAll('.storage-card .storage-icon').forEach((node,index)=>{const key=['total','used','free','percent'][index];if(!key)return;const image=document.createElement('img');image.src=`https://api.iconify.design/${icons[key]}.svg?color=${key==='used'?'%23f4d267':key==='free'?'%236ae29a':key==='percent'?'%23c59cff':'%2360a9e6'}`;image.alt='';image.setAttribute('aria-hidden','true');node.textContent='';node.appendChild(image)})});</script>
<script>document.addEventListener('DOMContentLoaded',()=>{if(!document.querySelector('link[rel="shortcut icon"]')){const favicon=document.createElement('link');favicon.rel='shortcut icon';favicon.type='image/svg+xml';favicon.href="{{ url_for('static', filename='media-mark.svg') }}?v=media-max";document.head.appendChild(favicon)}});</script>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#1f252d"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes"><link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"><link rel="apple-touch-icon" href="{{ url_for('static', filename='media-mark.svg') }}"><link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='media-mark.svg') }}"><title>Media Max</title>
<style>
@@ -14,7 +15,7 @@
<header><div class="brand"><div class="brand-mark" aria-label="Media Max"><svg viewBox="0 0 48 48" role="img"><path d="M10 8h28a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z" fill="#20252d" stroke="#60a9e6" stroke-width="2"/><path d="M14 17h20M14 24h13M14 31h20" stroke="#f4c430" stroke-width="3" stroke-linecap="round"/><circle cx="34" cy="24" r="5" fill="#159447" stroke="#c9ffe0" stroke-width="1.5"/></svg></div><div class="brand-name">MEDIA</div></div><div><div class="head-title" data-de="Media Max" data-en="Media Max">Media Max</div><div class="head-sub" data-de="Audio- und Untertitelspuren aus den Mediendateien" data-en="Audio and subtitle tracks from media files">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button class="lang-switch" id="languageToggle" title="Sprache / Language">DE</button><button class="settings-button" id="settingsOpen" title="Einstellungen"></button><div class="scan-control"><select id="scanSource" aria-label="Scan-Quelle"><option value="all">Alles scannen</option><option value="radarr">Nur Radarr</option><option value="sonarr">Nur Sonarr</option></select><button id="rescan" data-de="↻ Neu scannen" data-en="↻ Rescan">↻ Neu scannen</button></div></div></header>
<main>
{% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
<nav class="nav"><button class="active" data-view="dashboard">▦ Dashboard</button><button data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}<button data-view="missing">⚠ Ohne Deutsch <span class="nav-count" id="missingNavCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</span></button>{% if sab_enabled %}<button data-view="downloads">SABNZBD · Downloads <span class="nav-count download-nav-count hidden" id="downloadNavCount">0</span></button>{% endif %}</nav>
<nav class="nav"><button class="active" data-view="dashboard">▦ Dashboard</button><button data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}<button data-view="missing">⚠ Ohne Deutsch <span class="nav-count" id="missingNavCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</span></button>{% if sab_enabled %}<button data-view="downloads">SABNZBD · Downloads <span class="nav-count download-nav-count hidden" id="downloadNavCount">0</span></button>{% endif %}<button data-view="datastore"><img class="nav-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true"><span data-de="Datastore" data-en="Datastore">Datastore</span></button></nav>
<section id="dashboard" class="view active" data-source="dashboard"><div class="dashboard-hero"><div><div class="eyebrow">MEDIA MAX</div><h1>Dashboard</h1><p>Deine gesamte Medienbibliothek und Systemauslastung auf einen Blick.</p></div><div class="live-pill"><span class="live-dot"></span> Live</div></div><div class="overview-grid"><div class="overview-card"><span class="overview-icon movie"></span><div><span>Filme</span><strong>{{ rows|length }}</strong><small>Radarr Library</small></div></div><div class="overview-card"><span class="overview-icon series-icon"></span><div><span>Serien</span><strong>{{ series|length }}</strong><small>Sonarr Shows</small></div></div><div class="overview-card"><span class="overview-icon warning"></span><div><span>Ohne Deutsch</span><strong id="dashboardMissing">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }}</strong><small>Audio oder Untertitel prüfen</small></div></div><div class="overview-card"><span class="overview-icon download-icon"></span><div><span>Downloads</span><strong id="dashboardDownloads"></strong><small id="dashboardRemaining">SABnzbd wird geladen …</small></div></div></div><div class="dashboard-columns"><section class="system-panel"><div class="panel-heading"><div><h2>Systemauslastung</h2><p>Aktuelle Werte aus dem Dashboard-Container</p></div><span id="systemUpdated" class="muted">Wird geladen …</span></div><div class="system-meters"><div class="meter"><div class="meter-top"><span>CPU</span><strong id="cpuValue"></strong></div><div class="meter-track"><span id="cpuBar"></span></div></div><div class="meter"><div class="meter-top"><span>RAM</span><strong id="ramValue"></strong></div><div class="meter-track"><span id="ramBar"></span></div></div></div><div id="diskList" class="disk-list"><div class="disk-row muted">Laufwerke werden geladen …</div></div></section><section class="service-panel"><div class="panel-heading"><div><h2>Dienste</h2><p>Verbindung und Bibliotheksstatus</p></div></div><div class="service-row"><span class="service-logo radarr-dot">R</span><div><strong>Radarr</strong><small>Filme & Qualität</small></div><span class="service-status online">Aktiv</span></div><div class="service-row"><span class="service-logo sonarr-dot">S</span><div><strong>Sonarr</strong><small>Serien & Episoden</small></div><span class="service-status {% if sonarr_enabled %}online{% else %}offline{% endif %}">{% if sonarr_enabled %}Aktiv{% else %}Nicht konfiguriert{% endif %}</span></div><div class="service-row"><span class="service-logo sab-dot"></span><div><strong>SABnzbd</strong><small>Downloads & Queue</small></div><span id="dashboardSabStatus" class="service-status {% if sab_enabled %}online{% else %}offline{% endif %}">{% if sab_enabled %}Wird geprüft …{% else %}Nicht konfiguriert{% endif %}</span></div></section></div></section>
<section id="radarr" class="view" data-source="radarr">
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
@@ -36,7 +37,7 @@
<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 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 navigation=document.querySelector('.nav');if(navigation&&!navigation.querySelector('[data-view="datastore"]')){const datastoreButton=document.createElement('button');datastoreButton.dataset.view='datastore';datastoreButton.dataset.de='▣ Datastore';datastoreButton.dataset.en='▣ Datastore';datastoreButton.textContent='▣ Datastore';navigation.appendChild(datastoreButton)}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 navigation=document.querySelector('.nav');if(navigation&&!navigation.querySelector('[data-view="datastore"]')){const datastoreButton=document.createElement('button');datastoreButton.dataset.view='datastore';datastoreButton.innerHTML='<img class="nav-icon" src="https://api.iconify.design/mdi:harddisk.svg?color=%2360a9e6" alt="" aria-hidden="true"><span data-de="Datastore" data-en="Datastore">Datastore</span>';navigation.appendChild(datastoreButton)}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();
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();
function statusText(s){if(s.state==='running'){const pct=s.total?Math.round(s.done/s.total*100):0;return `Scan läuft · ${s.done}/${s.total||'?'} Dateien · ${pct}%`}if(s.state==='done')return 'Scan abgeschlossen ✓';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
@@ -50,7 +51,7 @@ let autoScanTimer=null;function configureAutoScan(){if(autoScanTimer)clearInterv
const seasonZeroLabel=document.createElement('label');seasonZeroLabel.className='setting-toggle';seasonZeroLabel.htmlFor='showSeasonZero';seasonZeroLabel.innerHTML='<input type="checkbox" id="showSeasonZero"><span>Staffel 0 anzeigen</span>';const seasonZeroHelp=document.createElement('span');seasonZeroHelp.className='setting-help';seasonZeroHelp.textContent='Gilt für Sonarr und die Liste Ohne Deutsch.';if(settingsActions){settingsActions.before(seasonZeroLabel,seasonZeroHelp)}
function applySeasonZeroPreference(){const show=localStorage.getItem('show-season-zero')==='true';const rows=[...document.querySelectorAll('#sonarr tbody tr:not(.season-divider),#missing .missing-series tbody tr:not(.season-divider)')];rows.forEach(row=>{const title=row.querySelector('.title')?.textContent.trim()||'';row.classList.toggle('season-zero-hidden',!show&&/^S00E/i.test(title))});document.querySelectorAll('#sonarr details.series,#missing details.missing-series').forEach(detail=>{const episodeRows=[...detail.querySelectorAll('tbody tr:not(.season-divider)')];detail.classList.toggle('season-zero-empty',episodeRows.length>0&&episodeRows.every(row=>row.classList.contains('season-zero-hidden')))});const missingCount=document.querySelector('#missingCount'),missingNavCount=document.querySelector('#missingNavCount');if(missingCount||missingNavCount){const movies=document.querySelectorAll('#missingMovies tbody tr:not(.hidden)').length,episodes=document.querySelectorAll('#missing .missing-series tbody tr:not(.season-divider):not(.season-zero-hidden)').length,total=movies+episodes;if(missingCount)missingCount.textContent=`${total} Einträge ohne Deutsch`;if(missingNavCount)missingNavCount.textContent=total}const hint=document.querySelector('#seasonZeroHint');if(hint)hint.textContent=show?'Vorhanden oder wird gerade geladen · Staffel 0 eingeblendet':'Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet'}const seasonZeroToggle=document.querySelector('#showSeasonZero');if(seasonZeroToggle){seasonZeroToggle.checked=localStorage.getItem('show-season-zero')==='true';seasonZeroToggle.addEventListener('change',()=>{localStorage.setItem('show-season-zero',seasonZeroToggle.checked?'true':'false');applySeasonZeroPreference()})}applySeasonZeroPreference();
function syncDashboardMissingCounter(){const visibleMissing=document.querySelectorAll('#missingMovies tbody tr:not(.hidden),#missing .missing-series tbody tr:not(.season-zero-hidden):not(.season-divider)').length;const dashboardMissing=document.querySelector('#dashboardMissing');if(dashboardMissing)dashboardMissing.textContent=visibleMissing}syncDashboardMissingCounter();document.querySelector('#showSeasonZero')?.addEventListener('change',()=>setTimeout(syncDashboardMissingCounter,0));
if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('/static/sw.js').catch(()=>{}));
if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.serviceWorker.register('/static/sw.js?v=2').catch(()=>{}));
function updateMissingHeading(){const heading=document.querySelector('#missingMovies thead th:first-child');if(heading)heading.textContent=document.documentElement.lang==='en'?'Media':'Medien'}updateMissingHeading();document.querySelector('#languageToggle')?.addEventListener('click',()=>setTimeout(updateMissingHeading,0));
function addMissingSections(){const view=document.querySelector('#missing'),movieTable=document.querySelector('#missingMovies'),seriesItems=[...document.querySelectorAll('#missing .missing-series')];if(!view)return;const makeHeading=(type,de,en)=>{const heading=document.createElement('div');heading.className=`missing-section-heading ${type}`;heading.dataset.de=de;heading.dataset.en=en;heading.textContent=document.documentElement.lang==='en'?en:de;return heading};if(movieTable&&movieTable.querySelector('tbody tr'))movieTable.before(makeHeading('movies','Filme ohne Deutsch','Movies without German'));if(seriesItems.length)seriesItems[0].before(makeHeading('series','Serien ohne Deutsch','Series without German'))}addMissingSections();
</script>
@@ -104,7 +105,7 @@ const diskList=document.querySelector('#diskList');
function renderSystem(data){const cpu=Number(data.cpu_percent)||0,ram=data.memory||{};const cpuValue=document.querySelector('#cpuValue'),ramValue=document.querySelector('#ramValue'),cpuBar=document.querySelector('#cpuBar'),ramBar=document.querySelector('#ramBar'),updated=document.querySelector('#systemUpdated');if(cpuValue)cpuValue.textContent=`${cpu.toFixed(1)}%`;if(ramValue)ramValue.textContent=`${ram.used||'—'} / ${ram.total||'—'} (${Number(ram.percent||0).toFixed(1)}%)`;if(cpuBar)cpuBar.style.width=`${Math.min(100,cpu)}%`;if(ramBar)ramBar.style.width=`${Math.min(100,Number(ram.percent)||0)}%`;if(updated)updated.textContent='Gerade aktualisiert';if(!diskList)return;diskList.innerHTML=(data.disks||[]).map(d=>`<div class="disk-row"><div class="disk-name">${html(d.label)}<span class="disk-path">${html(d.path)}</span></div><div class="disk-bar"><span style="width:${Math.min(100,Number(d.percent)||0)}%"></span></div><div class="disk-values">${html(d.used)} / ${html(d.total)}<br><span class="muted">${Number(d.percent||0).toFixed(1)}% belegt</span></div></div>`).join('')||'<div class="disk-row muted">Keine Laufwerke gefunden</div>'}
async function fetchSystem(){try{const response=await fetch('/api/system');renderSystem(await response.json())}catch(error){const updated=document.querySelector('#systemUpdated');if(updated)updated.textContent='Nicht verfügbar'}}
if(document.querySelector('#dashboard')){fetchSystem();setInterval(fetchSystem,5000)}
const datastoreView=document.querySelector('#datastore');function renderDatastore(data){const storage=data.storage||{},raid=data.raid||{};const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#datastoreTotal',storage.total||'—');set('#datastoreUsed',storage.used||'—');set('#datastoreFree',storage.free||'—');set('#datastorePercent',storage.percent!=null?`${storage.percent}%`:'—');set('#datastorePath',storage.path||'/nesflix');set('#datastoreCapacityLabel',storage.percent!=null?`${storage.used||'—'} / ${storage.total||'—'} · ${storage.percent}%`:storage.error||'Nicht verfügbar');set('#datastoreUpdated',storage.scanned_at?'Gerade aktualisiert':'Nicht verfügbar');const capacityBar=document.querySelector('#datastoreCapacityBar');if(capacityBar)capacityBar.style.width=`${Math.min(100,Number(storage.percent)||0)}%`;const categories=document.querySelector('#datastoreCategories');if(categories){const values=(storage.categories||[]).filter(category=>category.bytes>0||['movies','series','anime','kdrama','downloads'].includes(category.key));categories.innerHTML=values.length?values.map(category=>`<div class="category-row"><div class="category-row-head"><span>${html(category.label)}</span><strong>${html(category.size||'0 B')}</strong></div><div class="category-track"><span style="width:${Math.min(100,Number(category.percent)||0)}%"></span></div><small>${Number(category.percent||0).toFixed(1)}% vom belegten Speicher · ${Number(category.files||0).toLocaleString('de-DE')} Dateien</small></div>`).join(''):'<div class="datastore-empty">Keine Kategorien gefunden</div>'}const badge=document.querySelector('#raidStatusBadge');if(badge){badge.textContent=raid.label||'UNKNOWN';badge.className=`raid-status ${raid.status||'unknown'}`}set('#raidDevice',raid.device||'/dev/md127');const summary=document.querySelector('#raidSummary');if(summary)summary.innerHTML=`<span><small>Level</small><strong>${html(raid.level||'—')}</strong></span><span><small>Aktiv</small><strong>${html(raid.active_devices??'—')} / ${html(raid.raid_devices??'—')}</strong></span><span><small>Fehlend</small><strong>${html(raid.failed_devices??0)}</strong></span>`;const recovery=raid.recovery||{},recoveryBox=document.querySelector('#raidRecovery');if(recoveryBox){recoveryBox.classList.toggle('hidden',!recovery.active);set('#raidRecoveryPercent',`${Number(recovery.percent||0).toFixed(1)}%`);set('#raidRecoverySpeed',recovery.speed||'—');set('#raidRecoveryFinish',recovery.finish||'—');const recoveryBar=document.querySelector('#raidRecoveryBar');if(recoveryBar)recoveryBar.style.width=`${Math.min(100,Number(recovery.percent)||0)}%`}const disks=document.querySelector('#raidDisks');if(disks){disks.innerHTML=(raid.devices||[]).length?(raid.devices||[]).map(disk=>`<div class="raid-disk"><span class="disk-status ${html(disk.status||'unknown')}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot||'—')} · ${html(disk.model||'—')}</small><small>${html(disk.size||'—')} · Serial: ${html(disk.serial||'')}</small></div><b>${html(disk.status||'unknown')}</b></div>`).join(''):'<div class="datastore-empty">Keine RAID-Mitglieder erkannt</div>'}if(raid.error){const node=document.querySelector('#raidDisks .datastore-empty');if(node)node.textContent=raid.error}}async function fetchDatastore(){if(!datastoreView)return;try{renderDatastore(await (await fetch('/api/datastore')).json())}catch(error){renderDatastore({storage:{error:'Datastore nicht erreichbar'},raid:{status:'failed',label:'FAILED',error:'RAID-Status nicht verfügbar'}})}}if(datastoreView){fetchDatastore();setInterval(fetchDatastore,5000)}
const datastoreView=document.querySelector('#datastore');function renderDatastore(data){const storage=data.storage||{},raid=data.raid||{};const set=(id,value)=>{const node=document.querySelector(id);if(node)node.textContent=value};set('#datastoreTotal',storage.total||'—');set('#datastoreUsed',storage.used||'—');set('#datastoreFree',storage.free||'—');set('#datastorePercent',storage.percent!=null?`${storage.percent}%`:'—');set('#datastorePath',storage.path||'/nesflix');set('#datastoreCapacityLabel',storage.percent!=null?`${storage.used||'—'} / ${storage.total||'—'} · ${storage.percent}%`:storage.error||'Nicht verfügbar');set('#datastoreUpdated',storage.scanned_at?'Gerade aktualisiert':'Nicht verfügbar');const capacityBar=document.querySelector('#datastoreCapacityBar');if(capacityBar)capacityBar.style.width=`${Math.min(100,Number(storage.percent)||0)}%`;const categories=document.querySelector('#datastoreCategories');if(categories){const values=(storage.categories||[]).filter(category=>category.bytes>0||['movies','series','anime','kdrama','downloads'].includes(category.key));categories.innerHTML=values.length?values.map(category=>`<div class="category-row"><div class="category-row-head"><span>${html(category.label)}</span><strong>${html(category.size||'0 B')}</strong></div><div class="category-track"><span style="width:${Math.min(100,Number(category.percent)||0)}%"></span></div><small>${Number(category.percent||0).toFixed(1)}% vom belegten Speicher · ${Number(category.files||0).toLocaleString('de-DE')} Dateien</small></div>`).join(''):'<div class="datastore-empty">Keine Kategorien gefunden</div>'}const badge=document.querySelector('#raidStatusBadge');if(badge){badge.textContent=raid.label||'UNKNOWN';badge.className=`raid-status ${raid.status||'unknown'}`}set('#raidDevice',raid.device||'/dev/md127');const summary=document.querySelector('#raidSummary');if(summary)summary.innerHTML=`<span><small>Level</small><strong>${html(raid.level||'—')}</strong></span><span><small>Aktiv</small><strong>${html(raid.active_devices??'—')} / ${html(raid.raid_devices??'—')}</strong></span><span><small>Fehlend</small><strong>${html(raid.failed_devices??0)}</strong></span>`;const recovery=raid.recovery||{},recoveryBox=document.querySelector('#raidRecovery');if(recoveryBox){recoveryBox.classList.toggle('hidden',!recovery.active);set('#raidRecoveryPercent',`${Number(recovery.percent||0).toFixed(1)}%`);set('#raidRecoverySpeed',recovery.speed||'—');set('#raidRecoveryFinish',recovery.finish||'—');const recoveryBar=document.querySelector('#raidRecoveryBar');if(recoveryBar)recoveryBar.style.width=`${Math.min(100,Number(recovery.percent)||0)}%`}const disks=document.querySelector('#raidDisks');if(disks){disks.innerHTML=(raid.devices||[]).length?(raid.devices||[]).map(disk=>`<div class="raid-disk"><span class="disk-status ${html(disk.status||'unknown')}"></span><div><strong>${html(disk.device)}</strong><small>Slot ${html(disk.slot||'—')} · ${html(disk.model||'—')}</small><small>${html(disk.size||'—')} · Seriennummer: ${html(disk.serial||'—')}</small><small class="raid-smart">SMART: ${html(disk.smart||'Nicht aktiviert')}</small></div><b>${html(disk.status||'unknown')}</b></div>`).join(''):'<div class="datastore-empty">Keine RAID-Mitglieder erkannt</div>'}if(raid.error){const node=document.querySelector('#raidDisks .datastore-empty');if(node)node.textContent=raid.error}}async function fetchDatastore(){if(!datastoreView)return;try{renderDatastore(await (await fetch('/api/datastore')).json())}catch(error){renderDatastore({storage:{error:'Datastore nicht erreichbar'},raid:{status:'failed',label:'FAILED',error:'RAID-Status nicht verfügbar'}})}}if(datastoreView){fetchDatastore();setInterval(fetchDatastore,5000)}
</script>
<style>
.season-divider td{padding:6px 10px;background:#1b1b1b;border-top:1px solid #444;border-bottom:1px solid #303030;color:#aaa;font-size:11px;font-weight:700;letter-spacing:.3px}
@@ -194,3 +195,4 @@ document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20
setInterval(()=>{document.querySelectorAll('.service-logo svg').forEach(svg=>{svg.style.width='19px';svg.style.height='19px';svg.style.fill='none';svg.style.stroke='currentColor';svg.style.strokeWidth='1.8';svg.style.strokeLinecap='round';svg.style.strokeLinejoin='round'});document.querySelectorAll('.service-logo img').forEach(img=>{img.style.width='20px';img.style.height='20px';img.style.objectFit='contain'})},500);
const settingsBackdrop=document.querySelector('#settingsBackdrop');const closeSettings=()=>settingsBackdrop.classList.add('hidden');document.querySelector('#settingsOpen').addEventListener('click',()=>{loadPreferences();settingsBackdrop.classList.remove('hidden')});document.querySelector('#settingsClose').addEventListener('click',closeSettings);document.querySelector('#settingsCancel').addEventListener('click',closeSettings);settingsBackdrop.addEventListener('click',event=>{if(event.target===settingsBackdrop)closeSettings()});document.querySelector('#settingsSave').addEventListener('click',()=>{localStorage.setItem('preferred-audio',document.querySelector('#preferredAudio').value);localStorage.setItem('preferred-subs',document.querySelector('#preferredSubs').value);loadPreferences();closeSettings()});
</script>
<style>.nav-icon{width:15px;height:15px;margin-right:5px;vertical-align:-3px;object-fit:contain}.storage-icon img{width:20px;height:20px;object-fit:contain}</style>