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:
1 parent
49d404eec7
commit
1f69d63b09
35 files changed
+1156
-88
No files matched your search
@@ -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?;
|
||||
|
||||
Reference in new issue
Block a user