70cfd4bc96
CI / checks (pull_request) Successful in 26m54s
Create groups from a launch template (in the default subnets unless given), change their minimum, desired and maximum capacity, replace or remove an instance, and follow the launches and terminations TheCloud's reconciler makes. Deleting a group terminates its instances. Scaling policies are not offered: the runtime stores them but its alarms only drive ECS scaling.
147 lines
9.8 KiB
TypeScript
147 lines
9.8 KiB
TypeScript
import {RefreshCw} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {useEffect, useState} from 'react'
|
|
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
|
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
|
import {useAccountId} from '@/lib/accountStore'
|
|
import {formatDateTime} from '@/lib/format'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
type Props = {
|
|
resource?: CloudResource
|
|
runtimeReachable: boolean
|
|
}
|
|
|
|
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
|
|
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
|
|
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
|
|
|
|
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
|
|
const SETTLING_POLL_MS = 4000
|
|
|
|
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
|
|
const accountId = useAccountId()
|
|
const queryClient = useQueryClient()
|
|
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
|
|
const [notice, setNotice] = useState('')
|
|
const groupKey = ['autoscaling-group', accountId, resource?.id]
|
|
|
|
const groupQuery = useQuery({
|
|
queryKey: groupKey,
|
|
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
|
|
})
|
|
|
|
const group = groupQuery.data ?? resource
|
|
useEffect(() => {
|
|
setNotice('')
|
|
}, [resource?.id])
|
|
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
|
|
const savedMin = String(group?.metadata.minSize ?? '')
|
|
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
|
|
const savedMax = String(group?.metadata.maxSize ?? '')
|
|
useEffect(() => {
|
|
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
|
|
}, [group?.id, savedMin, savedDesired, savedMax])
|
|
|
|
const refresh = () => Promise.all([
|
|
queryClient.invalidateQueries({queryKey: groupKey}),
|
|
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
|
|
])
|
|
|
|
const capacityMutation = useMutation({
|
|
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
|
|
minSize: Number(capacity.minSize),
|
|
desiredCapacity: Number(capacity.desiredCapacity),
|
|
maxSize: Number(capacity.maxSize),
|
|
}),
|
|
onSuccess: (updated) => {
|
|
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
|
|
return refresh()
|
|
},
|
|
})
|
|
const terminateMutation = useMutation({
|
|
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
|
|
onSuccess: (_result, {instanceId, replace}) => {
|
|
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
|
|
return refresh()
|
|
},
|
|
})
|
|
|
|
if (!resource || !group) {
|
|
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
|
|
}
|
|
|
|
const metadata = group.metadata
|
|
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
|
|
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
|
|
const desired = Number(metadata.desiredCapacity ?? 0)
|
|
const min = Number(metadata.minSize ?? 0)
|
|
const pending = capacityMutation.isPending || terminateMutation.isPending
|
|
const disabled = !runtimeReachable || pending
|
|
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
|
|
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
|
|
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
|
|
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
|
|
</label>
|
|
|
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
|
|
<div className="input-row resource-table-bar">
|
|
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
|
|
<div className="cdn-actions">
|
|
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
|
|
{field('minSize', 'Minimum')}
|
|
{field('desiredCapacity', 'Desired')}
|
|
{field('maxSize', 'Maximum')}
|
|
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
|
|
</form>
|
|
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
|
|
|
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group's template.</span></div></div>
|
|
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
|
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
|
|
<tr key={instance.id}>
|
|
<td className="mono">{instance.id}</td>
|
|
<td><code>{instance.lifecycleState ?? '—'}</code></td>
|
|
<td>{instance.health ?? '—'}</td>
|
|
<td>{instance.availabilityZone ?? '—'}</td>
|
|
<td>{instance.instanceType ?? '—'}</td>
|
|
<td className="autoscaling-instance-actions">
|
|
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
|
|
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
|
|
setNotice('')
|
|
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
|
|
}}>Remove</button>
|
|
</td>
|
|
</tr>
|
|
))}</tbody></table></div>}
|
|
|
|
{activities.length > 0 && <>
|
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
|
|
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
|
|
<tr key={activity.id}>
|
|
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
|
|
<td><code>{activity.status ?? '—'}</code></td>
|
|
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
|
|
</tr>
|
|
))}</tbody></table></div>
|
|
</>}
|
|
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
|
|
|
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
|
</section>
|
|
}
|
|
|
|
/** Settled once every desired instance is in service and none is still starting or stopping. */
|
|
function settled(group: CloudResource): boolean {
|
|
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
|
|
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
|
|
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
|
|
}
|