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>
This commit is contained in:
Sharang Parnerkar
2026-02-25 17:49:56 +01:00
parent d9401d4be5
commit a24ea984b1
21 changed files with 467 additions and 52 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

@@ -154,6 +154,9 @@ jobs:
MONGODB_URI: mongodb://root:example@mongo:27017
MONGODB_DATABASE: certifai
SEARXNG_URL: http://searxng:8080
LANGGRAPH_URL: ""
LANGFLOW_URL: ""
LANGFUSE_URL: ""
steps:
- name: Checkout
run: |

View File

@@ -96,7 +96,9 @@
"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"
},
"org": {
"title": "Organisation",

View File

@@ -96,7 +96,9 @@
"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"
},
"org": {
"title": "Organization",

View File

@@ -96,7 +96,9 @@
"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"
},
"org": {
"title": "Organizacion",

View File

@@ -96,7 +96,9 @@
"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"
},
"org": {
"title": "Organisation",

View File

@@ -96,7 +96,9 @@
"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"
},
"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;

View File

@@ -94,5 +94,155 @@ 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:
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:
librechat-data:
langgraph-db-data:
langfuse-db-data:
langfuse-clickhouse-data:
langfuse-clickhouse-logs:

View File

@@ -11,23 +11,50 @@ test.describe("Developer section", () => {
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.waitForSelector(".placeholder-page", { timeout: 15_000 });
await expect(page.locator(".placeholder-badge")).toContainText(
"Coming Soon"
"Not Configured"
);
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.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(
"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();
}
});
});

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

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

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

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

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.
@@ -91,6 +97,9 @@ mod tests {
assert_eq!(info.name, "");
assert_eq!(info.avatar_url, "");
assert_eq!(info.librechat_url, "");
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
}
#[test]
@@ -102,6 +111,9 @@ mod tests {
name: "Test User".into(),
avatar_url: "https://example.com/avatar.png".into(),
librechat_url: "https://chat.example.com".into(),
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");

View File

@@ -1,27 +1,27 @@
use dioxus::prelude::*;
use crate::components::ToolEmbed;
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.
/// Will eventually integrate with the LangGraph framework.
/// When `langgraph_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[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();
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\")}"
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
ToolEmbed {
url,
title: t(l, "developer.agents_title"),
description: t(l, "developer.agents_desc"),
icon: "A",
launch_label: t(l, "developer.launch_agents"),
}
}
}

View File

@@ -1,16 +1,20 @@
use dioxus::prelude::*;
use crate::components::ToolEmbed;
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,
/// plus a mock stats bar showing sample metrics.
/// Always shows a stats bar with sample metrics. Below that, when
/// `langfuse_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[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 metrics = mock_metrics(l);
@@ -21,21 +25,24 @@ pub fn AnalyticsPage() -> Element {
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\")}"
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
}
ToolEmbed {
url,
title: t(l, "developer.analytics_title"),
description: t(l, "developer.analytics_desc"),
icon: "L",
launch_label: t(l, "developer.launch_analytics"),
}
}
}

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