ontoref-code/crates/ontoref-daemon/src/ui/handlers.rs
Jesús Pérez 2d87d60bb5
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: add src code
2026-03-13 00:18:14 +00:00

2467 lines
78 KiB
Rust

use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::{Form, Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use tera::Context;
use tokio::sync::RwLock;
use tracing::warn;
use super::auth::AuthUser;
use crate::api::AppState;
// ── Error ────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum UiError {
#[error("template render error: {0}")]
Render(#[from] tera::Error),
#[error("ncl export failed for {path}: {reason}")]
NclExport { path: String, reason: String },
#[error("ui is not configured — start the daemon with --templates-dir")]
NotConfigured,
#[error("bad request: {0}")]
BadRequest(String),
#[error("forbidden: {0}")]
Forbidden(String),
}
impl IntoResponse for UiError {
fn into_response(self) -> Response {
let status = match &self {
UiError::BadRequest(_) => StatusCode::BAD_REQUEST,
UiError::Forbidden(_) => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let html = format!(
r#"<!DOCTYPE html><html data-theme="dark"><body class="p-8">
<h1 class="text-2xl font-bold text-error mb-4">UI Error</h1>
<pre class="bg-base-200 p-4 rounded">{self}</pre>
</body></html>"#
);
(status, Html(html)).into_response()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Acquire read lock on Tera and render a template.
/// The lock is held only for the duration of `render()` — not across the
/// async NCL export — so reloads are never blocked by slow nickel subprocess
/// calls.
pub(crate) async fn render(
tera: &Arc<RwLock<tera::Tera>>,
template: &str,
ctx: &Context,
) -> Result<Html<String>, UiError> {
let guard = tera.read().await;
let html = guard.render(template, ctx)?;
Ok(Html(html))
}
pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc<RwLock<tera::Tera>>, UiError> {
state.tera.as_ref().ok_or(UiError::NotConfigured)
}
async fn ncl_export(state: &AppState, relative_path: &str) -> Result<serde_json::Value, UiError> {
let path = state.project_root.join(relative_path);
let (value, _hit) = state
.cache
.export(&path, state.nickel_import_path.as_deref())
.await
.map_err(|e| UiError::NclExport {
path: relative_path.to_string(),
reason: e.to_string(),
})?;
Ok(value)
}
pub(crate) fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
// ── Auth helpers ─────────────────────────────────────────────────────────────
/// Map the HTTP auth role to a lowercase string for Tera context.
/// Templates gate admin-only sections on `current_role == "admin"`.
fn auth_role_str(auth: &AuthUser) -> &'static str {
use crate::registry::Role;
match auth.0.role {
Role::Admin => "admin",
Role::Viewer => "viewer",
}
}
// ── Brand context ────────────────────────────────────────────────────────────
/// Resolve a logo config value to a URL.
/// Bare paths (no `/` or scheme prefix) are served from `{base_url}/assets/`.
fn resolve_logo_url(raw: &str, base_url: &str) -> String {
if raw.starts_with('/') || raw.starts_with("http://") || raw.starts_with("https://") {
raw.to_string()
} else {
format!("{base_url}/assets/{raw}")
}
}
/// Load logo URLs from `.ontoref/config.ncl` ui section.
/// Returns `(logo_light_url, logo_dark_url)` — either may be `None`.
async fn load_logos(
root: &std::path::Path,
cache: &Arc<crate::cache::NclCache>,
import_path: Option<&str>,
base_url: &str,
) -> (Option<String>, Option<String>) {
let config_path = root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return (None, None);
}
let Ok((json, _)) = cache.export(&config_path, import_path).await else {
return (None, None);
};
let ui = json.get("ui");
let logo = ui
.and_then(|u| u.get("logo"))
.and_then(|v| v.as_str())
.map(|raw| resolve_logo_url(raw, base_url));
let logo_dark = ui
.and_then(|u| u.get("logo_dark"))
.and_then(|v| v.as_str())
.map(|raw| resolve_logo_url(raw, base_url));
(logo, logo_dark)
}
/// Insert logo and MCP metadata into a Tera context.
/// Logos are loaded from `.ontoref/config.ncl`; MCP availability is
/// compile-time.
async fn insert_brand_ctx(
ctx: &mut Context,
root: &std::path::Path,
cache: &Arc<crate::cache::NclCache>,
import_path: Option<&str>,
base_url: &str,
) {
let (logo, logo_dark) = load_logos(root, cache, import_path, base_url).await;
ctx.insert("logo", &logo);
ctx.insert("logo_dark", &logo_dark);
insert_mcp_ctx(ctx);
}
/// Insert MCP metadata and daemon version into a Tera context.
fn insert_mcp_ctx(ctx: &mut Context) {
ctx.insert("daemon_version", env!("CARGO_PKG_VERSION"));
#[cfg(feature = "mcp")]
{
ctx.insert("mcp_active", &true);
ctx.insert(
"mcp_tools",
&[
"ontoref_help",
"ontoref_list_projects",
"ontoref_set_project",
"ontoref_search",
"ontoref_get",
"ontoref_status",
"ontoref_describe",
"ontoref_list_adrs",
"ontoref_get_adr",
"ontoref_list_modes",
"ontoref_get_mode",
"ontoref_get_node",
"ontoref_backlog_list",
"ontoref_backlog",
"ontoref_constraints",
],
);
}
#[cfg(not(feature = "mcp"))]
{
ctx.insert("mcp_active", &false);
ctx.insert("mcp_tools", &Vec::<&str>::new());
}
}
// ── Dashboard ────────────────────────────────────────────────────────────────
pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let hits = state.cache.hit_count();
let misses = state.cache.miss_count();
let total = hits + misses;
let hit_rate = if total > 0 {
format!("{:.1}%", hits as f64 / total as f64 * 100.0)
} else {
"".to_string()
};
let mut ctx = Context::new();
ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs());
ctx.insert("cache_entries", &state.cache.len());
ctx.insert("cache_hits", &hits);
ctx.insert("cache_misses", &misses);
ctx.insert("cache_hit_rate", &hit_rate);
ctx.insert("active_actors", &state.actors.count());
ctx.insert(
"notification_count",
&state.notifications.all_recent().len(),
);
ctx.insert("project_root", &state.project_root.display().to_string());
ctx.insert("now", &epoch_secs());
ctx.insert("base_url", "/ui");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/dashboard.html", &ctx).await
}
// ── Graph ────────────────────────────────────────────────────────────────────
pub async fn graph(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let core_json = match ncl_export(&state, ".ontology/core.ncl").await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "failed to load .ontology/core.ncl for graph");
serde_json::json!({ "nodes": [], "edges": [] })
}
};
let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string());
let mut ctx = Context::new();
ctx.insert("graph_json", &graph_json);
ctx.insert("project_root", &state.project_root.display().to_string());
ctx.insert("base_url", "/ui");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/graph.html", &ctx).await
}
// ── Sessions ─────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct SessionRow {
token: String,
actor_type: String,
hostname: String,
pid: u32,
project: String,
role: String,
registered_ago: u64,
last_seen_ago: u64,
pending_notifications: u64,
has_preferences: bool,
}
pub async fn sessions(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let now = epoch_secs();
let rows: Vec<SessionRow> = state
.actors
.list()
.into_iter()
.map(|(token, s)| SessionRow {
token,
actor_type: s.actor_type,
hostname: s.hostname,
pid: s.pid,
project: s.project,
role: s.role,
registered_ago: now.saturating_sub(s.registered_at),
last_seen_ago: now.saturating_sub(s.last_seen),
pending_notifications: s.pending_notifications,
has_preferences: !s
.preferences
.as_object()
.map(|m| m.is_empty())
.unwrap_or(true),
})
.collect();
let mut ctx = Context::new();
ctx.insert("sessions", &rows);
ctx.insert("total", &rows.len());
ctx.insert("base_url", "/ui");
ctx.insert("current_role", "admin");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/sessions.html", &ctx).await
}
// ── Notifications
// ─────────────────────────────────────────────────────────────
pub async fn notifications_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let now = epoch_secs();
#[derive(Serialize)]
struct NotifRow {
id: u64,
project: String,
event: String,
files: Vec<String>,
age_secs: u64,
source_actor: Option<String>,
}
let rows: Vec<NotifRow> = state
.notifications
.all_recent()
.into_iter()
.map(|n| NotifRow {
id: n.id,
project: n.project,
event: format!("{:?}", n.event),
files: n.files,
age_secs: now.saturating_sub(n.timestamp),
source_actor: n.source_actor,
})
.collect();
let mut ctx = Context::new();
ctx.insert("notifications", &rows);
ctx.insert("total", &rows.len());
ctx.insert("base_url", "/ui");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/notifications.html", &ctx).await
}
// ── Search ────────────────────────────────────────────────────────────────────
pub async fn search_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let mut ctx = Context::new();
ctx.insert("base_url", "/ui");
ctx.insert("slug", &Option::<String>::None);
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/search.html", &ctx).await
}
pub async fn search_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/search.html", &ctx).await
}
// ── Modes ─────────────────────────────────────────────────────────────────────
pub async fn modes(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let modes_dir = state.project_root.join("reflection").join("modes");
let mut mode_entries: Vec<serde_json::Value> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&modes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let rel = path
.strip_prefix(&state.project_root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
match state
.cache
.export(&path, state.nickel_import_path.as_deref())
.await
{
Ok((mut json, _)) => {
json.as_object_mut()
.map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel)));
mode_entries.push(json);
}
Err(e) => {
warn!(path = %rel, error = %e, "failed to export reflection mode");
mode_entries.push(serde_json::json!({
"_file": rel,
"id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"),
"_error": e.to_string(),
}));
}
}
}
}
mode_entries.sort_by_key(|v| {
v.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string()
});
let showcase = detect_showcase(&state.project_root, "/ui");
let generated = detect_generated(&state.project_root, "/ui");
let mut ctx = Context::new();
ctx.insert("modes", &mode_entries);
ctx.insert("total", &mode_entries.len());
ctx.insert("showcase", &showcase);
ctx.insert("generated", &generated);
ctx.insert("base_url", "/ui");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/modes.html", &ctx).await
}
// ── Project Picker
// ────────────────────────────────────────────────────────────
pub async fn project_picker(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let registry = match state.registry.as_ref() {
Some(r) => r,
None => {
let mut ctx = Context::new();
ctx.insert("projects", &serde_json::json!([]));
ctx.insert("base_url", "/ui");
ctx.insert("hide_project_nav", &true);
insert_mcp_ctx(&mut ctx);
return render(tera, "pages/project_picker.html", &ctx).await;
}
};
let now = epoch_secs();
let mut projects: Vec<serde_json::Value> = Vec::new();
for proj in registry.all() {
// Sessions
let actor_list = proj.actors.list();
let sessions: Vec<serde_json::Value> = actor_list
.iter()
.map(|(token, s)| {
serde_json::json!({
"token": &token[..8.min(token.len())],
"actor_type": s.actor_type,
"hostname": s.hostname,
"pid": s.pid,
"last_seen_ago": now.saturating_sub(s.last_seen),
})
})
.collect();
// Notifications
let notif_list = proj.notifications.all_recent();
let notifications: Vec<serde_json::Value> = notif_list
.iter()
.take(8)
.map(|n| {
serde_json::json!({
"id": n.id,
"event": format!("{:?}", n.event),
"files": n.files.iter().take(3).collect::<Vec<_>>(),
"age_secs": now.saturating_sub(n.timestamp),
})
})
.collect();
// Backlog — optional NCL export
let backlog_path = proj.root.join("reflection").join("backlog.ncl");
let (backlog_items, backlog_open) = if backlog_path.exists() {
match proj
.cache
.export(&backlog_path, proj.import_path.as_deref())
.await
{
Ok((json, _)) => {
let items: Vec<serde_json::Value> = json
.get("items")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|it| serde_json::json!({
"id": it.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"title": it.get("title").and_then(|v| v.as_str()).unwrap_or(""),
"status": it.get("status").and_then(|v| v.as_str()).unwrap_or(""),
"priority": it.get("priority").and_then(|v| v.as_str()).unwrap_or(""),
"kind": it.get("kind").and_then(|v| v.as_str()).unwrap_or(""),
}))
.collect()
})
.unwrap_or_default();
let open = items
.iter()
.filter(|it| it.get("status").and_then(|v| v.as_str()) == Some("Open"))
.count();
(items, open)
}
Err(_) => (vec![], 0),
}
} else {
(vec![], 0)
};
// Manifest — layers, operational_modes, default_mode, repo_kind
let manifest_path = proj.root.join(".ontology").join("manifest.ncl");
let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() {
match proj
.cache
.export(&manifest_path, proj.import_path.as_deref())
.await
{
Ok((json, _)) => {
let layers: Vec<serde_json::Value> = json
.get("layers")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|l| serde_json::json!({
"id": l.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"description": l.get("description").and_then(|v| v.as_str()).unwrap_or(""),
})).collect())
.unwrap_or_default();
let op_modes: Vec<serde_json::Value> = json
.get("operational_modes")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|m| serde_json::json!({
"id": m.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
})).collect())
.unwrap_or_default();
let default_mode = json
.get("default_mode")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let repo_kind = json
.get("repo_kind")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(layers, op_modes, default_mode, repo_kind)
}
Err(_) => (vec![], vec![], String::new(), String::new()),
}
} else {
(vec![], vec![], String::new(), String::new())
};
// Description — first meaningful text line from README.md
let description = readme_description(&proj.root);
let proj_base = format!("/ui/{}", proj.slug);
let showcase = detect_showcase(&proj.root, &proj_base);
let generated = detect_generated(&proj.root, &proj_base);
let repos = git_remotes(&proj.root);
projects.push(serde_json::json!({
"slug": proj.slug,
"root": proj.root.display().to_string(),
"auth": proj.auth_enabled(),
"description": description,
"default_mode": default_mode,
"repo_kind": repo_kind,
"repos": repos,
"session_count": sessions.len(),
"sessions": sessions,
"notif_count": notifications.len(),
"notifications": notifications,
"backlog_open": backlog_open,
"backlog_items": backlog_items,
"layers": layers,
"op_modes": op_modes,
"showcase": showcase,
"generated": generated,
}));
}
let mut ctx = Context::new();
ctx.insert("projects", &projects);
ctx.insert("base_url", "/ui");
ctx.insert("hide_project_nav", &true);
insert_mcp_ctx(&mut ctx);
render(tera, "pages/project_picker.html", &ctx).await
}
/// Read git remote names+URLs from `{root}/.git/config` by parsing the ini file
/// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." },
/// ...]`.
pub(crate) fn git_remotes(root: &std::path::Path) -> Vec<serde_json::Value> {
let Ok(content) = std::fs::read_to_string(root.join(".git").join("config")) else {
return vec![];
};
let mut remotes = Vec::new();
let mut current_name: Option<String> = None;
for line in content.lines() {
let t = line.trim();
if let Some(inner) = t
.strip_prefix("[remote \"")
.and_then(|s| s.strip_suffix("\"]"))
{
current_name = Some(inner.to_string());
} else if t.starts_with('[') {
current_name = None;
} else if let Some(name) = &current_name {
if let Some(url) = t.strip_prefix("url = ") {
remotes.push(serde_json::json!({ "name": name, "url": url }));
current_name = None;
}
}
}
remotes
}
/// Extract the first meaningful prose line from README.md.
/// Skips HTML tags, Markdown headings, image/badge lines, blockquotes, and
/// blank lines.
pub(crate) fn readme_description(root: &std::path::Path) -> String {
let readme = root.join("README.md");
let Ok(content) = std::fs::read_to_string(&readme) else {
return String::new();
};
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
// Skip: HTML tags, headings, images, badges, horizontal rules, blockquotes
if line.starts_with('<')
|| line.starts_with('#')
|| line.starts_with('!')
|| line.starts_with("---")
|| line.starts_with("===")
|| line.contains("badge")
|| line.contains("img.shields")
|| line.contains("<img")
|| line.starts_with('>')
// blockquote
{
continue;
}
// Strip leading Markdown bold/italic markers and quote chars
let text = line.trim_start_matches(['*', '_', '`', '>', ' ']);
if text.len() > 10 {
// Truncate at 160 chars for the card
return if text.len() > 160 {
format!("{}", &text[..text.floor_char_boundary(160)])
} else {
text.to_string()
};
}
}
String::new()
}
fn count_ncl_files(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
.count()
})
.unwrap_or(0)
}
// ── Multi-project handlers
// ────────────────────────────────────────────────────
pub async fn dashboard_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let hits = ctx_ref.cache.hit_count();
let misses = ctx_ref.cache.miss_count();
let total = hits + misses;
let hit_rate = if total > 0 {
format!("{:.1}%", hits as f64 / total as f64 * 100.0)
} else {
"".to_string()
};
let base_url = format!("/ui/{slug}");
let backlog_path = ctx_ref.root.join("reflection").join("backlog.ncl");
let backlog_items = if backlog_path.exists() {
load_backlog_items(
&ctx_ref.cache,
&backlog_path,
ctx_ref.import_path.as_deref(),
)
.await
} else {
vec![]
};
let backlog = backlog_stats(&backlog_items);
let adr_count = count_ncl_files(&ctx_ref.root.join("adrs"));
let mode_count = count_ncl_files(&ctx_ref.root.join("reflection").join("modes"));
let mut ctx = Context::new();
ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs());
ctx.insert("cache_entries", &ctx_ref.cache.len());
ctx.insert("cache_hits", &hits);
ctx.insert("cache_misses", &misses);
ctx.insert("cache_hit_rate", &hit_rate);
ctx.insert("active_actors", &ctx_ref.actors.count());
ctx.insert(
"notification_count",
&ctx_ref.notifications.all_recent().len(),
);
ctx.insert("project_root", &ctx_ref.root.display().to_string());
ctx.insert("now", &epoch_secs());
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("backlog", &backlog);
ctx.insert("adr_count", &adr_count);
ctx.insert("mode_count", &mode_count);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/dashboard.html", &ctx).await
}
pub async fn graph_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let core_path = ctx_ref.root.join(".ontology/core.ncl");
let core_json = match ctx_ref
.cache
.export(&core_path, ctx_ref.import_path.as_deref())
.await
{
Ok((v, _)) => v,
Err(e) => {
warn!(error = %e, slug = %slug, "failed to load .ontology/core.ncl for graph");
serde_json::json!({ "nodes": [], "edges": [] })
}
};
let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string());
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("graph_json", &graph_json);
ctx.insert("project_root", &ctx_ref.root.display().to_string());
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/graph.html", &ctx).await
}
pub async fn sessions_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
_auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let now = epoch_secs();
let rows: Vec<SessionRow> = ctx_ref
.actors
.list()
.into_iter()
.map(|(token, s)| SessionRow {
token,
actor_type: s.actor_type,
hostname: s.hostname,
pid: s.pid,
project: s.project,
role: s.role,
registered_ago: now.saturating_sub(s.registered_at),
last_seen_ago: now.saturating_sub(s.last_seen),
pending_notifications: s.pending_notifications,
has_preferences: !s
.preferences
.as_object()
.map(|m| m.is_empty())
.unwrap_or(true),
})
.collect();
let base_url = format!("/ui/{slug}");
let http_role = auth_role_str(&_auth);
let mut ctx = Context::new();
ctx.insert("sessions", &rows);
ctx.insert("total", &rows.len());
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &http_role);
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/sessions.html", &ctx).await
}
pub async fn notifications_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let now = epoch_secs();
#[derive(Serialize)]
struct NotifRow {
id: u64,
project: String,
event: String,
is_custom: bool,
files: Vec<String>,
age_secs: u64,
source_actor: Option<String>,
custom_kind: Option<String>,
custom_title: Option<String>,
custom_payload: Option<serde_json::Value>,
source_project: Option<String>,
}
let rows: Vec<NotifRow> = ctx_ref
.notifications
.all_recent()
.into_iter()
.map(|n| {
let is_custom = matches!(n.event, crate::notifications::NotificationEvent::Custom);
NotifRow {
id: n.id,
project: n.project,
event: format!("{:?}", n.event),
is_custom,
files: n.files,
age_secs: now.saturating_sub(n.timestamp),
source_actor: n.source_actor,
custom_kind: n.custom_kind,
custom_title: n.custom_title,
custom_payload: n.custom_payload,
source_project: n.source_project,
}
})
.collect();
// All other registered projects for the emit-to selector
let other_projects: Vec<String> = state
.registry
.as_ref()
.map(|r| r.all().into_iter().map(|p| p.slug.clone()).collect())
.unwrap_or_default();
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("notifications", &rows);
ctx.insert("total", &rows.len());
let http_role = auth_role_str(&auth);
// Viewers can see notifications but cannot emit them — template gates on
// current_role.
let empty: Vec<String> = vec![];
let visible_projects: &Vec<String> = if http_role == "admin" {
&other_projects
} else {
&empty
};
ctx.insert("other_projects", visible_projects);
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &http_role);
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/notifications.html", &ctx).await
}
// ── Notification emit
// ─────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct EmitNotifForm {
pub target_slug: String,
pub kind: String,
pub title: String,
#[serde(default)]
pub payload: String,
#[serde(default)]
pub source_actor: String,
}
// ── Notification DAG action execution ────────────────────────────────────────
#[derive(Deserialize)]
pub struct NotifActionForm {
pub action_id: String,
}
pub async fn notification_action_mp(
State(state): State<AppState>,
Path((slug, notif_id)): Path<(String, u64)>,
_auth: AuthUser,
Form(form): Form<NotifActionForm>,
) -> Result<Response, UiError> {
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let notif = ctx_ref
.notifications
.get_one(notif_id)
.ok_or_else(|| UiError::BadRequest(format!("notification {notif_id} not found")))?;
let actions: Vec<serde_json::Value> = notif
.custom_payload
.as_ref()
.and_then(|p| p.get("actions"))
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
let action = actions
.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(form.action_id.as_str()))
.ok_or_else(|| UiError::BadRequest(format!("action '{}' not found", form.action_id)))?;
let mode = action
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("manual");
let dag_id = action.get("dag").and_then(|v| v.as_str()).unwrap_or("");
if !dag_id.is_empty() && matches!(mode, "auto" | "semi") {
let ontoref_bin = ctx_ref.root.join("ontoref");
if ontoref_bin.exists() {
tokio::process::Command::new(&ontoref_bin)
.arg(dag_id)
.current_dir(&ctx_ref.root)
.spawn()
.map_err(|e| UiError::BadRequest(format!("dag spawn failed: {e}")))?;
} else {
return Err(UiError::BadRequest(format!(
"ontoref binary not found at {}",
ontoref_bin.display()
)));
}
}
Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response())
}
pub async fn emit_notification_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
_auth: AuthUser,
Form(form): Form<EmitNotifForm>,
) -> Result<Response, UiError> {
let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?;
let target = registry.get(&form.target_slug).ok_or_else(|| {
UiError::BadRequest(format!("unknown target project: {}", form.target_slug))
})?;
let payload = if form.payload.trim().is_empty() {
None
} else {
serde_json::from_str::<serde_json::Value>(&form.payload).ok()
};
let actor = if form.source_actor.trim().is_empty() {
None
} else {
Some(form.source_actor)
};
target.notifications.push_custom(
&form.target_slug,
&form.kind,
&form.title,
payload,
actor,
Some(slug.clone()),
);
Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response())
}
// ── Asset serving ────────────────────────────────────────────────────────────
/// Serve a file from `{project_root}/assets/` by slug + relative path.
/// Validates that the resolved path stays under the assets directory.
pub async fn serve_asset_mp(
State(state): State<AppState>,
Path((slug, asset_path)): Path<(String, String)>,
) -> Result<Response, UiError> {
let proj = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
serve_asset_from(&proj.root, &asset_path).await
}
pub async fn serve_asset_single(
State(state): State<AppState>,
Path(asset_path): Path<String>,
) -> Result<Response, UiError> {
serve_asset_from(&state.project_root, &asset_path).await
}
async fn serve_asset_from(root: &std::path::Path, rel: &str) -> Result<Response, UiError> {
let assets_root = root.join("assets");
let Ok(canonical_root) = assets_root.canonicalize() else {
return Err(UiError::NotConfigured);
};
// Resolve the requested path (tolerate leading `/`)
let requested = canonical_root.join(rel.trim_start_matches('/'));
let resolved = match requested.canonicalize() {
Ok(p) => p,
// Try appending index.html for bare directory URLs
Err(_) => {
let with_index = requested.join("index.html");
with_index
.canonicalize()
.map_err(|_| UiError::NotConfigured)?
}
};
// Security: must stay within assets root
if !resolved.starts_with(&canonical_root) {
return Ok(StatusCode::FORBIDDEN.into_response());
}
// Directory → serve index.html
let file_path = if resolved.is_dir() {
let idx = resolved.join("index.html");
if idx.exists() {
idx
} else {
return Ok(StatusCode::NOT_FOUND.into_response());
}
} else {
resolved
};
let bytes = tokio::fs::read(&file_path)
.await
.map_err(|_| UiError::NotConfigured)?;
let ct = asset_content_type(&file_path);
let mut resp = bytes.into_response();
resp.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct));
Ok(resp)
}
fn asset_content_type(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") | Some("htm") => "text/html; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("woff2") => "font/woff2",
Some("woff") => "font/woff",
Some("md") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
/// Detect known showcase pages in `assets/` (committed artefacts).
pub fn detect_showcase(root: &std::path::Path, base_url: &str) -> Vec<serde_json::Value> {
let candidates = [
(
"branding",
"Branding",
&["branding/index.html", "index.html"] as &[&str],
),
("web", "Website", &["web/index.html"]),
(
"presentation",
"Presentation",
&[
"presentation/dist/index.html",
"presentation/slides/index.html",
],
),
];
candidates
.iter()
.filter_map(|(id, label, paths)| {
let found = paths
.iter()
.find(|p| root.join("assets").join(p).exists())?;
Some(serde_json::json!({
"id": id,
"label": label,
"url": format!("{base_url}/assets/{found}"),
}))
})
.collect()
}
/// Scan `public/` for generated artefacts — any subdirectory that contains
/// an `index.html` becomes a served link.
///
/// Well-known subdirectory names get a display label; unknown names are
/// title-cased from the directory name.
pub fn detect_generated(root: &std::path::Path, base_url: &str) -> Vec<serde_json::Value> {
let public_dir = root.join("public");
let Ok(entries) = std::fs::read_dir(&public_dir) else {
return vec![];
};
let known_labels: std::collections::HashMap<&str, &str> = [
("cargo-doc", "API Docs (cargo doc)"),
("docs", "Documentation"),
("mdbook", "MDBook"),
("book", "Book"),
("coverage", "Coverage Report"),
("typedoc", "TypeDoc"),
("storybook", "Storybook"),
]
.into_iter()
.collect();
let mut items: Vec<serde_json::Value> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
if !path.is_dir() {
return None;
}
// Must have an index.html to be servable
if !path.join("index.html").exists() {
return None;
}
let dir_name = path.file_name()?.to_str()?.to_string();
let label = known_labels
.get(dir_name.as_str())
.copied()
.unwrap_or(&dir_name)
.to_string();
Some(serde_json::json!({
"id": dir_name,
"label": label,
"url": format!("{base_url}/public/{dir_name}/"),
}))
})
.collect();
items.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
items
}
/// Serve a file from `{project_root}/public/` — generated, gitignored
/// artefacts.
pub async fn serve_public_mp(
State(state): State<AppState>,
Path((slug, pub_path)): Path<(String, String)>,
) -> Result<Response, UiError> {
let proj = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
serve_dir_from(&proj.root.join("public"), &pub_path).await
}
pub async fn serve_public_single(
State(state): State<AppState>,
Path(pub_path): Path<String>,
) -> Result<Response, UiError> {
serve_dir_from(&state.project_root.join("public"), &pub_path).await
}
/// Generic file server for an arbitrary base directory.
async fn serve_dir_from(base: &std::path::Path, rel: &str) -> Result<Response, UiError> {
let Ok(canonical_base) = base.canonicalize() else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let requested = canonical_base.join(rel.trim_start_matches('/'));
let resolved = match requested.canonicalize() {
Ok(p) => p,
Err(_) => {
let with_index = requested.join("index.html");
match with_index.canonicalize() {
Ok(p) => p,
Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()),
}
}
};
if !resolved.starts_with(&canonical_base) {
return Ok(StatusCode::FORBIDDEN.into_response());
}
let file_path = if resolved.is_dir() {
let idx = resolved.join("index.html");
if idx.exists() {
idx
} else {
return Ok(StatusCode::NOT_FOUND.into_response());
}
} else {
resolved
};
let bytes = tokio::fs::read(&file_path)
.await
.map_err(|_| UiError::NotConfigured)?;
let ct = asset_content_type(&file_path);
let mut resp = bytes.into_response();
resp.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct));
Ok(resp)
}
// ── Backlog
// ───────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct BacklogStatusForm {
pub id: String,
pub status: String,
}
#[derive(Deserialize)]
pub struct BacklogAddForm {
pub title: String,
pub kind: String,
pub priority: String,
pub detail: String,
}
fn today_iso() -> String {
// No chrono dep — derive from SystemTime epoch arithmetic.
let secs = epoch_secs();
let days_since_epoch = secs / 86400;
// Gregorian calendar from Julian Day Number (JDN 2440588 = 1970-01-01)
let jdn = days_since_epoch as i64 + 2440588;
let (y, m, d) = jdn_to_ymd(jdn);
format!("{y:04}-{m:02}-{d:02}")
}
fn jdn_to_ymd(jdn: i64) -> (i64, i64, i64) {
let a = jdn + 32044;
let b = (4 * a + 3) / 146097;
let c = a - (146097 * b) / 4;
let dd = (4 * c + 3) / 1461;
let e = c - (1461 * dd) / 4;
let mm = (5 * e + 2) / 153;
let d = e - (153 * mm + 2) / 5 + 1;
let m = mm + 3 - 12 * (mm / 10);
let y = 100 * b + dd - 4800 + mm / 10;
(y, m, d)
}
#[derive(Serialize)]
struct BacklogStats {
total: usize,
open: usize,
inprog: usize,
done: usize,
cancelled: usize,
critical: usize,
}
async fn load_backlog_items(
cache: &Arc<crate::cache::NclCache>,
backlog_path: &std::path::Path,
import_path: Option<&str>,
) -> Vec<serde_json::Value> {
let Ok((json, _)) = cache.export(backlog_path, import_path).await else {
return vec![];
};
json.get("items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn backlog_stats(items: &[serde_json::Value]) -> BacklogStats {
let count = |key: &str, val: &str| {
items
.iter()
.filter(|it| it.get(key).and_then(|v| v.as_str()) == Some(val))
.count()
};
BacklogStats {
total: items.len(),
open: count("status", "Open"),
inprog: count("status", "InProgress"),
done: count("status", "Done"),
cancelled: count("status", "Cancelled"),
critical: count("priority", "Critical"),
}
}
pub async fn backlog_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
let items = if backlog_path.exists() {
load_backlog_items(
&state.cache,
&backlog_path,
state.nickel_import_path.as_deref(),
)
.await
} else {
vec![]
};
let stats = backlog_stats(&items);
let mut ctx = Context::new();
ctx.insert("items", &items);
ctx.insert("stats", &stats);
ctx.insert("has_backlog", &backlog_path.exists());
ctx.insert("base_url", "/ui");
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/backlog.html", &ctx).await
}
pub async fn backlog_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let proj = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let backlog_path = proj.root.join("reflection").join("backlog.ncl");
let items = if backlog_path.exists() {
load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await
} else {
vec![]
};
proj.cache
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));
let stats = backlog_stats(&items);
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("items", &items);
ctx.insert("stats", &stats);
ctx.insert("has_backlog", &backlog_path.exists());
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&proj.root,
&proj.cache,
proj.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/backlog.html", &ctx).await
}
pub async fn backlog_update_status(
State(state): State<AppState>,
Form(form): Form<BacklogStatusForm>,
) -> Result<Response, UiError> {
let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
.map_err(|e| UiError::NclExport {
path: "backlog.ncl".into(),
reason: e.to_string(),
})?;
state
.cache
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
Ok(Redirect::to("/ui/backlog").into_response())
}
pub async fn backlog_update_status_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
Form(form): Form<BacklogStatusForm>,
) -> Result<Response, UiError> {
if auth_role_str(&auth) != "admin" {
return Err(UiError::Forbidden(
"admin role required for backlog writes".into(),
));
}
let proj = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let backlog_path = proj.root.join("reflection").join("backlog.ncl");
super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
.map_err(|e| UiError::NclExport {
path: "backlog.ncl".into(),
reason: e.to_string(),
})?;
proj.cache
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}
pub async fn backlog_add(
State(state): State<AppState>,
Form(form): Form<BacklogAddForm>,
) -> Result<Response, UiError> {
let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
super::backlog_ncl::add_item(
&backlog_path,
&form.title,
&form.kind,
&form.priority,
&form.detail,
&today_iso(),
)
.map_err(|e| UiError::NclExport {
path: "backlog.ncl".into(),
reason: e.to_string(),
})?;
state
.cache
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
Ok(Redirect::to("/ui/backlog").into_response())
}
pub async fn backlog_add_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
Form(form): Form<BacklogAddForm>,
) -> Result<Response, UiError> {
if auth_role_str(&auth) != "admin" {
return Err(UiError::Forbidden(
"admin role required for backlog writes".into(),
));
}
let proj = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let backlog_path = proj.root.join("reflection").join("backlog.ncl");
super::backlog_ncl::add_item(
&backlog_path,
&form.title,
&form.kind,
&form.priority,
&form.detail,
&today_iso(),
)
.map_err(|e| UiError::NclExport {
path: "backlog.ncl".into(),
reason: e.to_string(),
})?;
proj.cache
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}
// ── Manage ────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct AddProjectForm {
pub slug: String,
pub root: String,
}
#[derive(Deserialize)]
pub struct RemoveProjectForm {
pub slug: String,
}
/// Guarded variant: requires `AdminGuard` (any admin session or no auth
/// configured).
pub async fn manage_page_guarded(
State(state): State<AppState>,
_guard: super::auth::AdminGuard,
) -> Result<Html<String>, UiError> {
manage_page(State(state)).await
}
pub async fn manage_add_guarded(
State(state): State<AppState>,
_guard: super::auth::AdminGuard,
Form(form): Form<AddProjectForm>,
) -> Result<Response, UiError> {
manage_add(State(state), Form(form)).await
}
pub async fn manage_remove_guarded(
State(state): State<AppState>,
_guard: super::auth::AdminGuard,
Form(form): Form<RemoveProjectForm>,
) -> Result<Response, UiError> {
manage_remove(State(state), Form(form)).await
}
pub async fn manage_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let projects: Vec<serde_json::Value> = state
.registry
.as_ref()
.map(|r| {
r.all()
.into_iter()
.map(|ctx| {
serde_json::json!({
"slug": ctx.slug,
"root": ctx.root.display().to_string(),
"auth": ctx.auth_enabled(),
})
})
.collect()
})
.unwrap_or_default();
let registry_path = state
.registry
.as_ref()
.map(|r| r.path.display().to_string())
.unwrap_or_default();
let mut ctx = Context::new();
ctx.insert("projects", &projects);
ctx.insert("registry_path", &registry_path);
ctx.insert("base_url", "/ui");
ctx.insert("hide_project_nav", &true);
ctx.insert("error", &Option::<String>::None);
insert_mcp_ctx(&mut ctx);
render(tera, "pages/manage.html", &ctx).await
}
pub async fn manage_add(
State(state): State<AppState>,
Form(form): Form<AddProjectForm>,
) -> Result<Response, UiError> {
let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?;
let entry = crate::registry::RegistryEntry {
slug: form.slug.trim().to_string(),
root: std::path::PathBuf::from(form.root.trim()),
keys: vec![],
};
if let Err(e) = registry.add_project(entry) {
let tera = tera_ref(&state)?;
let projects: Vec<serde_json::Value> = registry
.all()
.into_iter()
.map(|ctx| {
serde_json::json!({
"slug": ctx.slug,
"root": ctx.root.display().to_string(),
"auth": ctx.auth_enabled(),
})
})
.collect();
let registry_path = registry.path.display().to_string();
let mut ctx = Context::new();
ctx.insert("projects", &projects);
ctx.insert("registry_path", &registry_path);
ctx.insert("base_url", "/ui");
ctx.insert("error", &e.to_string());
return render(tera, "pages/manage.html", &ctx)
.await
.map(IntoResponse::into_response);
}
Ok(Redirect::to("/ui/manage").into_response())
}
pub async fn manage_remove(
State(state): State<AppState>,
Form(form): Form<RemoveProjectForm>,
) -> Result<Response, UiError> {
let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?;
registry
.remove_project(&form.slug)
.map_err(|e| UiError::NclExport {
path: form.slug.clone(),
reason: e.to_string(),
})?;
Ok(Redirect::to("/ui/manage").into_response())
}
pub async fn modes_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let modes_dir = ctx_ref.root.join("reflection").join("modes");
let mut mode_entries: Vec<serde_json::Value> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&modes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let rel = path
.strip_prefix(&ctx_ref.root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
match ctx_ref
.cache
.export(&path, ctx_ref.import_path.as_deref())
.await
{
Ok((mut json, _)) => {
json.as_object_mut()
.map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel)));
mode_entries.push(json);
}
Err(e) => {
warn!(path = %rel, error = %e, "failed to export reflection mode");
mode_entries.push(serde_json::json!({
"_file": rel,
"id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"),
"_error": e.to_string(),
}));
}
}
}
}
mode_entries.sort_by_key(|v| {
v.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string()
});
let base_url = format!("/ui/{slug}");
let showcase = detect_showcase(&ctx_ref.root, &base_url);
let generated = detect_generated(&ctx_ref.root, &base_url);
let mut ctx = Context::new();
ctx.insert("modes", &mode_entries);
ctx.insert("total", &mode_entries.len());
ctx.insert("showcase", &showcase);
ctx.insert("generated", &generated);
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/modes.html", &ctx).await
}
// ── Agent task composer
// ───────────────────────────────────────────────────────
/// Default LLM providers used when config.ncl has no `providers` array.
fn default_providers() -> Vec<serde_json::Value> {
vec![
serde_json::json!({ "id": "anthropic", "label": "Anthropic (Claude)", "api_url": "", "model": "claude-opus-4-6", "key_env": "ANTHROPIC_API_KEY", "kind": "anthropic" }),
serde_json::json!({ "id": "openai", "label": "OpenAI (GPT-4o)", "api_url": "", "model": "gpt-4o", "key_env": "OPENAI_API_KEY", "kind": "openai" }),
serde_json::json!({ "id": "local", "label": "Local (Ollama)", "api_url": "http://localhost:11434/v1/chat/completions", "model": "llama3.2", "key_env": "", "kind": "openai" }),
]
}
async fn read_providers(
root: &std::path::Path,
cache: &Arc<crate::cache::NclCache>,
import_path: Option<&str>,
) -> Vec<serde_json::Value> {
let config_path = root.join(".ontoref").join("config.ncl");
if config_path.exists() {
if let Ok((json, _)) = cache.export(&config_path, import_path).await {
if let Some(arr) = json.get("providers").and_then(|v| v.as_array()) {
if !arr.is_empty() {
return arr.clone();
}
}
}
}
default_providers()
}
pub async fn compose_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let forms_dir = ctx_ref.root.join("reflection").join("forms");
let mut forms: Vec<serde_json::Value> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&forms_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
forms.push(serde_json::json!({
"id": stem,
"label": stem.replace('_', " "),
}));
}
}
forms.sort_by_key(|v| {
v.get("id")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string()
});
let providers = read_providers(
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
)
.await;
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("forms", &forms);
ctx.insert("providers", &providers);
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/compose.html", &ctx).await
}
pub async fn compose_form_schema_mp(
State(state): State<AppState>,
Path((slug, form_id)): Path<(String, String)>,
_auth: AuthUser,
) -> Result<axum::Json<serde_json::Value>, UiError> {
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let form_path = ctx_ref
.root
.join("reflection")
.join("forms")
.join(format!("{form_id}.ncl"));
if !form_path.exists() {
return Err(UiError::BadRequest(format!("form not found: {form_id}")));
}
let (json, _) = ctx_ref
.cache
.export(&form_path, ctx_ref.import_path.as_deref())
.await
.map_err(|e| UiError::NclExport {
path: form_id.clone(),
reason: e.to_string(),
})?;
Ok(axum::Json(json))
}
#[derive(serde::Deserialize)]
pub struct ComposeSendBody {
pub provider_id: String,
pub prompt: String,
#[serde(default)]
pub system: String,
}
pub async fn compose_send_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
_auth: AuthUser,
axum::Json(body): axum::Json<ComposeSendBody>,
) -> Result<axum::Json<serde_json::Value>, UiError> {
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let providers = read_providers(
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
)
.await;
let provider = providers
.iter()
.find(|p| p.get("id").and_then(|v| v.as_str()) == Some(body.provider_id.as_str()))
.ok_or_else(|| UiError::BadRequest(format!("unknown provider: {}", body.provider_id)))?
.clone();
let api_url = provider
.get("api_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let model = provider
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("claude-opus-4-6")
.to_string();
let key_env = provider
.get("key_env")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let kind = provider
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("anthropic")
.to_string();
let api_key = if key_env.is_empty() {
String::new()
} else {
std::env::var(&key_env).unwrap_or_default()
};
if api_key.is_empty() && !matches!(kind.as_str(), "openai") {
return Err(UiError::BadRequest(format!(
"API key not configured: set {} environment variable",
key_env
)));
}
let result = match kind.as_str() {
"openai" => call_openai_api(&api_url, &api_key, &model, &body.system, &body.prompt).await,
_ => call_anthropic_api(&api_url, &api_key, &model, &body.system, &body.prompt).await,
};
result
.map(axum::Json)
.map_err(|e| UiError::BadRequest(e.to_string()))
}
async fn call_anthropic_api(
url: &str,
key: &str,
model: &str,
system: &str,
prompt: &str,
) -> anyhow::Result<serde_json::Value> {
let effective_url = if url.is_empty() {
"https://api.anthropic.com/v1/messages"
} else {
url
};
let mut req = serde_json::json!({
"model": model,
"max_tokens": 4096,
"messages": [{"role": "user", "content": prompt}]
});
if !system.is_empty() {
req["system"] = serde_json::Value::String(system.to_string());
}
let resp = reqwest::Client::new()
.post(effective_url)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.json(&req)
.send()
.await?
.json::<serde_json::Value>()
.await?;
Ok(resp)
}
async fn call_openai_api(
url: &str,
key: &str,
model: &str,
system: &str,
prompt: &str,
) -> anyhow::Result<serde_json::Value> {
let effective_url = if url.is_empty() {
"https://api.openai.com/v1/chat/completions"
} else {
url
};
let mut messages: Vec<serde_json::Value> = Vec::new();
if !system.is_empty() {
messages.push(serde_json::json!({"role": "system", "content": system}));
}
messages.push(serde_json::json!({"role": "user", "content": prompt}));
let resp = reqwest::Client::new()
.post(effective_url)
.header("Authorization", format!("Bearer {key}"))
.json(&serde_json::json!({ "model": model, "messages": messages }))
.send()
.await?
.json::<serde_json::Value>()
.await?;
Ok(resp)
}
// ── Actions
// ───────────────────────────────────────────────────────────────────
/// Load `quick_actions` from `.ontoref/config.ncl`.
async fn load_quick_actions(
cache: &Arc<crate::cache::NclCache>,
root: &std::path::Path,
import_path: Option<&str>,
) -> Vec<serde_json::Value> {
let config_path = root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return vec![];
}
let Ok((json, _)) = cache.export(&config_path, import_path).await else {
return vec![];
};
json.get("quick_actions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
pub async fn actions_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let actions = load_quick_actions(
&state.cache,
&state.project_root,
state.nickel_import_path.as_deref(),
)
.await;
let mut ctx = Context::new();
ctx.insert("actions", &actions);
ctx.insert("base_url", "/ui");
ctx.insert("slug", &Option::<String>::None);
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/actions.html", &ctx).await
}
pub async fn actions_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let actions = load_quick_actions(
&ctx_ref.cache,
&ctx_ref.root,
ctx_ref.import_path.as_deref(),
)
.await;
let base_url = format!("/ui/{slug}");
let mut ctx = Context::new();
ctx.insert("actions", &actions);
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/actions.html", &ctx).await
}
// ── Q&A ───────────────────────────────────────────────────────────────────────
pub async fn qa_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let mut ctx = Context::new();
ctx.insert("base_url", "/ui");
ctx.insert("slug", &Option::<String>::None);
let entries = load_qa_entries(
&state.cache,
&state.project_root,
state.nickel_import_path.as_deref(),
)
.await;
ctx.insert("entries", &entries);
insert_brand_ctx(
&mut ctx,
&state.project_root,
&state.cache,
state.nickel_import_path.as_deref(),
"/ui",
)
.await;
render(tera, "pages/qa.html", &ctx).await
}
pub async fn qa_page_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
auth: AuthUser,
) -> Result<Html<String>, UiError> {
let tera = tera_ref(&state)?;
let ctx_ref = state
.registry
.as_ref()
.and_then(|r| r.get(&slug))
.ok_or(UiError::NotConfigured)?;
let base_url = format!("/ui/{slug}");
let entries = load_qa_entries(
&ctx_ref.cache,
&ctx_ref.root,
ctx_ref.import_path.as_deref(),
)
.await;
let mut ctx = Context::new();
ctx.insert("base_url", &base_url);
ctx.insert("slug", &slug);
ctx.insert("current_role", &auth_role_str(&auth));
ctx.insert("entries", &entries);
insert_brand_ctx(
&mut ctx,
&ctx_ref.root,
&ctx_ref.cache,
ctx_ref.import_path.as_deref(),
&base_url,
)
.await;
render(tera, "pages/qa.html", &ctx).await
}
// ── Q&A mutation ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct QaAddRequest {
pub question: String,
pub answer: Option<String>,
pub actor: Option<String>,
pub tags: Option<Vec<String>>,
pub related: Option<Vec<String>>,
pub slug: Option<String>,
}
pub async fn qa_add(
State(state): State<AppState>,
Json(body): Json<QaAddRequest>,
) -> impl IntoResponse {
let (root, cache) = if let (Some(slug), Some(reg)) = (body.slug.as_deref(), &state.registry) {
if let Some(ctx) = reg.get(slug) {
(ctx.root.clone(), ctx.cache.clone())
} else {
(state.project_root.clone(), state.cache.clone())
}
} else {
(state.project_root.clone(), state.cache.clone())
};
let qa_path = root.join("reflection").join("qa.ncl");
if !qa_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(
serde_json::json!({ "error": "qa.ncl not found — create reflection/qa.ncl first" }),
),
);
}
let answer = body.answer.as_deref().unwrap_or("");
let actor = body.actor.as_deref().unwrap_or("human");
let tags = body.tags.as_deref().unwrap_or(&[]);
let related = body.related.as_deref().unwrap_or(&[]);
let now = now_iso();
match super::qa_ncl::add_entry(&qa_path, &body.question, answer, actor, &now, tags, related) {
Ok(id) => {
cache.invalidate_file(&qa_path);
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"id": id,
"created_at": now,
})),
)
}
Err(e) => {
warn!(error = %e, "qa_add failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
fn now_iso() -> String {
let total_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let secs_in_day = total_secs % 86400;
let days = total_secs / 86400;
let h = secs_in_day / 3600;
let m = (secs_in_day % 3600) / 60;
let s = secs_in_day % 60;
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y0 = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y0 + 1 } else { y0 };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
async fn load_qa_entries(
cache: &Arc<crate::cache::NclCache>,
root: &std::path::Path,
import_path: Option<&str>,
) -> Vec<serde_json::Value> {
let qa_path = root.join("reflection").join("qa.ncl");
if !qa_path.exists() {
return vec![];
}
match cache.export(&qa_path, import_path).await {
Ok((json, _)) => json
.get("entries")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
Err(_) => vec![],
}
}
// ── Q&A delete / update
// ───────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct QaDeleteRequest {
pub id: String,
pub slug: Option<String>,
}
pub async fn qa_delete(
State(state): State<AppState>,
Json(body): Json<QaDeleteRequest>,
) -> impl IntoResponse {
let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref());
let qa_path = root.join("reflection").join("qa.ncl");
if !qa_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "qa.ncl not found" })),
);
}
match super::qa_ncl::remove_entry(&qa_path, &body.id) {
Ok(()) => {
cache.invalidate_file(&qa_path);
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "id": body.id })),
)
}
Err(e) => {
warn!(error = %e, id = %body.id, "qa_delete failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
#[derive(Deserialize)]
pub struct QaUpdateRequest {
pub id: String,
pub question: String,
pub answer: Option<String>,
pub slug: Option<String>,
}
pub async fn qa_update(
State(state): State<AppState>,
Json(body): Json<QaUpdateRequest>,
) -> impl IntoResponse {
let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref());
let qa_path = root.join("reflection").join("qa.ncl");
if !qa_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "qa.ncl not found" })),
);
}
let answer = body.answer.as_deref().unwrap_or("");
match super::qa_ncl::update_entry(&qa_path, &body.id, &body.question, answer) {
Ok(()) => {
cache.invalidate_file(&qa_path);
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "id": body.id })),
)
}
Err(e) => {
warn!(error = %e, id = %body.id, "qa_update failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
}
}
}
fn resolve_qa_ctx(
state: &crate::api::AppState,
slug: Option<&str>,
) -> (std::path::PathBuf, Arc<crate::cache::NclCache>) {
if let (Some(s), Some(reg)) = (slug, &state.registry) {
if let Some(ctx) = reg.get(s) {
return (ctx.root.clone(), ctx.cache.clone());
}
}
(state.project_root.clone(), state.cache.clone())
}
// ── Actions run
// ───────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ActionRunForm {
pub action_id: String,
}
pub async fn actions_run(
State(state): State<AppState>,
Form(form): Form<ActionRunForm>,
) -> impl IntoResponse {
let root = state.project_root.clone();
let cache = state.cache.clone();
let import_path = state.nickel_import_path.clone();
run_action_by_id(&root, &cache, import_path.as_deref(), &form.action_id).await;
axum::response::Redirect::to("/ui/actions").into_response()
}
pub async fn actions_run_mp(
State(state): State<AppState>,
Path(slug): Path<String>,
_auth: AuthUser,
Form(form): Form<ActionRunForm>,
) -> impl IntoResponse {
let redirect = format!("/ui/{slug}/actions");
if let Some(ctx) = state.registry.as_ref().and_then(|r| r.get(&slug)) {
run_action_by_id(
&ctx.root,
&ctx.cache,
ctx.import_path.as_deref(),
&form.action_id,
)
.await;
}
axum::response::Redirect::to(&redirect).into_response()
}
/// Resolve `action_id` from `quick_actions`, then spawn `./ontoref {mode}`.
async fn run_action_by_id(
root: &std::path::Path,
cache: &Arc<crate::cache::NclCache>,
import_path: Option<&str>,
action_id: &str,
) {
let config_path = root.join(".ontoref").join("config.ncl");
let Ok((json, _)) = cache.export(&config_path, import_path).await else {
warn!(action_id, "actions_run: config export failed");
return;
};
let mode = json
.get("quick_actions")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter()
.find(|a| a.get("id").and_then(|i| i.as_str()) == Some(action_id))
})
.and_then(|a| a.get("mode"))
.and_then(|m| m.as_str())
.map(str::to_string);
let Some(mode) = mode else {
warn!(action_id, "actions_run: action not found in quick_actions");
return;
};
let ontoref_bin = root.join("ontoref");
if !ontoref_bin.exists() {
warn!(path = %ontoref_bin.display(), "actions_run: ontoref binary not found");
return;
}
match tokio::process::Command::new(&ontoref_bin)
.arg(&mode)
.current_dir(root)
.spawn()
{
Ok(_) => tracing::info!(action_id, mode, "action spawned"),
Err(e) => warn!(action_id, mode, error = %e, "actions_run: spawn failed"),
}
}