Status LEDs, image model selection, backup restore, provider test, monthly versions

- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 09:31:24 +01:00
1 parent 49d404eec7
commit 1f69d63b09
35 files changed
+1156 -88

No files matched your search

+107
View File
@@ -119,11 +119,15 @@ pub fn router(app: AppState) -> Router {
.route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/backups/{name}/restore", post(restore_stored_backup))
.route("/backups/restore", post(restore_uploaded_backup).layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024 * 1024)))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/providers/status", get(provider_status).post(recheck_providers))
.route("/providers/test", post(test_provider))
.route("/status", get(status_overview))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
@@ -1116,6 +1120,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(json!(res)))
}
/// Stage a restore and restart: the database is replaced before it is opened again.
/// In Docker the restart policy brings the server back up.
async fn restore_and_restart(app: &App, user: &User, bytes: Vec<u8>, source: &str) -> ApiResult {
crate::backup::stage_restore(app, bytes).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(user), "backup.restore", source, "");
tracing::warn!(source, "backup staged for restore – restarting");
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
std::process::exit(0);
});
Ok(Json(json!({"ok": true, "restart": true})))
}
async fn restore_stored_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
restore_and_restart(&app, &user, bytes, &name).await
}
async fn restore_uploaded_backup(Extension(user): Extension<User>, State(app): State<AppState>, body: axum::body::Bytes) -> ApiResult {
require_admin(&user)?;
restore_and_restart(&app, &user, body.to_vec(), &format!("upload ({} bytes)", body.len())).await
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
@@ -1138,10 +1169,85 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
/// Status overlay: every enabled provider and MCP server with its state. Error texts can
/// contain URLs or hosts, so only admins get them.
async fn status_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let admin = user.is_admin();
let cfg = app.config();
let registry = app.registry();
let health = app.health.all();
let providers: serde_json::Map<String, Value> = cfg
.providers
.iter()
.filter(|(_, p)| p.enabled)
.map(|(name, _)| {
let models = registry.providers.get(name).map(|e| e.models.len()).unwrap_or(0);
let v = match health.get(name) {
Some(h) => json!({
"state": h.state,
"latency_ms": h.latency_ms,
"checked_at": h.checked_at,
"error": if admin { h.error.clone() } else { None },
"models": models,
}),
None => json!({"state": "unknown", "models": models}),
};
(name.clone(), v)
})
.collect();
let mcp: serde_json::Map<String, Value> = app
.mcp
.states()
.into_iter()
.filter(|(_, st)| !matches!(st, crate::mcp::McpState::Disabled))
.map(|(name, st)| {
let v = match st {
crate::mcp::McpState::Connected { tools } => json!({"state": "connected", "tools": tools}),
crate::mcp::McpState::Failed { error } => json!({"state": "failed", "error": if admin { Some(error) } else { None }}),
_ => json!({"state": "connecting"}),
};
(name, v)
})
.collect();
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.health.all()))
}
#[derive(Deserialize)]
struct TestProviderBody {
/// Existing provider: its stored key is used when `api_key` is empty.
#[serde(default)]
name: Option<String>,
kind: String,
base_url: String,
#[serde(default)]
api_key: String,
#[serde(default)]
auth_prefix: Option<String>,
}
/// Check URL and key from the edit form without saving them.
async fn test_provider(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<TestProviderBody>) -> ApiResult {
require_admin(&user)?;
let stored = body.name.as_ref().and_then(|n| app.config().providers.get(n).cloned());
let mut value = stored.as_ref().map(|p| serde_json::to_value(p)).transpose()?.unwrap_or_else(|| json!({}));
value["kind"] = json!(body.kind);
value["base_url"] = json!(body.base_url);
if !body.api_key.is_empty() {
value["api_key"] = json!(body.api_key);
}
if let Some(prefix) = body.auth_prefix {
value["auth_prefix"] = if prefix.is_empty() { Value::Null } else { json!(prefix) };
}
let pc: crate::config::ProviderConfig = serde_json::from_value(value).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("invalid provider: {e}")))?;
let provider = crate::provider::build(crate::provider::http_client(), &pc);
let (status, models) = crate::health::probe(provider).await;
Ok(Json(json!({"status": status, "models": models})))
}
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
crate::health::check_all(&app).await;
@@ -1220,6 +1326,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
dir,
user_id: Some(user.id.clone()),
session_id: None,
references: vec![],
},
)
.await?;