diff --git a/app.py b/app.py index 7786617..2427572 100644 --- a/app.py +++ b/app.py @@ -50,6 +50,8 @@ copy_state = { 'running': False, 'progress': 0, 'total': 0, 'done': 0, 'current': '', 'error': None, 'last_copy': None, 'logs': [], + 'bytes_total': 0, 'bytes_done': 0, + 'start_ts': None, 'eta_sec': None, 'speed_bps': 0, } copy_lock = threading.Lock() @@ -242,6 +244,24 @@ def wifi_monitor(): # ── USB Geräteerkennung ─────────────────────────────────────────────────────── def usb_port_of(dev_name): + """Gibt den physischen USB-Port-Pfad zurück (z.B. '2-2'). + Primär via udevadm, Fallback via sysfs.""" + # Primär: udevadm (zuverlässiger) + try: + r = subprocess.run( + ['udevadm', 'info', '-q', 'path', '-n', f'/dev/{dev_name}'], + capture_output=True, text=True, timeout=5 + ) + if r.returncode == 0: + port = None + for seg in r.stdout.strip().split('/'): + if re.fullmatch(r'\d+-[\d.]+', seg): + port = seg + if port: + return port + except Exception: + pass + # Fallback: sysfs readlink try: real = Path(f'/sys/block/{dev_name}').resolve() port = None @@ -309,13 +329,22 @@ def add_log(msg): copy_state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg}) copy_state['logs'] = copy_state['logs'][-200:] +def _fmt_bytes(b): + if b < 1024: return f'{b} B' + if b < 1024**2: return f'{b/1024:.1f} KB' + if b < 1024**3: return f'{b/1024**2:.1f} MB' + return f'{b/1024**3:.2f} GB' + + def do_copy(src_dev, dst_dev, cfg): src_mp = dst_mp = None src_owned = dst_owned = False try: with copy_lock: copy_state.update(running=True, progress=0, error=None, - done=0, total=0, logs=[], current='') + done=0, total=0, logs=[], current='', + bytes_total=0, bytes_done=0, + start_ts=time.time(), eta_sec=None, speed_bps=0) save_state() add_log('Kopiervorgang gestartet') @@ -344,9 +373,11 @@ def do_copy(src_dev, dst_dev, cfg): src_path = Path(src_mp) files = [f for f in src_path.rglob('*') if f.is_file()] total = len(files) + bytes_total = sum(f.stat().st_size for f in files) with copy_lock: copy_state['total'] = total - add_log(f'{total} Dateien gefunden') + copy_state['bytes_total'] = bytes_total + add_log(f'{total} Dateien gefunden ({_fmt_bytes(bytes_total)})') save_state() for i, f in enumerate(files): @@ -356,11 +387,22 @@ def do_copy(src_dev, dst_dev, cfg): return dst_f = dst_dir / f.relative_to(src_path) dst_f.parent.mkdir(parents=True, exist_ok=True) + fsize = f.stat().st_size shutil.copy2(f, dst_f) with copy_lock: - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name)) + copy_state['bytes_done'] += fsize + bd = copy_state['bytes_done'] + bt = copy_state['bytes_total'] + elapsed = time.time() - copy_state['start_ts'] + speed = bd / elapsed if elapsed > 1 else 0 + eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0 + copy_state.update( + done=i+1, + progress=int((i+1)/total*100) if total else 100, + current=str(f.name), + speed_bps=int(speed), + eta_sec=eta, + ) if (i+1) % 20 == 0: save_state() @@ -618,7 +660,7 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e .btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem} .prog-wrap{margin:.6rem 0 .3rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden} .prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s ease} -.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem} +.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem;font-family:ui-monospace,monospace} .st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)} .field{margin-bottom:.8rem} .field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem} @@ -656,8 +698,8 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e .pdot{width:10px;height:10px;border-radius:50%;flex-shrink:0;transition:.3s} .pdot.on{background:var(--grn);box-shadow:0 0 6px var(--grn)} .pdot.off{background:var(--brd)} -.port-dev-name{font-weight:600;font-size:.9rem;line-height:1.3} -.port-dev-sub{font-size:.73rem;color:var(--mut);font-family:monospace;margin-top:.1rem} +.port-path{font-weight:700;font-size:1rem;font-family:ui-monospace,monospace;color:var(--txt);line-height:1.3} +.port-dev-info{font-size:.75rem;color:var(--mut);margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .port-hint{font-size:.73rem;color:var(--mut);margin-top:.65rem;padding:.5rem .65rem;background:var(--bg);border-radius:.4rem;border-left:3px solid var(--brd)} /* ── Port + Explorer grid ── */ @@ -723,6 +765,10 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
+
@@ -743,18 +789,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
-
Nicht verbunden
-
Kein Port konfiguriert
+
+
Kein Port konfiguriert
- - + +
- +
@@ -768,18 +814,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
-
Nicht verbunden
-
Kein Port konfiguriert
+
+
Kein Port konfiguriert
- - + +
- +
@@ -898,31 +944,38 @@ function renderPortSlots() { } function renderSlot(role, port, label) { - const isSrc = role === 'src'; - const dev = devs.find(d => d.usb_port === port); - const dot = $(role+'-dot'), nameEl=$(role+'-dev-name'), subEl=$(role+'-dev-sub'); - const slotEl = $('slot-'+role), lblEl=$(role+'-label'); + const isSrc = role === 'src'; + const dev = devs.find(d => d.usb_port === port); + const dot = $(role+'-dot'); + const pathEl = $(role+'-port-path'); + const infoEl = $(role+'-dev-info'); + const slotEl = $('slot-'+role); + const lblEl = $(role+'-label'); + slotEl.classList.toggle('has-src', isSrc && !!port); slotEl.classList.toggle('has-dst', !isSrc && !!port); - if (dev) { - dot.className = 'pdot on'; - nameEl.textContent = dev.label || dev.device; - subEl.textContent = 'Port '+port+(dev.size?' · '+dev.size:'')+(dev.mount?' · '+dev.mount:''); - } else if (port) { - dot.className = 'pdot off'; - nameEl.textContent = label || 'Nicht verbunden'; - subEl.textContent = 'Konfiguriert: Port '+port+(label?' · '+label:''); + + if (port) { + // Port is configured — show port path PROMINENTLY + pathEl.textContent = 'Port ' + port + (label ? ' · ' + label : ''); + if (dev) { + dot.className = 'pdot on'; + infoEl.textContent = (dev.label||dev.device) + (dev.size?' · '+dev.size:'') + (dev.mount?' · '+dev.mount:''); + } else { + dot.className = 'pdot off'; + infoEl.textContent = 'Gerät nicht verbunden'; + } } else { - dot.className = 'pdot off'; - nameEl.textContent = 'Nicht verbunden'; - subEl.textContent = 'Kein Port konfiguriert'; + dot.className = 'pdot off'; + pathEl.textContent = '—'; + infoEl.textContent = 'Kein Port konfiguriert'; } if (lblEl && !lblEl.dataset.dirty) lblEl.value = label || ''; } function populateSelects() { const opts = devs.map(d => - `` + `` ).join(''); ['src-select','dst-select'].forEach(id => { const el=$(id), prev=el.value; @@ -955,7 +1008,7 @@ async function assignPort(role) { cfg[isSrc?'source_label':'dest_label'] = label; $(lblId).dataset.dirty=''; await api('/config','POST',cfg); - flash(fId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.'); + flash(fId,'ok','Port '+port+' dauerhaft als '+(isSrc?'Quelle':'Ziel')+' gespeichert.'); renderPortSlots(); renderUnassigned(); expl.reload(); } @@ -1131,10 +1184,23 @@ async function poll() { if(c.running){ txt.className='st-run';txt.textContent='Kopiert… '+c.progress+'%'; wrap.style.display='block';bar.style.width=c.progress+'%'; - info.textContent=c.current?c.done+' / '+c.total+' — '+c.current:''; + // Datei + Bytes-Info + const byteInfo = c.bytes_total>0 ? fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total) : ''; + info.textContent = (c.current ? c.done+' / '+c.total+' — '+c.current : '') + (byteInfo ? ' ('+byteInfo+')' : ''); + // ETA + Speed Badges + const etaRow=$('eta-row'), etaBadge=$('eta-badge'), speedBadge=$('speed-badge'); + const etaTxt=fmtETA(c.eta_sec), spdTxt=fmtSpeed(c.speed_bps); + if(etaTxt||spdTxt){ + etaRow.style.display='flex'; etaRow.style.alignItems='center'; + etaBadge.innerHTML = etaTxt ? '⏱ '+etaTxt : ''; + etaBadge.style.display = etaTxt ? '' : 'none'; + speedBadge.innerHTML = spdTxt ? '⚡ '+spdTxt : ''; + speedBadge.style.display = spdTxt ? '' : 'none'; + } else { etaRow.style.display='none'; } sum.textContent='';bS.style.display='none';bC.style.display=''; } else { bS.style.display='';bC.style.display='none';info.textContent=''; + $('eta-row').style.display='none'; if(c.error){txt.className='st-err';txt.textContent='Fehler: '+c.error;wrap.style.display='none';sum.textContent='';} else if(c.last_copy){txt.className='st-ok';txt.textContent='✓ Abgeschlossen';wrap.style.display='block';bar.style.width='100%';sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');} else{txt.className='st-idle';txt.textContent='Bereit';wrap.style.display='none';sum.textContent='';} @@ -1144,6 +1210,29 @@ async function poll() { } catch(e){} } +function fmtETA(sec) { + if (!sec || sec <= 0) return ''; + if (sec < 60) return 'noch < 1 Min.'; + const m = Math.round(sec / 60); + if (m < 60) return 'noch ca. ' + m + ' Min.'; + const h = Math.floor(m / 60), rm = m % 60; + return 'noch ca. ' + h + ' Std.' + (rm ? ' ' + rm + ' Min.' : ''); +} +function fmtSpeed(bps) { + if (!bps || bps <= 0) return ''; + if (bps < 1024) return bps + ' B/s'; + if (bps < 1048576) return (bps/1024).toFixed(1) + ' KB/s'; + if (bps < 1073741824) return (bps/1048576).toFixed(1) + ' MB/s'; + return (bps/1073741824).toFixed(2) + ' GB/s'; +} +function fmtBytes(b) { + if (!b) return ''; + if (b < 1024) return b + ' B'; + if (b < 1048576) return (b/1024).toFixed(1) + ' KB'; + if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB'; + return (b/1073741824).toFixed(2) + ' GB'; +} + function flash(id,cls,msg){const el=$(id);el.className='flash '+cls;el.textContent=msg;el.style.display='block';if(cls==='ok')setTimeout(()=>el.style.display='none',3500);} (async()=>{