Skip to content
Open
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
1 change: 1 addition & 0 deletions catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ complete sentence without it.

## Changes

- [Added] Admin Settings: a Support Diagnostics section whose button collects a diagnostics bundle from the stack — search-cluster and infrastructure state — and downloads it, so an admin with no AWS access can attach it to a support request instead of working through a multi-step cloud procedure. The bundle's `manifest.json` states what was collected, for review before sending. The section renders on every stack, reporting when the CloudFormation template predates the collector or when a collection is already running ([#5168](https://github.com/quiltdata/quilt/pull/5168))
- [Changed] Object delete writes an S3 delete marker instead of destroying a specific object version, so prior versions are retained and stay available in the object's version history. This applies to all three delete entrypoints: the file viewer, the directory-listing row action, and bulk "Delete selected". Permanently erasing a version now requires an admin-attached custom policy granting `s3:DeleteObjectVersion` ([#5161](https://github.com/quiltdata/quilt/pull/5161))
- [Added] Design-context contract for the catalog: `PRODUCT.md` (strategic context — users, positioning, brand personality, anti-references) and `DESIGN.md` (the visual-system baseline in the [design.md format](https://github.com/google-labs-code/design.md): design tokens + named rules), with an `.impeccable/` sidecar for design-aware agent tooling and pointers from README and the new `catalog/CLAUDE.md`. Documentation only — no runtime changes ([#5124](https://github.com/quiltdata/quilt/pull/5124))
- [Fixed] A lost session now reliably redirects to sign-in instead of showing a generic error: both the GraphQL and REST auth-loss interceptors key on the HTTP **401** status rather than matching a single legacy error message, so any unauthenticatable request (an expired or refresh-failed session, "not logged in", etc.) bounces to `/signin`. A credential-less request that races or precedes sign-in is held rather than logged out, so the first-login handshake no longer spuriously redirects ([#5113](https://github.com/quiltdata/quilt/pull/5113))
Expand Down
8 changes: 8 additions & 0 deletions catalog/app/containers/Admin/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as validators from 'utils/validators'
import * as Form from '../Form'
import PackagerSettings from './PackagerSettings'
import SearchSettings from './SearchSettings'
import SupportDiagnostics from './SupportDiagnostics'
import TabulatorSettings from './TabulatorSettings'
import ThemeEditor from './ThemeEditor'

Expand Down Expand Up @@ -344,6 +345,13 @@ export default function Settings() {
<M.Paper className={classes.group}>
<TabulatorSettings />
</M.Paper>

<M.Typography variant="h5" className={classes.title}>
Support Diagnostics
</M.Typography>
<M.Paper className={classes.group}>
<SupportDiagnostics />
</M.Paper>
</div>
)
}
161 changes: 161 additions & 0 deletions catalog/app/containers/Admin/Settings/SupportDiagnostics.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as React from 'react'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'

const mocks = vi.hoisted(() => {
class HTTPError extends Error {
status: number

json: { message: string; error_code?: string }

// `json` is whatever the body parsed to; APIConnector falls back to
// `{ message: <the raw text> }` for a body that is not JSON at all, which is
// what an ALB or nginx error page arrives as.
constructor(status: number, message: string, errorCode?: string) {
super(message)
this.status = status
this.json = errorCode ? { message, error_code: errorCode } : { message }
}
}
return { HTTPError, req: vi.fn() }
})

vi.mock('utils/APIConnector', () => ({
use: () => mocks.req,
HTTPError: mocks.HTTPError,
}))

vi.mock('@sentry/react', () => ({ captureException: vi.fn() }))

import SupportDiagnostics from './SupportDiagnostics'

const theme = createMuiTheme()

function renderComponent() {
return render(
<ThemeProvider theme={theme}>
<SupportDiagnostics />
</ThemeProvider>,
)
}

function archive(headers: Record<string, string>) {
return new Response(new Blob(['PK\x03\x04'], { type: 'application/zip' }), { headers })
}

describe('containers/Admin/Settings/SupportDiagnostics', () => {
let downloaded: { name: string } | null = null

beforeEach(() => {
downloaded = null
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:bundle')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloaded = { name: this.download }
},
)
})

afterEach(() => {
cleanup()
vi.restoreAllMocks()
mocks.req.mockReset()
})

it('downloads the bundle under the name the registry gave it', async () => {
mocks.req.mockResolvedValue(
archive({
'Content-Disposition':
'attachment; filename="quilt-support-diagnostics-run-1.zip"',
}),
)
const { getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() => expect(downloaded).not.toBeNull())
expect(downloaded!.name).toBe('quilt-support-diagnostics-run-1.zip')
expect(mocks.req).toHaveBeenCalledWith({
endpoint: '/admin/support-diagnostics',
method: 'POST',
json: false,
})
})

it('falls back to a generic name when the filename header is not exposed', async () => {
mocks.req.mockResolvedValue(archive({}))
const { getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() => expect(downloaded).not.toBeNull())
expect(downloaded!.name).toBe('quilt-support-diagnostics.zip')
})

it('reports a stack without the collector as information, not as a failure', async () => {
mocks.req.mockRejectedValue(
new mocks.HTTPError(
503,
'Support diagnostics collection is not available on this stack.',
'NotAvailable',
),
)
const { container, getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() => expect(getByText(/not available on this stack/)).toBeTruthy())
expect(container.querySelector('.MuiAlert-standardInfo')).not.toBeNull()
expect(container.querySelector('.MuiAlert-standardError')).toBeNull()
})

it('surfaces an unexpected failure as an error', async () => {
mocks.req.mockRejectedValue(
new mocks.HTTPError(502, 'The diagnostics collector failed.', 'CollectorFailed'),
)
const { container, getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() => expect(getByText(/collector failed/)).toBeTruthy())
expect(container.querySelector('.MuiAlert-standardError')).not.toBeNull()
})

it('does not dress an infrastructure failure up as an expected one', async () => {
// The registry cycling, or an ALB with no healthy target, answers 503 too --
// with an HTML body and no error_code. Keying severity on the status alone
// would paint that the same calm blue as "this stack has no collector".
mocks.req.mockRejectedValue(
new mocks.HTTPError(
503,
'<html><body><h1>503 Service Unavailable</h1></body></html>',
),
)
const { container, getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() =>
expect(container.querySelector('.MuiAlert-standardError')).not.toBeNull(),
)
expect(container.querySelector('.MuiAlert-standardInfo')).toBeNull()
// And the page itself never reaches the admin.
expect(getByText(/registry may be restarting/)).toBeTruthy()
expect(container.textContent).not.toContain('<html>')
})

it('reports a registry that predates the endpoint as information', async () => {
// The catalog and the registry are separate containers in one template, so a
// stack mid-update can serve this button from a registry with no such route.
// Flask answers 404 with an HTML body, which is the same shape as above.
mocks.req.mockRejectedValue(new mocks.HTTPError(404, '<html>404 Not Found</html>'))
const { container, getByText } = renderComponent()

fireEvent.click(getByText('Collect diagnostics'))

await waitFor(() => expect(getByText(/Could not collect diagnostics/)).toBeTruthy())
expect(container.textContent).not.toContain('<html>')
})
})
146 changes: 146 additions & 0 deletions catalog/app/containers/Admin/Settings/SupportDiagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from 'react'
import * as M from '@material-ui/core'
import * as Lab from '@material-ui/lab'
import * as Sentry from '@sentry/react'

import * as APIConnector from 'utils/APIConnector'

// The registry names the archive after the collection run, and support asks for
// that name when several bundles are in flight. Readable cross-origin only
// because the endpoint's CORS config exposes Content-Disposition.
const FILENAME_RE = /filename="([^"]+)"/

const FALLBACK_FILENAME = 'quilt-support-diagnostics.zip'

function getFilename(response: Response): string {
const match = FILENAME_RE.exec(response.headers.get('Content-Disposition') || '')
return match?.[1] || FALLBACK_FILENAME
}

function saveAs(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Not synchronously: clicking only queues the download, and revoking the URL
// before the browser reads the blob cancels it in some of them.
setTimeout(() => URL.revokeObjectURL(url), 60000)
}

interface Failure {
severity: 'error' | 'warning' | 'info'
message: string
}

// Keyed on the registry's error_code, not on the HTTP status: this stack having
// no collector answers 503 and so does a registry that is merely cycling, and
// only the code tells them apart. Anything unrecognised -- including every error
// raised before the endpoint is reached, by the ALB or the nginx sidecar -- keeps
// the red alert, which is the right reading for an infrastructure failure.
const SEVERITIES: Record<string, Failure['severity']> = {
NotAvailable: 'info',
AlreadyRunning: 'warning',
}

// An error from anything other than the endpoint carries whatever body that thing
// serves, usually an HTML page. APIConnector puts the raw text in `message` when
// it will not parse as JSON, so showing it would paste a document into the alert.
const GENERIC_FAILURE =
'Could not collect diagnostics. The registry may be restarting or unreachable; try again in a few minutes.'

function describe(e: APIConnector.HTTPError): Failure {
const code = e.json?.error_code
if (!code) return { severity: 'error', message: GENERIC_FAILURE }
return {
severity: SEVERITIES[code] || 'error',
message: e.json?.message || GENERIC_FAILURE,
}
}

const useStyles = M.makeStyles((t) => ({
actions: {
alignItems: 'center',
display: 'flex',
marginTop: t.spacing(2),
},
progress: {
marginLeft: t.spacing(2),
},
progressText: {
color: t.palette.text.secondary,
marginLeft: t.spacing(1),
},
failure: {
marginTop: t.spacing(2),
},
}))

export default function SupportDiagnostics() {
const classes = useStyles()
const req = APIConnector.use()

const [collecting, setCollecting] = React.useState(false)
const [failure, setFailure] = React.useState<Failure | null>(null)

const collect = React.useCallback(async () => {
setCollecting(true)
setFailure(null)
try {
const response: Response = await req({
endpoint: '/admin/support-diagnostics',
method: 'POST',
// The response is an archive, and there is no request body to encode.
json: false,
})
Comment on lines +93 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Navigation does not cancel collection

When an administrator leaves Settings before the request completes, the application-level API saga continues the POST and the success path still calls saveAs, causing the archive to download on another page despite the UI stating that navigation cancels collection.

Knowledge Base Used: Catalog Frontend (catalog/app)

saveAs(await response.blob(), getFilename(response))
} catch (e) {
if (e instanceof APIConnector.HTTPError) {
setFailure(describe(e))
} else {
Sentry.captureException(e)
setFailure({ severity: 'error', message: GENERIC_FAILURE })
}
} finally {
setCollecting(false)
}
}, [req])
Comment on lines +88 to +109

return (
<>
<M.Typography variant="body2">
Collect a diagnostics bundle describing this stack&apos;s search cluster and
infrastructure, for Quilt support to debug against. Nothing is sent anywhere: the
bundle downloads to your computer, and the <code>manifest.json</code> inside it
lists exactly what was collected, so you can review it before attaching it to a
support request.
</M.Typography>
<div className={classes.actions}>
<M.Button
variant="contained"
color="primary"
onClick={collect}
disabled={collecting}
>
Collect diagnostics
</M.Button>
{collecting && (
<>
<M.CircularProgress size={20} className={classes.progress} />
<M.Typography variant="body2" className={classes.progressText}>
Collecting&hellip; A large or unhealthy cluster takes longer; closing this
tab loses the download.
</M.Typography>
</>
)}
</div>
{failure && (
<Lab.Alert severity={failure.severity} className={classes.failure}>
{failure.message}
</Lab.Alert>
)}
</>
)
}