feat(developer): add hybrid iframe integration for developer tools
All checks were successful
All checks were successful
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>
This commit is contained in:
@@ -66,10 +66,11 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# LangChain / LangGraph / Langfuse [OPTIONAL]
|
# LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL]
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
LANGCHAIN_URL=
|
LANGCHAIN_URL=
|
||||||
LANGGRAPH_URL=
|
LANGGRAPH_URL=
|
||||||
|
LANGFLOW_URL=
|
||||||
LANGFUSE_URL=
|
LANGFUSE_URL=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ jobs:
|
|||||||
MONGODB_URI: mongodb://root:example@mongo:27017
|
MONGODB_URI: mongodb://root:example@mongo:27017
|
||||||
MONGODB_DATABASE: certifai
|
MONGODB_DATABASE: certifai
|
||||||
SEARXNG_URL: http://searxng:8080
|
SEARXNG_URL: http://searxng:8080
|
||||||
|
LANGGRAPH_URL: ""
|
||||||
|
LANGFLOW_URL: ""
|
||||||
|
LANGFUSE_URL: ""
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
"total_requests": "Anfragen gesamt",
|
"total_requests": "Anfragen gesamt",
|
||||||
"avg_latency": "Durchschn. Latenz",
|
"avg_latency": "Durchschn. Latenz",
|
||||||
"tokens_used": "Verbrauchte Token",
|
"tokens_used": "Verbrauchte Token",
|
||||||
"error_rate": "Fehlerrate"
|
"error_rate": "Fehlerrate",
|
||||||
|
"not_configured": "Nicht konfiguriert",
|
||||||
|
"open_new_tab": "In neuem Tab oeffnen"
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organisation",
|
"title": "Organisation",
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
"total_requests": "Total Requests",
|
"total_requests": "Total Requests",
|
||||||
"avg_latency": "Avg Latency",
|
"avg_latency": "Avg Latency",
|
||||||
"tokens_used": "Tokens Used",
|
"tokens_used": "Tokens Used",
|
||||||
"error_rate": "Error Rate"
|
"error_rate": "Error Rate",
|
||||||
|
"not_configured": "Not Configured",
|
||||||
|
"open_new_tab": "Open in New Tab"
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organization",
|
"title": "Organization",
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
"total_requests": "Total de solicitudes",
|
"total_requests": "Total de solicitudes",
|
||||||
"avg_latency": "Latencia promedio",
|
"avg_latency": "Latencia promedio",
|
||||||
"tokens_used": "Tokens utilizados",
|
"tokens_used": "Tokens utilizados",
|
||||||
"error_rate": "Tasa de errores"
|
"error_rate": "Tasa de errores",
|
||||||
|
"not_configured": "No configurado",
|
||||||
|
"open_new_tab": "Abrir en nueva pestana"
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organizacion",
|
"title": "Organizacion",
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
"total_requests": "Requetes totales",
|
"total_requests": "Requetes totales",
|
||||||
"avg_latency": "Latence moyenne",
|
"avg_latency": "Latence moyenne",
|
||||||
"tokens_used": "Tokens utilises",
|
"tokens_used": "Tokens utilises",
|
||||||
"error_rate": "Taux d'erreur"
|
"error_rate": "Taux d'erreur",
|
||||||
|
"not_configured": "Non configure",
|
||||||
|
"open_new_tab": "Ouvrir dans un nouvel onglet"
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organisation",
|
"title": "Organisation",
|
||||||
|
|||||||
@@ -96,7 +96,9 @@
|
|||||||
"total_requests": "Total de Pedidos",
|
"total_requests": "Total de Pedidos",
|
||||||
"avg_latency": "Latencia Media",
|
"avg_latency": "Latencia Media",
|
||||||
"tokens_used": "Tokens Utilizados",
|
"tokens_used": "Tokens Utilizados",
|
||||||
"error_rate": "Taxa de Erros"
|
"error_rate": "Taxa de Erros",
|
||||||
|
"not_configured": "Nao configurado",
|
||||||
|
"open_new_tab": "Abrir em novo separador"
|
||||||
},
|
},
|
||||||
"org": {
|
"org": {
|
||||||
"title": "Organizacao",
|
"title": "Organizacao",
|
||||||
|
|||||||
@@ -2591,6 +2591,58 @@ h6 {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Tool Embed (iframe integration) ===== */
|
||||||
|
.tool-embed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-embed-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-embed-title {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-embed-popout-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-embed-popout-btn:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-embed-iframe {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Analytics Stats Bar ===== */
|
/* ===== Analytics Stats Bar ===== */
|
||||||
.analytics-stats-bar {
|
.analytics-stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -94,5 +94,155 @@ services:
|
|||||||
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
|
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
|
||||||
- librechat-data:/app/data
|
- librechat-data:/app/data
|
||||||
|
|
||||||
|
langflow:
|
||||||
|
image: langflowai/langflow:latest
|
||||||
|
container_name: certifai-langflow
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7860:7860"
|
||||||
|
environment:
|
||||||
|
LANGFLOW_AUTO_LOGIN: "true"
|
||||||
|
|
||||||
|
langgraph:
|
||||||
|
image: langchain/langgraph-trial:3.12
|
||||||
|
container_name: certifai-langgraph
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
langgraph-db:
|
||||||
|
condition: service_started
|
||||||
|
langgraph-redis:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "8123:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URI: postgresql://langgraph:langgraph@langgraph-db:5432/langgraph
|
||||||
|
REDIS_URI: redis://langgraph-redis:6379
|
||||||
|
|
||||||
|
langgraph-db:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: certifai-langgraph-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: langgraph
|
||||||
|
POSTGRES_PASSWORD: langgraph
|
||||||
|
POSTGRES_DB: langgraph
|
||||||
|
volumes:
|
||||||
|
- langgraph-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
langgraph-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: certifai-langgraph-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
langfuse:
|
||||||
|
image: langfuse/langfuse:3
|
||||||
|
container_name: certifai-langfuse
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
langfuse-db:
|
||||||
|
condition: service_healthy
|
||||||
|
langfuse-clickhouse:
|
||||||
|
condition: service_healthy
|
||||||
|
langfuse-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
langfuse-minio:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
|
||||||
|
NEXTAUTH_URL: http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET: certifai-langfuse-dev-secret
|
||||||
|
SALT: certifai-langfuse-dev-salt
|
||||||
|
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
|
||||||
|
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
|
||||||
|
CLICKHOUSE_USER: clickhouse
|
||||||
|
CLICKHOUSE_PASSWORD: clickhouse
|
||||||
|
CLICKHOUSE_CLUSTER_ENABLED: "false"
|
||||||
|
REDIS_HOST: langfuse-redis
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
REDIS_AUTH: langfuse-dev-redis
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
|
||||||
|
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
|
||||||
|
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
|
||||||
|
|
||||||
|
langfuse-db:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: certifai-langfuse-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: langfuse
|
||||||
|
POSTGRES_PASSWORD: langfuse
|
||||||
|
POSTGRES_DB: langfuse
|
||||||
|
volumes:
|
||||||
|
- langfuse-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U langfuse"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
langfuse-clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:latest
|
||||||
|
container_name: certifai-langfuse-clickhouse
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "101:101"
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_DB: default
|
||||||
|
CLICKHOUSE_USER: clickhouse
|
||||||
|
CLICKHOUSE_PASSWORD: clickhouse
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 262144
|
||||||
|
hard: 262144
|
||||||
|
volumes:
|
||||||
|
- langfuse-clickhouse-data:/var/lib/clickhouse
|
||||||
|
- langfuse-clickhouse-logs:/var/log/clickhouse-server
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
langfuse-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: certifai-langfuse-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --requirepass langfuse-dev-redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "langfuse-dev-redis", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
langfuse-minio:
|
||||||
|
image: cgr.dev/chainguard/minio
|
||||||
|
container_name: certifai-langfuse-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: sh
|
||||||
|
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minio
|
||||||
|
MINIO_ROOT_PASSWORD: miniosecret
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
librechat-data:
|
librechat-data:
|
||||||
|
langgraph-db-data:
|
||||||
|
langfuse-db-data:
|
||||||
|
langfuse-clickhouse-data:
|
||||||
|
langfuse-clickhouse-logs:
|
||||||
|
|||||||
@@ -11,23 +11,50 @@ test.describe("Developer section", () => {
|
|||||||
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
|
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agents page shows Coming Soon badge", async ({ page }) => {
|
test("agents page shows Not Configured when URL is empty", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/developer/agents");
|
await page.goto("/developer/agents");
|
||||||
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
||||||
|
|
||||||
await expect(page.locator(".placeholder-badge")).toContainText(
|
await expect(page.locator(".placeholder-badge")).toContainText(
|
||||||
"Coming Soon"
|
"Not Configured"
|
||||||
);
|
);
|
||||||
await expect(page.locator("h2")).toContainText("Agent Builder");
|
await expect(page.locator("h2")).toContainText("Agent Builder");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("analytics page loads via sub-nav", async ({ page }) => {
|
test("analytics page shows Not Configured when URL is empty", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/developer/analytics");
|
await page.goto("/developer/analytics");
|
||||||
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
||||||
|
|
||||||
await expect(page.locator("h2")).toContainText("Analytics");
|
await expect(
|
||||||
|
page.locator("h2", { hasText: "Analytics" })
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.locator(".placeholder-badge")).toContainText(
|
await expect(page.locator(".placeholder-badge")).toContainText(
|
||||||
"Coming Soon"
|
"Not Configured"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agents page shows iframe when URL is configured", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// This test only runs meaningfully when LANGGRAPH_URL is set in the
|
||||||
|
// environment. When empty, the placeholder is shown instead.
|
||||||
|
await page.goto("/developer/agents");
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const iframe = page.locator(".tool-embed-iframe");
|
||||||
|
const placeholder = page.locator(".placeholder-badge");
|
||||||
|
|
||||||
|
if (await placeholder.isVisible()) {
|
||||||
|
await expect(placeholder).toContainText("Not Configured");
|
||||||
|
} else {
|
||||||
|
await expect(iframe).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator(".tool-embed-popout-btn")
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use dioxus_free_icons::Icon;
|
|||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::i18n::{t, tw, Locale};
|
use crate::i18n::{t, tw, Locale};
|
||||||
use crate::infrastructure::auth_check::check_auth;
|
use crate::infrastructure::auth_check::check_auth;
|
||||||
use crate::models::AuthInfo;
|
use crate::models::{AuthInfo, ServiceUrlsContext};
|
||||||
use crate::Route;
|
use crate::Route;
|
||||||
|
|
||||||
/// Application shell layout that wraps all authenticated pages.
|
/// Application shell layout that wraps all authenticated pages.
|
||||||
@@ -29,6 +29,16 @@ pub fn AppShell() -> Element {
|
|||||||
|
|
||||||
match auth_snapshot {
|
match auth_snapshot {
|
||||||
Some(Ok(info)) if info.authenticated => {
|
Some(Ok(info)) if info.authenticated => {
|
||||||
|
// Provide developer tool URLs as context so child pages
|
||||||
|
// can read them without prop-drilling through layouts.
|
||||||
|
use_context_provider(|| {
|
||||||
|
Signal::new(ServiceUrlsContext {
|
||||||
|
langgraph_url: info.langgraph_url.clone(),
|
||||||
|
langflow_url: info.langflow_url.clone(),
|
||||||
|
langfuse_url: info.langfuse_url.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
let menu_open = *mobile_menu_open.read();
|
let menu_open = *mobile_menu_open.read();
|
||||||
let sidebar_cls = if menu_open {
|
let sidebar_cls = if menu_open {
|
||||||
"sidebar sidebar--open"
|
"sidebar sidebar--open"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod page_header;
|
|||||||
mod pricing_card;
|
mod pricing_card;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod sub_nav;
|
pub mod sub_nav;
|
||||||
|
mod tool_embed;
|
||||||
|
|
||||||
pub use app_shell::*;
|
pub use app_shell::*;
|
||||||
pub use article_detail::*;
|
pub use article_detail::*;
|
||||||
@@ -20,3 +21,4 @@ pub use news_card::*;
|
|||||||
pub use page_header::*;
|
pub use page_header::*;
|
||||||
pub use pricing_card::*;
|
pub use pricing_card::*;
|
||||||
pub use sub_nav::*;
|
pub use sub_nav::*;
|
||||||
|
pub use tool_embed::*;
|
||||||
|
|||||||
81
src/components/tool_embed.rs
Normal file
81
src/components/tool_embed.rs
Normal 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}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
|||||||
Some(u) => {
|
Some(u) => {
|
||||||
let librechat_url =
|
let librechat_url =
|
||||||
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
|
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
|
||||||
|
|
||||||
|
// Extract service URLs from server state so the frontend can
|
||||||
|
// embed developer tools (LangGraph, LangFlow, Langfuse).
|
||||||
|
let state: crate::infrastructure::server_state::ServerState =
|
||||||
|
FullstackContext::extract().await?;
|
||||||
|
let langgraph_url = state.services.langgraph_url.clone();
|
||||||
|
let langflow_url = state.services.langflow_url.clone();
|
||||||
|
let langfuse_url = state.services.langfuse_url.clone();
|
||||||
|
|
||||||
Ok(AuthInfo {
|
Ok(AuthInfo {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
sub: u.sub,
|
sub: u.sub,
|
||||||
@@ -34,6 +43,9 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
|||||||
name: u.user.name,
|
name: u.user.name,
|
||||||
avatar_url: u.user.avatar_url,
|
avatar_url: u.user.avatar_url,
|
||||||
librechat_url,
|
librechat_url,
|
||||||
|
langgraph_url,
|
||||||
|
langflow_url,
|
||||||
|
langfuse_url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => Ok(AuthInfo::default()),
|
None => Ok(AuthInfo::default()),
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ pub struct ServiceUrls {
|
|||||||
pub langchain_url: String,
|
pub langchain_url: String,
|
||||||
/// LangGraph service URL.
|
/// LangGraph service URL.
|
||||||
pub langgraph_url: String,
|
pub langgraph_url: String,
|
||||||
|
/// LangFlow visual workflow builder URL.
|
||||||
|
pub langflow_url: String,
|
||||||
/// Langfuse observability URL.
|
/// Langfuse observability URL.
|
||||||
pub langfuse_url: String,
|
pub langfuse_url: String,
|
||||||
/// Vector database URL.
|
/// Vector database URL.
|
||||||
@@ -183,6 +185,7 @@ impl ServiceUrls {
|
|||||||
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
||||||
langchain_url: optional_env("LANGCHAIN_URL"),
|
langchain_url: optional_env("LANGCHAIN_URL"),
|
||||||
langgraph_url: optional_env("LANGGRAPH_URL"),
|
langgraph_url: optional_env("LANGGRAPH_URL"),
|
||||||
|
langflow_url: optional_env("LANGFLOW_URL"),
|
||||||
langfuse_url: optional_env("LANGFUSE_URL"),
|
langfuse_url: optional_env("LANGFUSE_URL"),
|
||||||
vectordb_url: optional_env("VECTORDB_URL"),
|
vectordb_url: optional_env("VECTORDB_URL"),
|
||||||
s3_url: optional_env("S3_URL"),
|
s3_url: optional_env("S3_URL"),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod developer;
|
|||||||
mod news;
|
mod news;
|
||||||
mod organization;
|
mod organization;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod services;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
@@ -10,4 +11,5 @@ pub use developer::*;
|
|||||||
pub use news::*;
|
pub use news::*;
|
||||||
pub use organization::*;
|
pub use organization::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
|
pub use services::*;
|
||||||
pub use user::*;
|
pub use user::*;
|
||||||
|
|||||||
43
src/models/services.rs
Normal file
43
src/models/services.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ pub struct AuthInfo {
|
|||||||
pub avatar_url: String,
|
pub avatar_url: String,
|
||||||
/// LibreChat instance URL for the sidebar chat link
|
/// LibreChat instance URL for the sidebar chat link
|
||||||
pub librechat_url: String,
|
pub librechat_url: String,
|
||||||
|
/// LangGraph agent builder URL (empty if not configured)
|
||||||
|
pub langgraph_url: String,
|
||||||
|
/// LangFlow visual workflow builder URL (empty if not configured)
|
||||||
|
pub langflow_url: String,
|
||||||
|
/// Langfuse observability URL (empty if not configured)
|
||||||
|
pub langfuse_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-user LLM provider configuration stored in MongoDB.
|
/// Per-user LLM provider configuration stored in MongoDB.
|
||||||
@@ -91,6 +97,9 @@ mod tests {
|
|||||||
assert_eq!(info.name, "");
|
assert_eq!(info.name, "");
|
||||||
assert_eq!(info.avatar_url, "");
|
assert_eq!(info.avatar_url, "");
|
||||||
assert_eq!(info.librechat_url, "");
|
assert_eq!(info.librechat_url, "");
|
||||||
|
assert_eq!(info.langgraph_url, "");
|
||||||
|
assert_eq!(info.langflow_url, "");
|
||||||
|
assert_eq!(info.langfuse_url, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -102,6 +111,9 @@ mod tests {
|
|||||||
name: "Test User".into(),
|
name: "Test User".into(),
|
||||||
avatar_url: "https://example.com/avatar.png".into(),
|
avatar_url: "https://example.com/avatar.png".into(),
|
||||||
librechat_url: "https://chat.example.com".into(),
|
librechat_url: "https://chat.example.com".into(),
|
||||||
|
langgraph_url: "http://localhost:8123".into(),
|
||||||
|
langflow_url: "http://localhost:7860".into(),
|
||||||
|
langfuse_url: "http://localhost:3000".into(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
|
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
|
||||||
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
|
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::ToolEmbed;
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
|
use crate::models::ServiceUrlsContext;
|
||||||
|
|
||||||
/// Agents page placeholder for the LangGraph agent builder.
|
/// Agents page embedding the LangGraph agent builder.
|
||||||
///
|
///
|
||||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
/// When `langgraph_url` is configured, embeds the service in an iframe
|
||||||
/// Will eventually integrate with the LangGraph framework.
|
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AgentsPage() -> Element {
|
pub fn AgentsPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
|
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
|
let url = svc.read().langgraph_url.clone();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
section { class: "placeholder-page",
|
ToolEmbed {
|
||||||
div { class: "placeholder-card",
|
url,
|
||||||
div { class: "placeholder-icon", "A" }
|
title: t(l, "developer.agents_title"),
|
||||||
h2 { "{t(l, \"developer.agents_title\")}" }
|
description: t(l, "developer.agents_desc"),
|
||||||
p { class: "placeholder-desc",
|
icon: "A",
|
||||||
"{t(l, \"developer.agents_desc\")}"
|
launch_label: t(l, "developer.launch_agents"),
|
||||||
}
|
|
||||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
|
|
||||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::ToolEmbed;
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
use crate::models::AnalyticsMetric;
|
use crate::models::{AnalyticsMetric, ServiceUrlsContext};
|
||||||
|
|
||||||
/// Analytics page placeholder for LangFuse integration.
|
/// Analytics page embedding Langfuse for observability.
|
||||||
///
|
///
|
||||||
/// Shows a "Coming Soon" card with a disabled launch button,
|
/// Always shows a stats bar with sample metrics. Below that, when
|
||||||
/// plus a mock stats bar showing sample metrics.
|
/// `langfuse_url` is configured, embeds the service in an iframe
|
||||||
|
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AnalyticsPage() -> Element {
|
pub fn AnalyticsPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
|
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
|
let url = svc.read().langfuse_url.clone();
|
||||||
|
|
||||||
let metrics = mock_metrics(l);
|
let metrics = mock_metrics(l);
|
||||||
|
|
||||||
@@ -21,21 +25,24 @@ pub fn AnalyticsPage() -> Element {
|
|||||||
div { class: "analytics-stat",
|
div { class: "analytics-stat",
|
||||||
span { class: "analytics-stat-value", "{metric.value}" }
|
span { class: "analytics-stat-value", "{metric.value}" }
|
||||||
span { class: "analytics-stat-label", "{metric.label}" }
|
span { class: "analytics-stat-label", "{metric.label}" }
|
||||||
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
span {
|
||||||
|
class: if metric.change_pct >= 0.0 {
|
||||||
|
"analytics-stat-change analytics-stat-change--up"
|
||||||
|
} else {
|
||||||
|
"analytics-stat-change analytics-stat-change--down"
|
||||||
|
},
|
||||||
"{metric.change_pct:+.1}%"
|
"{metric.change_pct:+.1}%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div { class: "placeholder-card",
|
}
|
||||||
div { class: "placeholder-icon", "L" }
|
ToolEmbed {
|
||||||
h2 { "{t(l, \"developer.analytics_title\")}" }
|
url,
|
||||||
p { class: "placeholder-desc",
|
title: t(l, "developer.analytics_title"),
|
||||||
"{t(l, \"developer.analytics_desc\")}"
|
description: t(l, "developer.analytics_desc"),
|
||||||
}
|
icon: "L",
|
||||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
|
launch_label: t(l, "developer.launch_analytics"),
|
||||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::ToolEmbed;
|
||||||
use crate::i18n::{t, Locale};
|
use crate::i18n::{t, Locale};
|
||||||
|
use crate::models::ServiceUrlsContext;
|
||||||
|
|
||||||
/// Flow page placeholder for the LangFlow visual workflow builder.
|
/// Flow page embedding the LangFlow visual workflow builder.
|
||||||
///
|
///
|
||||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
/// When `langflow_url` is configured, embeds the service in an iframe
|
||||||
/// Will eventually integrate with LangFlow for visual flow design.
|
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn FlowPage() -> Element {
|
pub fn FlowPage() -> Element {
|
||||||
let locale = use_context::<Signal<Locale>>();
|
let locale = use_context::<Signal<Locale>>();
|
||||||
|
let svc = use_context::<Signal<ServiceUrlsContext>>();
|
||||||
let l = *locale.read();
|
let l = *locale.read();
|
||||||
|
let url = svc.read().langflow_url.clone();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
section { class: "placeholder-page",
|
ToolEmbed {
|
||||||
div { class: "placeholder-card",
|
url,
|
||||||
div { class: "placeholder-icon", "F" }
|
title: t(l, "developer.flow_title"),
|
||||||
h2 { "{t(l, \"developer.flow_title\")}" }
|
description: t(l, "developer.flow_desc"),
|
||||||
p { class: "placeholder-desc",
|
icon: "F",
|
||||||
"{t(l, \"developer.flow_desc\")}"
|
launch_label: t(l, "developer.launch_flow"),
|
||||||
}
|
|
||||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
|
|
||||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user