polaris_dashboard 0.1.3

Opinionated read-only dashboard for Polaris sessions.
Documentation
//! Opinionated read-only dashboard for Polaris sessions.
//!
//! Register [`DashboardPlugin`] on a [`polaris_ai::system::server::Server`] to
//! mount the bundled `SvelteKit` SPA at a configurable base path. The dashboard
//! is read-only and consumes session, run, and span data exposed by
//! [`HttpPlugin`](polaris_ai::sessions::HttpPlugin) and
//! [`TracingPlugin`](polaris_ai::plugins::TracingPlugin).
//!
//! The SPA is not built by this crate. It must be compiled and built in its own
//! repository; the build output is dropped into `assets/` and embedded at
//! compile time via `rust-embed`. With an empty `assets/`, the crate still
//! compiles and every dashboard route returns a "bundle missing" notice instead
//! of the UI.
//!
//! # Required backend plugins
//!
//! The SPA calls `/v1/sessions/*` (mounted by `HttpPlugin`) and
//! `/v1/tracing/*` (mounted by `TracingPlugin` when its `dashboard`
//! feature is active). Per the upstream plugin stack, that requires:
//!
//! - `ServerInfoPlugin` (foundation)
//! - `AppPlugin` (HTTP router)
//! - `ModelsPlugin`, `ToolsPlugin` (transitive deps of `TracingPlugin`
//!   when `dashboard` is on)
//! - `SessionsPlugin` + `HttpPlugin` (sessions REST surface)
//! - `TracingPlugin` (the `/v1/tracing/*` runs / span-tree endpoints)
//!
//! `SpanStorePlugin` is optional but recommended for restart-safe run
//! history. Hosts that omit `TracingPlugin` will see "Failed to load
//! runs: 404" in the runs pane — the bundled `examples/serve.rs` is a
//! minimal complete wiring.
//!
//! # Replacing the SPA
//!
//! This crate is framework-agnostic: it embeds whatever static bundle lives in
//! `assets/` and serves it: `index.html` at the base path, embedded files for
//! matching sub-paths, and `index.html` as the fallback for everything else
//! (client-side routing). It does no templating and injects no config. Any
//! framework (React, Vue, Solid, plain HTML, …) works in place of the bundled
//! `SvelteKit` SPA as long as the bundle satisfies this contract:
//!
//! 1. **Client-routed, rooted at `index.html`** — there is no SSR; unmatched
//!    paths under the base return `index.html` verbatim.
//! 2. **Asset URLs resolve under the mount base** — the SPA's build-time base
//!    must equal [`DashboardPlugin`]'s `base_path` (the bundled SPA is built for
//!    `/dashboard`). A mismatch 404s every asset; [`DashboardPlugin`] logs a
//!    warning when it can detect one.
//! 3. **Same-origin, relative API calls** to the documented contracts:
//!    `/v1/sessions/*` (from `HttpPlugin`) and `/v1/tracing/*` (from
//!    `TracingPlugin` or [`OtelTracingPlugin`]), consuming the [`RunSummary`] /
//!    [`SpanTree`] JSON shapes. The host supplies no API base — the SPA assumes
//!    same origin.
//! 4. **No host-injected configuration** — the SPA must self-configure.
//!
//! # Auth
//!
//! Authentication is driven by whatever `AuthProvider` the host server
//! registers on `polaris_ai::app::HttpRouter::set_auth`. The dashboard plugin
//! does not register its own provider — its routes inherit the global
//! middleware applied by `polaris_ai::app::AppPlugin`.
//!
//! # Example
//!
//! ```no_run
//! use std::sync::Arc;
//! use polaris_ai::app::{AppConfig, AppPlugin};
//! use polaris_ai::sessions::{HttpPlugin, InMemoryStore, SessionsPlugin};
//! use polaris_ai::system::server::Server;
//! use polaris_dashboard::DashboardPlugin;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let mut server = Server::new();
//! server
//!     .add_plugins(AppPlugin::new(AppConfig::new()))
//!     .add_plugins(SessionsPlugin::new(Arc::new(InMemoryStore::new())))
//!     .add_plugins(HttpPlugin::new())
//!     .add_plugins(DashboardPlugin::default());
//! server.run().await?;
//! # Ok(())
//! # }
//! ```

mod otel;

pub use otel::{
    Configuring, DEFAULT_CAPACITY, OtelTracingPlugin, Ready, RunSummary, RunsResponse, SpanEvent,
    SpanNode, SpanTree, StoreState, TraceStore,
};

use axum::Router;
use axum::extract::Path;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use polaris_ai::app::{AppPlugin, HttpRouter};
use polaris_ai::system::plugin::{Plugin, PluginId, Version};
use polaris_ai::system::server::Server;
use rust_embed::RustEmbed;

/// Embedded SPA bundle.
///
/// The SPA is built in its own repository; its build output is dropped into
/// this crate's `assets/` directory before `cargo build` runs, and embedded at
/// compile time. When the bundle is missing, every route returns a helpful
/// error instead of panicking.
#[derive(RustEmbed)]
#[folder = "assets/"]
struct DashboardAssets;

/// Plugin that serves the bundled Polaris sessions dashboard SPA over HTTP.
///
/// The plugin only hosts static assets; all session data comes from
/// [`HttpPlugin`](polaris_ai::sessions::HttpPlugin), which must be registered
/// alongside it.
///
/// # Routes Provided
///
/// Mounted relative to the configured base path (default `/dashboard`):
///
/// | Method | Path | Description |
/// |--------|------|-------------|
/// | `GET`  | `{base}` | SPA shell (`index.html`). |
/// | `GET`  | `{base}/` | SPA shell — trailing-slash form. |
/// | `GET`  | `{base}/{*path}` | Embedded asset, or the SPA shell as a client-routing fallback. |
///
/// # Resources Provided
///
/// | Resource | Scope | Description |
/// |----------|-------|-------------|
/// | _none_   | —     | This plugin only mounts HTTP routes against [`HttpRouter`]. |
///
/// # APIs Provided
///
/// None.
///
/// # Dependencies
///
/// - [`AppPlugin`] — provides the [`HttpRouter`] the SPA is mounted on. This is
///   the only build-order dependency.
///
/// The SPA needs a data backend at runtime, but those are *not* build
/// dependencies of this plugin — register them yourself:
/// [`SessionsPlugin`](polaris_ai::sessions::SessionsPlugin) +
/// [`HttpPlugin`](polaris_ai::sessions::HttpPlugin) for the `/v1/sessions/*`
/// surface, and a `/v1/tracing/*` provider (the upstream `TracingPlugin` via
/// the default `native-tracing` feature, or [`OtelTracingPlugin`]). Omitting
/// the tracing surface yields "Failed to load runs: 404" in the runs pane.
///
/// # Lifecycle
///
/// - **Feature `native-tracing` (default-on):** pulls in the upstream
///   `TracingPlugin` HTTP surface (`/v1/tracing/*`) the runs pane reads. Drop it
///   (`default-features = false`) only when substituting [`OtelTracingPlugin`]
///   as the trace surface — the two conflict on `/v1/tracing/runs`.
/// - **`build()` panics** if no [`AppPlugin`] (the [`HttpRouter`] provider) has
///   been registered before this plugin.
///
/// # Example
///
/// ```no_run
/// use std::sync::Arc;
/// use polaris_ai::app::{AppConfig, AppPlugin};
/// use polaris_ai::sessions::{HttpPlugin, InMemoryStore, SessionsPlugin};
/// use polaris_ai::system::server::Server;
/// use polaris_dashboard::DashboardPlugin;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let mut server = Server::new();
/// server
///     .add_plugins(AppPlugin::new(AppConfig::new()))
///     .add_plugins(SessionsPlugin::new(Arc::new(InMemoryStore::new())))
///     .add_plugins(HttpPlugin::new())
///     .add_plugins(DashboardPlugin::new("/admin"));
/// server.run().await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct DashboardPlugin {
    base_path: String,
}

impl DashboardPlugin {
    /// Mount the dashboard at `base_path`.
    ///
    /// The path should start with `/` and not end with `/` — e.g. `"/dashboard"`.
    ///
    /// **`base_path` must match the base the embedded SPA was built with.** The
    /// SPA bakes this prefix into its asset URLs at build time, so it is not a
    /// freely-chosen runtime value: the bundled SPA is built for `/dashboard`.
    /// To serve from a different base you must rebuild the SPA with a matching
    /// base. `build()` logs a warning if the two disagree. See the crate-level
    /// "Replacing the SPA" docs.
    pub fn new(base_path: impl Into<String>) -> Self {
        Self {
            base_path: base_path.into(),
        }
    }

    /// Base path the SPA is served from.
    pub fn base_path(&self) -> &str {
        &self.base_path
    }
}

impl Default for DashboardPlugin {
    fn default() -> Self {
        Self::new("/dashboard")
    }
}

impl Plugin for DashboardPlugin {
    const ID: &'static str = "polaris::dashboard";
    const VERSION: Version = Version::new(0, 1, 0);

    fn build(&self, server: &mut Server) {
        // Explicit routes rather than `nest` — axum 0.8's nest + wildcard
        // composition doesn't reliably match the trailing-slash root, and
        // we want both `/{base}` and `/{base}/` to serve the SPA shell.
        let base = self.base_path.trim_end_matches('/');

        // The SPA bakes its base path into asset URLs at build time (e.g.
        // `/dashboard/_app/...`). If `base_path` doesn't match the bundle's
        // base, every asset request 404s and the page renders blank — warn
        // loudly rather than fail silently. Best-effort: only fires for bundles
        // that expose a detectable base (SvelteKit-style `/_app/` assets).
        if let Some(index) = DashboardAssets::get("index.html")
            && let Ok(html) = std::str::from_utf8(&index.data)
            && let Some(spa_base) = baked_asset_base(html)
            && spa_base != base
        {
            tracing::warn!(
                configured_base = %base,
                spa_build_base = %spa_base,
                "dashboard: base_path does not match the embedded SPA's build-time base; \
                 asset URLs will 404 and the page will not load. Mount at the SPA's base, \
                 or rebuild the SPA with a matching base path."
            );
        }

        let mut router = Router::new()
            .route(base, get(serve_index))
            .route(&format!("{base}/{{*path}}"), get(serve_path));
        if !base.is_empty() {
            router = router.route(&format!("{base}/"), get(serve_index));
        }

        server
            .api::<HttpRouter>()
            .expect("AppPlugin must be added before DashboardPlugin")
            .add_routes(router);
    }

    fn dependencies(&self) -> Vec<PluginId> {
        // Only `AppPlugin` is a genuine build-order dependency — `build()`
        // mounts routes on its `HttpRouter`. The session/tracing backends the
        // SPA reads are runtime-data dependencies the host wires up, not
        // build-order edges, so naming them here would needlessly couple every
        // consumer to one concrete provider.
        vec![PluginId::of::<AppPlugin>()]
    }
}

async fn serve_index() -> Response {
    index_response()
}

async fn serve_path(Path(path): Path<String>) -> Response {
    if let Some(file) = DashboardAssets::get(&path) {
        let mime = file.metadata.mimetype();
        return (
            [(header::CONTENT_TYPE, mime.to_string())],
            file.data.into_owned(),
        )
            .into_response();
    }
    // SPA fallback: unknown paths return index.html so client-side routing
    // can take over. The browser keeps its URL; SvelteKit picks up the route.
    index_response()
}

/// Extract the base path a SPA bundle baked into its asset URLs, if detectable.
///
/// Looks for the first SvelteKit-style `/_app/` asset reference and returns the
/// prefix in front of it (`/dashboard/_app/...` → `/dashboard`, `/_app/...` →
/// `""`). Returns `None` for bundles without that marker — the crate then makes
/// no claim about the SPA's base and skips the mismatch check.
fn baked_asset_base(index_html: &str) -> Option<String> {
    let marker = "/_app/";
    let at = index_html.find(marker)?;
    let before = &index_html[..at];
    // The URL starts just after the opening quote/paren/space before this point.
    let start = before.rfind(['"', '\'', '(', ' ']).map_or(0, |i| i + 1);
    Some(before[start..].to_string())
}

fn index_response() -> Response {
    match DashboardAssets::get("index.html") {
        Some(file) => (
            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
            file.data.into_owned(),
        )
            .into_response(),
        None => (
            StatusCode::SERVICE_UNAVAILABLE,
            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
            "Polaris dashboard assets are missing. Build the dashboard SPA and \
             drop its output into this crate's `assets/` directory before \
             `cargo build` so the SPA is embedded into the binary.",
        )
            .into_response(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_mounts_at_dashboard() {
        assert_eq!(DashboardPlugin::default().base_path(), "/dashboard");
    }

    #[test]
    fn new_accepts_custom_base_path() {
        let plugin = DashboardPlugin::new("/admin/dash");
        assert_eq!(plugin.base_path(), "/admin/dash");
    }

    #[test]
    fn baked_base_extracted_from_spa_asset_urls() {
        let html = r#"<link href="/dashboard/_app/x.css"><script src="/dashboard/_app/y.js">"#;
        assert_eq!(baked_asset_base(html).as_deref(), Some("/dashboard"));
        // Nested base.
        let nested = r#"<link href="/admin/dash/_app/x.css">"#;
        assert_eq!(baked_asset_base(nested).as_deref(), Some("/admin/dash"));
        // Root mount → empty base.
        assert_eq!(
            baked_asset_base(r#"<script src="/_app/app.js">"#).as_deref(),
            Some("")
        );
        // Bundle without the SvelteKit marker → no opinion, check is skipped.
        assert_eq!(baked_asset_base(r#"<script src="/static/app.js">"#), None);
    }

    // Note: the "assets missing" placeholder is exercised by the integration
    // test in `tests/dashboard_routes.rs`, which accepts either 200 (SPA
    // built) or 503 (placeholder) depending on whether `bun run build` has
    // run. Keeping that behavior out of unit tests avoids flapping based on
    // whether `assets/` happens to be populated.
}