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:
+51
-1
@@ -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<HashMap<String, Arc<Notify>>>,
|
||||
/// When set, every state change lands in this JSON file, so the job list —
|
||||
/// and its Register buttons — survives a gateway restart.
|
||||
persist: Mutex<Option<std::path::PathBuf>>,
|
||||
last_save: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl JobStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
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::<Vec<Job>>(&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<Job> = 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<Notify>) {
|
||||
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<Job> {
|
||||
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<Job> {
|
||||
|
||||
Reference in New Issue
Block a user