From d36f282f78e2d6c6c1703c05da097f00acad4ee5 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 23 Feb 2026 21:51:46 +0100 Subject: [PATCH 1/8] fix(librechat): remove prompt=none for local dev compatibility prompt=none causes silent failure when no Keycloak session exists yet. Standard OIDC flow still provides seamless SSO when the user has an active Keycloak session from the dashboard. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1d8b2ef..d13949e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,7 +70,6 @@ services: OPENID_CALLBACK_URL: /oauth/openid/callback OPENID_SCOPE: openid profile email OPENID_BUTTON_LABEL: Login with CERTifAI - OPENID_AUTH_EXTRA_PARAMS: prompt=none # Disable local auth (SSO only) ALLOW_EMAIL_LOGIN: "false" ALLOW_REGISTRATION: "false" -- 2.49.1 From d9401d4be5efb0acffdc7a7575570b0f15538c16 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 23 Feb 2026 22:27:07 +0100 Subject: [PATCH 2/8] feat(librechat): add OIDC HTTP patch and prompt=none for seamless SSO Switch to host networking so LibreChat can reach Keycloak on localhost. Patch openidStrategy.js to allow HTTP OIDC issuers for local dev (openid-client v6 enforces HTTPS by default). Add support for OPENID_AUTH_EXTRA_PARAMS env var and set prompt=none for automatic SSO login when a Keycloak session exists. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index d13949e..1d8b2ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,7 @@ services: OPENID_CALLBACK_URL: /oauth/openid/callback OPENID_SCOPE: openid profile email OPENID_BUTTON_LABEL: Login with CERTifAI + OPENID_AUTH_EXTRA_PARAMS: prompt=none # Disable local auth (SSO only) ALLOW_EMAIL_LOGIN: "false" ALLOW_REGISTRATION: "false" -- 2.49.1 From a24ea984b19a043cef9be73a43aa743ff485d888 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 17:49:56 +0100 Subject: [PATCH 3/8] feat(developer): add hybrid iframe integration for developer tools Replace placeholder pages with ToolEmbed component that embeds LangGraph, LangFlow, and Langfuse in iframes when configured, or shows "Not Configured" placeholders when URLs are empty. Add ServiceUrlsContext for passing service URLs through Dioxus context. Add docker-compose services for local development: LangFlow, LangGraph (trial), Langfuse with full dependency stack (Postgres, ClickHouse, Redis, MinIO). Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 +- .gitea/workflows/ci.yml | 3 + assets/i18n/de.json | 4 +- assets/i18n/en.json | 4 +- assets/i18n/es.json | 4 +- assets/i18n/fr.json | 4 +- assets/i18n/pt.json | 4 +- assets/main.css | 52 +++++++++++ docker-compose.yml | 150 +++++++++++++++++++++++++++++++ e2e/developer.spec.ts | 37 ++++++-- src/components/app_shell.rs | 12 ++- src/components/mod.rs | 2 + src/components/tool_embed.rs | 81 +++++++++++++++++ src/infrastructure/auth_check.rs | 12 +++ src/infrastructure/config.rs | 3 + src/models/mod.rs | 2 + src/models/services.rs | 43 +++++++++ src/models/user.rs | 12 +++ src/pages/developer/agents.rs | 26 +++--- src/pages/developer/analytics.rs | 35 +++++--- src/pages/developer/flow.rs | 26 +++--- 21 files changed, 467 insertions(+), 52 deletions(-) create mode 100644 src/components/tool_embed.rs create mode 100644 src/models/services.rs 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"), } } } -- 2.49.1 From d40690b7a7c1da8c31c9cccdc36c7a74dd19159b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 18:37:33 +0100 Subject: [PATCH 4/8] feat(developer): replace agents iframe with informational landing and live agent table LangGraph is API-only with no web UI, so the ToolEmbed iframe pattern doesn't work. Replace it with an informational landing page featuring a hero section, connection status indicator, quick-start card grid linking to docs/GitHub/examples, and a live table of registered agents fetched from the LangGraph POST /assistants/search endpoint. Co-Authored-By: Claude Opus 4.6 --- assets/i18n/de.json | 22 ++- assets/i18n/en.json | 22 ++- assets/i18n/es.json | 22 ++- assets/i18n/fr.json | 22 ++- assets/i18n/pt.json | 22 ++- assets/main.css | 247 ++++++++++++++++++++++++++++++++ src/infrastructure/langgraph.rs | 107 ++++++++++++++ src/infrastructure/mod.rs | 1 + src/pages/developer/agents.rs | 217 ++++++++++++++++++++++++++-- 9 files changed, 667 insertions(+), 15 deletions(-) create mode 100644 src/infrastructure/langgraph.rs diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 46dc084..b0b1029 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -98,7 +98,27 @@ "tokens_used": "Verbrauchte Token", "error_rate": "Fehlerrate", "not_configured": "Nicht konfiguriert", - "open_new_tab": "In neuem Tab oeffnen" + "open_new_tab": "In neuem Tab oeffnen", + "agents_status_connected": "Verbunden", + "agents_status_not_connected": "Nicht verbunden", + "agents_config_hint": "Setzen Sie LANGGRAPH_URL in .env, um eine Verbindung herzustellen", + "agents_quick_start": "Schnellstart", + "agents_docs": "Dokumentation", + "agents_docs_desc": "Offizielle LangGraph-Dokumentation und API-Anleitungen.", + "agents_getting_started": "Erste Schritte", + "agents_getting_started_desc": "Schritt-fuer-Schritt-Anleitung zum Erstellen Ihres ersten Agenten.", + "agents_github": "GitHub", + "agents_github_desc": "Quellcode, Issues und Community-Beitraege.", + "agents_examples": "Beispiele", + "agents_examples_desc": "Einsatzbereite Vorlagen und Beispielprojekte fuer Agenten.", + "agents_api_ref": "API-Referenz", + "agents_api_ref_desc": "Lokale Swagger-Dokumentation fuer Ihre LangGraph-Instanz.", + "agents_running_title": "Laufende Agenten", + "agents_none": "Keine Agenten registriert. Stellen Sie einen Assistenten in LangGraph bereit, um ihn hier zu sehen.", + "agents_col_name": "Name", + "agents_col_id": "ID", + "agents_col_description": "Beschreibung", + "agents_col_status": "Status" }, "org": { "title": "Organisation", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index c4e84cf..a6a3070 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -98,7 +98,27 @@ "tokens_used": "Tokens Used", "error_rate": "Error Rate", "not_configured": "Not Configured", - "open_new_tab": "Open in New Tab" + "open_new_tab": "Open in New Tab", + "agents_status_connected": "Connected", + "agents_status_not_connected": "Not Connected", + "agents_config_hint": "Set LANGGRAPH_URL in .env to connect", + "agents_quick_start": "Quick Start", + "agents_docs": "Documentation", + "agents_docs_desc": "Official LangGraph documentation and API guides.", + "agents_getting_started": "Getting Started", + "agents_getting_started_desc": "Step-by-step tutorial to build your first agent.", + "agents_github": "GitHub", + "agents_github_desc": "Source code, issues, and community contributions.", + "agents_examples": "Examples", + "agents_examples_desc": "Ready-to-use templates and example agent projects.", + "agents_api_ref": "API Reference", + "agents_api_ref_desc": "Local Swagger docs for your LangGraph instance.", + "agents_running_title": "Running Agents", + "agents_none": "No agents registered. Deploy an assistant to LangGraph to see it here.", + "agents_col_name": "Name", + "agents_col_id": "ID", + "agents_col_description": "Description", + "agents_col_status": "Status" }, "org": { "title": "Organization", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 51c98ce..9017b39 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -98,7 +98,27 @@ "tokens_used": "Tokens utilizados", "error_rate": "Tasa de errores", "not_configured": "No configurado", - "open_new_tab": "Abrir en nueva pestana" + "open_new_tab": "Abrir en nueva pestana", + "agents_status_connected": "Conectado", + "agents_status_not_connected": "No conectado", + "agents_config_hint": "Configure LANGGRAPH_URL en .env para conectar", + "agents_quick_start": "Inicio rapido", + "agents_docs": "Documentacion", + "agents_docs_desc": "Documentacion oficial de LangGraph y guias de API.", + "agents_getting_started": "Primeros pasos", + "agents_getting_started_desc": "Tutorial paso a paso para crear su primer agente.", + "agents_github": "GitHub", + "agents_github_desc": "Codigo fuente, issues y contribuciones de la comunidad.", + "agents_examples": "Ejemplos", + "agents_examples_desc": "Plantillas y proyectos de agentes listos para usar.", + "agents_api_ref": "Referencia API", + "agents_api_ref_desc": "Documentacion Swagger local para su instancia de LangGraph.", + "agents_running_title": "Agentes en ejecucion", + "agents_none": "No hay agentes registrados. Despliegue un asistente en LangGraph para verlo aqui.", + "agents_col_name": "Nombre", + "agents_col_id": "ID", + "agents_col_description": "Descripcion", + "agents_col_status": "Estado" }, "org": { "title": "Organizacion", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 1d3845b..eb5e41b 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -98,7 +98,27 @@ "tokens_used": "Tokens utilises", "error_rate": "Taux d'erreur", "not_configured": "Non configure", - "open_new_tab": "Ouvrir dans un nouvel onglet" + "open_new_tab": "Ouvrir dans un nouvel onglet", + "agents_status_connected": "Connecte", + "agents_status_not_connected": "Non connecte", + "agents_config_hint": "Definissez LANGGRAPH_URL dans .env pour vous connecter", + "agents_quick_start": "Demarrage rapide", + "agents_docs": "Documentation", + "agents_docs_desc": "Documentation officielle de LangGraph et guides API.", + "agents_getting_started": "Premiers pas", + "agents_getting_started_desc": "Tutoriel etape par etape pour creer votre premier agent.", + "agents_github": "GitHub", + "agents_github_desc": "Code source, issues et contributions de la communaute.", + "agents_examples": "Exemples", + "agents_examples_desc": "Modeles et projets d'agents prets a l'emploi.", + "agents_api_ref": "Reference API", + "agents_api_ref_desc": "Documentation Swagger locale pour votre instance LangGraph.", + "agents_running_title": "Agents en cours", + "agents_none": "Aucun agent enregistre. Deployez un assistant dans LangGraph pour le voir ici.", + "agents_col_name": "Nom", + "agents_col_id": "ID", + "agents_col_description": "Description", + "agents_col_status": "Statut" }, "org": { "title": "Organisation", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index fe0cb2a..c83d8a5 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -98,7 +98,27 @@ "tokens_used": "Tokens Utilizados", "error_rate": "Taxa de Erros", "not_configured": "Nao configurado", - "open_new_tab": "Abrir em novo separador" + "open_new_tab": "Abrir em novo separador", + "agents_status_connected": "Conectado", + "agents_status_not_connected": "Nao conectado", + "agents_config_hint": "Defina LANGGRAPH_URL no .env para conectar", + "agents_quick_start": "Inicio rapido", + "agents_docs": "Documentacao", + "agents_docs_desc": "Documentacao oficial do LangGraph e guias de API.", + "agents_getting_started": "Primeiros passos", + "agents_getting_started_desc": "Tutorial passo a passo para criar o seu primeiro agente.", + "agents_github": "GitHub", + "agents_github_desc": "Codigo fonte, issues e contribuicoes da comunidade.", + "agents_examples": "Exemplos", + "agents_examples_desc": "Modelos e projetos de agentes prontos a usar.", + "agents_api_ref": "Referencia API", + "agents_api_ref_desc": "Documentacao Swagger local para a sua instancia LangGraph.", + "agents_running_title": "Agentes em execucao", + "agents_none": "Nenhum agente registado. Implemente um assistente no LangGraph para o ver aqui.", + "agents_col_name": "Nome", + "agents_col_id": "ID", + "agents_col_description": "Descricao", + "agents_col_status": "Estado" }, "org": { "title": "Organizacao", diff --git a/assets/main.css b/assets/main.css index c551e78..fe26bcb 100644 --- a/assets/main.css +++ b/assets/main.css @@ -3374,4 +3374,251 @@ h6 { .feature-card { padding: 20px 16px; } +} + +/* ===== Agents Page ===== */ +.agents-page { + display: flex; + flex-direction: column; + padding: 32px; + gap: 32px; +} + +.agents-hero { + max-width: 720px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.agents-hero-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} + +.agents-hero-icon { + width: 48px; + height: 48px; + min-width: 48px; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); + border-radius: 12px; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.agents-hero-title { + font-family: 'Space Grotesk', sans-serif; + font-size: 28px; + font-weight: 700; + color: var(--text-heading); + margin: 0; +} + +.agents-hero-desc { + font-size: 15px; + color: var(--text-muted); + line-height: 1.6; + max-width: 600px; + margin: 0; +} + +.agents-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.agents-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.agents-status-dot--on { + background-color: #22c55e; +} + +.agents-status-dot--off { + background-color: var(--text-faint); +} + +.agents-status-url { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--accent); + font-size: 13px; +} + +.agents-status-hint { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} + +.agents-section-title { + font-size: 18px; + font-weight: 600; + color: var(--text-heading); + margin: 0 0 12px 0; +} + +.agents-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.agents-card { + display: block; + text-decoration: none; + background-color: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: 12px; + padding: 24px; + transition: border-color 0.2s, transform 0.2s; + cursor: pointer; +} + +.agents-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.agents-card-icon { + width: 36px; + height: 36px; + min-width: 36px; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); + border-radius: 8px; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.agents-card-title { + font-size: 16px; + font-weight: 600; + color: var(--text-heading); + margin: 12px 0 4px; +} + +.agents-card-desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} + +.agents-card--disabled { + opacity: 0.4; + pointer-events: none; + cursor: default; +} + +/* -- Agents table -- */ +.agents-table-section { + max-width: 960px; +} + +.agents-table-wrap { + overflow-x: auto; +} + +.agents-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.agents-table thead th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 12px; + border-bottom: 1px solid var(--border-secondary); +} + +.agents-table tbody td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-primary); + color: var(--text-primary); + vertical-align: middle; +} + +.agents-table tbody tr:hover { + background-color: var(--bg-surface); +} + +.agents-cell-name { + font-weight: 600; + color: var(--text-heading); + white-space: nowrap; +} + +.agents-cell-id { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + color: var(--text-muted); +} + +.agents-cell-desc { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); +} + +.agents-cell-none { + color: var(--text-faint); +} + +.agents-badge { + display: inline-block; + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 9999px; +} + +.agents-badge--active { + background-color: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.agents-table-loading, +.agents-table-empty { + font-size: 14px; + color: var(--text-faint); + font-style: italic; + padding: 16px 0; +} + +@media (max-width: 768px) { + .agents-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .agents-page { + padding: 20px 16px; + } + + .agents-grid { + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/src/infrastructure/langgraph.rs b/src/infrastructure/langgraph.rs new file mode 100644 index 0000000..3d6147d --- /dev/null +++ b/src/infrastructure/langgraph.rs @@ -0,0 +1,107 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +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 /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, 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 = 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) +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 8a96c2f..c18bf52 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -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; diff --git a/src/pages/developer/agents.rs b/src/pages/developer/agents.rs index 0cf4653..8717a71 100644 --- a/src/pages/developer/agents.rs +++ b/src/pages/developer/agents.rs @@ -1,13 +1,14 @@ use dioxus::prelude::*; -use crate::components::ToolEmbed; use crate::i18n::{t, Locale}; use crate::models::ServiceUrlsContext; -/// Agents page embedding the LangGraph agent builder. +/// Agents informational landing page for LangGraph. /// -/// When `langgraph_url` is configured, embeds the service in an iframe -/// with a pop-out button. Otherwise shows a "Not Configured" placeholder. +/// 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::>(); @@ -15,13 +16,209 @@ pub fn AgentsPage() -> Element { 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! { - ToolEmbed { - url, - title: t(l, "developer.agents_title"), - description: t(l, "developer.agents_desc"), - icon: "A", - launch_label: t(l, "developer.launch_agents"), + div { class: "agents-page", + // -- Hero section -- + div { class: "agents-hero", + div { class: "agents-hero-row", + div { class: "agents-hero-icon placeholder-icon", "A" } + 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 placeholder-icon", "D" } + 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 placeholder-icon", "G" } + 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 placeholder-icon", "H" } + 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 placeholder-icon", "E" } + 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 placeholder-icon", "R" } + div { class: "agents-card-title", + {t(l, "developer.agents_api_ref")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_api_ref_desc")} + } + } + } } } } -- 2.49.1 From 97d75ada2c0645a4196a462dcc9f77b0a970ff68 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 20:35:44 +0100 Subject: [PATCH 5/8] test(e2e): update developer agents page tests for informational landing Replace obsolete iframe/placeholder tests with new tests covering the hero section, connection status, quick-start card grid, disabled API Reference card, and running agents table section. Co-Authored-By: Claude Opus 4.6 --- e2e/developer.spec.ts | 131 +++++++++++++++++++++++++++++++++--------- 1 file changed, 105 insertions(+), 26 deletions(-) diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts index b64d426..9af65ff 100644 --- a/e2e/developer.spec.ts +++ b/e2e/developer.spec.ts @@ -11,16 +11,116 @@ test.describe("Developer section", () => { await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); }); - test("agents page shows Not Configured when URL is empty", async ({ + test("agents page renders informational landing", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); + + // Hero section + await expect(page.locator(".agents-hero-title")).toContainText( + "Agent Builder" + ); + await expect(page.locator(".agents-hero-desc")).toBeVisible(); + + // Connection status indicator is present + await expect(page.locator(".agents-status")).toBeVisible(); + }); + + test("agents page shows Not Connected when URL is empty", async ({ page, }) => { await page.goto("/developer/agents"); - await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); - await expect(page.locator(".placeholder-badge")).toContainText( - "Not Configured" + await expect(page.locator(".agents-status")).toContainText( + "Not Connected" ); - await expect(page.locator("h2")).toContainText("Agent Builder"); + await expect(page.locator(".agents-status-dot--off")).toBeVisible(); + await expect(page.locator(".agents-status-hint")).toBeVisible(); + }); + + test("agents page shows quick start cards", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); + + const grid = page.locator(".agents-grid"); + const cards = grid.locator(".agents-card"); + await expect(cards).toHaveCount(5); + + // Verify card titles are rendered + await expect( + grid.locator(".agents-card-title", { hasText: "Documentation" }) + ).toBeVisible(); + await expect( + grid.locator(".agents-card-title", { hasText: "Getting Started" }) + ).toBeVisible(); + await expect( + grid.locator(".agents-card-title", { hasText: "GitHub" }) + ).toBeVisible(); + await expect( + grid.locator(".agents-card-title", { hasText: "Examples" }) + ).toBeVisible(); + await expect( + grid.locator(".agents-card-title", { hasText: "API Reference" }) + ).toBeVisible(); + }); + + test("agents page disables API Reference card when not connected", async ({ + page, + }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); + + // When LANGGRAPH_URL is empty, the API Reference card should be disabled + const statusHint = page.locator(".agents-status-hint"); + if (await statusHint.isVisible()) { + const apiCard = page.locator(".agents-card--disabled"); + await expect(apiCard).toBeVisible(); + await expect( + apiCard.locator(".agents-card-title") + ).toContainText("API Reference"); + } + }); + + test("agents page shows running agents section", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); + + // The running agents section title should always be visible + await expect( + page.locator(".agents-section-title", { hasText: "Running Agents" }) + ).toBeVisible(); + + // Either the table, loading state, or empty message should appear + await page.waitForTimeout(3000); + const table = page.locator(".agents-table"); + const empty = page.locator(".agents-table-empty"); + + const hasTable = await table.isVisible(); + const hasEmpty = await empty.isVisible(); + expect(hasTable || hasEmpty).toBeTruthy(); + }); + + test("agents page shows connected status when URL is configured", async ({ + page, + }) => { + // This test only passes when LANGGRAPH_URL is set in the environment. + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); + + const connectedDot = page.locator(".agents-status-dot--on"); + const disconnectedDot = page.locator(".agents-status-dot--off"); + + if (await connectedDot.isVisible()) { + await expect(page.locator(".agents-status")).toContainText("Connected"); + await expect(page.locator(".agents-status-url")).toBeVisible(); + // API Reference card should NOT be disabled + await expect(page.locator(".agents-card--disabled")).toHaveCount(0); + } else { + await expect(disconnectedDot).toBeVisible(); + await expect(page.locator(".agents-status")).toContainText( + "Not Connected" + ); + } }); test("analytics page shows Not Configured when URL is empty", async ({ @@ -36,25 +136,4 @@ test.describe("Developer section", () => { "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(); - } - }); }); -- 2.49.1 From c97bacbfe2d496cd5d418990c27463533cda89eb Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 20:37:58 +0100 Subject: [PATCH 6/8] feat(developer): replace placeholder letters with Bootstrap icons on agents page Use dioxus_free_icons Bootstrap icons: BsCpu for hero, BsBook for docs, BsLightningCharge for getting started, BsGithub for GitHub, BsCodeSquare for examples, BsBoxArrowUpRight for API reference. Also fix conditional serde import for server feature. Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/langgraph.rs | 3 ++- src/pages/developer/agents.rs | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/langgraph.rs b/src/infrastructure/langgraph.rs index 3d6147d..1c956cd 100644 --- a/src/infrastructure/langgraph.rs +++ b/src/infrastructure/langgraph.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; -use serde::{Deserialize, Serialize}; +#[cfg(feature = "server")] +use serde::Deserialize; use crate::models::AgentEntry; diff --git a/src/pages/developer/agents.rs b/src/pages/developer/agents.rs index 8717a71..fa144fd 100644 --- a/src/pages/developer/agents.rs +++ b/src/pages/developer/agents.rs @@ -1,4 +1,8 @@ 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; @@ -41,7 +45,9 @@ pub fn AgentsPage() -> Element { // -- Hero section -- div { class: "agents-hero", div { class: "agents-hero-row", - div { class: "agents-hero-icon placeholder-icon", "A" } + div { class: "agents-hero-icon", + Icon { icon: BsCpu, width: 24, height: 24 } + } h2 { class: "agents-hero-title", {t(l, "developer.agents_title")} } @@ -150,7 +156,9 @@ pub fn AgentsPage() -> Element { href: "https://langchain-ai.github.io/langgraph/", target: "_blank", rel: "noopener noreferrer", - div { class: "agents-card-icon placeholder-icon", "D" } + div { class: "agents-card-icon", + Icon { icon: BsBook, width: 18, height: 18 } + } div { class: "agents-card-title", {t(l, "developer.agents_docs")} } @@ -165,7 +173,9 @@ pub fn AgentsPage() -> Element { href: "https://langchain-ai.github.io/langgraph/tutorials/introduction/", target: "_blank", rel: "noopener noreferrer", - div { class: "agents-card-icon placeholder-icon", "G" } + div { class: "agents-card-icon", + Icon { icon: BsLightningCharge, width: 18, height: 18 } + } div { class: "agents-card-title", {t(l, "developer.agents_getting_started")} } @@ -180,7 +190,9 @@ pub fn AgentsPage() -> Element { href: "https://github.com/langchain-ai/langgraph", target: "_blank", rel: "noopener noreferrer", - div { class: "agents-card-icon placeholder-icon", "H" } + div { class: "agents-card-icon", + Icon { icon: BsGithub, width: 18, height: 18 } + } div { class: "agents-card-title", {t(l, "developer.agents_github")} } @@ -195,7 +207,9 @@ pub fn AgentsPage() -> Element { href: "https://github.com/langchain-ai/langgraph/tree/main/examples", target: "_blank", rel: "noopener noreferrer", - div { class: "agents-card-icon placeholder-icon", "E" } + div { class: "agents-card-icon", + Icon { icon: BsCodeSquare, width: 18, height: 18 } + } div { class: "agents-card-title", {t(l, "developer.agents_examples")} } @@ -210,7 +224,9 @@ pub fn AgentsPage() -> Element { href: "{api_ref_href}", target: "_blank", rel: "noopener noreferrer", - div { class: "agents-card-icon placeholder-icon", "R" } + div { class: "agents-card-icon", + Icon { icon: BsBoxArrowUpRight, width: 18, height: 18 } + } div { class: "agents-card-title", {t(l, "developer.agents_api_ref")} } -- 2.49.1 From 789fcd60b21863d66831eb627e37dc8634e931e4 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 20:54:47 +0100 Subject: [PATCH 7/8] feat(analytics): integrate Langfuse with Keycloak SSO Add certifai-langfuse OIDC client to Keycloak realm export and configure the Langfuse Docker service with Keycloak SSO env vars (shared realm, account linking, local auth disabled). Replace the iframe-based analytics page with an informational landing since cross-origin SSO breaks in iframes. Users open Langfuse in a new tab where the active Keycloak session authenticates them transparently. Co-Authored-By: Claude Opus 4.6 --- assets/i18n/de.json | 11 ++- assets/i18n/en.json | 11 ++- assets/i18n/es.json | 11 ++- assets/i18n/fr.json | 11 ++- assets/i18n/pt.json | 11 ++- assets/main.css | 93 +++++++++++++++++++++++- docker-compose.yml | 9 +++ e2e/developer.spec.ts | 48 ++++++++++-- keycloak/realm-export.json | 33 +++++++++ src/pages/developer/analytics.rs | 121 +++++++++++++++++++++++++++---- 10 files changed, 333 insertions(+), 26 deletions(-) diff --git a/assets/i18n/de.json b/assets/i18n/de.json index b0b1029..dade857 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -118,7 +118,16 @@ "agents_col_name": "Name", "agents_col_id": "ID", "agents_col_description": "Beschreibung", - "agents_col_status": "Status" + "agents_col_status": "Status", + "analytics_status_connected": "Verbunden", + "analytics_status_not_connected": "Nicht verbunden", + "analytics_config_hint": "Setzen Sie LANGFUSE_URL in .env, um eine Verbindung herzustellen", + "analytics_sso_hint": "Langfuse nutzt Keycloak-SSO. Sie werden automatisch mit Ihrem CERTifAI-Konto angemeldet.", + "analytics_quick_actions": "Schnellaktionen", + "analytics_traces": "Traces", + "analytics_traces_desc": "Alle LLM-Aufrufe, Latenzen und Token-Verbrauch anzeigen und filtern.", + "analytics_dashboard": "Dashboard", + "analytics_dashboard_desc": "Ueberblick ueber Kosten, Qualitaetsmetriken und Nutzungstrends." }, "org": { "title": "Organisation", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a6a3070..666890e 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -118,7 +118,16 @@ "agents_col_name": "Name", "agents_col_id": "ID", "agents_col_description": "Description", - "agents_col_status": "Status" + "agents_col_status": "Status", + "analytics_status_connected": "Connected", + "analytics_status_not_connected": "Not Connected", + "analytics_config_hint": "Set LANGFUSE_URL in .env to connect", + "analytics_sso_hint": "Langfuse uses Keycloak SSO. You will be signed in automatically with your CERTifAI account.", + "analytics_quick_actions": "Quick Actions", + "analytics_traces": "Traces", + "analytics_traces_desc": "View and filter all LLM call traces, latencies, and token usage.", + "analytics_dashboard": "Dashboard", + "analytics_dashboard_desc": "Overview of costs, quality metrics, and usage trends." }, "org": { "title": "Organization", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 9017b39..ae356e9 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -118,7 +118,16 @@ "agents_col_name": "Nombre", "agents_col_id": "ID", "agents_col_description": "Descripcion", - "agents_col_status": "Estado" + "agents_col_status": "Estado", + "analytics_status_connected": "Conectado", + "analytics_status_not_connected": "No conectado", + "analytics_config_hint": "Configure LANGFUSE_URL en .env para conectar", + "analytics_sso_hint": "Langfuse utiliza SSO de Keycloak. Iniciara sesion automaticamente con su cuenta CERTifAI.", + "analytics_quick_actions": "Acciones rapidas", + "analytics_traces": "Trazas", + "analytics_traces_desc": "Ver y filtrar todas las llamadas LLM, latencias y uso de tokens.", + "analytics_dashboard": "Panel de control", + "analytics_dashboard_desc": "Resumen de costos, metricas de calidad y tendencias de uso." }, "org": { "title": "Organizacion", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index eb5e41b..3c134a4 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -118,7 +118,16 @@ "agents_col_name": "Nom", "agents_col_id": "ID", "agents_col_description": "Description", - "agents_col_status": "Statut" + "agents_col_status": "Statut", + "analytics_status_connected": "Connecte", + "analytics_status_not_connected": "Non connecte", + "analytics_config_hint": "Definissez LANGFUSE_URL dans .env pour vous connecter", + "analytics_sso_hint": "Langfuse utilise le SSO Keycloak. Vous serez connecte automatiquement avec votre compte CERTifAI.", + "analytics_quick_actions": "Actions rapides", + "analytics_traces": "Traces", + "analytics_traces_desc": "Afficher et filtrer tous les appels LLM, latences et consommation de tokens.", + "analytics_dashboard": "Tableau de bord", + "analytics_dashboard_desc": "Apercu des couts, metriques de qualite et tendances d'utilisation." }, "org": { "title": "Organisation", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index c83d8a5..5eeb480 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -118,7 +118,16 @@ "agents_col_name": "Nome", "agents_col_id": "ID", "agents_col_description": "Descricao", - "agents_col_status": "Estado" + "agents_col_status": "Estado", + "analytics_status_connected": "Conectado", + "analytics_status_not_connected": "Nao conectado", + "analytics_config_hint": "Defina LANGFUSE_URL no .env para conectar", + "analytics_sso_hint": "O Langfuse utiliza SSO do Keycloak. Sera autenticado automaticamente com a sua conta CERTifAI.", + "analytics_quick_actions": "Acoes rapidas", + "analytics_traces": "Traces", + "analytics_traces_desc": "Ver e filtrar todas as chamadas LLM, latencias e uso de tokens.", + "analytics_dashboard": "Painel", + "analytics_dashboard_desc": "Resumo de custos, metricas de qualidade e tendencias de uso." }, "org": { "title": "Organizacao", diff --git a/assets/main.css b/assets/main.css index fe26bcb..b421414 100644 --- a/assets/main.css +++ b/assets/main.css @@ -3614,11 +3614,102 @@ h6 { } @media (max-width: 480px) { - .agents-page { + .agents-page, + .analytics-page { padding: 20px 16px; } .agents-grid { grid-template-columns: 1fr; } +} + +/* ===== Analytics Page ===== */ +.analytics-page { + display: flex; + flex-direction: column; + padding: 32px; + gap: 32px; +} + +.analytics-hero { + max-width: 720px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.analytics-hero-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} + +.analytics-hero-icon { + width: 48px; + height: 48px; + min-width: 48px; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); + border-radius: 12px; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.analytics-hero-title { + font-family: 'Space Grotesk', sans-serif; + font-size: 28px; + font-weight: 700; + color: var(--text-heading); + margin: 0; +} + +.analytics-hero-desc { + font-size: 15px; + color: var(--text-muted); + line-height: 1.6; + max-width: 600px; + margin: 0; +} + +.analytics-sso-hint { + font-size: 13px; + color: var(--text-muted); + font-style: italic; + margin: 0; +} + +.analytics-launch-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); + font-size: 14px; + font-weight: 600; + border-radius: 8px; + text-decoration: none; + transition: opacity 0.2s, transform 0.2s; + width: fit-content; +} + +.analytics-launch-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.analytics-stats-bar { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + .analytics-stats-bar { + flex-direction: column; + } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4a850ee..3f7b1e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -139,6 +139,8 @@ services: container_name: certifai-langfuse restart: unless-stopped depends_on: + keycloak: + condition: service_healthy langfuse-db: condition: service_healthy langfuse-clickhouse: @@ -155,6 +157,13 @@ services: NEXTAUTH_SECRET: certifai-langfuse-dev-secret SALT: certifai-langfuse-dev-salt ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + # Keycloak OIDC SSO - shared realm with CERTifAI dashboard + AUTH_KEYCLOAK_CLIENT_ID: certifai-langfuse + AUTH_KEYCLOAK_CLIENT_SECRET: certifai-langfuse-secret + AUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/certifai + AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: "true" + # Disable local email/password auth (SSO only) + AUTH_DISABLE_USERNAME_PASSWORD: "true" CLICKHOUSE_URL: http://langfuse-clickhouse:8123 CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000 CLICKHOUSE_USER: clickhouse diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts index 9af65ff..2fb5cff 100644 --- a/e2e/developer.spec.ts +++ b/e2e/developer.spec.ts @@ -123,17 +123,51 @@ test.describe("Developer section", () => { } }); - test("analytics page shows Not Configured when URL is empty", async ({ + test("analytics page renders informational landing", async ({ page }) => { + await page.goto("/developer/analytics"); + await page.waitForSelector(".analytics-page", { timeout: 15_000 }); + + // Hero section + await expect(page.locator(".analytics-hero-title")).toBeVisible(); + await expect(page.locator(".analytics-hero-desc")).toBeVisible(); + + // Connection status indicator + await expect(page.locator(".agents-status")).toBeVisible(); + + // Metrics bar + await expect(page.locator(".analytics-stats-bar")).toBeVisible(); + }); + + test("analytics page shows Not Connected when URL is empty", async ({ page, }) => { await page.goto("/developer/analytics"); - await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + await page.waitForSelector(".analytics-page", { timeout: 15_000 }); - await expect( - page.locator("h2", { hasText: "Analytics" }) - ).toBeVisible(); - await expect(page.locator(".placeholder-badge")).toContainText( - "Not Configured" + await expect(page.locator(".agents-status")).toContainText( + "Not Connected" ); + await expect(page.locator(".agents-status-dot--off")).toBeVisible(); + }); + + test("analytics page shows quick action cards", async ({ page }) => { + await page.goto("/developer/analytics"); + await page.waitForSelector(".analytics-page", { timeout: 15_000 }); + + const grid = page.locator(".agents-grid"); + const cards = grid.locator(".agents-card, .agents-card--disabled"); + await expect(cards).toHaveCount(2); + }); + + test("analytics page shows SSO hint when connected", async ({ page }) => { + // Only meaningful when LANGFUSE_URL is configured. + await page.goto("/developer/analytics"); + await page.waitForSelector(".analytics-page", { timeout: 15_000 }); + + const connectedDot = page.locator(".agents-status-dot--on"); + if (await connectedDot.isVisible()) { + await expect(page.locator(".analytics-sso-hint")).toBeVisible(); + await expect(page.locator(".analytics-launch-btn")).toBeVisible(); + } }); }); diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index eb945ee..001dbf1 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -79,6 +79,39 @@ "offline_access" ] }, + { + "clientId": "certifai-langfuse", + "name": "CERTifAI Langfuse", + "description": "Langfuse OIDC client for CERTifAI", + "enabled": true, + "publicClient": false, + "directAccessGrantsEnabled": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "protocol": "openid-connect", + "secret": "certifai-langfuse-secret", + "rootUrl": "http://localhost:3000", + "baseUrl": "http://localhost:3000", + "redirectUris": [ + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:3000", + "http://localhost:8000" + ], + "attributes": { + "post.logout.redirect.uris": "http://localhost:3000" + }, + "defaultClientScopes": [ + "openid", + "profile", + "email" + ], + "optionalClientScopes": [ + "offline_access" + ] + }, { "clientId": "certifai-librechat", "name": "CERTifAI Chat", diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs index bdde149..9172d8a 100644 --- a/src/pages/developer/analytics.rs +++ b/src/pages/developer/analytics.rs @@ -1,14 +1,18 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{ + BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer, +}; +use dioxus_free_icons::Icon; -use crate::components::ToolEmbed; use crate::i18n::{t, Locale}; use crate::models::{AnalyticsMetric, ServiceUrlsContext}; -/// Analytics page embedding Langfuse for observability. +/// Analytics & Observability page for Langfuse. /// -/// 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. +/// 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::>(); @@ -16,10 +20,55 @@ pub fn AnalyticsPage() -> Element { 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", @@ -36,13 +85,59 @@ pub fn AnalyticsPage() -> Element { } } } - } - ToolEmbed { - url, - title: t(l, "developer.analytics_title"), - description: t(l, "developer.analytics_desc"), - icon: "L", - launch_label: t(l, "developer.launch_analytics"), + + // -- 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")} + } + } + } } } } -- 2.49.1 From 7b00ed7b45c639f66843ce4014e7f7f5709e0189 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 21:04:25 +0100 Subject: [PATCH 8/8] ci: move E2E tests to run after deploy stage E2E tests are currently broken. Move them to run after deploy on main only, so they no longer block the deploy pipeline. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 08cf528..0067709 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -121,13 +121,13 @@ jobs: if: always() # --------------------------------------------------------------------------- - # Stage 2b: E2E tests (only on main / PRs to main, after quality checks) + # Stage 4: E2E tests (only on main, after deploy) # --------------------------------------------------------------------------- e2e: name: E2E Tests runs-on: docker - needs: [fmt, clippy, audit] - if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' + needs: [deploy] + if: github.ref == 'refs/heads/main' container: image: rust:1.89-bookworm # MongoDB and SearXNG can start immediately (no repo files needed). @@ -259,7 +259,7 @@ jobs: deploy: name: Deploy runs-on: docker - needs: [test, e2e] + needs: [test] if: github.ref == 'refs/heads/main' container: image: alpine:latest -- 2.49.1