CI/CD - CodeBuild + CodeDeploy + CodePipeline
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user