8 Commits

Author SHA1 Message Date
Sharang Parnerkar
789fcd60b2 feat(analytics): integrate Langfuse with Keycloak SSO
Some checks failed
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Deploy (pull_request) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
Add certifai-langfuse OIDC client to Keycloak realm export and configure
the Langfuse Docker service with Keycloak SSO env vars (shared realm,
account linking, local auth disabled). Replace the iframe-based analytics
page with an informational landing since cross-origin SSO breaks in
iframes. Users open Langfuse in a new tab where the active Keycloak
session authenticates them transparently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
c97bacbfe2 feat(developer): replace placeholder letters with Bootstrap icons on agents page
Use dioxus_free_icons Bootstrap icons: BsCpu for hero, BsBook for docs,
BsLightningCharge for getting started, BsGithub for GitHub,
BsCodeSquare for examples, BsBoxArrowUpRight for API reference.
Also fix conditional serde import for server feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
97d75ada2c test(e2e): update developer agents page tests for informational landing
Replace obsolete iframe/placeholder tests with new tests covering the
hero section, connection status, quick-start card grid, disabled API
Reference card, and running agents table section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
d40690b7a7 feat(developer): replace agents iframe with informational landing and live agent table
LangGraph is API-only with no web UI, so the ToolEmbed iframe pattern
doesn't work. Replace it with an informational landing page featuring a
hero section, connection status indicator, quick-start card grid linking
to docs/GitHub/examples, and a live table of registered agents fetched
from the LangGraph POST /assistants/search endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
a24ea984b1 feat(developer): add hybrid iframe integration for developer tools
Replace placeholder pages with ToolEmbed component that embeds
LangGraph, LangFlow, and Langfuse in iframes when configured, or
shows "Not Configured" placeholders when URLs are empty. Add
ServiceUrlsContext for passing service URLs through Dioxus context.

Add docker-compose services for local development: LangFlow,
LangGraph (trial), Langfuse with full dependency stack (Postgres,
ClickHouse, Redis, MinIO).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
d9401d4be5 feat(librechat): add OIDC HTTP patch and prompt=none for seamless SSO
Switch to host networking so LibreChat can reach Keycloak on localhost.
Patch openidStrategy.js to allow HTTP OIDC issuers for local dev
(openid-client v6 enforces HTTPS by default). Add support for
OPENID_AUTH_EXTRA_PARAMS env var and set prompt=none for automatic
SSO login when a Keycloak session exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
Sharang Parnerkar
d36f282f78 fix(librechat): remove prompt=none for local dev compatibility
prompt=none causes silent failure when no Keycloak session exists yet.
Standard OIDC flow still provides seamless SSO when the user has an
active Keycloak session from the dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:02:38 +01:00
1d7aebf37c test: added more tests (#16)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m47s
CI / Security Audit (push) Successful in 1m35s
CI / Tests (push) Successful in 3m54s
CI / E2E Tests (push) Failing after 16s
CI / Deploy (push) Has been skipped
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #16
2026-02-25 10:01:56 +00:00
49 changed files with 3757 additions and 66 deletions

View File

@@ -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=
# ---------------------------------------------------------------------------

View File

@@ -120,13 +120,146 @@ jobs:
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 2b: E2E tests (only on main / PRs to main, after quality checks)
# ---------------------------------------------------------------------------
e2e:
name: E2E Tests
runs-on: docker
needs: [fmt, clippy, audit]
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
container:
image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed).
# Keycloak requires realm-export.json from the repo, so it is started
# manually after checkout via docker CLI.
services:
mongo:
image: mongo:latest
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
searxng:
image: searxng/searxng:latest
env:
SEARXNG_BASE_URL: http://localhost:8888
ports:
- 8888:8080
env:
KEYCLOAK_URL: http://localhost:8080
KEYCLOAK_REALM: certifai
KEYCLOAK_CLIENT_ID: certifai-dashboard
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: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install system dependencies
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
unzip curl docker.io \
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
libcairo2 libasound2 libatspi2.0-0 libxshmfence1
- name: Start Keycloak
run: |
docker run -d --name ci-keycloak --network host \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_DB=dev-mem \
-e KC_HEALTH_ENABLED=true \
-v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \
-v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \
quay.io/keycloak/keycloak:26.0 start-dev --import-realm
echo "Waiting for Keycloak..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then
echo "Keycloak is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "Keycloak failed to start within 60s"
docker logs ci-keycloak
exit 1
fi
sleep 2
done
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
- name: Install dioxus-cli
run: cargo install dioxus-cli --locked
- name: Install bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- name: Install Playwright
run: |
export PATH="$HOME/.bun/bin:$PATH"
bun install
bunx playwright install chromium
- name: Build app
run: dx build --release
- name: Start app and run E2E tests
run: |
export PATH="$HOME/.bun/bin:$PATH"
# Start the app in the background
dx serve --release --port 8000 &
APP_PID=$!
# Wait for the app to be ready
echo "Waiting for app to start..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8000 > /dev/null 2>&1; then
echo "App is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "App failed to start within 60s"
exit 1
fi
sleep 1
done
BASE_URL=http://localhost:8000 bunx playwright test --reporter=list
kill "$APP_PID" 2>/dev/null || true
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup Keycloak
if: always()
run: docker rm -f ci-keycloak 2>/dev/null || true
- name: Show sccache stats
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 3: Deploy (only after tests pass, only on main)
# ---------------------------------------------------------------------------
deploy:
name: Deploy
runs-on: docker
needs: [test]
needs: [test, e2e]
if: github.ref == 'refs/heads/main'
container:
image: alpine:latest

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ keycloak/*
node_modules/
searxng/
# Playwright
e2e/.auth/
playwright-report/
test-results/

65
Cargo.lock generated
View File

@@ -776,6 +776,7 @@ dependencies = [
"maud",
"mongodb",
"petname",
"pretty_assertions",
"pulldown-cmark",
"rand 0.10.0",
"reqwest 0.13.2",
@@ -783,6 +784,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"serial_test",
"sha2",
"thiserror 2.0.18",
"time",
@@ -882,6 +884,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -3246,6 +3254,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3823,6 +3841,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -3862,6 +3889,12 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "secrecy"
version = "0.10.3"
@@ -4082,6 +4115,32 @@ dependencies = [
"syn 2.0.116",
]
[[package]]
name = "serial_test"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
dependencies = [
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
@@ -5683,6 +5742,12 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yazi"
version = "0.1.6"

View File

@@ -112,6 +112,10 @@ server = [
"dep:bytes",
]
[dev-dependencies]
pretty_assertions = "1.4"
serial_test = "3.2"
[[bin]]
name = "dashboard"
path = "bin/main.rs"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -8,6 +8,7 @@
"tailwindcss": "^4.1.18",
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest",
},
"peerDependencies": {
@@ -16,6 +17,8 @@
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
@@ -24,6 +27,12 @@
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View File

@@ -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:

24
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test as setup, expect } from "@playwright/test";
const AUTH_FILE = "e2e/.auth/user.json";
setup("authenticate via Keycloak", async ({ page }) => {
// Navigate to a protected route to trigger the auth redirect chain:
// /dashboard -> /auth (Axum) -> Keycloak login page
await page.goto("/dashboard");
// Wait for Keycloak login form to appear
await page.waitForSelector("#username", { timeout: 15_000 });
// Fill Keycloak credentials
await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local");
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
// Wait for redirect back to the app dashboard
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar")).toBeVisible();
// Persist authenticated state (cookies + localStorage)
await page.context().storageState({ path: AUTH_FILE });
});

72
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test";
// These tests use a fresh browser context (no saved auth state)
test.use({ storageState: { cookies: [], origins: [] } });
test.describe("Authentication flow", () => {
test("unauthenticated visit to /dashboard redirects to Keycloak", async ({
page,
}) => {
await page.goto("/dashboard");
// Should end up on Keycloak login page
await page.waitForSelector("#username", { timeout: 15_000 });
await expect(page.locator("#kc-login")).toBeVisible();
});
test("valid credentials log in and redirect to dashboard", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".dashboard-page")).toBeVisible();
});
test("dashboard shows sidebar with user info after login", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar-name")).toBeVisible();
await expect(page.locator(".sidebar-email")).toBeVisible();
});
test("logout redirects away from dashboard", async ({ page }) => {
// First log in
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
// Click logout
await page.locator('a.logout-btn, a[href="/logout"]').click();
// Should no longer be on the dashboard
await expect(page).not.toHaveURL(/\/dashboard/);
});
});

75
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
// Wait for WASM hydration and auth check to complete
await page.waitForSelector(".dashboard-page", { timeout: 15_000 });
});
test("dashboard page loads with page header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Dashboard");
});
test("default topic chips are visible", async ({ page }) => {
const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"];
for (const topic of topics) {
await expect(
page.locator(".filter-tab", { hasText: topic })
).toBeVisible();
}
});
test("clicking a topic chip triggers search", async ({ page }) => {
const chip = page.locator(".filter-tab", { hasText: "AI" });
await chip.click();
// Either a loading state or results should appear
const searchingOrResults = page
.locator(".dashboard-loading, .news-grid, .dashboard-empty");
await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 });
});
test("news cards render after search completes", async ({ page }) => {
// Click a topic to trigger search
await page.locator(".filter-tab", { hasText: "Technology" }).click();
// Wait for loading to finish
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {
// Loading may already be done
});
// Either news cards or an empty state message should be visible
const content = page.locator(".news-grid .news-card, .dashboard-empty");
await expect(content.first()).toBeVisible({ timeout: 10_000 });
});
test("clicking a news card opens article detail panel", async ({ page }) => {
// Trigger a search and wait for results
await page.locator(".filter-tab", { hasText: "AI" }).click();
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {});
const firstCard = page.locator(".news-card").first();
// Only test if cards are present (search results depend on live data)
if (await firstCard.isVisible().catch(() => false)) {
await firstCard.click();
await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible();
}
});
test("settings toggle opens settings panel", async ({ page }) => {
const settingsBtn = page.locator(".settings-toggle");
await settingsBtn.click();
await expect(page.locator(".settings-panel")).toBeVisible();
await expect(page.locator(".settings-panel-title")).toBeVisible();
});
});

173
e2e/developer.spec.ts Normal file
View File

@@ -0,0 +1,173 @@
import { test, expect } from "@playwright/test";
test.describe("Developer section", () => {
test("agents page loads with sub-nav tabs", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".developer-shell", { timeout: 15_000 });
const nav = page.locator(".sub-nav");
await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
});
test("agents page renders informational landing", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
// Hero section
await expect(page.locator(".agents-hero-title")).toContainText(
"Agent Builder"
);
await expect(page.locator(".agents-hero-desc")).toBeVisible();
// Connection status indicator is present
await expect(page.locator(".agents-status")).toBeVisible();
});
test("agents page shows Not Connected when URL is empty", async ({
page,
}) => {
await page.goto("/developer/agents");
await page.waitForSelector(".agents-page", { timeout: 15_000 });
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();
}
});
});

52
e2e/navigation.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
test.describe("Sidebar navigation", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
await page.waitForSelector(".sidebar", { timeout: 15_000 });
});
test("sidebar links route to correct pages", async ({ page }) => {
const navTests = [
{ label: "Providers", url: /\/providers/ },
{ label: "Developer", url: /\/developer\/agents/ },
{ label: "Organization", url: /\/organization\/pricing/ },
{ label: "Dashboard", url: /\/dashboard/ },
];
for (const { label, url } of navTests) {
await page.locator(".sidebar-link", { hasText: label }).click();
await expect(page).toHaveURL(url, { timeout: 10_000 });
}
});
test("browser back/forward navigation works", async ({ page }) => {
// Navigate to Providers
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Navigate to Developer
await page.locator(".sidebar-link", { hasText: "Developer" }).click();
await expect(page).toHaveURL(/\/developer/);
// Go back
await page.goBack();
await expect(page).toHaveURL(/\/providers/);
// Go forward
await page.goForward();
await expect(page).toHaveURL(/\/developer/);
});
test("logo link navigates to dashboard", async ({ page }) => {
// Navigate away first
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Click the logo/brand in sidebar header
const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first();
await logo.click();
await expect(page).toHaveURL(/\/dashboard/);
});
});

41
e2e/organization.spec.ts Normal file
View File

@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
test.describe("Organization section", () => {
test("pricing page loads with three pricing cards", async ({ page }) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
const cards = page.locator(".pricing-card");
await expect(cards).toHaveCount(3);
});
test("pricing cards show Starter, Team, Enterprise tiers", async ({
page,
}) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible();
});
test("organization dashboard loads with billing stats", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".page-header")).toContainText("Organization");
await expect(page.locator(".org-stats-bar")).toBeVisible();
await expect(page.locator(".org-stat").first()).toBeVisible();
});
test("member table is visible on org dashboard", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".org-table")).toBeVisible();
await expect(page.locator(".org-table thead")).toContainText("Name");
await expect(page.locator(".org-table thead")).toContainText("Email");
await expect(page.locator(".org-table thead")).toContainText("Role");
});
});

55
e2e/providers.spec.ts Normal file
View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
test.describe("Providers page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/providers");
await page.waitForSelector(".providers-page", { timeout: 15_000 });
});
test("providers page loads with header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Providers");
});
test("provider dropdown has Ollama selected by default", async ({
page,
}) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
await expect(providerSelect).toHaveValue(/ollama/i);
});
test("changing provider updates the model dropdown", async ({ page }) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
// Get current model options
const modelSelect = page
.locator(".form-group")
.filter({ hasText: /^Model/ })
.locator("select");
const initialOptions = await modelSelect.locator("option").allTextContents();
// Change to a different provider
await providerSelect.selectOption({ label: "OpenAI" });
// Wait for model list to update
await page.waitForTimeout(500);
const updatedOptions = await modelSelect.locator("option").allTextContents();
// Model options should differ between providers
expect(updatedOptions).not.toEqual(initialOptions);
});
test("save button shows confirmation feedback", async ({ page }) => {
const saveBtn = page.locator("button", { hasText: "Save Configuration" });
await saveBtn.click();
await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 });
await expect(page.locator(".form-success")).toContainText("saved");
});
});

60
e2e/public.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";
test.describe("Public pages", () => {
test("landing page loads with heading and nav links", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI");
await expect(page.locator(".landing-nav-links")).toBeVisible();
await expect(page.locator('a[href="#features"]')).toBeVisible();
await expect(page.locator('a[href="#how-it-works"]')).toBeVisible();
await expect(page.locator('a[href="#pricing"]')).toBeVisible();
});
test("landing page Log In link navigates to login route", async ({
page,
}) => {
await page.goto("/");
const loginLink = page
.locator(".landing-nav-actions a, .landing-nav-actions Link")
.filter({ hasText: "Log In" });
await loginLink.click();
await expect(page).toHaveURL(/\/login/);
});
test("impressum page loads with legal content", async ({ page }) => {
await page.goto("/impressum");
await expect(page.locator("h1")).toHaveText("Impressum");
await expect(
page.locator("h2", { hasText: "Information according to" })
).toBeVisible();
await expect(page.locator(".legal-content")).toContainText(
"CERTifAI GmbH"
);
});
test("privacy page loads with privacy content", async ({ page }) => {
await page.goto("/privacy");
await expect(page.locator("h1")).toHaveText("Privacy Policy");
await expect(
page.locator("h2", { hasText: "Introduction" })
).toBeVisible();
await expect(
page.locator("h2", { hasText: "Your Rights" })
).toBeVisible();
});
test("footer links are present on landing page", async ({ page }) => {
await page.goto("/");
const footer = page.locator(".landing-footer");
await expect(footer.locator('a:has-text("Impressum")')).toBeVisible();
await expect(
footer.locator('a:has-text("Privacy Policy")')
).toBeVisible();
});
});

View File

@@ -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",

View File

@@ -4,6 +4,7 @@
"type": "module",
"private": true,
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest"
},
"peerDependencies": {

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
timeout: 30_000,
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:8000",
actionTimeout: 10_000,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "public",
testMatch: /public\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "authenticated",
testMatch: /\.spec\.ts$/,
testIgnore: /public\.spec\.ts$/,
dependencies: ["setup"],
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
},
],
});

View File

@@ -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"

View File

@@ -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::*;

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

@@ -24,9 +24,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// post-login redirect URL and the PKCE code verifier needed for the
/// token exchange.
#[derive(Debug, Clone)]
struct PendingOAuthEntry {
redirect_url: Option<String>,
code_verifier: String,
pub(crate) struct PendingOAuthEntry {
pub(crate) redirect_url: Option<String>,
pub(crate) code_verifier: String,
}
/// In-memory store for pending OAuth states. Keyed by the random state
@@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional redirect URL and PKCE verifier.
fn insert(&self, state: String, entry: PendingOAuthEntry) {
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
@@ -50,7 +50,7 @@ impl PendingOAuthStore {
/// Remove and return the entry if the state was pending.
/// Returns `None` if the state was never stored (CSRF failure).
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
#[allow(clippy::expect_used)]
self.0
.write()
@@ -60,7 +60,8 @@ impl PendingOAuthStore {
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random();
// Encode as hex to produce a URL-safe string without padding.
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
@@ -75,7 +76,7 @@ fn generate_state() -> String {
///
/// Uses 32 random bytes encoded as base64url (no padding) to produce
/// a 43-character verifier per RFC 7636.
fn generate_code_verifier() -> String {
pub(crate) fn generate_code_verifier() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let bytes: [u8; 32] = rand::rng().random();
@@ -85,7 +86,7 @@ fn generate_code_verifier() -> String {
/// Derive the S256 code challenge from a code verifier per RFC 7636.
///
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
fn derive_code_challenge(verifier: &str) -> String {
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};
@@ -304,3 +305,117 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
.await
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// generate_state()
// -----------------------------------------------------------------------
#[test]
fn generate_state_length_is_64() {
let state = generate_state();
assert_eq!(state.len(), 64);
}
#[test]
fn generate_state_chars_are_hex() {
let state = generate_state();
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn generate_state_two_calls_differ() {
let a = generate_state();
let b = generate_state();
assert_ne!(a, b);
}
// -----------------------------------------------------------------------
// generate_code_verifier()
// -----------------------------------------------------------------------
#[test]
fn code_verifier_length_is_43() {
let verifier = generate_code_verifier();
assert_eq!(verifier.len(), 43);
}
#[test]
fn code_verifier_chars_are_url_safe_base64() {
let verifier = generate_code_verifier();
// URL-safe base64 without padding uses [A-Za-z0-9_-]
assert!(verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
// -----------------------------------------------------------------------
// derive_code_challenge()
// -----------------------------------------------------------------------
#[test]
fn code_challenge_deterministic() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let a = derive_code_challenge(verifier);
let b = derive_code_challenge(verifier);
assert_eq!(a, b);
}
#[test]
fn code_challenge_rfc7636_test_vector() {
// RFC 7636 Appendix B test vector:
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = derive_code_challenge(verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
// -----------------------------------------------------------------------
// PendingOAuthStore
// -----------------------------------------------------------------------
#[test]
fn pending_store_insert_and_take() {
let store = PendingOAuthStore::default();
store.insert(
"state-1".into(),
PendingOAuthEntry {
redirect_url: Some("/dashboard".into()),
code_verifier: "verifier-1".into(),
},
);
let entry = store.take("state-1");
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.redirect_url, Some("/dashboard".into()));
assert_eq!(entry.code_verifier, "verifier-1");
}
#[test]
fn pending_store_take_removes_entry() {
let store = PendingOAuthStore::default();
store.insert(
"state-2".into(),
PendingOAuthEntry {
redirect_url: None,
code_verifier: "v2".into(),
},
);
let _ = store.take("state-2");
// Second take should return None since the entry was removed.
assert!(store.take("state-2").is_none());
}
#[test]
fn pending_store_take_unknown_returns_none() {
let store = PendingOAuthStore::default();
assert!(store.take("nonexistent").is_none());
}
}

View File

@@ -27,6 +27,15 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
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<AuthInfo, ServerFnError> {
name: u.user.name,
avatar_url: u.user.avatar_url,
librechat_url,
langgraph_url,
langflow_url,
langfuse_url,
})
}
None => Ok(AuthInfo::default()),

View File

@@ -440,7 +440,12 @@ pub async fn chat_complete(
let session = doc_to_chat_session(&session_doc);
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model);
let (base_url, model) = resolve_provider_url(
&state.services.ollama_url,
&state.services.ollama_model,
&session.provider,
&session.model,
);
// Parse messages from JSON
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
@@ -480,10 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to server defaults.
/// Resolve the base URL for a provider, falling back to Ollama defaults.
///
/// # Arguments
///
/// * `ollama_url` - Default Ollama base URL from config
/// * `ollama_model` - Default Ollama model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for Ollama default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
fn resolve_provider_url(
state: &crate::infrastructure::ServerState,
pub(crate) fn resolve_provider_url(
ollama_url: &str,
ollama_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -496,12 +513,229 @@ fn resolve_provider_url(
),
// Default to Ollama
_ => (
state.services.ollama_url.clone(),
ollama_url.to_string(),
if model.is_empty() {
state.services.ollama_model.clone()
ollama_model.to_string()
} else {
model.to_string()
},
),
}
}
#[cfg(test)]
mod tests {
// -----------------------------------------------------------------------
// BSON document conversion tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
use crate::models::{ChatNamespace, ChatRole};
use mongodb::bson::{doc, oid::ObjectId, Document};
use pretty_assertions::assert_eq;
// -- doc_to_chat_session --
fn sample_session_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "user-42",
"title": "Test Session",
"namespace": "News",
"provider": "openai",
"model": "gpt-4",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z",
"article_url": "https://example.com/article",
};
(oid, doc)
}
#[test]
fn doc_to_chat_session_extracts_id_as_hex() {
let (oid, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.id, oid.to_hex());
}
#[test]
fn doc_to_chat_session_maps_news_namespace() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::News);
}
#[test]
fn doc_to_chat_session_defaults_to_general_for_unknown() {
let mut doc = sample_session_doc().1;
doc.insert("namespace", "SomethingElse");
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::General);
}
#[test]
fn doc_to_chat_session_extracts_all_string_fields() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.user_sub, "user-42");
assert_eq!(session.title, "Test Session");
assert_eq!(session.provider, "openai");
assert_eq!(session.model, "gpt-4");
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
}
#[test]
fn doc_to_chat_session_handles_missing_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
#[test]
fn doc_to_chat_session_filters_empty_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
"article_url": "",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
// -- doc_to_chat_message --
fn sample_message_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"session_id": "sess-1",
"role": "Assistant",
"content": "Hello there!",
"timestamp": "2025-01-01T12:00:00Z",
};
(oid, doc)
}
#[test]
fn doc_to_chat_message_extracts_id_as_hex() {
let (oid, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.id, oid.to_hex());
}
#[test]
fn doc_to_chat_message_maps_assistant_role() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::Assistant);
}
#[test]
fn doc_to_chat_message_maps_system_role() {
let mut doc = sample_message_doc().1;
doc.insert("role", "System");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::System);
}
#[test]
fn doc_to_chat_message_defaults_to_user_for_unknown() {
let mut doc = sample_message_doc().1;
doc.insert("role", "SomethingElse");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::User);
}
#[test]
fn doc_to_chat_message_extracts_content_and_timestamp() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.content, "Hello there!");
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
assert_eq!(msg.session_id, "sess-1");
}
#[test]
fn doc_to_chat_message_attachments_always_empty() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert!(msg.attachments.is_empty());
}
// -- resolve_provider_url --
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"anthropic",
"claude-3-opus",
);
assert_eq!(url, "https://api.anthropic.com");
assert_eq!(model, "claude-3-opus");
}
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
assert_eq!(
url,
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
);
assert_eq!(model, "meta-llama/Llama-2-7b");
}
#[test]
fn resolve_unknown_defaults_to_ollama() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "mistral:7b");
}
#[test]
fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_OLLAMA_MODEL);
}
}
}

View File

@@ -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"),
@@ -251,3 +254,160 @@ impl LlmProvidersConfig {
Ok(Self { providers })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
use serial_test::serial;
// -----------------------------------------------------------------------
// KeycloakConfig endpoint methods (no env vars needed)
// -----------------------------------------------------------------------
fn sample_keycloak() -> KeycloakConfig {
KeycloakConfig {
url: "https://auth.example.com".into(),
realm: "myrealm".into(),
client_id: "dashboard".into(),
redirect_uri: "https://app.example.com/callback".into(),
app_url: "https://app.example.com".into(),
admin_client_id: String::new(),
admin_client_secret: SecretString::from(String::new()),
}
}
#[test]
fn keycloak_auth_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.auth_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
);
}
#[test]
fn keycloak_token_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.token_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
);
}
#[test]
fn keycloak_userinfo_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.userinfo_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
);
}
#[test]
fn keycloak_logout_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.logout_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
);
}
// -----------------------------------------------------------------------
// LlmProvidersConfig::from_env()
// -----------------------------------------------------------------------
#[test]
#[serial]
fn llm_providers_empty_string() {
std::env::set_var("LLM_PROVIDERS", "");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert!(cfg.providers.is_empty());
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "ollama");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_multiple() {
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_trims_whitespace() {
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_filters_empty_entries() {
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
// -----------------------------------------------------------------------
// ServiceUrls::from_env() defaults
// -----------------------------------------------------------------------
#[test]
#[serial]
fn service_urls_default_ollama_url() {
std::env::remove_var("OLLAMA_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://localhost:11434");
}
#[test]
#[serial]
fn service_urls_default_ollama_model() {
std::env::remove_var("OLLAMA_MODEL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_model, "llama3.1:8b");
}
#[test]
#[serial]
fn service_urls_default_searxng_url() {
std::env::remove_var("SEARXNG_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.searxng_url, "http://localhost:8888");
}
#[test]
#[serial]
fn service_urls_custom_ollama_url() {
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
std::env::remove_var("OLLAMA_URL");
}
#[test]
#[serial]
fn required_env_missing_returns_config_error() {
std::env::remove_var("__TEST_REQUIRED_MISSING__");
let result = required_env("__TEST_REQUIRED_MISSING__");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
}
}

View File

@@ -41,3 +41,53 @@ impl IntoResponse for Error {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;
use pretty_assertions::assert_eq;
#[test]
fn state_error_display() {
let err = Error::StateError("bad state".into());
assert_eq!(err.to_string(), "bad state");
}
#[test]
fn database_error_display() {
let err = Error::DatabaseError("connection lost".into());
assert_eq!(err.to_string(), "database error: connection lost");
}
#[test]
fn config_error_display() {
let err = Error::ConfigError("missing var".into());
assert_eq!(err.to_string(), "configuration error: missing var");
}
#[test]
fn state_error_into_response_500() {
let resp = Error::StateError("oops".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn database_error_into_response_503() {
let resp = Error::DatabaseError("down".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn config_error_into_response_500() {
let resp = Error::ConfigError("bad cfg".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn io_error_into_response_500() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let resp = Error::IoError(io_err).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}

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

@@ -72,7 +72,25 @@ mod inner {
}
let html = resp.text().await.ok()?;
let document = scraper::Html::parse_document(&html);
parse_article_html(&html)
}
/// Parse article text from raw HTML without any network I/O.
///
/// Uses a tiered extraction strategy:
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
/// 2. Fall back to all `<p>` tags outside excluded containers
///
/// # Arguments
///
/// * `html` - Raw HTML string to parse
///
/// # Returns
///
/// The extracted text, or `None` if extraction yields < 100 chars.
/// Output is capped at 8000 characters.
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
let document = scraper::Html::parse_document(html);
// Strategy 1: Extract from semantic article containers.
// Most news sites wrap the main content in <article>, <main>,
@@ -134,7 +152,7 @@ mod inner {
}
/// Sum the total character length of all collected text parts.
fn joined_len(parts: &[String]) -> usize {
pub(crate) fn joined_len(parts: &[String]) -> usize {
parts.iter().map(|s| s.len()).sum()
}
}
@@ -325,3 +343,150 @@ pub async fn chat_followup(
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// FollowUpMessage serde tests
// -----------------------------------------------------------------------
#[test]
fn followup_message_serde_round_trip() {
let msg = FollowUpMessage {
role: "assistant".into(),
content: "Here is my answer.".into(),
};
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
let back: FollowUpMessage =
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
assert_eq!(msg, back);
}
#[test]
fn followup_message_deserialize_from_json_literal() {
let json = r#"{"role":"system","content":"You are helpful."}"#;
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
assert_eq!(msg.role, "system");
assert_eq!(msg.content, "You are helpful.");
}
// -----------------------------------------------------------------------
// joined_len and parse_article_html tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::inner::{joined_len, parse_article_html};
use pretty_assertions::assert_eq;
#[test]
fn joined_len_empty_input() {
assert_eq!(joined_len(&[]), 0);
}
#[test]
fn joined_len_sums_correctly() {
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
assert_eq!(joined_len(&parts), 10);
}
// -------------------------------------------------------------------
// parse_article_html tests
// -------------------------------------------------------------------
// Helper: generate a string of given length from a repeated word.
fn lorem(len: usize) -> String {
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
.repeat((len / 55) + 1)
.chars()
.take(len)
.collect()
}
#[test]
fn article_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for article tag");
assert!(result.unwrap().contains("Lorem"));
}
#[test]
fn main_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for main tag");
}
#[test]
fn fallback_to_p_tags_when_article_main_yield_little() {
// No <article>/<main>, so falls back to <p> tags
let body = lorem(250);
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected fallback to <p> tags");
}
#[test]
fn excludes_nav_footer_aside_content() {
// Content only inside excluded containers -- should be excluded
let body = lorem(250);
let html = format!(
"<html><body>\
<nav><p>{body}</p></nav>\
<footer><p>{body}</p></footer>\
<aside><p>{body}</p></aside>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_none(), "expected None for excluded-only content");
}
#[test]
fn returns_none_when_text_too_short() {
let html = "<html><body><p>Short.</p></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for short text");
}
#[test]
fn truncates_at_8000_chars() {
let body = lorem(10000);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html).expect("expected Some");
assert!(
result.len() <= 8000,
"expected <= 8000 chars, got {}",
result.len()
);
}
#[test]
fn skips_fragments_under_30_chars() {
// Only fragments < 30 chars -- should yield None
let html = "<html><body><article>\
<p>Short frag one</p>\
<p>Another tiny bit</p>\
</article></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for tiny fragments");
}
#[test]
fn extracts_from_role_main_attribute() {
let body = lorem(250);
let html = format!(
"<html><body>\
<div role=\"main\"><p>{body}</p></div>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for role=main");
}
}
}

View File

@@ -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;

View File

@@ -146,3 +146,30 @@ pub async fn send_chat_request(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn provider_message_serde_round_trip() {
let msg = ProviderMessage {
role: "assistant".into(),
content: "Hello, world!".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
let back: ProviderMessage =
serde_json::from_str(&json).expect("deserialize ProviderMessage");
assert_eq!(msg.role, back.role);
assert_eq!(msg.content, back.content);
}
#[test]
fn provider_message_deserialize_from_json_literal() {
let json = r#"{"role":"user","content":"What is Rust?"}"#;
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "What is Rust?");
}
}

View File

@@ -5,13 +5,13 @@ use dioxus::prelude::*;
// The #[server] macro generates a client stub for the web build that
// sends a network request instead of executing this function body.
#[cfg(feature = "server")]
mod inner {
pub(crate) mod inner {
use serde::Deserialize;
use std::collections::HashSet;
/// Individual result from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResult {
pub(crate) struct SearxngResult {
pub title: String,
pub url: String,
pub content: Option<String>,
@@ -25,7 +25,7 @@ mod inner {
/// Top-level response from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResponse {
pub(crate) struct SearxngResponse {
pub results: Vec<SearxngResult>,
}
@@ -40,7 +40,7 @@ mod inner {
/// # Returns
///
/// The domain host or a fallback "Web" string
pub(super) fn extract_source(url_str: &str) -> String {
pub(crate) fn extract_source(url_str: &str) -> String {
url::Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(String::from))
@@ -64,7 +64,7 @@ mod inner {
/// # Returns
///
/// Filtered, deduplicated, and ranked results
pub(super) fn rank_and_deduplicate(
pub(crate) fn rank_and_deduplicate(
mut results: Vec<SearxngResult>,
max_results: usize,
) -> Vec<SearxngResult> {
@@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
Ok(topics)
}
#[cfg(all(test, feature = "server"))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::inner::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// extract_source()
// -----------------------------------------------------------------------
#[test]
fn extract_source_strips_www() {
assert_eq!(
extract_source("https://www.example.com/page"),
"example.com"
);
}
#[test]
fn extract_source_returns_domain() {
assert_eq!(
extract_source("https://techcrunch.com/article"),
"techcrunch.com"
);
}
#[test]
fn extract_source_invalid_url_returns_web() {
assert_eq!(extract_source("not-a-url"), "Web");
}
#[test]
fn extract_source_no_scheme_returns_web() {
// url::Url::parse requires a scheme; bare domain fails
assert_eq!(extract_source("example.com/path"), "Web");
}
// -----------------------------------------------------------------------
// rank_and_deduplicate()
// -----------------------------------------------------------------------
fn make_result(url: &str, content: &str, score: f64) -> SearxngResult {
SearxngResult {
title: "Title".into(),
url: url.into(),
content: if content.is_empty() {
None
} else {
Some(content.into())
},
published_date: None,
thumbnail: None,
score,
}
}
#[test]
fn rank_filters_empty_content() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result(
"https://b.com",
"This is meaningful content that passes the length filter",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].url, "https://b.com");
}
#[test]
fn rank_filters_short_content() {
let results = vec![
make_result("https://a.com", "short", 10.0),
make_result(
"https://b.com",
"This content is long enough to pass the 20-char filter threshold",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
}
#[test]
fn rank_deduplicates_by_domain_keeps_highest() {
let results = vec![
make_result(
"https://example.com/page1",
"First result with enough content here for the filter",
3.0,
),
make_result(
"https://example.com/page2",
"Second result with enough content here for the filter",
8.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
// Should keep the highest-scored one (page2 with score 8.0)
assert_eq!(ranked[0].url, "https://example.com/page2");
}
#[test]
fn rank_sorts_by_score_descending() {
let results = vec![
make_result(
"https://a.com/p",
"Content A that is long enough to pass the filter check",
1.0,
),
make_result(
"https://b.com/p",
"Content B that is long enough to pass the filter check",
5.0,
),
make_result(
"https://c.com/p",
"Content C that is long enough to pass the filter check",
3.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 3);
assert!(ranked[0].score >= ranked[1].score);
assert!(ranked[1].score >= ranked[2].score);
}
#[test]
fn rank_truncates_to_max_results() {
let results: Vec<_> = (0..20)
.map(|i| {
make_result(
&format!("https://site{i}.com/page"),
&format!("Content for site {i} that is long enough to pass the filter"),
i as f64,
)
})
.collect();
let ranked = rank_and_deduplicate(results, 5);
assert_eq!(ranked.len(), 5);
}
#[test]
fn rank_empty_input_returns_empty() {
let ranked = rank_and_deduplicate(vec![], 10);
assert!(ranked.is_empty());
}
#[test]
fn rank_all_filtered_returns_empty() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result("https://b.com", "too short", 5.0),
];
let ranked = rank_and_deduplicate(results, 10);
assert!(ranked.is_empty());
}
}

View File

@@ -44,3 +44,91 @@ pub struct User {
/// Avatar / profile picture URL.
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_state_inner_default_has_empty_strings() {
let inner = UserStateInner::default();
assert_eq!(inner.sub, "");
assert_eq!(inner.access_token, "");
assert_eq!(inner.refresh_token, "");
assert_eq!(inner.user.email, "");
assert_eq!(inner.user.name, "");
assert_eq!(inner.user.avatar_url, "");
}
#[test]
fn user_default_has_empty_strings() {
let user = User::default();
assert_eq!(user.email, "");
assert_eq!(user.name, "");
assert_eq!(user.avatar_url, "");
}
#[test]
fn user_state_inner_serde_round_trip() {
let inner = UserStateInner {
sub: "user-123".into(),
access_token: "tok-abc".into(),
refresh_token: "ref-xyz".into(),
user: User {
email: "a@b.com".into(),
name: "Alice".into(),
avatar_url: "https://img.example.com/a.png".into(),
},
};
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
assert_eq!(inner.sub, back.sub);
assert_eq!(inner.access_token, back.access_token);
assert_eq!(inner.refresh_token, back.refresh_token);
assert_eq!(inner.user.email, back.user.email);
assert_eq!(inner.user.name, back.user.name);
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
}
#[test]
fn user_state_from_inner_and_deref() {
let inner = UserStateInner {
sub: "sub-1".into(),
access_token: "at".into(),
refresh_token: "rt".into(),
user: User {
email: "e@e.com".into(),
name: "Eve".into(),
avatar_url: "".into(),
},
};
let state = UserState::from(inner);
// Deref should give access to inner fields
assert_eq!(state.sub, "sub-1");
assert_eq!(state.user.name, "Eve");
}
#[test]
fn user_serde_round_trip() {
let user = User {
email: "bob@test.com".into(),
name: "Bob".into(),
avatar_url: "https://avatars.io/bob".into(),
};
let json = serde_json::to_string(&user).expect("serialize User");
let back: User = serde_json::from_str(&json).expect("deserialize User");
assert_eq!(user.email, back.email);
assert_eq!(user.name, back.name);
assert_eq!(user.avatar_url, back.avatar_url);
}
#[test]
fn user_state_clone_is_cheap() {
let inner = UserStateInner::default();
let state = UserState::from(inner);
let cloned = state.clone();
// Both point to the same Arc allocation
assert_eq!(state.sub, cloned.sub);
}
}

View File

@@ -105,3 +105,163 @@ pub struct ChatMessage {
pub attachments: Vec<Attachment>,
pub timestamp: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn chat_namespace_default_is_general() {
assert_eq!(ChatNamespace::default(), ChatNamespace::General);
}
#[test]
fn chat_role_serde_round_trip() {
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
let json =
serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: ChatRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(role, back);
}
}
#[test]
fn chat_namespace_serde_round_trip() {
for ns in [ChatNamespace::General, ChatNamespace::News] {
let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns));
let back: ChatNamespace =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns));
assert_eq!(ns, back);
}
}
#[test]
fn attachment_kind_serde_round_trip() {
for kind in [
AttachmentKind::Image,
AttachmentKind::Document,
AttachmentKind::Code,
] {
let json =
serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind));
let back: AttachmentKind =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind));
assert_eq!(kind, back);
}
}
#[test]
fn attachment_serde_round_trip() {
let att = Attachment {
name: "photo.png".into(),
kind: AttachmentKind::Image,
size_bytes: 2048,
};
let json = serde_json::to_string(&att).expect("serialize Attachment");
let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment");
assert_eq!(att, back);
}
#[test]
fn chat_session_serde_round_trip() {
let session = ChatSession {
id: "abc123".into(),
user_sub: "user-1".into(),
title: "Test Chat".into(),
namespace: ChatNamespace::General,
provider: "ollama".into(),
model: "llama3.1:8b".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize ChatSession");
let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession");
assert_eq!(session, back);
}
#[test]
fn chat_session_id_alias_deserialization() {
// MongoDB returns `_id` instead of `id`
let json = r#"{
"_id": "mongo-id",
"user_sub": "u1",
"title": "t",
"provider": "ollama",
"model": "m",
"created_at": "2025-01-01",
"updated_at": "2025-01-01"
}"#;
let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(session.id, "mongo-id");
}
#[test]
fn chat_session_empty_id_skips_serialization() {
let session = ChatSession {
id: String::new(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
// `id` field should be absent when empty due to skip_serializing_if
assert!(!json.contains("\"id\""));
}
#[test]
fn chat_session_none_article_url_skips_serialization() {
let session = ChatSession {
id: "s1".into(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
assert!(!json.contains("article_url"));
}
#[test]
fn chat_message_serde_round_trip() {
let msg = ChatMessage {
id: "msg-1".into(),
session_id: "s1".into(),
role: ChatRole::User,
content: "Hello AI".into(),
attachments: vec![Attachment {
name: "doc.pdf".into(),
kind: AttachmentKind::Document,
size_bytes: 4096,
}],
timestamp: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ChatMessage");
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage");
assert_eq!(msg, back);
}
#[test]
fn chat_message_id_alias_deserialization() {
let json = r#"{
"_id": "mongo-msg-id",
"session_id": "s1",
"role": "User",
"content": "hi",
"timestamp": "2025-01-01"
}"#;
let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(msg.id, "mongo-msg-id");
}
}

View File

@@ -45,3 +45,63 @@ pub struct AnalyticsMetric {
pub value: String,
pub change_pct: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn agent_entry_serde_round_trip() {
let agent = AgentEntry {
id: "a1".into(),
name: "RAG Agent".into(),
description: "Retrieval-augmented generation".into(),
status: "running".into(),
};
let json = serde_json::to_string(&agent).expect("serialize AgentEntry");
let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry");
assert_eq!(agent, back);
}
#[test]
fn flow_entry_serde_round_trip() {
let flow = FlowEntry {
id: "f1".into(),
name: "Data Pipeline".into(),
node_count: 5,
last_run: Some("2025-06-01T12:00:00Z".into()),
};
let json = serde_json::to_string(&flow).expect("serialize FlowEntry");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry");
assert_eq!(flow, back);
}
#[test]
fn flow_entry_with_none_last_run() {
let flow = FlowEntry {
id: "f2".into(),
name: "New Flow".into(),
node_count: 0,
last_run: None,
};
let json = serde_json::to_string(&flow).expect("serialize");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(flow, back);
assert_eq!(back.last_run, None);
}
#[test]
fn analytics_metric_negative_change_pct() {
let metric = AnalyticsMetric {
label: "Latency".into(),
value: "120ms".into(),
change_pct: -15.5,
};
let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric");
let back: AnalyticsMetric =
serde_json::from_str(&json).expect("deserialize AnalyticsMetric");
assert_eq!(metric, back);
assert!(back.change_pct < 0.0);
}
}

View File

@@ -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::*;

View File

@@ -23,3 +23,61 @@ pub struct NewsCard {
pub thumbnail_url: Option<String>,
pub published_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn news_card_serde_round_trip() {
let card = NewsCard {
title: "AI Breakthrough".into(),
source: "techcrunch.com".into(),
summary: "New model released".into(),
content: "Full article content here".into(),
category: "AI".into(),
url: "https://example.com/article".into(),
thumbnail_url: Some("https://example.com/thumb.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize NewsCard");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_none() {
let card = NewsCard {
title: "No Thumb".into(),
source: "bbc.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "Tech".into(),
url: "https://bbc.com/article".into(),
thumbnail_url: None,
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_some() {
let card = NewsCard {
title: "With Thumb".into(),
source: "cnn.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "News".into(),
url: "https://cnn.com/article".into(),
thumbnail_url: Some("https://cnn.com/img.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
assert!(json.contains("img.jpg"));
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card.thumbnail_url, back.thumbnail_url);
}
}

View File

@@ -116,3 +116,122 @@ pub struct OrgBillingRecord {
/// Number of tokens consumed during this cycle.
pub tokens_used: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn member_role_label_admin() {
assert_eq!(MemberRole::Admin.label(), "Admin");
}
#[test]
fn member_role_label_member() {
assert_eq!(MemberRole::Member.label(), "Member");
}
#[test]
fn member_role_label_viewer() {
assert_eq!(MemberRole::Viewer.label(), "Viewer");
}
#[test]
fn member_role_all_returns_three_in_order() {
let all = MemberRole::all();
assert_eq!(all.len(), 3);
assert_eq!(all[0], MemberRole::Admin);
assert_eq!(all[1], MemberRole::Member);
assert_eq!(all[2], MemberRole::Viewer);
}
#[test]
fn member_role_serde_round_trip() {
for role in MemberRole::all() {
let json =
serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: MemberRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(*role, back);
}
}
#[test]
fn org_member_serde_round_trip() {
let member = OrgMember {
id: "m1".into(),
name: "Alice".into(),
email: "alice@example.com".into(),
role: MemberRole::Admin,
joined_at: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&member).expect("serialize OrgMember");
let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember");
assert_eq!(member, back);
}
#[test]
fn pricing_plan_with_max_seats() {
let plan = PricingPlan {
id: "team".into(),
name: "Team".into(),
price_eur: 49,
features: vec!["SSO".into(), "Priority".into()],
highlighted: true,
max_seats: Some(25),
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
}
#[test]
fn pricing_plan_without_max_seats() {
let plan = PricingPlan {
id: "enterprise".into(),
name: "Enterprise".into(),
price_eur: 199,
features: vec!["Unlimited".into()],
highlighted: false,
max_seats: None,
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
assert!(json.contains("null") || !json.contains("max_seats"));
}
#[test]
fn billing_usage_serde_round_trip() {
let usage = BillingUsage {
seats_used: 5,
seats_total: 10,
tokens_used: 1_000_000,
tokens_limit: 5_000_000,
billing_cycle_end: "2025-12-31".into(),
};
let json = serde_json::to_string(&usage).expect("serialize BillingUsage");
let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage");
assert_eq!(usage, back);
}
#[test]
fn org_settings_default() {
let settings = OrgSettings::default();
assert_eq!(settings.org_id, "");
assert_eq!(settings.plan_id, "");
assert!(settings.enabled_features.is_empty());
assert_eq!(settings.stripe_customer_id, "");
}
#[test]
fn org_billing_record_default() {
let record = OrgBillingRecord::default();
assert_eq!(record.org_id, "");
assert_eq!(record.cycle_start, "");
assert_eq!(record.cycle_end, "");
assert_eq!(record.seats_used, 0);
assert_eq!(record.tokens_used, 0);
}
}

View File

@@ -72,3 +72,84 @@ pub struct ProviderConfig {
pub selected_embedding: String,
pub api_key_set: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn llm_provider_label_ollama() {
assert_eq!(LlmProvider::Ollama.label(), "Ollama");
}
#[test]
fn llm_provider_label_hugging_face() {
assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face");
}
#[test]
fn llm_provider_label_openai() {
assert_eq!(LlmProvider::OpenAi.label(), "OpenAI");
}
#[test]
fn llm_provider_label_anthropic() {
assert_eq!(LlmProvider::Anthropic.label(), "Anthropic");
}
#[test]
fn llm_provider_serde_round_trip() {
for variant in [
LlmProvider::Ollama,
LlmProvider::HuggingFace,
LlmProvider::OpenAi,
LlmProvider::Anthropic,
] {
let json = serde_json::to_string(&variant)
.unwrap_or_else(|_| panic!("serialize {:?}", variant));
let back: LlmProvider =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant));
assert_eq!(variant, back);
}
}
#[test]
fn model_entry_serde_round_trip() {
let entry = ModelEntry {
id: "llama3.1:8b".into(),
name: "Llama 3.1 8B".into(),
provider: LlmProvider::Ollama,
context_window: 8192,
};
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");
assert_eq!(entry, back);
}
#[test]
fn embedding_entry_serde_round_trip() {
let entry = EmbeddingEntry {
id: "nomic-embed".into(),
name: "Nomic Embed".into(),
provider: LlmProvider::HuggingFace,
dimensions: 768,
};
let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry");
let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry");
assert_eq!(entry, back);
}
#[test]
fn provider_config_serde_round_trip() {
let cfg = ProviderConfig {
provider: LlmProvider::Anthropic,
selected_model: "claude-3".into(),
selected_embedding: "embed-v1".into(),
api_key_set: true,
};
let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig");
let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig");
assert_eq!(cfg, back);
}
}

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,
/// 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.
@@ -70,3 +76,87 @@ pub struct UserPreferences {
#[serde(default)]
pub provider_config: UserProviderConfig,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_data_default() {
let ud = UserData::default();
assert_eq!(ud.name, "");
}
#[test]
fn auth_info_default_not_authenticated() {
let info = AuthInfo::default();
assert!(!info.authenticated);
assert_eq!(info.sub, "");
assert_eq!(info.email, "");
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]
fn auth_info_serde_round_trip() {
let info = AuthInfo {
authenticated: true,
sub: "sub-123".into(),
email: "test@example.com".into(),
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");
assert_eq!(info, back);
}
#[test]
fn user_preferences_default() {
let prefs = UserPreferences::default();
assert_eq!(prefs.sub, "");
assert_eq!(prefs.org_id, "");
assert!(prefs.custom_topics.is_empty());
assert!(prefs.recent_searches.is_empty());
}
#[test]
fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig {
default_provider: "ollama".into(),
default_model: "llama3.1:8b".into(),
openai_api_key: None,
anthropic_api_key: None,
huggingface_api_key: None,
ollama_url_override: String::new(),
};
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key"));
assert!(!json.contains("anthropic_api_key"));
assert!(!json.contains("huggingface_api_key"));
}
#[test]
fn user_provider_config_serde_round_trip_with_keys() {
let cfg = UserProviderConfig {
default_provider: "openai".into(),
default_model: "gpt-4o".into(),
openai_api_key: Some("sk-test".into()),
anthropic_api_key: Some("ak-test".into()),
huggingface_api_key: None,
ollama_url_override: "http://custom:11434".into(),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(cfg, back);
}
}

View File

@@ -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::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
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\")}" }
}
}
}

View File

@@ -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::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
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\")}" }
}
}
}

View File

@@ -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::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
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"),
}
}
}