Add three-scenario disaster recovery
This commit is contained in:
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Encrypted off-host backup for data-disk and total-loss recovery.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
CONFIG=${DISASTER_BACKUP_CONFIG:-/etc/mike-ai/disaster-backup.env}
|
||||
STATE=/var/lib/mike-ai-disaster-backup
|
||||
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -r $CONFIG ]] || die "Konfiguration fehlt: $CONFIG"
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG"
|
||||
[[ ${DISASTER_BACKUP_ENABLED:-false} == true ]] || die \
|
||||
"Externes Backup ist noch nicht freigeschaltet (DISASTER_BACKUP_ENABLED=true)."
|
||||
[[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt."
|
||||
if [[ -n ${RESTIC_REQUIRE_MOUNT:-} ]]; then
|
||||
mountpoint -q "$RESTIC_REQUIRE_MOUNT" || die \
|
||||
"Externes Backupziel ist nicht eingehängt: $RESTIC_REQUIRE_MOUNT"
|
||||
fi
|
||||
[[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \
|
||||
"RESTIC_PASSWORD_FILE fehlt oder ist nicht lesbar."
|
||||
command -v restic >/dev/null || die "restic ist nicht installiert."
|
||||
command -v docker >/dev/null || die "Docker ist nicht installiert."
|
||||
exec 9>/run/lock/athena-disaster-backup.lock
|
||||
flock -n 9 || die "Ein Disaster-Backup läuft bereits."
|
||||
|
||||
install -d -m 0700 "$STATE/latest"
|
||||
|
||||
log "Konsistentes Docker-Schnellbackup erzeugen"
|
||||
docker inspect mike-ai-backup >/dev/null 2>&1 || die "mike-ai-backup fehlt."
|
||||
docker exec mike-ai-backup backup
|
||||
latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz)
|
||||
[[ -s $latest ]] || die "Lokales Docker-Backup wurde nicht erzeugt."
|
||||
gzip -t "$latest" || die "Lokales Docker-Backup ist beschädigt."
|
||||
install -m 0600 "$latest" "$STATE/latest/docker-state.tar.gz"
|
||||
sha256sum "$STATE/latest/docker-state.tar.gz" >"$STATE/latest/docker-state.tar.gz.sha256"
|
||||
|
||||
log "Wiederaufbau-Metadaten erfassen"
|
||||
{
|
||||
printf 'created_utc=%s\n' "$(date -u +%FT%TZ)"
|
||||
printf 'hostname=%s\n' "$(hostname)"
|
||||
printf 'source_commit=%s\n' "$(git -C /opt/mike-ai/stack rev-parse HEAD 2>/dev/null || printf unknown)"
|
||||
findmnt -rn -o SOURCE,UUID,FSTYPE,TARGET / /data 2>/dev/null || true
|
||||
} >"$STATE/latest/manifest.txt"
|
||||
find /data/models -type f -printf '%P\t%s\n' 2>/dev/null | sort \
|
||||
>"$STATE/latest/model-manifest.tsv"
|
||||
docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' \
|
||||
>"$STATE/latest/container-manifest.tsv"
|
||||
|
||||
paths=(/etc/mike-ai /opt/mike-ai "$STATE/latest")
|
||||
for path in \
|
||||
/data/voice /data/music /data/audio /data/llama-dashboard \
|
||||
/data/mike-ai-operator /data/benchmarks /data/model-benchmarks \
|
||||
/data/image-comparison /data/backups /data/deploy-backups; do
|
||||
[[ ! -e $path ]] || paths+=("$path")
|
||||
done
|
||||
|
||||
tag=${RESTIC_TAG:-athena-disaster}
|
||||
log "Verschlüsseltes externes Backup schreiben"
|
||||
if ! restic snapshots >/dev/null 2>&1; then
|
||||
log "Neues Restic-Repository initialisieren"
|
||||
restic init
|
||||
fi
|
||||
restic backup --tag "$tag" "${paths[@]}"
|
||||
|
||||
log "Aufbewahrung anwenden"
|
||||
restic forget --tag "$tag" \
|
||||
--keep-daily "${RESTIC_KEEP_DAILY:-14}" \
|
||||
--keep-weekly "${RESTIC_KEEP_WEEKLY:-8}" \
|
||||
--keep-monthly "${RESTIC_KEEP_MONTHLY:-12}" --prune
|
||||
|
||||
log "Letzten Snapshot verifizieren"
|
||||
restic snapshots --tag "$tag" --latest 1
|
||||
restic check
|
||||
printf 'ATHENA_DISASTER_BACKUP_OK\n'
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Encrypted off-host disaster backup for Athena
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/mike-ai/disaster-backup.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/athena-disaster-backup
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Nightly Athena off-host disaster backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30m
|
||||
Unit=athena-disaster-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a browser-downloadable, encrypted archive of irreplaceable Athena data.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
OUTPUT_DIR=${ATHENA_EXPORT_DIR:-/data/emergency-backups}
|
||||
RECIPIENT_FILE=${ATHENA_AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient}
|
||||
STATE=/var/lib/mike-ai-disaster-backup/latest
|
||||
KEEP=${ATHENA_EXPORT_KEEP:-5}
|
||||
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -s $RECIPIENT_FILE ]] || die "Age-Empfänger fehlt: $RECIPIENT_FILE"
|
||||
[[ $KEEP =~ ^[1-9][0-9]*$ ]] || die "ATHENA_EXPORT_KEEP muss positiv sein."
|
||||
for command in age zstd tar docker sha256sum flock; do
|
||||
command -v "$command" >/dev/null || die "$command fehlt."
|
||||
done
|
||||
exec 9>/run/lock/athena-export-backup.lock
|
||||
flock -n 9 || die "Ein exportierbares Backup läuft bereits."
|
||||
|
||||
install -d -m 0755 "$OUTPUT_DIR"
|
||||
install -d -m 0700 "$STATE"
|
||||
|
||||
log "Aktuellen Docker-Zustand sichern"
|
||||
docker exec mike-ai-backup backup
|
||||
latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz)
|
||||
[[ -s $latest ]] || die "Docker-Zustandsbackup fehlt."
|
||||
gzip -t "$latest" || die "Docker-Zustandsbackup ist beschädigt."
|
||||
install -m 0600 "$latest" "$STATE/docker-state.tar.gz"
|
||||
|
||||
stamp=$(date -u +%Y-%m-%dT%H-%M-%SZ)
|
||||
name="athena-portable-$stamp.tar.zst.age"
|
||||
partial="$OUTPUT_DIR/.$name.partial"
|
||||
target="$OUTPUT_DIR/$name"
|
||||
list=$(mktemp /tmp/athena-export-list.XXXXXX)
|
||||
trap 'rm -f "$list" "$partial"' EXIT
|
||||
|
||||
add_path() {
|
||||
local path=${1#/}
|
||||
[[ ! -e /$path ]] || printf '%s\0' "$path" >>"$list"
|
||||
}
|
||||
|
||||
# Reproducible model/HF caches are deliberately omitted. Everything below is
|
||||
# either a host configuration, project source, user input or generated result.
|
||||
add_path /etc/mike-ai
|
||||
add_path /opt/mike-ai
|
||||
add_path /var/lib/mike-ai-disaster-backup/latest
|
||||
add_path /data/voice/applio/logs
|
||||
add_path /data/voice/applio/datasets
|
||||
add_path /data/voice/applio/config.json
|
||||
add_path /data/voice/omnivoice/output
|
||||
add_path /data/voice/xvc/output
|
||||
add_path /data/voice/studio
|
||||
add_path /data/music
|
||||
add_path /data/audio
|
||||
add_path /data/llama-dashboard
|
||||
add_path /data/mike-ai-operator
|
||||
add_path /data/benchmarks
|
||||
add_path /data/model-benchmarks
|
||||
add_path /data/image-comparison
|
||||
add_path /data/backups
|
||||
add_path /data/deploy-backups
|
||||
|
||||
log "Portables, verschlüsseltes Backup erzeugen"
|
||||
tar --create --numeric-owner --acls --xattrs -C / --null --files-from="$list" \
|
||||
| zstd -T0 -3 \
|
||||
| age -R "$RECIPIENT_FILE" -o "$partial"
|
||||
chmod 0644 "$partial"
|
||||
mv "$partial" "$target"
|
||||
sha256sum "$target" >"$target.sha256"
|
||||
chmod 0644 "$target.sha256"
|
||||
|
||||
log "Nur die letzten $KEEP Generationen behalten"
|
||||
mapfile -t old < <(find "$OUTPUT_DIR" -maxdepth 1 -type f \
|
||||
-name 'athena-portable-*.tar.zst.age' -printf '%T@ %p\n' | sort -rn | tail -n +$((KEEP + 1)) | cut -d' ' -f2-)
|
||||
for archive in "${old[@]}"; do
|
||||
rm -f -- "$archive" "$archive.sha256"
|
||||
done
|
||||
|
||||
printf 'ATHENA_EXPORT_BACKUP_OK file=%s bytes=%s\n' "$target" "$(stat -c %s "$target")"
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Create encrypted downloadable Athena recovery package
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
ConditionPathExists=/etc/mike-ai/recovery.age-recipient
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/athena-export-backup
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Create an Athena recovery package every five hours
|
||||
|
||||
[Timer]
|
||||
OnBootSec=45m
|
||||
OnUnitActiveSec=5h
|
||||
Persistent=true
|
||||
RandomizedDelaySec=10m
|
||||
Unit=athena-export-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -37,12 +37,40 @@ MIKES_APPLIO_UI_URL = os.getenv(
|
||||
HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc"))
|
||||
HOST_DATA = os.getenv("HOST_DATA", "/host/data")
|
||||
HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models"))
|
||||
BACKUP_DIR = Path(os.getenv("DASHBOARD_BACKUP_DIR", "/host/data/emergency-backups"))
|
||||
STARTED = time.time()
|
||||
HISTORY_DB = Path(os.getenv("DASHBOARD_HISTORY_DB", "/var/lib/llama-dashboard/history.sqlite3"))
|
||||
HISTORY_INTERVAL = max(5, int(os.getenv("DASHBOARD_HISTORY_INTERVAL", "15")))
|
||||
DETAIL_RETENTION_DAYS = max(1, int(os.getenv("DASHBOARD_DETAIL_RETENTION_DAYS", "21")))
|
||||
|
||||
|
||||
def backup_inventory() -> list[dict[str, Any]]:
|
||||
try:
|
||||
candidates = sorted(
|
||||
BACKUP_DIR.glob("athena-portable-*.tar.zst.age"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)[:5]
|
||||
except OSError:
|
||||
return []
|
||||
result = []
|
||||
for path in candidates:
|
||||
try:
|
||||
stat = path.stat()
|
||||
checksum_file = path.with_name(path.name + ".sha256")
|
||||
checksum = _read_text(checksum_file).split(maxsplit=1)[0]
|
||||
result.append({
|
||||
"name": path.name,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"sha256": checksum if len(checksum) == 64 else None,
|
||||
"download_url": "/api/backups/download/" + urllib.parse.quote(path.name),
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _number(value: str) -> int | float | None:
|
||||
value = value.strip()
|
||||
if not value or value.lower() in {"n/a", "[n/a]", "not supported"}:
|
||||
@@ -638,6 +666,7 @@ HTML = r'''<!doctype html>
|
||||
.history-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0 14px}.history-controls button{border:1px solid var(--line);background:#09111b;color:var(--muted);padding:6px 10px;border-radius:8px;cursor:pointer}.history-controls button.active{color:var(--cyan);border-color:var(--cyan)}.chart-legend{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px}.chart-legend button{border:1px solid var(--series);background:#09111b;color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.chart-legend button::before{content:'';display:inline-block;width:10px;height:3px;background:var(--series);margin:0 7px 3px 0}.chart-legend button.off{opacity:.4;text-decoration:line-through}.chart{width:100%;height:250px;display:block}.token-total{font-size:24px;font-weight:750;margin-top:5px}.history-note{color:var(--muted);font-size:11px;margin-top:8px}
|
||||
.usage-list{display:grid;gap:13px;margin-top:8px}.usage-head{display:flex;justify-content:space-between;gap:14px;align-items:baseline}.usage-head b{font-size:16px}.usage-head span{color:var(--muted)}.usage-meta{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px;margin-top:5px}
|
||||
.mode-row{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}.mode-buttons,.mode-buttons span{display:flex;gap:9px;flex-wrap:wrap}.mode-buttons button,.mode-buttons a{border:1px solid var(--line);background:#09111b;color:var(--text);padding:9px 14px;border-radius:9px;cursor:pointer;text-decoration:none;font:inherit}.mode-buttons button.active{border-color:var(--cyan);color:var(--cyan);box-shadow:inset 0 0 0 1px #45d7ff33}.mode-buttons button:disabled{opacity:.45;cursor:wait}.mode-buttons span[hidden]{display:none}.mode-buttons a.stable{border-color:#66e3a466;color:var(--green)}.mode-buttons a.experimental{border-color:#ffc65c66;color:var(--amber)}.mode-error{color:var(--red)}
|
||||
.download{display:inline-block;border:1px solid #66e3a466;color:var(--green);padding:6px 10px;border-radius:8px;text-decoration:none}.hash{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--muted);word-break:break-all}
|
||||
</style></head><body><main>
|
||||
<div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div>
|
||||
<section class="grid">
|
||||
@@ -673,6 +702,8 @@ HTML = r'''<!doctype html>
|
||||
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
|
||||
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
|
||||
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
|
||||
<div class="section-title">Notfall-Backups · herunterladen</div>
|
||||
<article class="card span12"><div class="row"><div><div class="label">Verschlüsselte portable Sicherungen</div><div class="sub">Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen</div></div></div><div class="scroll"><table><thead><tr><th>Erstellt</th><th>Größe</th><th>SHA256</th><th></th></tr></thead><tbody id="backupFiles"><tr><td colspan="4">Backups werden geladen …</td></tr></tbody></table></div><div class="sub" id="backupNote">Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.</div></article>
|
||||
<article class="card span12 error" id="errors" hidden></article>
|
||||
</section><div class="footer">Aktualisierung jede Sekunde · Umschaltung über Athena Router</div>
|
||||
</main><script>
|
||||
@@ -682,6 +713,7 @@ const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":
|
||||
let modeBusy=false;
|
||||
async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}}
|
||||
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'Musikstudio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik: ${md.music_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
|
||||
async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),rows=d.backups||[];$('backupFiles').innerHTML=rows.map(b=>`<tr><td>${new Date(b.modified*1000).toLocaleString('de-DE')}</td><td>${gib(b.size)}</td><td class="hash">${b.sha256||'Prüfsumme fehlt'}</td><td><a class="download" href="${b.download_url}">Herunterladen</a></td></tr>`).join('')||'<tr><td colspan="4">Noch kein portables Backup vorhanden</td></tr>';$('backupNote').textContent=rows.length?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Der erste Lauf startet spätestens fünf Stunden nach Aktivierung.'}catch(e){$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}}refreshBackups();setInterval(refreshBackups,60000);
|
||||
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.replace(
|
||||
"__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL
|
||||
).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL
|
||||
@@ -810,6 +842,62 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_backup(self, name: str) -> None:
|
||||
# Generated names are deliberately strict; never expose an arbitrary
|
||||
# host path through the dashboard.
|
||||
if not name.startswith("athena-portable-") or not name.endswith(".tar.zst.age"):
|
||||
self._send(404, b'{"error":"not found"}', "application/json")
|
||||
return
|
||||
if Path(name).name != name:
|
||||
self._send(404, b'{"error":"not found"}', "application/json")
|
||||
return
|
||||
path = BACKUP_DIR / name
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
self._send(404, b'{"error":"not found"}', "application/json")
|
||||
return
|
||||
|
||||
start, end = 0, size - 1
|
||||
status = 200
|
||||
range_header = self.headers.get("Range", "")
|
||||
if range_header:
|
||||
try:
|
||||
unit, raw = range_header.split("=", 1)
|
||||
first, last = raw.split("-", 1)
|
||||
if unit != "bytes" or "," in raw or not first:
|
||||
raise ValueError
|
||||
start = int(first)
|
||||
end = min(size - 1, int(last)) if last else size - 1
|
||||
if start < 0 or start > end or start >= size:
|
||||
raise ValueError
|
||||
status = 206
|
||||
except ValueError:
|
||||
self.send_response(416)
|
||||
self.send_header("Content-Range", f"bytes */{size}")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{name}"')
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(end - start + 1))
|
||||
if status == 206:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
remaining = end - start + 1
|
||||
with path.open("rb") as source:
|
||||
source.seek(start)
|
||||
while remaining:
|
||||
chunk = source.read(min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path == "/":
|
||||
@@ -828,6 +916,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
range_name = query.get("range", ["24h"])[0]
|
||||
body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode()
|
||||
self._send(200, body, "application/json; charset=utf-8")
|
||||
elif path == "/api/backups":
|
||||
body = json.dumps({"backups": backup_inventory()}, ensure_ascii=False,
|
||||
separators=(",", ":")).encode()
|
||||
self._send(200, body, "application/json; charset=utf-8")
|
||||
elif path.startswith("/api/backups/download/"):
|
||||
name = urllib.parse.unquote(path.removeprefix("/api/backups/download/"))
|
||||
self._send_backup(name)
|
||||
else:
|
||||
self._send(404, b'{"error":"not found"}', "application/json")
|
||||
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild/create specialist containers after a bare-metal restore. GPU workers
|
||||
# remain stopped; the core installer leaves Athena safely in LLM mode.
|
||||
set -Eeuo pipefail
|
||||
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
if [[ -r /etc/mike-ai/install.env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/mike-ai/install.env
|
||||
set +a
|
||||
fi
|
||||
export VOICE_GPU_UUID=${VOICE_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||
export ACESTEP_GPU_UUID=${ACESTEP_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||
export SEPARATOR_GPU_UUID=${SEPARATOR_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||
|
||||
create_project() {
|
||||
local dir=$1 file=${2:-compose.yaml} profile=${3:-}
|
||||
[[ -f $dir/$file ]] || { printf 'Übersprungen (fehlt): %s/%s\n' "$dir" "$file"; return 0; }
|
||||
log "Spezialprojekt vorbereiten: $dir"
|
||||
local args=(docker compose -f "$file")
|
||||
[[ -z $profile ]] || args+=(--profile "$profile")
|
||||
(cd "$dir" && "${args[@]}" pull --ignore-buildable && \
|
||||
"${args[@]}" build && "${args[@]}" create)
|
||||
}
|
||||
|
||||
docker network inspect mike-ai_frontend >/dev/null
|
||||
create_project /opt/mike-ai/acestep-test compose.yaml music-test
|
||||
create_project /opt/mike-ai/stem-separator
|
||||
create_project /opt/mike-ai/omnivoice-studio
|
||||
create_project /opt/mike-ai/xvc-studio
|
||||
create_project /opt/mike-ai/stack/experiments/applio-rvc
|
||||
create_project /opt/mike-ai/Mikes-Applio-UI compose.example.yaml
|
||||
|
||||
printf 'ATHENA_SPECIALISTS_REBUILT_OK\n'
|
||||
Reference in New Issue
Block a user