Skip to content

Commit 996c6a8

Browse files
henrypark133claude
andauthored
feat(web): improve WASM channel setup flow (#380)
* feat(web): improve WASM channel setup flow with stepper UI and auto-configure Streamline the WASM channel setup experience in the web gateway: - Auto-open configure modal after installing a WASM channel - Add progress stepper (Installed → Configured → Active) on channel cards - Replace generic Activate button with state-specific actions (Setup, Reconfigure, Restart) - Show "Awaiting Pairing" status for Telegram until first user is paired - Add SSE extension_status events for real-time status updates - Add gateway restart endpoint (POST /api/gateway/restart) with idempotency guard - Always mount webhook routes at startup so hot-added channels work without restart - Add pairing request polling (10s interval) on extensions tab - Track activation errors per channel with inline error display Includes review fixes: activation_error priority over active status, stepper failed state rendering, restart poll timeout, configure modal double-submit guard, and SSE sender ordering constraint documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address PR review comments - Move PairingStore construction outside .map() loop - Extract createReconfigureButton() helper to reduce duplication Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abda94d commit 996c6a8

13 files changed

Lines changed: 659 additions & 61 deletions

File tree

src/channels/web/handlers/extensions.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,43 @@ pub async fn extensions_list_handler(
2424
.await
2525
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
2626

27+
let pairing_store = crate::pairing::PairingStore::new();
2728
let extensions = installed
2829
.into_iter()
29-
.map(|ext| ExtensionInfo {
30-
name: ext.name,
31-
kind: ext.kind.to_string(),
32-
description: ext.description,
33-
url: ext.url,
34-
authenticated: ext.authenticated,
35-
active: ext.active,
36-
tools: ext.tools,
37-
needs_setup: ext.needs_setup,
30+
.map(|ext| {
31+
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
32+
Some(if ext.activation_error.is_some() {
33+
"failed".to_string()
34+
} else if !ext.authenticated {
35+
"installed".to_string()
36+
} else if ext.active && ext.name == "telegram" {
37+
let has_paired = pairing_store
38+
.read_allow_from(&ext.name)
39+
.map(|list| !list.is_empty())
40+
.unwrap_or(false);
41+
if has_paired {
42+
"active".to_string()
43+
} else {
44+
"pairing".to_string()
45+
}
46+
} else {
47+
"configured".to_string()
48+
})
49+
} else {
50+
None
51+
};
52+
ExtensionInfo {
53+
name: ext.name,
54+
kind: ext.kind.to_string(),
55+
description: ext.description,
56+
url: ext.url,
57+
authenticated: ext.authenticated,
58+
active: ext.active,
59+
tools: ext.tools,
60+
needs_setup: ext.needs_setup,
61+
activation_status,
62+
activation_error: ext.activation_error,
63+
}
3864
})
3965
.collect();
4066

src/channels/web/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ impl GatewayChannel {
9393
registry_entries: Vec::new(),
9494
cost_guard: None,
9595
startup_time: std::time::Instant::now(),
96+
restart_requested: std::sync::atomic::AtomicBool::new(false),
9697
});
9798

9899
Self {
@@ -126,6 +127,7 @@ impl GatewayChannel {
126127
registry_entries: self.state.registry_entries.clone(),
127128
cost_guard: self.state.cost_guard.clone(),
128129
startup_time: self.state.startup_time,
130+
restart_requested: std::sync::atomic::AtomicBool::new(false),
129131
};
130132
mutate(&mut new_state);
131133
self.state = Arc::new(new_state);

src/channels/web/server.rs

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ pub struct GatewayState {
158158
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
159159
/// Server startup time for uptime calculation.
160160
pub startup_time: std::time::Instant,
161+
/// Flag set when a restart has been requested via the API.
162+
pub restart_requested: std::sync::atomic::AtomicBool,
161163
}
162164

163165
/// Start the gateway HTTP server.
@@ -238,6 +240,8 @@ pub async fn start_server(
238240
"/api/extensions/{name}/setup",
239241
get(extensions_setup_handler).post(extensions_setup_submit_handler),
240242
)
243+
// Gateway management
244+
.route("/api/gateway/restart", post(gateway_restart_handler))
241245
// Pairing
242246
.route("/api/pairing/{channel}", get(pairing_list_handler))
243247
.route(
@@ -1722,17 +1726,46 @@ async fn extensions_list_handler(
17221726
.await
17231727
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
17241728

1729+
let pairing_store = crate::pairing::PairingStore::new();
17251730
let extensions = installed
17261731
.into_iter()
1727-
.map(|ext| ExtensionInfo {
1728-
name: ext.name,
1729-
kind: ext.kind.to_string(),
1730-
description: ext.description,
1731-
url: ext.url,
1732-
authenticated: ext.authenticated,
1733-
active: ext.active,
1734-
tools: ext.tools,
1735-
needs_setup: ext.needs_setup,
1732+
.map(|ext| {
1733+
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
1734+
Some(if ext.activation_error.is_some() {
1735+
"failed".to_string()
1736+
} else if !ext.authenticated {
1737+
// No credentials configured yet.
1738+
"installed".to_string()
1739+
} else if ext.active && ext.name == "telegram" {
1740+
// Telegram: check pairing status (end-to-end setup via web UI).
1741+
let has_paired = pairing_store
1742+
.read_allow_from(&ext.name)
1743+
.map(|list| !list.is_empty())
1744+
.unwrap_or(false);
1745+
if has_paired {
1746+
"active".to_string()
1747+
} else {
1748+
"pairing".to_string()
1749+
}
1750+
} else {
1751+
// Authenticated but not fully active (or non-Telegram).
1752+
"configured".to_string()
1753+
})
1754+
} else {
1755+
None
1756+
};
1757+
ExtensionInfo {
1758+
name: ext.name,
1759+
kind: ext.kind.to_string(),
1760+
description: ext.description,
1761+
url: ext.url,
1762+
authenticated: ext.authenticated,
1763+
active: ext.active,
1764+
tools: ext.tools,
1765+
needs_setup: ext.needs_setup,
1766+
activation_status,
1767+
activation_error: ext.activation_error,
1768+
}
17361769
})
17371770
.collect();
17381771

@@ -2037,11 +2070,44 @@ async fn extensions_setup_submit_handler(
20372070
))?;
20382071

20392072
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
2040-
Ok(message) => Ok(Json(ActionResponse::ok(message))),
2073+
Ok(result) => {
2074+
let mut resp = ActionResponse::ok(result.message);
2075+
resp.activated = Some(result.activated);
2076+
if !result.activated {
2077+
resp.needs_restart = Some(true);
2078+
}
2079+
Ok(Json(resp))
2080+
}
20412081
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
20422082
}
20432083
}
20442084

2085+
// --- Gateway management handlers ---
2086+
2087+
async fn gateway_restart_handler(State(state): State<Arc<GatewayState>>) -> Json<ActionResponse> {
2088+
// Idempotency guard: only allow one restart at a time.
2089+
if state
2090+
.restart_requested
2091+
.compare_exchange(
2092+
false,
2093+
true,
2094+
std::sync::atomic::Ordering::SeqCst,
2095+
std::sync::atomic::Ordering::SeqCst,
2096+
)
2097+
.is_err()
2098+
{
2099+
return Json(ActionResponse::ok("Restart already in progress"));
2100+
}
2101+
2102+
// Take the shutdown sender and trigger graceful shutdown.
2103+
if let Some(tx) = state.shutdown_tx.write().await.take() {
2104+
let _ = tx.send(());
2105+
tracing::info!("Gateway restart requested via API");
2106+
}
2107+
2108+
Json(ActionResponse::ok("Restarting..."))
2109+
}
2110+
20452111
// --- Pairing handlers ---
20462112

20472113
async fn pairing_list_handler(

src/channels/web/sse.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ impl SseManager {
4242
let _ = self.tx.send(event);
4343
}
4444

45+
/// Get a clone of the broadcast sender for use by other components.
46+
pub fn sender(&self) -> broadcast::Sender<SseEvent> {
47+
self.tx.clone()
48+
}
49+
4550
/// Get current number of active connections.
4651
pub fn connection_count(&self) -> u64 {
4752
self.connection_count.load(Ordering::Relaxed)
@@ -120,6 +125,7 @@ impl SseManager {
120125
SseEvent::JobStatus { .. } => "job_status",
121126
SseEvent::JobResult { .. } => "job_result",
122127
SseEvent::Heartbeat => "heartbeat",
128+
SseEvent::ExtensionStatus { .. } => "extension_status",
123129
};
124130
Ok(Event::default().event(event_type).data(data))
125131
});

0 commit comments

Comments
 (0)