#![allow(dead_code)]
pub mod file_resolve;
pub mod handlers;
pub mod query_graph_api;
use axum::{
body::Body,
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post, put},
Json, Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::db::schema::{init_db, CozoDb};
use crate::embed;
use crate::graph::GraphEngine;
#[derive(Clone)]
pub struct AppState {
pub db_path: Arc<RwLock<std::path::PathBuf>>,
pub current_project_path: Arc<RwLock<std::path::PathBuf>>,
db: Arc<RwLock<Option<CozoDb>>>,
graph_engine: Arc<RwLock<Option<GraphEngine>>>,
pub indexing_state: Arc<RwLock<IndexingState>>,
}
#[derive(Clone, Default)]
pub struct IndexingState {
pub is_indexing: bool,
pub progress_percent: usize,
pub current_file: String,
pub total_files: usize,
pub indexed_files: usize,
pub error: Option<String>,
}
impl Default for AppState {
fn default() -> Self {
Self {
db_path: Arc::new(RwLock::new(std::path::PathBuf::new())),
current_project_path: Arc::new(RwLock::new(std::path::PathBuf::new())),
db: Arc::new(RwLock::new(None)),
graph_engine: Arc::new(RwLock::new(None)),
indexing_state: Arc::new(RwLock::new(IndexingState::default())),
}
}
}
impl AppState {
pub async fn new(
db_path: std::path::PathBuf,
current_project_path: std::path::PathBuf,
) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
db_path: Arc::new(RwLock::new(db_path)),
current_project_path: Arc::new(RwLock::new(current_project_path)),
db: Arc::new(RwLock::new(None)),
graph_engine: Arc::new(RwLock::new(None)),
indexing_state: Arc::new(RwLock::new(IndexingState::default())),
})
}
pub async fn reset_indexing_state(&self) {
let mut state = self.indexing_state.write().await;
state.is_indexing = false;
state.progress_percent = 0;
state.current_file = String::new();
state.total_files = 0;
state.indexed_files = 0;
state.error = None;
}
pub async fn set_indexing_started(&self, total_files: usize) {
let mut state = self.indexing_state.write().await;
state.is_indexing = true;
state.progress_percent = 0;
state.total_files = total_files;
state.indexed_files = 0;
state.error = None;
}
pub async fn update_indexing_progress(&self, indexed_files: usize, current_file: &str) {
let mut state = self.indexing_state.write().await;
state.indexed_files = indexed_files;
state.current_file = current_file.to_string();
if state.total_files > 0 {
state.progress_percent = (indexed_files * 100)
.checked_div(state.total_files)
.unwrap_or(0);
}
}
pub async fn set_indexing_error(&self, error: String) {
let mut state = self.indexing_state.write().await;
state.is_indexing = false;
state.error = Some(error);
}
pub async fn set_indexing_complete(&self) {
let mut state = self.indexing_state.write().await;
state.is_indexing = false;
state.progress_percent = 100;
state.current_file = String::new();
if let Some(graph) = self.graph_engine.read().await.as_ref() {
graph.invalidate_cache();
}
drop(state);
let project = self.current_project_path.read().await.clone();
if let Ok(graph) = self.get_graph_engine().await {
if let Err(e) = crate::ontology::sync_for_project(&project, &graph) {
tracing::debug!("Ontology post-index sync skipped: {}", e);
}
}
}
pub async fn invalidate_graph_cache(&self) {
if let Some(graph) = self.graph_engine.read().await.as_ref() {
graph.invalidate_cache();
}
}
pub async fn switch_project(
&self,
project_path: std::path::PathBuf,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let db_path = project_path.join(".leankg");
std::fs::create_dir_all(&db_path)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let prev_project = self.current_project_path.read().await.clone();
let prev_db_path = self.db_path.read().await.clone();
{
let mut ge_lock = self.graph_engine.write().await;
*ge_lock = None;
}
{
let mut db_lock = self.db.write().await;
*db_lock = None;
}
tokio::task::yield_now().await;
let db_path_for_init = db_path.clone();
let init_result = tokio::task::spawn_blocking(move || {
init_db(&db_path_for_init).map_err(|e| e.to_string())
})
.await
.map_err(|e| {
Box::new(std::io::Error::other(format!("init_db task failed: {}", e)))
as Box<dyn std::error::Error + Send + Sync>
})?;
match init_result {
Ok(db) => {
let graph = GraphEngine::new(db.clone());
{
let mut db_lock = self.db.write().await;
*db_lock = Some(db);
}
{
let mut ge_lock = self.graph_engine.write().await;
*ge_lock = Some(graph);
}
{
let mut path_guard = self.db_path.write().await;
*path_guard = db_path;
}
{
let mut proj_guard = self.current_project_path.write().await;
*proj_guard = project_path;
}
self.reset_indexing_state().await;
if let Ok(g) = self.get_graph_engine().await {
if g.count_elements().unwrap_or(0) > 0 {
self.set_indexing_complete().await;
}
}
Ok(())
}
Err(msg) => {
if !prev_db_path.as_os_str().is_empty() {
let prev_db_path_for_init = prev_db_path.clone();
if let Ok(Ok(db)) = tokio::task::spawn_blocking(move || {
init_db(&prev_db_path_for_init).map_err(|e| e.to_string())
})
.await
{
let graph = GraphEngine::new(db.clone());
let mut db_lock = self.db.write().await;
*db_lock = Some(db);
let mut ge_lock = self.graph_engine.write().await;
*ge_lock = Some(graph);
let mut path_guard = self.db_path.write().await;
*path_guard = prev_db_path;
let mut proj_guard = self.current_project_path.write().await;
*proj_guard = prev_project;
}
}
Err(Box::new(std::io::Error::other(msg))
as Box<dyn std::error::Error + Send + Sync>)
}
}
}
pub async fn init_db(&self) -> Result<(), Box<dyn std::error::Error>> {
let db_path = self.db_path.read().await.clone();
let db = init_db(&db_path)?;
let graph = GraphEngine::new(db.clone());
let mut db_lock = self.db.write().await;
let mut ge_lock = self.graph_engine.write().await;
*db_lock = Some(db);
*ge_lock = Some(graph);
Ok(())
}
pub fn get_db(&self) -> Result<CozoDb, Box<dyn std::error::Error + Send + Sync>> {
crate::runtime::run_blocking(async {
let lock = self.db.read().await;
lock.clone()
.ok_or_else(|| "Database not initialized".into())
})
}
pub async fn get_graph_engine(
&self,
) -> Result<GraphEngine, Box<dyn std::error::Error + Send + Sync>> {
let lock = self.graph_engine.read().await;
lock.clone()
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
"Graph engine not initialized. Call init_db() first.".into()
})
}
}
#[derive(serde::Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
impl<T: serde::Serialize> ApiResponse<T> {
pub fn error(msg: &str) -> Self {
Self {
success: false,
data: None,
error: Some(msg.to_string()),
}
}
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
}
}
}
impl<T: serde::Serialize> IntoResponse for ApiResponse<T> {
fn into_response(self) -> Response {
let status = if self.success {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
(status, Json(self)).into_response()
}
}
fn content_type_for_path(path: &str) -> &'static str {
if path.ends_with(".html") {
"text/html"
} else if path.ends_with(".js") {
"application/javascript"
} else if path.ends_with(".css") {
"text/css"
} else if path.ends_with(".json") {
"application/json"
} else if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
"image/jpeg"
} else if path.ends_with(".svg") {
"image/svg+xml"
} else if path.ends_with(".ico") {
"image/x-icon"
} else if path.ends_with(".wasm") {
"application/wasm"
} else if path.ends_with(".map") {
"application/json"
} else if path.ends_with(".woff2") {
"font/woff2"
} else {
"application/octet-stream"
}
}
const GRAPH_UI_PREFIX: &str = "3d/";
async fn serve_embedded_file(path: &str) -> Response {
let path = path.trim_start_matches('/');
let (embed_key, ui_header) = if path == "3d" || path.starts_with(GRAPH_UI_PREFIX) {
let rel = path.trim_start_matches("3d").trim_start_matches('/');
let key = if rel.is_empty() {
format!("{GRAPH_UI_PREFIX}index.html")
} else {
format!("{GRAPH_UI_PREFIX}{rel}")
};
(key, "graph-ui")
} else if path.is_empty() || path == "/" {
("index.html".to_string(), "ui-v2")
} else {
(path.to_string(), "ui-v2")
};
if let Some(data) = embed::get(&embed_key) {
let ct = content_type_for_path(&embed_key);
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, ct);
if embed_key.ends_with(".html") {
builder = builder
.header(header::CACHE_CONTROL, "no-store, must-revalidate")
.header("X-LeanKG-UI", ui_header);
}
builder
.body(Body::from(data.to_vec()))
.unwrap_or_else(|_| internal_error())
} else {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from(embed::get_404().to_vec()))
.unwrap_or_else(|_| internal_error())
}
}
fn internal_error() -> Response {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(b"Internal Server Error".to_vec()))
.unwrap()
}
async fn fallback_handler(path: axum::extract::Path<String>) -> Response {
serve_embedded_file(&path.0).await
}
async fn root_handler() -> Response {
serve_embedded_file("index.html").await
}
async fn root_handler_3d() -> Response {
serve_embedded_file("3d").await
}
async fn fallback_handler_3d(path: axum::extract::Path<String>) -> Response {
let rest = path.0.trim_start_matches('/');
let key = if rest.is_empty() {
"3d".to_string()
} else {
format!("3d/{rest}")
};
serve_embedded_file(&key).await
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/", get(root_handler))
.route("/api/elements", get(handlers::api_elements))
.route("/api/relationships", get(handlers::api_relationships))
.route("/api/annotations", get(handlers::api_annotations))
.route("/api/annotations", post(handlers::api_create_annotation))
.route(
"/api/annotations/:element",
get(handlers::api_get_annotation),
)
.route(
"/api/annotations/:element",
put(handlers::api_update_annotation),
)
.route(
"/api/annotations/:element",
delete(handlers::api_delete_annotation),
)
.route("/api/search", get(handlers::api_search))
.route("/api/graph/data", get(handlers::api_graph_data))
.route("/api/graph/services", get(handlers::api_service_graph))
.route(
"/api/graph/service-topology",
get(handlers::api_service_topology),
)
.route(
"/api/graph/expand-service",
get(handlers::api_graph_expand_service),
)
.route(
"/api/graph/expand-cluster",
get(handlers::api_graph_expand_cluster),
)
.route("/api/graph/subgraph", get(handlers::api_graph_subgraph))
.route("/api/graph/clusters", get(handlers::api_graph_clusters))
.route("/api/graph/report", get(handlers::api_graph_report))
.route("/api/graph/children", get(handlers::api_graph_children))
.route(
"/api/graph/expand-node",
get(handlers::api_graph_expand_node),
)
.route("/api/graph/layout", get(handlers::api_graph_layout))
.route("/api/graph/layout3d", get(handlers::api_graph_layout3d))
.route("/api/export/graph", get(handlers::api_export_graph))
.route("/api/query", post(handlers::api_query))
.route("/api/query-graph", post(handlers::api_query_graph))
.route("/api/project/switch", post(handlers::api_switch_path))
.route("/api/index/status", get(handlers::api_index_status))
.route("/api/projects", get(handlers::api_projects))
.route("/api/ui-build", get(handlers::api_ui_build))
.route(
"/api/cache/invalidate",
post(handlers::api_invalidate_cache),
)
.route("/api/github/clone", post(handlers::api_github_clone))
.route("/api/file", get(handlers::api_get_file))
.route("/api/incidents", get(handlers::api_incidents))
.route("/api/conflicts", get(handlers::api_conflicts))
.route("/api/teams", get(handlers::api_teams))
.route("/api/teams", post(handlers::api_create_team))
.route("/api/teams/:id", get(handlers::api_get_team))
.route("/api/teams/:id", put(handlers::api_update_team))
.route("/api/teams/:id", delete(handlers::api_delete_team))
.route(
"/api/teams/:id/members",
post(handlers::api_add_team_member),
)
.route(
"/api/teams/:id/members/:user",
delete(handlers::api_remove_team_member),
)
.route("/api/teams/:id/invites", get(handlers::api_team_invites))
.route(
"/api/teams/:id/invites",
post(handlers::api_create_team_invite),
)
.route(
"/api/teams/invites/:token/accept",
post(handlers::api_accept_team_invite),
)
.route(
"/api/teams/invites/:token",
delete(handlers::api_revoke_team_invite),
)
.route(
"/api/teams/:id/permissions",
get(handlers::api_team_permissions),
)
.route("/services", get(handlers::services_page))
.route("/3d", get(root_handler_3d))
.route("/3d/", get(root_handler_3d))
.route("/3d/*path", get(fallback_handler_3d))
.route("/*path", get(fallback_handler))
.with_state(state)
}
pub async fn start_server(
port: u16,
db_path: std::path::PathBuf,
_ui_dist_path: Option<std::path::PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let project_root = db_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| db_path.clone());
let state = AppState::new(db_path.clone(), project_root.clone()).await?;
state.init_db().await?;
if let Ok(graph) = state.get_graph_engine().await {
let _ = crate::ontology::spawn_ontology_yaml_watcher(project_root.clone(), graph, |_| {});
}
let app = build_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("LeanKG Web UI listening on http://localhost:{}", port);
println!("Press Ctrl+C to stop");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod serve_tests {
use super::*;
#[test]
fn content_type_covers_3d_asset_extensions() {
assert_eq!(content_type_for_path("3d/index.html"), "text/html");
assert_eq!(
content_type_for_path("3d/assets/app.js"),
"application/javascript"
);
assert_eq!(content_type_for_path("3d/assets/app.css"), "text/css");
assert_eq!(
content_type_for_path("3d/assets/app.wasm"),
"application/wasm"
);
assert_eq!(
content_type_for_path("3d/assets/app.js.map"),
"application/json"
);
assert_eq!(content_type_for_path("3d/assets/font.woff2"), "font/woff2");
}
#[tokio::test]
async fn serve_3d_route_returns_graph_ui_shell() {
let resp = serve_embedded_file("3d").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("X-LeanKG-UI")
.map(|v| v.to_str().unwrap()),
Some("graph-ui")
);
let resp = serve_embedded_file("3d/").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn serve_3d_asset_under_prefix() {
let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/embed/3d");
let mut found = 0;
for (dir, rel_prefix) in [(base.clone(), "3d/"), (base.join("assets"), "3d/assets/")] {
if !dir.is_dir() {
continue;
}
for entry in std::fs::read_dir(dir).expect("src/embed/3d must exist") {
let path = entry.unwrap().path();
if !path.is_file() {
continue;
}
let rel = format!(
"{rel_prefix}{}",
path.file_name().unwrap().to_string_lossy()
);
let resp = serve_embedded_file(&rel).await;
assert_eq!(resp.status(), StatusCode::OK, "{rel} should serve");
found += 1;
}
}
assert!(
found >= 2,
"expected index.html + at least one asset, found {found}"
);
}
#[tokio::test]
async fn serve_root_returns_ui_v2() {
let resp = serve_embedded_file("index.html").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("X-LeanKG-UI")
.map(|v| v.to_str().unwrap()),
Some("ui-v2")
);
}
}