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:
+102
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user