5eee0b7e9b
CI / checks (pull_request) Successful in 28m25s
Create and delete Athena workgroups (with a result folder, optionally enforced), and run SQL in one: pick a database from the Glue Data Catalog, preview a table, run with Ctrl+Enter, cancel a running query, read up to 1,000 rows and download the full result CSV from S3. Recent queries in the workgroup can be reopened. TheCloud runs the SQL for real over the files a Glue table points at. The Glue page links to the editor.
186 lines
12 KiB
TypeScript
186 lines
12 KiB
TypeScript
import {Download, Play, RefreshCw, Square} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {useEffect, useMemo, useState} from 'react'
|
|
import {Link} from 'react-router-dom'
|
|
import {describeQueryCatalog, getCloudResource, getQueryResult, listQueries, startQuery, stopQuery, storageObjectDownloadUrl} from '@/api/cloudProxyClient'
|
|
import {useAccountId} from '@/lib/accountStore'
|
|
import {formatDateTime} from '@/lib/format'
|
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
|
|
|
type Props = {
|
|
resource?: CloudResource
|
|
runtimeReachable: boolean
|
|
}
|
|
|
|
/** Athena reports a running query's state; it is re-read this often until it finishes. */
|
|
const RUNNING_POLL_MS = 1000
|
|
const FINISHED = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
|
|
|
|
export function QueryPanel({resource, runtimeReachable}: Props) {
|
|
const accountId = useAccountId()
|
|
const queryClient = useQueryClient()
|
|
const workGroup = resource?.id ?? ''
|
|
const [database, setDatabase] = useState('')
|
|
const [sql, setSql] = useState('')
|
|
const [activeQueryId, setActiveQueryId] = useState<string | null>(null)
|
|
const [seededFor, setSeededFor] = useState<string | null>(null)
|
|
const historyKey = useMemo(() => ['athena-history', accountId, workGroup], [accountId, workGroup])
|
|
|
|
const workGroupQuery = useQuery({
|
|
queryKey: ['athena-workgroup', accountId, workGroup],
|
|
queryFn: ({signal}) => getCloudResource('aws', 'query', workGroup, signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const catalogQuery = useQuery({
|
|
queryKey: ['athena-catalog', accountId, database],
|
|
queryFn: ({signal}) => describeQueryCatalog('aws', database || undefined, signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const historyQuery = useQuery({
|
|
queryKey: historyKey,
|
|
queryFn: ({signal}) => listQueries('aws', workGroup, signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const resultQuery = useQuery({
|
|
queryKey: ['athena-result', accountId, workGroup, activeQueryId],
|
|
queryFn: ({signal}) => getQueryResult('aws', workGroup, activeQueryId ?? '', signal),
|
|
enabled: Boolean(activeQueryId) && runtimeReachable,
|
|
refetchInterval: (query) => query.state.data && !FINISHED.has(query.state.data.execution.state) ? RUNNING_POLL_MS : false,
|
|
})
|
|
|
|
const catalog = catalogQuery.data
|
|
const chosenDatabase = database || catalog?.database || ''
|
|
const firstTable = catalog?.tables[0]?.name
|
|
useEffect(() => {
|
|
setActiveQueryId(null)
|
|
setSql('')
|
|
}, [workGroup])
|
|
// Start each workgroup from a query that runs as-is, the way the Athena console opens on a table.
|
|
useEffect(() => {
|
|
if (!firstTable || seededFor === workGroup) return
|
|
setSeededFor(workGroup)
|
|
setSql((current) => current || `SELECT * FROM ${quote(firstTable)} LIMIT 10`)
|
|
}, [firstTable, seededFor, workGroup])
|
|
|
|
const execution = resultQuery.data?.execution
|
|
const finished = execution ? FINISHED.has(execution.state) : true
|
|
const executionState = execution?.state
|
|
useEffect(() => {
|
|
if (executionState && FINISHED.has(executionState)) void queryClient.invalidateQueries({queryKey: historyKey})
|
|
}, [executionState, historyKey, queryClient])
|
|
|
|
const run = useMutation({
|
|
mutationFn: () => startQuery('aws', workGroup, {sql, ...(chosenDatabase ? {database: chosenDatabase} : {})}),
|
|
onSuccess: (started) => {
|
|
setActiveQueryId(started.id)
|
|
return queryClient.invalidateQueries({queryKey: historyKey})
|
|
},
|
|
})
|
|
const cancel = useMutation({
|
|
mutationFn: () => stopQuery('aws', workGroup, activeQueryId ?? ''),
|
|
onSuccess: () => resultQuery.refetch(),
|
|
})
|
|
|
|
if (!resource) {
|
|
return <section className="table-panel"><div className="empty compact"><h3>Select a workgroup</h3><p>Choose a workgroup to run SQL against the tables in the Glue Data Catalog.</p></div></section>
|
|
}
|
|
|
|
const outputLocation = (workGroupQuery.data ?? resource).metadata.outputLocation
|
|
const disabled = !runtimeReachable || run.isPending
|
|
const result = resultQuery.data
|
|
const error = run.error ?? cancel.error ?? resultQuery.error ?? catalogQuery.error
|
|
const history = historyQuery.data ?? []
|
|
const download = execution?.outputLocation ? s3Location(execution.outputLocation) : null
|
|
const submit = () => { if (sql.trim() && !disabled) run.mutate() }
|
|
const openHistory = (entry: QueryExecutionSummary) => {
|
|
setActiveQueryId(entry.id)
|
|
setSql(entry.sql)
|
|
if (entry.database) setDatabase(entry.database)
|
|
}
|
|
|
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Workgroup ${resource.name}`}>
|
|
<div className="input-row resource-table-bar">
|
|
<div><p className="eyebrow">Athena query editor</p><span className="muted">Workgroup {resource.name}{outputLocation ? <> · results in <code>{String(outputLocation)}</code></> : null}</span></div>
|
|
<div className="cdn-actions">
|
|
<button className="button" type="button" disabled={!runtimeReachable || catalogQuery.isFetching} onClick={() => { void catalogQuery.refetch(); void historyQuery.refetch() }}><RefreshCw size={14}/>{catalogQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="query-layout">
|
|
<aside className="query-catalog" aria-label="Data catalog">
|
|
<label className="dynamic-field"><span>Database</span>
|
|
<select className="input" value={chosenDatabase} onChange={(event) => setDatabase(event.target.value)} disabled={!runtimeReachable || !catalog?.databases.length}>
|
|
{!catalog?.databases.length && <option value="">No databases</option>}
|
|
{catalog?.databases.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
</select>
|
|
</label>
|
|
{catalog && catalog.databases.length === 0 && <p className="muted compact-text">Athena queries tables in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>. Create a database and a table there first.</p>}
|
|
{catalog && catalog.tables.length === 0 && catalog.databases.length > 0 && <p className="muted compact-text">No tables in {chosenDatabase}. Add one in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>.</p>}
|
|
<ul className="query-tables">{catalog?.tables.map((table) => (
|
|
<li key={table.name}>
|
|
<details>
|
|
<summary><span className="mono">{table.name}</span></summary>
|
|
<ul>{table.columns.map((column) => <li key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></li>)}</ul>
|
|
</details>
|
|
<button className="button compact" type="button" aria-label={`Preview ${table.name}`} disabled={disabled} onClick={() => setSql(`SELECT * FROM ${quote(table.name)} LIMIT 10`)}>Preview</button>
|
|
</li>
|
|
))}</ul>
|
|
</aside>
|
|
|
|
<div className="query-editor">
|
|
<form aria-label="Query" onSubmit={(event) => { event.preventDefault(); submit() }}>
|
|
<label className="dynamic-field"><span>SQL</span>
|
|
<textarea className="textarea mono query-sql" value={sql} spellCheck={false} rows={7} placeholder="SELECT * FROM orders LIMIT 10" onChange={(event) => setSql(event.target.value)}
|
|
onKeyDown={(event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() } }}/>
|
|
</label>
|
|
<div className="query-actions">
|
|
<button className="button primary" type="submit" disabled={disabled || !sql.trim()}><Play size={14}/>{run.isPending ? 'Starting' : 'Run'}</button>
|
|
{execution && !finished && <button className="button" type="button" disabled={cancel.isPending} onClick={() => cancel.mutate()}><Square size={14}/>Cancel</button>}
|
|
<small className="muted">Ctrl+Enter runs the query. Unqualified table names resolve in {chosenDatabase || 'the chosen database'}.</small>
|
|
</div>
|
|
</form>
|
|
|
|
{execution && <div className="query-status" role="status">
|
|
<code className={`query-state query-state--${execution.state.toLowerCase()}`}>{execution.state}</code>
|
|
{execution.runtimeMs !== null && finished && <span className="muted">Run time {formatRuntime(execution.runtimeMs)}</span>}
|
|
{result && execution.state === 'SUCCEEDED' && <span className="muted">{result.rows.length === 1 ? '1 row' : `${result.rows.length} rows`}{result.truncated ? ' shown; the file has more' : ''}</span>}
|
|
{download && execution.state === 'SUCCEEDED' && <a className="button compact" href={storageObjectDownloadUrl('aws', download.bucket, download.key)} download><Download size={14}/>Download CSV</a>}
|
|
</div>}
|
|
{execution?.state === 'FAILED' && execution.stateReason && <pre className="query-error" role="alert">{execution.stateReason}</pre>}
|
|
|
|
{result && execution?.state === 'SUCCEEDED' && (result.columns.length === 0
|
|
? <p className="muted compact-text">The statement ran and returned no rows.</p>
|
|
: <div className="cognito-table query-results"><table className="table"><thead><tr>{result.columns.map((column, index) => <th key={`${column.name}-${index}`}>{column.name}</th>)}</tr></thead>
|
|
<tbody>{result.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="mono">{cell ?? <span className="muted">NULL</span>}</td>)}</tr>)}</tbody></table></div>)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Recent queries</p><span className="muted">The latest queries run in this workgroup.</span></div></div>
|
|
{history.length === 0 ? <p className="muted compact-text query-note">No queries yet.</p>
|
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Submitted</th><th>State</th><th>Query</th><th>Run time</th></tr></thead><tbody>{history.map((entry) => (
|
|
<tr key={entry.id} aria-current={entry.id === activeQueryId ? 'true' : undefined}>
|
|
<td>{formatDateTime(entry.submittedAt) ?? '—'}</td>
|
|
<td><code className={`query-state query-state--${entry.state.toLowerCase()}`}>{entry.state}</code></td>
|
|
<td><button className="link-button mono query-history-sql" type="button" onClick={() => openHistory(entry)} title="Open this query">{entry.sql}</button></td>
|
|
<td>{entry.runtimeMs !== null ? formatRuntime(entry.runtimeMs) : '—'}</td>
|
|
</tr>
|
|
))}</tbody></table></div>}
|
|
|
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
|
</section>
|
|
}
|
|
|
|
/** Quotes a table name only when Athena would need it, as the Athena console does. */
|
|
function quote(name: string): string {
|
|
return /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replace(/"/g, '""')}"`
|
|
}
|
|
|
|
function formatRuntime(ms: number): string {
|
|
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
|
|
}
|
|
|
|
function s3Location(uri: string): {bucket: string; key: string} | null {
|
|
const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri)
|
|
return match ? {bucket: match[1], key: match[2]} : null
|
|
}
|