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,126 @@
import {type ElementType} from 'react'
import {Cloud, Database, ExternalLink, Radio, Route, ShieldCheck} from 'lucide-react'
import {adapterLabel, cloudName, providerDescription, runtimeName} from './cloudConsoleHome.utils'
import type {ProviderBannerProps, RuntimeFlowProps, ServiceGridProps, SummarySectionProps} from './types'
export function ProviderBanner({cloud, runtimeClass, runtimeReachable, onOpenStorage}: ProviderBannerProps) {
return (
<section className={`console-provider-banner ${runtimeClass}`}>
<div>
<p className="eyebrow">Selected Cloud</p>
<h3>{cloudName(cloud)}</h3>
<p>{providerDescription(cloud)}</p>
</div>
<div className="console-provider-actions">
<button className="button primary" type="button" disabled={!runtimeReachable} onClick={onOpenStorage}>
<ExternalLink size={14}/>
Open Storage
</button>
</div>
</section>
)
}
export function SummarySection({
cloud,
runtimeLabel,
runtimeState,
runtimeClass,
runtimeDetail,
activeServices,
activeServicesDetail,
resourceCount,
resourceDetail,
}: SummarySectionProps) {
return (
<section className="console-summary">
<SummaryTile label="Cloud" value={cloud.toUpperCase()} detail={runtimeLabel} icon={Cloud}/>
<SummaryTile label="Runtime" value={runtimeState} detail={runtimeDetail} icon={Radio} state={runtimeClass}/>
<SummaryTile label="Active services" value={`${activeServices}`} detail={activeServicesDetail}/>
<SummaryTile label="Resources" value={`${resourceCount}`} detail={resourceDetail}/>
</section>
)
}
export function RuntimeFlow({cloud, status}: RuntimeFlowProps) {
return (
<section className="console-runtime-flow">
<FlowStep icon={Cloud} label="UI" value="Console Home"/>
<FlowStep icon={Route} label="Proxy" value="/api/clouds"/>
<FlowStep icon={ShieldCheck} label="Adapter" value={adapterLabel(cloud, status)}/>
<FlowStep icon={Database} label="Runtime" value={runtimeName(cloud)}/>
</section>
)
}
export function ServiceGrid({services, runtimeReachable, onNavigate, searchQuery}: ServiceGridProps) {
if (services.length === 0) {
return (
<section className="console-service-grid empty">
<p className="muted" style={{ padding: '24px', textAlign: 'center', gridColumn: '1 / -1' }}>
{searchQuery ? `No services matching "${searchQuery}"` : 'No services found.'}
</p>
</section>
)
}
return (
<section className="console-service-grid">
{services.map((service) => {
const Icon = service.icon
const isAvailable = service.status === 'available'
const canOpen = isAvailable && service.route && runtimeReachable
const content = (
<>
<div className="service-card-header">
<div className="service-icon"><Icon size={18}/></div>
<div>
<h3>{service.label}</h3>
<span className={isAvailable ? 'status healthy' : 'status unknown'}>
{isAvailable ? 'available' : 'coming soon'}
</span>
</div>
</div>
<div className="console-service-meta">
<strong>{service.count ?? '-'}</strong>
<span>{service.meta}</span>
</div>
</>
)
return canOpen ? (
<button key={service.id} className="service-card console-service-card" type="button" onClick={() => onNavigate(service.route!)}>
{content}
</button>
) : (
<div key={service.id} className={`service-card console-service-card ${isAvailable ? 'blocked' : 'offline'}`}>
{content}
</div>
)
})}
</section>
)
}
function SummaryTile({label, value, detail, icon, state}: {label: string; value: string; detail: string; icon?: ElementType; state?: string}) {
const Icon = icon
return (
<div className={`summary-tile ${state ?? ''}`}>
{Icon && <Icon size={16}/>}
<span>{label}</span>
<strong>{value}</strong>
<small>{detail}</small>
</div>
)
}
function FlowStep({icon, label, value}: {icon: ElementType; label: string; value: string}) {
const Icon = icon
return (
<div className="console-flow-step">
<Icon size={16}/>
<span>{label}</span>
<strong>{value}</strong>
</div>
)
}
@@ -0,0 +1,12 @@
/**
* Re-export shim. These queries moved to api/queries/cloudQueries so the app
* shell can use them without importing from a feature folder.
*/
export {
cloudQueryKeys as cloudConsoleHomeQueryKeys,
useCloudConsoleResourcesQuery,
useCloudServiceStatusQuery,
useCloudServicesQuery,
useCloudStatusQuery,
useCloudsQuery,
} from '@/api/queries/cloudQueries'
@@ -0,0 +1,69 @@
import type {CloudProvider, CloudStatus} from '@/types/cloud'
export function runtimeEndpointLabel(status?: CloudStatus): string {
return status?.endpoint ?? 'Unknown endpoint'
}
export function runtimeLabelFor(status: CloudStatus | undefined, loading: boolean): string {
if (loading) return 'Checking runtime'
if (!status) return 'Unknown'
if (status.runtime === 'reachable') return 'Runtime reachable'
if (status.runtime === 'unavailable') return 'Runtime unavailable'
return 'Coming soon'
}
export function runtimeClassFor(status: CloudStatus | undefined, loading: boolean): 'ready' | 'pending' | 'unavailable' {
if (loading || !status || status.runtime === 'coming_soon') return 'pending'
return status.runtime === 'reachable' ? 'ready' : 'unavailable'
}
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
if (status?.runtime === 'unavailable') return 'Start the selected runtime to load resources'
if (cloud === 'gcp') return 'Waiting for Floci-GCP runtime status'
return 'Waiting for runtime status'
}
export function resourceDetailFor(
status: CloudStatus | undefined,
statusLoading: boolean,
resourcesLoading: boolean,
resourcesError: boolean,
): string {
if (statusLoading) return 'Waiting for runtime status'
if (status?.runtime === 'unavailable') return 'Blocked until runtime is reachable'
if (status?.runtime === 'coming_soon') return 'No adapter registered yet'
if (resourcesLoading) return 'Loading normalized resources'
if (resourcesError) return 'Resource load failed'
return 'Normalized resources across available services'
}
export function serviceMetaLabel(status: CloudStatus | undefined, loading: boolean, label: string): string {
if (status?.runtime === 'unavailable') return 'runtime unavailable'
if (status?.runtime === 'coming_soon') return 'coming soon'
if (loading) return `loading ${label}`
return label
}
export function cloudName(cloud: CloudProvider): string {
if (cloud === 'aws') return 'AWS Local Runtime'
if (cloud === 'azure') return 'Azure Local Runtime'
return 'GCP Local Runtime'
}
export function providerDescription(cloud: CloudProvider): string {
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by Floci AWS Core.'
if (cloud === 'azure') return 'Storage is backed by Floci-AZ through the same normalized storage contract.'
return 'Storage is backed by Floci-GCP through the same normalized storage contract.'
}
export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string {
if (!status?.adapterRegistered) return 'Coming soon'
return `${cloud.toUpperCase()} Adapter`
}
export function runtimeName(cloud: CloudProvider): string {
if (cloud === 'aws') return 'Floci AWS Core'
if (cloud === 'azure') return 'Floci-AZ'
return 'Floci-GCP'
}
@@ -0,0 +1,43 @@
import type {ElementType} from 'react'
import type {CloudAvailability, CloudProvider, CloudStatus} from '@/types/cloud'
export interface ConsoleServiceCard {
id: string
label: string
status: CloudAvailability
count?: number
icon: ElementType
route?: string
meta: string
}
export interface ProviderBannerProps {
cloud: CloudProvider
runtimeClass: 'ready' | 'pending' | 'unavailable'
runtimeReachable: boolean
onOpenStorage: () => void
}
export interface SummarySectionProps {
cloud: CloudProvider
runtimeLabel: string
runtimeState: string
runtimeClass: 'ready' | 'pending' | 'unavailable'
runtimeDetail: string
activeServices: number
activeServicesDetail: string
resourceCount: number
resourceDetail: string
}
export interface ServiceGridProps {
services: ConsoleServiceCard[]
runtimeReachable: boolean
onNavigate: (route: string) => void
searchQuery?: string
}
export interface RuntimeFlowProps {
cloud: CloudProvider
status?: CloudStatus
}
@@ -0,0 +1,118 @@
import {useMemo} from 'react'
import {useQueries} from '@tanstack/react-query'
import {listCloudResources} from '@/api/cloudProxyClient'
import {
cloudQueryKeys,
useCloudServicesQuery,
useCloudStatusQuery,
useCloudsQuery,
} from '@/api/queries/cloudQueries'
import {serviceIcon} from '@/components/serviceIcons'
import {
resourceDetailFor,
runtimeClassFor,
runtimeDetailFor,
runtimeEndpointLabel,
runtimeLabelFor,
serviceMetaLabel,
} from './cloudConsoleHome.utils'
import type {CloudProvider} from '@/types/cloud'
import type {ConsoleServiceCard} from './types'
/**
* Console home is driven entirely by `GET /clouds/:cloud/services`.
*
* It previously hardcoded three resource queries, spliced in a Secrets Manager
* card for AWS with a hardcoded "available", and appended two permanent
* placeholder cards — so it could disagree with both the sidebar and the API.
*/
export function useCloudConsoleHomeData(cloud: CloudProvider) {
const cloudsQuery = useCloudsQuery()
const servicesQuery = useCloudServicesQuery(cloud)
const statusQuery = useCloudStatusQuery(cloud)
const status = statusQuery.data
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
// Availability is derived from adapter registration, so an available service
// always has the generic list endpoint. Route shape only decides where the
// card links, not whether it can be counted -- a legacy absolute-route page
// (AWS Secrets Manager) is still counted through its adapter.
const countable = useMemo(
() => services.filter((service) => service.availability === 'available'),
[services],
)
const runtimeReachable = status?.runtime === 'reachable'
const countQueries = useQueries({
queries: countable.map((service) => ({
queryKey: cloudQueryKeys.resources(cloud, service.service),
queryFn: ({signal}: {signal?: AbortSignal}) =>
listCloudResources(cloud, service.service, undefined, signal),
enabled: runtimeReachable,
staleTime: 30_000,
})),
})
const countsByService = useMemo(() => {
const map = new Map<string, {count?: number; isLoading: boolean; isError: boolean}>()
countable.forEach((service, index) => {
const query = countQueries[index]
map.set(service.service, {
count: query?.data?.length,
isLoading: query?.isLoading ?? false,
isError: query?.isError ?? false,
})
})
return map
}, [countable, countQueries])
const serviceCards = useMemo<ConsoleServiceCard[]>(
() =>
services.map((service): ConsoleServiceCard => {
const counts = countsByService.get(service.service)
const isLegacyPage = service.route.startsWith('/')
return {
id: service.service,
label: service.displayName,
status: service.availability,
count: counts?.count,
icon: serviceIcon(service.iconKey),
route:
service.availability === 'available'
? isLegacyPage
? service.route
: `/cloud-explorer/${cloud}/${service.route}`
: undefined,
meta:
service.availability === 'available'
? isLegacyPage
? 'open service'
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
: 'coming soon',
}
}),
[cloud, countsByService, services, status],
)
const resourcesLoading = countQueries.some((query) => query.isLoading)
const resourcesError = countQueries.some((query) => query.isError)
const resourceCount = countQueries.reduce((total, query) => total + (query.data?.length ?? 0), 0)
const activeServices = services.filter((service) => service.availability === 'available').length
return {
cloudsQuery,
status,
runtimeLabel: runtimeEndpointLabel(status),
runtimeState: runtimeLabelFor(status, statusQuery.isLoading),
runtimeClass: runtimeClassFor(status, statusQuery.isLoading),
runtimeDetail: status?.error ?? runtimeDetailFor(cloud, status),
activeServices,
// Counted rather than asserted: the old copy claimed a fixed service list.
activeServicesDetail: servicesQuery.isSuccess
? `${activeServices} of ${services.length} services available`
: 'Loading services',
resourceCount,
resourceDetail: resourceDetailFor(status, statusQuery.isLoading, resourcesLoading, resourcesError),
serviceCards,
}
}
@@ -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
}
@@ -0,0 +1,310 @@
import { useEffect, useMemo, useState } from "react";
import {
Boxes,
Info,
RefreshCw,
Server,
ShieldCheck,
Tags,
} from "lucide-react";
import { EmptyState } from "@/components/EmptyState";
import type { EksCluster, EksNodegroup } from "@/api/aws/eks.api";
import {
useEksClustersQuery,
useEksNodegroupsQuery,
} from "@/api/aws/eks.queries";
function statusClass(status?: string) {
const normalized = status?.toLowerCase();
if (normalized === "active") return "healthy";
if (normalized === "creating" || normalized === "updating") return "degraded";
if (normalized === "failed" || normalized === "deleting") return "unavailable";
return "unknown";
}
function formatDate(value?: string) {
if (!value) return "—";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function yesNo(value?: boolean) {
if (value === undefined) return "—";
return value ? "Enabled" : "Disabled";
}
function ClusterListItem({
cluster,
active,
onSelect,
}: {
cluster: EksCluster;
active: boolean;
onSelect: () => void;
}) {
return (
<button
className={`list-item ${active ? "active" : ""}`}
onClick={onSelect}
type="button"
>
<strong>{cluster.name}</strong>
<span>
Kubernetes {cluster.version ?? "—"} · {cluster.nodegroupCount ?? 0} nodegroups
</span>
</button>
);
}
function ClusterSummary({ cluster }: { cluster: EksCluster }) {
const vpc = cluster.resourcesVpcConfig;
return (
<div className="grid two">
<div className="widget">
<div className="widget-header">
<Boxes size={13} color="var(--accent)" />
<h3>Cluster</h3>
</div>
<div className="widget-body">
<div className="meta-grid">
<Meta label="Status" value={cluster.status ?? "unknown"} />
<Meta label="Kubernetes version" value={cluster.version ?? "—"} />
<Meta label="Platform version" value={cluster.platformVersion ?? "—"} />
<Meta label="Created" value={formatDate(cluster.createdAt)} />
<Meta label="Endpoint" value={cluster.endpoint ?? "—"} />
<Meta label="Role ARN" value={cluster.roleArn ?? "—"} />
</div>
</div>
</div>
<div className="widget">
<div className="widget-header">
<ShieldCheck size={13} color="var(--accent)" />
<h3>Networking</h3>
</div>
<div className="widget-body">
<div className="meta-grid">
<Meta label="VPC" value={vpc?.vpcId ?? "—"} />
<Meta label="Public endpoint" value={yesNo(vpc?.endpointPublicAccess)} />
<Meta label="Private endpoint" value={yesNo(vpc?.endpointPrivateAccess)} />
<Meta label="Subnets" value={String(vpc?.subnetIds.length ?? 0)} />
<Meta
label="Security groups"
value={String(vpc?.securityGroupIds.length ?? 0)}
/>
<Meta
label="Public CIDRs"
value={vpc?.publicAccessCidrs.join(", ") || "—"}
/>
</div>
</div>
</div>
</div>
);
}
function ClusterTags({ tags }: { tags: Record<string, string> }) {
const entries = Object.entries(tags);
return (
<div className="widget section-space">
<div className="widget-header">
<Tags size={13} color="var(--accent)" />
<h3>Tags</h3>
<span className="badge neutral">{entries.length}</span>
</div>
<div className="widget-body">
{entries.length === 0 ? (
<p className="muted compact-text">No tags returned for this cluster.</p>
) : (
<div className="metadata-tags">
{entries.map(([key, value]) => (
<span className="metadata-tag" key={`${key}:${value}`}>
<strong>{key}</strong>
<span>{value}</span>
</span>
))}
</div>
)}
</div>
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="meta-row">
<span className="meta-label">{label}</span>
<span className="meta-value">{value}</span>
</div>
);
}
function NodegroupTable({ nodegroups }: { nodegroups: EksNodegroup[] }) {
if (nodegroups.length === 0) {
return (
<EmptyState
icon={Server}
title="No nodegroups"
description="This cluster did not return any managed nodegroups."
/>
);
}
return (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Capacity</th>
<th>Instances</th>
<th>Scaling</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{nodegroups.map((nodegroup) => (
<tr key={nodegroup.arn ?? nodegroup.name}>
<td className="mono">{nodegroup.name}</td>
<td>
<span className={`status ${statusClass(nodegroup.status)}`}>
{nodegroup.status ?? "unknown"}
</span>
</td>
<td>{nodegroup.capacityType ?? "—"}</td>
<td className="mono">{nodegroup.instanceTypes.join(", ") || "—"}</td>
<td className="mono">
{nodegroup.scalingConfig
? `${nodegroup.scalingConfig.minSize ?? "?"}/${nodegroup.scalingConfig.desiredSize ?? "?"}/${nodegroup.scalingConfig.maxSize ?? "?"}`
: "—"}
</td>
<td>{formatDate(nodegroup.modifiedAt ?? nodegroup.createdAt)}</td>
</tr>
))}
</tbody>
</table>
);
}
export function EKSPage() {
const clustersQuery = useEksClustersQuery();
const clusters = useMemo(() => clustersQuery.data ?? [], [clustersQuery.data]);
const [selectedClusterName, setSelectedClusterName] = useState<string | null>(null);
useEffect(() => {
if (!selectedClusterName && clusters[0]) {
setSelectedClusterName(clusters[0].name);
}
}, [clusters, selectedClusterName]);
const selectedCluster = useMemo(
() => clusters.find((cluster) => cluster.name === selectedClusterName) ?? null,
[clusters, selectedClusterName],
);
const nodegroupsQuery = useEksNodegroupsQuery(selectedClusterName);
return (
<>
<div className="page-header">
<div className="page-title">
<h1>EKS</h1>
<span className="info-link">
<Info size={11} />
Kubernetes clusters
</span>
</div>
<button
className="button"
onClick={() => {
void clustersQuery.refetch();
void nodegroupsQuery.refetch();
}}
type="button"
>
<RefreshCw size={13} />
Refresh
</button>
</div>
<div className="split">
<aside className="list-pane">
<div className="widget-header">
<Boxes size={13} color="var(--text-2)" />
<h3>Clusters ({clusters.length})</h3>
</div>
{clustersQuery.isLoading ? (
<div className="empty compact"><p>Loading clusters...</p></div>
) : clustersQuery.isError ? (
<EmptyState
icon={Boxes}
title="Cannot load clusters"
description="EKS did not respond from the Floci endpoint."
/>
) : clusters.length === 0 ? (
<EmptyState
icon={Boxes}
title="No EKS clusters"
description="No Kubernetes clusters were returned by Floci."
/>
) : (
clusters.map((cluster) => (
<ClusterListItem
key={cluster.arn ?? cluster.name}
cluster={cluster}
active={selectedClusterName === cluster.name}
onSelect={() => setSelectedClusterName(cluster.name)}
/>
))
)}
</aside>
<section className="detail-pane">
{!selectedCluster ? (
<EmptyState
icon={Boxes}
title="Select a cluster"
description="Choose an EKS cluster to inspect its networking and nodegroups."
/>
) : (
<div className="content">
<div className="page-title" style={{ marginBottom: 16 }}>
<Boxes size={18} color="var(--accent)" />
<h2>{selectedCluster.name}</h2>
<span className={`status ${statusClass(selectedCluster.status)}`}>
{selectedCluster.status ?? "unknown"}
</span>
</div>
<ClusterSummary cluster={selectedCluster} />
<ClusterTags tags={selectedCluster.tags} />
<div className="table-panel section-space">
<div className="widget-header">
<Server size={13} color="var(--text-2)" />
<h3>Managed nodegroups</h3>
</div>
{nodegroupsQuery.isLoading ? (
<div className="empty compact"><p>Loading nodegroups...</p></div>
) : nodegroupsQuery.isError ? (
<EmptyState
icon={Server}
title="Cannot load nodegroups"
description="EKS did not return nodegroup details for this cluster."
/>
) : (
<NodegroupTable nodegroups={nodegroupsQuery.data ?? []} />
)}
</div>
</div>
)}
</section>
</div>
</>
);
}
@@ -0,0 +1,347 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useState} from 'react'
import {Plus, Trash2} from 'lucide-react'
import {
createKubernetesFargateProfile,
createKubernetesNodegroup,
deleteKubernetesFargateProfile,
deleteKubernetesNodegroup,
listKubernetesFargateProfiles,
listKubernetesNodegroups,
} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {
type CreateKubernetesFargateProfileInput,
type CreateKubernetesNodegroupInput,
type KubernetesFargateProfile,
type KubernetesNodegroup,
} from '@/types/resource'
import type {CloudProvider} from '@/types/cloud'
interface K8sEngineDetailsProps {
cloud: CloudProvider
clusterName: string
}
export function K8sEngineDetails({cloud, clusterName}: K8sEngineDetailsProps) {
if (cloud !== 'aws') {
return (
<section className="inspector-section">
<p className="metric-label">k8s Engine Capabilities</p>
<p className="muted compact-text">Nodegroups and Fargate profiles are currently wired for AWS EKS.</p>
</section>
)
}
return (
<>
<EksNodegroupsSection cloud={cloud} clusterName={clusterName}/>
<EksFargateProfilesSection cloud={cloud} clusterName={clusterName}/>
</>
)
}
function EksNodegroupsSection({cloud, clusterName}: {cloud: CloudProvider; clusterName: string}) {
const queryClient = useQueryClient()
const queryKey = ['k8s', cloud, clusterName, 'nodegroups'] as const
const nodegroupsQuery = useQuery({
queryKey,
queryFn: ({signal}) => listKubernetesNodegroups(cloud, clusterName, signal),
refetchInterval: 30_000,
})
const createNodegroup = useMutation({
mutationFn: (input: CreateKubernetesNodegroupInput) => createKubernetesNodegroup(cloud, clusterName, input),
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
})
const deleteNodegroup = useMutation({
mutationFn: (nodegroupId: string) => deleteKubernetesNodegroup(cloud, clusterName, nodegroupId),
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
})
const [deleteCandidate, setDeleteCandidate] = useState<string | null>(null)
const [form, setForm] = useState({
name: '',
nodeRole: '',
subnets: '',
instanceTypes: 't3.medium',
minSize: '1',
desiredSize: '1',
maxSize: '2',
})
const canCreate = form.name.trim() && form.nodeRole.trim() && parseCsv(form.subnets).length > 0
return (
<section className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">Managed Nodegroups</p>
<button className="button compact" type="button" onClick={() => nodegroupsQuery.refetch()}>
Refresh
</button>
</div>
<form
className="inspector-action-form"
onSubmit={(event) => {
event.preventDefault()
if (!canCreate) return
createNodegroup.mutate({
name: form.name.trim(),
nodeRole: form.nodeRole.trim(),
subnets: parseCsv(form.subnets),
instanceTypes: parseCsv(form.instanceTypes),
scalingConfig: {
minSize: Number(form.minSize),
desiredSize: Number(form.desiredSize),
maxSize: Number(form.maxSize),
},
})
}}
>
<input className="input" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} placeholder="Nodegroup name"/>
<input className="input" value={form.nodeRole} onChange={(event) => setForm({...form, nodeRole: event.target.value})} placeholder="Node role ARN"/>
<input className="input" value={form.subnets} onChange={(event) => setForm({...form, subnets: event.target.value})} placeholder="subnet-a, subnet-b"/>
<input className="input" value={form.instanceTypes} onChange={(event) => setForm({...form, instanceTypes: event.target.value})} placeholder="t3.medium"/>
<input className="input" type="number" min="0" value={form.minSize} onChange={(event) => setForm({...form, minSize: event.target.value})} placeholder="Min"/>
<input className="input" type="number" min="0" value={form.desiredSize} onChange={(event) => setForm({...form, desiredSize: event.target.value})} placeholder="Desired"/>
<input className="input" type="number" min="1" value={form.maxSize} onChange={(event) => setForm({...form, maxSize: event.target.value})} placeholder="Max"/>
<button className="button compact" type="submit" disabled={!canCreate || createNodegroup.isPending}>
<Plus size={13}/>
{createNodegroup.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createNodegroup.isError && <p className="error-text compact-text">{errorMessage(createNodegroup.error)}</p>}
{deleteNodegroup.isError && <p className="error-text compact-text">{errorMessage(deleteNodegroup.error)}</p>}
<NodegroupsList
deleteCandidate={deleteCandidate}
deletingName={deleteNodegroup.variables}
isError={nodegroupsQuery.isError}
isLoading={nodegroupsQuery.isLoading}
nodegroups={nodegroupsQuery.data ?? []}
onCancelDelete={() => setDeleteCandidate(null)}
onConfirmDelete={(nodegroupName) => {
deleteNodegroup.mutate(nodegroupName)
setDeleteCandidate(null)
}}
onRequestDelete={setDeleteCandidate}
/>
</section>
)
}
function EksFargateProfilesSection({cloud, clusterName}: {cloud: CloudProvider; clusterName: string}) {
const queryClient = useQueryClient()
const queryKey = ['k8s', cloud, clusterName, 'fargate-profiles'] as const
const profilesQuery = useQuery({
queryKey,
queryFn: ({signal}) => listKubernetesFargateProfiles(cloud, clusterName, signal),
refetchInterval: 30_000,
})
const createProfile = useMutation({
mutationFn: (input: CreateKubernetesFargateProfileInput) => createKubernetesFargateProfile(cloud, clusterName, input),
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
})
const deleteProfile = useMutation({
mutationFn: (profileId: string) => deleteKubernetesFargateProfile(cloud, clusterName, profileId),
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
})
const [deleteCandidate, setDeleteCandidate] = useState<string | null>(null)
const [form, setForm] = useState({
name: '',
roleArn: '',
namespace: 'default',
labels: '',
subnets: '',
})
const canCreate = form.name.trim() && form.roleArn.trim() && form.namespace.trim()
return (
<section className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">Fargate Profiles</p>
<button className="button compact" type="button" onClick={() => profilesQuery.refetch()}>
Refresh
</button>
</div>
<form
className="inspector-action-form"
onSubmit={(event) => {
event.preventDefault()
if (!canCreate) return
createProfile.mutate({
name: form.name.trim(),
podExecutionRoleArn: form.roleArn.trim(),
subnets: parseCsv(form.subnets),
selectors: [{
namespace: form.namespace.trim(),
labels: parseKeyValueCsv(form.labels),
}],
})
}}
>
<input className="input" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} placeholder="Profile name"/>
<input className="input" value={form.roleArn} onChange={(event) => setForm({...form, roleArn: event.target.value})} placeholder="Pod execution role ARN"/>
<input className="input" value={form.namespace} onChange={(event) => setForm({...form, namespace: event.target.value})} placeholder="Namespace"/>
<input className="input" value={form.labels} onChange={(event) => setForm({...form, labels: event.target.value})} placeholder="app=api, tier=backend"/>
<input className="input" value={form.subnets} onChange={(event) => setForm({...form, subnets: event.target.value})} placeholder="Optional subnets"/>
<button className="button compact" type="submit" disabled={!canCreate || createProfile.isPending}>
<Plus size={13}/>
{createProfile.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createProfile.isError && <p className="error-text compact-text">{errorMessage(createProfile.error)}</p>}
{deleteProfile.isError && <p className="error-text compact-text">{errorMessage(deleteProfile.error)}</p>}
<FargateProfilesList
deleteCandidate={deleteCandidate}
deletingName={deleteProfile.variables}
isError={profilesQuery.isError}
isLoading={profilesQuery.isLoading}
profiles={profilesQuery.data ?? []}
onCancelDelete={() => setDeleteCandidate(null)}
onConfirmDelete={(profileName) => {
deleteProfile.mutate(profileName)
setDeleteCandidate(null)
}}
onRequestDelete={setDeleteCandidate}
/>
</section>
)
}
function NodegroupsList({
nodegroups,
isLoading,
isError,
deletingName,
deleteCandidate,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: {
nodegroups: KubernetesNodegroup[]
isLoading: boolean
isError: boolean
deletingName?: string
deleteCandidate: string | null
onRequestDelete: (nodegroupName: string) => void
onConfirmDelete: (nodegroupName: string) => void
onCancelDelete: () => void
}) {
if (isLoading) return <p className="muted compact-text">Loading nodegroups.</p>
if (isError) return <p className="error-text compact-text">Failed to load nodegroups.</p>
if (nodegroups.length === 0) return <p className="muted compact-text">No managed nodegroups returned for this cluster.</p>
return (
<div className="snapshot-list">
{nodegroups.map((nodegroup) => (
<div className="snapshot-row" key={nodegroup.arn ?? nodegroup.name}>
<div>
<strong>{nodegroup.name}</strong>
<span>{nodegroup.instanceTypes.join(', ') || 'No instance types'} · {formatScaling(nodegroup)}</span>
</div>
{deleteCandidate === nodegroup.name ? (
<span className="inline-actions">
<button className="button compact danger" type="button" onClick={() => onConfirmDelete(nodegroup.name)}>Delete</button>
<button className="button compact" type="button" onClick={onCancelDelete}>Cancel</button>
</span>
) : (
<button
aria-label={`Delete ${nodegroup.name}`}
className="icon-btn danger"
type="button"
disabled={deletingName === nodegroup.name}
onClick={() => onRequestDelete(nodegroup.name)}
>
<Trash2 size={13}/>
</button>
)}
</div>
))}
</div>
)
}
function FargateProfilesList({
profiles,
isLoading,
isError,
deletingName,
deleteCandidate,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: {
profiles: KubernetesFargateProfile[]
isLoading: boolean
isError: boolean
deletingName?: string
deleteCandidate: string | null
onRequestDelete: (profileName: string) => void
onConfirmDelete: (profileName: string) => void
onCancelDelete: () => void
}) {
if (isLoading) return <p className="muted compact-text">Loading Fargate profiles.</p>
if (isError) return <p className="error-text compact-text">Failed to load Fargate profiles.</p>
if (profiles.length === 0) return <p className="muted compact-text">No Fargate profiles returned for this cluster.</p>
return (
<div className="snapshot-list">
{profiles.map((profile) => (
<div className="snapshot-row" key={profile.arn ?? profile.name}>
<div>
<strong>{profile.name}</strong>
<span>{profile.selectors.map((selector) => selector.namespace ?? '*').join(', ') || 'No selectors'}</span>
</div>
{deleteCandidate === profile.name ? (
<span className="inline-actions">
<button className="button compact danger" type="button" onClick={() => onConfirmDelete(profile.name)}>Delete</button>
<button className="button compact" type="button" onClick={onCancelDelete}>Cancel</button>
</span>
) : (
<button
aria-label={`Delete ${profile.name}`}
className="icon-btn danger"
type="button"
disabled={deletingName === profile.name}
onClick={() => onRequestDelete(profile.name)}
>
<Trash2 size={13}/>
</button>
)}
</div>
))}
</div>
)
}
function parseCsv(value: string) {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
function parseKeyValueCsv(value: string) {
return Object.fromEntries(
parseCsv(value)
.map((part) => part.split('='))
.filter(([key, val]) => key?.trim() && val?.trim())
.map(([key, val]) => [key.trim(), val.trim()]),
)
}
function formatScaling(nodegroup: KubernetesNodegroup) {
const scaling = nodegroup.scalingConfig
if (!scaling) return 'No scaling config'
return `${scaling.minSize ?? '?'}/${scaling.desiredSize ?? '?'}/${scaling.maxSize ?? '?'}`
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
return 'The EKS operation failed.'
}
function invalidateK8sQueries(queryClient: ReturnType<typeof useQueryClient>, cloud: CloudProvider, clusterName: string) {
void queryClient.invalidateQueries({queryKey: ['k8s', cloud, clusterName]})
void queryClient.invalidateQueries({queryKey: ['cloud-resources', cloud, 'k8s']})
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources(cloud, 'k8s')})
}
@@ -0,0 +1,19 @@
import {Activity, Info} from 'lucide-react'
import {EmptyState} from '@/components/EmptyState'
export function PlaceholderPage({title, description}: { title: string; description: string }) {
return (
<>
<div className="page-header">
<div className="page-title">
<h1>{title}</h1>
<span className="info-link">
<Info size={11}/>
Info
</span>
</div>
</div>
<EmptyState icon={Activity} title={`${title} is not available yet`} description={description}/>
</>
)
}
@@ -0,0 +1,774 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Check,
Copy,
Eye,
EyeOff,
Info,
KeyRound,
Loader2,
Plus,
RefreshCw,
Save,
Search,
Trash2,
X,
} from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import type { SecretSummary } from '@/api/aws/secretsmanager.api'
import {
secretsManagerQueryKeys,
useSecretDetailQuery,
useSecretsQuery,
useSecretValueQuery,
} from '@/api/aws/secretsmanager.queries'
import {
useCreateSecretMutation,
useDeleteSecretMutation,
usePutSecretValueMutation,
} from '@/api/aws/secretsmanager.mutations'
import { timeAgo } from '@/lib/utils'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
type SecretKeyValueEntry = {
id: string
key: string
value: string
kind: JsonValueKind
}
type SerializedKeyValueEntries =
| { secretString: string }
| { error: string }
function valueKind(value: JsonValue): JsonValueKind {
if (value === null) return 'null'
if (Array.isArray(value) || typeof value === 'object') return 'json'
if (typeof value === 'number') return 'number'
if (typeof value === 'boolean') return 'boolean'
return 'string'
}
function valueText(value: JsonValue): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
function parseKeyValueEntries(secretString: string): SecretKeyValueEntry[] | null {
try {
const parsed: unknown = JSON.parse(secretString)
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') return null
return Object.entries(parsed as Record<string, JsonValue>).map(([key, value], index) => ({
id: `${index}-${key}`,
key,
value: valueText(value),
kind: valueKind(value),
}))
} catch {
return null
}
}
function parseEntryValue(entry: SecretKeyValueEntry): JsonValue | undefined {
if (entry.kind === 'string') return entry.value
if (entry.kind === 'number') {
const value = Number(entry.value)
return Number.isFinite(value) ? value : undefined
}
if (entry.kind === 'boolean') {
if (entry.value === 'true') return true
if (entry.value === 'false') return false
return undefined
}
try {
const value: unknown = JSON.parse(entry.value)
if (entry.kind === 'null') return value === null ? null : undefined
return value as JsonValue
} catch {
return undefined
}
}
function serializeKeyValueEntries(entries: SecretKeyValueEntry[]): SerializedKeyValueEntries {
const output: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>
for (const entry of entries) {
const key = entry.key
if (!key) return { error: 'Every entry needs a key.' }
if (Object.prototype.hasOwnProperty.call(output, key)) return { error: `Duplicate key: ${key}` }
const value = parseEntryValue(entry)
if (value === undefined) {
return { error: `Invalid ${entry.kind} value for ${key}.` }
}
output[key] = value
}
return { secretString: JSON.stringify(output, null, 2) }
}
// ─── Create secret form ─────────────────────────────────────────────────────────
function CreateSecretForm({ onClose }: { onClose: () => void }) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [secretString, setSecretString] = useState('')
const [err, setErr] = useState('')
const createMut = useCreateSecretMutation({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
onClose()
},
onError: (e) => setErr(e instanceof Error ? e.message : 'Create failed'),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return setErr('Secret name is required')
if (!secretString) return setErr('Secret value is required')
setErr('')
createMut.mutate({ name: name.trim(), secretString, description: description.trim() || undefined })
}
return (
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14, background: 'var(--raised)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 12 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
<strong style={{ fontSize: 13 }}>Create secret</strong>
<button type="button" className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
<X size={14} />
</button>
</div>
<input
className="input"
placeholder="Secret name (e.g. prod/db/password)"
value={name}
onChange={(e) => { setName(e.target.value); setErr('') }}
autoFocus
/>
<input
className="input"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<textarea
className="json-editor"
style={{ minHeight: 100 }}
placeholder={'Secret value — plaintext or JSON, e.g.\n{\n "username": "admin",\n "password": "…"\n}'}
value={secretString}
onChange={(e) => { setSecretString(e.target.value); setErr('') }}
spellCheck={false}
/>
{err && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{err}</span>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="button" onClick={onClose}>Cancel</button>
<button type="submit" className="button primary" disabled={createMut.isPending}>
{createMut.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}
Create
</button>
</div>
</form>
)
}
// ─── Secret detail drawer ─────────────────────────────────────────────────────────
function SecretDrawer({
secretId,
onClose,
onDeleted,
}: {
secretId: string | null
onClose: () => void
onDeleted: () => void
}) {
const qc = useQueryClient()
const [tab, setTab] = useState<'details' | 'value'>('details')
const [revealed, setRevealed] = useState(false)
const [editing, setEditing] = useState(false)
const [draftValue, setDraftValue] = useState('')
const [valueViewMode, setValueViewMode] = useState<'key-value' | 'raw'>('raw')
const [editorMode, setEditorMode] = useState<'key-value' | 'raw'>('raw')
const [keyValueEntries, setKeyValueEntries] = useState<SecretKeyValueEntry[]>([])
const [editorError, setEditorError] = useState('')
const [copied, setCopied] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [forceDelete, setForceDelete] = useState(false)
// Reset transient state when the selected secret changes (via key prop), and
// evict any fetched plaintext from the cache so it never outlives this view.
useEffect(() => {
setTab('details')
setRevealed(false)
setEditing(false)
setDraftValue('')
setValueViewMode('raw')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
setCopied(false)
setDeleteConfirm(false)
setForceDelete(false)
return () => {
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}
}, [secretId, qc])
// Auto-hide when the user leaves the Value tab. Flipping `revealed` is not
// enough — the fetched plaintext stays in the React Query cache (readable via
// DevTools/other components) until its entry is evicted, so drop it here and
// discard any in-progress edit as well.
useEffect(() => {
if (tab === 'value') return
setRevealed(false)
setEditing(false)
setDraftValue('')
setValueViewMode('raw')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}, [tab, secretId, qc])
const detailQuery = useSecretDetailQuery(secretId)
// The plaintext value is only fetched once the user explicitly reveals it.
const valueQuery = useSecretValueQuery(secretId, revealed && tab === 'value')
const putMut = usePutSecretValueMutation({
onSuccess: () => {
setEditing(false)
setDraftValue('')
},
onError: (e) => alert(`Update failed: ${e instanceof Error ? e.message : e}`),
})
const deleteMut = useDeleteSecretMutation({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
onDeleted()
},
onError: (e) => alert(`Delete failed: ${e instanceof Error ? e.message : e}`),
})
const detail = detailQuery.data
const value = valueQuery.data
// A binary secret has no SecretString. The API only writes SecretString, so
// editing one here would overwrite the binary value with text — block it.
const isBinary = Boolean(value && value.secretString === undefined && value.secretBinary !== undefined)
const valueEntries = value?.secretString === undefined ? null : parseKeyValueEntries(value.secretString)
useEffect(() => {
if (!revealed || tab !== 'value' || editing) return
setValueViewMode(parseKeyValueEntries(value?.secretString ?? '') ? 'key-value' : 'raw')
}, [editing, revealed, tab, value?.secretString])
// Hiding must evict the fetched plaintext from the cache, not merely flip the
// reveal flag — otherwise the value lingers in memory (DevTools, extensions,
// other components) after the user hides it.
function hideValue() {
setRevealed(false)
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}
function cancelEditing() {
setEditing(false)
setDraftValue('')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
}
function startEditing() {
if (isBinary) return
const secretString = value?.secretString ?? ''
const entries = parseKeyValueEntries(secretString)
setDraftValue(secretString)
setKeyValueEntries(entries ?? [])
setEditorMode(valueViewMode === 'key-value' && entries ? 'key-value' : 'raw')
setEditorError('')
setEditing(true)
}
function switchEditorMode(mode: 'key-value' | 'raw') {
if (mode === editorMode) return
if (mode === 'key-value') {
const entries = parseKeyValueEntries(draftValue)
if (!entries) {
setEditorError('Key-value editing is available only for a JSON object.')
return
}
setKeyValueEntries(entries)
setEditorError('')
setEditorMode('key-value')
return
}
const serialized = serializeKeyValueEntries(keyValueEntries)
if ('error' in serialized) {
setEditorError(serialized.error)
return
}
setDraftValue(serialized.secretString)
setEditorError('')
setEditorMode('raw')
}
function updateKeyValueEntry(id: string, field: 'key' | 'value', nextValue: string) {
setKeyValueEntries((entries) => entries.map((entry) => (
entry.id === id ? { ...entry, [field]: nextValue } : entry
)))
setEditorError('')
}
function addKeyValueEntry() {
setKeyValueEntries((entries) => [
...entries,
{ id: crypto.randomUUID(), key: '', value: '', kind: 'string' },
])
setEditorError('')
}
function removeKeyValueEntry(id: string) {
setKeyValueEntries((entries) => entries.filter((entry) => entry.id !== id))
setEditorError('')
}
function saveValue() {
let secretString = draftValue
if (editorMode === 'key-value') {
const serialized = serializeKeyValueEntries(keyValueEntries)
if ('error' in serialized) {
setEditorError(serialized.error)
return
}
secretString = serialized.secretString
}
if (!secretString) {
setEditorError('Secret value is required.')
return
}
putMut.mutate({ id: secretId!, secretString })
}
async function copyValue() {
const text = value?.secretString ?? value?.secretBinary
if (text === undefined || text === null) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Clipboard access can be denied; silently ignore.
}
}
return (
<div className={`tag-drawer ${secretId ? 'open' : ''}`} style={{ width: 440 }}>
<div className="tag-drawer-header">
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
<h3 title={detail?.name ?? secretId ?? ''}>{detail?.name ?? secretId}</h3>
<button className="icon-btn" onClick={onClose} aria-label="Close" title="Close"><X size={14} aria-hidden="true" /></button>
</div>
<div className="drawer-tabs">
<button className={`drawer-tab ${tab === 'details' ? 'active' : ''}`} onClick={() => setTab('details')}>
Details
</button>
<button className={`drawer-tab ${tab === 'value' ? 'active' : ''}`} onClick={() => setTab('value')}>
Value
</button>
</div>
<div className="tag-drawer-body">
{/* ── Details tab ── */}
{tab === 'details' && (
detailQuery.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
<Loader2 size={14} className="spin" /> Loading details…
</div>
) : detailQuery.isError ? (
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret details.</p>
) : detail ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="badge" style={{ background: 'rgba(34,197,94,0.14)', color: 'var(--status-ok)' }}>
{detail.rotationEnabled ? 'Rotation enabled' : 'Active'}
</span>
{detail.versionIds.length > 0 && (
<span className="badge" style={{ background: 'rgba(107,114,128,0.14)', color: 'var(--text-3)' }}>
{detail.versionIds.length} version{detail.versionIds.length !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="meta-grid">
{detail.arn && (
<div className="meta-row">
<span className="meta-label">ARN</span>
<span className="meta-value" style={{ fontSize: 11 }}>{detail.arn}</span>
</div>
)}
{detail.description && (
<div className="meta-row">
<span className="meta-label">Description</span>
<span className="meta-value" style={{ fontFamily: 'inherit', color: 'var(--text-3)' }}>{detail.description}</span>
</div>
)}
{detail.kmsKeyId && (
<div className="meta-row">
<span className="meta-label">KMS key</span>
<span className="meta-value" style={{ fontSize: 11 }}>{detail.kmsKeyId}</span>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{detail.createdDate && (
<div className="meta-row">
<span className="meta-label">Created</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.createdDate)}</span>
</div>
)}
{detail.lastChangedDate && (
<div className="meta-row">
<span className="meta-label">Last changed</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastChangedDate)}</span>
</div>
)}
{detail.lastAccessedDate && (
<div className="meta-row">
<span className="meta-label">Last accessed</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastAccessedDate)}</span>
</div>
)}
</div>
</div>
{/* Tags */}
{detail.tags.length > 0 && (
<div>
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>
Tags ({detail.tags.length})
</p>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{detail.tags.map((tag, i) => (
<div
key={tag.key}
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', borderBottom: i < detail.tags.length - 1 ? '1px solid var(--border)' : undefined }}
>
<div style={{ padding: '6px 8px', borderRight: '1px solid var(--border)', fontSize: 12, fontFamily: 'monospace', color: 'var(--status-warn)' }}>{tag.key}</div>
<div style={{ padding: '6px 8px', fontSize: 12, fontFamily: 'monospace', color: 'var(--text-2)' }} title={tag.value}>{tag.value}</div>
</div>
))}
</div>
</div>
)}
</div>
) : null
)}
{/* ── Value tab ── */}
{tab === 'value' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!revealed && !editing ? (
<button className="button" onClick={() => setRevealed(true)} style={{ alignSelf: 'flex-start' }}>
<Eye size={13} />
Reveal secret value
</button>
) : editing ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: 0 }}>
New secret value
</p>
<div className="drawer-tabs" style={{ marginLeft: 'auto' }}>
<button
className={`drawer-tab ${editorMode === 'key-value' ? 'active' : ''}`}
onClick={() => switchEditorMode('key-value')}
>
Key-value
</button>
<button
className={`drawer-tab ${editorMode === 'raw' ? 'active' : ''}`}
onClick={() => switchEditorMode('raw')}
>
Plain text
</button>
</div>
</div>
{editorMode === 'key-value' ? (
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<span>Key</span>
<span>Value</span>
<span />
</div>
{keyValueEntries.map((entry) => (
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: 8, borderBottom: '1px solid var(--border)' }}>
<input
className="input"
aria-label="Secret key"
value={entry.key}
onChange={(e) => updateKeyValueEntry(entry.id, 'key', e.target.value)}
placeholder="Key"
/>
<input
className="input"
aria-label={`Value for ${entry.key || 'new key'}`}
value={entry.value}
onChange={(e) => updateKeyValueEntry(entry.id, 'value', e.target.value)}
placeholder="Value"
/>
<button className="icon-btn" title="Delete entry" aria-label={`Delete ${entry.key || 'entry'}`} onClick={() => removeKeyValueEntry(entry.id)}>
<Trash2 size={13} />
</button>
</div>
))}
<div style={{ padding: 8 }}>
<button className="button" onClick={addKeyValueEntry}>
<Plus size={13} />
Add key-value pair
</button>
</div>
</div>
) : (
<textarea
className="json-editor"
style={{ minHeight: 180 }}
value={draftValue}
onChange={(e) => { setDraftValue(e.target.value); setEditorError('') }}
spellCheck={false}
/>
)}
{editorError && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{editorError}</span>}
<div style={{ display: 'flex', gap: 8 }}>
<button className="button primary" disabled={putMut.isPending} onClick={saveValue}>
{putMut.isPending ? <Loader2 size={13} className="spin" /> : <Save size={13} />}
Save new version
</button>
<button className="button" onClick={cancelEditing}>Cancel</button>
</div>
</>
) : valueQuery.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
<Loader2 size={14} className="spin" /> Loading value…
</div>
) : valueQuery.isError ? (
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret value.</p>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{value?.secretBinary ? 'Binary value (base64)' : 'Secret value'}
</span>
<button className="icon-btn" style={{ marginLeft: 'auto' }} title="Hide" onClick={hideValue}>
<EyeOff size={13} />
</button>
<button className="icon-btn" title="Copy" onClick={copyValue}>
{copied ? <Check size={13} color="var(--status-ok)" /> : <Copy size={13} />}
</button>
</div>
{!isBinary && valueEntries && (
<div className="drawer-tabs" style={{ alignSelf: 'flex-start' }}>
<button className={`drawer-tab ${valueViewMode === 'key-value' ? 'active' : ''}`} onClick={() => setValueViewMode('key-value')}>
Key-value
</button>
<button className={`drawer-tab ${valueViewMode === 'raw' ? 'active' : ''}`} onClick={() => setValueViewMode('raw')}>
Plain text
</button>
</div>
)}
{valueViewMode === 'key-value' && valueEntries ? (
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<span>Key</span>
<span>Value</span>
</div>
{valueEntries.length === 0 ? (
<p style={{ margin: 0, padding: 10, color: 'var(--text-3)', fontSize: 12 }}>Empty JSON object</p>
) : valueEntries.map((entry, index) => (
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 8, borderBottom: index < valueEntries.length - 1 ? '1px solid var(--border)' : undefined }}>
<span className="mono" style={{ color: 'var(--status-warn)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.key}</span>
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.value}</span>
</div>
))}
</div>
) : (
<pre className="json-editor" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0 }}>
{value?.secretString ?? value?.secretBinary ?? '(empty)'}
</pre>
)}
{value?.versionId && (
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>Version: {value.versionId}</span>
)}
{isBinary ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
Binary secrets are read-only here; editing would overwrite the binary value with text.
</span>
) : (
<button className="button" onClick={startEditing} style={{ alignSelf: 'flex-start' }}>
<Save size={13} />
Edit value
</button>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="tag-drawer-footer">
{deleteConfirm ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', padding: '8px', background: 'rgba(239,68,68,0.08)', borderRadius: 4, border: '1px solid rgba(239,68,68,0.3)' }}>
<span style={{ fontSize: 12, color: 'var(--status-error)' }}>Delete this secret?</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text-2)' }}>
<input type="checkbox" checked={forceDelete} onChange={(e) => setForceDelete(e.target.checked)} />
Force delete (no 7-day recovery window)
</label>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="button danger" onClick={() => deleteMut.mutate({ id: secretId!, force: forceDelete })} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={12} className="spin" /> : 'Yes, delete'}
</button>
<button className="button" onClick={() => setDeleteConfirm(false)}>Cancel</button>
</div>
</div>
) : (
<button className="button danger" style={{ marginLeft: 'auto' }} onClick={() => setDeleteConfirm(true)}>
<Trash2 size={13} />
Delete
</button>
)}
</div>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function SecretsManagerPage() {
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const query = useSecretsQuery()
const secrets = useMemo(() => {
const all = query.data ?? []
if (!search) return all
const q = search.toLowerCase()
return all.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
}, [query.data, search])
function selectionId(secret: SecretSummary): string {
return secret.arn ?? secret.name
}
return (
<>
<SecretDrawer
key={selected}
secretId={selected}
onClose={() => setSelected(null)}
onDeleted={() => setSelected(null)}
/>
<div className="page-header">
<div className="page-title">
<h1>Secrets Manager</h1>
<span className="info-link">
<Info size={11} />
{query.data ? `${query.data.length} secrets` : 'Encrypted secrets'}
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="button" onClick={() => void query.refetch()}>
<RefreshCw size={13} />
Refresh
</button>
<button className="button primary" onClick={() => setCreating((v) => !v)}>
<Plus size={13} />
Create secret
</button>
</div>
</div>
<div className="input-row">
<Search size={14} color="var(--text-3)" />
<input
className="input"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or description…"
/>
</div>
<div className="content">
{creating && <CreateSecretForm onClose={() => setCreating(false)} />}
<div className="table-panel">
<div className="widget-header">
<h3>Secrets</h3>
</div>
{query.isError ? (
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from the Floci endpoint." />
) : query.isLoading ? (
<div className="empty"><p>Loading secrets…</p></div>
) : secrets.length === 0 ? (
<EmptyState
icon={KeyRound}
title={search ? 'No secrets match your search' : 'No secrets'}
description={search ? 'Try a different name or description filter.' : 'Create a secret to get started.'}
/>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Last changed</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
{secrets.map((secret) => {
const id = selectionId(secret)
return (
<tr
key={id}
style={{ cursor: 'pointer', background: selected === id ? 'var(--raised)' : undefined }}
onClick={() => setSelected(selected === id ? null : id)}
>
<td className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<KeyRound size={13} style={{ color: 'var(--accent)', flexShrink: 0 }} />
{secret.name}
</td>
<td style={{ color: 'var(--text-3)' }}>{secret.description ?? '—'}</td>
<td style={{ color: 'var(--text-3)' }}>{secret.lastChangedDate ? timeAgo(secret.lastChangedDate) : '—'}</td>
<td>{secret.tags.length || '—'}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
</>
)
}