diff --git a/Cargo.lock b/Cargo.lock index 3f3a94b..e3cde71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 1ba04c7..05f4d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.16" +version = "0.2.17" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index d72364c..9652c10 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -606,7 +606,10 @@ fn cmf_file_for_model( cmf: &crate::config::CmfCfg, m: &crate::config::ModelCfg, ) -> Option { + // Any loopback URL counts as local — managed servers may sit on any port. let local = m.base_url == cmf.cortiq_server_url + || m.base_url.contains("//127.0.0.1:") + || m.base_url.contains("//localhost:") || m.base_url .contains(&format!("{}:{}", cmf.local_host, cmf.local_port)); let stem = m.model.trim(); @@ -646,12 +649,24 @@ fn cmf_path_referenced(cfg: &crate::config::Config, path: &std::path::Path) -> b .any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path)) } -async fn delete_model(State(state): State, Path(id): Path) -> ApiResult { +#[derive(Deserialize)] +struct DeleteModelQuery { + /// Also remove the local `.cmf` from disk. Defaults to true (the historical + /// behaviour); the UI asks the user and passes it explicitly. + delete_file: Option, +} + +async fn delete_model( + State(state): State, + Path(id): Path, + Query(q): Query, +) -> ApiResult { let mut cfg = current_cfg(&state); + let delete_file = q.delete_file.unwrap_or(true); // Managed local models live in [cmf], not [[models]]. Deleting one stops its - // server, removes it from the managed config, and deletes its file — unless - // another model still points at the same file. + // server, removes it from the managed config, and (when asked) deletes its + // file — unless another model still points at the same file. let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id); if let Some(server) = managed { if !cfg.models.iter().any(|x| x.id == id) { @@ -666,10 +681,10 @@ async fn delete_model(State(state): State, Path(id): Path) let shared = cmf_path_referenced(&cfg, &path); state.cmf.stop_one(&id).await; state.reload(cfg)?; - let file_removed = if shared { - false - } else { + let file_removed = if delete_file && !shared { remove_cmf_path(&path) + } else { + false }; return ok(json!({ "ok": true, "file_removed": file_removed })); } @@ -685,8 +700,8 @@ async fn delete_model(State(state): State, Path(id): Path) let file = victim .as_ref() .and_then(|m| cmf_file_for_model(&cfg.cmf, m)); - // Only remove the file if no remaining model still references it. - let file = file.filter(|p| !cmf_path_referenced(&cfg, p)); + // Only remove the file if asked to AND no remaining model still references it. + let file = file.filter(|p| delete_file && !cmf_path_referenced(&cfg, p)); state.reload(cfg)?; let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false); ok(json!({ "ok": true, "file_removed": file_removed })) diff --git a/web/js/api.js b/web/js/api.js index 5c9768c..a9c939c 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -60,7 +60,8 @@ export const api = { listModels: () => j("GET", "/models"), createModel: (b) => j("POST", "/models", b), updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b), - deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)), + deleteModel: (id, deleteFile = true) => + j("DELETE", "/models/" + encodeURIComponent(id) + "?delete_file=" + (deleteFile ? "true" : "false")), probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"), // routing diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 9ee5825..4b8bc22 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -132,6 +132,8 @@ export default { "models.form.secretMissing": "not set", "models.form.secretPlaceholder": "set or replace key (write-only)", "models.deleteConfirm": "Delete model \"{id}\"?", + "models.deleteFile": "also delete the model file from disk", + "models.deletedWithFile": "Deleted (file removed from disk)", "models.form.idOrModel": "Enter an id or a model name", "models.form.apiKey": "API Key", "models.form.apiKeyPlaceholder": "paste your API key", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 0ac890c..b8591b1 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -131,6 +131,8 @@ export default { "models.form.secretMissing": "не задан", "models.form.secretPlaceholder": "задать или заменить ключ (только запись)", "models.deleteConfirm": "Удалить модель \"{id}\"?", + "models.deleteFile": "также удалить файл модели с диска", + "models.deletedWithFile": "Удалено (файл стёрт с диска)", "models.form.idOrModel": "Укажите id или имя модели", "models.form.apiKey": "API-ключ", "models.form.apiKeyPlaceholder": "вставьте API-ключ", diff --git a/web/js/views/models.js b/web/js/views/models.js index c1b809b..5a03b62 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -1,5 +1,5 @@ // Models — pool table with add/edit modal, probe, and per-model secret entry. -import { h, mount, modal, toast, confirmDialog, money } from "../ui.js"; +import { h, mount, modal, toast, money } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; import { appState } from "../app.js"; @@ -280,15 +280,31 @@ function row(m, meta, reload) { "button", { class: "btn sm danger", - onClick: async () => { - if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return; - try { - await api.deleteModel(m.id); - toast(t("toast.deleted"), "good"); - await reload(); - } catch (e) { - toast(String(e.message || e), "bad"); - } + onClick: () => { + // local models may have a .cmf on disk — let the user decide its fate + const isLocal = m.managed || /127\.0\.0\.1|localhost/.test(m.base_url || ""); + const fileCb = h("input", { type: "checkbox", checked: isLocal ? true : null }); + const body = h( + "div", + {}, + h("p", {}, t("models.deleteConfirm", { id: m.id })), + isLocal + ? h("label", { class: "check" }, fileCb, t("models.deleteFile")) + : null + ); + modal(t("common.delete") + ": " + m.id, body, async () => { + try { + const r = await api.deleteModel(m.id, isLocal && fileCb.checked); + toast( + r.file_removed ? t("models.deletedWithFile") : t("toast.deleted"), + "good" + ); + await reload(); + } catch (e) { + toast(String(e.message || e), "bad"); + return false; + } + }, t("common.delete")); }, }, t("common.delete")