first commit
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user