chore(release): v0.2.6 — Settings UX, empty pool by default, CMF port check
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s

- Settings: "?" help tooltips on every option; router key auto-probes the
  connection right after saving (no more hanging "unavailable")
- Local models card reworked into 3 clear steps (install cortiq → add model →
  run locally); removed the hand-entered .cmf path / pool-id fields — models
  are added via the Import tab and the managed server registers them
- Fresh install ships an EMPTY model pool; the local .cmf is auto-registered by
  the managed CMF server, so it needs no static [[models]] entry
- New: port-availability check — GET /admin/api/cmf/port + "Check port" button
  with next-free-port suggestion. bind()+connect() probe so Docker-proxied /
  dual-stack ports (e.g. 8081) are correctly detected as occupied
- /admin/api/cmf now reports the serving model + port; card shows "▶ serving …"
- On-demand runtime install: POST /admin/api/cmf/install
This commit is contained in:
infosave2007
2026-07-08 19:07:24 +03:00
parent 25f93d1a27
commit 3c90dad9b9
10 changed files with 370 additions and 54 deletions
+105
View File
@@ -26,6 +26,7 @@ use axum::{
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::time::Instant;
// ───────────────────────────── common types / helpers ───────────────────────
@@ -171,6 +172,9 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
.route("/admin/api/import/:job", get(import_status).delete(delete_import))
.route("/admin/api/import/:job/cancel", post(cancel_import))
.route("/admin/api/import/:job/register", post(register_import))
.route("/admin/api/cmf", get(cmf_status))
.route("/admin/api/cmf/install", post(cmf_install))
.route("/admin/api/cmf/port", get(cmf_port_check))
.route("/admin/api/requests", get(get_requests))
.route("/admin/api/test", post(run_test))
.route("/admin/api/test/stream", post(run_test_stream))
@@ -671,6 +675,107 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
ok(json!({ "ok": true, "needs_restart": needs_restart }))
}
// ───────────────────────────── cmf runtime ─────────────────────────────────
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which
/// version, and whether a managed server is running. Drives the "Install cortiq"
/// button + status in the Settings → Local models card.
async fn cmf_status(State(state): State<SharedState>) -> ApiResult {
let cmf = state.live().cfg.cmf.clone();
let ver = crate::cmf_runtime::installed_version(&cmf.cortiq_bin);
let st = state.cmf.status();
ok(json!({
"installed": ver.is_some(),
"version": ver,
"running": st.running,
"healthy": st.healthy,
"last_error": st.last_error,
"log": st.log,
// serving info so the card can show "serving <model> on :<port>"
"manage_server": cmf.manage_server,
"local_model": cmf.local_model,
"model_id": cmf.model_id,
"local_host": cmf.local_host,
"local_port": cmf.local_port,
}))
}
/// Install/upgrade the `cortiq` CLI from crates.io on demand (spawned in the
/// background; poll `GET /admin/api/cmf` for `installed`/`version`).
async fn cmf_install(State(state): State<SharedState>) -> ApiResult {
let bin = state.live().cfg.cmf.cortiq_bin.clone();
tokio::spawn(crate::cmf_runtime::install_now(state.cmf.clone(), bin));
ok(json!({ "started": true }))
}
#[derive(Deserialize)]
struct PortQuery {
port: u16,
}
/// Check whether a TCP port is free to bind on the CMF local host, so a user can
/// pick an available port before enabling the managed server (avoids clashing
/// with something already on the default 8081). If the port is the one this
/// gateway's own managed server already runs on, that is reported distinctly.
/// When occupied, `suggested` holds the next free port so the UI can offer it.
async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQuery>) -> ApiResult {
let cmf = state.live().cfg.cmf.clone();
let host = if cmf.local_host.trim().is_empty() {
"127.0.0.1".to_string()
} else {
cmf.local_host.clone()
};
let st = state.cmf.status();
let port = q.port;
let running_here = port == cmf.local_port && st.running;
// The bind+connect probe is blocking std::net; run it off the async runtime.
let (available, detail, suggested) = tokio::task::spawn_blocking(move || {
if running_here {
return (false, "in use by this gateway's managed CMF server", None);
}
if port_is_free(&host, port) {
(true, "free", None)
} else {
(false, "occupied by another process", next_free_port(&host, port))
}
})
.await
.unwrap_or((false, "port check failed", None));
ok(json!({
"port": port,
"available": available,
"detail": detail,
"suggested": suggested,
}))
}
/// Whether `port` is truly free on `host`. A bind test alone is not enough: a
/// Docker-published / dual-stack listener can leave an IPv4 bind succeeding even
/// though connections to the port are forwarded to a container. So a port counts
/// as free only if it is BOTH bindable AND nothing answers a connect attempt.
fn port_is_free(host: &str, port: u16) -> bool {
if std::net::TcpListener::bind((host, port)).is_err() {
return false; // something is bound to exactly this address
}
// catch proxied/forwarded ports a bind test misses (e.g. Docker on macOS)
let reachable = (host, port)
.to_socket_addrs()
.ok()
.and_then(|mut it| it.next())
.map(|addr| {
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
.is_ok()
})
.unwrap_or(false);
!reachable
}
/// First free port in (from, from+50], or None if all are taken.
fn next_free_port(host: &str, from: u16) -> Option<u16> {
(from.saturating_add(1)..=from.saturating_add(50)).find(|p| port_is_free(host, *p))
}
// ───────────────────────────── keys ────────────────────────────────────────
async fn list_keys(State(state): State<SharedState>) -> ApiResult {
+16 -1
View File
@@ -119,7 +119,7 @@ async fn latest_crates_version() -> Option<String> {
}
/// `<bin> --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2").
fn installed_version(bin: &str) -> Option<String> {
pub(crate) fn installed_version(bin: &str) -> Option<String> {
let out = std::process::Command::new(bin).arg("--version").output().ok()?;
if !out.status.success() {
return None;
@@ -152,6 +152,21 @@ async fn cargo_install(force: bool) -> Result<(), String> {
}
}
/// Install/upgrade `cortiq-cli` from crates.io on demand (the admin "Install
/// cortiq" button). Runs in the background; progress + result land in the CMF
/// status log, and `installed_version` is refreshed on success.
pub async fn install_now(rt: Arc<CmfRuntime>, bin: String) {
rt.log("installing cortiq-cli from crates.io (this compiles — a few minutes)…");
match cargo_install(true).await {
Ok(()) => {
let v = installed_version(&bin);
rt.status.lock().unwrap().installed_version = v.clone();
rt.log(format!("installed cortiq-cli {}", v.as_deref().unwrap_or("?")));
}
Err(e) => rt.fail(format!("cargo install cortiq-cli failed: {e}")),
}
}
/// Spawn `cortiq serve <model> --host <host> --port <port>`; the child is killed
/// when its handle is dropped (i.e. when the gateway shuts down).
fn spawn_serve(cfg: &CmfCfg) -> Result<Child, String> {