UI: stuck on "Connecting…" when embedded under a path prefix (Hero OS iframe) #92
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_compute#92
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
When the Compute dashboard is opened from inside the Hero OS shell, the sidebar
shows a yellow "Connecting…" indicator and the Component Versions list never
loads, even though
hero_compute_server,hero_compute_ui,my_hypervisor,and
hero_procare all running and healthy.Hitting the UI directly at
http://127.0.0.1:9001/statusworks and returns afully populated, healthy JSON response.
Environment
development<iframe src="/hero_compute/ui/">(see
hero_os/crates/hero_os_app/src/island_content.rs:422)/hero_compute/ui/*→hero_compute/ui.sock, doesnot strip the prefix, injects
X-Forwarded-Prefix: /hero_compute/uiRoot cause
crates/hero_compute_ui/static/js/dashboard.jsfetches status/RPC withabsolute paths:
dashboard.js:2296—fetch("/status")(startup check)dashboard.js:2365—fetch("/status")(health poll every 2 s)fetch("/rpc"),fetch("/console/…"),fetch("/explorer/rpc")etc.When the iframe is loaded at
/hero_compute/ui/, the browser resolves/statustohttp://<router>:9988/status, which does not exist on therouter → 404 →
setServerStatus(false)→ sidebar is stuck on "Connecting…"forever.
Works when accessed directly on
:9001because there is no prefix.Reproduce
hero_procup withhero_compute_server+hero_compute_uiregistered/statusreturn 404 from hero_routerhttp://127.0.0.1:9001/— works fineFix options
Quick (recommended for now): change absolute fetches to relative in
dashboard.js:fetch("/status")→fetch("./status")fetch("/rpc")→fetch("./rpc")fetch("/…")/new WebSocket("/…")/
new EventSource("/…")and make them prefix-relativeProper: adopt the standard
hero_web_prefixpattern used by otherhero_*_ui crates:
X-Forwarded-Prefixfrom the request<script>window.HERO_PREFIX = "{{ prefix }}";</script>dashboard.jsbuilds URLs with a helper:const url = (p) => (window.HERO_PREFIX || "") + p;:9001, prefix="") and proxied(
/hero_compute/ui/, prefix=/hero_compute/ui) modesAcceptance
Component Versions populated, Recent Activity live
:9001still works/status,/rpc,/console/…,/explorer/rpcfetchesremain in
dashboard.jsReferences
hero_os/crates/hero_os_app/src/island_content.rs:422— iframe srchero_router/crates/hero_router/src/server/routes.rs(~L874, ~L1091) —forward-path and
X-Forwarded-Prefixinjectionhero_compute/crates/hero_compute_ui/static/js/dashboard.js:2296,2365— bughero_web_prefixskill — the standard fix patternImplementation Spec for Issue #92
Objective
Make the Hero Compute UI work correctly when embedded under a reverse-proxy path prefix (e.g.
/hero_compute/ui/in Hero OS), by adopting the standardhero_web_prefixpattern: an Axum middleware readsX-Forwarded-Prefixper request, injects it asbase_pathinto every Askama template, the base template exposes it via a<meta name="base-path">tag and awindow.HERO_PREFIXJS constant, and both HTML templates anddashboard.jsbuild every same-origin URL with that prefix. Direct access on:9001continues to work because the header is absent andbase_pathrenders as the empty string.Requirements
X-Forwarded-Prefixper request (no env var, no restart needed).base.html,index.html,nodes.html,vms.html,admin.html,explorer.html,settings.html,partials/navbar.html,partials/sidebar.html) has abase_pathfield available.href="/...",src="/...") is prefixed with{{ base_path }}.dashboard.jsuses aHERO_PREFIXconstant (read from the meta tag orwindow.HERO_PREFIX) on everyfetch(...),new WebSocket(...),window.location.href = "/...", and inline-generated<a href="/...">HTML.:9001still works (prefix resolves to"")./status,/rpc,/explorer/rpc,/console/...,/api/...,/openrpc.json,/explorer-openrpc.json,/css/...,/js/...,/fonts/...,/favicon.svg,/settings,/vms,/nodes,/admin,/explorer,/anchor or resource references in files shipped by the UI crate.Files to Modify/Create
crates/hero_compute_ui/src/server.rsBasePath(String)extension type andbase_path_middlewarereadingx-forwarded-prefix..layer(axum::middleware::from_fn(base_path_middleware))).base_path: Stringfield to every#[derive(Template)]struct (IndexTemplate,NodesTemplate,VmsTemplate,AdminTemplate,ExplorerTemplate,SettingsTemplate).Extension(BasePath(bp))and passes it into the template.admin_handlerandexplorer_handlerworker-mode redirect must useformat!("{}/", base_path)so it still works behind a prefix.crates/hero_compute_ui/templates/base.html<meta name="base-path" content="{{ base_path }}">inside<head>.href/srcon<link>,<script>,<link rel="icon">with{{ base_path }}.<script>window.HERO_PREFIX = "{{ base_path }}";</script>before loadingdashboard.js(next to the existing_uiModeinjection).crates/hero_compute_ui/templates/partials/navbar.htmlhref="/..."(brand link,/settings,/openrpc.json) with{{ base_path }}.crates/hero_compute_ui/templates/partials/sidebar.htmlhref="/..."(/,/nodes,/vms,/admin,/explorer,/settings) with{{ base_path }}.crates/hero_compute_ui/templates/index.html<a href="/vms">,<a href="/nodes">,<a href="/admin">,<a href="/explorer">(both static markup and JS-generated HTML) with{{ base_path }}-prefixed orHERO_PREFIX-prefixed equivalents.crates/hero_compute_ui/templates/nodes.html<a href="/vms">(useHERO_PREFIXin JS-string construction).crates/hero_compute_ui/static/js/dashboard.jsHERO_PREFIXconstant near the top (read fromwindow.HERO_PREFIXor the<meta name="base-path">tag as a fallback).HERO_PREFIX. Specific call sites (approximate line numbers):fetch("/api/config")rpc()uses"/rpc"explorerRpc()uses"/explorer/rpc"window.location.href = "/settings"fetch("/api/console/destroy/" + ...)"/explorer-openrpc.json"/"/openrpc.json""/explorer/rpc"/"/rpc"fetch("/status")(startup check)fetch("/status")(health poll)consolePath = '/console/' + ...fetch("/api/console/destroy/..." )fetch("/api/console/sessions")"/console/" + ...window.location.replace(window.location.pathname)as-is.http://<nodeIp>:<uiPort>/vms?...) as-is — it targets a different node entirely.Implementation Plan
Step 1: Add base_path middleware and extension in the server
Files:
crates/hero_compute_ui/src/server.rsBasePath(String)newtype and an asyncbase_path_middlewarethat reads the lowercasex-forwarded-prefixheader, trims trailing/, and inserts the value into request extensions..layer(axum::middleware::from_fn(base_path_middleware)).Dependencies: none
Step 2: Thread base_path into every Askama template struct and page handler
Files:
crates/hero_compute_ui/src/server.rsbase_path: Stringto each ofIndexTemplate,NodesTemplate,VmsTemplate,AdminTemplate,ExplorerTemplate,SettingsTemplate.Extension(BasePath(base_path)): Extension<BasePath>extractor and passbase_pathinto the template.admin_handlerandexplorer_handlerworker-mode redirect, redirect toformat!("{}/", base_path)instead of"/".Dependencies: Step 1
Step 3: Update base.html — meta tag, HERO_PREFIX script, and static-asset URLs
Files:
crates/hero_compute_ui/templates/base.html<head>, add<meta name="base-path" content="{{ base_path }}">just after the viewport meta.<link>/<script>href/srcwith{{ base_path }}(bootstrap, unpoly, dashboard css/js, xterm, favicon). Leave the external CDN bootstrap-icons link alone._uiModeinline script, add<script>window.HERO_PREFIX = "{{ base_path }}";</script>.Dependencies: Step 2
Step 4: Update the shared partials (navbar and sidebar)
Files:
crates/hero_compute_ui/templates/partials/navbar.html,crates/hero_compute_ui/templates/partials/sidebar.htmlhref="/..."in both partials with{{ base_path }}. Askama includes render in the parent's context, sobase_pathis already in scope once Step 2 lands.Dependencies: Step 2
Step 5: Update page-level templates (index.html, nodes.html)
Files:
crates/hero_compute_ui/templates/index.html,crates/hero_compute_ui/templates/nodes.html<a href="/...">inindex.html→ prefix with{{ base_path }}.<script>blocks that contain"/vms","/admin","/explorer"→ prefix withHERO_PREFIX.nodes.html.Dependencies: Step 3 (HERO_PREFIX must exist before the scripts run — it does, since base.html injects it above dashboard.js).
Step 6: Introduce HERO_PREFIX in dashboard.js and rewrite every absolute same-origin URL
Files:
crates/hero_compute_ui/static/js/dashboard.jsHERO_PREFIXconstant read fromwindow.HERO_PREFIXor the<meta name="base-path">tag as a fallback.HERO_PREFIX.consolePathat its single point of definition is sufficient — do not also prependHERO_PREFIXat the WS call site.HERO_PREFIXbefore/console/.location.pathname-based reloads and the cross-origin remote-console URL untouched.Dependencies: Step 3 (so
window.HERO_PREFIXis defined before dashboard.js loads).Step 7: Final audit
base.html, the cross-origin node URL builder in dashboard.js, andwindow.location.replace(window.location.pathname).Dependencies: Steps 3–6.
Acceptance Criteria
:9001: dashboard loads, solid green dot, Component Versions populated, Recent Activity live,/statusand/rpcfire unmodified./hero_compute/ui/viahero_router(prefix preserved,X-Forwarded-Prefixinjected): dashboard inside the Hero OS iframe loads cleanly, solid green dot, Component Versions populated, Recent Activity live, VM console opens and streams.grep -nE 'fetch\("/|new WebSocket\("/|new EventSource\("/' crates/hero_compute_ui/static/js/dashboard.jsreturns nothing.grep -nE 'href="/|src="/|action="/' crates/hero_compute_ui/templates/returns only external CDN URLs.Notes
{% include "partials/..." %}renders with the parent's context, so once every top-level template hasbase_path, partials can use{{ base_path }}with no extra wiring.HeaderMap::getis case-insensitive for lowercase names. Use"x-forwarded-prefix"./soHERO_PREFIX + "/status"never produces//status.base_path = "", so{{ base_path }}/css/...renders as/css/...(unchanged) andHERO_PREFIX + "/status"evaluates to/status.window.HERO_PREFIXis set beforedashboard.jsruns because the inline script sits just above<script src=".../dashboard.js">inbase.html. The meta-tag fallback inside dashboard.js is defensive.consolePathat its single definition; the downstream WS call already uses it, so no double-prefix.http://<nodeIp>:<uiPort>/vms?console=...points to another node on its own port (no proxy), so it must NOT receive the prefix.Redirect::to(&format!("{}/", base_path))so workers land on the island home inside Hero OS rather than escaping the iframe.Cargo.toml; no additions needed.X-Forwarded-Prefix. No proxy-side changes needed.Test Results
Command:
cargo test -p hero_compute_uiAll tests passed. One unit test executed in the
hero_compute_uicrate binary (server::tests::test_normalize_socket_path). Final result line:test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out.Workspace check
cargo check --workspace: PASS - the entire workspace compiles cleanly, includinghero_compute,hero_compute_sdk,hero_compute_ui,hero_compute_server,hero_compute_explorer, andhero_compute_examples. No warnings or errors reported; build finished in ~8.6s.No failures to report.
Implementation Summary
The
hero_web_prefixpattern has been applied tohero_compute_uiso the dashboard works identically when served directly on:9001and when embedded under a reverse-proxy path prefix such as/hero_compute/ui/.Changes
crates/hero_compute_ui/src/server.rsBasePath(String)newtype and an axum middleware (base_path_middleware) that reads theX-Forwarded-Prefixheader on every request, trims any trailing/, and stores it in request extensions. Falls back to the empty string when the header is absent..layer(axum::middleware::from_fn(base_path_middleware)).base_path: Stringfield to each Askama template struct:IndexTemplate,NodesTemplate,VmsTemplate,AdminTemplate,ExplorerTemplate,SettingsTemplate.Extension(BasePath(base_path)): Extension<BasePath>and threads the value into the rendered template.admin_handlerandexplorer_handlernow useRedirect::to(&format!("{}/", base_path))so the user stays inside the iframe when running under a prefix.crates/hero_compute_ui/templates/base.html<meta name="base-path" content="{{ base_path }}">in<head>.<link rel="icon|stylesheet">and<script src=...>with{{ base_path }}(bootstrap, unpoly, dashboard, xterm, favicon). The external CDNbootstrap-iconslink is left untouched.<script>window.HERO_PREFIX = "{{ base_path }}";</script>immediately above the existing_uiMode/dashboard.jsscripts, guaranteeing the constant is defined before dashboard.js runs.crates/hero_compute_ui/templates/partials/navbar.htmlandpartials/sidebar.htmlhref="/..."(brand link,/,/nodes,/vms,/admin,/explorer,/settings,/openrpc.json) with{{ base_path }}.crates/hero_compute_ui/templates/index.html<a href="/...">anchors with{{ base_path }}.<script>blocks to concatenateHERO_PREFIX + "/..."instead of"/...".crates/hero_compute_ui/templates/nodes.htmlHERO_PREFIX + "/vms".crates/hero_compute_ui/static/js/dashboard.jsHERO_PREFIXconstant near the top of the file, sourced fromwindow.HERO_PREFIXwith a<meta name="base-path">fallback./api/config,/rpc,/explorer/rpc,/settings,/status(startup + health poll),/openrpc.json,/explorer-openrpc.json,/api/console/destroy/...,/api/console/sessions,/console/...(primary preflight+WebSocket pair and the background WS builder), and two inline<a href="/settings">strings used in SSH-keys empty-state and toast HTML.consolePathat its single definition, so both the downstreamfetch(...)preflight andnew WebSocket(...)upgrade pick it up automatically with no risk of double-prefixing.window.location.replace(window.location.pathname)as-is becausepathnamealready carries the prefix.http://<nodeIp>:<uiPort>/vms?console=...) as-is because it targets another node on its own port, where no reverse proxy is involved.Audit
The only remaining absolute paths in the code shipped by this crate are:
base.html(cdn.jsdelivr.net/...bootstrap-icons...).dashboard.js.window.location.replace(window.location.pathname)(path, not URL).Test Results
cargo test -p hero_compute_uitest result: ok. 1 passed; 0 failedcargo check --workspace— PASS. All crates (hero_compute,hero_compute_sdk,hero_compute_ui,hero_compute_server,hero_compute_explorer,hero_compute_examples) compile cleanly.Acceptance Criteria
:9001still works (header absent →base_pathresolves to"", all URLs render unchanged).fetch("/..."),new WebSocket("/..."), ornew EventSource("/...")calls remain indashboard.js.href="/...",src="/...", oraction="/..."remain in any template undercrates/hero_compute_ui/templates/.Notes
Cargo.toml.hero_routeralready strips the prefix and injectsX-Forwarded-Prefix, so no proxy-side changes were needed.crates/hero_compute_server/src/cloud/constants.rs(literal merge-conflict markers in the committed file); that file was not touched by this change set. Per the issue author's instruction, no commits were created — the changes are ready in the working tree for review.