feat: added langflow, langfuse and langgraph integrations (#17)
Some checks failed
Some checks failed
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -5,7 +5,7 @@ use dioxus_free_icons::Icon;
|
||||
use crate::components::sidebar::Sidebar;
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
use crate::infrastructure::auth_check::check_auth;
|
||||
use crate::models::AuthInfo;
|
||||
use crate::models::{AuthInfo, ServiceUrlsContext};
|
||||
use crate::Route;
|
||||
|
||||
/// Application shell layout that wraps all authenticated pages.
|
||||
@@ -29,6 +29,16 @@ pub fn AppShell() -> Element {
|
||||
|
||||
match auth_snapshot {
|
||||
Some(Ok(info)) if info.authenticated => {
|
||||
// Provide developer tool URLs as context so child pages
|
||||
// can read them without prop-drilling through layouts.
|
||||
use_context_provider(|| {
|
||||
Signal::new(ServiceUrlsContext {
|
||||
langgraph_url: info.langgraph_url.clone(),
|
||||
langflow_url: info.langflow_url.clone(),
|
||||
langfuse_url: info.langfuse_url.clone(),
|
||||
})
|
||||
});
|
||||
|
||||
let menu_open = *mobile_menu_open.read();
|
||||
let sidebar_cls = if menu_open {
|
||||
"sidebar sidebar--open"
|
||||
|
||||
@@ -9,6 +9,7 @@ mod page_header;
|
||||
mod pricing_card;
|
||||
pub mod sidebar;
|
||||
pub mod sub_nav;
|
||||
mod tool_embed;
|
||||
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
@@ -20,3 +21,4 @@ pub use news_card::*;
|
||||
pub use page_header::*;
|
||||
pub use pricing_card::*;
|
||||
pub use sub_nav::*;
|
||||
pub use tool_embed::*;
|
||||
|
||||
81
src/components/tool_embed.rs
Normal file
81
src/components/tool_embed.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
|
||||
/// Properties for the [`ToolEmbed`] component.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `url` - Service URL; when empty, a "Not Configured" placeholder is shown
|
||||
/// * `title` - Display title for the tool (e.g. "Agent Builder")
|
||||
/// * `description` - Description text shown in the placeholder card
|
||||
/// * `icon` - Single-character icon for the placeholder card
|
||||
/// * `launch_label` - Label for the disabled button in the placeholder
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ToolEmbedProps {
|
||||
/// Service URL. Empty string means "not configured".
|
||||
pub url: String,
|
||||
/// Display title shown in the toolbar / placeholder heading.
|
||||
pub title: String,
|
||||
/// Description shown in the "not configured" placeholder.
|
||||
pub description: String,
|
||||
/// Single-character icon for the placeholder card.
|
||||
pub icon: &'static str,
|
||||
/// Label for the disabled launch button in placeholder mode.
|
||||
pub launch_label: String,
|
||||
}
|
||||
|
||||
/// Hybrid iframe / placeholder component for developer tool pages.
|
||||
///
|
||||
/// When `url` is non-empty, renders a toolbar (title + pop-out button)
|
||||
/// above a full-height iframe embedding the service. When `url` is
|
||||
/// empty, renders the existing placeholder card with a "Not Configured"
|
||||
/// badge instead of "Coming Soon".
|
||||
#[component]
|
||||
pub fn ToolEmbed(props: ToolEmbedProps) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
if props.url.is_empty() {
|
||||
// Not configured -- show placeholder card
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "{props.icon}" }
|
||||
h2 { "{props.title}" }
|
||||
p { class: "placeholder-desc", "{props.description}" }
|
||||
button {
|
||||
class: "btn-primary",
|
||||
disabled: true,
|
||||
"{props.launch_label}"
|
||||
}
|
||||
span { class: "placeholder-badge",
|
||||
"{t(l, \"developer.not_configured\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// URL is set -- render toolbar + iframe
|
||||
let pop_out_url = props.url.clone();
|
||||
rsx! {
|
||||
div { class: "tool-embed",
|
||||
div { class: "tool-embed-toolbar",
|
||||
span { class: "tool-embed-title", "{props.title}" }
|
||||
a {
|
||||
class: "tool-embed-popout-btn",
|
||||
href: "{pop_out_url}",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
"{t(l, \"developer.open_new_tab\")}"
|
||||
}
|
||||
}
|
||||
iframe {
|
||||
class: "tool-embed-iframe",
|
||||
src: "{props.url}",
|
||||
title: "{props.title}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,15 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||
Some(u) => {
|
||||
let librechat_url =
|
||||
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
|
||||
|
||||
// Extract service URLs from server state so the frontend can
|
||||
// embed developer tools (LangGraph, LangFlow, Langfuse).
|
||||
let state: crate::infrastructure::server_state::ServerState =
|
||||
FullstackContext::extract().await?;
|
||||
let langgraph_url = state.services.langgraph_url.clone();
|
||||
let langflow_url = state.services.langflow_url.clone();
|
||||
let langfuse_url = state.services.langfuse_url.clone();
|
||||
|
||||
Ok(AuthInfo {
|
||||
authenticated: true,
|
||||
sub: u.sub,
|
||||
@@ -34,6 +43,9 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||
name: u.user.name,
|
||||
avatar_url: u.user.avatar_url,
|
||||
librechat_url,
|
||||
langgraph_url,
|
||||
langflow_url,
|
||||
langfuse_url,
|
||||
})
|
||||
}
|
||||
None => Ok(AuthInfo::default()),
|
||||
|
||||
@@ -154,6 +154,8 @@ pub struct ServiceUrls {
|
||||
pub langchain_url: String,
|
||||
/// LangGraph service URL.
|
||||
pub langgraph_url: String,
|
||||
/// LangFlow visual workflow builder URL.
|
||||
pub langflow_url: String,
|
||||
/// Langfuse observability URL.
|
||||
pub langfuse_url: String,
|
||||
/// Vector database URL.
|
||||
@@ -183,6 +185,7 @@ impl ServiceUrls {
|
||||
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
||||
langchain_url: optional_env("LANGCHAIN_URL"),
|
||||
langgraph_url: optional_env("LANGGRAPH_URL"),
|
||||
langflow_url: optional_env("LANGFLOW_URL"),
|
||||
langfuse_url: optional_env("LANGFUSE_URL"),
|
||||
vectordb_url: optional_env("VECTORDB_URL"),
|
||||
s3_url: optional_env("S3_URL"),
|
||||
|
||||
108
src/infrastructure/langgraph.rs
Normal file
108
src/infrastructure/langgraph.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use dioxus::prelude::*;
|
||||
#[cfg(feature = "server")]
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::AgentEntry;
|
||||
|
||||
/// Raw assistant object returned by the LangGraph `POST /assistants/search`
|
||||
/// endpoint. Only the fields we display are deserialized; unknown keys are
|
||||
/// silently ignored thanks to serde defaults.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct LangGraphAssistant {
|
||||
assistant_id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
graph_id: String,
|
||||
#[serde(default)]
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Fetch the list of assistants (agents) from a LangGraph instance.
|
||||
///
|
||||
/// Calls `POST <langgraph_url>/assistants/search` with an empty body to
|
||||
/// retrieve every registered assistant. Each result is mapped to the
|
||||
/// frontend-friendly `AgentEntry` model.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of `AgentEntry` structs. Returns an empty vector when the
|
||||
/// LangGraph URL is not configured or the service is unreachable.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` on network or deserialization failures that
|
||||
/// indicate a misconfigured (but present) LangGraph instance.
|
||||
#[server(endpoint = "list-langgraph-agents")]
|
||||
pub async fn list_langgraph_agents() -> Result<Vec<AgentEntry>, ServerFnError> {
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = state.services.langgraph_url.clone();
|
||||
if base_url.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let url = format!("{}/assistants/search", base_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
// LangGraph expects a POST with a JSON body (empty object = no filters).
|
||||
let resp = match client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
tracing::error!("LangGraph returned {status}: {body}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("LangGraph request failed: {e}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
let assistants: Vec<LangGraphAssistant> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse LangGraph response: {e}")))?;
|
||||
|
||||
let entries = assistants
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
// Use the assistant name if present, otherwise fall back to graph_id.
|
||||
let name = if a.name.is_empty() {
|
||||
a.graph_id.clone()
|
||||
} else {
|
||||
a.name
|
||||
};
|
||||
|
||||
// Extract a description from metadata if available.
|
||||
let description = a
|
||||
.metadata
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
AgentEntry {
|
||||
id: a.assistant_id,
|
||||
name,
|
||||
description,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod auth_check;
|
||||
pub mod chat;
|
||||
pub mod langgraph;
|
||||
pub mod llm;
|
||||
pub mod ollama;
|
||||
pub mod searxng;
|
||||
|
||||
@@ -3,6 +3,7 @@ mod developer;
|
||||
mod news;
|
||||
mod organization;
|
||||
mod provider;
|
||||
mod services;
|
||||
mod user;
|
||||
|
||||
pub use chat::*;
|
||||
@@ -10,4 +11,5 @@ pub use developer::*;
|
||||
pub use news::*;
|
||||
pub use organization::*;
|
||||
pub use provider::*;
|
||||
pub use services::*;
|
||||
pub use user::*;
|
||||
|
||||
43
src/models/services.rs
Normal file
43
src/models/services.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Frontend-facing URLs for developer tool services.
|
||||
///
|
||||
/// Provided as a context signal in `AppShell` so that developer pages
|
||||
/// can read the configured URLs without threading props through layouts.
|
||||
/// An empty string indicates the service is not configured.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ServiceUrlsContext {
|
||||
/// LangGraph agent builder URL (empty if not configured)
|
||||
pub langgraph_url: String,
|
||||
/// LangFlow visual workflow builder URL (empty if not configured)
|
||||
pub langflow_url: String,
|
||||
/// Langfuse observability URL (empty if not configured)
|
||||
pub langfuse_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn default_urls_are_empty() {
|
||||
let ctx = ServiceUrlsContext::default();
|
||||
assert_eq!(ctx.langgraph_url, "");
|
||||
assert_eq!(ctx.langflow_url, "");
|
||||
assert_eq!(ctx.langfuse_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let ctx = ServiceUrlsContext {
|
||||
langgraph_url: "http://localhost:8123".into(),
|
||||
langflow_url: "http://localhost:7860".into(),
|
||||
langfuse_url: "http://localhost:3000".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&ctx).expect("serialize ServiceUrlsContext");
|
||||
let back: ServiceUrlsContext =
|
||||
serde_json::from_str(&json).expect("deserialize ServiceUrlsContext");
|
||||
assert_eq!(ctx, back);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ pub struct AuthInfo {
|
||||
pub avatar_url: String,
|
||||
/// LibreChat instance URL for the sidebar chat link
|
||||
pub librechat_url: String,
|
||||
/// LangGraph agent builder URL (empty if not configured)
|
||||
pub langgraph_url: String,
|
||||
/// LangFlow visual workflow builder URL (empty if not configured)
|
||||
pub langflow_url: String,
|
||||
/// Langfuse observability URL (empty if not configured)
|
||||
pub langfuse_url: String,
|
||||
}
|
||||
|
||||
/// Per-user LLM provider configuration stored in MongoDB.
|
||||
@@ -91,6 +97,9 @@ mod tests {
|
||||
assert_eq!(info.name, "");
|
||||
assert_eq!(info.avatar_url, "");
|
||||
assert_eq!(info.librechat_url, "");
|
||||
assert_eq!(info.langgraph_url, "");
|
||||
assert_eq!(info.langflow_url, "");
|
||||
assert_eq!(info.langfuse_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -102,6 +111,9 @@ mod tests {
|
||||
name: "Test User".into(),
|
||||
avatar_url: "https://example.com/avatar.png".into(),
|
||||
librechat_url: "https://chat.example.com".into(),
|
||||
langgraph_url: "http://localhost:8123".into(),
|
||||
langflow_url: "http://localhost:7860".into(),
|
||||
langfuse_url: "http://localhost:3000".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
|
||||
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
|
||||
|
||||
@@ -1,26 +1,239 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBook, BsBoxArrowUpRight, BsCodeSquare, BsCpu, BsGithub, BsLightningCharge,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::ServiceUrlsContext;
|
||||
|
||||
/// Agents page placeholder for the LangGraph agent builder.
|
||||
/// Agents informational landing page for LangGraph.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with the LangGraph framework.
|
||||
/// Since LangGraph is API-only (no web UI), this page displays a hero section
|
||||
/// explaining its role, a connection status indicator, a card grid linking
|
||||
/// to documentation, and a live table of registered agents fetched from the
|
||||
/// LangGraph assistants API.
|
||||
#[component]
|
||||
pub fn AgentsPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||
let l = *locale.read();
|
||||
let url = svc.read().langgraph_url.clone();
|
||||
|
||||
// Derive whether a LangGraph URL is configured
|
||||
let connected = !url.is_empty();
|
||||
// Build the API reference URL from the configured base, falling back to "#"
|
||||
let api_ref_href = if connected {
|
||||
format!("{}/docs", url)
|
||||
} else {
|
||||
"#".to_string()
|
||||
};
|
||||
|
||||
// Fetch agents from LangGraph when connected
|
||||
let agents_resource = use_resource(move || async move {
|
||||
match crate::infrastructure::langgraph::list_langgraph_agents().await {
|
||||
Ok(agents) => agents,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch agents: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "A" }
|
||||
h2 { "{t(l, \"developer.agents_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.agents_desc\")}"
|
||||
div { class: "agents-page",
|
||||
// -- Hero section --
|
||||
div { class: "agents-hero",
|
||||
div { class: "agents-hero-row",
|
||||
div { class: "agents-hero-icon",
|
||||
Icon { icon: BsCpu, width: 24, height: 24 }
|
||||
}
|
||||
h2 { class: "agents-hero-title",
|
||||
{t(l, "developer.agents_title")}
|
||||
}
|
||||
}
|
||||
p { class: "agents-hero-desc",
|
||||
{t(l, "developer.agents_desc")}
|
||||
}
|
||||
|
||||
// -- Connection status --
|
||||
if connected {
|
||||
div { class: "agents-status",
|
||||
span {
|
||||
class: "agents-status-dot agents-status-dot--on",
|
||||
}
|
||||
span { {t(l, "developer.agents_status_connected")} }
|
||||
code { class: "agents-status-url", {url.clone()} }
|
||||
}
|
||||
} else {
|
||||
div { class: "agents-status",
|
||||
span {
|
||||
class: "agents-status-dot agents-status-dot--off",
|
||||
}
|
||||
span { {t(l, "developer.agents_status_not_connected")} }
|
||||
span { class: "agents-status-hint",
|
||||
{t(l, "developer.agents_config_hint")}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Running Agents table --
|
||||
div { class: "agents-table-section",
|
||||
h3 { class: "agents-section-title",
|
||||
{t(l, "developer.agents_running_title")}
|
||||
}
|
||||
|
||||
match agents_resource.read().as_ref() {
|
||||
None => {
|
||||
rsx! {
|
||||
p { class: "agents-table-loading",
|
||||
{t(l, "common.loading")}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(agents) if agents.is_empty() => {
|
||||
rsx! {
|
||||
p { class: "agents-table-empty",
|
||||
{t(l, "developer.agents_none")}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(agents) => {
|
||||
rsx! {
|
||||
div { class: "agents-table-wrap",
|
||||
table { class: "agents-table",
|
||||
thead {
|
||||
tr {
|
||||
th { {t(l, "developer.agents_col_name")} }
|
||||
th { {t(l, "developer.agents_col_id")} }
|
||||
th { {t(l, "developer.agents_col_description")} }
|
||||
th { {t(l, "developer.agents_col_status")} }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for agent in agents.iter() {
|
||||
tr { key: "{agent.id}",
|
||||
td { class: "agents-cell-name",
|
||||
{agent.name.clone()}
|
||||
}
|
||||
td {
|
||||
code { class: "agents-cell-id",
|
||||
{agent.id.clone()}
|
||||
}
|
||||
}
|
||||
td { class: "agents-cell-desc",
|
||||
if agent.description.is_empty() {
|
||||
span { class: "agents-cell-none", "--" }
|
||||
} else {
|
||||
{agent.description.clone()}
|
||||
}
|
||||
}
|
||||
td {
|
||||
span { class: "agents-badge agents-badge--active",
|
||||
{agent.status.clone()}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Quick Start card grid --
|
||||
h3 { class: "agents-section-title",
|
||||
{t(l, "developer.agents_quick_start")}
|
||||
}
|
||||
|
||||
div { class: "agents-grid",
|
||||
// Documentation
|
||||
a {
|
||||
class: "agents-card",
|
||||
href: "https://langchain-ai.github.io/langgraph/",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsBook, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.agents_docs")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.agents_docs_desc")}
|
||||
}
|
||||
}
|
||||
|
||||
// Getting Started
|
||||
a {
|
||||
class: "agents-card",
|
||||
href: "https://langchain-ai.github.io/langgraph/tutorials/introduction/",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsLightningCharge, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.agents_getting_started")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.agents_getting_started_desc")}
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub
|
||||
a {
|
||||
class: "agents-card",
|
||||
href: "https://github.com/langchain-ai/langgraph",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsGithub, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.agents_github")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.agents_github_desc")}
|
||||
}
|
||||
}
|
||||
|
||||
// Examples
|
||||
a {
|
||||
class: "agents-card",
|
||||
href: "https://github.com/langchain-ai/langgraph/tree/main/examples",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsCodeSquare, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.agents_examples")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.agents_examples_desc")}
|
||||
}
|
||||
}
|
||||
|
||||
// API Reference (disabled when URL is empty)
|
||||
a {
|
||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
||||
href: "{api_ref_href}",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsBoxArrowUpRight, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.agents_api_ref")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.agents_api_ref_desc")}
|
||||
}
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,142 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::AnalyticsMetric;
|
||||
use crate::models::{AnalyticsMetric, ServiceUrlsContext};
|
||||
|
||||
/// Analytics page placeholder for LangFuse integration.
|
||||
/// Analytics & Observability page for Langfuse.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||
/// plus a mock stats bar showing sample metrics.
|
||||
/// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI).
|
||||
/// When users open Langfuse, the existing Keycloak session auto-authenticates
|
||||
/// them transparently. This page shows a metrics bar, connection status,
|
||||
/// and a prominent button to open Langfuse in a new tab.
|
||||
#[component]
|
||||
pub fn AnalyticsPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||
let l = *locale.read();
|
||||
let url = svc.read().langfuse_url.clone();
|
||||
|
||||
let connected = !url.is_empty();
|
||||
let metrics = mock_metrics(l);
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "analytics-page",
|
||||
// -- Hero section --
|
||||
div { class: "analytics-hero",
|
||||
div { class: "analytics-hero-row",
|
||||
div { class: "analytics-hero-icon",
|
||||
Icon { icon: BsGraphUp, width: 24, height: 24 }
|
||||
}
|
||||
h2 { class: "analytics-hero-title",
|
||||
{t(l, "developer.analytics_title")}
|
||||
}
|
||||
}
|
||||
p { class: "analytics-hero-desc",
|
||||
{t(l, "developer.analytics_desc")}
|
||||
}
|
||||
|
||||
// -- Connection status --
|
||||
if connected {
|
||||
div { class: "agents-status",
|
||||
span {
|
||||
class: "agents-status-dot agents-status-dot--on",
|
||||
}
|
||||
span { {t(l, "developer.analytics_status_connected")} }
|
||||
code { class: "agents-status-url", {url.clone()} }
|
||||
}
|
||||
} else {
|
||||
div { class: "agents-status",
|
||||
span {
|
||||
class: "agents-status-dot agents-status-dot--off",
|
||||
}
|
||||
span { {t(l, "developer.analytics_status_not_connected")} }
|
||||
span { class: "agents-status-hint",
|
||||
{t(l, "developer.analytics_config_hint")}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- SSO info --
|
||||
if connected {
|
||||
p { class: "analytics-sso-hint",
|
||||
{t(l, "developer.analytics_sso_hint")}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Metrics bar --
|
||||
div { class: "analytics-stats-bar",
|
||||
for metric in &metrics {
|
||||
div { class: "analytics-stat",
|
||||
span { class: "analytics-stat-value", "{metric.value}" }
|
||||
span { class: "analytics-stat-label", "{metric.label}" }
|
||||
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
||||
span {
|
||||
class: if metric.change_pct >= 0.0 {
|
||||
"analytics-stat-change analytics-stat-change--up"
|
||||
} else {
|
||||
"analytics-stat-change analytics-stat-change--down"
|
||||
},
|
||||
"{metric.change_pct:+.1}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "L" }
|
||||
h2 { "{t(l, \"developer.analytics_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.analytics_desc\")}"
|
||||
|
||||
// -- Open Langfuse button --
|
||||
if connected {
|
||||
a {
|
||||
class: "analytics-launch-btn",
|
||||
href: "{url}",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
Icon { icon: BsBoxArrowUpRight, width: 16, height: 16 }
|
||||
span { {t(l, "developer.launch_analytics")} }
|
||||
}
|
||||
}
|
||||
|
||||
// -- Quick actions --
|
||||
h3 { class: "agents-section-title",
|
||||
{t(l, "developer.analytics_quick_actions")}
|
||||
}
|
||||
|
||||
div { class: "agents-grid",
|
||||
// Traces
|
||||
a {
|
||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
||||
href: if connected { format!("{url}/project") } else { "#".to_string() },
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsBarChartLine, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.analytics_traces")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.analytics_traces_desc")}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
a {
|
||||
class: if connected { "agents-card" } else { "agents-card agents-card--disabled" },
|
||||
href: if connected { format!("{url}/project") } else { "#".to_string() },
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
div { class: "agents-card-icon",
|
||||
Icon { icon: BsSpeedometer, width: 18, height: 18 }
|
||||
}
|
||||
div { class: "agents-card-title",
|
||||
{t(l, "developer.analytics_dashboard")}
|
||||
}
|
||||
div { class: "agents-card-desc",
|
||||
{t(l, "developer.analytics_dashboard_desc")}
|
||||
}
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ToolEmbed;
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::models::ServiceUrlsContext;
|
||||
|
||||
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||
/// Flow page embedding the LangFlow visual workflow builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with LangFlow for visual flow design.
|
||||
/// When `langflow_url` is configured, embeds the service in an iframe
|
||||
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
|
||||
#[component]
|
||||
pub fn FlowPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||
let l = *locale.read();
|
||||
let url = svc.read().langflow_url.clone();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "F" }
|
||||
h2 { "{t(l, \"developer.flow_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.flow_desc\")}"
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
ToolEmbed {
|
||||
url,
|
||||
title: t(l, "developer.flow_title"),
|
||||
description: t(l, "developer.flow_desc"),
|
||||
icon: "F",
|
||||
launch_label: t(l, "developer.launch_flow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user