feat(api): add Athena workgroups and a query editor to the cloud explorer
CI / checks (pull_request) Successful in 28m25s
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.
This commit is contained in:
@@ -91,6 +91,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
||||||
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
||||||
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
|
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Analytics | Athena / Query | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||||
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-athena": "^3.1146.0",
|
||||||
"@aws-sdk/client-auto-scaling": "^3.1146.0",
|
"@aws-sdk/client-auto-scaling": "^3.1146.0",
|
||||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {AwsQueryAdapter} from './AwsQueryAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function execution(id: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
QueryExecutionId: id,
|
||||||
|
Query: 'SELECT name, amount FROM orders',
|
||||||
|
StatementType: 'DML',
|
||||||
|
WorkGroup: 'analysts',
|
||||||
|
QueryExecutionContext: {Database: 'shop', Catalog: 'AwsDataCatalog'},
|
||||||
|
ResultConfiguration: {OutputLocation: `s3://results/${id}.csv`},
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date('2026-10-25T21:00:00Z'), CompletionDateTime: new Date('2026-10-25T21:00:01Z')},
|
||||||
|
Statistics: {TotalExecutionTimeInMillis: 42, DataScannedInBytes: 0},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = {Data: [{VarCharValue: 'name'}, {VarCharValue: 'amount'}]}
|
||||||
|
const columnInfo = [{Name: 'name', Label: 'name', Type: 'varchar'}, {Name: 'amount', Label: 'amount', Type: 'double'}]
|
||||||
|
const row = (name: string, amount: string) => ({Data: [{VarCharValue: name}, {VarCharValue: amount}]})
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const result = handler(command)
|
||||||
|
if (result) return result
|
||||||
|
if (command instanceof GetQueryExecutionCommand) return {QueryExecution: execution(command.input.QueryExecutionId ?? '')}
|
||||||
|
if (command instanceof GetWorkGroupCommand) {
|
||||||
|
return {WorkGroup: {Name: command.input.WorkGroup, State: 'ENABLED', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/'}, EnforceWorkGroupConfiguration: true, EngineVersion: {EffectiveEngineVersion: 'Athena engine version 3'}}}}
|
||||||
|
}
|
||||||
|
if (command instanceof StartQueryExecutionCommand) return {QueryExecutionId: 'q-new'}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {sent, adapter: new AwsQueryAdapter(client as unknown as AthenaClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsQueryAdapter', () => {
|
||||||
|
test('lists workgroups across pages and describes one with its result location', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof ListWorkGroupsCommand
|
||||||
|
? command.input.NextToken ? {WorkGroups: [{Name: 'analysts', State: 'ENABLED', Description: 'Team'}]} : {WorkGroups: [{Name: 'primary', State: 'ENABLED'}], NextToken: 'n'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
expect((await adapter.list()).map((wg) => [wg.name, wg.status])).toEqual([['primary', 'ENABLED'], ['analysts', 'ENABLED']])
|
||||||
|
expect((await adapter.list({search: 'team'})).map((wg) => wg.name)).toEqual(['analysts'])
|
||||||
|
expect((await adapter.get('analysts'))?.metadata).toEqual({description: 'Team', outputLocation: 's3://results/', enforceConfiguration: true, engineVersion: 'Athena engine version 3'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a workgroup with a result folder, and keeps the primary workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'analysts', description: 'Team', outputLocation: 's3://results/athena', enforce: 'true'}})
|
||||||
|
expect((sent.find((command) => command instanceof CreateWorkGroupCommand) as CreateWorkGroupCommand).input).toEqual({
|
||||||
|
Name: 'analysts', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/athena/'}, EnforceWorkGroupConfiguration: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {name: 'primary'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'bad name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.delete('primary')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await adapter.delete('analysts')
|
||||||
|
expect((sent.find((command) => command instanceof DeleteWorkGroupCommand) as DeleteWorkGroupCommand).input).toEqual({WorkGroup: 'analysts', RecursiveDeleteOption: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts a query in the workgroup against a database', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
const started = await adapter.startQuery('analysts', {sql: ' SELECT 1 ', database: 'shop'})
|
||||||
|
|
||||||
|
expect((sent.find((command) => command instanceof StartQueryExecutionCommand) as StartQueryExecutionCommand).input).toEqual({
|
||||||
|
QueryString: 'SELECT 1', WorkGroup: 'analysts', QueryExecutionContext: {Catalog: 'AwsDataCatalog', Database: 'shop'},
|
||||||
|
})
|
||||||
|
expect(started).toMatchObject({id: 'q-new', state: 'SUCCEEDED', runtimeMs: 42, outputLocation: 's3://results/q-new.csv'})
|
||||||
|
await expect(adapter.startQuery('analysts', {sql: ' '})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a succeeded query with its columns and rows, without the header row', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, row('alpha', '10'), row('beta', '25')]}}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.columns).toEqual([{name: 'name', type: 'varchar'}, {name: 'amount', type: 'double'}])
|
||||||
|
expect(result.rows).toEqual([['alpha', '10'], ['beta', '25']])
|
||||||
|
expect(result.truncated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stops reading after the row limit and says the result is truncated', async () => {
|
||||||
|
const page = Array.from({length: 1000}, (_, i) => row(`n${i}`, String(i)))
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? command.input.NextToken ? {ResultSet: {Rows: page}, NextToken: 'more'} : {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, ...page.slice(0, 999)]}, NextToken: 'p2'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.rows).toHaveLength(1000)
|
||||||
|
expect(result.truncated).toBe(true)
|
||||||
|
expect(sent.filter((command) => command instanceof GetQueryResultsCommand)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not read results of a query that has not succeeded', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryExecutionCommand
|
||||||
|
? {QueryExecution: execution('q1', {Status: {State: 'FAILED', StateChangeReason: 'Table orders not found'}})}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.execution).toMatchObject({state: 'FAILED', stateReason: 'Table orders not found'})
|
||||||
|
expect(result.rows).toEqual([])
|
||||||
|
expect(sent.some((command) => command instanceof GetQueryResultsCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a query from another workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await expect(adapter.getQueryResult('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
await expect(adapter.stopQuery('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(false)
|
||||||
|
|
||||||
|
await adapter.stopQuery('analysts', 'q1')
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists the workgroup queries newest first', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListQueryExecutionsCommand) return {QueryExecutionIds: ['old', 'new', 'other']}
|
||||||
|
if (command instanceof GetQueryExecutionCommand) {
|
||||||
|
const id = command.input.QueryExecutionId
|
||||||
|
return {QueryExecution: execution(id ?? '', {
|
||||||
|
WorkGroup: id === 'other' ? 'primary' : 'analysts',
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date(id === 'new' ? '2026-10-25T22:00:00Z' : '2026-10-25T21:00:00Z')},
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await adapter.listQueries('analysts')).map((query) => query.id)).toEqual(['new', 'old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists databases and the tables of the chosen one', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListDatabasesCommand) return {DatabaseList: [{Name: 'shop'}, {Name: 'logs'}]}
|
||||||
|
if (command instanceof ListTableMetadataCommand) {
|
||||||
|
return {TableMetadataList: [{Name: 'orders', Columns: [{Name: 'id', Type: 'int'}], PartitionKeys: [{Name: 'day', Type: 'string'}]}]}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await adapter.describeQueryCatalog('shop')).toEqual({databases: ['logs', 'shop'], database: 'shop', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'day', type: 'string'}]}]})
|
||||||
|
expect((await adapter.describeQueryCatalog('missing')).database).toBe('logs')
|
||||||
|
expect((sent.filter((command) => command instanceof ListTableMetadataCommand) as ListTableMetadataCommand[]).map((command) => command.input.DatabaseName)).toEqual(['shop', 'logs'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
type GetQueryResultsCommandOutput,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
type ListTableMetadataCommandOutput,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
type QueryExecution,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
type TableMetadata,
|
||||||
|
type WorkGroup,
|
||||||
|
type WorkGroupSummary,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
awsQuerySchema,
|
||||||
|
PRIMARY_WORKGROUP,
|
||||||
|
QUERY_HISTORY_LIMIT,
|
||||||
|
QUERY_RESULT_ROW_LIMIT,
|
||||||
|
WORKGROUP_NAME_PATTERN,
|
||||||
|
} from '../cloud-spi/querySchema'
|
||||||
|
import type {
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** Athena's own catalog name for the Glue Data Catalog. */
|
||||||
|
const DATA_CATALOG = 'AwsDataCatalog'
|
||||||
|
/** Athena's cap on a query string. */
|
||||||
|
const MAX_QUERY_LENGTH = 262_144
|
||||||
|
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Amazon Athena, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Queries really run: TheCloud executes the SQL with DuckDB over the files a
|
||||||
|
* Glue table points at, and writes the result to S3 as CSV, as Athena does.
|
||||||
|
* - Statements that return no rows, such as SHOW TABLES, fail in the runtime,
|
||||||
|
* so the editor lists databases and tables through the catalog APIs instead.
|
||||||
|
* - Data scanned is always reported as 0 bytes, so the editor shows only the
|
||||||
|
* run time.
|
||||||
|
*/
|
||||||
|
export class AwsQueryAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'query' as const
|
||||||
|
|
||||||
|
constructor(private readonly athena: AthenaClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsQuerySchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: WorkGroupSummary[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListWorkGroupsCommand({MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.WorkGroups ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map(toResource).filter((workGroup) => !search
|
||||||
|
|| workGroup.name.toLowerCase().includes(search)
|
||||||
|
|| String(workGroup.metadata.description ?? '').toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
const workGroup = (await this.athena.send(new GetWorkGroupCommand({WorkGroup: id}))).WorkGroup
|
||||||
|
return workGroup ? toResource(workGroup) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(WORKGROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, dots, dashes or underscores')
|
||||||
|
if (name === PRIMARY_WORKGROUP) throw new ValidationError('Every account already has the primary workgroup')
|
||||||
|
const outputLocation = text(values.outputLocation)
|
||||||
|
if (outputLocation && !S3_URI.test(outputLocation)) throw new ValidationError('outputLocation must be an S3 URI such as s3://my-bucket/athena-results/')
|
||||||
|
const description = text(values.description)
|
||||||
|
|
||||||
|
await this.athena.send(new CreateWorkGroupCommand({
|
||||||
|
Name: name,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
Configuration: {
|
||||||
|
...(outputLocation ? {ResultConfiguration: {OutputLocation: outputLocation.endsWith('/') ? outputLocation : `${outputLocation}/`}} : {}),
|
||||||
|
EnforceWorkGroupConfiguration: values.enforce === 'true',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`Athena did not return the new workgroup ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
if (id === PRIMARY_WORKGROUP) throw new ValidationError('The primary workgroup cannot be deleted')
|
||||||
|
await this.athena.send(new DeleteWorkGroupCommand({WorkGroup: id, RecursiveDeleteOption: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async startQuery(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const sql = typeof input.sql === 'string' ? input.sql.trim() : ''
|
||||||
|
if (!sql) throw new ValidationError('Enter a query to run')
|
||||||
|
if (sql.length > MAX_QUERY_LENGTH) throw new ValidationError(`A query can be at most ${MAX_QUERY_LENGTH} characters`)
|
||||||
|
const database = text(input.database)
|
||||||
|
|
||||||
|
const started = await this.athena.send(new StartQueryExecutionCommand({
|
||||||
|
QueryString: sql,
|
||||||
|
WorkGroup: workGroup,
|
||||||
|
...(database ? {QueryExecutionContext: {Catalog: DATA_CATALOG, Database: database}} : {}),
|
||||||
|
}))
|
||||||
|
if (!started.QueryExecutionId) throw new RuntimeError('Athena did not return a query id')
|
||||||
|
return (await this.execution(workGroup, started.QueryExecutionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const execution = await this.execution(workGroup, queryId)
|
||||||
|
if (execution.state !== 'SUCCEEDED' || !execution.outputLocation) return {execution, columns: [], rows: [], truncated: false}
|
||||||
|
|
||||||
|
const pages: GetQueryResultsCommandOutput[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
let rowCount = 0
|
||||||
|
do {
|
||||||
|
const page = await this.athena.send(new GetQueryResultsCommand({QueryExecutionId: queryId, MaxResults: 1000, ...(next ? {NextToken: next} : {})}))
|
||||||
|
pages.push(page)
|
||||||
|
rowCount += page.ResultSet?.Rows?.length ?? 0
|
||||||
|
next = page.NextToken
|
||||||
|
} while (next && rowCount <= QUERY_RESULT_ROW_LIMIT)
|
||||||
|
|
||||||
|
const columnInfo = pages[0]?.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
|
||||||
|
const raw = pages.flatMap((page) => page.ResultSet?.Rows ?? []).map((row) => (row.Data ?? []).map((cell) => cell.VarCharValue ?? null))
|
||||||
|
const header = raw[0] ?? []
|
||||||
|
const columns = columnInfo.length > 0
|
||||||
|
? columnInfo.map((column) => ({name: column.Label ?? column.Name ?? '', type: column.Type ?? null}))
|
||||||
|
: header.map((name) => ({name: name ?? '', type: null}))
|
||||||
|
// Athena repeats the column names as the first row of a SELECT's results.
|
||||||
|
const body = raw.length > 0 && header.length === columns.length && header.every((name, index) => name === columns[index].name) ? raw.slice(1) : raw
|
||||||
|
return {execution, columns, rows: body.slice(0, QUERY_RESULT_ROW_LIMIT), truncated: body.length > QUERY_RESULT_ROW_LIMIT || Boolean(next)}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(workGroup: string, queryId: string): Promise<void> {
|
||||||
|
await this.execution(workGroup, queryId)
|
||||||
|
await this.athena.send(new StopQueryExecutionCommand({QueryExecutionId: queryId}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const ids = (await this.athena.send(new ListQueryExecutionsCommand({WorkGroup: workGroup, MaxResults: QUERY_HISTORY_LIMIT}))).QueryExecutionIds ?? []
|
||||||
|
const executions = await Promise.all(ids.slice(0, QUERY_HISTORY_LIMIT).map(async (id) => {
|
||||||
|
const res = await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: id}))
|
||||||
|
return res.QueryExecution ? toExecution(res.QueryExecution) : null
|
||||||
|
}))
|
||||||
|
return executions
|
||||||
|
.filter((execution): execution is QueryExecutionSummary => execution !== null && (execution.workGroup ?? PRIMARY_WORKGROUP) === workGroup)
|
||||||
|
.sort((a, b) => (b.submittedAt ?? '').localeCompare(a.submittedAt ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(database?: string): Promise<QueryCatalog> {
|
||||||
|
const databases: string[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListDatabasesCommand({CatalogName: DATA_CATALOG, MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
databases.push(...(res.DatabaseList ?? []).map((entry) => entry.Name ?? '').filter(Boolean))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
databases.sort()
|
||||||
|
|
||||||
|
const chosen = database && databases.includes(database) ? database : databases[0] ?? null
|
||||||
|
if (!chosen) return {databases, database: null, tables: []}
|
||||||
|
|
||||||
|
const tables: TableMetadata[] = []
|
||||||
|
let tableToken: string | undefined
|
||||||
|
do {
|
||||||
|
const res: ListTableMetadataCommandOutput = await this.athena.send(new ListTableMetadataCommand({CatalogName: DATA_CATALOG, DatabaseName: chosen, MaxResults: 50, ...(tableToken ? {NextToken: tableToken} : {})}))
|
||||||
|
tables.push(...(res.TableMetadataList ?? []))
|
||||||
|
tableToken = res.NextToken
|
||||||
|
} while (tableToken)
|
||||||
|
return {
|
||||||
|
databases,
|
||||||
|
database: chosen,
|
||||||
|
tables: tables.map((table) => ({
|
||||||
|
name: table.Name ?? '',
|
||||||
|
columns: [...(table.Columns ?? []), ...(table.PartitionKeys ?? [])].map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A query, refused unless it ran in this workgroup, so one workgroup's page never shows another's. */
|
||||||
|
private async execution(workGroup: string, queryId: string): Promise<QueryExecutionSummary> {
|
||||||
|
let execution: QueryExecution | undefined
|
||||||
|
try {
|
||||||
|
execution = (await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: queryId}))).QueryExecution
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') throw new NotFoundError(`Query ${queryId} not found`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!execution || (execution.WorkGroup ?? PRIMARY_WORKGROUP) !== workGroup) throw new NotFoundError(`Query ${queryId} is not in the ${workGroup} workgroup`)
|
||||||
|
return toExecution(execution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(workGroup: WorkGroup | WorkGroupSummary): CloudResource {
|
||||||
|
const name = workGroup.Name ?? ''
|
||||||
|
const detail = 'Configuration' in workGroup ? workGroup : undefined
|
||||||
|
const configuration = detail?.Configuration
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
type: 'athenaWorkGroup',
|
||||||
|
region: null,
|
||||||
|
createdAt: workGroup.CreationTime ? workGroup.CreationTime.toISOString() : null,
|
||||||
|
status: workGroup.State ?? null,
|
||||||
|
metadata: {
|
||||||
|
description: workGroup.Description || null,
|
||||||
|
...(detail ? {
|
||||||
|
outputLocation: configuration?.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
enforceConfiguration: configuration?.EnforceWorkGroupConfiguration === true,
|
||||||
|
engineVersion: configuration?.EngineVersion?.EffectiveEngineVersion ?? configuration?.EngineVersion?.SelectedEngineVersion ?? null,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toExecution(execution: QueryExecution): QueryExecutionSummary {
|
||||||
|
const status = execution.Status
|
||||||
|
return {
|
||||||
|
id: execution.QueryExecutionId ?? '',
|
||||||
|
sql: execution.Query ?? '',
|
||||||
|
database: execution.QueryExecutionContext?.Database ?? null,
|
||||||
|
workGroup: execution.WorkGroup ?? null,
|
||||||
|
state: status?.State ?? 'QUEUED',
|
||||||
|
stateReason: status?.StateChangeReason ?? status?.AthenaError?.ErrorMessage ?? null,
|
||||||
|
statementType: execution.StatementType ?? null,
|
||||||
|
submittedAt: status?.SubmissionDateTime ? status.SubmissionDateTime.toISOString() : null,
|
||||||
|
completedAt: status?.CompletionDateTime ? status.CompletionDateTime.toISOString() : null,
|
||||||
|
runtimeMs: execution.Statistics?.TotalExecutionTimeInMillis ?? execution.Statistics?.EngineExecutionTimeInMillis ?? null,
|
||||||
|
dataScannedBytes: execution.Statistics?.DataScannedInBytes ?? null,
|
||||||
|
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import { ACMClient } from "@aws-sdk/client-acm";
|
|||||||
import { PipesClient } from "@aws-sdk/client-pipes";
|
import { PipesClient } from "@aws-sdk/client-pipes";
|
||||||
import { EFSClient } from "@aws-sdk/client-efs";
|
import { EFSClient } from "@aws-sdk/client-efs";
|
||||||
import { GlueClient } from "@aws-sdk/client-glue";
|
import { GlueClient } from "@aws-sdk/client-glue";
|
||||||
|
import { AthenaClient } from "@aws-sdk/client-athena";
|
||||||
|
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||||
const region = process.env.AWS_REGION || "us-east-1";
|
const region = process.env.AWS_REGION || "us-east-1";
|
||||||
@@ -109,6 +110,7 @@ export type AwsClients = {
|
|||||||
pipes: PipesClient;
|
pipes: PipesClient;
|
||||||
efs: EFSClient;
|
efs: EFSClient;
|
||||||
glue: GlueClient;
|
glue: GlueClient;
|
||||||
|
athena: AthenaClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -163,6 +165,7 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
pipes: new PipesClient(base),
|
pipes: new PipesClient(base),
|
||||||
efs: new EFSClient(base),
|
efs: new EFSClient(base),
|
||||||
glue: new GlueClient(base),
|
glue: new GlueClient(base),
|
||||||
|
athena: new AthenaClient(base),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** CreateWorkGroup's name rule. */
|
||||||
|
export const WORKGROUP_NAME_PATTERN = '^[a-zA-Z0-9._-]{1,128}$'
|
||||||
|
/** The workgroup every account has, which Athena does not let you delete. */
|
||||||
|
export const PRIMARY_WORKGROUP = 'primary'
|
||||||
|
/** Rows the editor shows for one query; the full result stays in the CSV file in S3. */
|
||||||
|
export const QUERY_RESULT_ROW_LIMIT = 1000
|
||||||
|
/** Recent queries the history shows. */
|
||||||
|
export const QUERY_HISTORY_LIMIT = 25
|
||||||
|
|
||||||
|
const workGroupColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime', emptyText: '—'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const workGroupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsQuerySchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
displayName: 'Athena workgroups',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: WORKGROUP_NAME_PATTERN, message: 'Use up to 128 letters, numbers, dots, dashes and underscores'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 1024}},
|
||||||
|
{
|
||||||
|
name: 'outputLocation',
|
||||||
|
label: 'Query result location',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Where each query writes its results as a CSV file, such as s3://my-bucket/athena-results/. Leave empty to use TheCloud\'s default results bucket.',
|
||||||
|
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/athena-results/'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enforce',
|
||||||
|
label: 'Override client-side settings',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'false',
|
||||||
|
description: 'When on, queries always use this workgroup\'s result location, even if a client asks for another.',
|
||||||
|
options: [
|
||||||
|
{label: 'Off', value: 'false'},
|
||||||
|
{label: 'On', value: 'true'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List workgroups', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
queryActions: [
|
||||||
|
{name: 'run', label: 'Run query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'results', label: 'Get query results', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'stop', label: 'Cancel query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'history', label: 'Recent queries', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'catalog', label: 'Browse databases and tables', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: workGroupFilters,
|
||||||
|
columns: workGroupColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function querySchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsQuerySchema() : null
|
||||||
|
}
|
||||||
@@ -229,6 +229,13 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Analytics',
|
group: 'Analytics',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
displayName: 'Query',
|
||||||
|
displayNameByCloud: {aws: 'Athena'},
|
||||||
|
iconKey: 'query',
|
||||||
|
group: 'Analytics',
|
||||||
|
order: 21,
|
||||||
|
},
|
||||||
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
||||||
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
||||||
email: {
|
email: {
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export type UserPoolActionName =
|
|||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||||
|
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
|
||||||
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
||||||
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
@@ -253,6 +254,7 @@ export interface ServiceSchema {
|
|||||||
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
||||||
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
|
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
|
||||||
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
|
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
|
||||||
|
queryActions?: CapabilitySchema<QueryActionName>[]
|
||||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||||
@@ -560,6 +562,43 @@ export interface AccessPointInput {
|
|||||||
permissions: string
|
permissions: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
sql: string
|
||||||
|
/** The Glue database unqualified table names resolve in. */
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutionSummary {
|
||||||
|
id: string
|
||||||
|
sql: string
|
||||||
|
database: string | null
|
||||||
|
workGroup: string | null
|
||||||
|
/** QUEUED, RUNNING, SUCCEEDED, FAILED or CANCELLED. */
|
||||||
|
state: string
|
||||||
|
stateReason: string | null
|
||||||
|
statementType: string | null
|
||||||
|
submittedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
runtimeMs: number | null
|
||||||
|
dataScannedBytes: number | null
|
||||||
|
/** The S3 object holding the full result as CSV. */
|
||||||
|
outputLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
execution: QueryExecutionSummary
|
||||||
|
columns: Array<{name: string; type: string | null}>
|
||||||
|
rows: Array<Array<string | null>>
|
||||||
|
/** More rows exist than the console returns; the full result is in the output file. */
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCatalog {
|
||||||
|
databases: string[]
|
||||||
|
database: string | null
|
||||||
|
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogColumnInput {
|
export interface CatalogColumnInput {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
@@ -1255,6 +1294,13 @@ export interface CloudServiceAdapter {
|
|||||||
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||||
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||||
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
||||||
|
startQuery?(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary>
|
||||||
|
/** The query's state, and its first rows once it has succeeded. */
|
||||||
|
getQueryResult?(workGroup: string, queryId: string): Promise<QueryResult>
|
||||||
|
stopQuery?(workGroup: string, queryId: string): Promise<void>
|
||||||
|
listQueries?(workGroup: string): Promise<QueryExecutionSummary[]>
|
||||||
|
/** The databases a query can use and, for the one named, its tables. */
|
||||||
|
describeQueryCatalog?(database?: string): Promise<QueryCatalog>
|
||||||
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
|
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
|
||||||
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
|
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
|
||||||
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
|
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||||
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guards the schema-to-implementation contract for every registered adapter.
|
* Guards the schema-to-implementation contract for every registered adapter.
|
||||||
@@ -81,6 +81,14 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
|
|||||||
revoke: 'revokeCertificate',
|
revoke: 'revokeCertificate',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_QUERY_ACTION: Record<QueryActionName, keyof CloudServiceAdapter> = {
|
||||||
|
run: 'startQuery',
|
||||||
|
results: 'getQueryResult',
|
||||||
|
stop: 'stopQuery',
|
||||||
|
history: 'listQueries',
|
||||||
|
catalog: 'describeQueryCatalog',
|
||||||
|
}
|
||||||
|
|
||||||
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
|
||||||
createTable: 'createCatalogTable',
|
createTable: 'createCatalogTable',
|
||||||
deleteTable: 'deleteCatalogTable',
|
deleteTable: 'deleteCatalogTable',
|
||||||
@@ -231,6 +239,15 @@ describe('registered adapters honour their schema', () => {
|
|||||||
).toBe('function')
|
).toBe('function')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryClaims = (adapter.schema().capabilities?.queryActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of queryClaims) {
|
||||||
|
const method = METHOD_FOR_QUERY_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
|
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
for (const capability of dataCatalogClaims) {
|
for (const capability of dataCatalogClaims) {
|
||||||
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
|
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
|
||||||
@@ -274,6 +291,7 @@ describe('registered adapters honour their schema', () => {
|
|||||||
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||||
...(adapter.schema().capabilities?.fileSystemActions ?? []),
|
...(adapter.schema().capabilities?.fileSystemActions ?? []),
|
||||||
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
|
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.queryActions ?? []),
|
||||||
...(adapter.schema().capabilities?.certificateActions ?? []),
|
...(adapter.schema().capabilities?.certificateActions ?? []),
|
||||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
|||||||
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
||||||
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
|
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
|
||||||
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
|
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
|
||||||
|
import {AwsQueryAdapter} from './adapter-aws/AwsQueryAdapter'
|
||||||
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||||
import {createEc2Service} from './services/ec2'
|
import {createEc2Service} from './services/ec2'
|
||||||
@@ -113,6 +114,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsPipesAdapter(clients.pipes),
|
new AwsPipesAdapter(clients.pipes),
|
||||||
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
|
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
|
||||||
new AwsDataCatalogAdapter(clients.glue),
|
new AwsDataCatalogAdapter(clients.glue),
|
||||||
|
new AwsQueryAdapter(clients.athena),
|
||||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
|||||||
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||||
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
|
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
|
||||||
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
|
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
|
||||||
|
import {awsQuerySchema} from '../cloud-spi/querySchema'
|
||||||
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
||||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
@@ -877,6 +878,28 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('runs, reads, lists and cancels queries through the Athena adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const execution = {id: 'q1', sql: 'SELECT 1', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML', submittedAt: null, completedAt: null, runtimeMs: 5, dataScannedBytes: 0, outputLocation: 's3://r/q1.csv'}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'query',
|
||||||
|
schema: awsQuerySchema,
|
||||||
|
startQuery: async (id, input) => { calls.push(['start', id, input.sql, input.database]); return execution },
|
||||||
|
getQueryResult: async (id, queryId) => { calls.push(['result', id, queryId]); return {execution, columns: [{name: '_col0', type: 'integer'}], rows: [['1']], truncated: false} },
|
||||||
|
stopQuery: async (id, queryId) => { calls.push(['stop', id, queryId]) },
|
||||||
|
listQueries: async (id) => { calls.push(['list', id]); return [execution] },
|
||||||
|
describeQueryCatalog: async (database) => { calls.push(['catalog', database]); return {databases: ['shop'], database: 'shop', tables: []} },
|
||||||
|
})])
|
||||||
|
const base = '/api/clouds/aws/services/query/resources/analysts/queries'
|
||||||
|
|
||||||
|
expect((await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({sql: 'SELECT 1', database: 'shop'})})).status).toBe(201)
|
||||||
|
expect((await (await app.request(`${base}/q1`)).json()).rows).toEqual([['1']])
|
||||||
|
expect((await app.request(base)).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/q1/stop`, {method: 'POST'})).status).toBe(200)
|
||||||
|
expect((await (await app.request('/api/clouds/aws/services/query/catalog?database=shop')).json()).databases).toEqual(['shop'])
|
||||||
|
expect(calls).toEqual([['start', 'analysts', 'SELECT 1', 'shop'], ['result', 'analysts', 'q1'], ['list', 'analysts'], ['stop', 'analysts', 'q1'], ['catalog', 'shop']])
|
||||||
|
})
|
||||||
|
|
||||||
test('creates and deletes catalog tables through the Glue adapter', async () => {
|
test('creates and deletes catalog tables through the Glue adapter', async () => {
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
|
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
AccessPointInput,
|
AccessPointInput,
|
||||||
CatalogTableInput,
|
CatalogTableInput,
|
||||||
|
QueryInput,
|
||||||
FileSystemAttachmentInput,
|
FileSystemAttachmentInput,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
@@ -557,6 +558,44 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/catalog', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).describeQueryCatalog(cloud, c.req.query('database') || undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).listQueries(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).startQuery(cloud, c.req.param('id'), await c.req.json<QueryInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries/:queryId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).getQueryResult(cloud, c.req.param('id'), c.req.param('queryId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries/:queryId/stop', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).stopQuery(cloud, c.req.param('id'), c.req.param('queryId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/:cloud/services/datacatalog/resources/:id/tables', async (c) => {
|
app.post('/:cloud/services/datacatalog/resources/:id/tables', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ import type {
|
|||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
AccessPointInput,
|
AccessPointInput,
|
||||||
CatalogTableInput,
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
FileSystemAttachmentInput,
|
FileSystemAttachmentInput,
|
||||||
CertificatePem,
|
CertificatePem,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
@@ -967,6 +971,36 @@ export class CloudProxyService {
|
|||||||
return adapter.revokeCertificate(certificateId, reason)
|
return adapter.revokeCertificate(certificateId, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startQuery(cloud: CloudProvider, workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.startQuery) throw new NotSupportedError(`Running queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.startQuery(workGroup, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(cloud: CloudProvider, workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.getQueryResult) throw new NotSupportedError(`Query results are not supported for ${cloud}/query`)
|
||||||
|
return adapter.getQueryResult(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(cloud: CloudProvider, workGroup: string, queryId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.stopQuery) throw new NotSupportedError(`Cancelling queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.stopQuery(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(cloud: CloudProvider, workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.listQueries) throw new NotSupportedError(`Query history is not supported for ${cloud}/query`)
|
||||||
|
return adapter.listQueries(workGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(cloud: CloudProvider, database?: string): Promise<QueryCatalog> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.describeQueryCatalog) throw new NotSupportedError(`Browsing the catalog is not supported for ${cloud}/query`)
|
||||||
|
return adapter.describeQueryCatalog(database)
|
||||||
|
}
|
||||||
|
|
||||||
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
||||||
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
||||||
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
|
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ export const apiEndpointKeys = {
|
|||||||
export: "clouds.services.certificates.export",
|
export: "clouds.services.certificates.export",
|
||||||
revoke: "clouds.services.certificates.revoke",
|
revoke: "clouds.services.certificates.revoke",
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
catalog: "clouds.services.query.catalog",
|
||||||
|
queries: "clouds.services.query.queries",
|
||||||
|
start: "clouds.services.query.start",
|
||||||
|
result: "clouds.services.query.result",
|
||||||
|
stop: "clouds.services.query.stop",
|
||||||
|
},
|
||||||
datacatalog: {
|
datacatalog: {
|
||||||
tables: "clouds.services.datacatalog.tables",
|
tables: "clouds.services.datacatalog.tables",
|
||||||
table: "clouds.services.datacatalog.table",
|
table: "clouds.services.datacatalog.table",
|
||||||
@@ -968,6 +975,41 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/catalog",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId/stop",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.datacatalog.tables,
|
apiEndpointKeys.clouds.datacatalog.tables,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import type {
|
|||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
AccessPointInput,
|
AccessPointInput,
|
||||||
CatalogTableInput,
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
FileSystemAttachmentInput,
|
FileSystemAttachmentInput,
|
||||||
CertificatePem,
|
CertificatePem,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
@@ -1724,6 +1728,71 @@ export async function revokeCertificate(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function describeQueryCatalog(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
database?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryCatalog> {
|
||||||
|
const res = await apiClient.call<QueryCatalog>(
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
requestOptions(cloud, "query", { signal, params: { database } }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listQueries(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryExecutionSummary[]> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary[]>(
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
input: QueryInput,
|
||||||
|
): Promise<QueryExecutionSummary> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary, QueryInput>(
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
requestOptions(cloud, "query", { body: input }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueryResult(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryResult> {
|
||||||
|
const res = await apiClient.call<QueryResult>(
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
requestOptions(cloud, "query"),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCatalogTable(
|
export async function createCatalogTable(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
databaseName: string,
|
databaseName: string,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {render, screen, within} from '@testing-library/react'
|
import {render, screen, within} from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
import {beforeEach, expect, test, vi} from 'vitest'
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
import {DataCatalogPanel} from './DataCatalogPanel'
|
import {DataCatalogPanel} from './DataCatalogPanel'
|
||||||
import type {CloudResource} from '@/types/resource'
|
import type {CloudResource} from '@/types/resource'
|
||||||
@@ -24,7 +25,7 @@ function database(tables: unknown[] = [{
|
|||||||
function renderPanel(resource: CloudResource) {
|
function renderPanel(resource: CloudResource) {
|
||||||
api.getCloudResource.mockResolvedValue(resource)
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
return render(<QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
return render(<MemoryRouter><QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -40,6 +41,7 @@ test('lists tables with their format, location and columns', async () => {
|
|||||||
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
|
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
|
||||||
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
|
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
|
||||||
expect(within(row).getByText('amount')).toBeInTheDocument()
|
expect(within(row).getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Query in Athena'})).toHaveAttribute('href', '/cloud-explorer/aws/query')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates a CSV table from the columns entered', async () => {
|
test('creates a CSV table from the columns entered', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Plus, RefreshCw, X} from 'lucide-react'
|
import {Plus, RefreshCw, X} from 'lucide-react'
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
|
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
|
||||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
import {useAccountId} from '@/lib/accountStore'
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
@@ -90,6 +91,7 @@ export function DataCatalogPanel({resource, runtimeReachable}: Props) {
|
|||||||
<div className="input-row resource-table-bar">
|
<div className="input-row resource-table-bar">
|
||||||
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
|
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
|
||||||
<div className="cdn-actions">
|
<div className="cdn-actions">
|
||||||
|
{tables.length > 0 && <Link className="button" to="/cloud-explorer/aws/query">Query in Athena</Link>}
|
||||||
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
|||||||
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||||
import { FileSystemPanel } from "@/components/FileSystemPanel";
|
import { FileSystemPanel } from "@/components/FileSystemPanel";
|
||||||
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
|
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
|
||||||
|
import { QueryPanel } from "@/components/QueryPanel";
|
||||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
import { CertificatePanel } from "@/components/CertificatePanel";
|
||||||
import { PipePanel } from "@/components/PipePanel";
|
import { PipePanel } from "@/components/PipePanel";
|
||||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||||
@@ -1007,6 +1008,9 @@ export function DynamicResourceView({
|
|||||||
{service === "datacatalog" && cloud === "aws" && (
|
{service === "datacatalog" && cloud === "aws" && (
|
||||||
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "query" && cloud === "aws" && (
|
||||||
|
<QueryPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
{service === "cdn" && cloud === "aws" && (
|
{service === "cdn" && cloud === "aws" && (
|
||||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {QueryPanel} from './QueryPanel'
|
||||||
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
describeQueryCatalog: vi.fn(),
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listQueries: vi.fn(),
|
||||||
|
startQuery: vi.fn(),
|
||||||
|
getQueryResult: vi.fn(),
|
||||||
|
stopQuery: vi.fn(),
|
||||||
|
storageObjectDownloadUrl: vi.fn((_cloud: string, bucket: string, key: string) => `/download/${bucket}/${key}`),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const workGroup: CloudResource = {id: 'analysts', name: 'analysts', cloud: 'aws', service: 'query', type: 'athenaWorkGroup', region: null, createdAt: null, status: 'ENABLED', metadata: {outputLocation: 's3://results/'}}
|
||||||
|
|
||||||
|
function execution(overrides: Partial<QueryExecutionSummary> = {}): QueryExecutionSummary {
|
||||||
|
return {
|
||||||
|
id: 'q1', sql: 'SELECT * FROM orders LIMIT 10', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML',
|
||||||
|
submittedAt: '2026-10-25T21:00:00.000Z', completedAt: null, runtimeMs: 42, dataScannedBytes: 0, outputLocation: 's3://results/q1.csv', ...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><QueryPanel resource={workGroup} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCloudResource.mockResolvedValue({...workGroup, metadata: {outputLocation: 's3://results/athena/'}})
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: ['logs', 'shop'], database: 'logs', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}]}]})
|
||||||
|
api.listQueries.mockResolvedValue([])
|
||||||
|
api.startQuery.mockResolvedValue(execution({state: 'RUNNING'}))
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution(), columns: [{name: 'id', type: 'integer'}, {name: 'amount', type: 'double'}], rows: [['1', '10.5'], ['2', null]], truncated: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts from a preview of the first table and lists its columns', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Database')).toHaveValue('logs')
|
||||||
|
expect(await screen.findByText('s3://results/athena/')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs the query in the workgroup and shows its rows', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Run'}))
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM orders LIMIT 10', database: 'logs'})
|
||||||
|
expect(await screen.findByText('10.5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NULL')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 rows')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Download CSV'})).toHaveAttribute('href', '/download/results/q1.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows why a query failed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution({state: 'FAILED', stateReason: 'Table nope does not exist'}), columns: [], rows: [], truncated: false})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const sql = await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.clear(sql)
|
||||||
|
await user.type(sql, 'SELECT * FROM nope')
|
||||||
|
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM nope', database: 'logs'})
|
||||||
|
expect(await screen.findByText('Table nope does not exist')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', {name: 'Download CSV'})).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens a query from the history', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.listQueries.mockResolvedValue([execution({id: 'q0', sql: 'SELECT count(*) FROM orders', database: 'shop'})])
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'SELECT count(*) FROM orders'}))
|
||||||
|
expect(screen.getByLabelText('SQL')).toHaveValue('SELECT count(*) FROM orders')
|
||||||
|
expect(api.getQueryResult).toHaveBeenCalledWith('aws', 'analysts', 'q0', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('points to the Glue Data Catalog when there is nothing to query', async () => {
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: [], database: null, tables: []})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: 'Glue Data Catalog'})
|
||||||
|
expect(link).toHaveAttribute('href', '/cloud-explorer/aws/datacatalog')
|
||||||
|
expect(within(screen.getByRole('form', {name: 'Query'})).getByRole('button', {name: 'Run'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Radio,
|
Radio,
|
||||||
Scaling,
|
Scaling,
|
||||||
|
SearchCode,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Scale,
|
Scale,
|
||||||
Server,
|
Server,
|
||||||
@@ -42,6 +43,7 @@ import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Deliver
|
|||||||
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
||||||
import efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
|
import efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
|
||||||
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
|
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
|
||||||
|
import athenaIcon from '../../../../aws-icons-svg/Analytics/Athena.svg'
|
||||||
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
||||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
@@ -106,6 +108,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
autoscaling: Scaling,
|
autoscaling: Scaling,
|
||||||
filesystems: FolderTree,
|
filesystems: FolderTree,
|
||||||
datacatalog: Table2,
|
datacatalog: Table2,
|
||||||
|
query: SearchCode,
|
||||||
certificates: FileBadge,
|
certificates: FileBadge,
|
||||||
monitoring: Activity,
|
monitoring: Activity,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
@@ -158,6 +161,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
autoscaling: autoScalingIcon,
|
autoscaling: autoScalingIcon,
|
||||||
filesystems: efsIcon,
|
filesystems: efsIcon,
|
||||||
datacatalog: glueIcon,
|
datacatalog: glueIcon,
|
||||||
|
query: athenaIcon,
|
||||||
certificates: certificateManagerIcon,
|
certificates: certificateManagerIcon,
|
||||||
monitoring: cloudWatchIcon,
|
monitoring: cloudWatchIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
|
|||||||
@@ -6654,3 +6654,24 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
|||||||
.catalog-column-row .input { width: 100%; min-width: 0; }
|
.catalog-column-row .input { width: 100%; min-width: 0; }
|
||||||
.catalog-columns-editor > .button { justify-self: start; }
|
.catalog-columns-editor > .button { justify-self: start; }
|
||||||
@media (max-width: 640px) { .catalog-span { grid-column: auto; } }
|
@media (max-width: 640px) { .catalog-span { grid-column: auto; } }
|
||||||
|
|
||||||
|
/* Athena: catalog beside the editor, results below, finished/failed states coloured by their badge. */
|
||||||
|
.query-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 16px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.query-catalog { display: grid; gap: 10px; align-content: start; min-width: 0; }
|
||||||
|
.query-catalog p { margin: 0; }
|
||||||
|
.query-tables { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
|
.query-tables > li { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
|
||||||
|
.query-tables details { min-width: 0; flex: 1; }
|
||||||
|
.query-tables summary { cursor: pointer; overflow-wrap: anywhere; }
|
||||||
|
.query-tables details ul { list-style: none; margin: 4px 0 0 14px; padding: 0; display: grid; gap: 2px; }
|
||||||
|
.query-editor { display: grid; gap: 10px; align-content: start; min-width: 0; }
|
||||||
|
.query-editor form { display: grid; gap: 8px; }
|
||||||
|
.query-sql { min-height: 140px; resize: vertical; }
|
||||||
|
.query-actions, .query-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.query-state--succeeded { color: var(--status-ok); }
|
||||||
|
.query-state--failed, .query-state--cancelled { color: var(--status-error); }
|
||||||
|
.query-error { margin: 0; padding: 8px 10px; border-radius: 4px; background: var(--raised); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
||||||
|
.query-results { max-height: 420px; overflow: auto; }
|
||||||
|
.query-note { padding: 0 16px 12px; margin: 0; }
|
||||||
|
.query-history-sql { text-align: left; max-width: 640px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
|
@media (max-width: 760px) { .query-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|||||||
@@ -362,6 +362,39 @@ export interface AutoScalingCapacityInput {
|
|||||||
maxSize?: number
|
maxSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
sql: string
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutionSummary {
|
||||||
|
id: string
|
||||||
|
sql: string
|
||||||
|
database: string | null
|
||||||
|
workGroup: string | null
|
||||||
|
state: string
|
||||||
|
stateReason: string | null
|
||||||
|
statementType: string | null
|
||||||
|
submittedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
runtimeMs: number | null
|
||||||
|
dataScannedBytes: number | null
|
||||||
|
outputLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
execution: QueryExecutionSummary
|
||||||
|
columns: Array<{name: string; type: string | null}>
|
||||||
|
rows: Array<Array<string | null>>
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCatalog {
|
||||||
|
databases: string[]
|
||||||
|
database: string | null
|
||||||
|
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogColumnInput {
|
export interface CatalogColumnInput {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
|||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||||
|
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
|
||||||
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
||||||
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
@@ -99,6 +100,7 @@ export interface ServiceSchema {
|
|||||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
||||||
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
|
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
|
||||||
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
|
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
|
||||||
|
queryActions?: Array<CapabilitySchema<QueryActionName>>
|
||||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
|
|||||||
Generated
+137
-119
@@ -22,6 +22,9 @@ importers:
|
|||||||
'@aws-sdk/client-appconfig':
|
'@aws-sdk/client-appconfig':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1130.0
|
version: 3.1130.0
|
||||||
|
'@aws-sdk/client-athena':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.0
|
||||||
'@aws-sdk/client-auto-scaling':
|
'@aws-sdk/client-auto-scaling':
|
||||||
specifier: ^3.1146.0
|
specifier: ^3.1146.0
|
||||||
version: 3.1156.0
|
version: 3.1156.0
|
||||||
@@ -329,6 +332,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
|
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-athena@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-rqebnBvY6c9DuSKZz99dI0BS01HZl3iqXYTWTqbEbu02C1YCiYoho7tW7buAnNqIhZ1laB+EJh/JncO59pruJA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||||
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
|
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2919,15 +2926,15 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-crypto/util@5.2.0':
|
'@aws-crypto/util@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/util-utf8': 2.3.0
|
'@smithy/util-utf8': 2.3.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/checksums@3.1000.13':
|
'@aws-sdk/checksums@3.1000.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
@@ -2976,6 +2983,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.17.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-athena@3.1156.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/credential-provider-node': 3.972.88
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.5
|
'@aws-sdk/core': 3.978.5
|
||||||
@@ -3489,34 +3507,34 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.54':
|
'@aws-sdk/credential-provider-env@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.62':
|
'@aws-sdk/credential-provider-env@3.972.62':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.69':
|
'@aws-sdk/credential-provider-env@3.972.69':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.70':
|
'@aws-sdk/credential-provider-env@3.972.70':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.71':
|
'@aws-sdk/credential-provider-env@3.972.71':
|
||||||
@@ -3545,42 +3563,42 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.56':
|
'@aws-sdk/credential-provider-http@3.972.56':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.64':
|
'@aws-sdk/credential-provider-http@3.972.64':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.71':
|
'@aws-sdk/credential-provider-http@3.972.71':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.72':
|
'@aws-sdk/credential-provider-http@3.972.72':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.73':
|
'@aws-sdk/credential-provider-http@3.972.73':
|
||||||
@@ -3615,7 +3633,7 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.60
|
'@aws-sdk/credential-provider-login': 3.972.60
|
||||||
@@ -3623,15 +3641,15 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.14':
|
'@aws-sdk/credential-provider-ini@3.973.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.76
|
'@aws-sdk/credential-provider-login': 3.972.76
|
||||||
@@ -3639,15 +3657,15 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.15':
|
'@aws-sdk/credential-provider-ini@3.973.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.77
|
'@aws-sdk/credential-provider-login': 3.972.77
|
||||||
@@ -3655,10 +3673,10 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.16':
|
'@aws-sdk/credential-provider-ini@3.973.16':
|
||||||
@@ -3711,7 +3729,7 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.62
|
'@aws-sdk/credential-provider-env': 3.972.62
|
||||||
'@aws-sdk/credential-provider-http': 3.972.64
|
'@aws-sdk/credential-provider-http': 3.972.64
|
||||||
'@aws-sdk/credential-provider-login': 3.972.69
|
'@aws-sdk/credential-provider-login': 3.972.69
|
||||||
@@ -3719,10 +3737,10 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.6
|
'@aws-sdk/credential-provider-sso': 3.973.6
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.68
|
'@aws-sdk/credential-provider-web-identity': 3.972.68
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.4.14
|
'@smithy/credential-provider-imds': 4.4.14
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.60':
|
'@aws-sdk/credential-provider-login@3.972.60':
|
||||||
@@ -3902,34 +3920,34 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.54':
|
'@aws-sdk/credential-provider-process@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.62':
|
'@aws-sdk/credential-provider-process@3.972.62':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.69':
|
'@aws-sdk/credential-provider-process@3.972.69':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.70':
|
'@aws-sdk/credential-provider-process@3.972.70':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.71':
|
'@aws-sdk/credential-provider-process@3.972.71':
|
||||||
@@ -3958,32 +3976,32 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/token-providers': 3.1080.0
|
'@aws-sdk/token-providers': 3.1080.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.13':
|
'@aws-sdk/credential-provider-sso@3.973.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/token-providers': 3.1111.0
|
'@aws-sdk/token-providers': 3.1111.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.14':
|
'@aws-sdk/credential-provider-sso@3.973.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/token-providers': 3.1116.0
|
'@aws-sdk/token-providers': 3.1116.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.15':
|
'@aws-sdk/credential-provider-sso@3.973.15':
|
||||||
@@ -4018,48 +4036,48 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/token-providers': 3.1096.0
|
'@aws-sdk/token-providers': 3.1096.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.60':
|
'@aws-sdk/credential-provider-web-identity@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.68':
|
'@aws-sdk/credential-provider-web-identity@3.972.68':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.77':
|
'@aws-sdk/credential-provider-web-identity@3.972.77':
|
||||||
@@ -4370,17 +4388,17 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.33':
|
'@aws-sdk/xml-builder@3.972.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.37':
|
'@aws-sdk/xml-builder@3.972.37':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.39':
|
'@aws-sdk/xml-builder@3.972.39':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.40':
|
'@aws-sdk/xml-builder@3.972.40':
|
||||||
@@ -4863,14 +4881,14 @@ snapshots:
|
|||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.14':
|
'@smithy/credential-provider-imds@4.4.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.6':
|
'@smithy/credential-provider-imds@4.4.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.5.0':
|
'@smithy/credential-provider-imds@4.5.0':
|
||||||
@@ -4958,14 +4976,14 @@ snapshots:
|
|||||||
|
|
||||||
'@smithy/signature-v4@5.6.10':
|
'@smithy/signature-v4@5.6.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/signature-v4@5.6.2':
|
'@smithy/signature-v4@5.6.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/signature-v4@5.7.0':
|
'@smithy/signature-v4@5.7.0':
|
||||||
|
|||||||
Reference in New Issue
Block a user