diff --git a/opencode-ui/packages/app/src/context/devaipod.tsx b/opencode-ui/packages/app/src/context/devaipod.tsx index 369371b..1a9a651 100644 --- a/opencode-ui/packages/app/src/context/devaipod.tsx +++ b/opencode-ui/packages/app/src/context/devaipod.tsx @@ -264,23 +264,47 @@ export const { use: useDevaipod, provider: DevaipodProvider } = createSimpleCont } // -- Polling setup ------------------------------------------------------ - - const podInterval = setInterval(() => { - fetchPods() - }, POD_POLL_MS) - onCleanup(() => clearInterval(podInterval)) - - const launchInterval = setInterval(() => { - if (Object.keys(store.launches).length > 0) { - fetchLaunches() - } - }, LAUNCH_POLL_MS) - onCleanup(() => clearInterval(launchInterval)) - - const proposalInterval = setInterval(() => { - fetchProposals() - }, PROPOSAL_POLL_MS) - onCleanup(() => clearInterval(proposalInterval)) + // Use self-scheduling setTimeout loops instead of setInterval so the next + // poll is only queued after the current one finishes. This makes request + // pileup structurally impossible when the server is slow. + + let disposed = false + onCleanup(() => { disposed = true }) + + let podTimer: ReturnType | undefined + function schedulePodPoll() { + if (disposed) return + podTimer = setTimeout(async () => { + await fetchPods() + schedulePodPoll() + }, POD_POLL_MS) + } + schedulePodPoll() + onCleanup(() => clearTimeout(podTimer)) + + let launchTimer: ReturnType | undefined + function scheduleLaunchPoll() { + if (disposed) return + launchTimer = setTimeout(async () => { + if (Object.keys(store.launches).length > 0) { + await fetchLaunches() + } + scheduleLaunchPoll() + }, LAUNCH_POLL_MS) + } + scheduleLaunchPoll() + onCleanup(() => clearTimeout(launchTimer)) + + let proposalTimer: ReturnType | undefined + function scheduleProposalPoll() { + if (disposed) return + proposalTimer = setTimeout(async () => { + await fetchProposals() + scheduleProposalPoll() + }, PROPOSAL_POLL_MS) + } + scheduleProposalPoll() + onCleanup(() => clearTimeout(proposalTimer)) // Initial fetch — the effect tracks only refreshCounter; the async bodies // read store state (e.g. store.pods, store.launches) which must NOT be tracked diff --git a/src/static/agent-wrapper.js b/src/static/agent-wrapper.js index dd76aae..15df546 100644 --- a/src/static/agent-wrapper.js +++ b/src/static/agent-wrapper.js @@ -211,9 +211,14 @@ } } - // Initial fetch + periodic refresh + // Initial fetch, then self-scheduling poll (next poll waits for current to finish) fetchPodList(); - setInterval(fetchPodList, 15000); + function schedulePoll() { + setTimeout(function () { + fetchPodList().finally(schedulePoll); + }, 15000); + } + schedulePoll(); // -- Session title ---------------------------------------------------------- async function fetchTitle() { diff --git a/src/web.rs b/src/web.rs index 6ecb949..901b478 100644 --- a/src/web.rs +++ b/src/web.rs @@ -764,33 +764,24 @@ async fn get_pod_api_port(pod_name: &str) -> Result { }) } -/// Get all published port mappings for a pod, excluding infrastructure ports. +/// Inspect a pod's agent container once and extract both the pod-api host port +/// and all forwarded ports, avoiding redundant Docker connections and +/// `inspect_container` calls. /// -/// Returns a list of `ForwardedPort` pairs (container_port, host_port). -/// The pod-api internal port (8090) is excluded since it's infrastructure. -async fn get_pod_forwarded_ports(pod_name: &str) -> Vec { - use bollard::Docker; - - let socket_path = match get_container_socket() { - Ok(p) => p, - Err(_) => return vec![], - }; - - let docker = match Docker::connect_with_unix( - &format!("unix://{}", socket_path.display()), - 120, - bollard::API_DEFAULT_VERSION, - ) { - Ok(d) => d, - Err(_) => return vec![], - }; - +/// Returns `(Option, Vec)`. +async fn inspect_pod_ports( + docker: &bollard::Docker, + pod_name: &str, +) -> (Option, Vec) { let info = match docker .inspect_container(&format!("{pod_name}-agent"), None) .await { Ok(i) => i, - Err(_) => return vec![], + Err(e) => { + tracing::debug!("Failed to inspect agent container for {pod_name}: {e}"); + return (None, vec![]); + } }; let ports = match info @@ -799,14 +790,23 @@ async fn get_pod_forwarded_ports(pod_name: &str) -> Vec { .and_then(|ns| ns.ports.as_ref()) { Some(p) => p, - None => return vec![], + None => return (None, vec![]), }; let api_port = crate::pod::POD_API_PORT; - let mut result = Vec::new(); + let port_key = format!("{api_port}/tcp"); + + // Extract the pod-api host port + let pod_api_host_port = ports + .get(&port_key) + .and_then(|b| b.as_ref()) + .and_then(|bindings| bindings.first()) + .and_then(|b| b.host_port.as_ref()) + .and_then(|p| p.parse::().ok()); + // Extract all forwarded ports (excluding pod-api infrastructure port) + let mut forwarded = Vec::new(); for (container_port_key, bindings) in ports { - // Parse "8080/tcp" format let container_port: u16 = match container_port_key .split('/') .next() @@ -816,7 +816,6 @@ async fn get_pod_forwarded_ports(pod_name: &str) -> Vec { None => continue, }; - // Skip the pod-api infrastructure port if container_port == api_port { continue; } @@ -828,7 +827,7 @@ async fn get_pod_forwarded_ports(pod_name: &str) -> Vec { .as_ref() .and_then(|p| p.parse::().ok()) { - result.push(ForwardedPort { + forwarded.push(ForwardedPort { container_port, host_port, }); @@ -837,7 +836,7 @@ async fn get_pod_forwarded_ports(pod_name: &str) -> Vec { } } - result + (pod_api_host_port, forwarded) } /// Low-level HTTP proxy: connect to `host:port`, forward `request`, @@ -2058,6 +2057,27 @@ async fn list_pods_unified(State(state): State>) -> Json>) -> Json fetch_agent_status_with_port(&client, &host, port).await, + None => None, + }; + (status, ports) + } else { + (None, vec![]) + } } else { (None, vec![]) }; @@ -2167,15 +2197,12 @@ async fn list_pods_unified(State(state): State>) -> Json Option { - let port = get_pod_api_port(pod_name).await.ok()?; let resp = client .get(format!("http://{}:{}/summary", host, port)) .send()