Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/25891-marketplace-unreachable-copy.md
Original file line number Diff line number Diff line change
@@ -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.
119 changes: 102 additions & 17 deletions ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -168,14 +181,93 @@ interface LoadFailure {
*/
const UNREACHABLE_STATUSES: ReadonlySet<number> = 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 (
<>
<div className="mt-1" data-testid="marketplace-load-class">
{t('marketplace.load.answeredError', { status: failure.status })}
</div>
<div className="text-muted-foreground mt-1" data-testid="marketplace-load-cause">{failure.message}</div>
</>
);
}

const copy = async () => {
try {
await navigator.clipboard.writeText(failure.message);
setCopied(true);
} catch {
// Denied or unavailable: the text is on screen and selectable.
}
};

return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-1 min-w-0">
<span data-testid="marketplace-load-class">{t('marketplace.load.unreachable')}</span>{' '}
<CollapsibleTrigger
className="group inline-flex items-center gap-0.5 whitespace-nowrap text-xs font-medium underline underline-offset-2 opacity-80 hover:opacity-100"
data-testid="marketplace-load-more"
>
{open ? t('refusalDetails.less') : t('refusalDetails.more')}
<ChevronDown className="size-3 transition-transform group-data-[state=open]:rotate-180" aria-hidden="true" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 flex items-start gap-1 rounded border border-destructive/30 bg-background/60 p-2">
<pre
data-testid="marketplace-load-cause"
className="min-w-0 flex-1 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-xs select-text"
>
{failure.message}
</pre>
<button
type="button"
onClick={() => { void copy(); }}
aria-label={copied ? t('refusalDetails.copied') : t('refusalDetails.copy')}
title={copied ? t('refusalDetails.copied') : t('refusalDetails.copy')}
className="shrink-0 rounded p-1 opacity-80 hover:bg-destructive/10 hover:opacity-100"
data-testid="marketplace-load-copy"
>
{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}
</button>
</div>
</CollapsibleContent>
</Collapsible>
);
}

/**
Expand Down Expand Up @@ -717,14 +809,7 @@ export function MarketplacePage() {
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-destructive" aria-hidden="true" />
<div className="flex-1">
<div className="font-medium text-destructive">{t('marketplace.load.failed')}</div>
<div className="mt-1" data-testid="marketplace-load-class">
{error.status === undefined
? t('marketplace.load.unreachable')
: t('marketplace.load.answeredError', { status: error.status })}
</div>
{/* The server's own text, as a text child: it is server-supplied
data, so it never reaches `dangerouslySetInnerHTML`. */}
<div className="text-muted-foreground mt-1" data-testid="marketplace-load-cause">{error.message}</div>
<LoadFailureCause failure={error} />
{/* The hint names the control plane the SERVER said it uses, so it
can never claim a default the operator overrode (objectui#5504).
Under `''` it names none and says only that the catalog is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
Expand Down Expand Up @@ -184,19 +184,63 @@ describe('a failure whose cause IS reachability keeps the online hint (objectui#
await screen.findByText('«marketplace.load.failed»');
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent('«marketplace.load.failedHintSameOrigin»');
expect(screen.queryByText('«marketplace.noApprovedYet»')).toBeNull();
// The browser's own transport text is a developer string: behind More (#25891).
expect(screen.getByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.unreachable»');
expect(screen.queryByText('Failed to fetch')).toBeNull();
});

it('the runtime proxy reporting its upstream unreachable (502) shows its message and names the plane', async () => {
it('the runtime proxy reporting its upstream unreachable (502) reads as unreachable, its raw text behind More (#25891)', async () => {
await bootOn('https://cloud.acme.internal');
catalog = async () =>
json({ success: false, error: { code: 'MARKETPLACE_PROXY_FAILED', message: 'fetch failed' } }, 502, 'Bad Gateway');

render(<MarketplacePage />);

expect(await screen.findByTestId('marketplace-load-cause')).toHaveTextContent('fetch failed');
// The runtime reached nothing: the catalog did not "answer with an error".
expect(await screen.findByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.unreachable»');
expect(screen.queryByText('«marketplace.load.answeredError»')).toBeNull();
expect(screen.getByTestId('marketplace-load-hint')).toHaveTextContent(
'«marketplace.load.failedHintConfigured:https://cloud.acme.internal»',
);
// "fetch failed" is not in full view until More is pressed.
expect(screen.queryByText('fetch failed')).toBeNull();
const more = screen.getByTestId('marketplace-load-more');
expect(more).toHaveTextContent('«refusalDetails.more»');

fireEvent.click(more);

expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent('fetch failed');
expect(more).toHaveTextContent('«refusalDetails.less»');
expect(screen.getByTestId('marketplace-load-copy')).toHaveAttribute('aria-label', '«refusalDetails.copy»');
});
});

describe('controls: an answered failure keeps its class and its text in full view (#25891)', () => {
it.each([
[
'a 503 the upstream answered, relayed by the proxy',
async () =>
json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'catalog is down' } }, 503, 'Service Unavailable'),
'catalog is down',
true,
],
[
'a 403 refusal',
async () =>
new Response(ALLOWLIST_TEXT, { status: 403, statusText: 'Forbidden', headers: { 'content-type': 'text/plain' } }),
ALLOWLIST_TEXT,
false,
],
] as const)('%s', async (_label, answer, cause, hinted) => {
await bootOn('');
catalog = answer;

render(<MarketplacePage />);

expect(await screen.findByTestId('marketplace-load-class')).toHaveTextContent('«marketplace.load.answeredError»');
expect(screen.getByTestId('marketplace-load-cause')).toHaveTextContent(cause);
expect(screen.queryByTestId('marketplace-load-more')).toBeNull();
expect(screen.queryByTestId('marketplace-load-hint') !== null).toBe(hinted);
});
});

Expand Down
Loading