Füge Fortschritts- und Geschwindigkeitsanzeigen für den Kopiervorgang hinzu und verbessere die USB-Port-Anzeige

This commit is contained in:
2026-05-09 01:25:43 +02:00
parent 05ef6209d1
commit 1a78a7da8a

153
app.py
View File

@@ -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,
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))
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
<div class="prog-bar" id="prog-bar" style="width:0%"></div>
</div>
<div id="prog-info" class="prog-info"></div>
<div id="eta-row" style="display:none;margin-top:.4rem;display:none">
<span id="eta-badge" style="display:inline-flex;align-items:center;gap:.35rem;font-size:.8rem;padding:.2rem .55rem;border-radius:9999px;border:1px solid var(--brd);color:var(--mut)"></span>
<span id="speed-badge" style="display:inline-flex;align-items:center;gap:.35rem;font-size:.8rem;padding:.2rem .55rem;border-radius:9999px;border:1px solid var(--brd);color:var(--mut);margin-left:.4rem"></span>
</div>
<div id="st-summary" style="font-size:.82rem;color:var(--mut);margin-top:.3rem"></div>
<div class="btn-row">
<button id="btn-start" class="btn pri" onclick="startCopy()">&#9654;&nbsp;Kopieren starten</button>
@@ -743,18 +789,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<div class="port-status">
<div class="pdot off" id="src-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="src-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="src-dev-sub">Kein Port konfiguriert</div>
<div class="port-path" id="src-port-path">—</div>
<div class="port-dev-info" id="src-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="src-label" placeholder="z.B. Kamera-Stick">
<label>Eigene Bezeichnung für diesen Port</label>
<input type="text" id="src-label" placeholder="z.B. linker USB-3 Port">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<label>Physischen Port lernen (Gerät einstecken → wählen)</label>
<select id="src-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
<option value="">— Gerät einstecken, dann hier wählen —</option>
</select>
</div>
<button class="btn sec" style="width:100%" onclick="assignPort('source')">&#10003;&nbsp;Als feste Quelle speichern</button>
@@ -768,18 +814,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<div class="port-status">
<div class="pdot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="dst-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="dst-dev-sub">Kein Port konfiguriert</div>
<div class="port-path" id="dst-port-path">—</div>
<div class="port-dev-info" id="dst-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="dst-label" placeholder="z.B. Backup-Laufwerk">
<label>Eigene Bezeichnung für diesen Port</label>
<input type="text" id="dst-label" placeholder="z.B. rechter USB-3 Port">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<label>Physischen Port lernen (Gerät einstecken → wählen)</label>
<select id="dst-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
<option value="">— Gerät einstecken, dann hier wählen —</option>
</select>
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">&#10003;&nbsp;Als festes Ziel speichern</button>
@@ -900,29 +946,36 @@ 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 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 (port) {
// Port is configured — show port path PROMINENTLY
pathEl.textContent = 'Port ' + port + (label ? ' · ' + label : '');
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:'');
infoEl.textContent = (dev.label||dev.device) + (dev.size?' · '+dev.size:'') + (dev.mount?' · '+dev.mount:'');
} else {
dot.className = 'pdot off';
nameEl.textContent = 'Nicht verbunden';
subEl.textContent = 'Kein Port konfiguriert';
infoEl.textContent = 'Gerät nicht verbunden';
}
} else {
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 =>
`<option value="${d.usb_port}">${d.label||d.device} — Port ${d.usb_port||'?'} (${d.size})</option>`
`<option value="${d.usb_port}">Port ${d.usb_port||'?'} — ${d.label||d.device} (${d.size})</option>`
).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 ? '&#9201; '+etaTxt : '';
etaBadge.style.display = etaTxt ? '' : 'none';
speedBadge.innerHTML = spdTxt ? '&#9889; '+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()=>{