b171f745db
- CodeDeploy: query the redesigned form's labels. - CloudFormation: expect the parameters and capabilities fields the form now submits. - ALB listeners: advance the five-second minimum create state before deleting, since row actions are disabled while it is pending. - Lightsail: cast the fetch mock as a whole so it type-checks. - TargetGroupPanel: depend on the stable refetch function to satisfy react-hooks/exhaustive-deps.
110 lines
7.7 KiB
TypeScript
110 lines
7.7 KiB
TypeScript
import {RefreshCw, Trash2} from 'lucide-react'
|
|
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 {TargetHealthIcon} from '@/components/LoadBalancingProgress'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
type Props = {
|
|
resource?: CloudResource
|
|
runtimeReachable: boolean
|
|
}
|
|
|
|
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],
|
|
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const refetchHealth = healthQuery.refetch
|
|
|
|
useEffect(() => {
|
|
setInstanceId('')
|
|
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 refetchHealth()
|
|
}, 1_000)
|
|
return () => window.clearInterval(interval)
|
|
}, [pendingTargets.length, refetchHealth])
|
|
|
|
const refresh = () => {
|
|
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
|
}
|
|
const registerMutation = useMutation({
|
|
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]})
|
|
},
|
|
})
|
|
const deregisterMutation = useMutation({
|
|
mutationFn: (targetKey: string) => deleteFlatItem('aws', 'targetgroups', resource?.id ?? '', targetKey),
|
|
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
|
|
})
|
|
|
|
if (!resource) {
|
|
return <section className="table-panel"><div className="empty compact"><h3>Select a target group</h3><p>Choose a target group to register running instances and inspect target health.</p></div></section>
|
|
}
|
|
|
|
const instances = dependenciesQuery.data ?? []
|
|
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(); 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>}
|
|
</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 && 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>
|
|
}
|
|
|
|
async function getTargetGroupInstances(signal?: AbortSignal): Promise<TargetInstance[]> {
|
|
const dependencies = await getLoadBalancingDependencies('targetgroups', signal)
|
|
return Array.isArray(dependencies.instances) ? dependencies.instances : []
|
|
}
|
|
|
|
function stringValue(value: unknown): string {
|
|
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
|
|
}
|