feat: fully functioning ALB

This commit is contained in:
Zakaria
2026-09-26 16:14:24 -04:00
parent 093afb9b2b
commit 8470b8d17a
44 changed files with 2076 additions and 132 deletions
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
AlertTriangle,
@@ -583,6 +583,22 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
</span>
</div>
)}
{inst.networkInterfaces.length > 0 && (
<div className="meta-row" style={{ gridColumn: '1 / -1', alignItems: 'start' }}>
<span className="meta-label">Network interfaces</span>
<span className="meta-value" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{inst.networkInterfaces.map((networkInterface) => (
<span key={networkInterface.networkInterfaceId} style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<code>{networkInterface.networkInterfaceId}</code>
{networkInterface.deviceIndex !== undefined && <span>device {networkInterface.deviceIndex}</span>}
{networkInterface.status && <span>{networkInterface.status}</span>}
{networkInterface.privateIpAddress && <span>private {networkInterface.privateIpAddress}</span>}
{networkInterface.publicIpAddress && <span>public {networkInterface.publicIpAddress}</span>}
</span>
))}
</span>
</div>
)}
</div>
)}
@@ -789,6 +805,23 @@ export function LaunchInstanceForm({
const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) })
const subsQ = useQuery({ queryKey: ['ec2', 'subnets'], queryFn: ({ signal }) => listEc2Subnets(undefined, signal) })
const sgsQ = useQuery({ queryKey: ['ec2', 'security-groups'], queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal) })
const selectedSubnet = (subsQ.data ?? []).find((subnet) => subnet.subnetId === subnetId)
useEffect(() => {
if (!selectedSubnet) {
return
}
const compatibleGroupIds = new Set((sgsQ.data ?? [])
.filter((group) => group.vpcId === selectedSubnet.vpcId)
.map((group) => group.groupId))
setSelectedSgs((current) => {
const compatible = current.filter((groupId) => compatibleGroupIds.has(groupId))
if (compatible.length !== current.length) {
setStepError('Security groups from another VPC were removed. Select groups in the subnet VPC.')
}
return compatible
})
}, [selectedSubnet, sgsQ.data])
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
@@ -845,6 +878,13 @@ export function LaunchInstanceForm({
}
function launch() {
const hasIncompatibleSecurityGroup = selectedSubnet && selectedSgs.some((groupId) =>
!(sgsQ.data ?? []).some((group) => group.groupId === groupId && group.vpcId === selectedSubnet.vpcId))
if (hasIncompatibleSecurityGroup) {
setStep(3)
setStepError('Security groups must belong to the selected subnet VPC.')
return
}
createMut.mutate({
name,
imageId,
@@ -908,8 +948,8 @@ export function LaunchInstanceForm({
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
})
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId)
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId)
const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' },
@@ -1046,10 +1086,11 @@ export function LaunchInstanceForm({
<div className="sg-checklist">
{sgsQ.isLoading && <span className="muted">Loading…</span>}
{selectedSubnet && <span className="muted">Showing groups in {selectedSubnet.vpcId} only.</span>}
{sgsQ.data?.length === 0 && !showCreateSg && (
<span className="muted">No security groups — create one above.</span>
)}
{(sgsQ.data ?? []).map((sg: Ec2SecurityGroup) => (
{availableSecurityGroups.map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item">
<input
type="checkbox"
@@ -0,0 +1,67 @@
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
interface CopyValueButtonProps {
value: string;
label: string;
}
export function CopyValueButton({value, label}: CopyValueButtonProps) {
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
async function copy() {
const copied = await copyText(value);
setStatus(copied ? 'copied' : 'failed');
window.setTimeout(() => setStatus('idle'), 2_000);
}
const message = status === 'copied'
? `${label} copied`
: status === 'failed'
? `Unable to copy ${label}`
: '';
return (
<>
<button
className="icon-btn copy-value-button"
type="button"
onClick={(event) => {
event.stopPropagation();
void copy();
}}
title={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
aria-label={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
>
{status === 'copied' ? <Check size={13} /> : <Copy size={13} />}
</button>
<span className="visually-hidden" role="status">{message}</span>
</>
);
}
async function copyText(value: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// Some embedded or insecure contexts expose Clipboard API but reject writes.
}
const textarea = document.createElement('textarea');
try {
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
@@ -53,6 +53,10 @@ import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHisto
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
import { SnsTopicPanel } from "@/components/SnsTopicPanel";
import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -76,6 +80,7 @@ export function DynamicResourceView({
}: DynamicResourceViewProps) {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [state, setState] = useState("");
const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances");
const [selected, setSelected] = useState<CloudResource | undefined>();
const [selectedObject, setSelectedObject] = useState<
@@ -104,8 +109,8 @@ export function DynamicResourceView({
};
const resourcesKey = useMemo(
() => ["cloud-resources", cloud, service, search],
[cloud, service, search],
() => ["cloud-resources", cloud, service, search, state],
[cloud, service, search, state],
);
const schemaQuery = useQuery({
@@ -115,7 +120,7 @@ export function DynamicResourceView({
const resourcesQuery = useQuery({
queryKey: resourcesKey,
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal),
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal, state),
enabled:
schemaQuery.isSuccess &&
serviceAvailability === "available" &&
@@ -433,6 +438,7 @@ export function DynamicResourceView({
setEditingResource(null);
setUpdateError(null);
setSearch("");
setState("");
setDatabaseTab("instances");
setIsDeleteMulti(false);
setSelectedItems([]);
@@ -599,6 +605,18 @@ export function DynamicResourceView({
placeholder="Filter resources"
/>
</div>
{schema.filters.find((filter) => filter.name === "state" && filter.type === "select") && (
<select
className="input"
aria-label="Filter by lifecycle state"
value={state}
onChange={(event) => setState(event.target.value)}
>
{schema.filters.find((filter) => filter.name === "state")?.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{service === "email" && (
clearConfirm ? (
<>
@@ -756,6 +774,13 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : (
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
@@ -832,6 +857,15 @@ export function DynamicResourceView({
onSelectObject={setSelectedObject}
/>
)}
{service === "ecr" && cloud === "aws" && (
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "targetgroups" && cloud === "aws" && (
<TargetGroupPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -940,7 +974,15 @@ export function DynamicResourceView({
<X size={14} />
</button>
</div>
<DynamicFormRenderer
{service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
resource={editingResource}
isSubmitting={updateMut.isPending}
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
) : <DynamicFormRenderer
key={editingResource.id}
schema={schema}
fields={schema.updateFields}
@@ -951,7 +993,7 @@ export function DynamicResourceView({
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
/>}
</div>
</div>
)}
@@ -0,0 +1,70 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {deleteFlatItem, listFlatItems} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
interface EcrImageBrowserProps {
resource?: CloudResource
runtimeReachable: boolean
}
export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserProps) {
const queryClient = useQueryClient()
const imagesQuery = useQuery({
queryKey: ['ecr-images', resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'ecr', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const deleteMutation = useMutation({
mutationFn: (imageDigest: string) => deleteFlatItem('aws', 'ecr', resource?.id ?? '', imageDigest),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['ecr-images', resource?.id]}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a repository</h3><p>Choose an ECR repository to browse its images.</p></div></section>
}
const images = imagesQuery.data?.items ?? []
return (
<section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Repository images</p>
<span className="muted">{images.length} images in {resource.name}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || imagesQuery.isFetching} onClick={() => imagesQuery.refetch()}>
<RefreshCw size={14}/>{imagesQuery.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
{imagesQuery.error instanceof Error && <p className="error-text compact-text">{imagesQuery.error.message}</p>}
{imagesQuery.isLoading && <div className="muted padded">Loading images</div>}
{imagesQuery.isSuccess && images.length === 0 && <div className="empty compact"><h3>No images</h3><p>Push an image to this repository to see it here.</p></div>}
{images.length > 0 && <div className="cosmos-items-table">
<table>
<thead><tr><th>Tags</th><th>Digest</th><th>Pushed</th><th>Size</th><th/></tr></thead>
<tbody>{images.map((image) => {
const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : []
const size = typeof image.body.imageSizeInBytes === 'number' ? formatBytes(image.body.imageSizeInBytes) : '-'
return <tr key={image.id}>
<td>{tags.length ? tags.join(', ') : <span className="muted">Untagged</span>}</td>
<td><code>{shortDigest(image.id)}</code></td>
<td>{image.timestamp ? new Date(image.timestamp).toLocaleString() : '-'}</td>
<td>{size}</td>
<td><button className="icon-btn danger" type="button" title="Delete image" aria-label={`Delete ${image.id}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(image.id)}><Trash2 size={14}/></button></td>
</tr>
})}</tbody>
</table>
</div>}
</section>
)
}
function shortDigest(digest: string): string {
return digest.length > 20 ? `${digest.slice(0, 19)}...` : digest
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
}
@@ -0,0 +1,35 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
listCloudResources: vi.fn(),
listFlatItems: vi.fn(),
putFlatItem: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
const user = userEvent.setup()
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
api.putFlatItem.mockResolvedValue({})
api.deleteFlatItem.mockResolvedValue(undefined)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={client}><LoadBalancerListenerPanel runtimeReachable resource={{id: 'load-balancer-arn', name: 'web', cloud: 'aws', service: 'loadbalancing', type: 'load-balancer', region: null, createdAt: null, metadata: {}}}/></QueryClientProvider>)
await screen.findByText('web-targets (target-group-arn)')
await user.selectOptions(screen.getByLabelText(/default target group/i), 'target-group-arn')
await user.clear(screen.getByLabelText(/listener port/i))
await user.type(screen.getByLabelText(/listener port/i), '8080')
await user.click(screen.getByRole('button', {name: /create listener/i}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
})
@@ -0,0 +1,74 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [targetGroupArn, setTargetGroupArn] = useState('')
const [port, setPort] = useState('80')
const listenersQuery = useQuery({
queryKey: ['load-balancer-listeners', accountId, resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'loadbalancing', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const targetGroupsQuery = useQuery({
queryKey: ['listener-target-groups', accountId],
queryFn: ({signal}) => listCloudResources('aws', 'targetgroups', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setTargetGroupArn('')
setPort('80')
}, [resource?.id])
const createMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
})
const deleteMutation = useMutation({
mutationFn: (listenerArn: string) => deleteFlatItem('aws', 'loadbalancing', resource?.id ?? '', listenerArn),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select an Application Load Balancer</h3><p>Choose a load balancer to create and manage HTTP listeners.</p></div></section>
}
const listeners = listenersQuery.data?.items ?? []
const targetGroups = targetGroupsQuery.data ?? []
const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535
const error = listenersQuery.error ?? targetGroupsQuery.error ?? createMutation.error ?? deleteMutation.error
const pending = createMutation.isPending || deleteMutation.isPending
return <section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Listeners</p><span className="muted">{listeners.length} listeners on {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || listenersQuery.isFetching} onClick={() => { void Promise.all([listenersQuery.refetch(), targetGroupsQuery.refetch()]) }}><RefreshCw size={14}/>{listenersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (targetGroupArn && validPort) createMutation.mutate() }}>
<label className="dynamic-field"><span>Protocol</span><input className="input" value="HTTP" disabled/></label>
<label className="dynamic-field"><span>Listener port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={pending}/></label>
<label className="dynamic-field dynamic-field--span"><span>Default target group</span><select className="input" value={targetGroupArn} onChange={(event) => setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}><option value="">Select a target group</option>{targetGroups.map((group) => <option key={group.id} value={group.id}>{group.name} ({group.id})</option>)}</select></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !targetGroupArn || !validPort || pending}>{createMutation.isPending ? 'Creating' : 'Create listener'}</button></div>
{targetGroups.length === 0 && targetGroupsQuery.isSuccess && <p className="muted">No target groups are available in this account.</p>}
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>}
{listenersQuery.isSuccess && listeners.length === 0 && <div className="empty compact"><h3>No listeners</h3><p>Create an HTTP listener that forwards requests to a target group.</p></div>}
{listeners.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Protocol</th><th>Port</th><th>Default target group</th><th/></tr></thead><tbody>{listeners.map((listener) => <tr key={listener.id}><td>{stringValue(listener.body.protocol)}</td><td>{stringValue(listener.body.port)}</td><td><code>{stringValue(listener.body.targetGroupArn)}</code></td><td><button className="icon-btn danger" type="button" title="Delete listener" aria-label={`Delete listener ${stringValue(listener.body.port)}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(listener.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
</section>
}
function stringValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
@@ -0,0 +1,40 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {LoadBalancerPanel} from './LoadBalancerPanel'
vi.mock('@/api/cloudProxyClient', () => ({
getLoadBalancingDependencies: vi.fn().mockResolvedValue({
vpcs: [{id: 'vpc-a', cidrBlock: '10.0.0.0/16'}, {id: 'vpc-b', cidrBlock: '10.1.0.0/16'}],
subnets: [
{id: 'subnet-a', vpcId: 'vpc-a', availabilityZone: 'us-east-1a', cidrBlock: '10.0.1.0/24'},
{id: 'subnet-b', vpcId: 'vpc-a', availabilityZone: 'us-east-1b', cidrBlock: '10.0.2.0/24'},
],
securityGroups: [{id: 'sg-a', vpcId: 'vpc-a', name: 'web', description: ''}],
}),
}))
test('filters network choices by VPC, resets them, and submits an application load balancer', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<QueryClientProvider client={new QueryClient()}><LoadBalancerPanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/></QueryClientProvider>)
await screen.findByText('vpc-a (10.0.0.0/16)')
await user.type(screen.getByLabelText(/load balancer name/i), 'web')
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a')
await user.click(screen.getByLabelText(/subnet-a/i))
await user.click(screen.getByLabelText(/subnet-b/i))
await user.click(screen.getByLabelText(/web \(sg-a\)/i))
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-b')
expect(screen.queryByLabelText(/subnet-a/i)).not.toBeInTheDocument()
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a')
expect(screen.getByLabelText(/subnet-a/i)).not.toBeChecked()
expect(screen.getByLabelText(/web \(sg-a\)/i)).not.toBeChecked()
await user.click(screen.getByLabelText(/subnet-a/i))
await user.click(screen.getByLabelText(/subnet-b/i))
await user.click(screen.getByLabelText(/web \(sg-a\)/i))
await user.click(screen.getByRole('button', {name: /create load balancer/i}))
expect(onSubmit).toHaveBeenCalledWith({name: 'web', subnets: 'subnet-a,subnet-b', securityGroups: 'sg-a', scheme: 'internet-facing'})
})
@@ -0,0 +1,72 @@
import {useQuery} from '@tanstack/react-query'
import {FormEvent, useEffect, useState} from 'react'
import {getLoadBalancingDependencies} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
isSubmitting: boolean
submitError: string | null
onCancel: () => void
onSubmit: (values: Record<string, unknown>) => void
}
export function LoadBalancerPanel({resource, isSubmitting, submitError, onCancel, onSubmit}: Props) {
const dependencies = useQuery({queryKey: ['loadbalancing-dependencies'], queryFn: ({signal}) => getLoadBalancingDependencies('loadbalancing', signal)})
const [name, setName] = useState('')
const [scheme, setScheme] = useState('internet-facing')
const [vpcId, setVpcId] = useState('')
const [subnetIds, setSubnetIds] = useState<string[]>([])
const [securityGroupIds, setSecurityGroupIds] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!resource) return
setVpcId(typeof resource.metadata.vpcId === 'string' ? resource.metadata.vpcId : '')
setSecurityGroupIds(stringValues(resource.metadata.securityGroups))
}, [resource])
const subnets = (dependencies.data?.subnets ?? []).filter((subnet) => subnet.vpcId === vpcId)
const groups = (dependencies.data?.securityGroups ?? []).filter((group) => group.vpcId === vpcId)
const selectedZones = new Set(subnets.filter((subnet) => subnetIds.includes(subnet.id)).map((subnet) => subnet.availabilityZone))
function selectVpc(nextVpcId: string) {
setVpcId(nextVpcId)
setSubnetIds([])
setSecurityGroupIds([])
setError(null)
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!resource && !name.trim()) return setError('Load balancer name is required.')
if (!resource && (subnetIds.length < 2 || selectedZones.size < 2)) return setError('Select two subnets in distinct Availability Zones.')
if (securityGroupIds.length === 0) return setError('Select at least one security group.')
setError(null)
onSubmit(resource ? {securityGroups: securityGroupIds.join(',')} : {
name: name.trim(), subnets: subnetIds.join(','), securityGroups: securityGroupIds.join(','), scheme,
})
}
if (dependencies.isLoading) return <p className="muted">Loading VPC configuration...</p>
if (dependencies.isError) return <p className="form-error" role="alert">Unable to load VPC configuration.</p>
return <form className="dynamic-form" onSubmit={submit} noValidate>
<div className="dynamic-form-group">{resource ? 'Security groups' : 'Application Load Balancer'}</div>
{!resource && <label className="dynamic-field"><span>Load Balancer Name<em className="field-required">*</em></span><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></label>}
{!resource && <label className="dynamic-field"><span>Scheme</span><select className="input" value={scheme} onChange={(event) => setScheme(event.target.value)}><option value="internet-facing">internet-facing</option><option value="internal">internal</option></select></label>}
<label className="dynamic-field"><span>VPC<em className="field-required">*</em></span><select className="input" value={vpcId} required onChange={(event) => selectVpc(event.target.value)} disabled={Boolean(resource)}><option value="">Select a VPC</option>{(dependencies.data?.vpcs ?? []).map((vpc) => <option key={vpc.id} value={vpc.id}>{vpc.id} ({vpc.cidrBlock})</option>)}</select></label>
{!resource && <fieldset className="dynamic-field dynamic-field--span"><legend>Subnets<em className="field-required">*</em></legend><small>Select at least two subnets in distinct Availability Zones.</small>{subnets.map((subnet) => <label key={subnet.id}><input type="checkbox" checked={subnetIds.includes(subnet.id)} onChange={() => setSubnetIds((current) => toggle(current, subnet.id))} /> {subnet.id} ({subnet.availabilityZone}, {subnet.cidrBlock})</label>)}</fieldset>}
<fieldset className="dynamic-field dynamic-field--span"><legend>Security Groups<em className="field-required">*</em></legend>{groups.map((group) => <label key={group.id}><input type="checkbox" checked={securityGroupIds.includes(group.id)} onChange={() => setSecurityGroupIds((current) => toggle(current, group.id))} /> {group.name || group.id} ({group.id})</label>)}</fieldset>
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: 8}}><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? (resource ? 'Saving' : 'Creating') : (resource ? 'Set Security Groups' : 'Create Load Balancer')}</button></div>
{(error || submitError) && <div className="form-error" role="alert">{error ?? submitError}</div>}
</form>
}
function toggle(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value]
}
function stringValues(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -229,8 +229,8 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
No rules. Click Add rule to add one.
</div>
) : (
<div style={{border: '1px solid var(--border)', borderRadius: 4, marginBottom: 8}}>
<table style={{width: '100%', borderCollapse: 'collapse', fontSize: 12}}>
<div className="sg-edit-rule-table-wrap">
<table className="sg-edit-rule-table">
<thead>
<tr style={{borderBottom: '1px solid var(--border)'}}>
<th style={thS}>Type</th>
@@ -248,10 +248,10 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
const toErr = isCustom(r) && r.toPort !== '' && !isValidPort(r.toPort)
return (
<tr key={r.rid} style={{borderBottom: idx < rules.length - 1 ? '1px solid var(--border-faint)' : 'none'}}>
<td style={tdS}>
<td className="sg-edit-type-cell" style={tdS}>
<select
className="input"
style={{width: 130, fontSize: 12}}
style={{fontSize: 12}}
value={pKey}
onChange={(e) => handlePreset(r.rid, e.target.value)}
>
@@ -407,7 +407,7 @@ function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => v
return (
<div className="modal-overlay" onClick={(e) => {if (e.target === e.currentTarget) onClose()}}>
<div className="create-table-modal" style={{maxWidth: 680}}>
<div className="create-table-modal sg-rules-edit-modal">
<div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}>
<Shield size={14} style={{color: 'var(--accent)'}}/>
<span>Edit rules — {sg.groupName}</span>
@@ -5,6 +5,7 @@ import { LogsQueryPanel } from "@/components/LogsQueryPanel";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton";
interface ResourceInspectorProps {
resource?: CloudResource;
@@ -82,6 +83,7 @@ export function ResourceInspector({
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource ID" value={resource.id} copyable />
<InspectorItem label="Service" value={serviceName ?? resource.service} />
<InspectorItem label="Region" value={resource.region ?? "-"} />
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
@@ -163,6 +165,7 @@ export function ResourceInspector({
<InspectorItem
label="ARN"
value={getStringMetadata(resource.metadata.arn) ?? "-"}
copyable
/>
<InspectorItem
label="Last Modified"
@@ -343,11 +346,14 @@ function DatabaseLifecycleSection({ status }: { status?: string | null }) {
}
function InspectorItem({ label, value }: { label: string; value: string }) {
function InspectorItem({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) {
return (
<div>
<p className="metric-label">{label}</p>
<p className="metric-value mono">{value}</p>
<p className="metric-value mono copy-value">
{value}
{copyable && value !== "-" && <CopyValueButton value={value} label={label} />}
</p>
</div>
);
}
@@ -370,13 +376,20 @@ function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
{rows.map(([key, value]) => (
<div key={key}>
<span>{humanizeKey(key)}</span>
<code>{formatMetadataValue(value)}</code>
<span className="copy-value">
<code>{formatMetadataValue(value)}</code>
{isCopyableMetadata(key, value) && <CopyValueButton value={formatMetadataValue(value)} label={humanizeKey(key)} />}
</span>
</div>
))}
</div>
);
}
function isCopyableMetadata(key: string, value: unknown): value is string | number {
return (typeof value === "string" || typeof value === "number") && /id$|arn|ip|endpoint|url|uri|key|hash|token/i.test(key);
}
function formatMetadataValue(value: unknown): string {
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
@@ -5,6 +5,7 @@ import type {CloudResource} from '@/types/resource';
import type {ServiceSchema} from '@/types/schema';
import {getPath} from '@/lib/resourcePath';
import {renderColumnValue} from '@/lib/columnFormat';
import {CopyValueButton} from '@/components/CopyValueButton';
interface ResourceTableProps {
schema: ServiceSchema;
@@ -102,7 +103,7 @@ export function ResourceTable({
)}
{schema.columns.map((column) => (
<td key={column.name} onClick={() => onSelect(resource)}>
{renderColumnValue(getPath(resource, column.path ?? column.name), column)}
<CopyableColumnValue resource={resource} column={column} />
</td>
))}
{hasActions && (
@@ -188,3 +189,21 @@ export function ResourceTable({
</table>
);
}
function CopyableColumnValue({resource, column}: {resource: CloudResource; column: ServiceSchema['columns'][number]}) {
const value = getPath(resource, column.path ?? column.name);
const copyable = typeof value === 'string' && (
column.format === 'code' || /(^id$|id$|arn|ip|endpoint|url|uri|key)/i.test(column.name)
);
if (!copyable) {
return renderColumnValue(value, column);
}
return (
<span className="copy-value">
{renderColumnValue(value, column)}
<CopyValueButton value={value} label={column.label} />
</span>
);
}
@@ -0,0 +1,81 @@
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 type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type TargetInstance = {id: string; name: string; privateIp: string | null}
export function TargetGroupPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [instanceId, setInstanceId] = useState('')
const [port, setPort] = useState('')
const dependenciesQuery = useQuery({
queryKey: ['target-group-instances', accountId],
queryFn: ({signal}) => getTargetGroupInstances(signal),
enabled: Boolean(resource) && runtimeReachable,
})
const healthQuery = useQuery({
queryKey: ['target-group-health', accountId, resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setInstanceId('')
setPort(typeof resource?.metadata.port === 'number' ? String(resource.metadata.port) : '')
}, [resource?.id, resource?.metadata.port])
const refresh = () => {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
}
const registerMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}),
onSuccess: () => 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
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>
{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 && <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>}
</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) : ''
}
@@ -33,6 +33,7 @@ import {
*/
const SERVICE_ICONS: Record<string, LucideIcon> = {
storage: HardDrive,
ecr: Container,
database: Table2,
nosql: Database,
k8s: Boxes,