CI/CD - CodeBuild + CodeDeploy + CodePipeline

This commit is contained in:
Zakaria
2026-10-01 09:18:04 -04:00
parent aa02cdb7f6
commit 7f0d707578
41 changed files with 1817 additions and 78 deletions
+55 -2
View File
@@ -22,7 +22,10 @@ export const apiEndpointKeys = {
create: "clouds.services.resources.create",
update: "clouds.services.resources.update",
delete: "clouds.services.resources.delete",
invoke: "clouds.services.resources.invoke",
invoke: "clouds.services.resources.invoke",
start: "clouds.services.resources.start",
stop: "clouds.services.resources.stop",
reboot: "clouds.services.resources.reboot",
publish: "clouds.services.resources.notifications.publish",
createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
@@ -60,7 +63,17 @@ export const apiEndpointKeys = {
listWorkflowExecutions: "clouds.services.resources.workflows.executions.list",
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
getWorkflowExecutionHistory: "clouds.services.resources.workflows.execution.history.get",
stopWorkflowExecution: "clouds.services.resources.workflows.execution.stop",
stopWorkflowExecution: "clouds.services.resources.workflows.execution.stop",
startCodeBuild: "clouds.services.resources.codebuild.build.start",
listCodeBuilds: "clouds.services.resources.codebuild.builds.list",
getCodeBuild: "clouds.services.resources.codebuild.build.get",
startCodePipeline: "clouds.services.resources.codepipeline.execution.start",
listCodePipelineExecutions: "clouds.services.resources.codepipeline.executions.list",
getCodePipelineState: "clouds.services.resources.codepipeline.state.get",
startCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.start",
listCodeDeployDeployments: "clouds.services.resources.codedeploy.deployments.list",
getCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.get",
stopCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.stop",
},
storage: {
objects: {
@@ -342,6 +355,18 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[
apiEndpointKeys.clouds.resources.start,
{ path: "/clouds/:cloud/services/:service/resources/:id/start", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.stop,
{ path: "/clouds/:cloud/services/:service/resources/:id/stop", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.reboot,
{ path: "/clouds/:cloud/services/:service/resources/:id/reboot", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.publish,
{
@@ -542,6 +567,34 @@ export const endpointRegistry: EndpointRegistry = new Map([
apiEndpointKeys.clouds.resources.stopWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.startCodeBuild,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.listCodeBuilds,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.getCodeBuild,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds/:buildId", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.startCodePipeline,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/executions", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.listCodePipelineExecutions,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/executions", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.getCodePipelineState,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/state", method: "GET" },
],
[apiEndpointKeys.clouds.resources.startCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/resources/:id/deployments", method: "POST"}],
[apiEndpointKeys.clouds.resources.listCodeDeployDeployments, {path: "/clouds/:cloud/services/codedeploy/resources/:id/deployments", method: "GET"}],
[apiEndpointKeys.clouds.resources.getCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/deployments/:deploymentId", method: "GET"}],
[apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/deployments/:deploymentId/stop", method: "POST"}],
[
apiEndpointKeys.clouds.resources.get,
{
@@ -208,6 +208,42 @@ export async function deleteCloudResource(
);
}
export async function startCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.start,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function stopCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.stop,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function rebootCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.reboot,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function clearEmailInbox(
cloud: CloudProvider,
signal?: AbortSignal,
@@ -257,6 +293,47 @@ export interface WorkflowExecutionHistoryPage {
nextToken?: string;
}
export interface CodeBuildEnvironmentVariableOverride {
name: string;
value: string;
type?: "PLAINTEXT" | "PARAMETER_STORE" | "SECRETS_MANAGER";
}
export interface CodeBuildBuild {
id: string;
arn?: string;
projectName: string;
buildNumber?: number;
buildStatus?: string;
currentPhase?: string;
startTime?: string;
endTime?: string;
phases: Array<{phaseType: string; phaseStatus?: string; startTime?: string; endTime?: string; contexts: Array<{statusCode?: string; message?: string}>}>;
logGroupName?: string;
logStreamName?: string;
}
export interface PipelineExecutionSummary {
id: string;
status?: string;
statusSummary?: string;
startTime?: string;
lastUpdateTime?: string;
}
export interface PipelineState {
stages: Array<{
name: string;
status?: string;
lastStatusChange?: string;
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string}>;
}>;
}
export interface CodeDeployDeployment { id: string; applicationName: string; deploymentGroupName: string; status: string | null; creator: string | null; deploymentConfigName: string | null; createTime?: string; completeTime?: string; errorInformation: {code?: string; message?: string} | null; currentVersion: string | null; targetVersion: string | null; trafficPercent: number | null }
export interface StartCodeDeployDeploymentInput { functionName: string; alias: string; currentVersion: string; targetVersion: string; beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string }
export interface CodeDeployDependencies { lambdaFunctions: Array<{name: string; aliases: string[]; versions: string[]}> }
export interface CreateApiGatewayLambdaRouteInput {
path: string;
parentResourceId?: string;
@@ -883,6 +960,42 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr
);
}
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise<CodeBuildBuild> {
const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
return res.data;
}
export async function listCodeBuilds(projectName: string, signal?: AbortSignal): Promise<CodeBuildBuild[]> {
const res = await apiClient.call<CodeBuildBuild[]>(apiEndpointKeys.clouds.resources.listCodeBuilds, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName});
return res.data;
}
export async function getCodeBuild(projectName: string, buildId: string, signal?: AbortSignal): Promise<CodeBuildBuild> {
const res = await apiClient.call<CodeBuildBuild>(apiEndpointKeys.clouds.resources.getCodeBuild, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName, buildId});
return res.data;
}
export async function startCodePipeline(name: string): Promise<{id: string}> {
const res = await apiClient.call<{id: string}>(apiEndpointKeys.clouds.resources.startCodePipeline, requestOptions('aws', 'codepipeline'), {cloud: 'aws', id: name});
return res.data;
}
export async function listCodePipelineExecutions(name: string, signal?: AbortSignal): Promise<PipelineExecutionSummary[]> {
const res = await apiClient.call<PipelineExecutionSummary[]>(apiEndpointKeys.clouds.resources.listCodePipelineExecutions, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
return res.data;
}
export async function getCodePipelineState(name: string, signal?: AbortSignal): Promise<PipelineState> {
const res = await apiClient.call<PipelineState>(apiEndpointKeys.clouds.resources.getCodePipelineState, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
return res.data;
}
export async function getCodeDeployDependencies(signal?: AbortSignal): Promise<CodeDeployDependencies> { const res = await apiClient.call<CodeDeployDependencies>(apiEndpointKeys.clouds.dependencies, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', service: 'codedeploy'}); return res.data }
export async function startCodeDeployDeployment(groupId: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment, StartCodeDeployDeploymentInput>(apiEndpointKeys.clouds.resources.startCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: input}), {cloud: 'aws', id: groupId}); return res.data }
export async function listCodeDeployDeployments(groupId: string, signal?: AbortSignal): Promise<CodeDeployDeployment[]> { const res = await apiClient.call<CodeDeployDeployment[]>(apiEndpointKeys.clouds.resources.listCodeDeployDeployments, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', id: groupId}); return res.data }
export async function getCodeDeployDeployment(id: string, signal?: AbortSignal): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment>(apiEndpointKeys.clouds.resources.getCodeDeployDeployment, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', deploymentId: id}); return res.data }
export async function stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await apiClient.call<void, {rollback: boolean}>(apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: {rollback}}), {cloud: 'aws', deploymentId: id}) }
export async function publishNotification(
cloud: CloudProvider,
topicArn: string,
@@ -26,6 +26,10 @@ const POSTGRES_DEFAULT_QUERY = `SELECT
current_database() AS database_name,
current_user AS username,
version();`;
const MYSQL_DEFAULT_QUERY = `SELECT
DATABASE() AS database_name,
CURRENT_USER() AS username,
VERSION() AS version;`;
interface AzureSqlPanelProps {
cloud: CloudProvider;
@@ -40,11 +44,13 @@ export function AzureSqlPanel({
}: AzureSqlPanelProps) {
const accountId = useAccountId();
const serverId = resource?.id;
const engine: SqlEngine =
resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
const defaultDatabase = engine === "postgresql" ? "postgres" : "master";
const isAwsRds = cloud === "aws" && resource?.type === "db-instance";
const engine: SqlEngine = isAwsRds
? resource?.engine === "mysql" ? "mysql" : "postgresql"
: resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
const defaultDatabase = engine === "postgresql" ? "postgres" : engine === "mysql" ? "mysql" : "master";
const defaultQuery =
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : engine === "mysql" ? MYSQL_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
const administratorLogin = metadataString(
resource?.metadata.administratorLogin,
);
@@ -100,7 +106,7 @@ export function AzureSqlPanel({
setQueryText(defaultQuery);
}, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]);
if (cloud !== "azure") return null;
if (cloud !== "azure" && !isAwsRds) return null;
if (!serverId) {
return (
@@ -115,7 +121,7 @@ export function AzureSqlPanel({
function connect(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!username || !password) return;
if (!isAwsRds && (!username || !password)) return;
queryMut.reset();
loadDatabases();
}
@@ -181,12 +187,12 @@ export function AzureSqlPanel({
<div className="sql-connection-title">
<Plug size={16} />
<span>
<small>{engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
<small>{isAwsRds ? "RDS DATA API" : engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
<strong>{resource.name}</strong>
<em>{metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
<em>{isAwsRds ? `${resource.engine} · ${resource.status ?? "unknown"}` : metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
</span>
</div>
<label>
{!isAwsRds && <label>
<span>Username</span>
<input
className="input"
@@ -195,8 +201,8 @@ export function AzureSqlPanel({
disabled={connected || !runtimeReachable}
autoComplete="username"
/>
</label>
<label>
</label>}
{!isAwsRds && <label>
<span>Password</span>
<input
className="input"
@@ -206,7 +212,7 @@ export function AzureSqlPanel({
disabled={connected || !runtimeReachable}
autoComplete="current-password"
/>
</label>
</label>}
{connected ? (
<button className="button" type="button" onClick={disconnect}>
<Unplug size={14} />
@@ -219,8 +225,7 @@ export function AzureSqlPanel({
disabled={
!runtimeReachable ||
databasesMut.isPending ||
!username ||
!password
(!isAwsRds && (!username || !password))
}
>
<Plug size={14} />
@@ -228,7 +233,9 @@ export function AzureSqlPanel({
</button>
)}
<small className="sql-credential-note">
Credentials remain in this page only and are sent to the local API per request.
{isAwsRds
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
: "Credentials remain in this page only and are sent to the local API per request."}
</small>
{connectionError && <div className="form-error">{connectionError}</div>}
</form>
@@ -236,7 +243,7 @@ export function AzureSqlPanel({
{!connected ? (
<div className="empty compact sql-connect-empty">
<h3>Connect to browse SQL data</h3>
<p>Use the administrator login configured for this local engine.</p>
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
</div>
) : (
<div className="sql-explorer">
@@ -329,6 +336,8 @@ export function AzureSqlPanel({
title={selectedDatabase}
detail={engine === "postgresql"
? "PostgreSQL SQL against selected database"
: engine === "mysql"
? "MySQL SQL against selected database"
: "T-SQL against selected database"}
/>
<div className="sql-query-editor">
@@ -454,12 +463,16 @@ function metadataString(value: unknown): string {
function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string {
const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`;
return engine === 'postgresql'
? `SELECT * FROM ${tableName} LIMIT 100;`
: engine === 'mysql'
? `SELECT * FROM ${tableName} LIMIT 100;`
: `SELECT TOP (100) * FROM ${tableName};`;
}
function quoteIdentifier(value: string, engine: SqlEngine): string {
return engine === "postgresql"
return engine === "mysql"
? `\`${value.replace(/`/g, "``")}\``
: engine === "postgresql"
? `"${value.replace(/"/g, '""')}"`
: `[${value.replace(/]/g, "]]")}]`;
}
@@ -0,0 +1,46 @@
import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react'
import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: boolean }
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
const client = useQueryClient()
const [startOpen, setStartOpen] = useState(false)
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
const [selectedId, setSelectedId] = useState<string>()
const enabled = Boolean(resource && runtimeReachable)
const buildsKey = ['codebuild-builds', resource?.id]
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }})
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id])
if (!resource) return null
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Builds</p><h3>Run and inspect builds</h3></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || builds.isFetching} onClick={() => builds.refetch()}><RefreshCw size={13}/>{builds.isFetching ? 'Refreshing' : 'Refresh'}</button><button className="button primary" type="button" disabled={!enabled} onClick={() => setStartOpen(true)}><Play size={13}/>Start build</button></div></div>
{startOpen && <StartBuildDialog overrides={overrides} pending={start.isPending} error={start.error} onChange={setOverrides} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>}
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
</section>
}
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) {
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">Environment variable overrides apply only to this build.</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
}
function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) {
if (loading) return <p className="muted compact-text">Loading build details...</p>
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load build details.')}</p>
if (!build) return null
const logsHref = build.logGroupName ? `/cloud-explorer/aws/logs?resource=${encodeURIComponent(build.logGroupName)}` : undefined
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Build detail</p><h3>#{build.buildNumber ?? build.id}</h3><p className="muted compact-text">{build.buildStatus ?? 'Unknown'}{build.endTime ? `, finished ${formatDateTime(build.endTime)}` : ''}</p></div>{logsHref && <a className="button compact" href={logsHref}><ExternalLink size={12}/>Open logs{build.logStreamName ? `: ${build.logStreamName}` : ''}</a>}</div><ol className="workflow-execution-history">{build.phases.map((phase, index) => <li key={`${phase.phaseType}-${index}`}><div><strong>{phase.phaseType}</strong><span>{phase.phaseStatus ?? 'Unknown'}</span></div><time>{formatDateTime(phase.startTime) ?? '-'}{phase.endTime ? ` to ${formatDateTime(phase.endTime)}` : ''}</time>{phase.contexts.map((context, contextIndex) => <p className="muted compact-text" key={contextIndex}>{context.statusCode}: {context.message}</p>)}</li>)}</ol></section>
}
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
@@ -0,0 +1,63 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {Ban, Play, RefreshCw, RotateCcw} from 'lucide-react'
import {useState} from 'react'
import {getCodeDeployDependencies, listCodeDeployDeployments, startCodeDeployDeployment, stopCodeDeployDeployment, type CodeDeployDependencies} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void }
export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) {
const [applicationName, setApplicationName] = useState('')
const [deploymentGroupName, setDeploymentGroupName] = useState('')
const [serviceRoleArn, setServiceRoleArn] = useState('')
const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}>
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">The group controls the traffic-shift policy and rollback on deployment failure.</p></div></div>
<label>Application name<input className="input" required value={applicationName} onChange={(event) => setApplicationName(event.target.value)}/></label>
<label>Deployment group name<input className="input" required value={deploymentGroupName} onChange={(event) => setDeploymentGroupName(event.target.value)}/></label>
<label>CodeDeploy service role ARN<input className="input" required value={serviceRoleArn} onChange={(event) => setServiceRoleArn(event.target.value)} placeholder="arn:aws:iam::000000000000:role/CodeDeployRole"/></label>
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent30Minutes">Canary 10% then 30 minutes</option><option value="Linear10PercentEvery1Minute">Linear 10% every minute</option><option value="Linear10PercentEvery10Minutes">Linear 10% every 10 minutes</option></select></label>
<label className="checkbox-row"><input type="checkbox" checked={rollbackOnFailure} onChange={(event) => setRollbackOnFailure(event.target.checked)}/> Roll back automatically on deployment failure</label>
{submitError && <p className="error-text compact-text">{submitError}</p>}
<div className="form-actions"><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? 'Creating' : 'Create deployment group'}</button></div>
</form>
}
export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
const client = useQueryClient()
const [functionName, setFunctionName] = useState('')
const [alias, setAlias] = useState('')
const [currentVersion, setCurrentVersion] = useState('')
const [targetVersion, setTargetVersion] = useState('')
const [beforeHook, setBeforeHook] = useState('')
const [afterHook, setAfterHook] = useState('')
const enabled = Boolean(resource && runtimeReachable)
const dependencies = useQuery({queryKey: ['codedeploy-dependencies'], queryFn: ({signal}) => getCodeDeployDependencies(signal), enabled})
const deploymentsKey = ['codedeploy-deployments', resource?.id]
const deployments = useQuery({queryKey: deploymentsKey, queryFn: ({signal}) => listCodeDeployDeployments(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((deployment) => active(deployment.status)) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodeDeployDeployment(resource!.id, {functionName, alias, currentVersion, targetVersion, ...(beforeHook ? {beforeAllowTrafficHook: beforeHook} : {}), ...(afterHook ? {afterAllowTrafficHook: afterHook} : {})}), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
const stop = useMutation({mutationFn: ({id, rollback}: {id: string; rollback: boolean}) => stopCodeDeployDeployment(id, rollback), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
if (!resource) return null
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda release</p><h3>Deploy a version to an alias</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>Current version<select className="input" required value={currentVersion} onChange={(event) => setCurrentVersion(event.target.value)} disabled={!selected}><option value="">Select current version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>Target version<select className="input" required value={targetVersion} onChange={(event) => setTargetVersion(event.target.value)} disabled={!selected}><option value="">Select target version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>BeforeAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={beforeHook} onChange={setBeforeHook}/></label>
<label>AfterAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={afterHook} onChange={setAfterHook}/></label>
{start.isError && <p className="error-text compact-text">{message(start.error)}</p>}
<div className="form-actions"><button className="button primary" type="submit" disabled={!enabled || start.isPending}><Play size={13}/>{start.isPending ? 'Starting' : 'Start deployment'}</button></div>
</form>
{deployments.isError && <p className="error-text compact-text">{message(deployments.error)}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Deployment group history</h4><span>{deployments.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Traffic</th><th>Versions</th><th>Started</th><th>Actions</th></tr></thead><tbody>{(deployments.data ?? []).map((deployment) => <tr key={deployment.id}><td><code>{deployment.id}</code>{deployment.errorInformation?.message && <p className="error-text compact-text">{deployment.errorInformation.message}</p>}</td><td>{deployment.status ?? '-'}</td><td>{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}</td><td>{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}</td><td>{formatDateTime(deployment.createTime) ?? '-'}</td><td>{active(deployment.status) && <><button className="button compact danger" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: false})}><Ban size={12}/>Stop</button><button className="button compact" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: true})}><RotateCcw size={12}/>Stop and roll back</button></>}</td></tr>)}</tbody></table></div>{!deployments.isLoading && deployments.data?.length === 0 && <p className="muted compact-text">No deployments yet.</p>}</div>
</section>
}
function HookPicker({dependencies, value, onChange}: {dependencies?: CodeDeployDependencies; value: string; onChange: (value: string) => void}) { return <select className="input" value={value} onChange={(event) => onChange(event.target.value)}><option value="">No hook</option>{(dependencies?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select> }
function active(status: string | null): boolean { return status === 'Created' || status === 'Queued' || status === 'InProgress' }
function message(error: unknown): string { return error instanceof Error ? error.message : 'Unable to complete the CodeDeploy operation.' }
@@ -0,0 +1,44 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {ExternalLink, Play, RefreshCw} from 'lucide-react'
import {getCodePipelineState, listCodePipelineExecutions, startCodePipeline, type PipelineState} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CodePipelinePanelProps { resource?: CloudResource; runtimeReachable: boolean }
export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePanelProps) {
const client = useQueryClient()
const enabled = Boolean(resource && runtimeReachable)
const executionsKey = ['codepipeline-executions', resource?.id]
const executions = useQuery({queryKey: executionsKey, queryFn: ({signal}) => listCodePipelineExecutions(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((execution) => active(execution.status)) ? 2_000 : false})
const state = useQuery({queryKey: ['codepipeline-state', resource?.id], queryFn: ({signal}) => getCodePipelineState(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data && activeState(query.state.data) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
if (!resource) return null
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and S3 deploy.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
<PipelineTimeline state={state.data} loading={state.isLoading}/>
</section>
}
function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) {
if (loading) return <p className="muted compact-text">Loading stage state...</p>
if (!state) return null
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Current pipeline state</p><h3>Stages and actions</h3></div></div><ol className="workflow-execution-history">{state.stages.map((stage) => <li key={stage.name}><div><strong>{stage.name}</strong><span>{stage.status ?? 'Unknown'}</span></div><time>{formatDateTime(stage.lastStatusChange) ?? '-'}</time>{stage.actions.map((action) => <div className="workflow-action" key={action.name}><strong>{action.name}</strong><span>{action.status ?? 'Unknown'}</span>{action.summary && <p className="muted compact-text">{action.summary}</p>}<ActionLinks action={action}/></div>)}</li>)}</ol></section>
}
function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
const links = [
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined,
action.codeDeployDeploymentId ? {href: `/cloud-explorer/aws/codedeploy?deployment=${encodeURIComponent(action.codeDeployDeploymentId)}`, label: 'Open CodeDeploy'} : undefined,
action.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
].filter((item): item is {href: string; label: string} => Boolean(item))
return <>{links.map((link) => <a className="button compact" key={`${link.label}:${link.href}`} href={link.href}><ExternalLink size={12}/>{link.label}</a>)}</>
}
function active(status?: string): boolean { return status === 'InProgress' || status === 'Stopping' }
function activeState(state: PipelineState): boolean { return state.stages.some((stage) => active(stage.status) || stage.actions.some((action) => active(action.status))) }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
@@ -63,6 +63,9 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio";
@@ -824,13 +827,15 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)}
onSubmit={(submission) => createMut.mutate(submission)}
/>
) : service === "iac" && cloud === "aws" ? (
) : service === "iac" && cloud === "aws" ? (
<CloudFormationCreatePanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "codedeploy" && cloud === "aws" ? (
<CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/>
) : (
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
@@ -1004,6 +1009,15 @@ export function DynamicResourceView({
{service === "workflows" && cloud === "aws" && (
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codebuild" && cloud === "aws" && (
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codepipeline" && cloud === "aws" && (
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{editingResource && schema.updateFields && (
<div
className="modal-overlay"
@@ -1,4 +1,5 @@
import {useState} from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
import { LogsQueryPanel } from "@/components/LogsQueryPanel";
@@ -6,6 +7,8 @@ import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton";
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
import {cloudQueryKeys} from "@/api/queries/cloudQueries";
interface ResourceInspectorProps {
resource?: CloudResource;
@@ -149,9 +152,9 @@ export function ResourceInspector({
)}
</section>
{isDatabase && (
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} />
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} status={resource.status} />
)}
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
{isAwsDatabase && <DatabaseLifecycleSection resource={resource} />}
{isDatabase && !isAwsDatabase && (
<ProviderDatabaseSection cloud={resource.cloud} />
)}
@@ -283,9 +286,11 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) {
function DatabaseConnectionsSection({
metadata,
engine,
status,
}: {
metadata: Record<string, unknown>;
engine?: string | null;
status?: string | null;
}) {
const endpoint = getRecordMetadata(metadata.endpoint);
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
@@ -299,6 +304,7 @@ function DatabaseConnectionsSection({
return (
<section className="inspector-section">
<p className="metric-label">Connections</p>
<DatabaseConnectionHealth status={status} hasEndpoint={Boolean(address && port !== null)} />
<div className="inspector-grid compact-grid">
<InspectorItem label="Endpoint" value={address ?? "-"} />
<InspectorItem
@@ -328,6 +334,19 @@ function DatabaseConnectionsSection({
);
}
function DatabaseConnectionHealth({status, hasEndpoint}: {status?: string | null; hasEndpoint: boolean}) {
const normalized = status?.toLowerCase()
const state = normalized === 'available' && hasEndpoint
? {label: 'Ready', detail: 'The provider reports this instance as available with a connection endpoint.'}
: normalized === 'failed'
? {label: 'Unavailable', detail: 'The provider reports the backing database as failed.'}
: !hasEndpoint
? {label: 'Endpoint unavailable', detail: 'The provider has not returned a usable database endpoint.'}
: {label: status ?? 'Unknown', detail: 'Connection readiness follows the provider-reported instance status.'}
return <p className="muted compact-text" role="status"><strong>Connection health: {state.label}.</strong> {state.detail}</p>
}
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
const host = address.startsWith('172.') ? 'localhost' : address
const command = engine.includes('postgres')
@@ -343,22 +362,40 @@ function DatabaseConnectionCommand({ engine, address, port, database, username }
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="metric-label">Client connection URI</p><p className="copy-value mono">{uri}<CopyValueButton value={uri} label="Connection URI" /></p><p className="muted compact-text">Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.</p></div>
}
function DatabaseLifecycleSection({ status }: { status?: string | null }) {
function DatabaseLifecycleSection({resource}: {resource: CloudResource}) {
const queryClient = useQueryClient()
const lifecycle = useMutation({
mutationFn: async (action: "start" | "stop" | "reboot") => {
if (action === "start") return startCloudResource(resource.cloud, resource.service, resource.id)
if (action === "stop") return stopCloudResource(resource.cloud, resource.service, resource.id)
return rebootCloudResource(resource.cloud, resource.service, resource.id)
},
onSuccess: () => {
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources(resource.cloud, resource.service)})
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resource(resource.cloud, resource.service, resource.id)})
},
})
const status = resource.status?.toLowerCase()
const isPending = lifecycle.isPending
return (
<section className="inspector-section">
<p className="metric-label">Lifecycle</p>
<div className="lifecycle-actions">
<button className="button compact" type="button" disabled>
<button className="button compact" type="button" disabled={isPending || status !== "stopped"} onClick={() => lifecycle.mutate("start")}>
Start
</button>
<button className="button compact" type="button" disabled>
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("stop")}>
Stop
</button>
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("reboot")}>
Reboot
</button>
</div>
<p className="muted compact-text">
Current status: {status ?? "unknown"}. Start and stop are not supported
by the local Floci RDS runtime.
Current status: {resource.status ?? "unknown"}.
</p>
{lifecycle.error && <p className="muted compact-text">{lifecycle.error instanceof Error ? lifecycle.error.message : "Unable to update DB instance lifecycle."}</p>}
</section>
);
}
@@ -5,6 +5,7 @@ import {
Container,
Database,
HardDrive,
Hammer,
KeyRound,
Layers,
Lock,
@@ -25,6 +26,9 @@ import {
import type {CloudProvider} from '@/types/cloud'
import apiGatewayIcon from '@/assets/aws-services/api-gateway.svg'
import appConfigIcon from '@/assets/aws-services/appconfig.svg'
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -80,11 +84,17 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
configuration: SlidersHorizontal,
scheduler: Clock,
workflows: Workflow,
codebuild: Hammer,
codepipeline: Workflow,
codedeploy: Workflow,
}
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
apigateway: apiGatewayIcon,
compute: ec2Icon,
codebuild: codeBuildIcon,
codepipeline: codePipelineIcon,
codedeploy: codeDeployIcon,
configuration: appConfigIcon,
containers: ecsIcon,
database: rdsIcon,
@@ -0,0 +1,28 @@
import {describe, expect, test} from 'vitest'
import {dataExplorerKind, dataExplorerPath} from './dataExplorer'
import type {CloudResource} from '@/types/resource'
function rdsInstance(engine: string): CloudResource {
return {
id: 'orders-db',
name: 'orders-db',
cloud: 'aws',
service: 'database',
type: 'db-instance',
region: 'us-east-1',
createdAt: null,
engine,
metadata: {},
}
}
describe('dataExplorerPath', () => {
test('enables the SQL explorer for PostgreSQL and MySQL RDS instances', () => {
expect(dataExplorerKind(rdsInstance('postgres'))).toBe('sql')
expect(dataExplorerPath(rdsInstance('mysql'))).toBe('/cloud-explorer/aws/database/orders-db/data')
})
test('does not expose unsupported RDS engines as SQL explorers', () => {
expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined()
})
})
@@ -5,6 +5,8 @@ export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'database' &&
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'database' && resource.type === 'db-instance' &&
(resource.engine === 'postgres' || resource.engine === 'mysql')) return 'sql'
}
export function dataExplorerPath(resource: CloudResource): string | undefined {
+1 -1
View File
@@ -112,7 +112,7 @@ export interface SqlCredentials {
password: string
}
export type SqlEngine = 'azure-sql' | 'postgresql'
export type SqlEngine = 'azure-sql' | 'postgresql' | 'mysql'
export interface SqlDatabase {
name: string