Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 425261f

Browse files
z23ccclaude
andcommitted
feat: fix Leptos SSR + implement web dashboard with real data
- Fix SSR spawner panic: add any_spawner::Executor::init_tokio() before SSR rendering. Remove leptos_meta dependency (caused <head> panic). Add HTML shell wrapper with CSS link + static CSS serving at /pkg/. - Dashboard page: real epic cards with progress bars, status badges, links to detail pages. Uses LocalResource + Suspense pattern. - Epic detail page: task list with color-coded status badges, dependency info, task completion summary. - Full E2E test passed: 215 cargo tests, CLI lifecycle, daemon API (create/start/done/transitions/validation), web SSR, MCP, interop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 47edcb9 commit 425261f

6 files changed

Lines changed: 171 additions & 38 deletions

File tree

flowctl/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flowctl/crates/flowctl-cli/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ path = "src/main.rs"
1212

1313
[features]
1414
default = []
15-
daemon = ["dep:flowctl-daemon", "dep:flowctl-web", "dep:tokio", "dep:leptos", "dep:leptos_axum", "dep:axum"]
15+
daemon = ["dep:flowctl-daemon", "dep:flowctl-web", "dep:tokio", "dep:leptos", "dep:leptos_axum", "dep:any_spawner", "dep:axum"]
1616

1717
[dependencies]
1818
flowctl-core = { workspace = true }
@@ -24,6 +24,7 @@ flowctl-web = { path = "../flowctl-web", features = ["ssr"], optional = true }
2424

2525
leptos = { version = "0.8", features = ["ssr"], optional = true }
2626
leptos_axum = { version = "0.8", optional = true }
27+
any_spawner = { version = "0.3", optional = true }
2728
axum = { workspace = true, optional = true }
2829
serde = { workspace = true }
2930
serde_json = { workspace = true }

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,17 +526,64 @@ fn main() {
526526
let result = if let Some(tcp_port) = port {
527527
println!("flowctl daemon starting on http://127.0.0.1:{tcp_port}");
528528

529+
// Initialize the Leptos/tokio executor for SSR rendering.
530+
let _ = any_spawner::Executor::init_tokio();
531+
529532
// Build API router from daemon.
530533
let (state, cancel) = flowctl_daemon::server::create_state(runtime, event_bus)
531534
.expect("failed to create state");
532535
let api_router = flowctl_daemon::server::build_router(state);
533536

537+
// Serve static CSS at /pkg/flowctl-web.css
538+
let css_content = include_str!("../../flowctl-web/style/main.css");
539+
let css_content_owned = css_content.to_string();
540+
let css_handler = axum::routing::get(move || {
541+
let css = css_content_owned.clone();
542+
async move {
543+
(
544+
[(axum::http::header::CONTENT_TYPE, "text/css")],
545+
css,
546+
)
547+
}
548+
});
549+
534550
// Add Leptos SSR fallback: API routes take priority,
535551
// everything else renders the Leptos app via SSR.
552+
// Wrap in HTML shell since we're not using leptos_meta.
553+
let ssr_handler = leptos_axum::render_app_to_stream(
554+
flowctl_web::app::App,
555+
);
556+
let shell_handler = move |req: axum::http::Request<axum::body::Body>| {
557+
let ssr = ssr_handler.clone();
558+
async move {
559+
let resp = ssr(req).await;
560+
let (parts, body) = resp.into_parts();
561+
let bytes = axum::body::to_bytes(body, 1024 * 1024).await.unwrap_or_default();
562+
let inner_html = String::from_utf8_lossy(&bytes);
563+
let full_html = format!(
564+
r#"<!DOCTYPE html>
565+
<html lang="en" class="dark">
566+
<head>
567+
<meta charset="utf-8"/>
568+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
569+
<title>flowctl — AI Development Platform</title>
570+
<link rel="stylesheet" href="/pkg/flowctl-web.css"/>
571+
</head>
572+
<body class="bg-gray-900 text-gray-100 min-h-screen">
573+
{inner_html}
574+
</body>
575+
</html>"#
576+
);
577+
axum::http::Response::from_parts(
578+
parts,
579+
axum::body::Body::from(full_html),
580+
)
581+
}
582+
};
583+
536584
let router = api_router
537-
.fallback(leptos_axum::render_app_to_stream(
538-
flowctl_web::app::App,
539-
));
585+
.route("/pkg/flowctl-web.css", css_handler)
586+
.fallback(shell_handler);
540587

541588
let addr = format!("127.0.0.1:{tcp_port}");
542589
let listener = tokio::net::TcpListener::bind(&addr).await

flowctl/crates/flowctl-web/src/app.rs

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
11
//! Root application component with router.
22
33
use leptos::prelude::*;
4-
use leptos_meta::*;
54
use leptos_router::components::*;
65
use leptos_router::path;
76

87
use crate::pages::{dashboard::DashboardPage, epic_detail::EpicDetailPage};
98

10-
/// Shell component that wraps the entire app (provides <head> metadata).
11-
pub fn shell(options: LeptosOptions) -> impl IntoView {
12-
view! {
13-
<!DOCTYPE html>
14-
<html lang="en" class="dark">
15-
<head>
16-
<meta charset="utf-8"/>
17-
<meta name="viewport" content="width=device-width, initial-scale=1"/>
18-
<AutoReload options=options.clone()/>
19-
<HydrationScripts options/>
20-
<MetaTags/>
21-
<link rel="stylesheet" href="/pkg/flowctl-web.css"/>
22-
</head>
23-
<body class="bg-gray-900 text-gray-100 min-h-screen">
24-
<App/>
25-
</body>
26-
</html>
27-
}
28-
}
29-
309
/// Main application component with routing.
3110
#[component]
3211
pub fn App() -> impl IntoView {
33-
provide_meta_context();
34-
3512
view! {
36-
<Title text="flowctl — AI Development Platform"/>
3713
<Router>
3814
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-3">
3915
<div class="flex items-center justify-between max-w-7xl mx-auto">
Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,67 @@
1-
//! Dashboard page: lists all epics with status statistics.
1+
//! Dashboard page: lists all epics with status and progress.
22
33
use leptos::prelude::*;
44

5-
/// Dashboard page component.
5+
use crate::api;
6+
7+
/// Dashboard page component — shows all epics.
68
#[component]
79
pub fn DashboardPage() -> impl IntoView {
10+
let epics = LocalResource::new(move || async move {
11+
api::fetch_epics().await.unwrap_or_default()
12+
});
13+
814
view! {
915
<div>
1016
<h1 class="text-2xl font-bold mb-6">"Dashboard"</h1>
11-
<p class="text-gray-400">"Epic list will appear here."</p>
17+
<Suspense fallback=move || view! { <p class="text-gray-400">"Loading epics..."</p> }>
18+
{move || {
19+
epics.get().map(|epics_data| {
20+
let epics_list: Vec<_> = epics_data.into_iter().collect();
21+
if epics_list.is_empty() {
22+
view! {
23+
<p class="text-gray-400">"No epics found. Create one with flowctl epic create."</p>
24+
}.into_any()
25+
} else {
26+
view! {
27+
<div class="grid gap-4">
28+
{epics_list.into_iter().map(|epic| {
29+
let progress = if epic.tasks > 0 {
30+
(epic.done as f64 / epic.tasks as f64 * 100.0) as u32
31+
} else { 0 };
32+
let status_class = match epic.status.as_str() {
33+
"done" | "closed" => "bg-green-600",
34+
_ if progress == 100 => "bg-blue-600",
35+
_ if progress > 0 => "bg-yellow-600",
36+
_ => "bg-gray-600",
37+
};
38+
let link = format!("/epic/{}", epic.id);
39+
let badge = format!("px-2 py-1 rounded text-xs font-medium text-white {status_class}");
40+
let width = format!("width: {}%", progress);
41+
let count = format!("{}/{}", epic.done, epic.tasks);
42+
view! {
43+
<a href={link}
44+
class="block bg-gray-800 rounded-lg p-4 hover:bg-gray-750 border border-gray-700 hover:border-gray-600 transition-colors">
45+
<div class="flex items-center justify-between mb-2">
46+
<h2 class="text-lg font-semibold text-white">{epic.title.clone()}</h2>
47+
<span class={badge}>{epic.status.clone()}</span>
48+
</div>
49+
<p class="text-sm text-gray-400 mb-2">{epic.id.clone()}</p>
50+
<div class="flex items-center gap-3">
51+
<div class="flex-1 bg-gray-700 rounded-full h-2">
52+
<div class="bg-cyan-500 h-2 rounded-full" style={width}></div>
53+
</div>
54+
<span class="text-sm text-gray-400">{count}</span>
55+
</div>
56+
</a>
57+
}
58+
}).collect::<Vec<_>>()}
59+
</div>
60+
}.into_any()
61+
}
62+
})
63+
}}
64+
</Suspense>
1265
</div>
1366
}
1467
}
Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,75 @@
1-
//! Epic detail page: task list, progress, and DAG visualization.
1+
//! Epic detail page: task list with status badges.
22
33
use leptos::prelude::*;
44
use leptos_router::hooks::use_params_map;
55

6-
/// Epic detail page component.
6+
use crate::api;
7+
8+
/// Epic detail page component — shows tasks for a specific epic.
79
#[component]
810
pub fn EpicDetailPage() -> impl IntoView {
911
let params = use_params_map();
10-
let epic_id = move || params.read().get("id");
12+
let epic_id = move || params.read().get("id").unwrap_or_default();
13+
14+
let tasks = LocalResource::new(move || {
15+
let id = epic_id();
16+
async move {
17+
api::fetch_tasks(&id).await.unwrap_or_default()
18+
}
19+
});
1120

1221
view! {
1322
<div>
14-
<h1 class="text-2xl font-bold mb-6">
15-
{move || format!("Epic: {}", epic_id().unwrap_or_default())}
16-
</h1>
17-
<p class="text-gray-400">"Task list and DAG will appear here."</p>
23+
<div class="flex items-center gap-3 mb-6">
24+
<a href="/" class="text-gray-400 hover:text-white">"← Back"</a>
25+
<h1 class="text-2xl font-bold">{move || epic_id()}</h1>
26+
</div>
27+
<Suspense fallback=move || view! { <p class="text-gray-400">"Loading tasks..."</p> }>
28+
{move || {
29+
tasks.get().map(|tasks_data| {
30+
let task_list: Vec<_> = tasks_data.into_iter().collect();
31+
if task_list.is_empty() {
32+
view! {
33+
<p class="text-gray-400">"No tasks found for this epic."</p>
34+
}.into_any()
35+
} else {
36+
let total = task_list.len();
37+
let done_count = task_list.iter().filter(|t| t.status == "done").count();
38+
let summary = format!("{done_count}/{total} tasks complete");
39+
view! {
40+
<div class="mb-4 text-sm text-gray-400">{summary}</div>
41+
<div class="space-y-2">
42+
{task_list.into_iter().map(|task| {
43+
let (badge_class, badge_text) = match task.status.as_str() {
44+
"done" => ("bg-green-600", "done"),
45+
"in_progress" => ("bg-yellow-600", "in progress"),
46+
"blocked" => ("bg-red-600", "blocked"),
47+
"skipped" => ("bg-gray-600", "skipped"),
48+
_ => ("bg-gray-700", "todo"),
49+
};
50+
let badge_cls = format!("px-2 py-0.5 rounded text-xs font-medium text-white {badge_class}");
51+
let deps = if task.depends_on.is_empty() {
52+
String::new()
53+
} else {
54+
format!(" → {}", task.depends_on.join(", "))
55+
};
56+
view! {
57+
<div class="flex items-center gap-3 bg-gray-800 rounded-lg p-3 border border-gray-700">
58+
<span class={badge_cls}>{badge_text}</span>
59+
<div class="flex-1">
60+
<span class="text-white">{task.title.clone()}</span>
61+
<span class="text-xs text-gray-500 ml-2">{task.id.clone()}</span>
62+
<span class="text-xs text-gray-600 ml-2">{deps}</span>
63+
</div>
64+
</div>
65+
}
66+
}).collect::<Vec<_>>()}
67+
</div>
68+
}.into_any()
69+
}
70+
})
71+
}}
72+
</Suspense>
1873
</div>
1974
}
2075
}

0 commit comments

Comments
 (0)