small feat: svg animation for servers, codebuild, codePipleline etc...
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {parse} from 'yaml'
|
||||
import {CodeBuildPanel, DEFAULT_BUILDSPEC} from './CodeBuildPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => ({
|
||||
getCodeBuild: vi.fn(),
|
||||
listCodeBuilds: vi.fn().mockResolvedValue([]),
|
||||
startCodeBuild: vi.fn(),
|
||||
}))
|
||||
const api = vi.hoisted(() => ({getCodeBuild: vi.fn(), listCodeBuilds: vi.fn(), startCodeBuild: vi.fn()}))
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
const project: CloudResource = {id: 'orders', name: 'orders', cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: 'us-east-1', createdAt: null, metadata: {sourceType: 'NO_SOURCE', ecrRepository: 'orders'}}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listCodeBuilds.mockResolvedValue([])
|
||||
})
|
||||
|
||||
test('shows the corrected CodeDeployToECS artifact guidance and default buildspec', async () => {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
@@ -26,3 +28,17 @@ test('shows the corrected CodeDeployToECS artifact guidance and default buildspe
|
||||
expect(screen.getByRole('textbox', {name: 'Buildspec override'})).toHaveValue(DEFAULT_BUILDSPEC)
|
||||
expect(parse(DEFAULT_BUILDSPEC).artifacts.files).toEqual(['imageDetail.json', 'taskdef.json', 'appspec.yaml'])
|
||||
})
|
||||
|
||||
test('shows the configuration animation while a started build is in progress', async () => {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
api.startCodeBuild.mockResolvedValue({id: 'build-1', buildNumber: 1, buildStatus: 'IN_PROGRESS', currentPhase: 'BUILD', phases: []})
|
||||
api.getCodeBuild.mockResolvedValue({id: 'build-1', buildNumber: 1, buildStatus: 'IN_PROGRESS', currentPhase: 'BUILD', phases: []})
|
||||
render(<QueryClientProvider client={client}><CodeBuildPanel resource={project} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Start build'}))
|
||||
await user.click(screen.getAllByRole('button', {name: 'Start build'})[1])
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Build in progress')
|
||||
expect(screen.getByRole('img', {name: 'Animated binary configuration icon'})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react'
|
||||
import codeBuildAnimation from '../../../../svgAsset/CodeBuild.svg'
|
||||
import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
@@ -14,12 +15,16 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
|
||||
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
|
||||
const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType))
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [progressBuildId, setProgressBuildId] = 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, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setProgressBuildId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setProgressBuildId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
|
||||
const progressBuild = detail.data?.id === progressBuildId ? detail.data : builds.data?.find((build) => build.id === progressBuildId)
|
||||
const buildInProgress = Boolean(progressBuildId && (!progressBuild || active(progressBuild.buildStatus)))
|
||||
useEffect(() => { if (progressBuildId && progressBuild && !active(progressBuild.buildStatus)) setProgressBuildId(undefined) }, [progressBuild, progressBuildId])
|
||||
if (!resource) return null
|
||||
const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined
|
||||
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
|
||||
@@ -28,6 +33,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
|
||||
{startOpen && (
|
||||
<StartBuildDialog overrides={overrides} buildspecOverride={buildspecOverride} requiresBuildspec={projectSourceType === 'NO_SOURCE'} pending={start.isPending} error={start.error} onChange={setOverrides} onBuildspecChange={setBuildspecOverride} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>
|
||||
)}
|
||||
{buildInProgress && <CodeBuildProgress build={progressBuild}/>}
|
||||
{ecrRepository && <p className="muted compact-text">Docker builds push to local ECR repository <a href={`/cloud-explorer/aws/ecr?resource=${encodeURIComponent(ecrRepository)}`}>{ecrRepository}</a>. For CodeDeployToECS, BuildOutput must include <code>imageDetail.json</code> as <code>{'{"ImageURI":"..."}'}</code>, plus <code>taskdef.json</code> and <code>appspec.yaml</code>.</p>}
|
||||
{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>
|
||||
@@ -35,6 +41,17 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
|
||||
</section>
|
||||
}
|
||||
|
||||
function CodeBuildProgress({build}: {build?: CodeBuildBuild}) {
|
||||
return <div className="codebuild-progress" role="status" aria-live="polite">
|
||||
<BuildConfigurationAnimation/>
|
||||
<div><p className="eyebrow">Build in progress</p><h3>{build?.currentPhase ?? 'Preparing build environment'}</h3><p className="muted compact-text">CodeBuild is running #{build?.buildNumber ?? build?.id ?? 'new build'}. This view updates until the build succeeds or fails.</p></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function BuildConfigurationAnimation() {
|
||||
return <img className="codebuild-progress-art" src={codeBuildAnimation} alt="Animated binary configuration icon"/>
|
||||
}
|
||||
|
||||
function StartBuildDialog({overrides, buildspecOverride, requiresBuildspec, pending, error, onChange, onBuildspecChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; buildspecOverride: string; requiresBuildspec: boolean; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onBuildspecChange: (value: string) => 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">{requiresBuildspec ? 'A buildspec override lets this no-source project run a real build.' : 'Leave the buildspec override blank to use the S3 source buildspec.'}</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div><label className="dynamic-field"><span>Buildspec override</span><textarea className="textarea code-textarea small" value={buildspecOverride} onChange={(event) => onBuildspecChange(event.target.value)} rows={8} spellCheck={false}/></label>{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 || (requiresBuildspec && !buildspecOverride.trim()) || 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>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Ban, Play, RefreshCw, RotateCcw} from 'lucide-react'
|
||||
import {Ban, Box, GitBranch, Play, RefreshCw, RotateCcw, ShieldCheck, SlidersHorizontal} from 'lucide-react'
|
||||
import {useState} from 'react'
|
||||
import {getCodeDeployDependencies, listCodeDeployDeployments, startCodeDeployDeployment, stopCodeDeployDeployment, type CodeDeployDependencies} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
@@ -20,23 +20,43 @@ export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSu
|
||||
const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
|
||||
const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
|
||||
const ecs = computePlatform === 'ECS'
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, computePlatform, serviceRoleArn, ...(ecs ? {clusterName, serviceName, blueTargetGroupName, greenTargetGroupName, productionListenerArn} : {}), deploymentConfig, rollbackOnFailure}) }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{computePlatform} 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>Compute platform<select className="input" value={computePlatform} onChange={(event) => { setComputePlatform(event.target.value as 'Lambda' | 'ECS'); setDeploymentConfig('AllAtOnce') }}><option value="Lambda">Lambda</option><option value="ECS">ECS blue/green</option></select></label>
|
||||
<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>
|
||||
return <form className="codedeploy-create-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, computePlatform, serviceRoleArn, ...(ecs ? {clusterName, serviceName, blueTargetGroupName, greenTargetGroupName, productionListenerArn} : {}), deploymentConfig, rollbackOnFailure}) }}>
|
||||
<header className="codedeploy-form-header"><div><p className="eyebrow">{computePlatform} deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">Connect a workload, define its traffic path, and choose its rollback behavior.</p></div><span>{ecs ? 'Blue/green' : 'Lambda alias'}</span></header>
|
||||
<section className="codedeploy-form-section">
|
||||
<div className="codedeploy-section-title"><SlidersHorizontal size={17}/><div><h4>Deployment identity</h4><p>Name the application and choose its execution role.</p></div></div>
|
||||
<div className="codedeploy-form-grid">
|
||||
<label className="dynamic-field"><span>Compute platform</span><select className="input" value={computePlatform} onChange={(event) => { setComputePlatform(event.target.value as 'Lambda' | 'ECS'); setDeploymentConfig('AllAtOnce') }}><option value="Lambda">Lambda</option><option value="ECS">ECS blue/green</option></select></label>
|
||||
<label className="dynamic-field"><span>Application name <em className="field-required">*</em></span><input className="input" required value={applicationName} onChange={(event) => setApplicationName(event.target.value)} placeholder="blue-app"/></label>
|
||||
<label className="dynamic-field"><span>Deployment group name <em className="field-required">*</em></span><input className="input" required value={deploymentGroupName} onChange={(event) => setDeploymentGroupName(event.target.value)} placeholder="blue-app-production"/></label>
|
||||
<label className="dynamic-field"><span>Service role ARN <em className="field-required">*</em></span><input className="input" required value={serviceRoleArn} onChange={(event) => setServiceRoleArn(event.target.value)} placeholder="arn:aws:iam::000000000000:role/codedeploy-role"/></label>
|
||||
</div>
|
||||
</section>
|
||||
{ecs && <>
|
||||
<label>ECS cluster name<input className="input" required value={clusterName} onChange={(event) => setClusterName(event.target.value)}/></label>
|
||||
<label>ECS service name<input className="input" required value={serviceName} onChange={(event) => setServiceName(event.target.value)}/></label>
|
||||
<label>Blue target group name<input className="input" required value={blueTargetGroupName} onChange={(event) => setBlueTargetGroupName(event.target.value)}/></label>
|
||||
<label>Green target group name<input className="input" required value={greenTargetGroupName} onChange={(event) => setGreenTargetGroupName(event.target.value)}/></label>
|
||||
<label>Production listener ARN<input className="input" required value={productionListenerArn} onChange={(event) => setProductionListenerArn(event.target.value)} placeholder="arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/..."/></label>
|
||||
<section className="codedeploy-form-section">
|
||||
<div className="codedeploy-section-title"><Box size={17}/><div><h4>ECS workload</h4><p>Identify the service CodeDeploy will replace.</p></div></div>
|
||||
<div className="codedeploy-form-grid">
|
||||
<label className="dynamic-field"><span>Cluster name <em className="field-required">*</em></span><input className="input" required value={clusterName} onChange={(event) => setClusterName(event.target.value)} placeholder="blue-cluster"/><small>The ECS cluster containing the service.</small></label>
|
||||
<label className="dynamic-field"><span>Service name <em className="field-required">*</em></span><input className="input" required value={serviceName} onChange={(event) => setServiceName(event.target.value)} placeholder="blue-app-service"/><small>The ECS service inside that cluster.</small></label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="codedeploy-form-section">
|
||||
<div className="codedeploy-section-title"><GitBranch size={17}/><div><h4>Blue/green traffic</h4><p>Connect the production listener to the current and replacement task sets.</p></div></div>
|
||||
<div className="codedeploy-form-grid">
|
||||
<label className="dynamic-field codedeploy-listener-field"><span>Production listener ARN <em className="field-required">*</em></span><input className="input" required value={productionListenerArn} onChange={(event) => setProductionListenerArn(event.target.value)} placeholder="arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/..."/><small>The listener currently receiving live application traffic.</small></label>
|
||||
<label className="dynamic-field codedeploy-target-card blue"><b>Blue</b><span>Current production target group <em className="field-required">*</em></span><input className="input" required value={blueTargetGroupName} onChange={(event) => setBlueTargetGroupName(event.target.value)} placeholder="blue-app-blue-tg"/><small>Receives traffic before the deployment begins.</small></label>
|
||||
<label className="dynamic-field codedeploy-target-card green"><b>Green</b><span>Replacement target group <em className="field-required">*</em></span><input className="input" required value={greenTargetGroupName} onChange={(event) => setGreenTargetGroupName(event.target.value)} placeholder="blue-app-green-tg"/><small>Receives the new task set during deployment.</small></label>
|
||||
</div>
|
||||
</section>
|
||||
</>}
|
||||
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option>{ecs ? <><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent15Minutes">Canary 10% then 15 minutes</option><option value="Linear10PercentEvery1Minutes">Linear 10% every minute</option><option value="Linear10PercentEvery3Minutes">Linear 10% every 3 minutes</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>
|
||||
<section className="codedeploy-form-section">
|
||||
<div className="codedeploy-section-title"><ShieldCheck size={17}/><div><h4>Deployment behavior</h4><p>Control traffic shifting and failure recovery.</p></div></div>
|
||||
<div className="codedeploy-form-grid">
|
||||
<label className="dynamic-field"><span>Traffic configuration</span><select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option>{ecs ? <><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent15Minutes">Canary 10% then 15 minutes</option><option value="Linear10PercentEvery1Minutes">Linear 10% every minute</option><option value="Linear10PercentEvery3Minutes">Linear 10% every 3 minutes</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="codedeploy-rollback"><input type="checkbox" checked={rollbackOnFailure} onChange={(event) => setRollbackOnFailure(event.target.checked)}/><span><strong>Automatic rollback</strong><small>Restore the previous task set if deployment fails.</small></span></label>
|
||||
</div>
|
||||
</section>
|
||||
{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>
|
||||
<footer className="codedeploy-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></footer>
|
||||
</form>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {ExternalLink, Play, RefreshCw} from 'lucide-react'
|
||||
import pipelineReleaseAnimation from '../../../../svgAsset/Pipleline.svg'
|
||||
import {getCodePipelineState, listCodePipelineExecutions, startCodePipeline, type PipelineState} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
@@ -8,14 +10,20 @@ interface CodePipelinePanelProps { resource?: CloudResource; runtimeReachable: b
|
||||
|
||||
export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePanelProps) {
|
||||
const client = useQueryClient()
|
||||
const [releaseExecutionId, setReleaseExecutionId] = useState<string>()
|
||||
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]}) }})
|
||||
const executions = useQuery({queryKey: executionsKey, queryFn: ({signal}) => listCodePipelineExecutions(resource!.id, signal), enabled, refetchInterval: (query) => Boolean(releaseExecutionId) || 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) => Boolean(releaseExecutionId) || query.state.data && activeState(query.state.data) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: ({id}) => { setReleaseExecutionId(id); void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
||||
useEffect(() => { setReleaseExecutionId(undefined) }, [resource?.id])
|
||||
const releaseExecution = executions.data?.find((execution) => execution.id === releaseExecutionId)
|
||||
const releaseInProgress = Boolean(releaseExecutionId && (!releaseExecution || active(releaseExecution.status)))
|
||||
useEffect(() => { if (releaseExecutionId && releaseExecution && !active(releaseExecution.status)) setReleaseExecutionId(undefined) }, [releaseExecution, releaseExecutionId])
|
||||
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 CodeDeployToECS for ECS EC2 and Fargate services.</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>
|
||||
{releaseInProgress && <PipelineReleaseProgress executionId={releaseExecutionId} execution={releaseExecution}/>}
|
||||
{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>
|
||||
@@ -23,6 +31,17 @@ export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePane
|
||||
</section>
|
||||
}
|
||||
|
||||
function PipelineReleaseProgress({executionId, execution}: {executionId?: string; execution?: {status?: string; statusSummary?: string}}) {
|
||||
return <div className="pipeline-release-progress" role="status" aria-live="polite">
|
||||
<PipelineReleaseAnimation/>
|
||||
<div><p className="eyebrow">Release in progress</p><h3>{execution?.statusSummary || execution?.status || 'Starting pipeline execution'}</h3><p className="muted compact-text">Pipeline execution <code>{executionId}</code> is moving through source, build, and deployment stages.</p></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function PipelineReleaseAnimation() {
|
||||
return <img className="pipeline-release-art" src={pipelineReleaseAnimation} alt="Animated pipeline release icon"/>
|
||||
}
|
||||
|
||||
function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) {
|
||||
if (loading) return <p className="muted compact-text">Loading stage state...</p>
|
||||
if (!state) return null
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import ec2CpuAnimation from '../../../../svgAsset/animated-cpu.svg'
|
||||
import { CopyValueButton } from './CopyValueButton'
|
||||
import { ServerLaunchProgress } from './ServerLaunchProgress'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Network,
|
||||
Play,
|
||||
Plus,
|
||||
Power,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -395,7 +399,11 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.state
|
||||
return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : false
|
||||
if (state === 'pending' || state === 'stopping' || state === 'shutting-down') {
|
||||
return 1_500
|
||||
}
|
||||
const connection = query.state.data?.connection
|
||||
return connection?.launchStatus === 'in-progress' || connection?.sshStatus === 'initializing' ? 5_000 : false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -675,6 +683,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
const failure = typeof connection.failure === 'string'
|
||||
? connection.failure
|
||||
: connection.failure?.message
|
||||
const launchPending = connection.launchStatus === 'in-progress'
|
||||
const sshPending = connection.sshStatus === 'initializing'
|
||||
const keyInjected = connection.keyInjectionStatus === 'succeeded' || connection.keyInjectionStatus === 'ok'
|
||||
return <div className="ec2-connect-panel">
|
||||
{!canCheckSsh && <div className="ec2-connection-notice">
|
||||
{state === 'terminated'
|
||||
@@ -685,17 +696,17 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
</div>}
|
||||
<div className="ec2-readiness-grid">
|
||||
<div className="ec2-readiness-card">
|
||||
<span>Launch</span>
|
||||
<div className="ec2-readiness-heading"><span>Launch</span><LaunchGear active={launchPending}/></div>
|
||||
<strong className={connection.launchStatus === 'failed' ? 'error' : ''}>{connection.launchStatus ?? 'unknown'}</strong>
|
||||
<small>{connection.selectedImageReference ?? 'No selected image recorded'}</small>
|
||||
</div>
|
||||
<div className="ec2-readiness-card">
|
||||
<span>SSH readiness</span>
|
||||
<div className="ec2-readiness-heading"><span>SSH readiness</span><SshHeartbeat active={sshPending}/></div>
|
||||
<strong className={connection.sshStatus === 'impaired' ? 'error' : ''}>{connection.sshStatus ?? 'unknown'}</strong>
|
||||
<small>{connection.sshStatusMessage ?? 'No readiness result recorded'}</small>
|
||||
</div>
|
||||
<div className="ec2-readiness-card">
|
||||
<span>Key injection</span>
|
||||
<div className="ec2-readiness-heading"><span>Key injection</span>{keyInjected && <KeyInjectionReady/>}</div>
|
||||
<strong>{connection.keyInjectionStatus ?? (inst.keyName ? 'unknown' : 'not requested')}</strong>
|
||||
<small>{inst.keyName ?? 'No key pair selected'}</small>
|
||||
</div>
|
||||
@@ -771,6 +782,29 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LaunchGear({active}: {active: boolean}) {
|
||||
return <img className={`ec2-launch-cpu${active ? ' active' : ''}`} src={ec2CpuAnimation} alt="Animated instance CPU"/>
|
||||
}
|
||||
|
||||
function SshHeartbeat({active}: {active: boolean}) {
|
||||
return <svg className={`ec2-ssh-heartbeat${active ? ' active' : ''}`} viewBox="0 0 100 32" aria-hidden="true">
|
||||
<path d="M2 17h16l6-11 9 22 10-17 6 6h49"/>
|
||||
</svg>
|
||||
}
|
||||
|
||||
function KeyInjectionReady() {
|
||||
return <svg className="ec2-key-ready" viewBox="0 0 150 150" aria-label="Key injected">
|
||||
<rect x="12" y="13" width="126" height="125" rx="9" fill="#f4f1f7" stroke="#19152d" strokeWidth="3"/>
|
||||
<path d="M12 33V22a9 9 0 0 1 9-9h108a9 9 0 0 1 9 9v11Z" fill="#393044"/>
|
||||
<circle cx="24" cy="23" r="3" fill="#ff5a36"/><circle cx="35" cy="23" r="3" fill="#ffc33a"/><circle cx="46" cy="23" r="3" fill="#62c9aa"/>
|
||||
<path d="M63 62v-6c0-7 5-11 12-11s12 4 12 11v6" fill="none" stroke="#19152d" strokeWidth="2.6"/>
|
||||
<path d="M57 62h36v17c0 6-5 10-10 10H67c-5 0-10-4-10-10Z" fill="#ffc33a" stroke="#19152d" strokeWidth="2.6"/>
|
||||
<path d="M75 71v7" stroke="#19152d" strokeWidth="2.6" strokeLinecap="round"/>
|
||||
<rect x="34" y="100" width="79" height="24" rx="12" fill="#45b5fa" stroke="#19152d" strokeWidth="2.6"/>
|
||||
<circle cx="51" cy="112" r="1.8" fill="#19152d"/><circle cx="66" cy="112" r="1.8" fill="#19152d"/><circle cx="81" cy="112" r="1.8" fill="#19152d"/><circle cx="96" cy="112" r="1.8" fill="#19152d"/>
|
||||
</svg>
|
||||
}
|
||||
|
||||
// ─── AMI panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
function AmiPanel({ imageId }: { imageId: string }) {
|
||||
@@ -929,6 +963,7 @@ export function LaunchInstanceForm({
|
||||
const [createdKeyMaterial, setCreatedKeyMaterial] = useState<{ name: string; material: string }>()
|
||||
const [rootVolumeSize, setRootVolumeSize] = useState('20')
|
||||
const [rootVolumeType, setRootVolumeType] = useState('gp3')
|
||||
const [userData, setUserData] = useState('')
|
||||
const [subnetId, setSubnetId] = useState('')
|
||||
const [selectedSgs, setSelectedSgs] = useState<string[]>([])
|
||||
const [showCreateSg, setShowCreateSg] = useState(false)
|
||||
@@ -1049,6 +1084,7 @@ export function LaunchInstanceForm({
|
||||
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
|
||||
rootVolumeSize,
|
||||
rootVolumeType,
|
||||
userData: userData || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1120,17 +1156,22 @@ export function LaunchInstanceForm({
|
||||
]
|
||||
|
||||
return (
|
||||
<form className="launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
|
||||
<div className="sns-tabs" aria-label="Launch steps" style={{ gridColumn: '1 / -1', overflowX: 'auto' }}>
|
||||
<form className="launch-form ec2-launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
|
||||
<header className="launch-form-header">
|
||||
<div><p className="eyebrow">EC2 compute</p><h3>Launch an instance</h3><p className="muted compact-text">Choose an image, configure access and networking, then review the instance before launch.</p></div>
|
||||
<span>4-step setup</span>
|
||||
</header>
|
||||
<nav className="launch-stepper" aria-label="Launch steps">
|
||||
{steps.map((item) => (
|
||||
<button key={item.number} type="button" className={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
|
||||
{item.number}. {item.label}
|
||||
<button key={item.number} type="button" className={step === item.number ? 'active' : ''} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
|
||||
<b>{item.number}</b><span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{step === 1 && <>
|
||||
<div className="launch-form-group">Name and AMI</div>
|
||||
{step === 1 && <section className="launch-form-section">
|
||||
<div className="launch-section-title"><Server size={17}/><div><h4>Instance identity</h4><p>Name the instance and select its operating system image.</p></div></div>
|
||||
<div className="launch-form-grid">
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Instance name <em className="field-required">*</em></span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. my-web-server" />
|
||||
@@ -1188,10 +1229,12 @@ export function LaunchInstanceForm({
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{step === 2 && <>
|
||||
<div className="launch-form-group">Instance type and key pair</div>
|
||||
{step === 2 && <section className="launch-form-section">
|
||||
<div className="launch-section-title"><Cpu size={17}/><div><h4>Compute and access</h4><p>Choose the instance capacity and SSH credentials.</p></div></div>
|
||||
<div className="launch-form-grid">
|
||||
<label className="launch-field">
|
||||
<span>Instance type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
|
||||
@@ -1201,16 +1244,16 @@ export function LaunchInstanceForm({
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Key pair</span>
|
||||
<div className="field-row" style={{ marginTop: 6 }}>
|
||||
<div className="launch-choice-row">
|
||||
<label><input type="radio" checked={keyPairMode === 'existing'} onChange={() => setKeyPairMode('existing')} /> Choose existing</label>
|
||||
<label><input type="radio" checked={keyPairMode === 'new'} onChange={() => { setKeyPairMode('new'); setKeyName('') }} /> Create new</label>
|
||||
</div>
|
||||
{keyPairMode === 'existing' ? (
|
||||
<select className="input" style={{ marginTop: 8 }} value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<option value="">No key pair</option>
|
||||
{(keysQ.data ?? []).map((keyPair: Ec2KeyPair) => <option key={keyPair.keyPairId} value={keyPair.keyName}>{keyPair.keyName}</option>)}
|
||||
</select>
|
||||
) : <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
) : <div className="launch-key-create">
|
||||
<input className="input" value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" />
|
||||
<select className="input" value={newKeyType} onChange={(e) => setNewKeyType(e.target.value as 'rsa' | 'ed25519')}><option value="rsa">RSA</option><option value="ed25519">ED25519</option></select>
|
||||
<button type="button" className="button" disabled={!newKeyName.trim() || createKeyMut.isPending} onClick={() => createKeyMut.mutate()}>{createKeyMut.isPending ? 'Creating…' : 'Create key pair'}</button>
|
||||
@@ -1219,10 +1262,12 @@ export function LaunchInstanceForm({
|
||||
{createKeyMut.error instanceof Error && <p className="form-error" style={{ marginTop: 8 }}>{createKeyMut.error.message}</p>}
|
||||
{createdKeyMaterial && <p className="muted" style={{ margin: '8px 0 0' }}>The private key is available only from this creation response. Download it before leaving this wizard.</p>}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{step === 3 && <>
|
||||
<div className="launch-form-group">Network and security</div>
|
||||
{step === 3 && <section className="launch-form-section">
|
||||
<div className="launch-section-title"><Network size={17}/><div><h4>Network and security</h4><p>Place the instance in a subnet and control inbound access.</p></div></div>
|
||||
<div className="launch-form-grid">
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Subnet</span>
|
||||
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
|
||||
@@ -1360,10 +1405,12 @@ export function LaunchInstanceForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{step === 4 && <>
|
||||
<div className="launch-form-group">Storage and review</div>
|
||||
{step === 4 && <section className="launch-form-section">
|
||||
<div className="launch-section-title"><HardDrive size={17}/><div><h4>Storage and review</h4><p>Configure the root disk, bootstrap script, and verify the launch request.</p></div></div>
|
||||
<div className="launch-form-grid">
|
||||
<label className="launch-field">
|
||||
<span>Root volume size (GiB)</span>
|
||||
<input className="input" type="number" min="1" value={rootVolumeSize} onChange={(event) => setRootVolumeSize(event.target.value)} />
|
||||
@@ -1379,9 +1426,9 @@ export function LaunchInstanceForm({
|
||||
<option value="sc1">Cold HDD (sc1)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<div className="launch-field launch-field--span launch-review">
|
||||
<span>Review: fields sent to RunInstances</span>
|
||||
<div className="meta-grid" style={{ marginTop: 8 }}>
|
||||
<div className="meta-grid">
|
||||
<div className="meta-row"><span className="meta-label">Name</span><span className="meta-value">{name}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">AMI</span><span className="meta-value">{selectedAmi?.name || imageId} ({imageId})</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Instance type</span><span className="meta-value">{instanceType}</span></div>
|
||||
@@ -1389,14 +1436,22 @@ export function LaunchInstanceForm({
|
||||
<div className="meta-row"><span className="meta-label">Subnet</span><span className="meta-value">{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Security groups</span><span className="meta-value">{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Root volume</span><span className="meta-value">{rootVolumeSize} GiB {rootVolumeType}, delete on termination</span></div>
|
||||
<div className="meta-row"><span className="meta-label">User data</span><span className="meta-value">{userData ? 'Shell script or cloud-init supplied' : 'Not sent'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>User data</span>
|
||||
<textarea className="textarea code-textarea small" value={userData} onChange={(event) => setUserData(event.target.value)} placeholder={'#!/bin/bash\napt-get update\napt-get upgrade -y\napt-get install -y curl git'} rows={7}/>
|
||||
<small>Runs once after the instance starts. Use a shell script or cloud-init document, matching the AWS RunInstances UserData field.</small>
|
||||
<input type="file" accept=".sh,.txt,.yaml,.yml,.cfg,text/plain,text/x-shellscript" onChange={async (event) => { const file = event.currentTarget.files?.[0]; if (file) setUserData(await file.text()); event.currentTarget.value = '' }}/>
|
||||
</label>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
|
||||
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
|
||||
{createMut.isPending && <div className="launch-field--span"><ServerLaunchProgress name={name} description="Creating the instance and preparing its local runtime."/></div>}
|
||||
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
|
||||
<div className="launch-form-actions">
|
||||
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
|
||||
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,8 @@ interface DatabaseLaunchProgressProps {
|
||||
|
||||
export function DatabaseLaunchProgress({ name, description }: DatabaseLaunchProgressProps) {
|
||||
return <div className="database-launch-progress" role="status" aria-live="polite">
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path d="M7 11C7 15 14.6 17 24 17S41 15 41 11v26c0 4-7.6 6-17 6S7 41 7 37Z" fill="#0D3D8C" />
|
||||
<path d="M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z" fill="#1769C2"><animate attributeName="d" dur="1.3s" repeatCount="indefinite" values="M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z;M7 9C7 13 14.6 15 24 15S41 13 41 9v14c0 4-7.6 6-17 6S7 27 7 23Z;M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z" /></path>
|
||||
<g><path d="M7 11C7 7 14.6 5 24 5S41 7 41 11v7c0 4-7.6 6-17 6S7 22 7 18Z" fill="#1976D2" /><ellipse cx="24" cy="11" rx="17" ry="5.5" fill="#2B91E3" /><ellipse cx="24" cy="9.7" rx="14.5" ry="3.5" fill="#39A0EA" opacity=".35" /><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.3s" repeatCount="indefinite" /></g>
|
||||
<g fill="#35d0b5"><circle className="database-led database-led-a" cx="16" cy="35.5" r="1.6" /><circle className="database-led database-led-b" cx="21.3" cy="36.2" r="1.6" /><circle className="database-led database-led-c" cx="26.7" cy="36.2" r="1.6" /><circle className="database-led database-led-d" cx="32" cy="35.5" r="1.6" /></g>
|
||||
</svg>
|
||||
<img className="database-launch-progress-art" src={databaseCreationAnimation} alt="Animated database creation icon"/>
|
||||
<div><strong>Creating {name || "database"}</strong><small>{description}</small></div>
|
||||
</div>;
|
||||
}
|
||||
import databaseCreationAnimation from '../../../../svgAsset/db-creation.svg';
|
||||
|
||||
@@ -45,6 +45,7 @@ test('shows Lightsail instance state and invokes lifecycle actions from Actions'
|
||||
await user.click(screen.getByRole('button', {name: 'Stop'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.stopCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web'))
|
||||
expect(screen.getByText('Actions').closest('details')).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
test('manages firewall rules through generic resource updates and shows browser mappings', async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function LightsailInstancePanel({resource, runtimeReachable, onDeleted}:
|
||||
const [startCommand, setStartCommand] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const actionsMenu = useRef<HTMLDetailsElement>(null)
|
||||
const ports = publicPorts(resource?.metadata.networking)
|
||||
const hostMappings = hostPortMappings(resource?.metadata.hostPortMappings)
|
||||
const disks = instanceDisks(resource?.metadata.hardware)
|
||||
@@ -100,11 +101,12 @@ export function LightsailInstancePanel({resource, runtimeReachable, onDeleted}:
|
||||
const wordpress = stringMetadata(resource.metadata.blueprintId) === 'wordpress'
|
||||
const wordpressPort = hostMappings.find((mapping) => mapping.instancePort === 80)
|
||||
const stateLabel = resource.status ?? 'Unknown'
|
||||
const closeActionsMenu = () => actionsMenu.current?.removeAttribute('open')
|
||||
|
||||
return <section className="table-panel lightsail-instance-panel" style={{marginTop: 16}}>
|
||||
<header className="lightsail-instance-header">
|
||||
<div><p className="eyebrow">Amazon Lightsail instance</p><h2>{resource.name}</h2><p className="muted"><code>{resource.id}</code>{resource.region && <> · {resource.region}</>}</p></div>
|
||||
<div className="lightsail-instance-controls"><span className={`runtime-state ${status === 'running' ? 'ready' : 'pending'}`}>{stateLabel}</span><details className="dynamodb-actions"><summary>Actions <ChevronDown size={14}/></summary><div className="dynamodb-actions-menu lightsail-actions-menu"><button type="button" onClick={() => setTab('overview')}><Settings size={14}/>Manage</button><button type="button" disabled={!runtimeReachable || busy || status !== 'stopped'} onClick={() => lifecycle.mutate('start')}><Play size={14}/>Start</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('stop')}><Square size={14}/>Stop</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('reboot')}><RotateCw size={14}/>Reboot</button><button className="dynamodb-action-delete" type="button" disabled={!runtimeReachable || busy} onClick={() => setConfirmDelete(true)}><Trash2 size={14}/>Delete</button></div></details></div>
|
||||
<div className="lightsail-instance-controls"><span className={`runtime-state ${status === 'running' ? 'ready' : 'pending'}`}>{stateLabel}</span><details className="dynamodb-actions" ref={actionsMenu}><summary>Actions <ChevronDown size={14}/></summary><div className="dynamodb-actions-menu lightsail-actions-menu"><button type="button" onClick={() => { closeActionsMenu(); setTab('overview') }}><Settings size={14}/>Manage</button><button type="button" disabled={!runtimeReachable || busy || status !== 'stopped'} onClick={() => { closeActionsMenu(); lifecycle.mutate('start') }}><Play size={14}/>Start</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => { closeActionsMenu(); lifecycle.mutate('stop') }}><Square size={14}/>Stop</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => { closeActionsMenu(); lifecycle.mutate('reboot') }}><RotateCw size={14}/>Reboot</button><button className="dynamodb-action-delete" type="button" disabled={!runtimeReachable || busy} onClick={() => { closeActionsMenu(); setConfirmDelete(true) }}><Trash2 size={14}/>Delete</button></div></details></div>
|
||||
</header>
|
||||
<div className="drawer-tabs lightsail-instance-tabs" role="tablist" aria-label="Instance details">
|
||||
<button className={`drawer-tab ${tab === 'overview' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'overview'} onClick={() => setTab('overview')}>Overview</button>
|
||||
|
||||
@@ -10,10 +10,21 @@ export function LoadBalancingProgress({kind, description}: LoadBalancingProgress
|
||||
</div>
|
||||
}
|
||||
|
||||
export function TargetHealthIcon({state}: {state: string}) {
|
||||
if (state.toLowerCase() === 'healthy') {
|
||||
return <svg className="target-state-icon target-state-icon--healthy" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-label="Healthy target"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 9.897c0 -1.714 1.46 -3.104 3.26 -3.104c.275 -1.22 1.255 -2.215 2.572 -2.611c1.317 -.397 2.77 -.134 3.811 .69c1.042 .822 1.514 2.08 1.239 3.3h.693a2.42 2.42 0 0 1 2.425 2.414a2.42 2.42 0 0 1 -2.425 2.414h-8.315c-1.8 0 -3.26 -1.39 -3.26 -3.103"/><path d="M12 13v3"/><path d="M10 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M14 18h7"/><path d="M3 18h7"/></svg>
|
||||
}
|
||||
if (state.toLowerCase() === 'unhealthy') {
|
||||
return <svg className="target-state-icon target-state-icon--unhealthy" viewBox="0 0 21 21" aria-label="Unhealthy target"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" d="M8.739 5.819a5 5 0 0 1 6.563 6.08a2 2 0 1 1 2.234 3.312m-2.104.289H5.5a3 3 0 1 1 .1-5.998a5 5 0 0 1 1.286-2.457M4 4l13 13.071"/></svg>
|
||||
}
|
||||
return <span className="target-state-icon target-state-icon--checking"><TargetIcon/></span>
|
||||
}
|
||||
|
||||
function ListenerIcon() {
|
||||
return <svg viewBox="0 0 140 140" aria-hidden="true"><rect className="lb-device" x="20" y="28" width="96" height="29" rx="4"/><circle className="lb-yellow lb-blink-1" cx="35" cy="42.5" r="5.3"/><g className="lb-yellow"><circle className="lb-blink-1" cx="72" cy="37" r="2"/><circle className="lb-blink-2" cx="81" cy="37" r="2"/><circle className="lb-blink-3" cx="97" cy="37" r="2"/><circle className="lb-blink-2" cx="106" cy="37" r="2"/><circle className="lb-blink-3" cx="64" cy="47" r="2"/><circle className="lb-blink-1" cx="75" cy="47" r="2"/><circle className="lb-blink-2" cx="85" cy="47" r="2"/><circle className="lb-blink-3" cx="97" cy="47" r="2"/><circle className="lb-blink-1" cx="106" cy="47" r="2"/></g><path className="lb-wire" d="M58 64 L49 83 L42 97M79 64 L88 83 L94 97"/><circle className="lb-yellow" r="3.2"><animateMotion dur="1.55s" repeatCount="indefinite" path="M58 64 L49 83 L42 97"/></circle><circle className="lb-yellow" r="3.2"><animateMotion begin=".72s" dur="1.55s" repeatCount="indefinite" path="M79 64 L88 83 L94 97"/></circle><rect className="lb-device" x="24" y="88" width="36" height="19" rx="5"/><rect className="lb-device" x="77" y="88" width="36" height="19" rx="5"/></svg>
|
||||
return <img className="load-balancing-progress-art" src={loadBalancerAnimation} alt="Animated load balancer"/>
|
||||
}
|
||||
|
||||
function TargetIcon() {
|
||||
return <svg viewBox="0 0 362 356" aria-hidden="true"><rect className="lb-panel" x="49" y="68" width="270" height="68" rx="15"/><rect className="lb-panel" x="49" y="135" width="270" height="68" rx="15"/><g className="lb-ports"><rect className="lb-port" x="81" y="92" width="47" height="25" rx="13"/><rect className="lb-port" x="81" y="158" width="47" height="25" rx="13"/></g><g className="lb-green">{[178, 197, 216, 236, 256, 276, 295].map((cx, index) => <circle key={`top-${cx}`} className={`lb-blink-${index % 3 + 1}`} cx={cx} cy="95" r="6.5"/>)}{[178, 197, 216, 236, 256, 276].map((cx, index) => <circle key={`bottom-${cx}`} className={`lb-blink-${(index + 1) % 3 + 1}`} cx={cx} cy="162" r="6.5"/>)}</g><path className="lb-wire" d="M184 202v51M55 267H307"/><circle className="lb-green" r="7"><animateMotion dur="3.8s" repeatCount="indefinite" path="M66 267 H184 V181"/></circle><circle className="lb-green" r="7"><animateMotion begin="1.25s" dur="3.8s" repeatCount="indefinite" path="M302 267 H184 V162"/></circle><circle className="lb-green lb-hub" cx="184" cy="267" r="8"/></svg>
|
||||
}
|
||||
import loadBalancerAnimation from '../../../../svgAsset/Load-Balancer.svg'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {Loader2, Plus, RefreshCw, Send, Trash2} from "lucide-react";
|
||||
import {Activity, Loader2, MessageSquare, Network, Plus, RefreshCw, Send, Server, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createMskCluster, createMskTopic, deleteMskCluster, deleteMskTopic, getMskConsumerGroupLag, listMskClusters, listMskTopics, peekMskMessages, produceMskMessage} from "@/api/mskClient";
|
||||
import {listEc2Subnets} from "@/api/aws/ec2.api";
|
||||
@@ -38,25 +38,51 @@ export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const error = createCluster.error ?? deleteCluster.error ?? createTopic.error ?? deleteTopic.error ?? produce.error ?? peek.error ?? loadLag.error;
|
||||
|
||||
return <div className="sns-topic-workspace msk-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Broker access is open: no IAM, SASL, or TLS authentication is configured.</p></div><button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Cluster name</span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} required /></label><label className="dynamic-field"><span>Broker nodes</span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label><label className="dynamic-field"><span>Kafka version</span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label><label className="dynamic-field"><span>Client subnets</span><select className="input subnet-multiselect" multiple size={3} value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button>
|
||||
<section className="table-panel msk-service-section">
|
||||
<header className="msk-section-header">
|
||||
<div className="msk-section-heading"><span><Server size={17}/></span><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Provision broker nodes in existing EC2 subnets for local Kafka workloads.</p></div></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button>
|
||||
</header>
|
||||
<form className="msk-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Cluster name <em className="field-required">*</em></span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} placeholder="events-cluster" required /></label>
|
||||
<label className="dynamic-field"><span>Broker nodes <em className="field-required">*</em></span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label>
|
||||
<label className="dynamic-field"><span>Kafka version <em className="field-required">*</em></span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label>
|
||||
<label className="dynamic-field"><span>Client subnets <em className="field-required">*</em></span><select className="input subnet-multiselect" multiple size={3} value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label>
|
||||
<div className="msk-form-actions"><span>Broker access is open: no IAM, SASL, or TLS authentication is configured.</span><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button></div>
|
||||
</form>
|
||||
<div className="apigateway-route-explorer"><label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}</div>
|
||||
<div className="msk-active-resource">
|
||||
<label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>
|
||||
{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Topics</p><h3>Kafka Admin API</h3><p className="muted compact-text">Topic management uses the cluster's Kafka Admin API. Retention configuration is accepted, but enforcement is unverified.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && topic.trim() && Number(partitions) > 0) createTopic.mutate(); }}><label className="dynamic-field"><span>Topic</span><input className="input" value={topic} onChange={(event) => setTopic(event.target.value)} required /></label><label className="dynamic-field"><span>Partitions</span><input className="input" type="number" min="1" value={partitions} onChange={(event) => setPartitions(event.target.value)} required /></label><label className="dynamic-field"><span>Retention (ms, optional)</span><input className="input" type="number" min="0" value={retentionMs} onChange={(event) => setRetentionMs(event.target.value)} /><small>Configured but enforcement unverified.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || createTopic.isPending}>{createTopic.isPending ? "Creating" : "Create topic"}</button></form>
|
||||
{topicsQuery.isLoading && <p className="muted compact-text">Loading topics...</p>}{topicsQuery.data?.length === 0 && <p className="muted compact-text">No topics found for this cluster.</p>}{topicsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.name}><div className="apigateway-route-select"><code>{item.name}</code><small>{item.partitionCount} partition{item.partitionCount === 1 ? "" : "s"}</small></div><button className="icon-btn danger" type="button" aria-label={`Delete topic ${item.name}`} disabled={deleteTopic.isPending} onClick={() => deleteTopic.mutate(item.name)}><Trash2 size={14}/></button></div>)}
|
||||
|
||||
<section className="table-panel msk-service-section">
|
||||
<header className="msk-section-header"><div className="msk-section-heading"><span><Network size={17}/></span><div><p className="eyebrow">Topics</p><h3>Kafka Admin API</h3><p className="muted compact-text">Define partitions and optional retention for the active cluster.</p></div></div></header>
|
||||
<form className="msk-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && topic.trim() && Number(partitions) > 0) createTopic.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Topic name <em className="field-required">*</em></span><input className="input" aria-label="Topic" value={topic} onChange={(event) => setTopic(event.target.value)} placeholder="orders.created" required /></label>
|
||||
<label className="dynamic-field"><span>Partitions <em className="field-required">*</em></span><input className="input" type="number" min="1" value={partitions} onChange={(event) => setPartitions(event.target.value)} required /></label>
|
||||
<label className="dynamic-field"><span>Retention in milliseconds</span><input className="input" type="number" min="0" value={retentionMs} onChange={(event) => setRetentionMs(event.target.value)} placeholder="Optional"/><small>Configuration is accepted, but enforcement unverified.</small></label>
|
||||
<div className="msk-form-actions"><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || createTopic.isPending}><Plus size={13}/>{createTopic.isPending ? "Creating" : "Create topic"}</button></div>
|
||||
</form>
|
||||
<div className="msk-resource-list">{topicsQuery.isLoading && <p className="muted compact-text">Loading topics...</p>}{topicsQuery.data?.length === 0 && <p className="muted compact-text">No topics found for this cluster.</p>}{topicsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.name}><div className="apigateway-route-select"><code>{item.name}</code><small>{item.partitionCount} partition{item.partitionCount === 1 ? "" : "s"}</small></div><button className="icon-btn danger" type="button" aria-label={`Delete topic ${item.name}`} disabled={deleteTopic.isPending} onClick={() => deleteTopic.mutate(item.name)}><Trash2 size={14}/></button></div>)}</div>
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Messages</p><h3>Produce and peek</h3><p className="muted compact-text">Peek uses a non-committing consumer and reads from the beginning.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && messageTopic && messageValue) produce.mutate(); }}><label className="dynamic-field"><span>Topic</span><select className="input" value={messageTopic} onChange={(event) => setMessageTopic(event.target.value)} required><option value="">Select a topic</option>{topicsQuery.data?.map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label><label className="dynamic-field"><span>Key (optional)</span><input className="input" value={messageKey} onChange={(event) => setMessageKey(event.target.value)} /></label><label className="dynamic-field dynamic-field--span"><span>Value</span><textarea className="textarea code-textarea small" value={messageValue} onChange={(event) => setMessageValue(event.target.value)} required /></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || !messageTopic || !messageValue || produce.isPending}>{produce.isPending ? <Loader2 size={13} className="spin"/> : <Send size={13}/>}{produce.isPending ? "Producing" : "Produce message"}</button><button className="button" type="button" disabled={!runtimeReachable || !clusterArn || !messageTopic || peek.isPending} onClick={() => peek.mutate()}>{peek.isPending ? "Peeking" : "Peek messages"}</button></form>
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={`${message.partition}:${message.offset}`}><div className="apigateway-route-select"><pre>{message.value ?? ""}</pre><small>Partition {message.partition} · Offset {message.offset} · Timestamp {formatTimestamp(message.timestamp)}</small>{message.key && <small>Key: <code>{message.key}</code></small>}</div></article>)}{messages.length === 0 && <p className="muted compact-text">Select a topic and peek to inspect up to 50 messages.</p>}
|
||||
|
||||
<section className="table-panel msk-service-section">
|
||||
<header className="msk-section-header"><div className="msk-section-heading"><span><MessageSquare size={17}/></span><div><p className="eyebrow">Messages</p><h3>Produce and inspect</h3><p className="muted compact-text">Publish records or peek from the beginning with a non-committing consumer.</p></div></div></header>
|
||||
<form className="msk-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && messageTopic && messageValue) produce.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Topic <em className="field-required">*</em></span><select className="input" aria-label="Topic" value={messageTopic} onChange={(event) => setMessageTopic(event.target.value)} required><option value="">Select a topic</option>{topicsQuery.data?.map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>Message key</span><input className="input" value={messageKey} onChange={(event) => setMessageKey(event.target.value)} placeholder="Optional partition key"/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message value <em className="field-required">*</em></span><textarea className="textarea code-textarea small" aria-label="Value" value={messageValue} onChange={(event) => setMessageValue(event.target.value)} placeholder="Enter the record payload" required /></label>
|
||||
<div className="msk-form-actions"><button className="button" type="button" disabled={!runtimeReachable || !clusterArn || !messageTopic || peek.isPending} onClick={() => peek.mutate()}>{peek.isPending ? "Peeking" : "Peek messages"}</button><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || !messageTopic || !messageValue || produce.isPending}>{produce.isPending ? <Loader2 size={13} className="spin"/> : <Send size={13}/>}{produce.isPending ? "Producing" : "Produce message"}</button></div>
|
||||
</form>
|
||||
<div className="msk-resource-list">{messages.map((message) => <article className="apigateway-route-item" key={`${message.partition}:${message.offset}`}><div className="apigateway-route-select"><pre>{message.value ?? ""}</pre><small>Partition {message.partition} · Offset {message.offset} · Timestamp {formatTimestamp(message.timestamp)}</small>{message.key && <small>Key: <code>{message.key}</code></small>}</div></article>)}{messages.length === 0 && <p className="muted compact-text">Select a topic and peek to inspect up to 50 messages.</p>}</div>
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Consumer groups</p><h3>Lag</h3></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && groupId.trim()) loadLag.mutate(); }}><label className="dynamic-field"><span>Consumer group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required /></label><button className="button" type="submit" disabled={!runtimeReachable || !clusterArn || !groupId.trim() || loadLag.isPending}>{loadLag.isPending ? "Loading" : "View lag"}</button></form>{lag.map((topicLag) => <div className="apigateway-route-item" key={topicLag.topic}><div className="apigateway-route-select"><code>{topicLag.topic}</code><small>Total lag: {topicLag.totalLag}</small>{topicLag.partitions.map((partition) => <small key={partition.partition}>Partition {partition.partition}: {partition.currentOffset} / {partition.endOffset}, lag {partition.lag}</small>)}</div></div>)}{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
|
||||
<section className="table-panel msk-service-section">
|
||||
<header className="msk-section-header"><div className="msk-section-heading"><span><Activity size={17}/></span><div><p className="eyebrow">Consumer groups</p><h3>Lag inspection</h3><p className="muted compact-text">Compare committed offsets with partition end offsets.</p></div></div></header>
|
||||
<form className="msk-form msk-lag-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && groupId.trim()) loadLag.mutate(); }}><label className="dynamic-field"><span>Consumer group ID <em className="field-required">*</em></span><input className="input" aria-label="Consumer group ID" value={groupId} onChange={(event) => setGroupId(event.target.value)} placeholder="checkout-workers" required /></label><div className="msk-form-actions"><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || !groupId.trim() || loadLag.isPending}>{loadLag.isPending ? "Loading" : "View lag"}</button></div></form>
|
||||
<div className="msk-resource-list">{lag.map((topicLag) => <div className="apigateway-route-item" key={topicLag.topic}><div className="apigateway-route-select"><code>{topicLag.topic}</code><small>Total lag: {topicLag.totalLag}</small>{topicLag.partitions.map((partition) => <small key={partition.partition}>Partition {partition.partition}: {partition.currentOffset} / {partition.endOffset}, lag {partition.lag}</small>)}</div></div>)}</div>
|
||||
{error instanceof Error && <p className="error-text compact-text msk-error">{error.message}</p>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,8 @@ interface ServerLaunchProgressProps {
|
||||
|
||||
export function ServerLaunchProgress({name, description}: ServerLaunchProgressProps) {
|
||||
return <div className="server-launch-progress" role="status" aria-live="polite">
|
||||
<svg viewBox="0 0 256 256" aria-hidden="true">
|
||||
<path fill="#45515D" d="M18 145 48 65c1.4-3.7 5-6 9-6h143c4 0 7.6 2.4 9 6l29 80H18Z"/>
|
||||
<path fill="#68737F" d="M40 145 69 65c1.4-3.7 5-6 9-6h122c4 0 7.6 2.4 9 6l29 80H40Z"/>
|
||||
<rect x="18" y="143" width="220" height="54" rx="13" fill="#3B334A"/>
|
||||
<path fill="#2C2638" d="M18 156c0-7.2 5.8-13 13-13h20c-7.2 0-13 5.8-13 13v28c0 7.2 5.8 13 13 13H31c-7.2 0-13-5.8-13-13v-28Z"/>
|
||||
<rect x="39.5" y="159.5" width="21" height="21" rx="10.5" fill="none" stroke="#FFC735" strokeWidth="4.5"><animate attributeName="width" values="21;35;57;35;21;35;57;35;21" dur="1.64s" repeatCount="indefinite"/></rect>
|
||||
<g fill="#2FB6A2"><circle cx="112" cy="161" r="4"/><circle cx="130" cy="161" r="4"/><circle cx="148" cy="161" r="4"/><circle cx="166" cy="161" r="4"/><circle cx="184" cy="161" r="4"/><circle cx="202" cy="161" r="4"/><circle cx="220" cy="161" r="4"/><circle cx="112" cy="179" r="4"/><circle cx="130" cy="179" r="4"/><circle cx="148" cy="179" r="4"/><circle cx="166" cy="179" r="4"/><circle cx="184" cy="179" r="4"/><circle cx="202" cy="179" r="4"/><circle cx="220" cy="179" r="4"/></g>
|
||||
<rect x="108" y="156" width="116" height="28" fill="#3B334A" opacity="0"><animate attributeName="width" values="0;72;92;72;0;72;92;72;0" dur="1.64s" repeatCount="indefinite"/><animate attributeName="x" values="224;152;132;152;224;152;132;152;224" dur="1.64s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;1;1;0;1;1;1;0" dur="1.64s" repeatCount="indefinite"/></rect>
|
||||
</svg>
|
||||
<img className="server-launch-progress-art" src={serverLaunchAnimation} alt="Animated server launch icon"/>
|
||||
<div><strong>Launching {name || 'instance'}</strong><small>{description}</small></div>
|
||||
</div>
|
||||
}
|
||||
import serverLaunchAnimation from '../../../../svgAsset/server-for-lightmode.svg'
|
||||
|
||||
@@ -3,7 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
|
||||
import {TargetHealthIcon} from '@/components/LoadBalancingProgress'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
@@ -12,16 +12,19 @@ type Props = {
|
||||
}
|
||||
|
||||
type TargetInstance = {id: string; name: string; privateIp: string | null}
|
||||
type PendingTarget = {id: string; name: string; port: number}
|
||||
|
||||
export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [instanceId, setInstanceId] = useState('')
|
||||
const [port, setPort] = useState('')
|
||||
const [pendingTargets, setPendingTargets] = useState<PendingTarget[]>([])
|
||||
const dependenciesQuery = useQuery({
|
||||
queryKey: ['target-group-instances', accountId],
|
||||
queryFn: ({signal}) => getTargetGroupInstances(signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
refetchInterval: pendingTargets.length ? 500 : false,
|
||||
})
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['target-group-health', accountId, resource?.id],
|
||||
@@ -34,26 +37,33 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
setPort(typeof resource?.metadata.port === 'number' ? String(resource.metadata.port) : '')
|
||||
}, [resource?.id, resource?.metadata.port])
|
||||
|
||||
useEffect(() => {
|
||||
const targets = healthQuery.data?.items ?? []
|
||||
setPendingTargets((current) => current.filter((pending) => {
|
||||
const state = stringValue(targets.find((target) => stringValue(target.body.id) === pending.id && Number(target.body.port) === pending.port)?.body.state).toLowerCase()
|
||||
return state !== 'healthy' && state !== 'unhealthy'
|
||||
}))
|
||||
}, [healthQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTargets.length) {
|
||||
return
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void healthQuery.refetch()
|
||||
}, 1_000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [pendingTargets.length, healthQuery.refetch])
|
||||
|
||||
const refresh = () => {
|
||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||
}
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const startedAt = Date.now()
|
||||
await putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)})
|
||||
while (true) {
|
||||
const health = await listFlatItems('aws', 'targetgroups', resource?.id ?? '')
|
||||
const target = health.items.find((item) => stringValue(item.body.id) === instanceId && Number(item.body.port) === Number(port))
|
||||
const state = stringValue(target?.body.state).toLowerCase()
|
||||
if (state === 'healthy' || state === 'unhealthy') {
|
||||
const remaining = 5_000 - (Date.now() - startedAt)
|
||||
if (remaining > 0) await new Promise<void>((resolve) => setTimeout(resolve, remaining))
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
mutationFn: (target: PendingTarget) => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: target.id, port: target.port}),
|
||||
onSuccess: (_, target) => {
|
||||
setPendingTargets((current) => [...current, target])
|
||||
void queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]})
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
|
||||
})
|
||||
const deregisterMutation = useMutation({
|
||||
mutationFn: (targetKey: string) => deleteFlatItem('aws', 'targetgroups', resource?.id ?? '', targetKey),
|
||||
@@ -68,22 +78,23 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
const targets = healthQuery.data?.items ?? []
|
||||
const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535
|
||||
const error = healthQuery.error ?? dependenciesQuery.error ?? registerMutation.error ?? deregisterMutation.error
|
||||
const pendingRows = pendingTargets.filter((pending) => !targets.some((target) => stringValue(target.body.id) === pending.id && Number(target.body.port) === pending.port))
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Target health</p><span className="muted">{targets.length} registered targets in {resource.name}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || healthQuery.isFetching} onClick={refresh}><RefreshCw size={14}/>{healthQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (instanceId && validPort) registerMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Running instance</span><select className="input" value={instanceId} onChange={(event) => setInstanceId(event.target.value)} disabled={dependenciesQuery.isLoading || registerMutation.isPending}><option value="">Select an instance</option>{instances.map((instance) => <option key={instance.id} value={instance.id}>{instance.name} ({instance.id}{instance.privateIp ? `, ${instance.privateIp}` : ''})</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={registerMutation.isPending}/></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort || registerMutation.isPending}>{registerMutation.isPending ? 'Registering' : 'Register target'}</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); const instance = instances.find((candidate) => candidate.id === instanceId); if (instance && validPort) registerMutation.mutate({id: instance.id, name: instance.name, port: Number(port)}) }}>
|
||||
<label className="dynamic-field"><span>Running instance</span><select className="input" value={instanceId} onChange={(event) => setInstanceId(event.target.value)} disabled={dependenciesQuery.isLoading}><option value="">Select an instance</option>{instances.map((instance) => <option key={instance.id} value={instance.id}>{instance.name} ({instance.id}{instance.privateIp ? `, ${instance.privateIp}` : ''})</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)}/></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort}>Register target</button></div>
|
||||
{instances.length === 0 && dependenciesQuery.isSuccess && <p className="muted">No running instances are available in this account.</p>}
|
||||
{registerMutation.isPending && <div className="dynamic-field--span"><LoadBalancingProgress kind="target" description="Waiting for the target health check to become healthy or unhealthy."/></div>}
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{healthQuery.isLoading && <div className="muted padded">Loading target health</div>}
|
||||
{healthQuery.isSuccess && targets.length === 0 && <div className="empty compact"><h3>No registered targets</h3><p>Register a running instance to see its ELB health state.</p></div>}
|
||||
{targets.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Instance</th><th>Port</th><th>State</th><th>Reason</th><th>Description</th><th/></tr></thead><tbody>{targets.map((target) => <tr key={target.id}><td><code>{stringValue(target.body.id)}</code></td><td>{stringValue(target.body.port)}</td><td>{stringValue(target.body.state) || '-'}</td><td>{stringValue(target.body.reason) || '-'}</td><td>{stringValue(target.body.description) || '-'}</td><td><button className="icon-btn danger" type="button" title="Deregister target" aria-label={`Deregister ${stringValue(target.body.id)}`} disabled={!runtimeReachable || deregisterMutation.isPending} onClick={() => deregisterMutation.mutate(target.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
|
||||
{healthQuery.isSuccess && targets.length === 0 && pendingRows.length === 0 && <div className="empty compact"><h3>No registered targets</h3><p>Register a running instance to see its ELB health state.</p></div>}
|
||||
{(targets.length > 0 || pendingRows.length > 0) && <div className="cosmos-items-table target-health-table"><table><thead><tr><th>Instance</th><th>Port</th><th>State</th><th>Reason</th><th>Description</th><th/></tr></thead><tbody>{pendingRows.map((target) => <tr key={`${target.id}:${target.port}`}><td><code>{target.id}</code></td><td>{target.port}</td><td><span className="target-state"><TargetHealthIcon state="checking"/>Checking</span></td><td>-</td><td>Waiting for target health.</td><td/></tr>)}{targets.map((target) => { const state = stringValue(target.body.state); return <tr key={target.id}><td><code>{stringValue(target.body.id)}</code></td><td>{stringValue(target.body.port)}</td><td><span className="target-state"><TargetHealthIcon state={state}/>{state || '-'}</span></td><td>{stringValue(target.body.reason) || '-'}</td><td>{stringValue(target.body.description) || '-'}</td><td><button className="icon-btn danger" type="button" title="Deregister target" aria-label={`Deregister ${stringValue(target.body.id)}`} disabled={!runtimeReachable || deregisterMutation.isPending} onClick={() => deregisterMutation.mutate(target.id)}><Trash2 size={14}/></button></td></tr>})}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -976,6 +976,21 @@ pre,
|
||||
.workflow-studio > .dynamic-stage-header { min-height: 58px; margin: 0; padding: 9px 18px; border-bottom: 1px solid var(--border); }
|
||||
.workflow-studio > .dynamic-stage-header h3 { margin: 1px 0 0; font-size: 15px; }
|
||||
.workflow-studio > .compact-text { margin: 0; padding: 7px 18px; border-bottom: 1px solid var(--border-faint); }
|
||||
.codebuild-progress { display: flex; align-items: center; gap: 18px; margin: 16px; padding: 16px 18px; border: 1px solid color-mix(in srgb, var(--link) 42%, var(--border)); border-radius: 10px; background: color-mix(in srgb, var(--link) 6%, var(--surface)); }
|
||||
.codebuild-progress h3 { margin: 3px 0 0; color: var(--text-bright); font-size: 14px; }
|
||||
.codebuild-progress p:last-child { margin: 5px 0 0; }
|
||||
.codebuild-progress-art { width: 112px; height: 103px; flex: 0 0 auto; overflow: visible; }
|
||||
.pipeline-release-progress { display: flex; align-items: center; gap: 18px; margin: 16px; padding: 16px 18px; border: 1px solid color-mix(in srgb, #e38c6c 46%, var(--border)); border-radius: 10px; background: color-mix(in srgb, #e38c6c 8%, var(--surface)); }
|
||||
.pipeline-release-progress h3 { margin: 3px 0 0; color: var(--text-bright); font-size: 14px; }
|
||||
.pipeline-release-progress p:last-child { margin: 5px 0 0; }
|
||||
.pipeline-release-art { width: 112px; height: 112px; flex: 0 0 auto; overflow: visible; }
|
||||
.codebuild-art-outline { stroke: #050505; stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.codebuild-art-binary { font-family: Arial, sans-serif; font-size: 51px; font-weight: 600; fill: #43bed0; }
|
||||
.codebuild-art-gear { transform-box: fill-box; transform-origin: center; animation: codebuild-gear-spin 4s linear infinite; }
|
||||
@keyframes codebuild-gear-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .codebuild-art-gear { animation: none; } .codebuild-art-binary animate { display: none; } }
|
||||
@media (max-width: 520px) { .codebuild-progress { align-items: flex-start; margin: 12px; padding: 14px; } .codebuild-progress-art { width: 76px; height: 70px; } }
|
||||
@media (max-width: 520px) { .pipeline-release-progress { align-items: flex-start; margin: 12px; padding: 14px; } .pipeline-release-art { width: 76px; height: 76px; } }
|
||||
.workflow-studio-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.workflow-save-definition { min-height: 30px; border-color: var(--workflow-save-border); background: var(--workflow-save-bg); color: var(--workflow-save-text); font-size: 11px; }
|
||||
.workflow-save-definition:not(:disabled):hover { border-color: var(--workflow-save-hover-border); background: var(--workflow-save-hover-bg); }
|
||||
@@ -2881,6 +2896,8 @@ pre,
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ec2-launch-form { display: flex; flex-direction: column; gap: 18px; width: 100%; }
|
||||
|
||||
.launch-form-group {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
@@ -2894,11 +2911,87 @@ pre,
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.launch-form-group::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
.launch-form-group::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.launch-form-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.launch-form-header h3,
|
||||
.launch-form-header p { margin-bottom: 0; }
|
||||
|
||||
.launch-form-header > span {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--link) 36%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--link) 9%, var(--surface));
|
||||
color: var(--link);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.launch-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.launch-stepper button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 145px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text-3);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.launch-stepper button:not(:disabled) { cursor: pointer; }
|
||||
.launch-stepper button:disabled { opacity: .58; }
|
||||
.launch-stepper button.active { border-color: color-mix(in srgb, var(--link) 52%, var(--border)); background: color-mix(in srgb, var(--link) 8%, var(--surface)); color: var(--text); }
|
||||
.launch-stepper b { display: grid; width: 22px; height: 22px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: var(--raised); color: var(--text-3); font-size: 10px; }
|
||||
.launch-stepper button.active b { background: var(--link); color: #fff; }
|
||||
|
||||
.launch-form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.launch-section-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--surface) 94%, var(--bg));
|
||||
}
|
||||
|
||||
.launch-section-title > svg { flex: 0 0 auto; margin-top: 1px; color: var(--link); }
|
||||
.launch-section-title h4 { margin: 0; color: var(--text-bright); font-size: 13px; }
|
||||
.launch-section-title p { margin: 3px 0 0; color: var(--text-3); font-size: 11px; line-height: 1.4; }
|
||||
|
||||
.launch-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 22px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.launch-field {
|
||||
@@ -2913,6 +3006,14 @@ pre,
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ec2-launch-form .launch-field { gap: 8px; min-width: 0; }
|
||||
.ec2-launch-form .launch-field > span { letter-spacing: .025em; text-transform: none; }
|
||||
.ec2-launch-form .launch-field .input,
|
||||
.ec2-launch-form .launch-field .textarea,
|
||||
.ec2-launch-form .launch-field select { width: 100%; min-width: 0; }
|
||||
.ec2-launch-form .launch-field .input { min-height: 38px; }
|
||||
.ec2-launch-form .launch-field small { color: var(--text-3); font-size: 10px; line-height: 1.4; }
|
||||
|
||||
.launch-field--span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -2927,6 +3028,25 @@ pre,
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ec2-launch-form .launch-form-actions { margin-top: 0; padding-top: 4px; border-top: 0; }
|
||||
|
||||
.launch-choice-row { display: flex; gap: 8px; }
|
||||
.launch-choice-row label { display: flex; flex: 1; align-items: center; gap: 7px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text-2); font-size: 11px; cursor: pointer; }
|
||||
.launch-choice-row input { accent-color: var(--link); }
|
||||
.launch-key-create { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto; gap: 8px; align-items: center; }
|
||||
.launch-key-create .button.primary { grid-column: 1 / -1; justify-self: start; }
|
||||
.launch-review { padding: 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
||||
.launch-review .meta-grid { margin-top: 2px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.launch-form-grid { grid-template-columns: 1fr; }
|
||||
.ec2-launch-form .launch-field--span { grid-column: auto; }
|
||||
.launch-stepper { grid-template-columns: repeat(4, minmax(145px, 1fr)); }
|
||||
.launch-form-header { gap: 12px; }
|
||||
.launch-key-create { grid-template-columns: 1fr; }
|
||||
.launch-key-create .button.primary { grid-column: auto; }
|
||||
}
|
||||
|
||||
.ami-catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -3414,6 +3534,35 @@ pre,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msk-workspace { grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
||||
.msk-service-section { border-radius: 10px; }
|
||||
.msk-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 94%, var(--bg)); }
|
||||
.msk-section-heading { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
|
||||
.msk-section-heading > span { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--link) 30%, var(--border)); border-radius: 8px; background: color-mix(in srgb, var(--link) 9%, var(--surface)); color: var(--link); }
|
||||
.msk-section-heading h3 { margin: 2px 0 0; color: var(--text-bright); font-size: 13px; }
|
||||
.msk-section-heading p:last-child { margin: 4px 0 0; }
|
||||
.msk-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 22px; align-items: start; padding: 18px; }
|
||||
.msk-form .dynamic-field { gap: 8px; }
|
||||
.msk-form .dynamic-field > span { letter-spacing: .025em; text-transform: none; }
|
||||
.msk-form .input { width: 100%; min-width: 0; min-height: 38px; }
|
||||
.msk-form .subnet-multiselect { min-height: 88px; }
|
||||
.msk-form-actions { grid-column: 1 / -1; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||||
.msk-form-actions > span { margin-right: auto; color: var(--text-3); font-size: 10px; line-height: 1.4; }
|
||||
.msk-active-resource { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px; padding: 16px 18px; border-top: 1px solid var(--border); background: var(--surface); }
|
||||
.msk-active-resource .dynamic-field { gap: 8px; }
|
||||
.msk-resource-list { display: grid; gap: 6px; padding: 0 18px 18px; }
|
||||
.msk-resource-list > .compact-text { margin: 0; padding: 10px 0; }
|
||||
.msk-lag-form { grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
|
||||
.msk-lag-form .msk-form-actions { grid-column: auto; }
|
||||
.msk-error { margin: 0 18px 18px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.msk-form, .msk-lag-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.msk-form .dynamic-field--span, .msk-form-actions, .msk-lag-form .msk-form-actions { grid-column: auto; }
|
||||
.msk-active-resource { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
|
||||
.msk-section-header { gap: 12px; }
|
||||
}
|
||||
|
||||
.sns-topic-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -4881,6 +5030,34 @@ button.console-service-card {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.codedeploy-create-form { display: flex; flex-direction: column; gap: 18px; width: 100%; }
|
||||
.codedeploy-form-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 4px; }
|
||||
.codedeploy-form-header h3, .codedeploy-form-header p { margin-bottom: 0; }
|
||||
.codedeploy-form-header > span { flex: 0 0 auto; padding: 5px 9px; border: 1px solid color-mix(in srgb, var(--link) 36%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--link) 9%, var(--surface)); color: var(--link); font-size: 10px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.codedeploy-form-section { display: flex; flex-direction: column; gap: 18px; padding: 18px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--surface) 94%, var(--bg)); }
|
||||
.codedeploy-section-title { display: flex; align-items: flex-start; gap: 11px; padding-bottom: 13px; border-bottom: 1px solid var(--border); }
|
||||
.codedeploy-section-title > svg { flex: 0 0 auto; margin-top: 1px; color: var(--link); }
|
||||
.codedeploy-section-title h4 { margin: 0; color: var(--text-bright); font-size: 13px; }
|
||||
.codedeploy-section-title p { margin: 3px 0 0; color: var(--text-3); font-size: 11px; line-height: 1.4; }
|
||||
.codedeploy-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 22px; align-items: start; }
|
||||
.codedeploy-form-grid .dynamic-field { gap: 8px; }
|
||||
.codedeploy-form-grid .dynamic-field > span { letter-spacing: .025em; text-transform: none; }
|
||||
.codedeploy-form-grid .input { min-height: 38px; }
|
||||
.codedeploy-listener-field { grid-column: 1 / -1; }
|
||||
.codedeploy-target-card { padding: 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
||||
.codedeploy-target-card b { width: max-content; padding: 3px 8px; border-radius: 999px; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.codedeploy-target-card.blue { border-color: color-mix(in srgb, #45b5fa 42%, var(--border)); }
|
||||
.codedeploy-target-card.blue b { background: color-mix(in srgb, #45b5fa 18%, var(--surface)); color: #2398df; }
|
||||
.codedeploy-target-card.green { border-color: color-mix(in srgb, #62c9aa 48%, var(--border)); }
|
||||
.codedeploy-target-card.green b { background: color-mix(in srgb, #62c9aa 18%, var(--surface)); color: #278f70; }
|
||||
.codedeploy-rollback { display: flex; align-items: flex-start; gap: 10px; min-height: 38px; padding: 11px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; }
|
||||
.codedeploy-rollback input { margin-top: 2px; accent-color: var(--link); }
|
||||
.codedeploy-rollback span, .codedeploy-rollback strong, .codedeploy-rollback small { display: block; }
|
||||
.codedeploy-rollback strong { color: var(--text); font-size: 11px; }
|
||||
.codedeploy-rollback small { margin-top: 3px; color: var(--text-3); font-size: 10px; line-height: 1.35; }
|
||||
.codedeploy-form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
|
||||
@media (max-width: 720px) { .codedeploy-form-grid { grid-template-columns: 1fr; } .codedeploy-listener-field { grid-column: auto; } .codedeploy-form-header { gap: 12px; } }
|
||||
|
||||
.ec2-readiness-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -4914,6 +5091,25 @@ button.console-service-card {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.ec2-readiness-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.ec2-launch-cpu { width: 42px; height: 42px; flex: 0 0 auto; }
|
||||
.ec2-cpu-pins { fill: none; stroke: #17162b; stroke-width: 2; stroke-linecap: round; }
|
||||
.ec2-launch-cpu.active .ec2-cpu-pins { animation: ec2-cpu-pin 1.2s ease-in-out infinite; }
|
||||
.ec2-cpu-chip { fill: #45b5fa; stroke: #17162b; stroke-width: 2.5; }
|
||||
.ec2-cpu-label { font: 25px Arial, sans-serif; fill: #17162b; letter-spacing: 4px; }
|
||||
.ec2-cpu-pulse { fill: none; stroke: #8bd6ff; stroke-width: 2; opacity: 0; transform-origin: 67px 66px; }
|
||||
.ec2-launch-cpu.active .ec2-cpu-pulse { animation: ec2-cpu-pulse 1.8s ease-out infinite; }
|
||||
.ec2-cpu-dots { fill: #17162b; }
|
||||
.ec2-ssh-heartbeat { width: 72px; height: 25px; overflow: visible; }
|
||||
.ec2-ssh-heartbeat path { fill: none; stroke: var(--text-3); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.ec2-ssh-heartbeat.active path { stroke: #ef5b5b; stroke-dasharray: 118; animation: ec2-ekg-trace 1.55s linear infinite; }
|
||||
.ec2-key-ready { width: 45px; height: 45px; margin: -4px -4px -8px 0; }
|
||||
@keyframes ec2-cpu-pin { 0%, 100% { stroke: #17162b; } 50% { stroke: #45b5fa; } }
|
||||
@keyframes ec2-cpu-pulse { 0% { opacity: .8; transform: scale(.75); } 100% { opacity: 0; transform: scale(1.08); } }
|
||||
@keyframes ec2-ekg-trace { from { stroke-dashoffset: 118; } to { stroke-dashoffset: 0; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .ec2-launch-cpu.active .ec2-cpu-pins, .ec2-launch-cpu.active .ec2-cpu-pulse, .ec2-ssh-heartbeat.active path { animation: none; } }
|
||||
|
||||
.ec2-connect-error,
|
||||
.ec2-check-result {
|
||||
display: flex;
|
||||
@@ -5908,11 +6104,11 @@ button.console-service-card {
|
||||
.lightsail-config { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 28px; }
|
||||
.lightsail-config label { display: grid; gap: 6px; font-weight: 600; }
|
||||
.server-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ec7211 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ec7211 8%, var(--surface)); }
|
||||
.server-launch-progress svg { width: 70px; height: 56px; flex: 0 0 auto; }
|
||||
.server-launch-progress-art { width: 70px; height: 56px; flex: 0 0 auto; }
|
||||
.server-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.server-launch-progress small { color: var(--text-muted); }
|
||||
.database-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #1769c2 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #1769c2 8%, var(--surface)); }
|
||||
.database-launch-progress svg { width: 56px; height: 56px; flex: 0 0 auto; }
|
||||
.database-launch-progress-art { width: 56px; height: 56px; flex: 0 0 auto; }
|
||||
.database-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.database-launch-progress small { color: var(--text-muted); }
|
||||
.database-led { animation: database-led-blink 1.4s ease-in-out infinite; }
|
||||
@@ -5921,11 +6117,12 @@ button.console-service-card {
|
||||
.database-led-d { animation-delay: .66s; }
|
||||
@keyframes database-led-blink { 0%, 25%, 100% { opacity: .28; } 45%, 72% { opacity: 1; } }
|
||||
.load-balancing-progress { display: flex; align-items: center; gap: 14px; margin-top: 16px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ffc52e 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ffc52e 8%, var(--surface)); }
|
||||
.load-balancing-progress svg { width: 72px; height: 64px; flex: 0 0 auto; }
|
||||
.load-balancing-progress-art, .load-balancing-progress svg { width: 72px; height: 64px; flex: 0 0 auto; }
|
||||
.load-balancing-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.load-balancing-progress small { color: var(--text-muted); }
|
||||
.lb-device { fill: #30283d; }.lb-panel { fill: #2b2440; stroke: #17132f; stroke-width: 5; }.lb-wire { fill: none; stroke: #17132f; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-yellow { fill: #ffc12d; }.lb-green { fill: #31aa98; }.lb-port { fill: none; stroke: #ffc52e; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-ports { animation: lb-port-morph 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-hub { animation: lb-hub-pulse 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-blink-1 { animation: lb-blink-one 4.5s steps(1, end) infinite; }.lb-blink-2 { animation: lb-blink-two 4.5s steps(1, end) infinite; }.lb-blink-3 { animation: lb-blink-three 4.5s steps(1, end) infinite; }
|
||||
@keyframes lb-blink-one { 0%, 18%, 42%, 68%, 100% { opacity: 1; } 19%, 41%, 69%, 84% { opacity: .22; } } @keyframes lb-blink-two { 0%, 28%, 58%, 88%, 100% { opacity: .28; } 29%, 57%, 89%, 99% { opacity: 1; } } @keyframes lb-blink-three { 0%, 12%, 48%, 76%, 100% { opacity: 1; } 13%, 47%, 77%, 92% { opacity: .2; } } @keyframes lb-port-morph { 0%, 18%, 82%, 100% { transform: scaleX(.62); } 42%, 62% { transform: scaleX(1.18); } } @keyframes lb-hub-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.13); } }
|
||||
.target-health-table table { min-width: 860px; table-layout: fixed; }.target-health-table th, .target-health-table td { padding: 14px 16px; vertical-align: middle; }.target-health-table th:nth-child(1) { width: 215px; }.target-health-table th:nth-child(2) { width: 70px; }.target-health-table th:nth-child(3) { width: 145px; }.target-health-table th:nth-child(4) { width: 170px; }.target-health-table th:last-child { width: 48px; }.target-health-table code { font-size: 12px; }.target-state { display: inline-flex; align-items: center; gap: 10px; font-weight: 600; text-transform: capitalize; }.target-state-icon { display: inline-block; width: 48px; height: 38px; flex: 0 0 auto; }.target-state-icon svg { width: 100%; height: 100%; }.target-state-icon--checking { animation: target-checking 1.2s ease-in-out infinite; }.target-state-icon--unhealthy { color: #cc3d3d; } @keyframes target-checking { 50% { opacity: .4; } }
|
||||
@media (max-width: 640px) { .lightsail-launch { padding: 18px; } .lightsail-config { grid-template-columns: 1fr; } .lightsail-platform, .lightsail-blueprint, .lightsail-plans button { width: 100%; } }
|
||||
|
||||
.lightsail-instance-panel { overflow: visible; }
|
||||
|
||||
Reference in New Issue
Block a user