feat: Kopier-Verlauf hinzugefügt und Systeminformationen bereitgestellt; Versionsnummer auf 1.0.57 erhöht
This commit is contained in:
229
app.py
229
app.py
@@ -40,6 +40,8 @@ LOG_DIR = BASE_DIR / 'logs'
|
|||||||
LOG_FILE = LOG_DIR / 'picopy.log'
|
LOG_FILE = LOG_DIR / 'picopy.log'
|
||||||
INTERNAL_DEST_DIR = BASE_DIR / 'internal'
|
INTERNAL_DEST_DIR = BASE_DIR / 'internal'
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
HISTORY_FILE = BASE_DIR / 'history.json'
|
||||||
|
MAX_HISTORY = 100
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -107,6 +109,64 @@ def save_state():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# -- Kopier-Verlauf ----------------------------------------------------------
|
||||||
|
|
||||||
|
def load_history() -> list:
|
||||||
|
try:
|
||||||
|
if HISTORY_FILE.exists():
|
||||||
|
return json.loads(HISTORY_FILE.read_text(encoding='utf-8'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def append_history(entry: dict):
|
||||||
|
h = load_history()
|
||||||
|
h.insert(0, entry)
|
||||||
|
try:
|
||||||
|
_atomic_write(HISTORY_FILE, json.dumps(h[:MAX_HISTORY]))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'Verlauf speichern fehlgeschlagen: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
# -- Systeminfo --------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_sysinfo() -> dict:
|
||||||
|
info: dict = {}
|
||||||
|
# CPU-Temperatur (Raspberry Pi)
|
||||||
|
for zone in ('/sys/class/thermal/thermal_zone0/temp',
|
||||||
|
'/sys/class/thermal/thermal_zone1/temp'):
|
||||||
|
try:
|
||||||
|
raw = Path(zone).read_text().strip()
|
||||||
|
info['cpu_temp'] = round(int(raw) / 1000, 1)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
info['cpu_temp'] = None
|
||||||
|
# RAM
|
||||||
|
try:
|
||||||
|
mem: dict = {}
|
||||||
|
for line in Path('/proc/meminfo').read_text().splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
mem[parts[0].rstrip(':')] = int(parts[1])
|
||||||
|
total = mem.get('MemTotal', 0)
|
||||||
|
avail = mem.get('MemAvailable', 0)
|
||||||
|
used = total - avail
|
||||||
|
info['ram_total'] = round(total / 1024)
|
||||||
|
info['ram_used'] = round(used / 1024)
|
||||||
|
info['ram_pct'] = round(used / total * 100) if total else 0
|
||||||
|
except Exception:
|
||||||
|
info['ram_total'] = info['ram_used'] = info['ram_pct'] = None
|
||||||
|
# SD-Karte (root-Dateisystem)
|
||||||
|
try:
|
||||||
|
du = shutil.disk_usage('/')
|
||||||
|
info['disk_total'] = round(du.total / 1e9, 1)
|
||||||
|
info['disk_used'] = round(du.used / 1e9, 1)
|
||||||
|
info['disk_pct'] = round(du.used / du.total * 100) if du.total else 0
|
||||||
|
except Exception:
|
||||||
|
info['disk_total'] = info['disk_used'] = info['disk_pct'] = None
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
# -- WiFi Status -------------------------------------------------------------
|
# -- WiFi Status -------------------------------------------------------------
|
||||||
|
|
||||||
wifi_state = {
|
wifi_state = {
|
||||||
@@ -761,6 +821,11 @@ def do_copy(src_devs, dst_dev, cfg):
|
|||||||
dst_owned = False
|
dst_owned = False
|
||||||
src_mounts = [] # [(src_dev, src_mp, src_owned)]
|
src_mounts = [] # [(src_dev, src_mp, src_owned)]
|
||||||
_upload_thread = None
|
_upload_thread = None
|
||||||
|
_hist = {
|
||||||
|
'start': time.time(),
|
||||||
|
'ok': False, 'copied': 0, 'skipped': 0, 'errors': 0,
|
||||||
|
'bytes': 0, 'error_msg': '',
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state.update(running=True, progress=0, error=None,
|
copy_state.update(running=True, progress=0, error=None,
|
||||||
@@ -978,6 +1043,9 @@ def do_copy(src_devs, dst_dev, cfg):
|
|||||||
|
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state['last_copy'] = datetime.now().isoformat()
|
copy_state['last_copy'] = datetime.now().isoformat()
|
||||||
|
_hist['bytes'] = copy_state['bytes_done']
|
||||||
|
_hist.update(ok=True, copied=len(all_copied_pairs),
|
||||||
|
skipped=skipped, errors=io_errors)
|
||||||
add_log('Fertig! ' + ', '.join(msg_parts))
|
add_log('Fertig! ' + ', '.join(msg_parts))
|
||||||
|
|
||||||
dst_dir_root = Path(dst_mp) / date_str
|
dst_dir_root = Path(dst_mp) / date_str
|
||||||
@@ -996,6 +1064,7 @@ def do_copy(src_devs, dst_dev, cfg):
|
|||||||
log.exception('Copy failed')
|
log.exception('Copy failed')
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state['error'] = str(e)
|
copy_state['error'] = str(e)
|
||||||
|
_hist['error_msg'] = str(e)
|
||||||
add_log(f'Fehler: {e}')
|
add_log(f'Fehler: {e}')
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -1014,6 +1083,19 @@ def do_copy(src_devs, dst_dev, cfg):
|
|||||||
copy_state['current'] = ''
|
copy_state['current'] = ''
|
||||||
copy_state['phase'] = 'idle'
|
copy_state['phase'] = 'idle'
|
||||||
save_state()
|
save_state()
|
||||||
|
# Verlaufseintrag speichern
|
||||||
|
append_history({
|
||||||
|
'ts': datetime.now().isoformat(),
|
||||||
|
'duration': int(time.time() - _hist['start']),
|
||||||
|
'sources': [d.get('label', d.get('device', '?')) for d in src_devs],
|
||||||
|
'dest': dst_dev.get('label', dst_dev.get('device', '?')) if dst_dev else '?',
|
||||||
|
'copied': _hist['copied'],
|
||||||
|
'skipped': _hist['skipped'],
|
||||||
|
'errors': _hist['errors'],
|
||||||
|
'bytes': _hist['bytes'],
|
||||||
|
'ok': _hist['ok'],
|
||||||
|
'error': _hist['error_msg'],
|
||||||
|
})
|
||||||
|
|
||||||
def check_auto_copy():
|
def check_auto_copy():
|
||||||
cfg = load_cfg()
|
cfg = load_cfg()
|
||||||
@@ -1435,6 +1517,22 @@ def r_config():
|
|||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
return jsonify(load_cfg())
|
return jsonify(load_cfg())
|
||||||
|
|
||||||
|
@app.route('/api/history')
|
||||||
|
def r_history():
|
||||||
|
return jsonify(load_history())
|
||||||
|
|
||||||
|
@app.route('/api/history', methods=['DELETE'])
|
||||||
|
def r_history_clear():
|
||||||
|
try:
|
||||||
|
HISTORY_FILE.write_text('[]', encoding='utf-8')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
@app.route('/api/sysinfo')
|
||||||
|
def r_sysinfo():
|
||||||
|
return jsonify(get_sysinfo())
|
||||||
|
|
||||||
@app.route('/api/status')
|
@app.route('/api/status')
|
||||||
def r_status():
|
def r_status():
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
@@ -2108,6 +2206,20 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
|
|||||||
|
|
||||||
/* -- Log -- */
|
/* -- Log -- */
|
||||||
.log-wrap{font-family:ui-monospace,monospace;font-size:.75rem;max-height:300px;overflow-y:auto;background:var(--bg2);border-radius:.45rem;padding:.5rem}
|
.log-wrap{font-family:ui-monospace,monospace;font-size:.75rem;max-height:300px;overflow-y:auto;background:var(--bg2);border-radius:.45rem;padding:.5rem}
|
||||||
|
.si-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem;margin-bottom:.6rem}
|
||||||
|
.si-item{background:var(--bg2);border-radius:.45rem;padding:.55rem .7rem}
|
||||||
|
.si-label{font-size:.7rem;color:var(--sub);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.2rem}
|
||||||
|
.si-val{font-size:1.05rem;font-weight:700;color:var(--txt)}
|
||||||
|
.si-sub{font-size:.7rem;color:var(--sub);margin-top:.1rem}
|
||||||
|
.si-bar{height:4px;background:var(--brd);border-radius:9999px;margin-top:.35rem;overflow:hidden}
|
||||||
|
.si-fill{height:100%;border-radius:9999px;transition:width .5s}
|
||||||
|
.si-fill.ok{background:var(--grn2)}.si-fill.warn{background:var(--ylw)}.si-fill.hot{background:var(--red)}
|
||||||
|
.hist-table{width:100%;border-collapse:collapse;font-size:.8rem}
|
||||||
|
.hist-table th{text-align:left;padding:.35rem .6rem;color:var(--sub);font-weight:600;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--brd);white-space:nowrap}
|
||||||
|
.hist-table td{padding:.42rem .6rem;border-bottom:1px solid var(--brd);vertical-align:middle}
|
||||||
|
.hist-table tr:last-child td{border-bottom:none}
|
||||||
|
.hist-table tr:hover td{background:var(--bg2)}
|
||||||
|
.hist-ok{color:var(--grn);font-weight:700}.hist-err{color:var(--red);font-weight:700}
|
||||||
.log-row{display:flex;gap:.5rem;padding:.18rem 0;border-bottom:1px solid rgba(42,54,80,.5)}
|
.log-row{display:flex;gap:.5rem;padding:.18rem 0;border-bottom:1px solid rgba(42,54,80,.5)}
|
||||||
.log-row:last-child{border-bottom:none}
|
.log-row:last-child{border-bottom:none}
|
||||||
.log-t{color:var(--brd2);flex-shrink:0}
|
.log-t{color:var(--brd2);flex-shrink:0}
|
||||||
@@ -2562,12 +2674,41 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
|
|||||||
<span class="card-title">System</span>
|
<span class="card-title">System</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="display:flex;flex-direction:column;gap:.6rem">
|
<div class="card-body" style="display:flex;flex-direction:column;gap:.6rem">
|
||||||
|
<div class="si-grid" id="sysinfo-grid">
|
||||||
|
<div class="si-item">
|
||||||
|
<div class="si-label">CPU-Temp</div>
|
||||||
|
<div class="si-val" id="si-temp">--</div>
|
||||||
|
<div class="si-sub" id="si-temp-sub"> </div>
|
||||||
|
</div>
|
||||||
|
<div class="si-item">
|
||||||
|
<div class="si-label">RAM</div>
|
||||||
|
<div class="si-val" id="si-ram">--</div>
|
||||||
|
<div class="si-bar"><div class="si-fill ok" id="si-ram-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="si-item">
|
||||||
|
<div class="si-label">SD-Karte</div>
|
||||||
|
<div class="si-val" id="si-disk">--</div>
|
||||||
|
<div class="si-bar"><div class="si-fill ok" id="si-disk-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="btn" style="width:100%" onclick="checkUpdate()">🔍 Nach Update suchen</button>
|
<button class="btn" style="width:100%" onclick="checkUpdate()">🔍 Nach Update suchen</button>
|
||||||
<div id="sys-update-flash" class="flash" style="display:none"></div>
|
<div id="sys-update-flash" class="flash" style="display:none"></div>
|
||||||
<button class="btn" style="width:100%;background:rgba(220,60,60,.12);color:#e05555;border-color:rgba(220,60,60,.25)" onclick="rebootDevice()">↺ Gerät neu starten</button>
|
<button class="btn" style="width:100%;background:rgba(220,60,60,.12);color:#e05555;border-color:rgba(220,60,60,.25)" onclick="rebootDevice()">↺ Gerät neu starten</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- -- Kopier-Verlauf -- -->
|
||||||
|
<div class="card col2">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-icon" style="background:rgba(79,142,247,.1);color:var(--acc)">📋</div>
|
||||||
|
<span class="card-title">Kopier-Verlauf</span>
|
||||||
|
<button class="btn sm ghost danger" style="margin-left:auto" onclick="clearHistory()">✕ Löschen</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding:.5rem .75rem">
|
||||||
|
<div id="history-wrap"><div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- -- Logs -- -->
|
<!-- -- Logs -- -->
|
||||||
<div class="card col2">
|
<div class="card col2">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
@@ -3248,6 +3389,7 @@ async function poll(){
|
|||||||
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
|
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
|
||||||
sum.textContent=''; time.textContent='';
|
sum.textContent=''; time.textContent='';
|
||||||
}else if(c.last_copy && !_dismissed){
|
}else if(c.last_copy && !_dismissed){
|
||||||
|
if(c.last_copy !== _lastHistoryTs){ _lastHistoryTs=c.last_copy; loadHistory(); }
|
||||||
tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen';
|
tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen';
|
||||||
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
|
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
|
||||||
sum.textContent=c.total+' Dateien'+' | '+fmtBytes(c.bytes_total);
|
sum.textContent=c.total+' Dateien'+' | '+fmtBytes(c.bytes_total);
|
||||||
@@ -3294,7 +3436,7 @@ async function poll(){
|
|||||||
}catch(e){}
|
}catch(e){}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _dismissed = false, _autoDismissTimer = null;
|
let _dismissed = false, _autoDismissTimer = null, _lastHistoryTs = null;
|
||||||
function dismissStatus(){
|
function dismissStatus(){
|
||||||
_dismissed = true;
|
_dismissed = true;
|
||||||
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
|
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
|
||||||
@@ -3430,18 +3572,101 @@ function flash(id,cls,msg){
|
|||||||
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
|
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Sysinfo ------------------------------------------------------------------
|
||||||
|
async function pollSysinfo(){
|
||||||
|
try{
|
||||||
|
const s=await api('/sysinfo');
|
||||||
|
// CPU-Temp
|
||||||
|
const tempEl=$('si-temp'), tempSub=$('si-temp-sub');
|
||||||
|
if(s.cpu_temp!=null){
|
||||||
|
tempEl.textContent=s.cpu_temp+'°C';
|
||||||
|
const cls=s.cpu_temp>=80?'hot':s.cpu_temp>=65?'warn':'ok';
|
||||||
|
tempEl.style.color=cls==='hot'?'var(--red)':cls==='warn'?'var(--ylw)':'var(--grn)';
|
||||||
|
tempSub.textContent=s.cpu_temp>=80?'Heiß':s.cpu_temp>=65?'Warm':'Normal';
|
||||||
|
} else { tempEl.textContent='n/v'; tempSub.textContent=''; }
|
||||||
|
// RAM
|
||||||
|
if(s.ram_used!=null){
|
||||||
|
$('si-ram').textContent=s.ram_used+' / '+s.ram_total+' MB';
|
||||||
|
const rb=$('si-ram-bar'); rb.style.width=s.ram_pct+'%';
|
||||||
|
rb.className='si-fill '+(s.ram_pct>=90?'hot':s.ram_pct>=70?'warn':'ok');
|
||||||
|
}
|
||||||
|
// Disk
|
||||||
|
if(s.disk_used!=null){
|
||||||
|
$('si-disk').textContent=s.disk_used+' / '+s.disk_total+' GB';
|
||||||
|
const db=$('si-disk-bar'); db.style.width=s.disk_pct+'%';
|
||||||
|
db.className='si-fill '+(s.disk_pct>=90?'hot':s.disk_pct>=75?'warn':'ok');
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Kopier-Verlauf -----------------------------------------------------------
|
||||||
|
function fmtDur(s){
|
||||||
|
if(s<60) return s+'s';
|
||||||
|
const m=Math.floor(s/60), sec=s%60;
|
||||||
|
return m+'m'+(sec?sec+'s':'');
|
||||||
|
}
|
||||||
|
async function loadHistory(){
|
||||||
|
try{
|
||||||
|
const h=await api('/history');
|
||||||
|
renderHistory(h);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function renderHistory(h){
|
||||||
|
const w=$('history-wrap');
|
||||||
|
if(!h||!h.length){
|
||||||
|
w.innerHTML='<div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.innerHTML=`<table class="hist-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Datum</th><th>Quellen</th><th>Ziel</th>
|
||||||
|
<th style="text-align:right">Dateien</th><th style="text-align:right">Größe</th>
|
||||||
|
<th style="text-align:right">Dauer</th><th>Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${h.map(e=>{
|
||||||
|
const d=new Date(e.ts);
|
||||||
|
const date=d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
|
||||||
|
const time=d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
|
||||||
|
const srcs=(e.sources||[]).join(', ')||'?';
|
||||||
|
const files=e.copied+(e.skipped?` <span style="color:var(--sub);font-size:.75em">(+${e.skipped} übersp.)</span>`:'');
|
||||||
|
const size=e.bytes>0?fmtBytes(e.bytes):'--';
|
||||||
|
const status=e.ok
|
||||||
|
? '<span class="hist-ok">✓ OK</span>'
|
||||||
|
: `<span class="hist-err" title="${(e.error||'').replace(/"/g,'"')}">✗ Fehler</span>`;
|
||||||
|
const io=e.errors?` <span style="color:var(--red);font-size:.75em">${e.errors} I/O-Err.</span>`:'';
|
||||||
|
return`<tr>
|
||||||
|
<td><span style="font-weight:600">${date}</span><br><span style="color:var(--sub);font-size:.75em">${time}</span></td>
|
||||||
|
<td>${srcs}</td>
|
||||||
|
<td style="color:var(--sub)">${e.dest||'?'}</td>
|
||||||
|
<td style="text-align:right">${files}${io}</td>
|
||||||
|
<td style="text-align:right">${size}</td>
|
||||||
|
<td style="text-align:right">${fmtDur(e.duration||0)}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('')}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
async function clearHistory(){
|
||||||
|
if(!confirm('Kopier-Verlauf wirklich löschen?'))return;
|
||||||
|
await api('/history','DELETE');
|
||||||
|
renderHistory([]);
|
||||||
|
}
|
||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
await loadCfg();
|
await loadCfg();
|
||||||
await refreshDevices();
|
await refreshDevices();
|
||||||
await loadUTs();
|
await loadUTs();
|
||||||
await loadWgConfig();
|
await loadWgConfig();
|
||||||
expl.load('');
|
expl.load('');
|
||||||
|
loadHistory();
|
||||||
|
pollSysinfo();
|
||||||
setInterval(poll,1500);
|
setInterval(poll,1500);
|
||||||
setInterval(refreshDevices,8000);
|
setInterval(refreshDevices,8000);
|
||||||
setInterval(pollUpdate,60000);
|
setInterval(pollUpdate,60000);
|
||||||
|
setInterval(pollSysinfo,8000);
|
||||||
poll();
|
poll();
|
||||||
pollUpdate();
|
pollUpdate();
|
||||||
setTimeout(pollUpdate, 8000); // Server-Check-Ergebnis abholen bevor der 60s-Takt greift
|
setTimeout(pollUpdate, 8000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.56
|
1.0.57
|
||||||
Reference in New Issue
Block a user