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..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). @@ -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: | @@ -256,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 diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 515c528..dade857 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -96,7 +96,38 @@ "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", + "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", + "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 774f1fa..666890e 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -96,7 +96,38 @@ "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", + "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", + "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 6a0a4b1..ae356e9 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -96,7 +96,38 @@ "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", + "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", + "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 9ab76f1..3c134a4 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -96,7 +96,38 @@ "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", + "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", + "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 1d4e7d4..5eeb480 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -96,7 +96,38 @@ "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", + "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", + "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 1ed9d8a..b421414 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; @@ -3322,4 +3374,342 @@ 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, + .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 1d8b2ef..3f7b1e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,5 +94,164 @@ 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: + keycloak: + condition: service_healthy + 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" + # 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 + 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..2fb5cff 100644 --- a/e2e/developer.spec.ts +++ b/e2e/developer.spec.ts @@ -11,23 +11,163 @@ test.describe("Developer section", () => { await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); }); - test("agents page shows Coming Soon badge", async ({ page }) => { + test("agents page renders informational landing", 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( - "Coming Soon" + // Hero section + await expect(page.locator(".agents-hero-title")).toContainText( + "Agent Builder" ); - await expect(page.locator("h2")).toContainText("Agent Builder"); + await expect(page.locator(".agents-hero-desc")).toBeVisible(); + + // Connection status indicator is present + await expect(page.locator(".agents-status")).toBeVisible(); }); - test("analytics page loads via sub-nav", async ({ page }) => { - await page.goto("/developer/analytics"); - await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + test("agents page shows Not Connected when URL is empty", async ({ + page, + }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".agents-page", { timeout: 15_000 }); - await expect(page.locator("h2")).toContainText("Analytics"); - await expect(page.locator(".placeholder-badge")).toContainText( - "Coming Soon" + await expect(page.locator(".agents-status")).toContainText( + "Not Connected" ); + 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 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(".analytics-page", { timeout: 15_000 }); + + 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/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/infrastructure/langgraph.rs b/src/infrastructure/langgraph.rs new file mode 100644 index 0000000..1c956cd --- /dev/null +++ b/src/infrastructure/langgraph.rs @@ -0,0 +1,108 @@ +use dioxus::prelude::*; +#[cfg(feature = "server")] +use serde::Deserialize; + +use crate::models::AgentEntry; + +/// Raw assistant object returned by the LangGraph `POST /assistants/search` +/// endpoint. Only the fields we display are deserialized; unknown keys are +/// silently ignored thanks to serde defaults. +#[cfg(feature = "server")] +#[derive(Deserialize)] +struct LangGraphAssistant { + assistant_id: String, + #[serde(default)] + name: String, + #[serde(default)] + graph_id: String, + #[serde(default)] + metadata: serde_json::Value, +} + +/// Fetch the list of assistants (agents) from a LangGraph instance. +/// +/// Calls `POST /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/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..fa144fd 100644 --- a/src/pages/developer/agents.rs +++ b/src/pages/developer/agents.rs @@ -1,26 +1,239 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{ + BsBook, BsBoxArrowUpRight, BsCodeSquare, BsCpu, BsGithub, BsLightningCharge, +}; +use dioxus_free_icons::Icon; use crate::i18n::{t, Locale}; +use crate::models::ServiceUrlsContext; -/// Agents page placeholder for the LangGraph agent builder. +/// Agents informational landing page for LangGraph. /// -/// Shows a "Coming Soon" card with a disabled launch button. -/// Will eventually integrate with the LangGraph framework. +/// Since LangGraph is API-only (no web UI), this page displays a hero section +/// explaining its role, a connection status indicator, a card grid linking +/// to documentation, and a live table of registered agents fetched from the +/// LangGraph assistants API. #[component] pub fn AgentsPage() -> Element { let locale = use_context::>(); + let svc = use_context::>(); let l = *locale.read(); + let url = svc.read().langgraph_url.clone(); + + // Derive whether a LangGraph URL is configured + let connected = !url.is_empty(); + // Build the API reference URL from the configured base, falling back to "#" + let api_ref_href = if connected { + format!("{}/docs", url) + } else { + "#".to_string() + }; + + // Fetch agents from LangGraph when connected + let agents_resource = use_resource(move || async move { + match crate::infrastructure::langgraph::list_langgraph_agents().await { + Ok(agents) => agents, + Err(e) => { + tracing::error!("Failed to fetch agents: {e}"); + Vec::new() + } + } + }); rsx! { - section { class: "placeholder-page", - div { class: "placeholder-card", - div { class: "placeholder-icon", "A" } - h2 { "{t(l, \"developer.agents_title\")}" } - p { class: "placeholder-desc", - "{t(l, \"developer.agents_desc\")}" + div { class: "agents-page", + // -- Hero section -- + div { class: "agents-hero", + div { class: "agents-hero-row", + div { class: "agents-hero-icon", + Icon { icon: BsCpu, width: 24, height: 24 } + } + h2 { class: "agents-hero-title", + {t(l, "developer.agents_title")} + } + } + p { class: "agents-hero-desc", + {t(l, "developer.agents_desc")} + } + + // -- Connection status -- + if connected { + div { class: "agents-status", + span { + class: "agents-status-dot agents-status-dot--on", + } + span { {t(l, "developer.agents_status_connected")} } + code { class: "agents-status-url", {url.clone()} } + } + } else { + div { class: "agents-status", + span { + class: "agents-status-dot agents-status-dot--off", + } + span { {t(l, "developer.agents_status_not_connected")} } + span { class: "agents-status-hint", + {t(l, "developer.agents_config_hint")} + } + } + } + } + + // -- Running Agents table -- + div { class: "agents-table-section", + h3 { class: "agents-section-title", + {t(l, "developer.agents_running_title")} + } + + match agents_resource.read().as_ref() { + None => { + rsx! { + p { class: "agents-table-loading", + {t(l, "common.loading")} + } + } + } + Some(agents) if agents.is_empty() => { + rsx! { + p { class: "agents-table-empty", + {t(l, "developer.agents_none")} + } + } + } + Some(agents) => { + rsx! { + div { class: "agents-table-wrap", + table { class: "agents-table", + thead { + tr { + th { {t(l, "developer.agents_col_name")} } + th { {t(l, "developer.agents_col_id")} } + th { {t(l, "developer.agents_col_description")} } + th { {t(l, "developer.agents_col_status")} } + } + } + tbody { + for agent in agents.iter() { + tr { key: "{agent.id}", + td { class: "agents-cell-name", + {agent.name.clone()} + } + td { + code { class: "agents-cell-id", + {agent.id.clone()} + } + } + td { class: "agents-cell-desc", + if agent.description.is_empty() { + span { class: "agents-cell-none", "--" } + } else { + {agent.description.clone()} + } + } + td { + span { class: "agents-badge agents-badge--active", + {agent.status.clone()} + } + } + } + } + } + } + } + } + } + } + } + + // -- Quick Start card grid -- + h3 { class: "agents-section-title", + {t(l, "developer.agents_quick_start")} + } + + div { class: "agents-grid", + // Documentation + a { + class: "agents-card", + href: "https://langchain-ai.github.io/langgraph/", + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsBook, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.agents_docs")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_docs_desc")} + } + } + + // Getting Started + a { + class: "agents-card", + href: "https://langchain-ai.github.io/langgraph/tutorials/introduction/", + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsLightningCharge, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.agents_getting_started")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_getting_started_desc")} + } + } + + // GitHub + a { + class: "agents-card", + href: "https://github.com/langchain-ai/langgraph", + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsGithub, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.agents_github")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_github_desc")} + } + } + + // Examples + a { + class: "agents-card", + href: "https://github.com/langchain-ai/langgraph/tree/main/examples", + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsCodeSquare, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.agents_examples")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_examples_desc")} + } + } + + // API Reference (disabled when URL is empty) + a { + class: if connected { "agents-card" } else { "agents-card agents-card--disabled" }, + href: "{api_ref_href}", + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsBoxArrowUpRight, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.agents_api_ref")} + } + div { class: "agents-card-desc", + {t(l, "developer.agents_api_ref_desc")} + } } - button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" } - span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } } } } diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs index b04883d..9172d8a 100644 --- a/src/pages/developer/analytics.rs +++ b/src/pages/developer/analytics.rs @@ -1,40 +1,142 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{ + BsBarChartLine, BsBoxArrowUpRight, BsGraphUp, BsSpeedometer, +}; +use dioxus_free_icons::Icon; use crate::i18n::{t, Locale}; -use crate::models::AnalyticsMetric; +use crate::models::{AnalyticsMetric, ServiceUrlsContext}; -/// Analytics page placeholder for LangFuse integration. +/// Analytics & Observability page for Langfuse. /// -/// Shows a "Coming Soon" card with a disabled launch button, -/// plus a mock stats bar showing sample metrics. +/// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI). +/// When users open Langfuse, the existing Keycloak session auto-authenticates +/// them transparently. This page shows a metrics bar, connection status, +/// and a prominent button to open Langfuse in a new tab. #[component] pub fn AnalyticsPage() -> Element { let locale = use_context::>(); + let svc = use_context::>(); let l = *locale.read(); + let url = svc.read().langfuse_url.clone(); + let connected = !url.is_empty(); let metrics = mock_metrics(l); rsx! { - section { class: "placeholder-page", + div { class: "analytics-page", + // -- Hero section -- + div { class: "analytics-hero", + div { class: "analytics-hero-row", + div { class: "analytics-hero-icon", + Icon { icon: BsGraphUp, width: 24, height: 24 } + } + h2 { class: "analytics-hero-title", + {t(l, "developer.analytics_title")} + } + } + p { class: "analytics-hero-desc", + {t(l, "developer.analytics_desc")} + } + + // -- Connection status -- + if connected { + div { class: "agents-status", + span { + class: "agents-status-dot agents-status-dot--on", + } + span { {t(l, "developer.analytics_status_connected")} } + code { class: "agents-status-url", {url.clone()} } + } + } else { + div { class: "agents-status", + span { + class: "agents-status-dot agents-status-dot--off", + } + span { {t(l, "developer.analytics_status_not_connected")} } + span { class: "agents-status-hint", + {t(l, "developer.analytics_config_hint")} + } + } + } + + // -- SSO info -- + if connected { + p { class: "analytics-sso-hint", + {t(l, "developer.analytics_sso_hint")} + } + } + } + + // -- Metrics bar -- div { class: "analytics-stats-bar", for metric in &metrics { div { class: "analytics-stat", span { class: "analytics-stat-value", "{metric.value}" } span { class: "analytics-stat-label", "{metric.label}" } - span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" }, + span { + class: if metric.change_pct >= 0.0 { + "analytics-stat-change analytics-stat-change--up" + } else { + "analytics-stat-change analytics-stat-change--down" + }, "{metric.change_pct:+.1}%" } } } } - div { class: "placeholder-card", - div { class: "placeholder-icon", "L" } - h2 { "{t(l, \"developer.analytics_title\")}" } - p { class: "placeholder-desc", - "{t(l, \"developer.analytics_desc\")}" + + // -- Open Langfuse button -- + if connected { + a { + class: "analytics-launch-btn", + href: "{url}", + target: "_blank", + rel: "noopener noreferrer", + Icon { icon: BsBoxArrowUpRight, width: 16, height: 16 } + span { {t(l, "developer.launch_analytics")} } + } + } + + // -- Quick actions -- + h3 { class: "agents-section-title", + {t(l, "developer.analytics_quick_actions")} + } + + div { class: "agents-grid", + // Traces + a { + class: if connected { "agents-card" } else { "agents-card agents-card--disabled" }, + href: if connected { format!("{url}/project") } else { "#".to_string() }, + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsBarChartLine, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.analytics_traces")} + } + div { class: "agents-card-desc", + {t(l, "developer.analytics_traces_desc")} + } + } + + // Dashboard + a { + class: if connected { "agents-card" } else { "agents-card agents-card--disabled" }, + href: if connected { format!("{url}/project") } else { "#".to_string() }, + target: "_blank", + rel: "noopener noreferrer", + div { class: "agents-card-icon", + Icon { icon: BsSpeedometer, width: 18, height: 18 } + } + div { class: "agents-card-title", + {t(l, "developer.analytics_dashboard")} + } + div { class: "agents-card-desc", + {t(l, "developer.analytics_dashboard_desc")} + } } - button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" } - span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } } } } 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"), } } }