2468 lines
78 KiB
Rust
2468 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) = ¤t_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", ®istry_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", ®istry_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"),
|
||
|
|
}
|
||
|
|
}
|