diff --git a/Cargo.lock b/Cargo.lock index 18acf1c..d9c0379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "sysinfo", "thiserror 1.0.69", "tokio", "tokio-stream", @@ -341,6 +342,16 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -873,6 +884,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -882,6 +902,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1430,6 +1507,21 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1914,6 +2006,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1923,12 +2031,113 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1989,6 +2198,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 450618b..c7fc0e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,9 @@ clap = { version = "4", features = ["derive"] } # open browser on first run (optional) open = { version = "5", optional = true } +# host metrics for the admin dashboard (CPU / RAM / disk) +sysinfo = "0.39" + # errors / logging / metrics thiserror = "1" anyhow = "1" diff --git a/Dockerfile b/Dockerfile index 35d2e2b..d9ab4d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,10 @@ # drifts to a newer glibc than the runtime image and the binary refuses to start FROM rust:1-slim-trixie AS builder WORKDIR /app +# the cortiq runtime CANNOT be auto-installed inside the container (no cargo in +# the runtime image), so it is compiled here and shipped alongside the gateway — +# local .cmf chat models and image/video generation work in Docker out of the box +RUN cargo install cortiq-cli --locked # the admin SPA (web/) is embedded at compile time via rust-embed, so it must be present COPY Cargo.toml Cargo.lock ./ COPY src ./src @@ -11,13 +15,15 @@ RUN cargo build --release # ---- runtime stage ---------------------------------------------------------- FROM debian:trixie-slim -# curl is required at runtime — model imports download ready .cmf files with it +# curl — model imports download ready .cmf files with it; +# libvulkan1 — optional GPU offload for cortiq (falls back to CPU without a GPU) RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ + && apt-get install -y --no-install-recommends ca-certificates curl libvulkan1 \ && rm -rf /var/lib/apt/lists/* \ && useradd -r -u 10001 cortiq \ && mkdir -p /app/config /app/data/models COPY --from=builder /app/target/release/cortiq-gateway /usr/local/bin/cortiq-gateway +COPY --from=builder /usr/local/cargo/bin/cortiq /usr/local/bin/cortiq # ship the example as the default config so `docker run` works out of the box COPY config/gateway.example.toml /app/config/gateway.toml RUN chown -R 10001:10001 /app diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 8de05d1..c60ea89 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -184,6 +184,12 @@ pub fn api_routes(admin_token: String) -> Router { .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) -> 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, Query(q): Query) -> 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) -> 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) -> 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 = 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, Json(b): Json) -> 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) -> 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) -> 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, 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, Path(job): Path) -> 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, Path(job): Path) 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, Path(job): Path) -> ApiResult { diff --git a/src/bench.rs b/src/bench.rs new file mode 100644 index 0000000..e07ff42 --- /dev/null +++ b/src/bench.rs @@ -0,0 +1,166 @@ +//! One-click local model benchmark: run `cortiq bench --json` and keep +//! the parsed result per model, so the Models page can show real tok/s numbers +//! measured on this machine. + +use serde::Serialize; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Serialize)] +pub struct BenchRun { + pub model: String, // file stem under models_dir + pub state: String, // running | done | error + pub log: Vec, + pub tok_s: Option, + /// The full JSON object the benchmark printed, passed through untouched. + pub raw: Option, + pub started: u64, + pub finished: Option, +} + +#[derive(Default)] +pub struct BenchStore { + runs: Mutex>, + busy: Mutex, +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// tok/s out of the benchmark's JSON: first numeric field whose key mentions +/// tokens-per-second (the exact name has varied across cortiq releases). +fn extract_tok_s(v: &serde_json::Value) -> Option { + let obj = v.as_object()?; + for want in ["tok_s", "tok_per_s", "tokens_per_sec", "decode_tok_s"] { + if let Some(n) = obj.get(want).and_then(|x| x.as_f64()) { + return Some(n); + } + } + obj.iter() + .find(|(k, val)| { + let k = k.to_ascii_lowercase(); + (k.contains("tok") && (k.contains("/s") || k.contains("_s") || k.contains("per"))) + && val.as_f64().is_some() + }) + .and_then(|(_, val)| val.as_f64()) +} + +impl BenchStore { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + pub fn get(&self, model: &str) -> Option { + self.runs.lock().unwrap().get(model).cloned() + } + pub fn list(&self) -> Vec { + self.runs.lock().unwrap().values().cloned().collect() + } +} + +/// Kick off a benchmark for `/.cmf`. One at a time — a +/// benchmark loads the whole model and owns the machine while it runs. +pub fn start( + store: Arc, + cmf: &crate::config::CmfCfg, + model: String, +) -> Result<(), String> { + let path = std::path::Path::new(&cmf.models_dir).join(format!("{model}.cmf")); + if !path.exists() { + return Err(format!("model file not found: {}", path.display())); + } + { + let mut busy = store.busy.lock().unwrap(); + if *busy { + return Err("a benchmark is already running — wait for it to finish".into()); + } + *busy = true; + } + store.runs.lock().unwrap().insert( + model.clone(), + BenchRun { + model: model.clone(), + state: "running".into(), + log: Vec::new(), + tok_s: None, + raw: None, + started: now(), + finished: None, + }, + ); + let bin = cmf.cortiq_bin.clone(); + let gpu = cmf.gpu; + tokio::spawn(async move { + let out = tokio::process::Command::new(&bin) + .arg("bench") + .arg(&path) + .arg("--json") + .env("CMF_GPU", if gpu { "1" } else { "0" }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + let mut g = store.runs.lock().unwrap(); + let run = g.get_mut(&model).unwrap(); + run.finished = Some(now()); + match out { + Ok(o) => { + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + // keep a readable tail; the JSON line is parsed separately + run.log = text + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.contains("Metal GPU path")) + .map(String::from) + .collect::>(); + let n = run.log.len(); + if n > 30 { + run.log.drain(0..n - 30); + } + // the --json contract: one JSON object on its own line + let parsed = text + .lines() + .rev() + .map(str::trim) + .find(|l| l.starts_with('{') && l.ends_with('}')) + .and_then(|l| serde_json::from_str::(l).ok()); + match parsed { + Some(v) if o.status.success() => { + run.tok_s = extract_tok_s(&v); + run.raw = Some(v); + run.state = "done".into(); + } + _ => run.state = "error".into(), + } + } + Err(e) => { + run.log.push(format!("✗ failed to start cortiq bench: {e}")); + run.state = "error".into(); + } + } + drop(g); + *store.busy.lock().unwrap() = false; + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tok_s_from_common_keys() { + let v = serde_json::json!({"tok_s": 42.5, "allocs_per_token": 0}); + assert_eq!(extract_tok_s(&v), Some(42.5)); + let v = serde_json::json!({"decode_tokens_per_sec": 31.0}); + assert_eq!(extract_tok_s(&v), Some(31.0)); + } +} diff --git a/src/cmf_runtime.rs b/src/cmf_runtime.rs index b152053..1e3911e 100644 --- a/src/cmf_runtime.rs +++ b/src/cmf_runtime.rs @@ -197,14 +197,14 @@ impl Default for CmfRuntime { } } -/// crates.io `newest_version` for `cortiq-cli`. -async fn latest_crates_version() -> Option { +/// crates.io `newest_version` for any crate. +async fn latest_version_of(krate: &str) -> Option { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .ok()?; let body = client - .get("https://crates.io/api/v1/crates/cortiq-cli") + .get(format!("https://crates.io/api/v1/crates/{krate}")) .header("User-Agent", "cortiq-gateway") .send() .await @@ -216,6 +216,16 @@ async fn latest_crates_version() -> Option { v["crate"]["newest_version"].as_str().map(|s| s.to_string()) } +/// crates.io `newest_version` for `cortiq-cli`. +async fn latest_crates_version() -> Option { + latest_version_of("cortiq-cli").await +} + +/// Newest published gateway version — the "update available" footer check. +pub(crate) async fn latest_gateway_version() -> Option { + latest_version_of("cortiq-gateway").await +} + /// ` --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2"). pub(crate) fn installed_version(bin: &str) -> Option { let out = std::process::Command::new(bin) diff --git a/src/import.rs b/src/import.rs index 5fa8882..afe9f08 100644 --- a/src/import.rs +++ b/src/import.rs @@ -132,7 +132,7 @@ fn default_quant() -> String { } /// Live state of one conversion job. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Job { pub id: String, pub repo: String, @@ -162,14 +162,60 @@ pub struct JobStore { /// Cancel handles, keyed by job id (not serialized). Notifying aborts the /// converter process and cleans up its partial output. cancels: Mutex>>, + /// When set, every state change lands in this JSON file, so the job list — + /// and its Register buttons — survives a gateway restart. + persist: Mutex>, + last_save: Mutex>, } impl JobStore { pub fn new() -> Arc { Arc::new(Self::default()) } + /// Load previously saved jobs and start persisting to `path`. A job that + /// was `running` when the gateway died is settled as an error — its process + /// is gone; the file it produced (if complete) is still on disk. + pub fn attach_persistence(&self, path: std::path::PathBuf) { + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(mut jobs) = serde_json::from_str::>(&text) { + let mut g = self.jobs.lock().unwrap(); + for j in jobs.drain(..) { + let mut j = j; + if j.state == "running" { + j.state = "error".into(); + j.log.push("✗ interrupted by a gateway restart".into()); + j.finished = Some(now()); + } + g.insert(j.id.clone(), j); + } + } + } + *self.persist.lock().unwrap() = Some(path); + } + fn save(&self, force: bool) { + let Some(path) = self.persist.lock().unwrap().clone() else { + return; + }; + if !force { + let mut last = self.last_save.lock().unwrap(); + if let Some(t) = *last { + if t.elapsed() < std::time::Duration::from_secs(2) { + return; + } + } + *last = Some(std::time::Instant::now()); + } + let jobs: Vec = self.jobs.lock().unwrap().values().cloned().collect(); + if let Ok(json) = serde_json::to_vec(&jobs) { + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } + } + } fn insert(&self, job: Job) { self.jobs.lock().unwrap().insert(job.id.clone(), job); + self.save(true); } fn set_cancel(&self, id: &str, n: Arc) { self.cancels.lock().unwrap().insert(id.to_string(), n); @@ -215,6 +261,7 @@ impl JobStore { j.phase = Some(phase); } } + self.save(false); } /// Expected total download size, once the HF tree is known. fn set_total(&self, id: &str, total: u64) { @@ -263,6 +310,7 @@ impl JobStore { j.state = state.into(); j.finished = Some(now()); } + self.save(true); } fn finish(&self, id: &str, ok: bool) { if let Some(j) = self.jobs.lock().unwrap().get_mut(id) { @@ -280,6 +328,7 @@ impl JobStore { j.total_bytes = Some(j.total_bytes.unwrap_or(on_disk).max(on_disk)); } } + self.save(true); } pub fn get(&self, id: &str) -> Option { self.jobs.lock().unwrap().get(id).cloned() @@ -295,6 +344,7 @@ impl JobStore { cleanup_output(&job.output); self.cancels.lock().unwrap().remove(id); self.jobs.lock().unwrap().remove(id); + self.save(true); Ok(true) } pub fn list(&self) -> Vec { diff --git a/src/logbuf.rs b/src/logbuf.rs new file mode 100644 index 0000000..3d8e25b --- /dev/null +++ b/src/logbuf.rs @@ -0,0 +1,73 @@ +//! In-memory ring of recent log lines, so the admin console can show logs to +//! users who never see the terminal (Docker, autostart, double-clicked exe). +//! Fed by a second `tracing` layer writing plain text (no ANSI) into the ring +//! while the normal stdout layer stays colored. + +use std::collections::VecDeque; +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; + +const CAP: usize = 500; + +fn ring() -> &'static Arc>> { + static RING: OnceLock>>> = OnceLock::new(); + RING.get_or_init(|| Arc::new(Mutex::new(VecDeque::with_capacity(CAP)))) +} + +/// Last `limit` lines, oldest first. +pub fn snapshot(limit: usize) -> Vec { + let g = ring().lock().unwrap(); + g.iter() + .skip(g.len().saturating_sub(limit)) + .cloned() + .collect() +} + +pub fn push_line(line: &str) { + let line = line.trim_end(); + if line.is_empty() { + return; + } + let mut g = ring().lock().unwrap(); + if g.len() >= CAP { + g.pop_front(); + } + g.push_back(line.to_string()); +} + +/// `MakeWriter` for the ring layer: buffers partial writes until a newline. +#[derive(Clone, Default)] +pub struct RingMakeWriter; + +pub struct RingWriter { + buf: String, +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RingMakeWriter { + type Writer = RingWriter; + fn make_writer(&'a self) -> Self::Writer { + RingWriter { buf: String::new() } + } +} + +impl io::Write for RingWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.buf.push_str(&String::from_utf8_lossy(data)); + while let Some(idx) = self.buf.find('\n') { + let line: String = self.buf.drain(..=idx).collect(); + push_line(&line); + } + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for RingWriter { + fn drop(&mut self) { + if !self.buf.is_empty() { + push_line(&self.buf.clone()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0bf5138..9e52e0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,11 +9,13 @@ #![allow(dead_code)] mod admin; +mod bench; mod cache; mod cmf_runtime; mod config; mod error; mod import; +mod logbuf; mod media; mod model; mod pipeline; @@ -26,6 +28,7 @@ mod routing; mod secrets; mod state; mod stats; +mod sysmon; use axum::{routing::get, Router}; use clap::Parser; @@ -204,12 +207,23 @@ async fn main() -> anyhow::Result<()> { if listen != original_listen { let _ = cfg.save(&config_path); } - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| cfg.log.level.clone().into()), - ) - .init(); + { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| cfg.log.level.clone().into()); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer()) + // second copy of every line, without ANSI, into the in-memory ring + // that backs the admin console's Логи view + .with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(logbuf::RingMakeWriter), + ) + .init(); + } let admin_enabled = cfg.admin.enabled; let admin_token = resolve_admin_token(&cfg, &args); @@ -217,6 +231,18 @@ async fn main() -> anyhow::Result<()> { let state = SharedState::build(cfg, config_path.clone())?; + // Open admin console reachable from other machines: fine on a laptop, + // a footgun on a VPS. Flag it for the UI banner and say it in the log. + let host_public = listen.starts_with("0.0.0.0") || listen.starts_with("[::]"); + if admin_enabled && admin_token.is_empty() && host_public && !is_docker() { + state + .admin_open_public + .store(true, std::sync::atomic::Ordering::Relaxed); + tracing::warn!( + "admin console is OPEN (no token) and listening on {listen} — anyone on your network can manage this gateway; set a token via --admin-token or [admin].token if this machine is reachable from outside" + ); + } + // Close the loop with the CMF format (github.com/infosave2007/cmf): install // / update cortiq-cli and run a local `cortiq serve` if configured. Runs in // the background so startup is never blocked by a cargo install or a model @@ -247,7 +273,25 @@ async fn main() -> anyhow::Result<()> { let app = app.with_state(state).layer(cors); - let listener = tokio::net::TcpListener::bind(&listen).await?; + let listener = { + let mut attempt = 0u32; + loop { + match tokio::net::TcpListener::bind(&listen).await { + Ok(l) => break l, + Err(e) => { + // during self-restart the old process is still letting go of + // the port — retry for a few seconds before giving up + let retrying = std::env::var("CORTIQ_BIND_RETRY").is_ok(); + if retrying && attempt < 50 { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + continue; + } + return Err(e.into()); + } + } + } + }; tracing::info!("cortiq-gateway listening on {listen}"); if admin_enabled { let host_for_url = if listen.starts_with("0.0.0.0:") { diff --git a/src/media.rs b/src/media.rs index 325f4a9..904fb5d 100644 --- a/src/media.rs +++ b/src/media.rs @@ -184,7 +184,7 @@ pub struct MediaParams { } /// Live state of one generation. -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, serde::Deserialize)] pub struct MediaJob { pub id: String, pub kind: String, // image | video @@ -195,7 +195,7 @@ pub struct MediaJob { pub steps: u32, pub seed: u64, pub frames: Option, - pub state: String, // running | done | error | cancelled + pub state: String, // queued | running | done | error | cancelled pub progress: Option, pub phase: Option, pub log: Vec, @@ -206,18 +206,67 @@ pub struct MediaJob { pub out_bytes: Option, } +/// Everything needed to actually launch a queued job when its turn comes. +struct Prepared { + bin: String, + args: Vec, + gpu: bool, + dir: PathBuf, + out_path: PathBuf, + kind: String, +} + #[derive(Default)] pub struct MediaStore { jobs: Mutex>, cancels: Mutex>>, - /// One generation at a time — these models take the whole GPU/RAM budget. - busy: Mutex>, + /// One generation runs at a time — these models take the whole GPU/RAM + /// budget — but new requests queue up instead of being rejected. + queue: Mutex>, + current: Mutex>, + prepared: Mutex>, + persist: Mutex>, } impl MediaStore { pub fn new() -> Arc { Arc::new(Self::default()) } + /// Load saved generations (their files are still under media_dir) and start + /// persisting. Jobs that were queued/running when the gateway died settle + /// as errors — the process is gone. + pub fn attach_persistence(&self, path: PathBuf) { + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(mut jobs) = serde_json::from_str::>(&text) { + let mut g = self.jobs.lock().unwrap(); + for mut j in jobs.drain(..) { + if j.state == "running" || j.state == "queued" { + j.state = "error".into(); + j.log.push("✗ interrupted by a gateway restart".into()); + j.finished = Some(now()); + } + g.insert(j.id.clone(), j); + } + } + } + *self.persist.lock().unwrap() = Some(path); + } + fn save(&self) { + let Some(path) = self.persist.lock().unwrap().clone() else { + return; + }; + let jobs: Vec = self.jobs.lock().unwrap().values().cloned().collect(); + if let Ok(json) = serde_json::to_vec(&jobs) { + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } + } + } + /// How many jobs sit in the queue ahead of `id` (0 = next up). + pub fn queue_position(&self, id: &str) -> Option { + self.queue.lock().unwrap().iter().position(|x| x == id) + } pub fn list(&self) -> Vec { let mut v: Vec = self.jobs.lock().unwrap().values().cloned().collect(); v.sort_by_key(|j| std::cmp::Reverse(j.started)); @@ -227,30 +276,43 @@ impl MediaStore { pub fn get(&self, id: &str) -> Option { self.jobs.lock().unwrap().get(id).cloned() } - pub fn cancel(&self, id: &str) -> bool { - let running = matches!( - self.jobs.lock().unwrap().get(id).map(|j| j.state.as_str()), - Some("running") - ); - if !running { - return false; - } - if let Some(n) = self.cancels.lock().unwrap().get(id) { - n.notify_one(); - true - } else { - false + pub fn cancel(self: &Arc, id: &str, media_dir: &Path) -> bool { + let state = self + .jobs + .lock() + .unwrap() + .get(id) + .map(|j| j.state.clone()) + .unwrap_or_default(); + match state.as_str() { + "queued" => { + self.queue.lock().unwrap().retain(|x| x != id); + self.prepared.lock().unwrap().remove(id); + let _ = std::fs::remove_dir_all(media_dir.join(id)); + self.set_cancelled(id); + true + } + "running" => { + if let Some(n) = self.cancels.lock().unwrap().get(id) { + n.notify_one(); + true + } else { + false + } + } + _ => false, } } /// Remove a settled job and its output directory. Err = still running. pub fn delete(&self, id: &str, media_dir: &Path) -> Result { let job = self.jobs.lock().unwrap().get(id).cloned(); let Some(job) = job else { return Ok(false) }; - if job.state == "running" { + if job.state == "running" || job.state == "queued" { return Err("cancel the running generation first".into()); } let _ = std::fs::remove_dir_all(media_dir.join(id)); self.jobs.lock().unwrap().remove(id); + self.save(); Ok(true) } fn push_line(&self, id: &str, line: String) { @@ -273,10 +335,12 @@ impl MediaStore { } } } - fn finish(&self, id: &str, ok: bool, outputs: Vec, out_bytes: u64) { + fn finish(self: &Arc, id: &str, ok: bool, outputs: Vec, out_bytes: u64) { let mut g = self.jobs.lock().unwrap(); if let Some(j) = g.get_mut(id) { if j.state == "cancelled" { + drop(g); + self.after_slot_freed(id); return; } j.finished = Some(now()); @@ -288,25 +352,67 @@ impl MediaStore { } } drop(g); - self.clear_busy(id); - self.cancels.lock().unwrap().remove(id); + self.after_slot_freed(id); } - fn set_cancelled(&self, id: &str) { + fn set_cancelled(self: &Arc, id: &str) { if let Some(j) = self.jobs.lock().unwrap().get_mut(id) { j.state = "cancelled".into(); j.finished = Some(now()); } - self.clear_busy(id); - self.cancels.lock().unwrap().remove(id); + self.after_slot_freed(id); } - fn clear_busy(&self, id: &str) { - let mut b = self.busy.lock().unwrap(); - if b.as_deref() == Some(id) { - *b = None; + fn after_slot_freed(self: &Arc, id: &str) { + { + let mut cur = self.current.lock().unwrap(); + if cur.as_deref() == Some(id) { + *cur = None; + } } + self.cancels.lock().unwrap().remove(id); + self.save(); + promote_next(self.clone()); } } +/// Start the next queued generation, if the slot is free. +fn promote_next(store: Arc) { + let next = { + let mut cur = store.current.lock().unwrap(); + if cur.is_some() { + return; + } + let Some(id) = store.queue.lock().unwrap().pop_front() else { + return; + }; + *cur = Some(id.clone()); + id + }; + let Some(p) = store.prepared.lock().unwrap().remove(&next) else { + // job was cancelled while queued and slipped through — free the slot + *store.current.lock().unwrap() = None; + return; + }; + if let Some(j) = store.jobs.lock().unwrap().get_mut(&next) { + j.state = "running".into(); + j.phase = Some("starting".into()); + j.started = now(); + } + store.save(); + let cancel = Arc::new(Notify::new()); + store + .cancels + .lock() + .unwrap() + .insert(next.clone(), cancel.clone()); + let s = store.clone(); + tokio::spawn(async move { + run_generation( + s, next, p.bin, p.args, p.gpu, p.dir, p.out_path, p.kind, cancel, + ) + .await; + }); +} + /// Progress markers in `cortiq imagine`/`animate` stdout. The exact wording has /// varied between releases, so accept anything that carries an X/Y pair near a /// step/frame word, a bare percentage, or the converter-style `@PROGRESS`. @@ -432,22 +538,10 @@ pub fn start_generation( if !model_path.exists() { return Err(format!("model file not found: {}", model_path.display())); } - { - let mut busy = store.busy.lock().unwrap(); - if let Some(running) = busy.as_ref() { - return Err(format!( - "a generation is already running (job {running}) — these models need the whole machine; wait or cancel it" - )); - } - *busy = Some(String::new()); // reserved; real id set below - } let id = gen_media_id(); - *store.busy.lock().unwrap() = Some(id.clone()); - let dir = media_dir().join(&id); if let Err(e) = std::fs::create_dir_all(&dir) { - store.clear_busy(&id); return Err(format!("cannot create media dir {}: {e}", dir.display())); } @@ -553,9 +647,9 @@ pub fn start_generation( steps, seed, frames: (kind == "video").then_some(frames), - state: "running".into(), + state: "queued".into(), progress: None, - phase: Some("starting".into()), + phase: None, log: vec![format!("→ cortiq {}", args.join(" "))], started: now(), finished: None, @@ -563,25 +657,21 @@ pub fn start_generation( out_bytes: None, }, ); - let cancel = Arc::new(Notify::new()); - store - .cancels - .lock() - .unwrap() - .insert(id.clone(), cancel.clone()); - - let bin = cmf.cortiq_bin.clone(); - let gpu = cmf.gpu; - let ret = id.clone(); - let dir_clone = dir.clone(); - let kind_owned = kind.to_string(); - tokio::spawn(async move { - run_generation( - store, id, bin, args, gpu, dir_clone, out_path, kind_owned, cancel, - ) - .await; - }); - Ok(ret) + store.prepared.lock().unwrap().insert( + id.clone(), + Prepared { + bin: cmf.cortiq_bin.clone(), + args, + gpu: cmf.gpu, + dir, + out_path, + kind: kind.to_string(), + }, + ); + store.queue.lock().unwrap().push_back(id.clone()); + store.save(); + promote_next(store.clone()); + Ok(id) } #[allow(clippy::too_many_arguments)] diff --git a/src/pipeline.rs b/src/pipeline.rs index 6f6348d..5aeee6f 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -738,6 +738,7 @@ fn tap_stream( let mut completion_tokens = 0u32; let mut got_usage = false; let mut delta_chars = 0usize; + let mut delta_chunks = 0u32; let mut errored: Option = None; while let Some(item) = inner.next().await { @@ -774,6 +775,7 @@ fn tap_stream( v["choices"][0]["delta"]["content"].as_str() { delta_chars += c.chars().count(); + delta_chunks += 1; } } } @@ -789,7 +791,11 @@ fn tap_stream( // estimate (~4 chars/token), flagged `cortiq_estimated` so nobody // mistakes it for the tokenizer's truth. if done_marker && !got_usage { - let est_completion = (delta_chars / 4) as u32; + let est_completion = if delta_chunks > 0 { + delta_chunks + } else { + (delta_chars / 4) as u32 + }; let est_prompt = (prompt_chars / 4) as u32; let evt = serde_json::json!({ "object": "chat.completion.chunk", @@ -812,7 +818,11 @@ fn tap_stream( if !got_usage && completion_tokens == 0 { // stream ended without a [DONE] marker — estimate for the stats - completion_tokens = (delta_chars / 4) as u32; + completion_tokens = if delta_chunks > 0 { + delta_chunks + } else { + (delta_chars / 4) as u32 + }; prompt_tokens = (prompt_chars / 4) as u32; } let cost = provider.price(prompt_tokens, completion_tokens); diff --git a/src/state.rs b/src/state.rs index c9c5a30..8ebf558 100644 --- a/src/state.rs +++ b/src/state.rs @@ -73,6 +73,15 @@ pub struct AppState { pub imports: Arc, /// Image/video generation jobs (cortiq imagine / animate). pub media: Arc, + /// One-click local model benchmarks (cortiq bench --json). + pub bench: Arc, + /// Host metrics sampler (CPU / RAM / disk) for the dashboard. + pub sysmon: Arc, + /// Newest cortiq-gateway version on crates.io, when newer than ours. + pub gateway_update: Arc>>, + /// Admin console reachable from other machines without a token — the + /// footgun banner flag (set at startup, shown by the SPA). + pub admin_open_public: std::sync::atomic::AtomicBool, /// Managed local CMF model server (install/update/spawn lifecycle + status). pub cmf: Arc, pub cache: Arc, @@ -116,6 +125,29 @@ impl SharedState { let promotion = crate::promotion::Promotion::new(promotion_cfg(&cfg.shadow)); let imports = crate::import::JobStore::new(); let media = crate::media::MediaStore::new(); + let bench = crate::bench::BenchStore::new(); + // jobs survive restarts: files under /, reconciled on load + let data = crate::config::data_dir(); + let _ = std::fs::create_dir_all(&data); + imports.attach_persistence(data.join("jobs-import.json")); + media.attach_persistence(data.join("jobs-media.json")); + let sysmon = crate::sysmon::spawn(cfg.cmf.models_dir.clone()); + let gateway_update: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + { + // check crates.io for a newer gateway once at start and twice a day + let slot = gateway_update.clone(); + tokio::spawn(async move { + loop { + if let Some(latest) = crate::cmf_runtime::latest_gateway_version().await { + let newer = + crate::cmf_runtime::version_lt(env!("CARGO_PKG_VERSION"), &latest); + *slot.lock().unwrap() = newer.then_some(latest); + } + tokio::time::sleep(std::time::Duration::from_secs(12 * 3600)).await; + } + }); + } let cmf = crate::cmf_runtime::CmfRuntime::new(); let cache = SemanticCache::new(&cfg.cache); let router_status = RouterLastStatus::default(); @@ -126,6 +158,10 @@ impl SharedState { promotion, imports, media, + bench, + sysmon, + gateway_update, + admin_open_public: std::sync::atomic::AtomicBool::new(false), cmf, cache, secrets, diff --git a/src/sysmon.rs b/src/sysmon.rs new file mode 100644 index 0000000..9cc2dab --- /dev/null +++ b/src/sysmon.rs @@ -0,0 +1,86 @@ +//! Host metrics sampler for the admin dashboard: CPU load, RAM, free disk at +//! the models dir, and the gateway's own RSS. One background task refreshes a +//! snapshot every 2 s; the API endpoint only reads it. + +use serde::Serialize; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default, Serialize)] +pub struct Snapshot { + pub ts: u64, + /// Whole-machine CPU usage 0..100 (average over all cores). + pub cpu_percent: f32, + pub cores: usize, + pub load1: f64, + pub ram_total: u64, + pub ram_used: u64, + /// Disk that holds models_dir. + pub disk_total: u64, + pub disk_free: u64, + /// Resident memory of the gateway process itself. + pub process_rss: u64, +} + +pub struct SysMon { + snap: Mutex, +} + +impl SysMon { + pub fn snapshot(&self) -> Snapshot { + self.snap.lock().unwrap().clone() + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Start the sampler. `models_dir` picks which disk's free space is reported. +pub fn spawn(models_dir: String) -> Arc { + let mon = Arc::new(SysMon { + snap: Mutex::new(Snapshot::default()), + }); + let ret = mon.clone(); + tokio::task::spawn_blocking(move || { + use sysinfo::{Disks, ProcessesToUpdate, System}; + let mut sys = System::new(); + let pid = sysinfo::Pid::from_u32(std::process::id()); + let models_abs = std::fs::canonicalize(&models_dir) + .unwrap_or_else(|_| std::path::PathBuf::from(&models_dir)); + loop { + sys.refresh_cpu_usage(); + sys.refresh_memory(); + let _ = sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + let disks = Disks::new_with_refreshed_list(); + // the disk whose mount point is the longest prefix of models_dir + let mut best: Option<(usize, u64, u64)> = None; + for d in disks.list() { + let mp = d.mount_point(); + if models_abs.starts_with(mp) { + let len = mp.as_os_str().len(); + if best.map(|(l, _, _)| len > l).unwrap_or(true) { + best = Some((len, d.total_space(), d.available_space())); + } + } + } + let (disk_total, disk_free) = best.map(|(_, t, f)| (t, f)).unwrap_or((0, 0)); + let snap = Snapshot { + ts: now(), + cpu_percent: sys.global_cpu_usage(), + cores: sys.cpus().len(), + load1: System::load_average().one, + ram_total: sys.total_memory(), + ram_used: sys.used_memory(), + disk_total, + disk_free, + process_rss: sys.process(pid).map(|p| p.memory()).unwrap_or(0), + }; + *mon.snap.lock().unwrap() = snap; + std::thread::sleep(std::time::Duration::from_secs(2)); + } + }); + ret +} diff --git a/web/css/styles.css b/web/css/styles.css index 0869d15..31eb743 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -585,6 +585,66 @@ body.modal-open { overflow: hidden; } /* Featured gallery: the list grows as new models are published, so give it more room than the search grid before it starts scrolling. */ .featured-section .hf-grid { max-height: min(62vh, 760px); } +/* Shell notices (open admin warning, update available) */ +.notice { + display: flex; align-items: center; gap: 8px; + padding: 8px 18px; font-size: 13px; + border-bottom: 1px solid var(--border-soft, #232833); +} +.notice.warn { background: color-mix(in srgb, #e5a54b 12%, var(--bg)); } +.notice.info { background: color-mix(in srgb, var(--accent) 10%, var(--bg)); } +.notice .icon-btn { margin-left: auto; } + +/* Dashboard host metrics */ +.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } +.sysitem .k { color: var(--muted); font-size: 12px; } +.sysitem .v { font-weight: 600; } +.sysbar { + height: 8px; border-radius: 6px; overflow: hidden; margin: 6px 0; + background: var(--bg-2, #12151c); border: 1px solid var(--border, #2a2f3a); +} +.sysbar i { display: block; height: 100%; background: var(--accent, #5b8def); } +.sysbar i.bad { background: #e5534b; } + +/* Log viewer */ +.logs-view { max-height: 68vh; } + +/* Chat dialogue bubbles */ +.chat-log { max-height: 56vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } +.chat-msg { display: flex; } +.chat-msg.user { justify-content: flex-end; } +.chat-bubble { + max-width: 85%; padding: 8px 12px; border-radius: 12px; + white-space: pre-wrap; word-break: break-word; font-size: 13.5px; + background: var(--bg-2, #12151c); border: 1px solid var(--border-soft, #232833); +} +.chat-msg.user .chat-bubble { + background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} + +/* Generation gallery */ +.gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; } +.gal-item { + position: relative; cursor: pointer; border: 1px solid var(--border, #2a2f3a); + border-radius: 8px; overflow: hidden; background: var(--bg-2, #12151c); +} +.gal-item:hover { border-color: var(--accent, #5b8def); } +.gal-thumb { width: 100%; display: block; min-height: 64px; } +.gal-video { display: flex; align-items: center; justify-content: center; font-size: 28px; height: 72px; } +.gal-cap { font-size: 10px; color: var(--muted); padding: 3px 6px; } +.gal-del { + position: absolute; top: 2px; right: 2px; z-index: 2; + border: 0; border-radius: 50%; width: 18px; height: 18px; line-height: 16px; + background: rgba(0,0,0,.55); color: #fff; cursor: pointer; font-size: 12px; +} + +/* Bench + badges */ +.bench-row { gap: 10px; padding: 6px 0; border-top: 1px solid var(--border-soft, #232833); } +.bench-row:first-of-type { border-top: 0; } +.badge.warn { background: rgba(229,165,75,.15); color: #e5a54b; } +.snippet { max-height: 260px; } + /* Playground media tabs: generated image / video frame */ .media-out { display: flex; flex-direction: column; gap: 8px; } .media-canvas { diff --git a/web/js/api.js b/web/js/api.js index fa92bcb..77eae01 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -126,6 +126,13 @@ export const api = { mediaCancel: (id) => j("POST", "/media/" + encodeURIComponent(id) + "/cancel"), mediaDelete: (id) => j("DELETE", "/media/" + encodeURIComponent(id)), + // system / logs / lifecycle + system: () => j("GET", "/system"), + logs: (limit = 300) => j("GET", "/logs?limit=" + limit), + restart: () => j("POST", "/restart"), + benchStart: (model) => j("POST", "/bench", { model }), + benchList: () => j("GET", "/bench"), + // first-run onboarding setupStatus: () => j("GET", "/setup/status"), // Connects every finished download as a local model unless register === false. @@ -142,6 +149,21 @@ export const api = { ), }; +// Raw-body POST (no JSON encoding) — the config import takes TOML text as-is. +export async function postRaw(path, text) { + const tok = getToken(); + const headers = { "content-type": "application/toml" }; + if (tok) headers["Authorization"] = "Bearer " + tok; + const r = await fetch(BASE + path, { method: "POST", headers, body: text }); + const body = await r.text(); + if (!r.ok) { + let msg = body; + try { msg = JSON.parse(body).error?.message || body; } catch { /* raw */ } + throw new Error(msg || r.statusText); + } + return body ? JSON.parse(body) : null; +} + // Streaming playground: POST /test/stream, calls onDelta(text) per content chunk, // returns the routing decision read from X-Cortiq-* response headers. export async function testStream(body, onDelta) { diff --git a/web/js/app.js b/web/js/app.js index 120a887..fec3659 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -11,6 +11,7 @@ import { renderProtocols } from "./views/protocols.js"; import { renderKeys } from "./views/keys.js"; import { renderPlayground } from "./views/playground.js"; import { renderSettings } from "./views/settings.js"; +import { renderLogs } from "./views/logs.js"; import { renderOnboarding } from "./views/onboarding.js"; const ROUTES = [ @@ -21,6 +22,7 @@ const ROUTES = [ { id: "protocols", icon: "⇆", view: renderProtocols }, { id: "keys", icon: "🔑", view: renderKeys }, { id: "playground", icon: "✦", view: renderPlayground }, + { id: "logs", icon: "📜", view: renderLogs }, { id: "settings", icon: "⚙", view: renderSettings }, { id: "onboarding", icon: "✦", view: renderOnboarding, hidden: true }, ]; @@ -52,6 +54,27 @@ const FAVICON = '' ); +// One-line banners under the topbar: open-admin warning, update available. +function shellNotices() { + const host = h("div", {}); + const m = appState.meta || {}; + if (m.admin_open_public && !sessionStorage.getItem("hide_open_admin")) { + const close = h("button", { class: "icon-btn", title: "×", onClick: (e) => { sessionStorage.setItem("hide_open_admin", "1"); e.target.closest(".notice").remove(); } }, "×"); + host.appendChild( + h("div", { class: "notice warn" }, "⚠ " + t("notice.openAdmin"), " ", + h("a", { href: "#/settings" }, t("nav.settings") + " →"), close) + ); + } + if (m.update_available) { + host.appendChild( + h("div", { class: "notice info" }, + "⬆ " + t("notice.update", { v: m.update_available }), + " ", h("code", { class: "mono" }, "cargo install cortiq-gateway")) + ); + } + return host; +} + function sidebar(activeId) { const visibleRoutes = ROUTES.filter((r) => !r.hidden); return h( @@ -221,7 +244,7 @@ function renderShell() { { class: "app" }, sidebar(route.id), h("button", { class: "mobile-nav-backdrop", type: "button", tabindex: "-1", "aria-label": "Close menu", onClick: () => setMobileNav(false) }), - h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" })) + h("main", { class: "main" }, topbar(route.id), shellNotices(), h("div", { class: "content", id: "view" })) ) ); renderView(); diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 7914003..617f396 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -506,4 +506,43 @@ export default { "pg.noMediaModels": "Noch kein lokales {kind}-Modell. Lade eines (z. B. aus der infosave-Sammlung) unter Import herunter:", "pg.tokSpeed": "Geschwindigkeit", "pg.ttft": "Erstes Token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Logs", + "logs.subtitle": "Jüngstes Gateway-Log — das, was das Terminal zeigen würde.", + "logs.filterPlaceholder": "filtern…", + "logs.pause": "Pause", + "logs.resume": "Fortsetzen", + "logs.empty": "Nichts gefunden.", + "notice.openAdmin": "Die Konsole ist ohne Token offen und von anderen Rechnern erreichbar. Setze einen Token, wenn diese Maschine nicht privat ist.", + "notice.update": "Update verfügbar: v{v}.", + "dash.sys.title": "System", + "dash.sys.cores": "Kerne", + "dash.sys.disk": "Festplatte (Modelle)", + "dash.sys.free": "frei", + "dash.sys.gateway": "Gateway", + "fit.disk": "zu wenig Speicherplatz", + "fit.diskTitle": "Der Download ist größer als der freie Platz auf der Modell-Festplatte.", + "fit.ram": "passt evtl. nicht in den RAM", + "fit.ramTitle": "Das Modell ist nahe an oder größer als der RAM dieser Maschine — es läuft womöglich nicht.", + "pg.eta": "übrig", + "pg.min": "Min", + "pg.queued": "In der Warteschlange — Position {n}. Startet, sobald die aktuelle Generierung fertig ist.", + "pg.gallery": "Letzte Generierungen", + "pg.galDeleteConfirm": "Diese Generierung samt Dateien löschen?", + "pg.clearChat": "Leeren", + "keys.snip.title": "Deine App verbinden", + "keys.snip.hint": "Füge eines der Beispiele in dein Projekt ein — nur der Schlüssel-Platzhalter muss ersetzt werden.", + "keys.snip.withKey": "Die Beispiele unten enthalten bereits den gerade erstellten Schlüssel.", + "keys.snip.openMode": "Keine API-Schlüssel konfiguriert — das Gateway akzeptiert jeden Wert.", + "keys.snip.keyPh": "dein API-Schlüssel", + "settings.restart": "Gateway neu starten", + "settings.restartConfirm": "Gateway neu starten? Laufende Anfragen brechen ab; lokale Modellserver starten ebenfalls neu.", + "settings.restarting": "Neustart — die Seite lädt neu, sobald das Gateway wieder da ist…", + "bench.title": "Geschwindigkeit lokaler Modelle", + "bench.hint": "Führt `cortiq bench` auf dieser Maschine aus — echte tok/s, ein Modell nach dem anderen.", + "bench.run": "Messen", + "bench.running": "misst…", + "bench.failed": "fehlgeschlagen", + "bench.none": "Noch keine lokalen .cmf-Chat-Modelle.", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 806d66e..9549cc0 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -499,4 +499,43 @@ export default { "pg.noMediaModels": "No local {kind} model yet. Download one (e.g. from the infosave collection) in Import:", "pg.tokSpeed": "Speed", "pg.ttft": "First token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Logs", + "logs.subtitle": "Recent gateway log — what the terminal would show.", + "logs.filterPlaceholder": "filter…", + "logs.pause": "Pause", + "logs.resume": "Resume", + "logs.empty": "Nothing matches.", + "notice.openAdmin": "The admin console is open without a token and reachable from other machines. Set a token if this box is not private.", + "notice.update": "Update available: v{v}.", + "dash.sys.title": "System", + "dash.sys.cores": "cores", + "dash.sys.disk": "Disk (models)", + "dash.sys.free": "free", + "dash.sys.gateway": "gateway", + "fit.disk": "not enough disk", + "fit.diskTitle": "The download is larger than the free space on the models disk.", + "fit.ram": "may not fit in RAM", + "fit.ramTitle": "The model is close to or larger than this machine's RAM — it may not run.", + "pg.eta": "left", + "pg.min": "min", + "pg.queued": "In queue — position {n}. It starts as soon as the current generation finishes.", + "pg.gallery": "Recent generations", + "pg.galDeleteConfirm": "Delete this generation and its files?", + "pg.clearChat": "Clear", + "keys.snip.title": "Connect your app", + "keys.snip.hint": "Paste one of these into your project — only the API key placeholder needs replacing.", + "keys.snip.withKey": "Snippets below include the key you just created.", + "keys.snip.openMode": "No API keys are configured — the gateway accepts any key value.", + "keys.snip.keyPh": "your API key", + "settings.restart": "Restart gateway", + "settings.restartConfirm": "Restart the gateway? Requests in flight will be dropped; local model servers restart too.", + "settings.restarting": "Restarting — the page reloads when the gateway is back…", + "bench.title": "Local model speed", + "bench.hint": "Runs `cortiq bench` on this machine — real tok/s, one model at a time.", + "bench.run": "Measure", + "bench.running": "measuring…", + "bench.failed": "failed", + "bench.none": "No local .cmf chat models yet.", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index f682b95..12701e7 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -506,4 +506,43 @@ export default { "pg.noMediaModels": "Aún no hay un modelo {kind} local. Descarga uno (p. ej. de la colección infosave) en Importar:", "pg.tokSpeed": "Velocidad", "pg.ttft": "Primer token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Registros", + "logs.subtitle": "Registro reciente de la pasarela — lo que mostraría la terminal.", + "logs.filterPlaceholder": "filtrar…", + "logs.pause": "Pausa", + "logs.resume": "Reanudar", + "logs.empty": "Nada coincide.", + "notice.openAdmin": "La consola está abierta sin token y accesible desde otras máquinas. Configura un token si esta máquina no es privada.", + "notice.update": "Actualización disponible: v{v}.", + "dash.sys.title": "Sistema", + "dash.sys.cores": "núcleos", + "dash.sys.disk": "Disco (modelos)", + "dash.sys.free": "libre", + "dash.sys.gateway": "pasarela", + "fit.disk": "no hay espacio suficiente", + "fit.diskTitle": "La descarga supera el espacio libre del disco de modelos.", + "fit.ram": "puede no caber en RAM", + "fit.ramTitle": "El modelo se acerca o supera la RAM de esta máquina — puede que no funcione.", + "pg.eta": "restante", + "pg.min": "min", + "pg.queued": "En cola — posición {n}. Arranca en cuanto termine la generación actual.", + "pg.gallery": "Generaciones recientes", + "pg.galDeleteConfirm": "¿Eliminar esta generación y sus archivos?", + "pg.clearChat": "Limpiar", + "keys.snip.title": "Conecta tu aplicación", + "keys.snip.hint": "Pega uno de estos ejemplos en tu proyecto — solo hay que sustituir el marcador de la clave.", + "keys.snip.withKey": "Los ejemplos de abajo ya incluyen la clave que acabas de crear.", + "keys.snip.openMode": "No hay claves API configuradas — la pasarela acepta cualquier valor.", + "keys.snip.keyPh": "tu clave API", + "settings.restart": "Reiniciar la pasarela", + "settings.restartConfirm": "¿Reiniciar la pasarela? Las peticiones en curso se cortarán; los servidores de modelos locales también se reinician.", + "settings.restarting": "Reiniciando — la página se recargará cuando la pasarela vuelva…", + "bench.title": "Velocidad de modelos locales", + "bench.hint": "Ejecuta `cortiq bench` en esta máquina — tok/s reales, un modelo a la vez.", + "bench.run": "Medir", + "bench.running": "midiendo…", + "bench.failed": "falló", + "bench.none": "Aún no hay modelos de chat .cmf locales.", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 668144c..0905922 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -506,4 +506,43 @@ export default { "pg.noMediaModels": "Pas encore de modèle {kind} local. Téléchargez-en un (par ex. de la collection infosave) dans Importer :", "pg.tokSpeed": "Vitesse", "pg.ttft": "Premier token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Journaux", + "logs.subtitle": "Journal récent de la passerelle — ce que montrerait le terminal.", + "logs.filterPlaceholder": "filtrer…", + "logs.pause": "Pause", + "logs.resume": "Reprendre", + "logs.empty": "Rien ne correspond.", + "notice.openAdmin": "La console est ouverte sans jeton et accessible depuis d'autres machines. Définissez un jeton si cette machine n'est pas privée.", + "notice.update": "Mise à jour disponible : v{v}.", + "dash.sys.title": "Système", + "dash.sys.cores": "cœurs", + "dash.sys.disk": "Disque (modèles)", + "dash.sys.free": "libre", + "dash.sys.gateway": "passerelle", + "fit.disk": "espace disque insuffisant", + "fit.diskTitle": "Le téléchargement dépasse l'espace libre du disque des modèles.", + "fit.ram": "risque de ne pas tenir en RAM", + "fit.ramTitle": "Le modèle approche ou dépasse la RAM de cette machine — il pourrait ne pas tourner.", + "pg.eta": "restant", + "pg.min": "min", + "pg.queued": "En file d'attente — position {n}. Démarre dès la fin de la génération en cours.", + "pg.gallery": "Générations récentes", + "pg.galDeleteConfirm": "Supprimer cette génération et ses fichiers ?", + "pg.clearChat": "Effacer", + "keys.snip.title": "Connecter votre application", + "keys.snip.hint": "Collez un de ces exemples dans votre projet — seul le espace réservé de la clé est à remplacer.", + "keys.snip.withKey": "Les exemples ci-dessous incluent la clé que vous venez de créer.", + "keys.snip.openMode": "Aucune clé API configurée — la passerelle accepte n'importe quelle valeur.", + "keys.snip.keyPh": "votre clé API", + "settings.restart": "Redémarrer la passerelle", + "settings.restartConfirm": "Redémarrer la passerelle ? Les requêtes en cours seront coupées ; les serveurs de modèles locaux redémarrent aussi.", + "settings.restarting": "Redémarrage — la page se recharge dès que la passerelle revient…", + "bench.title": "Vitesse des modèles locaux", + "bench.hint": "Exécute `cortiq bench` sur cette machine — de vrais tok/s, un modèle à la fois.", + "bench.run": "Mesurer", + "bench.running": "mesure…", + "bench.failed": "échec", + "bench.none": "Pas encore de modèles de chat .cmf locaux.", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 89b390d..343d65d 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -498,4 +498,43 @@ export default { "pg.noMediaModels": "Локальной модели ({kind}) ещё нет. Скачайте её (например, из коллекции infosave) во вкладке Импорт:", "pg.tokSpeed": "Скорость", "pg.ttft": "Первый токен", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Логи", + "logs.subtitle": "Последние записи журнала шлюза — то, что показал бы терминал.", + "logs.filterPlaceholder": "фильтр…", + "logs.pause": "Пауза", + "logs.resume": "Продолжить", + "logs.empty": "Ничего не найдено.", + "notice.openAdmin": "Панель управления открыта без токена и доступна с других машин. Задайте токен, если эта машина не приватная.", + "notice.update": "Доступно обновление: v{v}.", + "dash.sys.title": "Система", + "dash.sys.cores": "ядер", + "dash.sys.disk": "Диск (модели)", + "dash.sys.free": "свободно", + "dash.sys.gateway": "шлюз", + "fit.disk": "не хватит места", + "fit.diskTitle": "Загрузка больше свободного места на диске с моделями.", + "fit.ram": "может не влезть в RAM", + "fit.ramTitle": "Модель сравнима с объёмом RAM этой машины или больше — может не запуститься.", + "pg.eta": "осталось", + "pg.min": "мин", + "pg.queued": "В очереди — позиция {n}. Стартует, как только закончится текущая генерация.", + "pg.gallery": "Последние генерации", + "pg.galDeleteConfirm": "Удалить эту генерацию и её файлы?", + "pg.clearChat": "Очистить", + "keys.snip.title": "Подключите ваше приложение", + "keys.snip.hint": "Вставьте один из примеров в свой проект — заменить нужно только плейсхолдер ключа.", + "keys.snip.withKey": "В примерах ниже уже подставлен только что созданный ключ.", + "keys.snip.openMode": "API-ключи не настроены — шлюз принимает любое значение ключа.", + "keys.snip.keyPh": "ваш API-ключ", + "settings.restart": "Перезапустить шлюз", + "settings.restartConfirm": "Перезапустить шлюз? Текущие запросы оборвутся; серверы локальных моделей тоже перезапустятся.", + "settings.restarting": "Перезапуск — страница обновится, когда шлюз поднимется…", + "bench.title": "Скорость локальных моделей", + "bench.hint": "Запускает `cortiq bench` на этой машине — реальные tok/s, по одной модели за раз.", + "bench.run": "Замерить", + "bench.running": "замер…", + "bench.failed": "ошибка", + "bench.none": "Локальных чат-моделей .cmf пока нет.", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 2c67c31..6f10bd1 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -506,4 +506,43 @@ export default { "pg.noMediaModels": "Henüz yerel bir {kind} modeli yok. İçe aktarma sekmesinden bir tane indirin (ör. infosave koleksiyonundan):", "pg.tokSpeed": "Hız", "pg.ttft": "İlk token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "Günlükler", + "logs.subtitle": "Ağ geçidinin son günlüğü — terminalde görecekleriniz.", + "logs.filterPlaceholder": "filtrele…", + "logs.pause": "Duraklat", + "logs.resume": "Devam et", + "logs.empty": "Eşleşen yok.", + "notice.openAdmin": "Yönetim konsolu belirteçsiz açık ve diğer makinelerden erişilebilir. Bu makine özel değilse bir belirteç ayarlayın.", + "notice.update": "Güncelleme mevcut: v{v}.", + "dash.sys.title": "Sistem", + "dash.sys.cores": "çekirdek", + "dash.sys.disk": "Disk (modeller)", + "dash.sys.free": "boş", + "dash.sys.gateway": "ağ geçidi", + "fit.disk": "disk alanı yetmez", + "fit.diskTitle": "İndirme, model diskindeki boş alandan büyük.", + "fit.ram": "RAM'e sığmayabilir", + "fit.ramTitle": "Model bu makinenin RAM'ine yakın ya da ondan büyük — çalışmayabilir.", + "pg.eta": "kaldı", + "pg.min": "dk", + "pg.queued": "Kuyrukta — {n}. sırada. Mevcut üretim biter bitmez başlar.", + "pg.gallery": "Son üretimler", + "pg.galDeleteConfirm": "Bu üretim ve dosyaları silinsin mi?", + "pg.clearChat": "Temizle", + "keys.snip.title": "Uygulamanı bağla", + "keys.snip.hint": "Örneklerden birini projene yapıştır — yalnızca anahtar yer tutucusunu değiştirmen gerekir.", + "keys.snip.withKey": "Aşağıdaki örnekler az önce oluşturduğun anahtarı içeriyor.", + "keys.snip.openMode": "API anahtarı yapılandırılmadı — ağ geçidi her değeri kabul eder.", + "keys.snip.keyPh": "API anahtarın", + "settings.restart": "Ağ geçidini yeniden başlat", + "settings.restartConfirm": "Ağ geçidi yeniden başlatılsın mı? Devam eden istekler kesilir; yerel model sunucuları da yeniden başlar.", + "settings.restarting": "Yeniden başlatılıyor — ağ geçidi dönünce sayfa yenilenecek…", + "bench.title": "Yerel model hızı", + "bench.hint": "Bu makinede `cortiq bench` çalıştırır — gerçek tok/s, aynı anda tek model.", + "bench.run": "Ölç", + "bench.running": "ölçülüyor…", + "bench.failed": "başarısız", + "bench.none": "Henüz yerel .cmf sohbet modeli yok.", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 29f1ba0..f6200e4 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -506,4 +506,43 @@ export default { "pg.noMediaModels": "还没有本地{kind}模型。请在“导入模型”中下载一个(例如 infosave 合集):", "pg.tokSpeed": "速度", "pg.ttft": "首个 token", + + // ops: logs, system, notices, gallery, chat, snippets, bench + "nav.logs": "日志", + "logs.subtitle": "网关最近的日志 — 终端里会看到的内容。", + "logs.filterPlaceholder": "过滤……", + "logs.pause": "暂停", + "logs.resume": "继续", + "logs.empty": "没有匹配内容。", + "notice.openAdmin": "管理控制台未设置令牌即可从其他机器访问。若这台机器不是私有的,请设置令牌。", + "notice.update": "有可用更新:v{v}。", + "dash.sys.title": "系统", + "dash.sys.cores": "核", + "dash.sys.disk": "磁盘(模型)", + "dash.sys.free": "可用", + "dash.sys.gateway": "网关", + "fit.disk": "磁盘空间不足", + "fit.diskTitle": "下载大小超过模型磁盘的可用空间。", + "fit.ram": "可能超出内存", + "fit.ramTitle": "模型接近或超过本机内存 — 可能无法运行。", + "pg.eta": "剩余", + "pg.min": "分钟", + "pg.queued": "排队中 — 第 {n} 位。当前生成结束后立即开始。", + "pg.gallery": "最近的生成", + "pg.galDeleteConfirm": "删除这次生成及其文件?", + "pg.clearChat": "清空", + "keys.snip.title": "接入你的应用", + "keys.snip.hint": "把示例粘贴到你的项目里 — 只需替换密钥占位符。", + "keys.snip.withKey": "下面的示例已包含你刚创建的密钥。", + "keys.snip.openMode": "未配置 API 密钥 — 网关接受任意密钥值。", + "keys.snip.keyPh": "你的 API 密钥", + "settings.restart": "重启网关", + "settings.restartConfirm": "重启网关?进行中的请求会中断;本地模型服务器也会重启。", + "settings.restarting": "正在重启 — 网关恢复后页面会自动刷新……", + "bench.title": "本地模型速度", + "bench.hint": "在本机运行 `cortiq bench` — 真实 tok/s,一次一个模型。", + "bench.run": "测速", + "bench.running": "测速中……", + "bench.failed": "失败", + "bench.none": "还没有本地 .cmf 聊天模型。", }; diff --git a/web/js/views/dashboard.js b/web/js/views/dashboard.js index 76d4a00..75f5f2d 100644 --- a/web/js/views/dashboard.js +++ b/web/js/views/dashboard.js @@ -27,6 +27,87 @@ export async function renderDashboard() { let groupby = "model"; const root = h("div"); + // ── host metrics: CPU sparkline + RAM / disk bars, refreshed every 2 s ── + const sysHost = h("div", {}); + const cpuHistory = []; // last ~60 samples (2 min) + function fmtG(n) { + return (n / 1e9).toFixed(1) + " GB"; + } + function bar(frac, cls) { + return h("div", { class: "sysbar" }, h("i", { class: cls || "", style: `width:${Math.min(100, frac * 100)}%` })); + } + function sparkline(values, w, hgt) { + const c = document.createElement("canvas"); + c.width = w; + c.height = hgt; + const ctx = c.getContext("2d"); + const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#5b8def"; + ctx.strokeStyle = accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const n = Math.max(values.length, 2); + values.forEach((v, i) => { + const x = (i / (n - 1)) * (w - 2) + 1; + const y = hgt - 2 - (Math.min(v, 100) / 100) * (hgt - 4); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + return c; + } + async function refreshSystem() { + let sys; + try { + sys = await api.system(); + } catch { + return; + } + if (!sys.ts) return; + cpuHistory.push(sys.cpu_percent || 0); + if (cpuHistory.length > 60) cpuHistory.shift(); + const ramFrac = sys.ram_total ? sys.ram_used / sys.ram_total : 0; + const diskFrac = sys.disk_total ? (sys.disk_total - sys.disk_free) / sys.disk_total : 0; + mount( + sysHost, + h( + "div", + { class: "card" }, + h("div", { class: "card-title" }, t("dash.sys.title")), + h( + "div", + { class: "sysgrid" }, + h("div", { class: "sysitem" }, + h("div", { class: "flex" }, + h("span", { class: "k grow" }, `CPU · ${sys.cores} ${t("dash.sys.cores")}`), + h("span", { class: "v mono" }, (sys.cpu_percent || 0).toFixed(0) + "%")), + sparkline(cpuHistory, 220, 36), + h("div", { class: "muted small" }, `load ${Number(sys.load1 || 0).toFixed(1)}`)), + h("div", { class: "sysitem" }, + h("div", { class: "flex" }, + h("span", { class: "k grow" }, "RAM"), + h("span", { class: "v mono" }, `${fmtG(sys.ram_used)} / ${fmtG(sys.ram_total)}`)), + bar(ramFrac, ramFrac > 0.9 ? "bad" : ""), + h("div", { class: "muted small" }, t("dash.sys.gateway") + " " + (sys.process_rss / 1e6).toFixed(0) + " MB")), + h("div", { class: "sysitem" }, + h("div", { class: "flex" }, + h("span", { class: "k grow" }, t("dash.sys.disk")), + h("span", { class: "v mono" }, t("dash.sys.free") + " " + fmtG(sys.disk_free)), + ), + bar(diskFrac, diskFrac > 0.92 ? "bad" : ""), + h("div", { class: "muted small" }, `${fmtG(sys.disk_total - sys.disk_free)} / ${fmtG(sys.disk_total)}`)) + ) + ) + ); + } + const sysTimer = setInterval(() => { + if (!root.isConnected) { + clearInterval(sysTimer); + return; + } + refreshSystem(); + }, 2000); + refreshSystem(); + async function load() { const rangeSecs = range; let stats, recent, health; @@ -86,6 +167,8 @@ export async function renderDashboard() { kpi(num(tot.failovers), t("dash.kpi.failovers")) ), + sysHost, + h( "div", { class: "grid cols-2" }, diff --git a/web/js/views/featured.js b/web/js/views/featured.js index 094b83c..0c7c21e 100644 --- a/web/js/views/featured.js +++ b/web/js/views/featured.js @@ -78,7 +78,19 @@ function descLine(desc) { return desc && desc.includes(" ") ? h("div", { class: "muted small", style: "margin:6px 0" }, desc) : null; } -/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v) }. +/// Disk/RAM sanity for a model of `bytes` size against the live system +/// snapshot (ctx.sys): running a model needs it in RAM; downloading needs disk. +function fitWarnings(bytes, sys) { + const out = []; + if (!bytes || !sys) return out; + if (sys.disk_free && bytes > sys.disk_free) + out.push(h("span", { class: "badge bad", title: t("fit.diskTitle") }, "⚠ " + t("fit.disk"))); + else if (sys.ram_total && bytes > sys.ram_total * 0.75) + out.push(h("span", { class: "badge warn", title: t("fit.ramTitle") }, "⚠ " + t("fit.ram"))); + return out; +} + +/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v), sys? }. /// Live progress lands in the per-variant host so a poll can repaint it in place, /// without re-rendering the grid under the user's cursor. export function featuredCard(m, ctx) { @@ -101,6 +113,7 @@ export function featuredCard(m, ctx) { h("div", { class: "hf-meta" }, h("span", { class: "badge" }, badge), descBadge(desc), + ...fitWarnings(m.size_bytes, ctx.sys), isDownloaded ? h("span", { class: "badge ok" }, "✓ " + t("onboarding.downloaded")) : null), descLine(desc), h("div", { class: "muted small" }, m.id), @@ -149,7 +162,8 @@ export function featuredCard(m, ctx) { h("div", { class: "hf-meta" }, h("span", { class: "badge" }, badge), size0 ? h("span", { class: "badge", style: "background:var(--bg); border-style:dashed" + (done0 ? "; opacity:.7" : "") }, (done0 ? "✓ " : "⬇ ") + size0) : null, - descBadge(desc) + descBadge(desc), + ...(done0 ? [] : fitWarnings((v0 && v0.size_bytes) || m.size_bytes, ctx.sys)) ), descLine(desc), h("div", { class: "muted small" }, m.id), diff --git a/web/js/views/import.js b/web/js/views/import.js index cdca6ee..984d9d3 100644 --- a/web/js/views/import.js +++ b/web/js/views/import.js @@ -424,8 +424,10 @@ export function renderImport() { async function renderFeatured(reload = true) { if (reload) featured = await loadFeatured(); + let sys = null; + try { sys = await api.system(); } catch { /* metrics optional */ } featuredHosts = new Map(); - const ctx = { hosts: featuredHosts, onDownload: startFeatured, onDelete: deleteFeatured }; + const ctx = { hosts: featuredHosts, onDownload: startFeatured, onDelete: deleteFeatured, sys }; mount(featuredGrid, ...(featured.length ? featured.map((m) => featuredCard(m, ctx)) : [h("div", { class: "muted pad" }, t("import.noResults"))])); diff --git a/web/js/views/keys.js b/web/js/views/keys.js index 8e83f25..7325598 100644 --- a/web/js/views/keys.js +++ b/web/js/views/keys.js @@ -48,6 +48,68 @@ export async function renderKeys() { const input = h("input", { value: key, readonly: true, class: "mono" }); const copyBtn = h("button", { class: "btn", onClick: () => { input.select(); navigator.clipboard?.writeText(key); toast(t("common.copied"), "good"); } }, t("common.copy")); modal(t("common.ok"), h("div", {}, h("p", {}, t("keys.created")), h("div", { class: "flex" }, input, copyBtn)), null); + lastCreatedKey = key; + reload(); + } + + let lastCreatedKey = null; + + // "Connect your app" — copy-paste snippets with this gateway's real URL. + // Keys are stored hashed-masked in the list, so the key itself is a + // placeholder unless one was just created on this page. + function snippetsCard(openMode) { + const base = location.origin + "/v1"; + const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">"); + const snips = { + curl: `curl ${base}/chat/completions \\ + -H "Authorization: Bearer ${key}" \\ + -H "Content-Type: application/json" \\ + -d '{"model":"cortiq-auto","messages":[{"role":"user","content":"Привет!"}]}'`, + python: `from openai import OpenAI + +client = OpenAI(base_url="${base}", api_key="${key}") +resp = client.chat.completions.create( + model="cortiq-auto", # авто-роутинг; или точное имя модели + messages=[{"role": "user", "content": "Привет!"}], +) +print(resp.choices[0].message.content)`, + javascript: `import OpenAI from "openai"; + +const client = new OpenAI({ baseURL: "${base}", apiKey: "${key}" }); +const resp = await client.chat.completions.create({ + model: "cortiq-auto", + messages: [{ role: "user", content: "Привет!" }], +}); +console.log(resp.choices[0].message.content);`, + }; + let cur = "curl"; + const pre = h("pre", { class: "conv-log snippet" }, snips[cur]); + const seg = h( + "div", + { class: "seg" }, + ...Object.keys(snips).map((k, i) => + h("button", { class: i === 0 ? "active" : "", onClick: (e) => { + cur = k; + pre.textContent = snips[k]; + seg.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + e.target.classList.add("active"); + } }, k) + ) + ); + const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy")); + copy.addEventListener("click", async () => { + await navigator.clipboard.writeText(pre.textContent); + toast(t("common.copied"), "ok"); + }); + return h( + "div", + { class: "card" }, + h("div", { class: "card-title" }, "🔌 " + t("keys.snip.title")), + h("div", { class: "muted small", style: "margin-bottom:8px" }, + lastCreatedKey ? t("keys.snip.withKey") : openMode ? t("keys.snip.openMode") : t("keys.snip.hint")), + h("div", { class: "flex", style: "gap:8px; margin-bottom:8px" }, seg, h("div", { class: "grow" }), copy), + pre + ); } async function reload() { @@ -119,7 +181,8 @@ export async function renderKeys() { ) ) ) - ) + ), + snippetsCard(!!data.open_mode) ); } diff --git a/web/js/views/logs.js b/web/js/views/logs.js new file mode 100644 index 0000000..5a208a4 --- /dev/null +++ b/web/js/views/logs.js @@ -0,0 +1,84 @@ +// Gateway log viewer — the in-memory ring served by /admin/api/logs, for +// everyone who never sees the terminal (Docker, autostart, double-clicked exe). +import { h, mount, toast } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; + +export function renderLogs() { + const root = h("div"); + let timer = null; + let paused = false; + let filter = ""; + let level = ""; + + const pre = h("pre", { class: "conv-log logs-view" }, t("common.loading")); + const filterIn = h("input", { placeholder: t("logs.filterPlaceholder"), style: "max-width:260px" }); + const levelSel = h( + "select", + { style: "max-width:130px" }, + h("option", { value: "" }, t("common.all")), + ...["ERROR", "WARN", "INFO", "DEBUG"].map((l) => h("option", { value: l }, l)) + ); + const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause")); + const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy")); + + let lines = []; + function paint() { + let shown = lines; + if (level) shown = shown.filter((l) => l.includes(` ${level} `) || l.includes(`${level} `)); + if (filter) shown = shown.filter((l) => l.toLowerCase().includes(filter)); + pre.textContent = shown.length ? shown.join("\n") : t("logs.empty"); + if (!paused) pre.scrollTop = pre.scrollHeight; + } + async function refresh() { + if (paused) return; + try { + lines = (await api.logs(500)).lines || []; + paint(); + } catch { + /* transient */ + } + } + filterIn.addEventListener("input", () => { + filter = filterIn.value.trim().toLowerCase(); + paint(); + }); + levelSel.addEventListener("change", () => { + level = levelSel.value; + paint(); + }); + pauseBtn.addEventListener("click", () => { + paused = !paused; + pauseBtn.textContent = (paused ? "▶ " : "⏸ ") + t(paused ? "logs.resume" : "logs.pause"); + if (!paused) refresh(); + }); + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(pre.textContent); + toast(t("common.copied"), "ok"); + } catch (e) { + toast(String(e.message || e), "err"); + } + }); + + timer = setInterval(() => { + if (!root.isConnected) { + clearInterval(timer); + return; + } + refresh(); + }, 2000); + refresh(); + + mount( + root, + h("div", { class: "view-head" }, h("h2", {}, t("nav.logs")), h("div", { class: "muted" }, t("logs.subtitle"))), + h( + "div", + { class: "card" }, + h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn), + pre + ) + ); + return root; +} diff --git a/web/js/views/models.js b/web/js/views/models.js index dae3c2c..c875712 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -315,10 +315,63 @@ export async function renderModels() { ) ) ) - ) + ), + benchCard() ); } + // ── one-click benchmark of local .cmf files (cortiq bench --json) ── + function benchCard() { + const host = h("div", { class: "card" }, h("div", { class: "card-title" }, "⏱ " + t("bench.title")), + h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")), + h("div", { class: "empty" }, t("common.loading"))); + let timer = null; + async function paint() { + let files = []; + let runs = {}; + try { + files = (await api.mediaModels()).models || []; + for (const r of (await api.benchList()).runs || []) runs[r.model] = r; + } catch { + return; + } + const chat = files.filter((m) => m.modality === "chat"); + const anyRunning = Object.values(runs).some((r) => r.state === "running"); + const rows = chat.map((m) => { + const r = runs[m.name]; + const go = h("button", { class: "btn sm ghost", disabled: anyRunning ? true : null }, "⏱ " + t("bench.run")); + go.addEventListener("click", async () => { + try { + await api.benchStart(m.name); + paint(); + if (!timer) timer = setInterval(() => { + if (!host.isConnected) { clearInterval(timer); return; } + paint(); + }, 3000); + } catch (e) { + toast(String(e.message || e), "bad"); + } + }); + let result = h("span", { class: "muted" }, "—"); + if (r?.state === "running") result = h("span", { class: "flex" }, h("span", { class: "spinner" }), " ", t("bench.running")); + else if (r?.state === "done") result = h("b", { class: "mono" }, (r.tok_s != null ? r.tok_s.toFixed(1) : "?") + " tok/s"); + else if (r?.state === "error") result = h("span", { class: "err", title: (r.log || []).slice(-2).join(" · ") }, t("bench.failed")); + return h("div", { class: "flex bench-row" }, + h("span", { class: "mono grow" }, m.name), + h("span", { class: "muted small" }, (m.size_bytes / 1e9).toFixed(2) + " GB"), + result, + go); + }); + mount(host, + h("div", { class: "card-title" }, "⏱ " + t("bench.title")), + h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")), + ...(rows.length ? rows : [h("div", { class: "empty" }, t("bench.none"))])); + if (!anyRunning && timer) { clearInterval(timer); timer = null; } + } + paint(); + return host; + } + await reload(); return root; } diff --git a/web/js/views/onboarding.js b/web/js/views/onboarding.js index e6da1b9..ce6e8f9 100644 --- a/web/js/views/onboarding.js +++ b/web/js/views/onboarding.js @@ -45,6 +45,7 @@ export function renderOnboarding() { const root = h("div", { class: "onb-wrap" }); let jobsPoll = null; let jobs = []; + let sysSnapshot = null; // disk/RAM для плашек "не влезет" на карточках let jobStates = new Map(); // job id → last seen state, to spot completions let hosts = new Map(); // job id → card progress host (rebuilt on every render) let jobsHost = h("div", {}); @@ -131,6 +132,7 @@ export function renderOnboarding() { async function loadFeatured() { featured = await fetchFeatured(); + try { sysSnapshot = await api.system(); } catch { /* optional */ } featuredLoaded = true; } @@ -199,7 +201,7 @@ export function renderOnboarding() { ); } else if (step === 1) { const top = topNav(prev, next, h("button", { class: "btn ghost", onClick: () => { step = 3; render(); } }, t("onboarding.skip"))); - const ctx = { hosts, onDownload: startDownload, onDelete: handleDelete }; + const ctx = { hosts, onDownload: startDownload, onDelete: handleDelete, sys: sysSnapshot }; // the list is fetched from HuggingFace — say so instead of showing a blank grid const grid = featured.length ? h("div", { class: "hf-grid" }, ...featured.map((m) => featuredCard(m, ctx))) @@ -290,7 +292,8 @@ export function renderOnboarding() { // let the toast be readable before the reload swaps the page await new Promise((res) => setTimeout(res, 900)); } - location.hash = "#/models"; + // land where the reward is: try the model right away + location.hash = "#/playground"; location.reload(); }); const skipBtn = h("button", { class: "btn ghost", onClick: async () => { diff --git a/web/js/views/playground.js b/web/js/views/playground.js index 187bc8b..ee9cb3f 100644 --- a/web/js/views/playground.js +++ b/web/js/views/playground.js @@ -91,16 +91,33 @@ export async function renderPlayground() { modeExtra = next; } - const out = h("div", { class: "answer" }, h("div", { class: "empty" }, t("pg.empty"))); + const chatLog = []; // the running conversation: {role, content} + const out = h("div", { class: "answer chat-log" }, h("div", { class: "empty" }, t("pg.empty"))); const decision = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—")); + function bubble(role, text) { + return h("div", { class: "chat-msg " + role }, h("div", { class: "chat-bubble" }, text)); + } + function renderChat(liveText) { + const items = chatLog.map((m) => bubble(m.role, m.content)); + if (liveText != null) items.push(bubble("assistant", liveText || "…")); + mount(out, ...(items.length ? items : [h("div", { class: "empty" }, t("pg.empty"))])); + out.scrollTop = out.scrollHeight; + } + const clearBtn = h("button", { class: "btn ghost sm" }, "🧹 " + t("pg.clearChat")); + clearBtn.addEventListener("click", () => { + chatLog.length = 0; + renderChat(); + mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—")); + }); + const sendBtn = h("button", { class: "btn primary" }, t("pg.send")); sendBtn.addEventListener("click", async () => { const prompt = promptIn.value.trim(); if (!prompt) return; const messages = []; if (sysIn.value.trim()) messages.push({ role: "system", content: sysIn.value.trim() }); - messages.push({ role: "user", content: prompt }); + messages.push(...chatLog, { role: "user", content: prompt }); let model = "cortiq-auto"; if (mode === "auto") { if (profileSel.value) model = "cortiq-auto:" + profileSel.value; @@ -109,7 +126,9 @@ export async function renderPlayground() { } sendBtn.disabled = true; sendBtn.textContent = t("pg.sending"); - mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending"))); + chatLog.push({ role: "user", content: prompt }); + promptIn.value = ""; + renderChat(""); const body = { model, messages, @@ -120,12 +139,12 @@ export async function renderPlayground() { if (streamCb.checked) { const started = performance.now(); let acc = ""; - out.textContent = ""; const info = await testStream(body, (d) => { acc += d; - out.textContent = acc; + renderChat(acc); }); - out.textContent = acc || "—"; + chatLog.push({ role: "assistant", content: acc || "—" }); + renderChat(); const latency = Math.round(performance.now() - started); renderDecision({ cortiq: { @@ -144,19 +163,33 @@ export async function renderPlayground() { } else { const r = await api.test(body); if (r.ok) { - mount(out, r.answer || h("span", { class: "muted" }, "—")); + chatLog.push({ role: "assistant", content: r.answer || "—" }); + renderChat(); renderDecision(r); } else { - mount(out, h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || "")); + chatLog.pop(); // the user turn failed — let them retry it + renderChat(); + promptIn.value = prompt; + mount(out, out.firstChild || "", h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || "")); mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—")); } } } catch (e) { - mount(out, h("div", { class: "callout" }, String(e.message || e))); + chatLog.pop(); + renderChat(); + promptIn.value = prompt; + toast(String(e.message || e), "err"); } sendBtn.disabled = false; sendBtn.textContent = t("pg.send"); }); + // Cmd/Ctrl+Enter sends + promptIn.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + sendBtn.click(); + } + }); function renderDecision(r) { const c = r.cortiq || {}; @@ -207,9 +240,9 @@ export async function renderPlayground() { h("div", { class: "row" }, field(t("pg.temp"), tempIn), field(t("pg.maxTokens"), maxTokIn)), h( "div", - { class: "flex", style: "justify-content:space-between" }, + { class: "flex", style: "justify-content:space-between; gap:8px" }, h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")), - sendBtn + h("div", { class: "flex", style: "gap:8px" }, clearBtn, sendBtn) ) ), h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out) @@ -235,8 +268,24 @@ export async function renderPlayground() { } catch { return; } - const secs = ((performance.now() - started) / 1000).toFixed(0); + const elapsed = (performance.now() - started) / 1000; const pct = j.progress != null ? Math.round(j.progress * 100) : null; + // honest ETA once there is enough signal: total ≈ elapsed / progress + let eta = ""; + if (j.state === "running" && j.progress > 0.03) { + const left = elapsed / j.progress - elapsed; + if (left > 1) eta = ` · ${t("pg.eta")} ~${left > 90 ? Math.round(left / 60) + " " + t("pg.min") : Math.round(left) + " s"}`; + } + let statusLine; + if (j.state === "queued") { + const pos = (j.queue_position ?? 0) + 1; + statusLine = t("pg.queued", { n: pos }); + } else { + statusLine = + (j.phase || j.state) + + (pct != null && j.state === "running" ? ` · ${pct}%` : "") + + ` · ${elapsed.toFixed(0)}s` + eta; + } mount( ui.bar, h("div", { class: "pbar" }, @@ -244,13 +293,14 @@ export async function renderPlayground() { ? (pct != null ? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` }) : h("div", { class: "pbar-fill run" })) - : h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" }) + : j.state === "queued" + ? h("div", { class: "pbar-fill run" }) + : h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" }) ), - h("div", { class: "muted small" }, - (j.phase || j.state) + (pct != null && j.state === "running" ? ` · ${pct}%` : "") + ` · ${secs}s`), - (j.log || []).length ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null + h("div", { class: "muted small" }, statusLine), + (j.log || []).length && j.state === "running" ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null ); - if (j.state !== "running") { + if (j.state !== "running" && j.state !== "queued") { stopMediaPoll(); ui.goBtn.disabled = false; ui.cancelBtn.style.display = "none"; @@ -286,6 +336,65 @@ export async function renderPlayground() { return b; } + // ── gallery: past generations of one kind, loaded lazily ── + function buildGallery(kind, onOpen) { + const host = h("div", {}); + async function refresh() { + let jobs = []; + try { + jobs = ((await api.mediaJobs()).jobs || []).filter((j) => j.kind === kind && j.state === "done"); + } catch { + return; + } + if (!jobs.length) { + mount(host); + return; + } + const grid = h("div", { class: "gal-grid" }); + for (const j of jobs.slice(0, 12)) { + const del = h("button", { class: "gal-del", title: t("import.delete") }, "×"); + del.addEventListener("click", async (e) => { + e.stopPropagation(); + if (!window.confirm(t("pg.galDeleteConfirm"))) return; + try { + await api.mediaDelete(j.id); + refresh(); + } catch (err) { + toast(err.message, "err"); + } + }); + const cap = h("div", { class: "gal-cap" }, `${j.width}×${j.height} · seed ${j.seed}`); + const item = h("div", { class: "gal-item", title: j.prompt }, del, cap); + if (kind === "image") { + // lazy thumbnail: decode the PPM only when the item scrolls into view + const thumb = h("canvas", { class: "gal-thumb" }); + item.prepend(thumb); + const io = new IntersectionObserver(async (entries) => { + if (!entries.some((x) => x.isIntersecting)) return; + io.disconnect(); + try { + const buf = await mediaFileBytes(j.id, "image"); + const full = ppmToCanvas(buf); + thumb.width = 128; + thumb.height = Math.round((128 * full.height) / full.width) || 128; + thumb.getContext("2d").drawImage(full, 0, 0, thumb.width, thumb.height); + } catch { + /* file gone */ + } + }); + io.observe(item); + } else { + item.prepend(h("div", { class: "gal-thumb gal-video" }, "🎬")); + } + item.addEventListener("click", () => onOpen(j)); + grid.appendChild(item); + } + mount(host, h("div", { class: "card-title", style: "margin-top:14px" }, t("pg.gallery")), grid); + } + refresh(); + return { host, refresh }; + } + // ════════════════════════════ image panel ════════════════════════════ let imagePanel; if (!imageModels.length) { @@ -328,33 +437,8 @@ export async function renderPlayground() { watchJob(r.job, { bar: imBar, goBtn: imGo, cancelBtn: imCancel }, async (j) => { mount(imBar); const secs = (performance.now() - t0) / 1000; - try { - const buf = await mediaFileBytes(j.id, "image"); - const canvas = ppmToCanvas(buf); - canvas.className = "media-canvas"; - const dl = h("button", { class: "btn sm" }, "⬇ PNG"); - dl.addEventListener("click", () => - canvas.toBlob((b) => { - const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` }); - a.click(); - }, "image/png") - ); - mount(imOut, canvas, h("div", { class: "actions" }, dl)); - mount( - imMetrics, - h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), - kv(t("pg.model"), h("span", { class: "mono" }, j.model)), - kv(t("pg.resolution"), `${j.width}×${j.height}`), - kv(t("pg.steps"), String(j.steps)), - kv("CFG", String(params.cfg)), - kv(t("pg.seed"), String(j.seed)), - kv(t("pg.genTime"), secs.toFixed(1) + " s"), - kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"), - kv(t("pg.fileSize"), fmtBytes(j.out_bytes)) - ); - } catch (e) { - mount(imOut, h("div", { class: "callout" }, String(e.message || e))); - } + await showImageResult(j, secs); + imGallery.refresh(); }); } catch (e) { imGo.disabled = false; @@ -363,6 +447,45 @@ export async function renderPlayground() { } }); + async function showImageResult(j, secs) { + try { + const buf = await mediaFileBytes(j.id, "image"); + const canvas = ppmToCanvas(buf); + canvas.className = "media-canvas"; + const dl = h("button", { class: "btn sm" }, "⬇ PNG"); + dl.addEventListener("click", () => + canvas.toBlob((b) => { + const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` }); + a.click(); + }, "image/png") + ); + mount(imOut, canvas, h("div", { class: "actions" }, dl)); + const rows = [ + kv(t("pg.model"), h("span", { class: "mono" }, j.model)), + kv(t("pg.resolution"), `${j.width}×${j.height}`), + kv(t("pg.steps"), String(j.steps)), + kv(t("pg.seed"), String(j.seed)), + kv(t("pg.fileSize"), fmtBytes(j.out_bytes)), + ]; + if (secs != null) { + rows.push(kv(t("pg.genTime"), secs.toFixed(1) + " s")); + rows.push(kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s")); + } else if (j.finished && j.started) { + const dur = j.finished - j.started; + rows.push(kv(t("pg.genTime"), dur + " s")); + rows.push(kv(t("pg.timePerStep"), (dur / j.steps).toFixed(2) + " s")); + } + mount(imMetrics, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), ...rows); + } catch (e) { + mount(imOut, h("div", { class: "callout" }, String(e.message || e))); + } + } + const imGallery = buildGallery("image", (j) => { + imSeed.value = j.seed; + imSteps.value = j.steps; + showImageResult(j, null); + }); + imagePanel = h( "div", { class: "pg" }, @@ -383,7 +506,7 @@ export async function renderPlayground() { h("div", { class: "actions" }, imCancel, imGo), imBar ), - h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut) + h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut, imGallery.host) ), imMetrics ); @@ -436,6 +559,7 @@ export async function renderPlayground() { const t0 = performance.now(); watchJob(r.job, { bar: vBar, goBtn: vGo, cancelBtn: vCancel }, async (j) => { mount(vBar); + vGallery.refresh(); const secs = (performance.now() - t0) / 1000; try { await showVideoResult(j, vOut); @@ -507,6 +631,8 @@ export async function renderPlayground() { mount(host, player.canvas, h("div", { class: "actions" }, playBtn, ...downloads.childNodes ? [downloads] : [])); } + const vGallery = buildGallery("video", (j) => showVideoResult(j, vOut)); + videoPanel = h( "div", { class: "pg" }, @@ -527,7 +653,7 @@ export async function renderPlayground() { h("div", { class: "actions" }, vCancel, vGo), vBar ), - h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut) + h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut, vGallery.host) ), vMetrics ); diff --git a/web/js/views/settings.js b/web/js/views/settings.js index 7d6993a..3224cff 100644 --- a/web/js/views/settings.js +++ b/web/js/views/settings.js @@ -1,7 +1,7 @@ // Settings — router/route/breaker/log/telemetry/stats + export/import config. -import { h, mount, toast } from "../ui.js"; +import { h, mount, toast, confirmDialog } from "../ui.js"; import { t } from "../i18n.js"; -import { api } from "../api.js"; +import { api, getToken, postRaw } from "../api.js"; import { appState, SITE_URL } from "../app.js"; // A small "?" badge with a hover/focus tooltip explaining a setting. @@ -480,22 +480,33 @@ export async function renderSettings() { }); async function exportConfig() { - const cfg = await api.getConfig(); - const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = h("a", { href: url, download: "gateway-config.json" }); + // the real gateway.toml from disk — comments and all + const tok = getToken(); + const headers = {}; + if (tok) headers["Authorization"] = "Bearer " + tok; + const r = await fetch("/admin/api/config/export", { headers }); + if (!r.ok) { + toast(await r.text(), "bad"); + return; + } + const url = URL.createObjectURL(await r.blob()); + const a = h("a", { href: url, download: "gateway.toml" }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } - const fileIn = h("input", { type: "file", accept: ".json,application/json", style: "display:none" }); + const fileIn = h("input", { type: "file", accept: ".toml,.json,application/json", style: "display:none" }); fileIn.addEventListener("change", async () => { const f = fileIn.files[0]; if (!f) return; try { - const cfg = JSON.parse(await f.text()); - await api.putConfig(cfg); + const text = await f.text(); + if (f.name.endsWith(".json")) { + await api.putConfig(JSON.parse(text)); // legacy JSON export + } else { + await postRaw("/config/import", text); // gateway.toml — validated + hot-reloaded + } toast(t("toast.imported"), "good"); } catch (e) { toast(String(e.message || e), "bad"); @@ -503,6 +514,29 @@ export async function renderSettings() { fileIn.value = ""; }); + const restartBtn = h("button", { class: "btn danger" }, "⟳ " + t("settings.restart")); + restartBtn.addEventListener("click", async () => { + if (!(await confirmDialog(t("settings.restartConfirm")))) return; + restartBtn.disabled = true; + try { + await api.restart(); + toast(t("settings.restarting"), "good"); + // the gateway comes back on the same port — reload once it answers + const poll = setInterval(async () => { + try { + await api.health(); + clearInterval(poll); + location.reload(); + } catch { + /* still restarting */ + } + }, 1000); + } catch (e) { + toast(String(e.message || e), "bad"); + restartBtn.disabled = false; + } + }); + mount( root, h( @@ -623,7 +657,9 @@ export async function renderSettings() { { class: "flex wrap" }, h("button", { class: "btn", onClick: exportConfig }, t("settings.export")), h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")), - fileIn + fileIn, + h("div", { class: "grow" }), + restartBtn ) ) );