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

Fifteen changes aimed at the first day of real use:

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

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
This commit is contained in:
infosave2007
2026-08-07 17:07:48 +03:00
parent a77beeabab
commit 15de36be3b
32 changed files with 1876 additions and 143 deletions
+151 -61
View File
@@ -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<u32>,
pub state: String, // running | done | error | cancelled
pub state: String, // queued | running | done | error | cancelled
pub progress: Option<f32>,
pub phase: Option<String>,
pub log: Vec<String>,
@@ -206,18 +206,67 @@ pub struct MediaJob {
pub out_bytes: Option<u64>,
}
/// Everything needed to actually launch a queued job when its turn comes.
struct Prepared {
bin: String,
args: Vec<String>,
gpu: bool,
dir: PathBuf,
out_path: PathBuf,
kind: String,
}
#[derive(Default)]
pub struct MediaStore {
jobs: Mutex<HashMap<String, MediaJob>>,
cancels: Mutex<HashMap<String, Arc<Notify>>>,
/// One generation at a time — these models take the whole GPU/RAM budget.
busy: Mutex<Option<String>>,
/// 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<std::collections::VecDeque<String>>,
current: Mutex<Option<String>>,
prepared: Mutex<HashMap<String, Prepared>>,
persist: Mutex<Option<PathBuf>>,
}
impl MediaStore {
pub fn new() -> Arc<Self> {
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::<Vec<MediaJob>>(&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<MediaJob> = 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<usize> {
self.queue.lock().unwrap().iter().position(|x| x == id)
}
pub fn list(&self) -> Vec<MediaJob> {
let mut v: Vec<MediaJob> = 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<MediaJob> {
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<Self>, 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<bool, String> {
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<String>, out_bytes: u64) {
fn finish(self: &Arc<Self>, id: &str, ok: bool, outputs: Vec<String>, 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<Self>, 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<Self>, 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<MediaStore>) {
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)]