pub mod candidates;
pub mod decisions;
pub mod documents;
pub mod drift;
pub mod entities;
pub mod graph;
pub mod memos;
pub mod relations;
use std::io;
use axum::http::{HeaderValue, Method};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use tokio::task::JoinHandle;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use crate::assets;
use crate::config::Config;
use crate::state::AppState;
pub fn router(state: AppState, config: &Config) -> Router {
let api = Router::new()
.route("/health", get(health))
.route("/documents", post(documents::create).get(documents::list))
.route("/documents/:id", get(documents::get_one))
.route("/documents/:id/status", post(documents::set_status))
.route(
"/documents/:id/candidates",
get(candidates::list_for_document),
)
.route("/candidates", get(candidates::list))
.route("/candidates/:id/accept", post(candidates::accept))
.route("/candidates/:id/reject", post(candidates::reject))
.route("/decisions", get(decisions::list))
.route("/decisions/:id", get(decisions::get_one))
.route("/graph", get(graph::get_graph))
.route("/assumptions", get(entities::list_assumptions))
.route("/actions", get(entities::list_actions))
.route("/evidence", get(entities::list_evidence))
.route("/relations", post(relations::create))
.route("/drift-signals", get(drift::list))
.route("/drift-signals/:id/accept", post(drift::accept))
.route("/drift-signals/:id/dismiss", post(drift::dismiss))
.route("/memos", get(memos::list).post(memos::create))
.route("/memos/:id", get(memos::get_one).put(memos::update))
.route(
"/memos/from-drift/:drift_signal_id",
post(memos::from_drift),
)
.layer(TraceLayer::new_for_http())
.layer(build_cors(config))
.with_state(state);
if config.serve_dashboard {
attach_dashboard(api, config)
} else {
api
}
}
pub fn spawn(state: AppState, config: &Config) -> JoinHandle<()> {
let bind_addr = config.api_bind_addr.clone();
let app = router(state, config);
tokio::spawn(async move {
match tokio::net::TcpListener::bind(&bind_addr).await {
Ok(listener) => {
tracing::info!(addr = %bind_addr, "HTTP API + dashboard listening");
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "HTTP server stopped");
}
}
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
tracing::info!(
addr = %bind_addr,
"port already in use — reusing existing HTTP server"
);
}
Err(e) => {
tracing::error!(addr = %bind_addr, error = %e, "failed to bind HTTP server");
}
}
})
}
fn attach_dashboard(router: Router, config: &Config) -> Router {
if let Some(dist) = &config.frontend_dist_override {
let index = dist.join("index.html");
if index.exists() {
tracing::info!(path = %dist.display(), "serving dashboard from disk override");
let serve_dir = ServeDir::new(dist).not_found_service(ServeFile::new(index));
return router.fallback_service(serve_dir);
}
tracing::warn!(
path = %dist.display(),
"COCKPIT_FRONTEND_DIST set but index.html missing — using embedded dashboard"
);
}
tracing::info!("serving embedded dashboard");
router.fallback(get(assets::serve_dashboard))
}
async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
fn build_cors(config: &Config) -> CorsLayer {
let methods = [Method::GET, Method::POST, Method::PUT, Method::OPTIONS];
if config
.cors_allowed_origins
.iter()
.any(|o| o == "*")
|| config.cors_allowed_origins.is_empty()
{
return CorsLayer::new()
.allow_origin(Any)
.allow_methods(methods)
.allow_headers(Any);
}
let origins: Vec<HeaderValue> = config
.cors_allowed_origins
.iter()
.filter_map(|o| o.parse::<HeaderValue>().ok())
.collect();
CorsLayer::new()
.allow_origin(origins)
.allow_methods(methods)
.allow_headers(Any)
}