diff --git a/.changeset/25891-marketplace-unreachable-copy.md b/.changeset/25891-marketplace-unreachable-copy.md new file mode 100644 index 00000000000..114d69b3d7d --- /dev/null +++ b/.changeset/25891-marketplace-unreachable-copy.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): Browse Marketplace says the catalog could not be reached when the runtime cannot reach it, instead of "the catalog answered with an error (HTTP 502)" + +Clause-②: no +Metadata-protocol: none + +- **`@object-ui/app-shell`**: the runtime's marketplace proxy answers `502 MARKETPLACE_PROXY_FAILED` only when its own fetch to the catalog throws. The load-error panel picked its class line by status alone, so that answer read "The marketplace catalog answered with an error (HTTP 502)", with the raw transport text `fetch failed` in full view below it. The panel now reads the error code as well: that code, or no answer at all, reads "The marketplace catalog could not be reached.", and the raw text sits behind a **More** / **Less** disclosure with a copy button. A failure the catalog did answer, including a 502, 503 or 504 relayed from upstream, still names its HTTP status and shows the server's own text. The online hint is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index a65c3701b4b..f19da1750ab 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -18,8 +18,13 @@ import { Input, Button, Skeleton, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, } from '@object-ui/components'; -import { Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react'; +import { + Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2, ChevronDown, Copy, Check, +} from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '@object-ui/react'; @@ -150,15 +155,23 @@ function useRelativeFormatter() { interface LoadFailure { message: string; showOnlineHint: boolean; + /** The answered status, or `undefined` when no server answered (objectui#12710). */ + status: number | undefined; + /** The answered error code `call()` stamps from the envelope, if any. */ + code: string | undefined; /** - * The answered status, or `undefined` when no server answered (objectui#12710). - * It names the failure's class on the page: "could not be reached" (a - * transport failure: a network error, a CORS refusal, an abort) against - * "answered with an error" (any HTTP status). A 502 from the runtime's proxy - * is the second class with the online hint beside it: the runtime answered, - * and what it said is that it could not reach the plane behind it. + * The failure's class on the page (objectui#12710, #25891): "could not be + * reached" against "answered with an error (HTTP n)". Unreachable is a + * transport failure (a network error, a CORS refusal, an abort: no status), + * or the runtime proxy's own `MARKETPLACE_PROXY_FAILED`, which it answers + * only when its own fetch to the catalog threw: that 502 is the runtime + * saying it reached nothing, not the catalog answering. A 502 / 503 / 504 the + * upstream itself answered is relayed with the upstream's own code, and stays + * an answer. The unreachable class's message is the raw transport text + * ("fetch failed"), so the page keeps it behind More; an answer's message is + * the server's own words and stays in full view (objectui#11688). */ - status: number | undefined; + unreachable: boolean; } /** @@ -168,14 +181,93 @@ interface LoadFailure { */ const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +/** The code the runtime's marketplace proxy answers when its upstream fetch throws. */ +const PROXY_UNREACHABLE_CODE = 'MARKETPLACE_PROXY_FAILED'; + function toLoadFailure(e: unknown): LoadFailure { - const err = e as { message?: unknown; status?: unknown } | null | undefined; + const err = e as { message?: unknown; status?: unknown; code?: unknown } | null | undefined; const status = typeof err?.status === 'number' ? err.status : undefined; + const code = typeof err?.code === 'string' ? err.code : undefined; return { message: typeof err?.message === 'string' ? err.message : String(e), showOnlineHint: status === undefined || UNREACHABLE_STATUSES.has(status), status, + code, + unreachable: status === undefined || code === PROXY_UNREACHABLE_CODE, + }; +} + +/** + * The load failure's class line and its cause (#25891). An answer shows the + * server's own words under its class line. An unreachable failure says so in + * one plain line, and the raw transport text, written for a developer, sits + * behind More, selectable and copyable, as `RefusalMessage` keeps a refusal's + * detail (the same `refusalDetails` keys). + */ +function LoadFailureCause({ failure }: { failure: LoadFailure }) { + const { t } = useObjectTranslation(); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const id = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(id); + }, [copied]); + + // The server's own text, as a text child: it is server-supplied data, so it + // never reaches `dangerouslySetInnerHTML`. + if (!failure.unreachable) { + return ( + <> +
+ {t('marketplace.load.answeredError', { status: failure.status })} +
+
{failure.message}
+ + ); + } + + const copy = async () => { + try { + await navigator.clipboard.writeText(failure.message); + setCopied(true); + } catch { + // Denied or unavailable: the text is on screen and selectable. + } }; + + return ( + + {t('marketplace.load.unreachable')}{' '} + + {open ? t('refusalDetails.less') : t('refusalDetails.more')} + + +
+
+            {failure.message}
+          
+ +
+
+
+ ); } /** @@ -717,14 +809,7 @@ export function MarketplacePage() {