first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -0,0 +1,58 @@
import { useState } from 'react'
import { Network } from 'lucide-react'
import { useAllocateElasticIpMutation } from '@/api/aws/ec2.mutations'
import type { Ec2ElasticIp } from '@/api/aws/ec2.api'
type Props = {
onClose: () => void
onAllocated?: (eip: Ec2ElasticIp) => void
}
export function AllocateEipModal({ onClose, onAllocated }: Props) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
const mutation = useAllocateElasticIpMutation()
async function handleAllocate() {
setErr('')
try {
const eip = await mutation.mutateAsync(name.trim() || undefined)
onAllocated?.(eip)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Network size={14} /> Allocate Elastic IP
</h3>
<div className="modal-section">
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
Allocates a new VPC-scoped Elastic IP address. Standard hourly charges apply when not associated.
</p>
<div className="field-row">
<label>Name tag <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
<input
className="input"
placeholder="my-eip"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
</div>
<div className="modal-footer">
<button className="button" onClick={onClose}>Cancel</button>
<button className="button primary" disabled={mutation.isPending} onClick={() => void handleAllocate()}>
{mutation.isPending ? 'Allocating…' : 'Allocate'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
type Props = {
/** Title line, e.g. "Delete VPC" */
title: string
/** Resource identifier shown in monospace, e.g. "vpc-d0965efd" */
resourceId: string
/** Human-readable label shown below the ID, e.g. "my-vpc · 10.0.0.0/16 · default" */
subtitle?: string
/** Extra warning lines shown between resourceId and the input (cascade list, etc.) */
warnings?: string[]
/** Mutation pending state */
isPending?: boolean
onConfirm: () => void | Promise<void>
onClose: () => void
}
export function ConfirmDeleteModal({ title, resourceId, subtitle, warnings, isPending, onConfirm, onClose }: Props) {
const [typed, setTyped] = useState('')
const canDelete = typed === 'delete' && !isPending
async function handleConfirm() {
if (!canDelete) return
await onConfirm()
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="create-table-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="modal-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertTriangle size={14} style={{ color: 'var(--status-error)', flexShrink: 0 }} />
<span>{title}</span>
<button className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
<X size={13} />
</button>
</div>
<div className="modal-section">
{/* Resource ID + optional subtitle */}
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 4,
padding: '8px 12px',
marginBottom: warnings && warnings.length > 0 ? 10 : 14,
}}>
<div style={{ fontFamily: 'monospace', fontSize: 13 }}>{resourceId}</div>
{subtitle && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>{subtitle}</div>
)}
</div>
{/* Cascade / extra warnings */}
{warnings && warnings.length > 0 && (
<div style={{
background: 'rgba(251,191,36,0.08)',
border: '1px solid rgba(251,191,36,0.3)',
borderRadius: 4,
padding: '8px 12px',
marginBottom: 14,
}}>
{warnings.map((w, i) => (
<div key={i} style={{ fontSize: 12, color: 'var(--status-warn)', lineHeight: 1.5 }}>
{w}
</div>
))}
</div>
)}
{/* Confirmation input */}
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>
Type <strong style={{ color: 'var(--text)', fontFamily: 'monospace' }}>delete</strong> to confirm
</label>
<input
className="input"
placeholder="delete"
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleConfirm() }}
autoFocus
style={{ borderColor: typed && typed !== 'delete' ? 'var(--status-error)' : undefined }}
/>
</div>
<div className="modal-footer">
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
<button className="button danger" onClick={() => void handleConfirm()} disabled={!canDelete}>
{isPending ? <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> : <Trash2 size={13} />}
Delete
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,62 @@
import { Terminal } from "lucide-react";
import { useEc2ConsoleOutputQuery } from "@/api/aws/ec2.queries";
interface Props {
instanceId: string;
onClose: () => void;
}
export function ConsoleOutputModal({ instanceId, onClose }: Props) {
const query = useEc2ConsoleOutputQuery(instanceId);
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="create-table-modal" style={{ maxWidth: 720, maxHeight: "85vh", display: "flex", flexDirection: "column" }}>
<h3 style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Terminal size={14} />
Console output — {instanceId}
</h3>
{query.isLoading && (
<p style={{ fontSize: 12, color: "var(--text-2)" }}>Loading…</p>
)}
{query.isError && (
<p style={{ fontSize: 12, color: "var(--status-error)" }}>
{query.error instanceof Error ? query.error.message : "Failed to fetch console output."}
</p>
)}
{query.data && (
<>
{query.data.timestamp && (
<p style={{ fontSize: 11, color: "var(--text-2)", margin: "0 0 8px" }}>
As of {new Date(query.data.timestamp).toLocaleString()}
</p>
)}
<pre
className="mono"
style={{
flex: 1,
overflowY: "auto",
background: "var(--surface-2)",
padding: 12,
borderRadius: 4,
fontSize: 11,
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{query.data.output || "(no output yet)"}
</pre>
</>
)}
<div className="modal-footer" style={{ marginTop: 12 }}>
<button className="button" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useState } from "react";
import { HardDrive, Loader2 } from "lucide-react";
import { useCreateAmiMutation } from "@/api/aws/ec2.mutations";
interface Props {
instanceId: string;
instanceName: string;
onClose: () => void;
onCreated: () => void;
}
export function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: Props) {
const [name, setName] = useState(`${instanceName}-ami`);
const [description, setDescription] = useState("");
const [noReboot, setNoReboot] = useState(false);
const [err, setErr] = useState("");
const mutation = useCreateAmiMutation();
function handleSubmit() {
if (!name.trim()) { setErr("Name is required."); return; }
setErr("");
mutation.mutate(
{ instanceId, input: { name: name.trim(), description: description || undefined, noReboot } },
{
onSuccess: () => { onCreated(); onClose(); },
onError: (e) => setErr(e instanceof Error ? e.message : "AMI creation failed."),
},
);
}
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="create-table-modal" style={{ maxWidth: 420 }}>
<h3>Create AMI</h3>
<div className="modal-section">
<p className="modal-section-title">Image name</p>
<input
className="input"
style={{ width: "100%", minWidth: "unset" }}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onClose()}
/>
</div>
<div className="modal-section">
<p className="modal-section-title">Description — optional</p>
<input
className="input"
style={{ width: "100%", minWidth: "unset" }}
placeholder="e.g. production snapshot"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="modal-section">
<div className="field-row" style={{ alignItems: "center" }}>
<label className="toggle-switch" style={{ width: 32, height: 18 }}>
<input type="checkbox" checked={noReboot} onChange={(e) => setNoReboot(e.target.checked)} />
<span className="toggle-track" />
</label>
<p className="modal-section-title" style={{ margin: 0 }}>No reboot — skip instance restart before imaging</p>
</div>
</div>
{err && <p style={{ fontSize: 12, color: "var(--status-error)", margin: "0 0 8px" }}>{err}</p>}
<div className="modal-footer">
<button className="button" onClick={onClose} disabled={mutation.isPending}>Cancel</button>
<button
className="button primary"
onClick={handleSubmit}
disabled={!name.trim() || mutation.isPending}
>
{mutation.isPending ? <Loader2 size={13} /> : <HardDrive size={13} />}
Create AMI
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react'
import { Cloud } from 'lucide-react'
import { useCreateInternetGatewayMutation, useAttachInternetGatewayMutation } from '@/api/aws/ec2.mutations'
import { useEc2VpcsQuery } from '@/api/aws/ec2.queries'
import type { Ec2InternetGateway } from '@/api/aws/ec2.api'
type Props = {
onClose: () => void
onCreated?: (igw: Ec2InternetGateway) => void
}
export function CreateIgwModal({ onClose, onCreated }: Props) {
const [name, setName] = useState('')
const [vpcId, setVpcId] = useState('')
const [err, setErr] = useState('')
const vpcsQuery = useEc2VpcsQuery(true)
const vpcs = vpcsQuery.data ?? []
const createMut = useCreateInternetGatewayMutation()
const attachMut = useAttachInternetGatewayMutation()
const isPending = createMut.isPending || attachMut.isPending
async function handleCreate() {
setErr('')
try {
const igw = await createMut.mutateAsync(name.trim() || undefined)
if (vpcId) {
await attachMut.mutateAsync({ igwId: igw.internetGatewayId, vpcId })
}
onCreated?.(igw)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Cloud size={14} /> Create Internet Gateway
</h3>
<div className="modal-section">
<div className="field-row">
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
<input
className="input"
placeholder="my-igw"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="field-row">
<label>Attach to VPC <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
<select className="input" value={vpcId} onChange={(e) => setVpcId(e.target.value)}>
<option value="">— skip attach —</option>
{vpcs.map((v) => (
<option key={v.vpcId} value={v.vpcId}>{v.vpcId} ({v.cidrBlock})</option>
))}
</select>
</div>
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
</div>
<div className="modal-footer">
<button className="button" onClick={onClose}>Cancel</button>
<button className="button primary" disabled={isPending} onClick={() => void handleCreate()}>
{isPending ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,149 @@
import { useState } from 'react'
import { Waypoints } from 'lucide-react'
import { useCreateNatGatewayMutation } from '@/api/aws/ec2.mutations'
import { useEc2SubnetsQuery, useEc2ElasticIpsQuery } from '@/api/aws/ec2.queries'
import type { Ec2NatGateway } from '@/api/aws/ec2.api'
type Props = {
onClose: () => void
onCreated?: (nat: Ec2NatGateway) => void
}
type EipSource = 'auto' | 'existing'
export function CreateNatGwModal({ onClose, onCreated }: Props) {
const [name, setName] = useState('')
const [subnetId, setSubnetId] = useState('')
const [eipSource, setEipSource] = useState<EipSource>('auto')
const [allocationId, setAllocationId] = useState('')
const [err, setErr] = useState('')
const subnetsQuery = useEc2SubnetsQuery(undefined, true)
const subnets = subnetsQuery.data ?? []
const eipsQuery = useEc2ElasticIpsQuery(eipSource === 'existing')
// Only unattached EIPs can be bound to a NAT GW
const freeEips = (eipsQuery.data ?? []).filter((e) => !e.associationId && !e.instanceId)
const mutation = useCreateNatGatewayMutation()
async function handleCreate() {
if (!subnetId) { setErr('Select a subnet.'); return }
if (eipSource === 'existing' && !allocationId) { setErr('Select an Elastic IP.'); return }
setErr('')
try {
const nat = await mutation.mutateAsync({
name: name.trim() || undefined,
subnetId,
allocationId: eipSource === 'existing' ? allocationId : undefined,
})
onCreated?.(nat)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="create-table-modal" style={{ width: 520 }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Waypoints size={14} /> Create NAT Gateway
</h3>
<div className="modal-section">
<div className="field-row">
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
<input
className="input"
placeholder="my-nat-gateway"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="field-row">
<label>Subnet <span style={{ color: 'var(--status-error)' }}>*</span></label>
<select
className="input"
value={subnetId}
onChange={(e) => { setSubnetId(e.target.value); setErr('') }}
>
<option value="">— select subnet —</option>
{subnets.map((s) => (
<option key={s.subnetId} value={s.subnetId}>
{s.subnetId} — {s.cidrBlock} ({s.availabilityZone})
</option>
))}
</select>
</div>
</div>
<div className="modal-section">
<p className="modal-section-title">Elastic IP</p>
<div className="field-row" style={{ alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="eipSource"
checked={eipSource === 'auto'}
onChange={() => { setEipSource('auto'); setAllocationId('') }}
/>
Allocate new EIP automatically
</label>
</div>
<div className="field-row" style={{ alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="eipSource"
checked={eipSource === 'existing'}
onChange={() => setEipSource('existing')}
/>
Use existing EIP
</label>
</div>
{eipSource === 'existing' && (
<div className="field-row" style={{ marginTop: 4 }}>
<label>Allocation</label>
{eipsQuery.isLoading ? (
<p style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</p>
) : freeEips.length === 0 ? (
<p style={{ fontSize: 12, color: 'var(--status-error)' }}>No unattached EIPs. Allocate one first.</p>
) : (
<select
className="input"
value={allocationId}
onChange={(e) => { setAllocationId(e.target.value); setErr('') }}
>
<option value="">— select EIP —</option>
{freeEips.map((e) => (
<option key={e.allocationId} value={e.allocationId}>
{e.publicIp} ({e.allocationId})
</option>
))}
</select>
)}
</div>
)}
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
</div>
<div className="modal-footer">
<button className="button" onClick={onClose}>Cancel</button>
<button
className="button primary"
disabled={!subnetId || mutation.isPending}
onClick={() => void handleCreate()}
>
{mutation.isPending ? 'Creating… (this may take ~15s)' : 'Create NAT Gateway'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { useState } from 'react'
import { Router } from 'lucide-react'
import { useCreateRouteTableMutation } from '@/api/aws/ec2.mutations'
import { useEc2VpcsQuery } from '@/api/aws/ec2.queries'
import type { Ec2RouteTable } from '@/api/aws/ec2.api'
type Props = {
onClose: () => void
onCreated?: (rtb: Ec2RouteTable) => void
}
export function CreateRtbModal({ onClose, onCreated }: Props) {
const [vpcId, setVpcId] = useState('')
const [name, setName] = useState('')
const [err, setErr] = useState('')
const vpcsQuery = useEc2VpcsQuery(true)
const vpcs = vpcsQuery.data ?? []
const mutation = useCreateRouteTableMutation()
async function handleCreate() {
if (!vpcId) { setErr('Select a VPC.'); return }
setErr('')
try {
const rtb = await mutation.mutateAsync({ vpcId, name: name.trim() || undefined })
onCreated?.(rtb)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Router size={14} /> Create Route Table
</h3>
<div className="modal-section">
<div className="field-row">
<label>VPC <span style={{ color: 'var(--status-error)' }}>*</span></label>
<select
className="input"
value={vpcId}
onChange={(e) => { setVpcId(e.target.value); setErr('') }}
>
<option value="">— select VPC —</option>
{vpcs.map((v) => (
<option key={v.vpcId} value={v.vpcId}>{v.vpcId} ({v.cidrBlock})</option>
))}
</select>
</div>
<div className="field-row">
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
<input
className="input"
placeholder="my-route-table"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
</div>
<div className="modal-footer">
<button className="button" onClick={onClose}>Cancel</button>
<button className="button primary" disabled={!vpcId || mutation.isPending} onClick={() => void handleCreate()}>
{mutation.isPending ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,289 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import {
useCreateRouteMutation,
useDeleteRouteMutation,
useAssociateRouteTableMutation,
useDisassociateRouteTableMutation,
} from '@/api/aws/ec2.mutations'
import { isValidCidr } from './validators'
import type { Ec2Route, Ec2RouteTableAssociation, Ec2InternetGateway, Ec2NatGateway, Ec2Subnet } from '@/api/aws/ec2.api'
// ─── Types ────────────────────────────────────────────────────────────────────
type TargetKind = 'igw' | 'nat' | 'peering'
type Props = {
rtbId: string
routes: Ec2Route[]
associations: Ec2RouteTableAssociation[]
igws: Ec2InternetGateway[]
natGws: Ec2NatGateway[]
subnets: Ec2Subnet[]
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function targetLabel(r: Ec2Route): string {
if (r.gatewayId === 'local') return 'local'
if (r.gatewayId) return r.gatewayId
if (r.natGatewayId) return r.natGatewayId
if (r.vpcPeeringConnectionId) return r.vpcPeeringConnectionId
return '—'
}
function canDeleteRoute(r: Ec2Route): boolean {
return r.gatewayId !== 'local' && r.origin !== 'CreateRouteTable'
}
// ─── Component ───────────────────────────────────────────────────────────────
export function RouteTableEditor({ rtbId, routes, associations, igws, natGws, subnets }: Props) {
// New-route form state
const [newCidr, setNewCidr] = useState('')
const [targetKind, setTargetKind] = useState<TargetKind>('igw')
const [targetId, setTargetId] = useState('')
const [routeErr, setRouteErr] = useState('')
// New association form state
const [newSubnetId, setNewSubnetId] = useState('')
const [assocErr, setAssocErr] = useState('')
const createRouteMut = useCreateRouteMutation()
const deleteRouteMut = useDeleteRouteMutation()
const associateMut = useAssociateRouteTableMutation()
const disassociateMut = useDisassociateRouteTableMutation()
// Subnets not yet associated with this RT
const associatedSubnetIds = new Set(associations.map((a) => a.subnetId).filter(Boolean))
const availableSubnets = subnets.filter((s) => !associatedSubnetIds.has(s.subnetId))
// Targets by kind
const targets: { id: string; label: string }[] =
targetKind === 'igw'
? igws.map((g) => ({ id: g.internetGatewayId, label: g.internetGatewayId }))
: targetKind === 'nat'
? natGws.filter((n) => n.state === 'available').map((n) => ({
id: n.natGatewayId,
label: `${n.natGatewayId} (${n.publicIp ?? ''})`,
}))
: []
const cidrValid = isValidCidr(newCidr)
const isDuplicateRoute = cidrValid && routes.some((r) => r.destinationCidrBlock === newCidr.trim())
const canAddRoute = cidrValid && targetId !== '' && !isDuplicateRoute
async function handleAddRoute() {
if (!canAddRoute) return
setRouteErr('')
try {
await createRouteMut.mutateAsync({
rtbId,
input: {
destinationCidrBlock: newCidr.trim(),
gatewayId: targetKind === 'igw' ? targetId : undefined,
natGatewayId: targetKind === 'nat' ? targetId : undefined,
vpcPeeringConnectionId: targetKind === 'peering' ? targetId : undefined,
},
})
setNewCidr('')
setTargetId('')
} catch (e) {
setRouteErr(e instanceof Error ? e.message : 'Failed')
}
}
async function handleDeleteRoute(cidr: string) {
try {
await deleteRouteMut.mutateAsync({ rtbId, cidr })
} catch {
// non-blocking; RT will refresh on invalidation
}
}
async function handleAssociate() {
if (!newSubnetId) return
setAssocErr('')
try {
await associateMut.mutateAsync({ rtbId, subnetId: newSubnetId })
setNewSubnetId('')
} catch (e) {
setAssocErr(e instanceof Error ? e.message : 'Failed')
}
}
async function handleDisassociate(associationId: string) {
try {
await disassociateMut.mutateAsync(associationId)
} catch {
// non-blocking
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* ── Routes ──────────────────────────────────────────────────── */}
<div className="table-panel">
<div className="widget-header">
<h3>Routes</h3>
</div>
<table className="table">
<thead>
<tr>
<th>Destination</th>
<th>Target</th>
<th>Status</th>
<th>Origin</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{routes.map((r, i) => (
<tr key={i}>
<td className="mono" style={{ fontSize: 12 }}>{r.destinationCidrBlock ?? '—'}</td>
<td className="mono" style={{ fontSize: 12 }}>{targetLabel(r)}</td>
<td>
<span className={`status ${r.state === 'active' ? 'healthy' : 'unknown'}`}>
{r.state ?? '—'}
</span>
</td>
<td style={{ fontSize: 11, color: 'var(--text-muted)' }}>{r.origin ?? '—'}</td>
<td>
{canDeleteRoute(r) && (
<button
className="icon-btn"
title="Delete route"
disabled={deleteRouteMut.isPending}
onClick={() => void handleDeleteRoute(r.destinationCidrBlock ?? '')}
>
<Trash2 size={12} />
</button>
)}
</td>
</tr>
))}
{/* New route row */}
<tr>
<td>
<input
className="input"
style={{
fontSize: 12, padding: '2px 6px',
borderColor: newCidr && (!cidrValid || isDuplicateRoute) ? 'var(--status-error)' : undefined,
}}
placeholder="0.0.0.0/0"
value={newCidr}
onChange={(e) => { setNewCidr(e.target.value); setRouteErr('') }}
/>
</td>
<td colSpan={3} style={{ display: 'flex', gap: 4, alignItems: 'center', padding: '4px 8px' }}>
<select
className="input"
style={{ fontSize: 12, padding: '2px 6px', flex: '0 0 auto' }}
value={targetKind}
onChange={(e) => { setTargetKind(e.target.value as TargetKind); setTargetId('') }}
>
<option value="igw">Internet Gateway</option>
<option value="nat">NAT Gateway</option>
{/* VPC peering shown only if we add peering support later */}
</select>
<select
className="input"
style={{ fontSize: 12, padding: '2px 6px', flex: 1 }}
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
>
<option value="">— select target —</option>
{targets.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</td>
<td>
<button
className="icon-btn"
title="Add route"
disabled={!canAddRoute || createRouteMut.isPending}
onClick={() => void handleAddRoute()}
>
<Plus size={12} />
</button>
</td>
</tr>
</tbody>
</table>
{isDuplicateRoute && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>Route already exists</p>}
{routeErr && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>{routeErr}</p>}
</div>
{/* ── Subnet Associations ──────────────────────────────────────── */}
<div className="table-panel">
<div className="widget-header">
<h3>Subnet associations</h3>
</div>
<table className="table">
<thead>
<tr>
<th>Association ID</th>
<th>Subnet ID</th>
<th>Main</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{associations.map((a) => (
<tr key={a.associationId}>
<td className="mono" style={{ fontSize: 12 }}>{a.associationId}</td>
<td className="mono" style={{ fontSize: 12 }}>{a.subnetId ?? '—'}</td>
<td>{a.isMain ? <span className="status healthy">Yes</span> : '—'}</td>
<td>
{!a.isMain && (
<button
className="icon-btn"
title="Disassociate"
disabled={disassociateMut.isPending}
onClick={() => void handleDisassociate(a.associationId)}
>
<Trash2 size={12} />
</button>
)}
</td>
</tr>
))}
{/* Associate new subnet */}
{availableSubnets.length > 0 && (
<tr>
<td colSpan={3}>
<select
className="input"
style={{ fontSize: 12, padding: '2px 6px', width: '100%' }}
value={newSubnetId}
onChange={(e) => { setNewSubnetId(e.target.value); setAssocErr('') }}
>
<option value="">— associate a subnet —</option>
{availableSubnets.map((s) => (
<option key={s.subnetId} value={s.subnetId}>
{s.subnetId} — {s.cidrBlock}
</option>
))}
</select>
</td>
<td>
<button
className="icon-btn"
title="Associate"
disabled={!newSubnetId || associateMut.isPending}
onClick={() => void handleAssociate()}
>
<Plus size={12} />
</button>
</td>
</tr>
)}
</tbody>
</table>
{assocErr && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>{assocErr}</p>}
</div>
</div>
)
}
@@ -0,0 +1,262 @@
import { Trash2 } from "lucide-react";
import { newRule, ruleErrors } from "./sgRuleHelpers";
// ─── Types ────────────────────────────────────────────────────────────────────
export type SgRule = {
id: string;
type: string;
protocol: string;
fromPort: string;
toPort: string;
cidr: string;
description: string;
};
// ─── Presets ──────────────────────────────────────────────────────────────────
type RulePreset = {
key: string;
label: string;
protocol: string;
fromPort: string;
toPort: string;
};
const RULE_PRESETS: RulePreset[] = [
{ key: "ssh", label: "SSH", protocol: "tcp", fromPort: "22", toPort: "22" },
{ key: "http", label: "HTTP", protocol: "tcp", fromPort: "80", toPort: "80" },
{ key: "https", label: "HTTPS", protocol: "tcp", fromPort: "443", toPort: "443" },
{ key: "rdp", label: "RDP", protocol: "tcp", fromPort: "3389", toPort: "3389" },
{ key: "mysql", label: "MySQL/Aurora", protocol: "tcp", fromPort: "3306", toPort: "3306" },
{ key: "postgres", label: "PostgreSQL", protocol: "tcp", fromPort: "5432", toPort: "5432" },
{ key: "redis", label: "Redis", protocol: "tcp", fromPort: "6379", toPort: "6379" },
{ key: "all-tcp", label: "All TCP", protocol: "tcp", fromPort: "0", toPort: "65535" },
{ key: "all-udp", label: "All UDP", protocol: "udp", fromPort: "0", toPort: "65535" },
{ key: "all-icmp", label: "All ICMP", protocol: "icmp", fromPort: "-1", toPort: "-1" },
{ key: "all", label: "All traffic", protocol: "-1", fromPort: "0", toPort: "0" },
{ key: "custom-tcp", label: "Custom TCP", protocol: "tcp", fromPort: "", toPort: "" },
{ key: "custom-udp", label: "Custom UDP", protocol: "udp", fromPort: "", toPort: "" },
];
function presetFor(key: string): RulePreset | undefined {
return RULE_PRESETS.find((p) => p.key === key);
}
function portRangeDisplay(rule: SgRule): string {
if (rule.protocol === "-1") return "All";
if (rule.protocol === "icmp") return "All ICMP";
if (rule.fromPort === rule.toPort) return rule.fromPort;
return `${rule.fromPort} – ${rule.toPort}`;
}
function isCustomType(typeKey: string): boolean {
return typeKey === "custom-tcp" || typeKey === "custom-udp";
}
// ─── Component ────────────────────────────────────────────────────────────────
interface SgRuleTableProps {
rules: SgRule[];
onChange: (rules: SgRule[]) => void;
direction: "Inbound" | "Outbound";
/** Pass any truthy string to disable add/remove controls (read-only display) */
addLabel?: string;
}
export function SgRuleTable({ rules, onChange, direction, addLabel }: SgRuleTableProps) {
const readonly = Boolean(addLabel);
function addRow() {
onChange([...rules, newRule()]);
}
function removeRow(id: string) {
onChange(rules.filter((r) => r.id !== id));
}
function updateRule(id: string, patch: Partial<SgRule>) {
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
function handleTypeChange(id: string, typeKey: string) {
const preset = presetFor(typeKey);
if (!preset) return;
updateRule(id, {
type: typeKey,
protocol: preset.protocol,
fromPort: preset.fromPort,
toPort: preset.toPort,
});
}
const sourceLabel = direction === "Inbound" ? "Source" : "Destination";
return (
<div>
{rules.length === 0 ? (
<div style={{
border: "1px solid var(--border)",
borderRadius: 4,
padding: "12px 16px",
fontSize: 12,
color: "var(--text-2)",
marginBottom: 8,
}}>
No {direction.toLowerCase()} rules. Click Add rule to create one.
</div>
) : (
<div style={{
border: "1px solid var(--border)",
borderRadius: 4,
marginBottom: 8,
}}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)" }}>
<th style={thStyle}>Type</th>
<th style={thStyle}>Protocol</th>
<th style={thStyle}>Port range</th>
<th style={thStyle}>{sourceLabel}</th>
<th style={thStyle}>Description</th>
<th style={{ ...thStyle, width: 32 }} />
</tr>
</thead>
<tbody>
{rules.map((rule, idx) => {
const errs = ruleErrors(rule);
return (
<tr key={rule.id} style={{
borderBottom: idx < rules.length - 1 ? "1px solid var(--border-faint)" : "none",
}}>
{/* Type */}
<td style={tdStyle}>
{readonly ? (
<span style={{ fontSize: 12 }}>{presetFor(rule.type)?.label ?? rule.type}</span>
) : (
<select
className="input"
style={{ width: 150, fontSize: 12 }}
value={rule.type}
onChange={(e) => handleTypeChange(rule.id, e.target.value)}
>
{RULE_PRESETS.map((p) => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
</select>
)}
</td>
{/* Protocol */}
<td style={{ ...tdStyle, color: "var(--text-2)" }}>
{rule.protocol === "-1" ? "All" : rule.protocol.toUpperCase()}
</td>
{/* Port range */}
<td style={tdStyle}>
{rule.protocol === "-1" || rule.protocol === "icmp" || readonly || !isCustomType(rule.type) ? (
<span style={{ color: "var(--text-2)", fontSize: 12 }}>{portRangeDisplay(rule)}</span>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<div>
<input
className="input"
style={{ width: 52, fontSize: 12, minWidth: "unset", borderColor: errs.fromPort ? "var(--status-error)" : undefined }}
placeholder="From"
value={rule.fromPort}
onChange={(e) => updateRule(rule.id, { fromPort: e.target.value })}
title={errs.fromPort}
/>
</div>
<span style={{ color: "var(--text-2)" }}>–</span>
<div>
<input
className="input"
style={{ width: 52, fontSize: 12, minWidth: "unset", borderColor: errs.toPort ? "var(--status-error)" : undefined }}
placeholder="To"
value={rule.toPort}
onChange={(e) => updateRule(rule.id, { toPort: e.target.value })}
title={errs.toPort}
/>
</div>
</div>
)}
</td>
{/* CIDR */}
<td style={tdStyle}>
{readonly ? (
<span style={{ fontSize: 12 }}>{rule.cidr}</span>
) : (
<div>
<input
className="input"
style={{ width: 150, fontSize: 12, minWidth: "unset", borderColor: errs.cidr ? "var(--status-error)" : undefined }}
placeholder="0.0.0.0/0"
value={rule.cidr}
onChange={(e) => updateRule(rule.id, { cidr: e.target.value })}
title={errs.cidr}
/>
{errs.cidr && (
<p style={{ fontSize: 11, color: "var(--status-error)", margin: "2px 0 0", whiteSpace: "nowrap" }}>{errs.cidr}</p>
)}
</div>
)}
</td>
{/* Description */}
<td style={tdStyle}>
{readonly ? (
<span style={{ fontSize: 12, color: "var(--text-2)" }}>{rule.description}</span>
) : (
<input
className="input"
style={{ width: 150, fontSize: 12, minWidth: "unset" }}
placeholder="optional"
value={rule.description}
onChange={(e) => updateRule(rule.id, { description: e.target.value })}
/>
)}
</td>
{/* Delete */}
<td style={{ ...tdStyle, textAlign: "center" }}>
{!readonly && (
<button className="icon-btn danger" type="button" onClick={() => removeRow(rule.id)}>
<Trash2 size={13} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{!readonly && (
<button className="button" type="button" onClick={addRow} style={{ fontSize: 12 }}>
+ Add rule
</button>
)}
</div>
);
}
// ─── Cell styles ──────────────────────────────────────────────────────────────
const thStyle: React.CSSProperties = {
padding: "8px 10px",
textAlign: "left",
fontSize: 11,
fontWeight: 600,
color: "var(--text-2)",
background: "var(--surface-2)",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: "6px 10px",
verticalAlign: "middle",
};
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { Loader2, Plus, Save, Trash2 } from "lucide-react";
import { useUpdateEc2InstanceTagsMutation } from "@/api/aws/ec2.mutations";
import type { Ec2Tag } from "@/api/aws/ec2.api";
interface Props {
instanceId: string;
initialTags: Ec2Tag[];
}
export function TagEditor({ instanceId, initialTags }: Props) {
const [rows, setRows] = useState<Ec2Tag[]>(initialTags);
const [err, setErr] = useState("");
const mutation = useUpdateEc2InstanceTagsMutation();
useEffect(() => {
setRows(initialTags);
}, [initialTags]);
function addRow() {
setRows((prev) => [...prev, { key: "", value: "" }]);
}
function removeRow(idx: number) {
setRows((prev) => prev.filter((_, i) => i !== idx));
}
function setKey(idx: number, key: string) {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, key } : r)));
}
function setValue(idx: number, value: string) {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, value } : r)));
}
function handleSave() {
const newTags = rows.filter((r) => r.key.trim());
const newKeys = new Set(newTags.map((t) => t.key));
const oldKeys = new Set(initialTags.map((t) => t.key));
const toAdd = newTags.filter((t) => !oldKeys.has(t.key) || initialTags.find((ot) => ot.key === t.key)?.value !== t.value);
const toRemove = initialTags.map((t) => t.key).filter((k) => !newKeys.has(k));
setErr("");
mutation.mutate(
{ instanceId, toAdd, toRemove },
{ onError: (e) => setErr(e instanceof Error ? e.message : "Save failed.") },
);
}
return (
<div>
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}>
{rows.map((tag, idx) => (
<div key={idx} className="field-row" style={{ gap: 4 }}>
<input
className="input"
placeholder="Key"
value={tag.key}
onChange={(e) => setKey(idx, e.target.value)}
style={{ flex: 1 }}
/>
<input
className="input"
placeholder="Value"
value={tag.value}
onChange={(e) => setValue(idx, e.target.value)}
style={{ flex: 2 }}
/>
<button className="icon-btn danger" onClick={() => removeRow(idx)} title="Remove">
<Trash2 size={12} />
</button>
</div>
))}
</div>
{err && <p style={{ fontSize: 11, color: "var(--status-error)", margin: "0 0 6px" }}>{err}</p>}
<div style={{ display: "flex", gap: 6 }}>
<button className="button compact" onClick={addRow}>
<Plus size={12} />
Add tag
</button>
<button className="button compact primary" onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? <Loader2 size={12} /> : <Save size={12} />}
Save tags
</button>
</div>
</div>
);
}
@@ -0,0 +1,444 @@
import { useState, useMemo } from 'react'
import { Network, CheckCircle2, Loader2, AlertCircle, X, Plus, Trash2 } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createVpcWizard } from '@/api/aws/ec2.api'
import type { SubnetGroup, VpcWizardInput, VpcWizardResult } from '@/api/aws/ec2.api'
import { calculateSubnetCidrs } from '@/lib/network'
import { isValidCidr } from './validators'
import { ec2QueryKeys } from '@/api/aws/ec2.queries'
// ─── Types ────────────────────────────────────────────────────────────────────
type Props = {
onClose: () => void
onCreated?: (result: VpcWizardResult) => void
}
type Step = 'form' | 'creating' | 'done' | 'error'
// ─── Constants ────────────────────────────────────────────────────────────────
const AZ_OPTIONS = ['(auto)', 'us-east-1a', 'us-east-1b', 'us-east-1c', 'us-west-2a', 'us-west-2b']
const AZ_LABELS: Record<string, string> = {
'(auto)': 'auto', 'us-east-1a': '1a', 'us-east-1b': '1b', 'us-east-1c': '1c',
'us-west-2a': '2a', 'us-west-2b': '2b',
}
const DEFAULT_GROUPS: SubnetGroup[] = [
{ name: 'public', count: 2, isPublic: true },
{ name: 'private', count: 2, isPublic: false },
]
// Muted palette for private groups so they're visually distinct from each other
const PRIVATE_COLORS = ['#94a3b8', '#a78bfa', '#fb923c', '#f472b6']
// ─── Helpers ─────────────────────────────────────────────────────────────────
function ResourceRow({ label, id }: { label: string; id: string }) {
return (
<tr>
<td style={{ color: 'var(--text-muted)', paddingRight: 16, whiteSpace: 'nowrap', fontSize: 12 }}>
{label}
</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{id}</td>
</tr>
)
}
// ─── Component ───────────────────────────────────────────────────────────────
export function VpcWizardModal({ onClose, onCreated }: Props) {
const qc = useQueryClient()
// Form state
const [name, setName] = useState('')
const [cidrBlock, setCidrBlock] = useState('10.0.0.0/16')
const [groups, setGroups] = useState<SubnetGroup[]>(DEFAULT_GROUPS)
// Status
const [step, setStep] = useState<Step>('form')
const [result, setResult] = useState<VpcWizardResult | null>(null)
const [errorMsg, setErrorMsg] = useState('')
// Derived
const cidrValid = isValidCidr(cidrBlock)
const totalSubnets = groups.reduce((s, g) => s + g.count, 0)
const preview = useMemo(
() => (cidrValid && totalSubnets > 0 ? calculateSubnetCidrs(cidrBlock, groups) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[cidrBlock, cidrValid, JSON.stringify(groups)],
)
const mutation = useMutation({
mutationFn: (input: VpcWizardInput) => createVpcWizard(input),
onSuccess: (data) => {
void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs })
void qc.invalidateQueries({ queryKey: ['ec2', 'subnets'] })
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways })
void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGateways() })
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTables() })
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps })
setResult(data)
setStep('done')
onCreated?.(data)
},
onError: (err: Error) => {
setErrorMsg(err.message)
setStep('error')
},
})
function handleCreate() {
if (!cidrValid || !name.trim() || groups.length === 0) return
setStep('creating')
mutation.mutate({
name: name.trim(),
cidrBlock,
subnetGroups: groups,
natGateway: false,
})
}
// ── Group list helpers ──────────────────────────────────────────────────────
function updateGroup(idx: number, patch: Partial<SubnetGroup>) {
setGroups((prev) => prev.map((g, i) => (i === idx ? { ...g, ...patch } : g)))
}
function addGroup() {
setGroups((prev) => [...prev, { name: '', count: 1, isPublic: false }])
}
function removeGroup(idx: number) {
setGroups((prev) => prev.filter((_, i) => i !== idx))
}
const canSubmit = cidrValid && name.trim().length > 0 && groups.length > 0 &&
groups.every((g) => g.name.trim().length > 0 && g.count >= 1)
// ── Private-group color assignment ─────────────────────────────────────────
// Track a stable color per private group by their declaration order
const privateColors = useMemo(() => {
let pi = 0
return groups.map((g) => g.isPublic ? '#34d399' : PRIVATE_COLORS[pi++ % PRIVATE_COLORS.length])
}, [groups])
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="create-table-modal"
style={{ width: 860, maxWidth: '96vw' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="modal-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Network size={15} style={{ color: 'var(--accent)' }} />
<span>VPC Wizard</span>
<button className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
<X size={13} />
</button>
</div>
{/* ── Form ──────────────────────────────────────────────────────── */}
{step === 'form' && (
<>
<div style={{ display: 'flex', gap: 0 }}>
{/* Left panel — inputs */}
<div style={{ flex: '0 0 380px', padding: '16px 20px', borderRight: '1px solid var(--border)', overflowY: 'auto', maxHeight: '70vh' }}>
<div className="modal-section">
<div className="section-title">Identity</div>
<div className="field-row">
<label>Name</label>
<input
className="input"
placeholder="my-vpc"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="field-row">
<label>IPv4 CIDR block</label>
<input
className="input"
placeholder="10.0.0.0/16"
value={cidrBlock}
onChange={(e) => setCidrBlock(e.target.value)}
style={{ borderColor: cidrBlock && !cidrValid ? 'var(--status-error)' : undefined }}
/>
{cidrBlock && !cidrValid && (
<span style={{ color: 'var(--status-error)', fontSize: 11, marginTop: 2 }}>
Invalid CIDR
</span>
)}
</div>
</div>
<div className="modal-section">
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<div className="section-title" style={{ margin: 0 }}>Subnet groups</div>
<button
className="button compact"
style={{ marginLeft: 'auto', fontSize: 11 }}
onClick={addGroup}
>
<Plus size={11} /> Add group
</button>
</div>
{groups.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
No groups defined. Add at least one.
</p>
)}
{groups.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 44px 80px 54px 52px auto',
gap: 4,
padding: '0 6px',
marginBottom: 2,
}}>
{['Name', '#', 'Type', 'Mask', 'AZ', ''].map((h) => (
<span key={h} style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{h}</span>
))}
</div>
)}
{groups.map((g, idx) => (
<div
key={idx}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(60px, 1fr) 38px 70px 46px 44px auto',
gap: 4,
alignItems: 'center',
marginBottom: 4,
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 5,
padding: '5px 6px',
}}
>
{/* Name */}
<input
style={{
minWidth: 0, fontSize: 12, padding: '3px 7px',
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--text)',
}}
placeholder="group name"
value={g.name}
onChange={(e) => updateGroup(idx, { name: e.target.value })}
/>
{/* Count */}
<select
className="input"
style={{ minWidth: 0, width: '100%', fontSize: 12, padding: '2px 4px', height: 26 }}
value={g.count}
onChange={(e) => updateGroup(idx, { count: Number(e.target.value) })}
>
{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
{/* Public / Private toggle */}
<select
className="input"
style={{
minWidth: 0, width: '100%', fontSize: 11, padding: '2px 4px', height: 26,
color: g.isPublic ? '#34d399' : 'var(--text-muted)',
}}
value={g.isPublic ? 'public' : 'private'}
onChange={(e) => updateGroup(idx, { isPublic: e.target.value === 'public' })}
>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
{/* Prefix / mask */}
<select
className="input"
style={{ minWidth: 0, width: '100%', fontSize: 11, padding: '2px 2px', height: 26 }}
value={g.prefix ?? 'auto'}
onChange={(e) => updateGroup(idx, { prefix: e.target.value === 'auto' ? undefined : Number(e.target.value) })}
title="Subnet prefix length"
>
<option value="auto">auto</option>
{[20, 21, 22, 23, 24, 25, 26, 27, 28].map((p) => (
<option key={p} value={p}>/{p}</option>
))}
</select>
{/* AZ */}
<select
className="input"
style={{ minWidth: 0, width: '100%', fontSize: 11, padding: '2px 2px', height: 26 }}
value={g.az ?? '(auto)'}
onChange={(e) => updateGroup(idx, { az: e.target.value === '(auto)' ? undefined : e.target.value })}
title="Availability zone"
>
{AZ_OPTIONS.map((a) => (
<option key={a} value={a}>{AZ_LABELS[a]}</option>
))}
</select>
{/* Remove */}
<button
className="icon-btn danger"
onClick={() => removeGroup(idx)}
title="Remove group"
>
<Trash2 size={12} />
</button>
</div>
))}
{/* Validation hints */}
{groups.some((g) => !g.name.trim()) && (
<p style={{ fontSize: 11, color: 'var(--status-error)', marginTop: 4 }}>
All groups need a name.
</p>
)}
</div>
</div>
{/* Right panel — preview */}
<div style={{ flex: 1, padding: '16px 20px', background: 'var(--bg-subtle, var(--bg-secondary))', overflowY: 'auto', maxHeight: '70vh' }}>
<div className="section-title" style={{ marginBottom: 12 }}>Preview</div>
{/* VPC block */}
<div style={{
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-primary)',
padding: '10px 14px',
marginBottom: 10,
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent)', marginBottom: 4 }}>VPC</div>
<div style={{ fontFamily: 'monospace', fontSize: 12 }}>{cidrValid ? cidrBlock : '—'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
+ Internet Gateway
</div>
</div>
{/* Subnet group columns */}
{preview && preview.length > 0 ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{preview.map((grp, idx) => {
const color = privateColors[idx]
const borderColor = `${color}60`
const bgColor = `${color}10`
return (
<div key={idx} style={{ minWidth: 100, flex: '1 1 100px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color, marginBottom: 6 }}>
{grp.name || <em>unnamed</em>} ({grp.cidrs.length})
<span style={{ fontWeight: 400, marginLeft: 4, opacity: 0.7 }}>
{grp.isPublic ? '· public' : '· private'}
</span>
</div>
{grp.cidrs.map((cidr, i) => (
<div key={i} style={{
border: `1px solid ${borderColor}`,
borderRadius: 4,
padding: '5px 9px',
marginBottom: 5,
background: bgColor,
fontFamily: 'monospace',
fontSize: 11,
}}>
{cidr}
</div>
))}
</div>
)
})}
</div>
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{cidrValid ? 'Add subnet groups to preview.' : 'Enter a valid CIDR to preview.'}
</div>
)}
</div>
</div>
<div className="modal-footer">
<button className="button" onClick={onClose}>Cancel</button>
<button className="button primary" disabled={!canSubmit} onClick={handleCreate}>
Create VPC
</button>
</div>
</>
)}
{/* ── Creating ─────────────────────────────────────────────────── */}
{step === 'creating' && (
<div style={{ padding: 48, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
<Loader2 size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
<div style={{ fontSize: 13, fontWeight: 500 }}>Creating VPC infrastructure…</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
Provisioning VPC, Internet Gateway, {totalSubnets} subnet{totalSubnets !== 1 ? 's' : ''}, route tables…
</div>
</div>
)}
{/* ── Done ─────────────────────────────────────────────────────── */}
{step === 'done' && result && (
<>
<div style={{ padding: '24px 28px', maxHeight: '70vh', overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<CheckCircle2 size={18} style={{ color: '#34d399' }} />
<span style={{ fontWeight: 600 }}>VPC created successfully</span>
</div>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
<ResourceRow label="VPC" id={result.vpcId} />
<ResourceRow label="Internet Gateway" id={result.igwId} />
<ResourceRow label="Public Route Table" id={result.publicRouteTableId} />
{result.privateRouteTableId && (
<ResourceRow label="Private Route Table" id={result.privateRouteTableId} />
)}
{result.natGatewayId && (
<ResourceRow label="NAT Gateway" id={result.natGatewayId} />
)}
{result.eipAllocationId && (
<ResourceRow label="Elastic IP" id={result.eipAllocationId} />
)}
{result.subnetGroups.map((grp) =>
grp.subnetIds.map((id, i) => (
<ResourceRow
key={id}
label={`${grp.name} ${i + 1}`}
id={id}
/>
))
)}
</tbody>
</table>
</div>
<div className="modal-footer">
<button className="button primary" onClick={onClose}>Close</button>
</div>
</>
)}
{/* ── Error ────────────────────────────────────────────────────── */}
{step === 'error' && (
<>
<div style={{ padding: '24px 28px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<AlertCircle size={18} style={{ color: 'var(--status-error)' }} />
<span style={{ fontWeight: 600 }}>Creation failed</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--status-error)', background: 'var(--bg-secondary)', padding: 12, borderRadius: 4 }}>
{errorMsg}
</div>
</div>
<div className="modal-footer">
<button className="button" onClick={() => setStep('form')}>Back</button>
<button className="button" onClick={onClose}>Close</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import { isValidCidr, isValidPort } from "@/lib/network";
import type { SgRule } from "./SgRuleTable";
export function newRule(): SgRule {
return {
id: crypto.randomUUID(),
type: "custom-tcp",
protocol: "tcp",
fromPort: "",
toPort: "",
cidr: "0.0.0.0/0",
description: "",
};
}
export function ruleToPermission(r: SgRule) {
return {
protocol: r.protocol,
fromPort: r.protocol === "-1" ? 0 : (parseInt(r.fromPort) || 0),
toPort: r.protocol === "-1" ? 0 : (parseInt(r.toPort) || 0),
cidr: r.cidr,
};
}
/** Returns per-field errors for a rule: { cidr?, fromPort?, toPort? } */
export function ruleErrors(rule: SgRule): { cidr?: string; fromPort?: string; toPort?: string } {
const errs: { cidr?: string; fromPort?: string; toPort?: string } = {};
if (rule.cidr && !isValidCidr(rule.cidr)) errs.cidr = "Invalid CIDR";
if (rule.protocol !== "-1" && rule.protocol !== "icmp") {
if (rule.fromPort !== "" && !isValidPort(rule.fromPort)) errs.fromPort = "0–65535";
if (rule.toPort !== "" && !isValidPort(rule.toPort)) errs.toPort = "0–65535";
if (!errs.fromPort && !errs.toPort && rule.fromPort !== "" && rule.toPort !== "") {
if (Number(rule.fromPort) > Number(rule.toPort))
errs.fromPort = `From > To`;
}
}
return errs;
}
/** True when every rule in the table has valid CIDR and ports. */
export function allRulesValid(rules: SgRule[]): boolean {
return rules.every((r) => {
const e = ruleErrors(r);
return !e.cidr && !e.fromPort && !e.toPort;
});
}
@@ -0,0 +1,45 @@
/**
* EC2 / AWS-specific validators.
* Network-level primitives (CIDR, ports) are imported from src/lib/network.ts.
*/
import { isValidCidr, isValidPort } from '@/lib/network'
export { isValidCidr, isCidrWithinBlock, isValidPort, isValidPortRange } from '@/lib/network'
/**
* Returns a human-readable error for an SG inbound/outbound rule, or null when valid.
*/
export function validateSgRule(
protocol: string,
fromPort: string,
toPort: string,
cidr: string,
): string | null {
if (!cidr.trim()) return 'Source/destination CIDR is required.'
if (!isValidCidr(cidr.trim()))
return `"${cidr.trim()}" is not a valid CIDR (e.g. 10.0.0.0/16 or 0.0.0.0/0).`
if (protocol !== '-1' && protocol !== 'icmp') {
if (!isValidPort(fromPort))
return `From port "${fromPort}" must be an integer 0–65535.`
if (!isValidPort(toPort))
return `To port "${toPort}" must be an integer 0–65535.`
if (Number(fromPort) > Number(toPort))
return `From port (${fromPort}) cannot be greater than To port (${toPort}).`
}
return null
}
/** True when a key pair name matches AWS constraints (1–255 chars, alphanumeric/hyphens/underscores/dots). */
export function isValidKeyPairName(name: string): boolean {
return /^[a-zA-Z0-9._-]{1,255}$/.test(name.trim())
}
/** True when an AMI ID matches the expected AWS pattern (ami- + 8 or 17 hex chars). */
export function looksLikeAmiId(id: string): boolean {
return /^ami-[0-9a-f]{8}([0-9a-f]{9})?$/.test(id.trim())
}
/** True when `size` is a valid EBS volume size in GiB (1–16384). */
export function isValidVolumeSize(size: number): boolean {
return Number.isInteger(size) && size >= 1 && size <= 16384
}