Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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>
)
}
121 changes: 121 additions & 0 deletions catalog/app/containers/Admin/Settings/SupportDiagnostics.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 }

constructor(status: number, message: string) {
super(message)
this.status = status
this.json = { 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.',
),
)
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.'),
)
const { container, getByText } = renderComponent()

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

await waitFor(() => expect(getByText(/collector failed/)).toBeTruthy())
expect(container.querySelector('.MuiAlert-standardError')).not.toBeNull()
})
})
Comment on lines +114 to +161
130 changes: 130 additions & 0 deletions catalog/app/containers/Admin/Settings/SupportDiagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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)
URL.revokeObjectURL(url)
}

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

// 503 means the stack's CloudFormation template predates the collector and 409
// that a collection is already running -- neither is a malfunction, so neither
// gets the red alert that would send an admin to file a bug.
const SEVERITIES: Record<number, Failure['severity']> = {
503: 'info',
409: 'warning',
}

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({
severity: SEVERITIES[e.status] || 'error',
message: e.json?.message || e.message,
})
} else {
Sentry.captureException(e)
setFailure({ severity: 'error', message: `Could not collect diagnostics: ${e}` })
}
} 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; leaving this
page cancels it.
</M.Typography>
</>
)}
</div>
{failure && (
<Lab.Alert severity={failure.severity} className={classes.failure}>
{failure.message}
</Lab.Alert>
)}
</>
)
}