Browse Source

web: cache + settings windows; cache update is one code path

- 'cache' window (dock menu): the shared HF cache read over SSH — size-sorted
  repos with bars, the mount/df line, and a single-flight 'update all' with a
  live percentage polled from /api/cache/progress. CLI and dashboard share
  cache::collect/update_with (one implementation, two renderings).
- 'settings' window: the private config block in a real multi-line textarea.
  The Supervisor's option schema has no multi-line control (only
  str/password/int/bool/list(...)), so a whole TOML block could never live in
  an option; it now lives in /data/config-extra.toml (SM121_CONFIG_EXTRA_FILE
  overrides), validated as TOML on save and replaced atomically, with a
  refusal (400) that keeps a typo off the disk.
- entrypoint: append the block file, seed it from the legacy option on first
  boot, and keep booting when the dir is not writable (a plain docker run).
- smoke-addon: /data mount + migration/save/restart/400 assertions (green).
Alexander Korolev 18 giờ trước cách đây
mục cha
commit
fc460279dc

+ 18 - 8
AGENTS.md

@@ -66,7 +66,7 @@ See [`DEVELOPMENT.md`](DEVELOPMENT.md) for architecture, module layout, and dev
 
 ```
 serve/stop/bench/logs/health/doctor/exec/power/tmux/init-host-keys/config/web/report/runlock/validate
-repos/promote/archive/unarchive
+repos/promote/archive/unarchive/cache/hf
 ```
 
 `validate` — offline recipe-contract gate (no cluster, no SSH): checks every
@@ -962,13 +962,23 @@ unauthenticated) with a background re-sync loop
 (`SM121_REPO_RESYNC_MINUTES`). Its `custom_components/`
 custom-integration prototype is superseded (entities are a phase-2
 nicety over the add-on's REST API). The cluster definitions (private IPs
-— never in the public recipe repo) are supplied as the
-**`sm121_config_extra` add-on option**
-(raw TOML, appended to the generated config): `[[clusters]]`/nccl +
-`[home_assistant]` + `[mikrotik]` live in HA's own option storage —
-private, on the Pi, no second repo, no PAT. If that TOML carries
-`[home_assistant]`/`[registry]`, the matching flat options must stay
-empty (duplicate TOML tables fail the parse).
+— never in the public recipe repo) live in **`/data/config-extra.toml`**,
+edited from the dashboard's **settings window** (a real multi-line
+textarea; the Supervisor option schema has no multi-line control at all —
+only `str`/`password`/`int`/`bool`/`list(...)` — so the old
+`sm121_config_extra` option could never hold a TOML block). The entrypoint
+appends that file to the generated config and only uses the legacy option
+to **seed** the file on first boot (so an existing install keeps booting
+and the block appears in the editor). Saving validates TOML and writes
+atomically; it applies to the next command / the add-on restart. If the
+block carries `[home_assistant]`/`[registry]`, the matching flat options
+must stay empty (duplicate TOML tables fail the parse).
+
+The dashboard also has a **cache window** (`+ → cache…`): the shared HF
+cache read over SSH (no NAS mount on the Pi), a size-sorted repo table,
+and a single-flight `update` with a live percentage — the CLI
+`sm121 cache status|doctor|update` and the window share one
+implementation.
 
 **Single-launcher rule** — the per-recipe run ledger (`.lock` in the
 recipe repo) is the serialization point: a second launcher that serves

+ 29 - 1
scripts/smoke-addon.sh

@@ -38,14 +38,17 @@ git -C "$REPO" -c user.email=t@sm121.test -c user.name=sm121-test commit -qm ini
 # Persistent HOME (the add-on data folder in HA; here: the sm121 cache
 # dir — ~/.cache/sm121 — so re-runs reuse the recipe-repo cache).
 HOMEV="$(pwd)/target/smoke-addon-home"
-mkdir -p "$HOMEV"
+mkdir -p "$HOMEV/data"   # the add-on's persistent /data (block file + cache)
 
 CID="$(docker run -d \
   -p 3100:3100 \
   -v "$REPO":/repos:ro \
   -v "$HOMEV":/root \
+  -v "$HOMEV/data":/data \
   -e SM121_RECIPE_REPOS='[{"url":"file:///repos"}]' \
   -e SM121_SSH_USER=u \
+  -e SM121_CONFIG_EXTRA='[cache]
+node = "smoke-node"' \
   -e SM121_WEB_TOKEN="$TOKEN" \
   "$IMG")"
 trap 'docker rm -f "$CID" >/dev/null 2>&1 || true; rm -rf "$REPO"' EXIT
@@ -66,6 +69,31 @@ echo "--- container log (tail) ---"
 docker logs "$CID" 2>&1 | tail -5
 
 if [ -n "$ok" ]; then
+  # The private block moved from a one-line option into /data/config-extra.toml
+  # (edited in the dashboard's settings window): check the migration landed and
+  # that the editor API round-trips a save.
+  echo "--- /api/settings/extra (after migration) ---"
+  SET="$(curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3100/api/settings/extra)"
+  echo "$SET" | head -c 200; echo
+  echo "$SET" | grep -q 'smoke-node' || { echo "smoke FAILED: legacy option not migrated" >&2; exit 1; }
+  echo "$SET" | grep -q '/data/config-extra.toml' || { echo "smoke FAILED: unexpected block path" >&2; exit 1; }
+  curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
+    -d '{"text":"[cache]\nnode = \"edited-node\"\n"}' \
+    http://127.0.0.1:3100/api/settings/extra | grep -q 'edited-node' \
+    || { echo "smoke FAILED: save did not stick" >&2; exit 1; }
+  # invalid TOML must be refused (400), never reach the disk
+  code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" \
+    -H 'content-type: application/json' -d '{"text":"[cache"}' http://127.0.0.1:3100/api/settings/extra)"
+  [ "$code" = "400" ] || { echo "smoke FAILED: invalid TOML accepted ($code)" >&2; exit 1; }
+  echo "--- restart keeps the edited block ---"
+  docker restart "$CID" >/dev/null
+  for _ in $(seq 1 60); do
+    curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3100/api/state >/dev/null 2>&1 && break
+    sleep 1
+  done
+  curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3100/api/settings/extra \
+    | grep -q 'edited-node' || { echo "smoke FAILED: block lost on restart" >&2; exit 1; }
+  echo "settings: legacy migration + save + restart OK"
   echo "--- /api/state (first 200 chars) ---"
   curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3100/api/state | head -c 200
   echo

+ 20 - 0
specs/tech-architecture/ha-addon-recipe-repos.md

@@ -238,6 +238,26 @@ carry the cluster-side secrets (`[home_assistant]` token,
 `[mikrotik]` password). The public recipe repo carries only
 `recipes/` + `docker-images/`.
 
+## Addendum (2026-10-24): the private block is a file, not an option
+
+The Supervisor's add-on `schema` has no multi-line control (the accepted
+types are `str`, `password`, `bool`, `int`, `float`, `email`, `url`,
+`port`, `match(...)`, `list(...)`, `device` — no textarea, and the
+upstream feature request is still open), so `sm121_config_extra` — a raw
+TOML block with `[[clusters]]`, `[home_assistant]`, `[mikrotik]` — was
+unusable in the UI: one line, newline-escaped by hand.
+
+Now the block lives in **`/data/config-extra.toml`** (add-on `data`
+folder: persistent, backed up by the add-on backup) and is edited from the
+dashboard's **settings window** — a real multi-line editor with TOML
+validation before the write and an atomic replace. `SM121_CONFIG_EXTRA_FILE`
+overrides the path (a CLI install defaults to
+`<global config dir>/config-extra.toml`). The entrypoint appends the file
+to the generated config and uses the legacy option **only to seed** it when
+the file is absent — an existing install keeps booting and the block shows
+up in the editor on the first boot of 1.3.19. Saving does not reload the
+running dashboard (it keeps its loaded config); the panel says so.
+
 ## Addendum (2026-10-11, 3rd): cluster config as an HA option (simplification)
 
 The private `cluster-config` repo was dropped as over-complicated. The

+ 190 - 84
src/cache.rs

@@ -82,59 +82,85 @@ pub async fn dispatch(
     }
 }
 
-/// `sm121 cache status` — repos in the cache, their sizes, and the room left.
-pub async fn status(config: &Config, opts: &CacheOpts) -> Result<()> {
+/// One cached repo (name + size), shared by the CLI table and the API.
+#[derive(Clone, serde::Serialize)]
+pub struct CacheRepo {
+    pub repo: String,
+    pub bytes: u64,
+    pub gib: f64,
+}
+
+/// What the cache looks like right now: the node it was read from, the mount
+/// point, the filesystem line and every repo with its size.
+#[derive(Clone, serde::Serialize)]
+pub struct CacheStatus {
+    pub node: String,
+    pub hf_home: String,
+    pub df: String,
+    pub repos: Vec<CacheRepo>,
+    pub total_gib: f64,
+}
+
+/// Gather the cache status (`status` prints it, the dashboard serves it).
+pub async fn collect(config: &Config, opts: &CacheOpts) -> Result<CacheStatus> {
     let node = cache_node(config, opts.cluster.as_deref())?;
     let home = hf_home(config);
     let root = crate::docker::shq(&home);
     let cmd = format!(
-        "echo \"__HOME {home}\"; \
-         df -BG --output=size,used,avail {root} 2>/dev/null | tail -1; \
+        "df -BG --output=size,used,avail {root} 2>/dev/null | tail -1; \
          du -s --block-size=1 {root}/hub/*--* 2>/dev/null | awk '{{print $1, $2}}'"
     );
     let out = crate::ssh::run_ssh(config, &node, cmd, opts.dry_run).await?;
-    let mut free = String::new();
-    let mut repos: Vec<(u64, String)> = Vec::new();
+    let mut df = String::new();
+    let mut repos: Vec<CacheRepo> = Vec::new();
     for line in out.lines() {
-        if let Some(rest) = line.strip_prefix("__HOME ") {
-            if opts.json {
-                free = rest.to_string();
-            } else {
-                println!("cache: {rest} (on {})", node.name);
+        let toks: Vec<&str> = line.split_whitespace().collect();
+        match toks.as_slice() {
+            // `df -BG`: "931G 700G 231G" — size/used/avail, no path column
+            [size, used, avail] if size.parse::<u64>().is_err() => {
+                df = format!("{size} total, {used} used, {avail} avail");
             }
-            continue;
-        }
-        let mut it = line.split_whitespace();
-        if let (Some(bytes), Some(path)) = (it.next(), it.next())
-            && let Ok(b) = bytes.parse::<u64>()
-        {
-            let name = path.rsplit('/').next().unwrap_or(path).to_string();
-            repos.push((b, name));
+            // `du -s --block-size=1`: "<bytes> <path>"
+            [bytes, path] => {
+                if let Ok(b) = bytes.parse::<u64>() {
+                    let name = path.rsplit('/').next().unwrap_or(path).to_string();
+                    repos.push(CacheRepo {
+                        repo: name,
+                        bytes: b,
+                        gib: b as f64 / (1u64 << 30) as f64,
+                    });
+                }
+            }
+            _ => {}
         }
     }
-    repos.sort_by_key(|(b, _)| std::cmp::Reverse(*b));
+    repos.sort_by_key(|r| std::cmp::Reverse(r.bytes));
+    let total_gib = repos.iter().map(|r| r.gib).sum();
+    Ok(CacheStatus {
+        node: node.name,
+        hf_home: home,
+        df,
+        repos,
+        total_gib,
+    })
+}
+
+/// `sm121 cache status` — repos in the cache, their sizes, and the room left.
+pub async fn status(config: &Config, opts: &CacheOpts) -> Result<()> {
+    let st = collect(config, opts).await?;
     if opts.json {
-        let items: Vec<_> = repos
-            .iter()
-            .map(|(b, n)| json!({"repo": n, "bytes": b, "gib": (*b as f64) / (1u64 << 30) as f64}))
-            .collect();
-        println!(
-            "{}",
-            serde_json::to_string_pretty(&json!({
-                "node": node.name, "hf_home": home, "df": free, "repos": items,
-            }))?
-        );
+        println!("{}", serde_json::to_string_pretty(&st)?);
         return Ok(());
     }
-    let total: u64 = repos.iter().map(|(b, _)| b).sum();
+    println!("cache: {} (on {}, df {})", st.hf_home, st.node, st.df);
     println!("{:<58} {:>9}", "repo", "GiB");
-    for (b, n) in &repos {
-        println!("{:<58} {:>9.1}", n, *b as f64 / (1u64 << 30) as f64);
+    for r in &st.repos {
+        println!("{:<58} {:>9.1}", r.repo, r.gib);
     }
     println!(
         "{:<58} {:>9.1}",
-        format!("— {} repos", repos.len()),
-        total as f64 / (1u64 << 30) as f64
+        format!("— {} repos", st.repos.len()),
+        st.total_gib
     );
     Ok(())
 }
@@ -208,6 +234,23 @@ async fn expected_bytes(repo: &str, rev: &str) -> Option<u64> {
     (total > 0).then_some(total)
 }
 
+/// Live state of a running `cache update` (the dashboard renders it, the CLI
+/// renders the same fields as a progress line).
+#[derive(Clone, Default, serde::Serialize)]
+pub struct UpdateProgress {
+    pub running: bool,
+    pub stage: String,
+    pub repo: Option<String>,
+    pub index: usize,
+    pub total: usize,
+    pub bytes: u64,
+    pub expected: Option<u64>,
+    pub percent: Option<f64>,
+    pub failed: Vec<String>,
+    pub node: String,
+    pub message: String,
+}
+
 /// `sm121 cache update` — refresh every cached repo to its refs (or the ones
 /// selected with `--repo`), the same loop `hf-cache-maintain.sh` runs, but over
 /// SSH so the machine driving it needs no mount.
@@ -222,9 +265,49 @@ pub async fn update(
     opts: &CacheOpts,
     repos: &[String],
     apply: bool,
+) -> Result<()> {
+    let mut last = String::new();
+    update_with(config, opts, repos, apply, |p| {
+        if p.stage == "download" {
+            let text = p.message.clone();
+            if text != last {
+                use std::io::Write;
+                print!("\r  {text:<70}");
+                let _ = std::io::stdout().flush();
+                last = text;
+            }
+        } else if p.stage == "repo-done" {
+            if !last.is_empty() {
+                println!();
+                last.clear();
+            }
+            println!("  {}", p.message);
+        } else if p.stage == "dry-run" {
+            println!("  {}", p.message);
+        }
+    })
+    .await
+}
+
+/// Same as `update`, but reports every step to `report` — the dashboard's
+/// background job and the CLI progress line are two renderings of one loop.
+pub async fn update_with<F: FnMut(&UpdateProgress)>(
+    config: &Config,
+    opts: &CacheOpts,
+    repos: &[String],
+    apply: bool,
+    mut report: F,
 ) -> Result<()> {
     let node = cache_node(config, opts.cluster.as_deref())?;
     let home = hf_home(config);
+    let mut prog = UpdateProgress {
+        running: true,
+        stage: "listing".into(),
+        node: node.name.clone(),
+        message: "listing cached repos".into(),
+        ..Default::default()
+    };
+    report(&prog);
     let list_cmd = format!(
         "ls -d {root}/hub/*--* 2>/dev/null",
         root = crate::docker::shq(&home)
@@ -248,62 +331,85 @@ pub async fn update(
         targets.push((repo, dir.to_string()));
     }
     if targets.is_empty() {
-        println!("nothing to update on {}", node.name);
+        prog.running = false;
+        prog.stage = "done".into();
+        prog.message = format!("nothing to update on {}", node.name);
+        report(&prog);
         return Ok(());
     }
+    prog.total = targets.len();
     let mut failures = 0usize;
-    for (repo, dir) in &targets {
-        for rev in ["main"] {
-            let total = expected_bytes(repo, rev).await;
-            if !apply || opts.dry_run {
-                println!("  [dry-run] hfrs download {repo} --revision {rev}");
-                continue;
-            }
-            let quoted_dir = crate::docker::shq(dir);
-            let cmd = format!(
-                "export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"; \\
-                 ( hfrs download {repo} --type model --revision {rev} \\
-                     --disable-progress-bars --quiet >/dev/null 2>&1 & p=$!; \\
-                   while kill -0 $p 2>/dev/null; do \\
-                     b=$(du -sb {quoted_dir}/blobs 2>/dev/null | cut -f1); \\
-                     echo \"PROGRESS ${{b:-0}}\"; sleep 10; done; wait $p; echo \"EXIT $?\" )"
-            );
-            let out = crate::ssh::run_ssh(config, &node, cmd, false).await?;
-            let mut last_line_len = 0usize;
-            let mut ok = true;
-            for line in out.lines() {
-                if let Some(bytes) = line.strip_prefix("PROGRESS ") {
-                    let cur: u64 = bytes.trim().parse().unwrap_or(0);
-                    let text = match total {
-                        Some(t) if t > 1_000_000 => format!(
-                            "{repo} {:.1}% ({:.1} / {:.1} GB)",
-                            (cur as f64 / t as f64 * 100.0).min(100.0),
-                            cur as f64 / 1e9,
-                            t as f64 / 1e9
-                        ),
-                        _ => format!("{repo} {:.1} GB", cur as f64 / 1e9),
-                    };
-                    print!("\r  {text:<70}");
-                    last_line_len = text.len();
-                    use std::io::Write;
-                    let _ = std::io::stdout().flush();
-                } else if let Some(code) = line.strip_prefix("EXIT ") {
-                    ok = code.trim() == "0";
-                }
-            }
-            if last_line_len > 0 {
-                println!();
-            }
-            if ok {
-                println!("  [ok]   {repo} @ {rev}");
-            } else {
-                println!("  [FAIL] {repo} @ {rev}");
-                failures += 1;
+    for (i, (repo, dir)) in targets.iter().enumerate() {
+        let rev = "main";
+        prog.index = i + 1;
+        prog.repo = Some(repo.clone());
+        prog.bytes = 0;
+        prog.percent = None;
+        prog.expected = expected_bytes(repo, rev).await;
+        prog.stage = "repo-start".into();
+        prog.message = format!("{repo} @ {rev}");
+        report(&prog);
+        if !apply || opts.dry_run {
+            prog.stage = "dry-run".into();
+            prog.message = format!("[dry-run] hfrs download {repo} --revision {rev}");
+            report(&prog);
+            continue;
+        }
+        let quoted_dir = crate::docker::shq(dir);
+        let cmd = format!(
+            "export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"; \
+             ( hfrs download {repo} --type model --revision {rev} \
+                 --disable-progress-bars --quiet >/dev/null 2>&1 & p=$!; \
+               while kill -0 $p 2>/dev/null; do \
+                 b=$(du -sb {quoted_dir}/blobs 2>/dev/null | cut -f1); \
+                 echo \"PROGRESS ${{b:-0}}\"; sleep 10; done; wait $p; echo \"EXIT $?\" )"
+        );
+        prog.stage = "download".into();
+        report(&prog);
+        let out = crate::ssh::run_ssh(config, &node, cmd, false).await?;
+        let mut ok = true;
+        for line in out.lines() {
+            if let Some(bytes) = line.strip_prefix("PROGRESS ") {
+                prog.bytes = bytes.trim().parse().unwrap_or(0);
+                prog.percent = prog
+                    .expected
+                    .filter(|t| *t > 1_000_000)
+                    .map(|t| (prog.bytes as f64 / t as f64 * 100.0).min(100.0));
+                prog.message = match prog.expected {
+                    Some(t) if t > 1_000_000 => format!(
+                        "{repo} {:.1}% ({:.1} / {:.1} GB)",
+                        prog.percent.unwrap_or(0.0),
+                        prog.bytes as f64 / 1e9,
+                        t as f64 / 1e9
+                    ),
+                    _ => format!("{repo} {:.1} GB", prog.bytes as f64 / 1e9),
+                };
+                report(&prog);
+            } else if let Some(code) = line.strip_prefix("EXIT ") {
+                ok = code.trim() == "0";
             }
         }
+        prog.stage = "repo-done".into();
+        if ok {
+            prog.message = format!("[ok]   {repo} @ {rev}");
+        } else {
+            prog.message = format!("[FAIL] {repo} @ {rev}");
+            prog.failed.push(repo.clone());
+            failures += 1;
+        }
+        report(&prog);
     }
+    prog.running = false;
+    prog.stage = "done".into();
+    prog.repo = None;
+    prog.message = if failures > 0 {
+        format!("{failures} repo(s) failed to update on {}", node.name)
+    } else {
+        format!("updated {} repo(s) on {}", targets.len(), node.name)
+    };
+    report(&prog);
     if failures > 0 {
-        anyhow::bail!("{failures} repo(s) failed to update on {}", node.name);
+        anyhow::bail!("{}", prog.message);
     }
     Ok(())
 }

+ 1 - 0
src/main.rs

@@ -61,6 +61,7 @@ mod recipe_repos;
 mod registry;
 mod report;
 mod runlock;
+mod settings;
 mod ssh;
 mod staging;
 mod stamp;

+ 128 - 0
src/settings.rs

@@ -0,0 +1,128 @@
+//! The private config block, editable from the dashboard.
+//!
+//! Home Assistant's add-on option form has no multi-line control (the
+//! Supervisor schema only knows `str`/`password`/`int`/`bool`/`list(...)`,
+//! so a whole TOML block cannot live in an option), which is why the
+//! add-on's `sm121_config_extra` option is a one-line field nobody can use.
+//!
+//! Instead the block lives in a **file** the dashboard edits with a real
+//! textarea: `SM121_CONFIG_EXTRA_FILE` (the add-on points it at
+//! `/data/config-extra.toml`, so it survives restarts and rides the add-on
+//! backup) or `<global config dir>/config-extra.toml` for a CLI install. The
+//! entrypoint appends it to the generated config; a plain `-c/--config` run
+//! can `include` it. Validation happens on save (a TOML parse), so a typo
+//! cannot brick the next boot.
+
+use anyhow::{Context, Result};
+use std::path::PathBuf;
+
+/// Where the private block lives.
+pub fn extra_config_path() -> Option<PathBuf> {
+    if let Ok(p) = std::env::var("SM121_CONFIG_EXTRA_FILE")
+        && !p.trim().is_empty()
+    {
+        return Some(PathBuf::from(p.trim()));
+    }
+    crate::config::default_config_dir().map(|d| d.join("config-extra.toml"))
+}
+
+/// What the dashboard shows in the editor.
+#[derive(Clone, Debug, serde::Serialize)]
+pub struct ExtraConfig {
+    pub path: String,
+    pub exists: bool,
+    pub text: String,
+    /// True when the file is what the running add-on actually reads (the
+    /// entrypoint sets the env var); otherwise the panel is a plain editor.
+    pub managed: bool,
+}
+
+/// Read the block (a missing file is an empty block, not an error).
+pub fn read_extra() -> Result<ExtraConfig> {
+    let path = extra_config_path()
+        .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?;
+    let managed = std::env::var("SM121_CONFIG_EXTRA_FILE")
+        .map(|v| !v.trim().is_empty())
+        .unwrap_or(false);
+    let text = match std::fs::read_to_string(&path) {
+        Ok(t) => t,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
+        Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
+    };
+    Ok(ExtraConfig {
+        path: path.display().to_string(),
+        exists: path.exists(),
+        text,
+        managed,
+    })
+}
+
+/// Validate + save. An empty body deletes the file (the block goes away);
+/// a parse error is reported before anything is written.
+pub fn write_extra(text: &str) -> Result<ExtraConfig> {
+    let path = extra_config_path()
+        .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?;
+    let trimmed = text.trim();
+    if trimmed.is_empty() {
+        match std::fs::remove_file(&path) {
+            Ok(()) => {}
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+            Err(e) => return Err(e).with_context(|| format!("cannot remove {}", path.display())),
+        }
+        return read_extra();
+    }
+    toml::from_str::<toml::Value>(trimmed).map_err(|e| anyhow::anyhow!("TOML parse error: {e}"))?;
+    if let Some(dir) = path.parent() {
+        std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
+    }
+    // Atomic: a crash mid-write must not leave a half block for the boot.
+    let tmp = path.with_extension("toml.tmp");
+    std::fs::write(&tmp, format!("{}\n", trimmed.trim_end()))
+        .with_context(|| format!("cannot write {}", tmp.display()))?;
+    std::fs::rename(&tmp, &path).with_context(|| format!("cannot replace {}", path.display()))?;
+    read_extra()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn temp_path(name: &str) -> PathBuf {
+        let p = std::env::temp_dir().join(format!("sm121-settings-{name}-{}", std::process::id()));
+        let _ = std::fs::remove_file(&p);
+        p
+    }
+
+    #[test]
+    fn save_and_read_roundtrip() {
+        let p = temp_path("roundtrip");
+        // SAFETY: single-threaded test binary section; no other thread reads it.
+        unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
+        let saved = write_extra("[cache]\nnode = \"dgx5.lan\"\n").expect("save");
+        assert!(saved.exists && saved.managed);
+        assert!(saved.text.contains("dgx5.lan"));
+        let read = read_extra().expect("read");
+        assert_eq!(read.text.trim(), saved.text.trim());
+        let _ = std::fs::remove_file(&p);
+    }
+
+    #[test]
+    fn invalid_toml_is_refused_and_nothing_is_written() {
+        let p = temp_path("invalid");
+        unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
+        let err = write_extra("[cache\nnode = 1").expect_err("must fail");
+        assert!(err.to_string().contains("TOML"));
+        assert!(!p.exists(), "a rejected block must not reach the disk");
+    }
+
+    #[test]
+    fn empty_body_deletes_the_file() {
+        let p = temp_path("delete");
+        unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
+        write_extra("a = 1\n").expect("save");
+        assert!(p.exists());
+        let after = write_extra("   \n").expect("clear");
+        assert!(!after.exists);
+        assert!(after.text.is_empty());
+    }
+}

+ 104 - 0
src/web/cache_api.rs

@@ -0,0 +1,104 @@
+//! Cache window API: `/api/cache` (status), `/api/cache/update` (start) and
+//! `/api/cache/progress` (poll).
+//!
+//! Same code the CLI runs (`cache::collect` / `cache::update_with`) — the
+//! dashboard adds a single-flight guard and a progress slot, so one job at a
+//! time and the panel can render a percentage while it downloads.
+
+use axum::Json;
+use axum::extract::State;
+use axum::http::StatusCode;
+use serde::Deserialize;
+use serde_json::json;
+
+use super::AppState;
+use crate::cache::{self, CacheOpts, UpdateProgress};
+
+#[derive(Deserialize, Default)]
+pub(crate) struct UpdateBody {
+    #[serde(default)]
+    cluster: Option<String>,
+    #[serde(default)]
+    repos: Vec<String>,
+}
+
+fn job_of(app: &AppState) -> Option<UpdateProgress> {
+    app.cache_job
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner)
+        .clone()
+}
+
+/// `GET /api/cache` — the cache as it is on the node (SSH `du`, takes a few
+/// seconds) plus whatever the last/current update job left behind.
+pub(crate) async fn status(State(app): State<AppState>) -> Json<serde_json::Value> {
+    let opts = CacheOpts {
+        cluster: None,
+        json: true,
+        dry_run: false,
+    };
+    let job = job_of(&app);
+    match cache::collect(&app.config, &opts).await {
+        Ok(st) => Json(json!({ "ok": true, "status": st, "job": job })),
+        Err(e) => Json(json!({ "ok": false, "error": e.to_string(), "job": job })),
+    }
+}
+
+/// `GET /api/cache/progress` — just the job slot (cheap poll while running).
+pub(crate) async fn progress(State(app): State<AppState>) -> Json<serde_json::Value> {
+    Json(json!({ "job": job_of(&app) }))
+}
+
+/// `POST /api/cache/update` — start the refresh. One at a time; the panel
+/// polls `/api/cache/progress` for the percentage.
+pub(crate) async fn update_start(
+    State(app): State<AppState>,
+    Json(body): Json<UpdateBody>,
+) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
+    {
+        let mut slot = app
+            .cache_job
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        if slot.as_ref().is_some_and(|p| p.running) {
+            return Err((
+                StatusCode::CONFLICT,
+                "a cache update is already running".into(),
+            ));
+        }
+        *slot = Some(UpdateProgress {
+            running: true,
+            stage: "starting".into(),
+            message: "starting the cache update".into(),
+            ..Default::default()
+        });
+    }
+    let opts = CacheOpts {
+        cluster: body.cluster.clone(),
+        json: false,
+        dry_run: false,
+    };
+    let repos = body.repos.clone();
+    let slot = app.cache_job.clone();
+    let config = app.config.clone();
+    tokio::spawn(async move {
+        let writer = slot.clone();
+        let res = cache::update_with(&config, &opts, &repos, true, move |p| {
+            *writer
+                .lock()
+                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(p.clone());
+        })
+        .await;
+        if let Err(e) = res {
+            let mut guard = slot
+                .lock()
+                .unwrap_or_else(std::sync::PoisonError::into_inner);
+            let mut p = guard.clone().unwrap_or_default();
+            p.running = false;
+            p.stage = "done".into();
+            p.message = e.to_string();
+            *guard = Some(p);
+        }
+    });
+    Ok(Json(json!({ "started": true })))
+}

+ 12 - 0
src/web/mod.rs

@@ -9,6 +9,7 @@
 //! Design: `specs/tech-architecture/web-dashboard.md`.
 
 mod api;
+mod cache_api;
 pub mod events;
 pub(crate) mod flare_nodes;
 mod hf_api;
@@ -17,6 +18,7 @@ pub mod logs;
 pub mod model_settings;
 pub mod probe;
 mod repos;
+mod settings_api;
 pub mod ssh_stream;
 pub mod topo;
 
@@ -61,6 +63,8 @@ pub struct AppState {
     pub jobs: Arc<Mutex<Vec<jobs::Job>>>,
     /// Tracked log streams; capped at `logs::MAX_ATTACHES`.
     pub log_attaches: Arc<Mutex<Vec<logs::LogAttach>>>,
+    /// The cache window's single-flight update job (progress slot).
+    pub cache_job: Arc<Mutex<Option<crate::cache::UpdateProgress>>>,
 }
 
 /// Probe the fleet and broadcast a fresh state snapshot (supervisor tick
@@ -272,6 +276,13 @@ pub fn router(app: AppState) -> Router {
         .route("/api/repos", get(repos::repos_status))
         .route("/api/hf/search", get(hf_api::search))
         .route("/api/hf/card", get(hf_api::card))
+        .route("/api/cache", get(cache_api::status))
+        .route("/api/cache/progress", get(cache_api::progress))
+        .route("/api/cache/update", post(cache_api::update_start))
+        .route(
+            "/api/settings/extra",
+            get(settings_api::get_extra).post(settings_api::save_extra),
+        )
         .route("/api/login", post(login))
         .route(
             "/api/logs",
@@ -337,6 +348,7 @@ pub async fn run(
         last: Arc::new(Mutex::new(None)),
         jobs: Arc::new(Mutex::new(Vec::new())),
         log_attaches: Arc::new(Mutex::new(Vec::new())),
+        cache_job: Arc::new(Mutex::new(None)),
     };
     jobs::load(&app); // history survives restarts; in-flight jobs -> failed
 

+ 37 - 0
src/web/settings_api.rs

@@ -0,0 +1,37 @@
+//! `GET/POST /api/settings/extra` — the private config block editor.
+//!
+//! The add-on's option form cannot hold a TOML block (no multi-line control in
+//! the Supervisor schema), so the dashboard edits `config-extra.toml` directly:
+//! a real textarea, validated on save (parse) and written atomically. Saving
+//! does not reload the running process — the panel says so; the block is read
+//! by the next `sm121` command (and by the add-on entrypoint at boot).
+
+use axum::http::StatusCode;
+use axum::{Json, extract::State};
+use serde::Deserialize;
+
+use super::AppState;
+
+#[derive(Deserialize)]
+pub(crate) struct SaveBody {
+    text: String,
+}
+
+/// `GET /api/settings/extra`
+pub(crate) async fn get_extra(
+    State(_app): State<AppState>,
+) -> Result<Json<crate::settings::ExtraConfig>, (StatusCode, String)> {
+    crate::settings::read_extra()
+        .map(Json)
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
+}
+
+/// `POST /api/settings/extra` — validate, save, echo the stored state.
+pub(crate) async fn save_extra(
+    State(_app): State<AppState>,
+    Json(body): Json<SaveBody>,
+) -> Result<Json<crate::settings::ExtraConfig>, (StatusCode, String)> {
+    crate::settings::write_extra(&body.text)
+        .map(Json)
+        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
+}

+ 1 - 0
src/web/tests.rs

@@ -71,6 +71,7 @@ pub(crate) fn app_state_with(config: Config, token: Option<&str>) -> AppState {
         last: std::sync::Arc::new(std::sync::Mutex::new(None)),
         jobs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
         log_attaches: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
+        cache_job: std::sync::Arc::new(std::sync::Mutex::new(None)),
     }
 }
 

+ 178 - 0
src/web/ui.html

@@ -239,6 +239,31 @@
   .rawbar .abtn { height: 26px; }
   .rawnote { font-size: 12px; color: var(--muted); flex: 1; }
 
+  /* --- cache + settings windows ---------------------------------------------- */
+  .cachewin .win-body, .setwin .win-body { display: flex; flex-direction: column; padding: 0; }
+  .cachebar, .setbar {
+    display: flex; gap: 8px; align-items: center; padding: 8px 10px;
+    border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; font-size: 12px;
+  }
+  .cachebar .adv, .setbar .adv { color: var(--muted); flex: 1; min-width: 120px; }
+  .cachebar .abtn, .setbar .abtn { height: 26px; }
+  .cscroll { overflow: auto; flex: 1; }
+  .ctable { width: 100%; border-collapse: collapse; font-size: 12px; }
+  .ctable th { position: sticky; top: 0; background: var(--panel); text-align: left; font-weight: 600; padding: 4px 10px; }
+  .ctable td { padding: 3px 10px; border-top: 1px solid var(--border); white-space: nowrap; }
+  .ctable td.num { text-align: right; font-variant-numeric: tabular-nums; }
+  .ctable td.barc { width: 90px; }
+  .cbarwrap { background: var(--btn); border-radius: 3px; height: 7px; }
+  .cbar { height: 7px; background: var(--accent); border-radius: 3px; }
+  .cprog { padding: 6px 10px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
+  .setnote { padding: 6px 10px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
+  .settxt {
+    flex: 1; width: 100%; box-sizing: border-box; resize: none; border: 0; padding: 10px;
+    background: var(--bg); color: var(--fg); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
+    outline: none; tab-size: 2;
+  }
+  .setstat { font-size: 12px; color: var(--muted); }
+
   /* --- polish: skeleton + attention ------------------------------------------ */
   .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
     background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
@@ -822,6 +847,8 @@ function toggleDockMenu() {
   item("recipes", openRecipes);
   item("logs…", openLogsPicker);
   item("raw", openRaw);
+  item("cache…", openCache);
+  item("settings…", openSettings);
   document.body.appendChild(m);
   // Anchor the menu to the + button that opened it (the chips flow left,
   // so the button sits at the dock's left edge) — a fixed bottom-right menu
@@ -1916,6 +1943,157 @@ function openRecipes(g) {
   return recipesWin;
 }
 
+// --- cache window ------------------------------------------------------------
+// The shared HF cache on the NAS, read over SSH through the cluster (the Pi
+// needs no mount). `update` is a single-flight background job on the server;
+// the panel polls its progress and re-reads the table when it finishes.
+let cacheWin = null;
+async function openCache(g) {
+  if (cacheWin) { activateWindow(cacheWin); return cacheWin; }
+  const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
+  const handle = makeWin({
+    title: "cache", short: "cache", x: r("left", 60), y: r("top", 60),
+    w: r("width", Math.min(720, innerWidth - 60)), h: r("height", Math.min(520, innerHeight * 0.7)),
+    bodyHtml: `<div class="cachebar">
+        <span class="adv cacheadv">loading…</span>
+        <button class="abtn crefresh">⟳ refresh</button>
+        <button class="abtn cupdate">⬇ update all</button>
+      </div>
+      <div class="cprog" hidden></div>
+      <div class="cscroll"><table class="ctable"><thead><tr>
+        <th>repo</th><th class="num">GiB</th><th class="barc"></th>
+      </tr></thead><tbody></tbody></table></div>`,
+    extraCls: "cachewin", removeOnClose: true,
+    onClosed: () => { cacheWin = null; },
+  });
+  cacheWin = handle;
+  const adv = handle.body.querySelector(".cacheadv");
+  const prog = handle.body.querySelector(".cprog");
+  const tbody = handle.body.querySelector("tbody");
+  const updBtn = handle.body.querySelector(".cupdate");
+  let pollTimer = null, jobWasRunning = false;
+
+  const big = (gib) => (gib >= 1024 ? (gib / 1024).toFixed(2) + " TB" : gib.toFixed(1) + " GiB");
+  function renderTable(st) {
+    adv.textContent = `${st.node} · ${st.hf_home} · ${st.df}`;
+    const max = Math.max(1, ...st.repos.map((x) => x.gib));
+    tbody.innerHTML = st.repos.map((x) => `<tr>
+        <td title="${esc(x.repo)}">${esc(x.repo.replace(/^models--/, "").replace(/--/, "/"))}</td>
+        <td class="num">${big(x.gib)}</td>
+        <td class="barc"><div class="cbarwrap"><div class="cbar" style="width:${Math.max(2, (x.gib / max) * 100)}%"></div></div></td>
+      </tr>`).join("") +
+      `<tr><td><b>${st.repos.length} repos</b></td><td class="num"><b>${big(st.total_gib)}</b></td><td></td></tr>`;
+  }
+  function renderJob(job) {
+    if (!job) { prog.hidden = true; return; }
+    prog.hidden = false;
+    const pct = job.percent != null ? ` ${job.percent.toFixed(1)}%` : "";
+    const cnt = job.total ? ` [${job.index}/${job.total}]` : "";
+    prog.textContent = (job.running ? "⟳" : "✓") + cnt + pct + " " + (job.message || job.stage || "");
+    updBtn.disabled = !!job.running;
+  }
+  async function load() {
+    adv.textContent = "reading the cache over SSH…";
+    try {
+      const res = await fetch("/api/cache", { headers: AUTH() });
+      const j = await res.json();
+      if (!res.ok) { adv.textContent = "error: " + (j.error || res.status); return; }
+      if (!j.ok) { adv.textContent = "cache error: " + j.error; return; }
+      renderTable(j.status);
+      renderJob(j.job);
+      if (j.job && j.job.running) startPoll();
+    } catch (e) { adv.textContent = "error: " + e.message; }
+  }
+  async function poll() {
+    try {
+      const j = await (await fetch("/api/cache/progress", { headers: AUTH() })).json();
+      renderJob(j.job);
+      const running = !!(j.job && j.job.running);
+      if (jobWasRunning && !running) load(); // finished → re-read the sizes
+      jobWasRunning = running;
+      if (!running) stopPoll();
+    } catch {}
+  }
+  function startPoll() {
+    jobWasRunning = true;
+    if (pollTimer) return;
+    pollTimer = setInterval(poll, 2500);
+  }
+  function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
+  handle.body.querySelector(".crefresh").onclick = load;
+  updBtn.onclick = async () => {
+    updBtn.disabled = true;
+    try {
+      const res = await fetch("/api/cache/update", {
+        method: "POST", headers: { ...AUTH(), "content-type": "application/json" }, body: "{}",
+      });
+      const j = await res.json();
+      if (!res.ok) { prog.hidden = false; prog.textContent = "error: " + (j.error || res.status); updBtn.disabled = false; return; }
+      startPoll();
+    } catch (e) { prog.hidden = false; prog.textContent = "error: " + e.message; updBtn.disabled = false; }
+  };
+  handle.onClose = () => stopPoll();
+  saveWinState();
+  load();
+  return handle;
+}
+
+// --- settings window: the private config block (real multi-line editor) ------
+// The add-on's option form has no multi-line control, so the block lives in a
+// file (`/data/config-extra.toml` in the add-on) and is edited here: validated
+// (TOML parse) on save, applied by the next command / add-on restart.
+let setWin = null;
+async function openSettings(g) {
+  if (setWin) { activateWindow(setWin); return setWin; }
+  const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
+  const handle = makeWin({
+    title: "settings", short: "settings", x: r("left", 90), y: r("top", 80),
+    w: r("width", Math.min(760, innerWidth - 60)), h: r("height", Math.min(560, innerHeight * 0.75)),
+    bodyHtml: `<div class="setbar">
+        <span class="adv setpath">loading…</span>
+        <span class="setstat"></span>
+        <button class="abtn sreload">reload</button>
+        <button class="abtn ssave">save</button>
+      </div>
+      <div class="setnote">Private config block: clusters, nccl, HA token, mikrotik, registry —
+        anything that must not live in the shared recipe repo. Validated as TOML before it is written;
+        the running dashboard keeps its loaded config, so a restart picks it up.</div>
+      <textarea class="settxt" spellcheck="false" autocomplete="off"></textarea>`,
+    extraCls: "setwin", removeOnClose: true,
+    onClosed: () => { setWin = null; },
+  });
+  setWin = handle;
+  const pathEl = handle.body.querySelector(".setpath");
+  const stat = handle.body.querySelector(".setstat");
+  const ta = handle.body.querySelector(".settxt");
+  async function load() {
+    stat.textContent = "";
+    try {
+      const res = await fetch("/api/settings/extra", { headers: AUTH() });
+      const j = await res.json();
+      if (!res.ok) { stat.textContent = "error: " + (j.error || res.status); return; }
+      ta.value = j.text || "";
+      pathEl.textContent = j.path + (j.managed ? "" : " (not read by the add-on)");
+      pathEl.title = j.path;
+    } catch (e) { stat.textContent = "error: " + e.message; }
+  }
+  handle.body.querySelector(".sreload").onclick = () => { if (confirm("discard unsaved changes?")) load(); };
+  handle.body.querySelector(".ssave").onclick = async () => {
+    stat.textContent = "saving…";
+    try {
+      const res = await fetch("/api/settings/extra", {
+        method: "POST", headers: { ...AUTH(), "content-type": "application/json" },
+        body: JSON.stringify({ text: ta.value }),
+      });
+      const j = await res.json();
+      stat.textContent = res.ok ? "saved ✓ (applies on restart)" : "error: " + (j.error || res.status);
+    } catch (e) { stat.textContent = "error: " + e.message; }
+  };
+  saveWinState();
+  load();
+  return handle;
+}
+
 async function openRaw(g) {
   if (rawWin) { activateWindow(rawWin); return rawWin; }
   const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);

+ 42 - 2
src/web/ui.playwright.mjs

@@ -282,8 +282,8 @@ ok(
 // --- 13. raw window (via the dock + new-window menu) -------------------------------
 await page.click("#dock .plus");
 ok(
-  "dock + menu lists topo / state / jobs / recipes / logs / raw",
-  (await page.locator(".dockmenu .dock-chip").count()) === 6,
+  "dock + menu lists topo / state / jobs / recipes / logs / raw / cache / settings",
+  (await page.locator(".dockmenu .dock-chip").count()) === 8,
 );
 await page.click('.dockmenu .dock-chip:has-text("raw")');
 await page.waitForSelector(".rawwin", { timeout: 10000 });
@@ -314,6 +314,46 @@ ok(
   monoFont,
 );
 
+// --- cache + settings windows ------------------------------------------------------
+await page.click("#dockplus");
+await page.click('.dockmenu .dock-chip:has-text("settings")');
+await page.waitForSelector(".setwin .settxt", { timeout: 10000 });
+ok("settings window opens with a multi-line editor", (await page.locator(".setwin textarea.settxt").count()) === 1);
+// save roundtrip: a valid TOML block is accepted, an invalid one is refused
+await page.fill(".setwin .settxt", '[cache]\nnode = "dgx5.lan"\n');
+await page.click(".setwin .ssave");
+await page.waitForFunction(
+  () => (document.querySelector(".setwin .setstat").textContent || "").includes("saved"),
+  { timeout: 8000 },
+);
+ok("settings: valid TOML saves", true);
+await page.fill(".setwin .settxt", "[cache\nnode = 1");
+await page.click(".setwin .ssave");
+await page.waitForFunction(
+  () => (document.querySelector(".setwin .setstat").textContent || "").includes("TOML"),
+  { timeout: 8000 },
+);
+ok("settings: invalid TOML is refused with the parse error", true);
+await page.fill(".setwin .settxt", "");
+await page.click(".setwin .ssave");
+await page.waitForFunction(
+  () => (document.querySelector(".setwin .setstat").textContent || "").includes("saved"),
+  { timeout: 8000 },
+);
+
+await page.click("#dockplus");
+await page.click('.dockmenu .dock-chip:has-text("cache")');
+await page.waitForSelector(".cachewin .ctable", { timeout: 10000 });
+ok("cache window opens with its table", (await page.locator(".cachewin table.ctable").count()) === 1);
+await page.waitForFunction(
+  () => {
+    const t = document.querySelector(".cachewin .cacheadv")?.textContent || "";
+    return t && !t.includes("loading");
+  },
+  { timeout: 15000 },
+);
+ok("cache: the status line resolved (data or an error, not the placeholder)", true);
+
 // --- topology map (interaction) ---------------------------------------------------
 // frame chip click activates the cluster (frames sit behind devices —
 // the chip is the always-clickable part); hide the other windows first —