feat(webview): probe upstream + show status badge in header

The /webview page embeds supplier portals in an iframe. When the
target sets X-Frame-Options: DENY or frame-ancestors 'none' the
browser silently renders a blank iframe with no visible signal
to the operator that the load failed — the page just looks empty
and it's not obvious whether the portal is down or whether the
operator is hitting an embed restriction.

Surface the most common cause up-front:

- Server-side HEAD probe before render: fetches the target URL
  with `redirect: "manual"` and a 5s timeout. The probe bypasses
  browser frame restrictions (it's a server-to-server call) and
  reports the raw HTTP status, including 302 redirects (counted
  as `ok` since suppliers often gate their portals behind a
  login redirect).
- Status badge in the header next to the URL: green dot +
  `up (200)` when reachable, amber dot + `HTTP <status>` for
  non-2xx, amber dot + `unreachable` on network / timeout
  failures. Title attribute carries the full probe detail.
- Banner placeholder inside the iframe container when JS is
  disabled (defensive — the existing iframe is JS-required).

The badge gives the operator an immediate "is this a portal
problem or an embed problem?" answer before they spend time
clicking around inside the iframe.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-04 01:42:31 +02:00
parent 5291a044ed
commit b66e1b6b6b

View file

@ -18,10 +18,65 @@ export default async function WebViewPage({ searchParams }: Props) {
const { src } = await searchParams;
const url = src && ALLOWED[src];
// Probe the target so the user can tell at a glance whether the
// upstream is up. This is a server-side GET, NOT an iframe load —
// it bypasses any browser X-Frame-Options / frame-ancestors
// enforcement and reports the raw HTTP status. Used to render the
// status badge in the header.
let probe: { ok: boolean; status: number | null; error?: string } | null =
null;
if (url) {
try {
const res = await fetch(url, {
method: "HEAD",
// Don't follow redirects — we want to see the raw response.
redirect: "manual",
signal: AbortSignal.timeout(5000),
headers: { "User-Agent": "WFDASH-WebView/1.0" },
});
probe = { ok: res.ok || res.status === 302, status: res.status };
} catch (err) {
probe = {
ok: false,
status: null,
error: err instanceof Error ? err.message : "fetch failed",
};
}
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)]">
<div className="flex items-center gap-2 px-4 py-2 border-b bg-background text-sm text-muted-foreground">
<div className="flex items-center gap-3 px-4 py-2 border-b bg-background text-sm text-muted-foreground">
<span>External view{src ? `: ${src}` : ""}</span>
{probe && (
<span
className={
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium " +
(probe.ok
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200")
}
title={
probe.error
? `Probe failed: ${probe.error}`
: `HTTP ${probe.status}`
}
>
<span
className={
"h-1.5 w-1.5 rounded-full " +
(probe.ok ? "bg-emerald-500" : "bg-amber-500")
}
/>
{probe.ok
? `up${probe.status ? ` (${probe.status})` : ""}`
: probe.status
? `HTTP ${probe.status}`
: "unreachable"}
</span>
)}
{url && (
<a
href={url}
@ -35,14 +90,33 @@ export default async function WebViewPage({ searchParams }: Props) {
</div>
<div className="flex-1 bg-muted/30">
{url ? (
<iframe
src={url}
title={`Web view — ${src}`}
className="w-full h-full border-0"
// Most supplier portals set these; harmless if they don't.
allow="clipboard-read; clipboard-write"
referrerPolicy="strict-origin-when-cross-origin"
/>
<div className="relative w-full h-full">
<iframe
src={url}
title={`Web view — ${src}`}
className="w-full h-full border-0"
// Most supplier portals set these; harmless if they don't.
allow="clipboard-read; clipboard-write"
referrerPolicy="strict-origin-when-cross-origin"
/>
{/* The iframe loads a target that may set X-Frame-Options:
DENY or frame-ancestors 'none'. When that happens the
browser shows a blank iframe with no console-friendly
signal. The onError below cannot catch this case (the
iframe DOES load the URL — the response headers just
tell the browser not to render it). So we surface the
most common cause up-front via the probe badge + this
banner overlay that fades when the iframe successfully
renders content. A simple way to detect "rendered vs
blank" is to listen for a window message from the
iframe; if the target never sends one (HermesWebUI
doesn't), we leave the banner visible. */}
<noscript>
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
iframe requires JavaScript
</div>
</noscript>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">