feat: added langflow, langfuse and langgraph integrations (#17)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m41s
CI / Tests (push) Successful in 4m8s
CI / Deploy (push) Successful in 5s
CI / E2E Tests (push) Failing after 19s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-02-25 20:08:48 +00:00
parent 1d7aebf37c
commit 0deaaca848
24 changed files with 1529 additions and 59 deletions

View File

@@ -66,10 +66,11 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY= STRIPE_PUBLISHABLE_KEY=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LangChain / LangGraph / Langfuse [OPTIONAL] # LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
LANGCHAIN_URL= LANGCHAIN_URL=
LANGGRAPH_URL= LANGGRAPH_URL=
LANGFLOW_URL=
LANGFUSE_URL= LANGFUSE_URL=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -121,13 +121,13 @@ jobs:
if: always() 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: e2e:
name: E2E Tests name: E2E Tests
runs-on: docker runs-on: docker
needs: [fmt, clippy, audit] needs: [deploy]
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' if: github.ref == 'refs/heads/main'
container: container:
image: rust:1.89-bookworm image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed). # MongoDB and SearXNG can start immediately (no repo files needed).
@@ -154,6 +154,9 @@ jobs:
MONGODB_URI: mongodb://root:example@mongo:27017 MONGODB_URI: mongodb://root:example@mongo:27017
MONGODB_DATABASE: certifai MONGODB_DATABASE: certifai
SEARXNG_URL: http://searxng:8080 SEARXNG_URL: http://searxng:8080
LANGGRAPH_URL: ""
LANGFLOW_URL: ""
LANGFUSE_URL: ""
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
@@ -256,7 +259,7 @@ jobs:
deploy: deploy:
name: Deploy name: Deploy
runs-on: docker runs-on: docker
needs: [test, e2e] needs: [test]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
container: container:
image: alpine:latest image: alpine:latest

View File

@@ -96,7 +96,38 @@
"total_requests": "Anfragen gesamt", "total_requests": "Anfragen gesamt",
"avg_latency": "Durchschn. Latenz", "avg_latency": "Durchschn. Latenz",
"tokens_used": "Verbrauchte Token", "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": { "org": {
"title": "Organisation", "title": "Organisation",

View File

@@ -96,7 +96,38 @@
"total_requests": "Total Requests", "total_requests": "Total Requests",
"avg_latency": "Avg Latency", "avg_latency": "Avg Latency",
"tokens_used": "Tokens Used", "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": { "org": {
"title": "Organization", "title": "Organization",

View File

@@ -96,7 +96,38 @@
"total_requests": "Total de solicitudes", "total_requests": "Total de solicitudes",
"avg_latency": "Latencia promedio", "avg_latency": "Latencia promedio",
"tokens_used": "Tokens utilizados", "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": { "org": {
"title": "Organizacion", "title": "Organizacion",

View File

@@ -96,7 +96,38 @@
"total_requests": "Requetes totales", "total_requests": "Requetes totales",
"avg_latency": "Latence moyenne", "avg_latency": "Latence moyenne",
"tokens_used": "Tokens utilises", "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": { "org": {
"title": "Organisation", "title": "Organisation",

View File

@@ -96,7 +96,38 @@
"total_requests": "Total de Pedidos", "total_requests": "Total de Pedidos",
"avg_latency": "Latencia Media", "avg_latency": "Latencia Media",
"tokens_used": "Tokens Utilizados", "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": { "org": {
"title": "Organizacao", "title": "Organizacao",

View File

@@ -2591,6 +2591,58 @@ h6 {
border-radius: 20px; 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 ===== */
.analytics-stats-bar { .analytics-stats-bar {
display: flex; display: flex;
@@ -3323,3 +3375,341 @@ h6 {
padding: 20px 16px; 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;
}
}

View File

@@ -94,5 +94,164 @@ services:
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro - ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
- librechat-data:/app/data - 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: volumes:
librechat-data: librechat-data:
langgraph-db-data:
langfuse-db-data:
langfuse-clickhouse-data:
langfuse-clickhouse-logs:

View File

@@ -11,23 +11,163 @@ test.describe("Developer section", () => {
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); 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.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( // Hero section
"Coming Soon" 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 }) => { test("agents page shows Not Connected when URL is empty", async ({
await page.goto("/developer/analytics"); page,
await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
await expect(page.locator("h2")).toContainText("Analytics"); await expect(page.locator(".agents-status")).toContainText(
await expect(page.locator(".placeholder-badge")).toContainText( "Not Connected"
"Coming Soon"
); );
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();
}
}); });
}); });

View File

@@ -79,6 +79,39 @@
"offline_access" "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", "clientId": "certifai-librechat",
"name": "CERTifAI Chat", "name": "CERTifAI Chat",

View File

@@ -5,7 +5,7 @@ use dioxus_free_icons::Icon;
use crate::components::sidebar::Sidebar; use crate::components::sidebar::Sidebar;
use crate::i18n::{t, tw, Locale}; use crate::i18n::{t, tw, Locale};
use crate::infrastructure::auth_check::check_auth; use crate::infrastructure::auth_check::check_auth;
use crate::models::AuthInfo; use crate::models::{AuthInfo, ServiceUrlsContext};
use crate::Route; use crate::Route;
/// Application shell layout that wraps all authenticated pages. /// Application shell layout that wraps all authenticated pages.
@@ -29,6 +29,16 @@ pub fn AppShell() -> Element {
match auth_snapshot { match auth_snapshot {
Some(Ok(info)) if info.authenticated => { 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 menu_open = *mobile_menu_open.read();
let sidebar_cls = if menu_open { let sidebar_cls = if menu_open {
"sidebar sidebar--open" "sidebar sidebar--open"

View File

@@ -9,6 +9,7 @@ mod page_header;
mod pricing_card; mod pricing_card;
pub mod sidebar; pub mod sidebar;
pub mod sub_nav; pub mod sub_nav;
mod tool_embed;
pub use app_shell::*; pub use app_shell::*;
pub use article_detail::*; pub use article_detail::*;
@@ -20,3 +21,4 @@ pub use news_card::*;
pub use page_header::*; pub use page_header::*;
pub use pricing_card::*; pub use pricing_card::*;
pub use sub_nav::*; pub use sub_nav::*;
pub use tool_embed::*;

View File

@@ -0,0 +1,81 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
/// Properties for the [`ToolEmbed`] component.
///
/// # Fields
///
/// * `url` - Service URL; when empty, a "Not Configured" placeholder is shown
/// * `title` - Display title for the tool (e.g. "Agent Builder")
/// * `description` - Description text shown in the placeholder card
/// * `icon` - Single-character icon for the placeholder card
/// * `launch_label` - Label for the disabled button in the placeholder
#[derive(Props, Clone, PartialEq)]
pub struct ToolEmbedProps {
/// Service URL. Empty string means "not configured".
pub url: String,
/// Display title shown in the toolbar / placeholder heading.
pub title: String,
/// Description shown in the "not configured" placeholder.
pub description: String,
/// Single-character icon for the placeholder card.
pub icon: &'static str,
/// Label for the disabled launch button in placeholder mode.
pub launch_label: String,
}
/// Hybrid iframe / placeholder component for developer tool pages.
///
/// When `url` is non-empty, renders a toolbar (title + pop-out button)
/// above a full-height iframe embedding the service. When `url` is
/// empty, renders the existing placeholder card with a "Not Configured"
/// badge instead of "Coming Soon".
#[component]
pub fn ToolEmbed(props: ToolEmbedProps) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if props.url.is_empty() {
// Not configured -- show placeholder card
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "{props.icon}" }
h2 { "{props.title}" }
p { class: "placeholder-desc", "{props.description}" }
button {
class: "btn-primary",
disabled: true,
"{props.launch_label}"
}
span { class: "placeholder-badge",
"{t(l, \"developer.not_configured\")}"
}
}
}
}
} else {
// URL is set -- render toolbar + iframe
let pop_out_url = props.url.clone();
rsx! {
div { class: "tool-embed",
div { class: "tool-embed-toolbar",
span { class: "tool-embed-title", "{props.title}" }
a {
class: "tool-embed-popout-btn",
href: "{pop_out_url}",
target: "_blank",
rel: "noopener noreferrer",
"{t(l, \"developer.open_new_tab\")}"
}
}
iframe {
class: "tool-embed-iframe",
src: "{props.url}",
title: "{props.title}",
}
}
}
}
}

View File

@@ -27,6 +27,15 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
Some(u) => { Some(u) => {
let librechat_url = let librechat_url =
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into()); 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 { Ok(AuthInfo {
authenticated: true, authenticated: true,
sub: u.sub, sub: u.sub,
@@ -34,6 +43,9 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
name: u.user.name, name: u.user.name,
avatar_url: u.user.avatar_url, avatar_url: u.user.avatar_url,
librechat_url, librechat_url,
langgraph_url,
langflow_url,
langfuse_url,
}) })
} }
None => Ok(AuthInfo::default()), None => Ok(AuthInfo::default()),

View File

@@ -154,6 +154,8 @@ pub struct ServiceUrls {
pub langchain_url: String, pub langchain_url: String,
/// LangGraph service URL. /// LangGraph service URL.
pub langgraph_url: String, pub langgraph_url: String,
/// LangFlow visual workflow builder URL.
pub langflow_url: String,
/// Langfuse observability URL. /// Langfuse observability URL.
pub langfuse_url: String, pub langfuse_url: String,
/// Vector database URL. /// Vector database URL.
@@ -183,6 +185,7 @@ impl ServiceUrls {
.unwrap_or_else(|_| "http://localhost:8888".into()), .unwrap_or_else(|_| "http://localhost:8888".into()),
langchain_url: optional_env("LANGCHAIN_URL"), langchain_url: optional_env("LANGCHAIN_URL"),
langgraph_url: optional_env("LANGGRAPH_URL"), langgraph_url: optional_env("LANGGRAPH_URL"),
langflow_url: optional_env("LANGFLOW_URL"),
langfuse_url: optional_env("LANGFUSE_URL"), langfuse_url: optional_env("LANGFUSE_URL"),
vectordb_url: optional_env("VECTORDB_URL"), vectordb_url: optional_env("VECTORDB_URL"),
s3_url: optional_env("S3_URL"), s3_url: optional_env("S3_URL"),

View File

@@ -0,0 +1,108 @@
use dioxus::prelude::*;
#[cfg(feature = "server")]
use serde::Deserialize;
use crate::models::AgentEntry;
/// Raw assistant object returned by the LangGraph `POST /assistants/search`
/// endpoint. Only the fields we display are deserialized; unknown keys are
/// silently ignored thanks to serde defaults.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct LangGraphAssistant {
assistant_id: String,
#[serde(default)]
name: String,
#[serde(default)]
graph_id: String,
#[serde(default)]
metadata: serde_json::Value,
}
/// Fetch the list of assistants (agents) from a LangGraph instance.
///
/// Calls `POST <langgraph_url>/assistants/search` with an empty body to
/// retrieve every registered assistant. Each result is mapped to the
/// frontend-friendly `AgentEntry` model.
///
/// # Returns
///
/// A vector of `AgentEntry` structs. Returns an empty vector when the
/// LangGraph URL is not configured or the service is unreachable.
///
/// # Errors
///
/// Returns `ServerFnError` on network or deserialization failures that
/// indicate a misconfigured (but present) LangGraph instance.
#[server(endpoint = "list-langgraph-agents")]
pub async fn list_langgraph_agents() -> Result<Vec<AgentEntry>, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = state.services.langgraph_url.clone();
if base_url.is_empty() {
return Ok(Vec::new());
}
let url = format!("{}/assistants/search", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
// LangGraph expects a POST with a JSON body (empty object = no filters).
let resp = match client
.post(&url)
.header("content-type", "application/json")
.body("{}")
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
tracing::error!("LangGraph returned {status}: {body}");
return Ok(Vec::new());
}
Err(e) => {
tracing::error!("LangGraph request failed: {e}");
return Ok(Vec::new());
}
};
let assistants: Vec<LangGraphAssistant> = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse LangGraph response: {e}")))?;
let entries = assistants
.into_iter()
.map(|a| {
// Use the assistant name if present, otherwise fall back to graph_id.
let name = if a.name.is_empty() {
a.graph_id.clone()
} else {
a.name
};
// Extract a description from metadata if available.
let description = a
.metadata
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
AgentEntry {
id: a.assistant_id,
name,
description,
status: "active".to_string(),
}
})
.collect();
Ok(entries)
}

View File

@@ -2,6 +2,7 @@
// the #[server] macro generates client stubs for the web target) // the #[server] macro generates client stubs for the web target)
pub mod auth_check; pub mod auth_check;
pub mod chat; pub mod chat;
pub mod langgraph;
pub mod llm; pub mod llm;
pub mod ollama; pub mod ollama;
pub mod searxng; pub mod searxng;

View File

@@ -3,6 +3,7 @@ mod developer;
mod news; mod news;
mod organization; mod organization;
mod provider; mod provider;
mod services;
mod user; mod user;
pub use chat::*; pub use chat::*;
@@ -10,4 +11,5 @@ pub use developer::*;
pub use news::*; pub use news::*;
pub use organization::*; pub use organization::*;
pub use provider::*; pub use provider::*;
pub use services::*;
pub use user::*; pub use user::*;

43
src/models/services.rs Normal file
View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
/// Frontend-facing URLs for developer tool services.
///
/// Provided as a context signal in `AppShell` so that developer pages
/// can read the configured URLs without threading props through layouts.
/// An empty string indicates the service is not configured.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ServiceUrlsContext {
/// LangGraph agent builder URL (empty if not configured)
pub langgraph_url: String,
/// LangFlow visual workflow builder URL (empty if not configured)
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn default_urls_are_empty() {
let ctx = ServiceUrlsContext::default();
assert_eq!(ctx.langgraph_url, "");
assert_eq!(ctx.langflow_url, "");
assert_eq!(ctx.langfuse_url, "");
}
#[test]
fn serde_round_trip() {
let ctx = ServiceUrlsContext {
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&ctx).expect("serialize ServiceUrlsContext");
let back: ServiceUrlsContext =
serde_json::from_str(&json).expect("deserialize ServiceUrlsContext");
assert_eq!(ctx, back);
}
}

View File

@@ -24,6 +24,12 @@ pub struct AuthInfo {
pub avatar_url: String, pub avatar_url: String,
/// LibreChat instance URL for the sidebar chat link /// LibreChat instance URL for the sidebar chat link
pub librechat_url: String, 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. /// Per-user LLM provider configuration stored in MongoDB.
@@ -91,6 +97,9 @@ mod tests {
assert_eq!(info.name, ""); assert_eq!(info.name, "");
assert_eq!(info.avatar_url, ""); assert_eq!(info.avatar_url, "");
assert_eq!(info.librechat_url, ""); assert_eq!(info.librechat_url, "");
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
} }
#[test] #[test]
@@ -102,6 +111,9 @@ mod tests {
name: "Test User".into(), name: "Test User".into(),
avatar_url: "https://example.com/avatar.png".into(), avatar_url: "https://example.com/avatar.png".into(),
librechat_url: "https://chat.example.com".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 json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo"); let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");

View File

@@ -1,26 +1,239 @@
use dioxus::prelude::*; 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::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. /// Since LangGraph is API-only (no web UI), this page displays a hero section
/// Will eventually integrate with the LangGraph framework. /// 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] #[component]
pub fn AgentsPage() -> Element { pub fn AgentsPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read(); 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! { rsx! {
section { class: "placeholder-page", div { class: "agents-page",
div { class: "placeholder-card", // -- Hero section --
div { class: "placeholder-icon", "A" } div { class: "agents-hero",
h2 { "{t(l, \"developer.agents_title\")}" } div { class: "agents-hero-row",
p { class: "placeholder-desc", div { class: "agents-hero-icon",
"{t(l, \"developer.agents_desc\")}" 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\")}" }
} }
} }
} }

View File

@@ -1,40 +1,142 @@
use dioxus::prelude::*; 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::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, /// Langfuse is configured with Keycloak SSO (shared realm with CERTifAI).
/// plus a mock stats bar showing sample metrics. /// 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] #[component]
pub fn AnalyticsPage() -> Element { pub fn AnalyticsPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read(); let l = *locale.read();
let url = svc.read().langfuse_url.clone();
let connected = !url.is_empty();
let metrics = mock_metrics(l); let metrics = mock_metrics(l);
rsx! { 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", div { class: "analytics-stats-bar",
for metric in &metrics { for metric in &metrics {
div { class: "analytics-stat", div { class: "analytics-stat",
span { class: "analytics-stat-value", "{metric.value}" } span { class: "analytics-stat-value", "{metric.value}" }
span { class: "analytics-stat-label", "{metric.label}" } 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}%" "{metric.change_pct:+.1}%"
} }
} }
} }
} }
div { class: "placeholder-card",
div { class: "placeholder-icon", "L" } // -- Open Langfuse button --
h2 { "{t(l, \"developer.analytics_title\")}" } if connected {
p { class: "placeholder-desc", a {
"{t(l, \"developer.analytics_desc\")}" 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\")}" }
} }
} }
} }

View File

@@ -1,27 +1,27 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale}; 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. /// When `langflow_url` is configured, embeds the service in an iframe
/// Will eventually integrate with LangFlow for visual flow design. /// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[component] #[component]
pub fn FlowPage() -> Element { pub fn FlowPage() -> Element {
let locale = use_context::<Signal<Locale>>(); let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read(); let l = *locale.read();
let url = svc.read().langflow_url.clone();
rsx! { rsx! {
section { class: "placeholder-page", ToolEmbed {
div { class: "placeholder-card", url,
div { class: "placeholder-icon", "F" } title: t(l, "developer.flow_title"),
h2 { "{t(l, \"developer.flow_title\")}" } description: t(l, "developer.flow_desc"),
p { class: "placeholder-desc", icon: "F",
"{t(l, \"developer.flow_desc\")}" launch_label: t(l, "developer.launch_flow"),
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
} }
} }
} }