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

Commit ae1ceaa

Browse files
z23ccclaude
andcommitted
feat(web): scaffold Leptos 0.7 web platform frontend
New crate: flowctl-web (Leptos 0.7.8 + axum SSR + WASM hydration) Structure: - app.rs: Root component with Router (/ dashboard, /epic/:id detail) - api.rs: Client-side fetch wrapper for daemon REST API (gloo-net) - pages/: Dashboard + Epic detail page components - components/: StatusBadge + ProgressBar reusable components - style/main.css: Dark theme CSS system (cards, badges, progress, responsive) Daemon enhancements: - serve_tcp(): New TCP listener for browser access (--port flag) - CORS layer added to all API routes (tower-http) - flowctl serve --port 3000 starts HTTP on TCP instead of Unix socket Compiles on all three targets: - cargo check (default) - cargo check --target wasm32-unknown-unknown --features hydrate - cargo check --features ssr 224 existing tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5240875 commit ae1ceaa

16 files changed

Lines changed: 2234 additions & 30 deletions

File tree

flowctl/Cargo.lock

Lines changed: 1714 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flowctl/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ members = [
77
"crates/flowctl-cli",
88
"crates/flowctl-daemon",
99
"crates/flowctl-tui",
10+
"crates/flowctl-web",
1011
]
1112

1213
# ── Shared package metadata (nushell convention) ──────────────────────
@@ -88,3 +89,10 @@ lto = "fat"
8889
codegen-units = 1
8990
panic = "abort"
9091
strip = true
92+
93+
# ── WASM release profile ─────────────────────────────────────────────
94+
[profile.wasm-release]
95+
inherits = "release"
96+
opt-level = "z"
97+
lto = "fat"
98+
codegen-units = 1

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ fn main() {
489489

490490
// Daemon
491491
#[cfg(feature = "daemon")]
492-
Commands::Serve { port: _ } => {
492+
Commands::Serve { port } => {
493493
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
494494
rt.block_on(async {
495495
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
@@ -527,9 +527,15 @@ fn main() {
527527
cancel_clone.cancel();
528528
});
529529

530-
println!("flowctl daemon starting on {}", paths.socket_file.display());
530+
let result = if let Some(tcp_port) = port {
531+
println!("flowctl daemon starting on http://127.0.0.1:{tcp_port}");
532+
flowctl_daemon::server::serve_tcp(runtime, event_bus, tcp_port).await
533+
} else {
534+
println!("flowctl daemon starting on {}", paths.socket_file.display());
535+
flowctl_daemon::server::serve(runtime, event_bus).await
536+
};
531537

532-
if let Err(e) = flowctl_daemon::server::serve(runtime, event_bus).await {
538+
if let Err(e) = result {
533539
eprintln!("daemon error: {e}");
534540
std::process::exit(1);
535541
}

flowctl/crates/flowctl-daemon/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ daemon = [
1212
"dep:tokio",
1313
"dep:tokio-util",
1414
"dep:axum",
15+
"dep:tower-http",
1516
"dep:nix",
1617
]
1718

@@ -31,6 +32,7 @@ notify = { workspace = true }
3132
tokio = { workspace = true, optional = true }
3233
tokio-util = { workspace = true, optional = true }
3334
axum = { workspace = true, optional = true }
35+
tower-http = { version = "0.6", features = ["cors"], optional = true }
3436
nix = { workspace = true, optional = true }
3537

3638
[dev-dependencies]

flowctl/crates/flowctl-daemon/src/server.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use std::sync::Arc;
88

99
use anyhow::{Context, Result};
1010
use axum::routing::{get, post};
11-
use tokio::net::UnixListener;
11+
use tokio::net::{TcpListener, UnixListener};
12+
use tower_http::cors::{Any, CorsLayer};
1213
use tracing::info;
1314

1415
use crate::handlers::{
@@ -18,6 +19,11 @@ use crate::lifecycle::{set_socket_permissions, DaemonRuntime};
1819

1920
/// Build the Axum router with all daemon API routes.
2021
fn build_router(state: AppState) -> axum::Router {
22+
let cors = CorsLayer::new()
23+
.allow_origin(Any)
24+
.allow_methods(Any)
25+
.allow_headers(Any);
26+
2127
axum::Router::new()
2228
.route("/api/v1/health", get(handlers::health_handler))
2329
.route("/api/v1/metrics", get(handlers::metrics_handler))
@@ -29,6 +35,7 @@ fn build_router(state: AppState) -> axum::Router {
2935
.route("/api/v1/tasks/done", post(handlers::done_task_handler))
3036
.route("/api/v1/shutdown", post(handlers::shutdown_handler))
3137
.route("/api/v1/events", get(handlers::events_ws_handler))
38+
.layer(cors)
3239
.with_state(state)
3340
}
3441

@@ -67,6 +74,39 @@ pub async fn serve(runtime: DaemonRuntime, event_bus: flowctl_scheduler::EventBu
6774
Ok(())
6875
}
6976

77+
/// Start the HTTP server on a TCP port (for web browser access).
78+
pub async fn serve_tcp(
79+
runtime: DaemonRuntime,
80+
event_bus: flowctl_scheduler::EventBus,
81+
port: u16,
82+
) -> Result<()> {
83+
let addr = format!("127.0.0.1:{port}");
84+
let listener = TcpListener::bind(&addr)
85+
.await
86+
.with_context(|| format!("failed to bind TCP: {addr}"))?;
87+
88+
info!("daemon API listening on http://{addr}");
89+
90+
let cancel = runtime.cancel.clone();
91+
92+
let state: AppState = Arc::new(DaemonState {
93+
runtime,
94+
event_bus,
95+
});
96+
97+
let router = build_router(state);
98+
99+
axum::serve(listener, router)
100+
.with_graceful_shutdown(async move {
101+
cancel.cancelled().await;
102+
info!("HTTP server shutting down");
103+
})
104+
.await
105+
.context("HTTP server error")?;
106+
107+
Ok(())
108+
}
109+
70110
#[cfg(test)]
71111
mod tests {
72112
use super::*;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[package]
2+
name = "flowctl-web"
3+
version = "0.1.0"
4+
description = "Leptos web frontend for flowctl platform"
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
license.workspace = true
8+
9+
[lib]
10+
crate-type = ["cdylib", "rlib"]
11+
12+
[dependencies]
13+
flowctl-core = { workspace = true }
14+
leptos = { version = "0.7", features = [] }
15+
leptos_meta = { version = "0.7" }
16+
leptos_router = { version = "0.7", features = [] }
17+
serde = { workspace = true }
18+
serde_json = { workspace = true }
19+
20+
# Client-side only
21+
wasm-bindgen = { version = "0.2", optional = true }
22+
console_error_panic_hook = { version = "0.1", optional = true }
23+
gloo-net = { version = "0.6", optional = true }
24+
25+
# Server-side only
26+
leptos_axum = { version = "0.7", optional = true }
27+
tokio = { workspace = true, optional = true }
28+
29+
[features]
30+
default = []
31+
hydrate = [
32+
"leptos/hydrate",
33+
"dep:wasm-bindgen",
34+
"dep:console_error_panic_hook",
35+
"dep:gloo-net",
36+
]
37+
ssr = [
38+
"leptos/ssr",
39+
"leptos_meta/ssr",
40+
"leptos_router/ssr",
41+
"dep:leptos_axum",
42+
"dep:tokio",
43+
]
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! API client for communicating with the flowctl daemon.
2+
//!
3+
//! Uses `gloo-net` on WASM (client-side) for fetch requests.
4+
//! On the server side (SSR), these functions won't be called directly.
5+
6+
use serde::{Deserialize, Serialize};
7+
8+
/// API base URL — defaults to same origin.
9+
#[allow(dead_code)]
10+
fn api_base() -> String {
11+
// In the browser, use relative URLs (same origin).
12+
// Can be overridden via window.__FLOWCTL_API for dev.
13+
String::new()
14+
}
15+
16+
/// Epic summary from the /api/v1/epics endpoint.
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct EpicSummary {
19+
pub id: String,
20+
pub title: String,
21+
pub status: String,
22+
pub tasks: usize,
23+
pub done: usize,
24+
}
25+
26+
/// Epics list response.
27+
#[derive(Debug, Clone, Serialize, Deserialize)]
28+
pub struct EpicsResponse {
29+
pub epics: Vec<EpicSummary>,
30+
pub count: usize,
31+
pub success: bool,
32+
}
33+
34+
/// Task from the /api/v1/tasks endpoint.
35+
#[derive(Debug, Clone, Serialize, Deserialize)]
36+
pub struct TaskItem {
37+
pub id: String,
38+
pub title: String,
39+
pub status: String,
40+
pub epic: Option<String>,
41+
#[serde(default)]
42+
pub depends_on: Vec<String>,
43+
#[serde(default)]
44+
pub domain: String,
45+
}
46+
47+
/// Fetch all epics from the daemon API.
48+
#[cfg(feature = "hydrate")]
49+
pub async fn fetch_epics() -> Result<Vec<EpicSummary>, String> {
50+
let url = format!("{}/api/v1/epics", api_base());
51+
let resp = gloo_net::http::Request::get(&url)
52+
.send()
53+
.await
54+
.map_err(|e| format!("fetch error: {e}"))?;
55+
56+
if !resp.ok() {
57+
return Err(format!("HTTP {}", resp.status()));
58+
}
59+
60+
let data: serde_json::Value = resp.json().await.map_err(|e| format!("json error: {e}"))?;
61+
62+
// The epics endpoint returns a JSON array or {epics: [...]}
63+
if let Some(arr) = data.as_array() {
64+
serde_json::from_value(serde_json::Value::Array(arr.clone()))
65+
.map_err(|e| format!("parse error: {e}"))
66+
} else if let Some(epics) = data.get("epics") {
67+
serde_json::from_value(epics.clone())
68+
.map_err(|e| format!("parse error: {e}"))
69+
} else {
70+
Err("unexpected response format".to_string())
71+
}
72+
}
73+
74+
/// Fetch tasks for an epic.
75+
#[cfg(feature = "hydrate")]
76+
pub async fn fetch_tasks(epic_id: &str) -> Result<Vec<TaskItem>, String> {
77+
let url = format!("{}/api/v1/tasks?epic_id={}", api_base(), epic_id);
78+
let resp = gloo_net::http::Request::get(&url)
79+
.send()
80+
.await
81+
.map_err(|e| format!("fetch error: {e}"))?;
82+
83+
if !resp.ok() {
84+
return Err(format!("HTTP {}", resp.status()));
85+
}
86+
87+
let data: serde_json::Value = resp.json().await.map_err(|e| format!("json error: {e}"))?;
88+
89+
if let Some(arr) = data.as_array() {
90+
serde_json::from_value(serde_json::Value::Array(arr.clone()))
91+
.map_err(|e| format!("parse error: {e}"))
92+
} else {
93+
serde_json::from_value(data).map_err(|e| format!("parse error: {e}"))
94+
}
95+
}
96+
97+
/// Start a task via POST.
98+
#[cfg(feature = "hydrate")]
99+
pub async fn start_task(task_id: &str) -> Result<(), String> {
100+
let url = format!("{}/api/v1/tasks/start", api_base());
101+
let body = serde_json::json!({"task_id": task_id});
102+
let resp = gloo_net::http::Request::post(&url)
103+
.json(&body)
104+
.map_err(|e| format!("json error: {e}"))?
105+
.send()
106+
.await
107+
.map_err(|e| format!("fetch error: {e}"))?;
108+
109+
if resp.ok() { Ok(()) } else { Err(format!("HTTP {}", resp.status())) }
110+
}
111+
112+
/// Complete a task via POST.
113+
#[cfg(feature = "hydrate")]
114+
pub async fn done_task(task_id: &str) -> Result<(), String> {
115+
let url = format!("{}/api/v1/tasks/done", api_base());
116+
let body = serde_json::json!({"task_id": task_id});
117+
let resp = gloo_net::http::Request::post(&url)
118+
.json(&body)
119+
.map_err(|e| format!("json error: {e}"))?
120+
.send()
121+
.await
122+
.map_err(|e| format!("fetch error: {e}"))?;
123+
124+
if resp.ok() { Ok(()) } else { Err(format!("HTTP {}", resp.status())) }
125+
}
126+
127+
// SSR stubs — these won't be called on the server but need to exist for compilation.
128+
#[cfg(not(feature = "hydrate"))]
129+
pub async fn fetch_epics() -> Result<Vec<EpicSummary>, String> { Ok(vec![]) }
130+
#[cfg(not(feature = "hydrate"))]
131+
pub async fn fetch_tasks(_epic_id: &str) -> Result<Vec<TaskItem>, String> { Ok(vec![]) }
132+
#[cfg(not(feature = "hydrate"))]
133+
pub async fn start_task(_task_id: &str) -> Result<(), String> { Ok(()) }
134+
#[cfg(not(feature = "hydrate"))]
135+
pub async fn done_task(_task_id: &str) -> Result<(), String> { Ok(()) }
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! Root application component with router.
2+
3+
use leptos::prelude::*;
4+
use leptos_meta::*;
5+
use leptos_router::components::*;
6+
use leptos_router::path;
7+
8+
use crate::pages::{dashboard::DashboardPage, epic_detail::EpicDetailPage};
9+
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+
30+
/// Main application component with routing.
31+
#[component]
32+
pub fn App() -> impl IntoView {
33+
provide_meta_context();
34+
35+
view! {
36+
<Title text="flowctl — AI Development Platform"/>
37+
<Router>
38+
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-3">
39+
<div class="flex items-center justify-between max-w-7xl mx-auto">
40+
<a href="/" class="text-xl font-bold text-cyan-400">"flowctl"</a>
41+
<div class="flex gap-4 text-sm text-gray-400">
42+
<a href="/" class="hover:text-white">"Dashboard"</a>
43+
</div>
44+
</div>
45+
</nav>
46+
<main class="max-w-7xl mx-auto px-6 py-8">
47+
<Routes fallback=|| view! { <p class="text-red-400">"Page not found."</p> }>
48+
<Route path=path!("/") view=DashboardPage/>
49+
<Route path=path!("/epic/:id") view=EpicDetailPage/>
50+
</Routes>
51+
</main>
52+
</Router>
53+
}
54+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod status_badge;
2+
pub mod progress_bar;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! Progress bar component.
2+
3+
use leptos::prelude::*;
4+
5+
/// A horizontal progress bar.
6+
#[component]
7+
pub fn ProgressBar(
8+
#[prop(into)] done: usize,
9+
#[prop(into)] total: usize,
10+
) -> impl IntoView {
11+
let pct = if total > 0 { (done * 100) / total } else { 0 };
12+
13+
view! {
14+
<div class="w-full bg-gray-700 rounded-full h-2">
15+
<div
16+
class="bg-cyan-500 h-2 rounded-full transition-all"
17+
style={format!("width: {}%", pct)}
18+
/>
19+
</div>
20+
<span class="text-xs text-gray-400 mt-1">
21+
{format!("{done}/{total}")}
22+
</span>
23+
}
24+
}

0 commit comments

Comments
 (0)