feat: Cookbook-Download-Fix, Live-Update-Konsole, Profi-Highlight, OpenCode-Guide
Backend: - hf_bin() + HF_DOWNLOAD_ENV zentral in config.py; install-recipe und install-model in cookbook.py nutzten weiter das fehlende `hf` + falsches XET-Env -> Haupt-Installationsweg war ebenso kaputt, jetzt konsistent gefixt Frontend: - Server: laufende Updates (LLM-Engine/OS) live auf der Server-Seite mitlesbar, inkl. klarer Fertig-/Fehlgeschlagen-Meldung (onJobs + Aktueller-Vorgang-Karte) - Cookbook/Profi-Modus: meistgeladenes, passendes Modell wird als "★ Beste Wahl für dein System" hervorgehoben (analog zu den Templates) - Verbinden: OpenCode korrekt per opencode.json (@ai-sdk/openai-compatible) statt irrefuehrender Felder; erklaert "Connect to API"-Fehler (LAN-IP statt localhost) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
@@ -33,6 +34,19 @@ TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
|
|||||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
|
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
|
||||||
PROD_DIR = str(Path(__file__).resolve().parent)
|
PROD_DIR = str(Path(__file__).resolve().parent)
|
||||||
|
|
||||||
|
# Env fuer alle HuggingFace-Downloads: XET deaktivieren (mit aktivem XET haengt der
|
||||||
|
# Download reproduzierbar bei ~6 MB, siehe CLAUDE.md). Token wird je Job ergaenzt.
|
||||||
|
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def hf_bin() -> str:
|
||||||
|
"""Pfad zur `hf`-CLI fuer Modell-Downloads. Bevorzugt die im venv installierte
|
||||||
|
(neben dem laufenden Python), da der Dienst-PATH das venv/bin meist nicht
|
||||||
|
enthaelt. Faellt sonst auf ein global installiertes `hf` zurueck."""
|
||||||
|
cand = os.path.join(os.path.dirname(sys.executable), "hf")
|
||||||
|
return cand if os.path.exists(cand) else "hf"
|
||||||
|
|
||||||
|
|
||||||
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
|
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
|
||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
yaml.preserve_quotes = True
|
yaml.preserve_quotes = True
|
||||||
|
|||||||
+5
-5
@@ -12,7 +12,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString
|
|||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from hw_math import evaluate_fit, max_ctx_for
|
from hw_math import evaluate_fit, max_ctx_for
|
||||||
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL
|
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin
|
||||||
from llamaswap import read_config, write_config
|
from llamaswap import read_config, write_config
|
||||||
from jobengine import start_job, JOBS
|
from jobengine import start_job, JOBS
|
||||||
from recipes import RECIPES, UPGRADES
|
from recipes import RECIPES, UPGRADES
|
||||||
@@ -149,7 +149,7 @@ def install_recipe(req: InstallRecipeReq):
|
|||||||
if not recipe:
|
if not recipe:
|
||||||
raise HTTPException(404, "Setup nicht gefunden.")
|
raise HTTPException(404, "Setup nicht gefunden.")
|
||||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
env = {"HF_XET_HIGH_PERFORMANCE": "1"}
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if req.hf_token:
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = req.hf_token
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
@@ -160,7 +160,7 @@ def install_recipe(req: InstallRecipeReq):
|
|||||||
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
|
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
|
||||||
target = MODELS_DIR / m["repo"].split("/")[-1]
|
target = MODELS_DIR / m["repo"].split("/")[-1]
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
args = ["hf", "download", m["repo"], file, "--local-dir", str(target)]
|
args = [hf_bin(), "download", m["repo"], file, "--local-dir", str(target)]
|
||||||
jid = start_job(args, f"download {m['name']}", env=env)
|
jid = start_job(args, f"download {m['name']}", env=env)
|
||||||
JOBS[jid]["result_path"] = str(target / file)
|
JOBS[jid]["result_path"] = str(target / file)
|
||||||
job_ids.append(jid)
|
job_ids.append(jid)
|
||||||
@@ -226,12 +226,12 @@ def install_model(req: InstallModelReq):
|
|||||||
if not file:
|
if not file:
|
||||||
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
|
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
|
||||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
env = {"HF_XET_HIGH_PERFORMANCE": "1"}
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if req.hf_token:
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = req.hf_token
|
||||||
target = MODELS_DIR / req.repo.split("/")[-1]
|
target = MODELS_DIR / req.repo.split("/")[-1]
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
jid = start_job(["hf", "download", req.repo, file, "--local-dir", str(target)],
|
jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)],
|
||||||
f"download {req.repo.split('/')[-1]}", env=env)
|
f"download {req.repo.split('/')[-1]}", env=env)
|
||||||
JOBS[jid]["result_path"] = str(target / file)
|
JOBS[jid]["result_path"] = str(target / file)
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
|||||||
+4
-13
@@ -13,26 +13,18 @@ from pydantic import BaseModel
|
|||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR, TOKEN
|
from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
|
||||||
|
MODELS_DIR, TOKEN, hf_bin)
|
||||||
from jobengine import JOBS, start_job
|
from jobengine import JOBS, start_job
|
||||||
from llamaswap import _swap_get, read_config, write_config
|
from llamaswap import _swap_get, read_config, write_config
|
||||||
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
|
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
|
|
||||||
|
|
||||||
def _hf_bin() -> str:
|
|
||||||
"""Pfad zur `hf`-CLI. Bevorzugt die im venv installierte (neben dem laufenden
|
|
||||||
Python), da der Dienst-PATH das venv/bin meist nicht enthaelt. Faellt sonst auf
|
|
||||||
ein global installiertes `hf` zurueck."""
|
|
||||||
cand = os.path.join(os.path.dirname(sys.executable), "hf")
|
|
||||||
return cand if os.path.exists(cand) else "hf"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request-Modelle
|
# Request-Modelle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -148,9 +140,8 @@ def download(req: DownloadReq):
|
|||||||
sub = req.subdir or req.repo.split("/")[-1]
|
sub = req.subdir or req.repo.split("/")[-1]
|
||||||
target = MODELS_DIR / sub
|
target = MODELS_DIR / sub
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
args = [_hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
|
args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
|
||||||
# XET deaktivieren: mit aktivem XET haengt der Download reproduzierbar bei ~6 MB (siehe CLAUDE.md).
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
env = {"HF_HUB_DISABLE_XET": "1"}
|
|
||||||
if req.hf_token:
|
if req.hf_token:
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = req.hf_token
|
||||||
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
|
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
|||||||
color:var(--tx);font-family:var(--sans)}
|
color:var(--tx);font-family:var(--sans)}
|
||||||
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
|
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
|
||||||
.card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
|
.card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
|
||||||
|
.card.res-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
|
||||||
.cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500}
|
.cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500}
|
||||||
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
|
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
|
||||||
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
|
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
|
||||||
|
|||||||
@@ -21,6 +21,28 @@ window.cnGo = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click(
|
|||||||
function baseUrl() { return `${location.protocol}//${location.hostname}:8080/v1`; }
|
function baseUrl() { return `${location.protocol}//${location.hostname}:8080/v1`; }
|
||||||
function models() { return S?.models || []; }
|
function models() { return S?.models || []; }
|
||||||
|
|
||||||
|
// Fertige opencode.json fuer einen OpenAI-kompatiblen Provider auf llama-swap.
|
||||||
|
function openCodeConfig(url, ids) {
|
||||||
|
const list = ids.length ? ids : ["coder"];
|
||||||
|
const modelMap = list.map(id => ` "${id}": { "name": "${id}" }`).join(",\n");
|
||||||
|
return `{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"llama-swap": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Bosgame (llama-swap)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "${url}",
|
||||||
|
"apiKey": "local"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
${modelMap}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
function field(label, value) {
|
function field(label, value) {
|
||||||
return `<label>${label}</label>
|
return `<label>${label}</label>
|
||||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||||
@@ -76,12 +98,17 @@ function render() {
|
|||||||
|
|
||||||
<div class="card" style="padding:0;overflow:hidden">
|
<div class="card" style="padding:0;overflow:hidden">
|
||||||
<details class="guide-acc" open>
|
<details class="guide-acc" open>
|
||||||
<summary>OpenCode (Windows) — Config</summary>
|
<summary>OpenCode — Config-Datei (JSON)</summary>
|
||||||
<div class="acc-body">
|
<div class="acc-body">
|
||||||
<p>Lege in OpenCode einen OpenAI-kompatiblen Provider an:</p>
|
<p>OpenCode richtest du nicht über Felder ein, sondern über eine <b>Config-Datei</b>. Lege sie hier an:</p>
|
||||||
${field("Basis-URL", url)}
|
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\\.config\\opencode\\opencode.json</code><br>
|
||||||
${field("Modell-ID", firstModel)}
|
(oder im Projektordner als <code>opencode.json</code>)</p>
|
||||||
${field("API-Key", "(dein Token oder leer lassen)")}
|
<div style="position:relative;margin-top:12px">
|
||||||
|
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick="window.mcCopyCode(this)">Kopieren</button>
|
||||||
|
<div class="log" style="max-height:none"><code>${esc(openCodeConfig(url, ids))}</code></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:12px">Danach in OpenCode <code>/models</code> tippen und <b>llama-swap/${esc(firstModel)}</b> wählen. Das Auto-Swapping lädt das passende Modell von selbst.</p>
|
||||||
|
<div class="hint" style="margin-top:8px"><b>Fehler „Connect to API could not be established"?</b> Dann zeigt die <code>baseURL</code> ins Leere. Sie muss auf die <b>LAN-IP des Bosgame</b> zeigen (oben: <code>${esc(url)}</code>) — niemals <code>localhost</code> oder <code>127.0.0.1</code>, denn das wäre dein eigener PC, nicht der Server.</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="guide-acc">
|
<details class="guide-acc">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ let currentAnalysis = null;
|
|||||||
let activeFilter = "";
|
let activeFilter = "";
|
||||||
let RECIPES = [];
|
let RECIPES = [];
|
||||||
let RECOMMENDED = null;
|
let RECOMMENDED = null;
|
||||||
|
let cardFits = []; // Fit-Level je Such-Karte (fuer „Beste Wahl"-Markierung)
|
||||||
|
const FIT_RANK = { perfect: 0, marginal: 1, too_tight: 2 };
|
||||||
|
|
||||||
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
||||||
const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß";
|
const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß";
|
||||||
@@ -211,6 +213,7 @@ async function doSearch() {
|
|||||||
|
|
||||||
function renderResults(results) {
|
function renderResults(results) {
|
||||||
const grid = $("#cb-grid");
|
const grid = $("#cb-grid");
|
||||||
|
cardFits = [];
|
||||||
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
|
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
|
||||||
grid.innerHTML = results.map((m, i) => `
|
grid.innerHTML = results.map((m, i) => `
|
||||||
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
|
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
|
||||||
@@ -233,11 +236,28 @@ async function fetchFitForCard(i, repo_id) {
|
|||||||
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
|
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
|
||||||
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
|
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
|
||||||
if (!b || !mt) return;
|
if (!b || !mt) return;
|
||||||
if (!res.files?.length) { b.className = "fit-badge"; b.style.cssText = "background:rgba(139,151,165,.14);color:var(--mut)"; b.title = GGUF_HELP; b.textContent = "kein GGUF"; mt.textContent = "—"; return; }
|
if (!res.files?.length) { b.className = "fit-badge"; b.style.cssText = "background:rgba(139,151,165,.14);color:var(--mut)"; b.title = GGUF_HELP; b.textContent = "kein GGUF"; mt.textContent = "—"; cardFits[i] = null; markBestResult(); return; }
|
||||||
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
||||||
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
||||||
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
||||||
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } }
|
cardFits[i] = best.fit.level; markBestResult();
|
||||||
|
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } cardFits[i] = null; markBestResult(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markiert die beste System-Wahl in den Suchergebnissen: das meistgeladene Modell
|
||||||
|
// (Liste ist nach Downloads sortiert), das noch sauber auf die Hardware passt.
|
||||||
|
function markBestResult() {
|
||||||
|
const grid = $("#cb-grid"); if (!grid) return;
|
||||||
|
let bestI = -1, bestRank = 99;
|
||||||
|
cardFits.forEach((lvl, i) => {
|
||||||
|
if (lvl == null) return;
|
||||||
|
const rank = FIT_RANK[lvl] ?? 99;
|
||||||
|
if (rank < bestRank) { bestRank = rank; bestI = i; }
|
||||||
|
});
|
||||||
|
grid.querySelectorAll("[data-res]").forEach(el => { el.classList.remove("res-best"); el.querySelector(".cb-best-tag")?.remove(); });
|
||||||
|
if (bestI < 0 || bestRank > 1) return; // nur hervorheben, wenn etwas Lauffähiges (perfect/marginal) dabei ist
|
||||||
|
const el = grid.querySelector(`[data-res="${bestI}"]`);
|
||||||
|
if (el) { el.classList.add("res-best"); el.insertAdjacentHTML("afterbegin", `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFit() {
|
function showFit() {
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function mount() {
|
|||||||
${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")}
|
${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="w-update-card" style="display:none">
|
||||||
|
<div class="card-h"><h3>Aktueller Vorgang</h3><span id="w-update-badge" style="margin-left:auto"></span></div>
|
||||||
|
<div class="card-sub" id="w-update-label">—</div>
|
||||||
|
<div id="w-update-log" class="log" style="max-height:280px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h3>Live-Konsole</h3>
|
<div class="card-h"><h3>Live-Konsole</h3>
|
||||||
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
|
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
|
||||||
@@ -93,8 +99,46 @@ async function selfUpdate() {
|
|||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Live-Verlauf laufender Vorgaenge (Updates) direkt auf der Server-Seite ----
|
||||||
|
let activeJob = null; // { id, label }
|
||||||
|
const notified = new Set(); // Job-IDs, fuer die schon eine Abschluss-Meldung kam
|
||||||
|
|
||||||
|
function watchJob(id, label) {
|
||||||
|
activeJob = { id, label };
|
||||||
|
const card = $("#w-update-card");
|
||||||
|
if (!card) return;
|
||||||
|
card.style.display = "";
|
||||||
|
$("#w-update-label").textContent = label + " läuft…";
|
||||||
|
$("#w-update-badge").innerHTML = '<span class="badge b-load">läuft…</span>';
|
||||||
|
$("#w-update-log").textContent = "Starte…";
|
||||||
|
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobs(jobs) {
|
||||||
|
if (!activeJob) return;
|
||||||
|
const j = (jobs || []).find(x => x.id === activeJob.id);
|
||||||
|
const card = $("#w-update-card");
|
||||||
|
if (!j || !card) return;
|
||||||
|
card.style.display = "";
|
||||||
|
const logEl = $("#w-update-log");
|
||||||
|
if (logEl) { logEl.textContent = (j.log || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; }
|
||||||
|
const badge = $("#w-update-badge");
|
||||||
|
const done = j.state === "done", failed = j.state === "failed";
|
||||||
|
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
|
||||||
|
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
|
||||||
|
: '<span class="badge b-load">läuft…</span>';
|
||||||
|
if (done || failed) {
|
||||||
|
$("#w-update-label").textContent = activeJob.label + (done ? " — erfolgreich abgeschlossen." : " — fehlgeschlagen (siehe Protokoll).");
|
||||||
|
if (!notified.has(j.id)) {
|
||||||
|
notified.add(j.id);
|
||||||
|
toast(done ? "✓ " + activeJob.label + " abgeschlossen." : "✗ " + activeJob.label + " fehlgeschlagen.", failed);
|
||||||
|
if (done) setTimeout(refreshSoon, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
try { const r = await api("/api/update", { method: "POST" }); toast("llama.cpp-Update läuft — siehe Aktivität."); track(r.job_id); }
|
try { const r = await api("/api/update", { method: "POST" }); toast("LLM-Engine-Update läuft."); track(r.job_id); watchJob(r.job_id, "LLM-Engine-Update"); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +146,7 @@ async function osUpdate() {
|
|||||||
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
|
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
|
||||||
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
|
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
|
||||||
if (!pwd) return;
|
if (!pwd) return;
|
||||||
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); }
|
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); watchJob(r.job_id, "OS-Update"); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,4 +158,4 @@ async function rebootServer() {
|
|||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { id: "server", mount };
|
export default { id: "server", mount, onJobs };
|
||||||
|
|||||||
Reference in New Issue
Block a user