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")} + } + } + } } } }