feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens

Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
This commit is contained in:
infosave2007
2026-08-07 17:07:48 +03:00
parent a77beeabab
commit 15de36be3b
32 changed files with 1876 additions and 143 deletions
+102 -3
View File
@@ -184,6 +184,12 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
.route("/admin/api/requests", get(get_requests))
.route("/admin/api/test", post(run_test))
.route("/admin/api/test/stream", post(run_test_stream))
.route("/admin/api/logs", get(get_logs))
.route("/admin/api/system", get(get_system))
.route("/admin/api/restart", post(restart_gateway))
.route("/admin/api/bench", get(bench_list).post(bench_start))
.route("/admin/api/config/export", get(config_export))
.route("/admin/api/config/import", post(config_import))
.route("/admin/api/media/models", get(media_models))
.route("/admin/api/media", get(media_jobs).post(media_generate))
.route(
@@ -591,9 +597,13 @@ async fn provider_models(
ok(resp)
}
async fn meta() -> ApiResult {
async fn meta(State(state): State<SharedState>) -> ApiResult {
ok(json!({
"version": env!("CARGO_PKG_VERSION"),
"update_available": state.gateway_update.lock().unwrap().clone(),
"admin_open_public": state
.admin_open_public
.load(std::sync::atomic::Ordering::Relaxed),
"providers": ["openai", "anthropic", "openrouter", "lmstudio", "ollama", "http"],
"provider_impl": { "openai": true, "anthropic": true, "openrouter": true, "lmstudio": true, "ollama": true, "http": true },
"cost_tiers": ["cheap", "mid", "expensive", "local"],
@@ -2029,6 +2039,89 @@ async fn get_requests(State(state): State<SharedState>, Query(q): Query<RecentQu
ok(json!({ "requests": items }))
}
// ───────────────────────── system / logs / lifecycle ─────────────────────────
/// Recent gateway log lines (in-memory ring) — for users who never see the
/// terminal: Docker, autostart, a double-clicked exe.
async fn get_logs(Query(q): Query<RecentQuery>) -> ApiResult {
let limit = q.limit.unwrap_or(300).min(500);
ok(json!({ "lines": crate::logbuf::snapshot(limit) }))
}
/// Host metrics snapshot: CPU, RAM, free disk at the models dir, gateway RSS.
async fn get_system(State(state): State<SharedState>) -> ApiResult {
let snap = state.sysmon.snapshot();
ok(serde_json::to_value(snap).unwrap_or_else(|_| json!({})))
}
/// Restart the gateway in place: stop managed model servers, spawn a fresh
/// copy of ourselves (it retries the bind while our port frees), then exit.
/// In Docker we only exit — we are PID 1, a spawned child would die with the
/// container anyway; the container's restart policy brings the gateway back.
async fn restart_gateway(State(state): State<SharedState>) -> ApiResult {
state.cmf.stop().await;
let in_docker = std::path::Path::new("/.dockerenv").exists();
let exe = std::env::current_exe().map_err(|e| ApiError::bad(e.to_string()))?;
let args: Vec<String> = std::env::args().skip(1).collect();
tokio::spawn(async move {
// let the 200 reach the browser first
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if !in_docker {
let _ = std::process::Command::new(exe)
.args(args)
.env("CORTIQ_BIND_RETRY", "1")
.spawn();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
std::process::exit(0);
});
ok(json!({ "ok": true }))
}
#[derive(Deserialize)]
struct BenchBody {
model: String,
}
/// Benchmark a local .cmf with `cortiq bench --json` — real tok/s on this box.
async fn bench_start(State(state): State<SharedState>, Json(b): Json<BenchBody>) -> ApiResult {
let live = state.live();
crate::bench::start(state.bench.clone(), &live.cfg.cmf, b.model).map_err(ApiError::bad)?;
ok(json!({ "ok": true }))
}
async fn bench_list(State(state): State<SharedState>) -> ApiResult {
ok(json!({ "runs": state.bench.list() }))
}
/// The config file as-is (comments included) for download. Secrets live in
/// secrets.toml and are deliberately NOT part of the export.
async fn config_export(State(state): State<SharedState>) -> Response {
match tokio::fs::read_to_string(&state.config_path).await {
Ok(text) => (
[
(header::CONTENT_TYPE, "application/toml".to_string()),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"gateway.toml\"".to_string(),
),
],
text,
)
.into_response(),
Err(e) => ApiError::bad(format!("cannot read config: {e}")).into_response(),
}
}
/// Replace the whole config from an uploaded TOML. Validated and hot-reloaded
/// exactly like a panel edit; on any error the current config stays untouched.
async fn config_import(State(state): State<SharedState>, body: String) -> ApiResult {
let cfg: crate::config::Config =
toml::from_str(&body).map_err(|e| ApiError::bad(format!("invalid TOML: {e}")))?;
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── media generation ─────────────────────────────
/// Local `.cmf` files with their modality (chat / image / video) — the
@@ -2056,7 +2149,13 @@ async fn media_generate(
async fn media_job_status(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
match state.media.get(&job) {
Some(j) => ok(serde_json::to_value(j).unwrap_or_else(|_| json!({}))),
Some(j) => {
let mut v = serde_json::to_value(&j).unwrap_or_else(|_| json!({}));
if j.state == "queued" {
v["queue_position"] = json!(state.media.queue_position(&job));
}
ok(v)
}
None => Err(ApiError::not_found(format!("media job '{job}' not found"))),
}
}
@@ -2065,7 +2164,7 @@ async fn media_cancel(State(state): State<SharedState>, Path(job): Path<String>)
if state.media.get(&job).is_none() {
return Err(ApiError::not_found(format!("media job '{job}' not found")));
}
ok(json!({ "ok": state.media.cancel(&job) }))
ok(json!({ "ok": state.media.cancel(&job, &crate::media::media_dir()) }))
}
async fn media_delete(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {