diff --git a/.env.example b/.env.example index 6182d8f..f5a8f34 100644 --- a/.env.example +++ b/.env.example @@ -66,10 +66,11 @@ STRIPE_WEBHOOK_SECRET= STRIPE_PUBLISHABLE_KEY= # --------------------------------------------------------------------------- -# LangChain / LangGraph / Langfuse [OPTIONAL] +# LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL] # --------------------------------------------------------------------------- LANGCHAIN_URL= LANGGRAPH_URL= +LANGFLOW_URL= LANGFUSE_URL= # --------------------------------------------------------------------------- diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2f47959..08cf528 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -154,6 +154,9 @@ jobs: MONGODB_URI: mongodb://root:example@mongo:27017 MONGODB_DATABASE: certifai SEARXNG_URL: http://searxng:8080 + LANGGRAPH_URL: "" + LANGFLOW_URL: "" + LANGFUSE_URL: "" steps: - name: Checkout run: | diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 515c528..46dc084 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -96,7 +96,9 @@ "total_requests": "Anfragen gesamt", "avg_latency": "Durchschn. Latenz", "tokens_used": "Verbrauchte Token", - "error_rate": "Fehlerrate" + "error_rate": "Fehlerrate", + "not_configured": "Nicht konfiguriert", + "open_new_tab": "In neuem Tab oeffnen" }, "org": { "title": "Organisation", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 774f1fa..c4e84cf 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -96,7 +96,9 @@ "total_requests": "Total Requests", "avg_latency": "Avg Latency", "tokens_used": "Tokens Used", - "error_rate": "Error Rate" + "error_rate": "Error Rate", + "not_configured": "Not Configured", + "open_new_tab": "Open in New Tab" }, "org": { "title": "Organization", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 6a0a4b1..51c98ce 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -96,7 +96,9 @@ "total_requests": "Total de solicitudes", "avg_latency": "Latencia promedio", "tokens_used": "Tokens utilizados", - "error_rate": "Tasa de errores" + "error_rate": "Tasa de errores", + "not_configured": "No configurado", + "open_new_tab": "Abrir en nueva pestana" }, "org": { "title": "Organizacion", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 9ab76f1..1d3845b 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -96,7 +96,9 @@ "total_requests": "Requetes totales", "avg_latency": "Latence moyenne", "tokens_used": "Tokens utilises", - "error_rate": "Taux d'erreur" + "error_rate": "Taux d'erreur", + "not_configured": "Non configure", + "open_new_tab": "Ouvrir dans un nouvel onglet" }, "org": { "title": "Organisation", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 1d4e7d4..fe0cb2a 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -96,7 +96,9 @@ "total_requests": "Total de Pedidos", "avg_latency": "Latencia Media", "tokens_used": "Tokens Utilizados", - "error_rate": "Taxa de Erros" + "error_rate": "Taxa de Erros", + "not_configured": "Nao configurado", + "open_new_tab": "Abrir em novo separador" }, "org": { "title": "Organizacao", diff --git a/assets/main.css b/assets/main.css index 1ed9d8a..c551e78 100644 --- a/assets/main.css +++ b/assets/main.css @@ -2591,6 +2591,58 @@ h6 { border-radius: 20px; } +/* ===== Tool Embed (iframe integration) ===== */ +.tool-embed { + display: flex; + flex-direction: column; + flex: 1; + height: calc(100vh - 60px); + min-height: 400px; +} + +.tool-embed-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background-color: var(--bg-card); + border-bottom: 1px solid var(--border-primary); +} + +.tool-embed-title { + font-family: 'Space Grotesk', sans-serif; + font-size: 16px; + font-weight: 600; + color: var(--text-heading); +} + +.tool-embed-popout-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: var(--accent); + background-color: transparent; + border: 1px solid var(--accent); + border-radius: 6px; + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.tool-embed-popout-btn:hover { + background-color: var(--accent); + color: var(--bg-body); +} + +.tool-embed-iframe { + flex: 1; + width: 100%; + border: none; +} + /* ===== Analytics Stats Bar ===== */ .analytics-stats-bar { display: flex; diff --git a/docker-compose.yml b/docker-compose.yml index 1d8b2ef..4a850ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,5 +94,155 @@ services: - ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro - librechat-data:/app/data + langflow: + image: langflowai/langflow:latest + container_name: certifai-langflow + restart: unless-stopped + ports: + - "7860:7860" + environment: + LANGFLOW_AUTO_LOGIN: "true" + + langgraph: + image: langchain/langgraph-trial:3.12 + container_name: certifai-langgraph + restart: unless-stopped + depends_on: + langgraph-db: + condition: service_started + langgraph-redis: + condition: service_started + ports: + - "8123:8000" + environment: + DATABASE_URI: postgresql://langgraph:langgraph@langgraph-db:5432/langgraph + REDIS_URI: redis://langgraph-redis:6379 + + langgraph-db: + image: postgres:16 + container_name: certifai-langgraph-db + restart: unless-stopped + environment: + POSTGRES_USER: langgraph + POSTGRES_PASSWORD: langgraph + POSTGRES_DB: langgraph + volumes: + - langgraph-db-data:/var/lib/postgresql/data + + langgraph-redis: + image: redis:7-alpine + container_name: certifai-langgraph-redis + restart: unless-stopped + + langfuse: + image: langfuse/langfuse:3 + container_name: certifai-langfuse + restart: unless-stopped + depends_on: + langfuse-db: + condition: service_healthy + langfuse-clickhouse: + condition: service_healthy + langfuse-redis: + condition: service_healthy + langfuse-minio: + condition: service_healthy + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: certifai-langfuse-dev-secret + SALT: certifai-langfuse-dev-salt + ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + CLICKHOUSE_URL: http://langfuse-clickhouse:8123 + CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000 + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + CLICKHOUSE_CLUSTER_ENABLED: "false" + REDIS_HOST: langfuse-redis + REDIS_PORT: "6379" + REDIS_AUTH: langfuse-dev-redis + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_EVENT_UPLOAD_REGION: auto + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000 + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000 + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + + langfuse-db: + image: postgres:16 + container_name: certifai-langfuse-db + restart: unless-stopped + environment: + POSTGRES_USER: langfuse + POSTGRES_PASSWORD: langfuse + POSTGRES_DB: langfuse + volumes: + - langfuse-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U langfuse"] + interval: 5s + timeout: 5s + retries: 10 + + langfuse-clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: certifai-langfuse-clickhouse + restart: unless-stopped + user: "101:101" + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - langfuse-clickhouse-data:/var/lib/clickhouse + - langfuse-clickhouse-logs:/var/log/clickhouse-server + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + + langfuse-redis: + image: redis:7-alpine + container_name: certifai-langfuse-redis + restart: unless-stopped + command: redis-server --requirepass langfuse-dev-redis + healthcheck: + test: ["CMD", "redis-cli", "-a", "langfuse-dev-redis", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + langfuse-minio: + image: cgr.dev/chainguard/minio + container_name: certifai-langfuse-minio + restart: unless-stopped + entrypoint: sh + command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data' + environment: + MINIO_ROOT_USER: minio + MINIO_ROOT_PASSWORD: miniosecret + healthcheck: + test: ["CMD-SHELL", "mc ready local || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + volumes: librechat-data: + langgraph-db-data: + langfuse-db-data: + langfuse-clickhouse-data: + langfuse-clickhouse-logs: diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts index 9d84e30..b64d426 100644 --- a/e2e/developer.spec.ts +++ b/e2e/developer.spec.ts @@ -11,23 +11,50 @@ test.describe("Developer section", () => { await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); }); - test("agents page shows Coming Soon badge", async ({ page }) => { + test("agents page shows Not Configured when URL is empty", async ({ + page, + }) => { await page.goto("/developer/agents"); await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); await expect(page.locator(".placeholder-badge")).toContainText( - "Coming Soon" + "Not Configured" ); await expect(page.locator("h2")).toContainText("Agent Builder"); }); - test("analytics page loads via sub-nav", async ({ page }) => { + test("analytics page shows Not Configured when URL is empty", async ({ + page, + }) => { await page.goto("/developer/analytics"); await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); - await expect(page.locator("h2")).toContainText("Analytics"); + await expect( + page.locator("h2", { hasText: "Analytics" }) + ).toBeVisible(); await expect(page.locator(".placeholder-badge")).toContainText( - "Coming Soon" + "Not Configured" ); }); + + test("agents page shows iframe when URL is configured", async ({ + page, + }) => { + // This test only runs meaningfully when LANGGRAPH_URL is set in the + // environment. When empty, the placeholder is shown instead. + await page.goto("/developer/agents"); + await page.waitForTimeout(2000); + + const iframe = page.locator(".tool-embed-iframe"); + const placeholder = page.locator(".placeholder-badge"); + + if (await placeholder.isVisible()) { + await expect(placeholder).toContainText("Not Configured"); + } else { + await expect(iframe).toBeVisible(); + await expect( + page.locator(".tool-embed-popout-btn") + ).toBeVisible(); + } + }); }); diff --git a/src/components/app_shell.rs b/src/components/app_shell.rs index 37e084b..2eb96aa 100644 --- a/src/components/app_shell.rs +++ b/src/components/app_shell.rs @@ -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" diff --git a/src/components/mod.rs b/src/components/mod.rs index 614a89b..7188c2c 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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::*; diff --git a/src/components/tool_embed.rs b/src/components/tool_embed.rs new file mode 100644 index 0000000..6bfc119 --- /dev/null +++ b/src/components/tool_embed.rs @@ -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::>(); + 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}", + } + } + } + } +} diff --git a/src/infrastructure/auth_check.rs b/src/infrastructure/auth_check.rs index 6bbb8d8..009ec52 100644 --- a/src/infrastructure/auth_check.rs +++ b/src/infrastructure/auth_check.rs @@ -27,6 +27,15 @@ pub async fn check_auth() -> Result { 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 { name: u.user.name, avatar_url: u.user.avatar_url, librechat_url, + langgraph_url, + langflow_url, + langfuse_url, }) } None => Ok(AuthInfo::default()), diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index 3ce3ac5..23128fc 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -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"), diff --git a/src/models/mod.rs b/src/models/mod.rs index 933d0be..c9f5be8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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::*; diff --git a/src/models/services.rs b/src/models/services.rs new file mode 100644 index 0000000..7d1e599 --- /dev/null +++ b/src/models/services.rs @@ -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); + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 5bbc8f9..cbab583 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -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"); diff --git a/src/pages/developer/agents.rs b/src/pages/developer/agents.rs index 1396e4b..0cf4653 100644 --- a/src/pages/developer/agents.rs +++ b/src/pages/developer/agents.rs @@ -1,27 +1,27 @@ use dioxus::prelude::*; +use crate::components::ToolEmbed; use crate::i18n::{t, Locale}; +use crate::models::ServiceUrlsContext; -/// Agents page placeholder for the LangGraph agent builder. +/// Agents page embedding the LangGraph agent builder. /// -/// Shows a "Coming Soon" card with a disabled launch button. -/// Will eventually integrate with the LangGraph framework. +/// When `langgraph_url` is configured, embeds the service in an iframe +/// with a pop-out button. Otherwise shows a "Not Configured" placeholder. #[component] pub fn AgentsPage() -> Element { let locale = use_context::>(); + let svc = use_context::>(); let l = *locale.read(); + let url = svc.read().langgraph_url.clone(); 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\")}" - } - button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" } - span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } - } + ToolEmbed { + url, + title: t(l, "developer.agents_title"), + description: t(l, "developer.agents_desc"), + icon: "A", + launch_label: t(l, "developer.launch_agents"), } } } diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs index b04883d..bdde149 100644 --- a/src/pages/developer/analytics.rs +++ b/src/pages/developer/analytics.rs @@ -1,16 +1,20 @@ use dioxus::prelude::*; +use crate::components::ToolEmbed; use crate::i18n::{t, Locale}; -use crate::models::AnalyticsMetric; +use crate::models::{AnalyticsMetric, ServiceUrlsContext}; -/// Analytics page placeholder for LangFuse integration. +/// Analytics page embedding Langfuse for observability. /// -/// Shows a "Coming Soon" card with a disabled launch button, -/// plus a mock stats bar showing sample metrics. +/// Always shows a stats bar with sample metrics. Below that, when +/// `langfuse_url` is configured, embeds the service in an iframe +/// with a pop-out button. Otherwise shows a "Not Configured" placeholder. #[component] pub fn AnalyticsPage() -> Element { let locale = use_context::>(); + let svc = use_context::>(); let l = *locale.read(); + let url = svc.read().langfuse_url.clone(); let metrics = mock_metrics(l); @@ -21,21 +25,24 @@ pub fn AnalyticsPage() -> Element { 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\")}" - } - button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" } - span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } - } + } + ToolEmbed { + url, + title: t(l, "developer.analytics_title"), + description: t(l, "developer.analytics_desc"), + icon: "L", + launch_label: t(l, "developer.launch_analytics"), } } } diff --git a/src/pages/developer/flow.rs b/src/pages/developer/flow.rs index 0f95496..f252116 100644 --- a/src/pages/developer/flow.rs +++ b/src/pages/developer/flow.rs @@ -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::>(); + let svc = use_context::>(); 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"), } } }