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:
@@ -1,6 +1,7 @@
|
||||
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 {DataCatalogPanel} from './DataCatalogPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
@@ -24,7 +25,7 @@ function database(tables: unknown[] = [{
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
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(() => {
|
||||
@@ -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('semicolon-separated, header row')).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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Plus, RefreshCw, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
@@ -90,6 +91,7 @@ export function DataCatalogPanel({resource, runtimeReachable}: Props) {
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||
import { FileSystemPanel } from "@/components/FileSystemPanel";
|
||||
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
|
||||
import { QueryPanel } from "@/components/QueryPanel";
|
||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
||||
import { PipePanel } from "@/components/PipePanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
@@ -1007,6 +1008,9 @@ export function DynamicResourceView({
|
||||
{service === "datacatalog" && cloud === "aws" && (
|
||||
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "query" && cloud === "aws" && (
|
||||
<QueryPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<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,
|
||||
Radio,
|
||||
Scaling,
|
||||
SearchCode,
|
||||
ScrollText,
|
||||
Scale,
|
||||
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 efsIcon from '../../../../aws-icons-svg/Storage/EFS.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 cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
@@ -106,6 +108,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
autoscaling: Scaling,
|
||||
filesystems: FolderTree,
|
||||
datacatalog: Table2,
|
||||
query: SearchCode,
|
||||
certificates: FileBadge,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
@@ -158,6 +161,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
autoscaling: autoScalingIcon,
|
||||
filesystems: efsIcon,
|
||||
datacatalog: glueIcon,
|
||||
query: athenaIcon,
|
||||
certificates: certificateManagerIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
|
||||
Reference in New Issue
Block a user