feat: fully functioning ALB

This commit is contained in:
Zakaria
2026-09-26 16:14:24 -04:00
parent 093afb9b2b
commit 8470b8d17a
44 changed files with 2076 additions and 132 deletions
+38
View File
@@ -15,6 +15,7 @@ export const apiEndpointKeys = {
status: "clouds.status.get",
serviceStatus: "clouds.services.status.get",
schema: "clouds.services.schema.get",
dependencies: "clouds.services.dependencies.get",
resources: {
list: "clouds.services.resources.list",
get: "clouds.services.resources.get",
@@ -113,6 +114,11 @@ export const apiEndpointKeys = {
list: "clouds.services.childCollections.items.list",
},
},
flatItems: {
list: "clouds.services.flatItems.list",
create: "clouds.services.flatItems.create",
delete: "clouds.services.flatItems.delete",
},
configuration: {
environments: {
list: "clouds.services.configuration.environments.list",
@@ -295,6 +301,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "cloud-proxy" },
},
],
[
apiEndpointKeys.clouds.dependencies,
{
path: "/clouds/:cloud/services/:service/dependencies",
method: "GET",
telemetry: { service: "cloud-proxy" },
},
],
[
apiEndpointKeys.clouds.resources.list,
{
@@ -890,6 +904,30 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "cloud-proxy" },
},
],
[
apiEndpointKeys.clouds.flatItems.list,
{
path: "/clouds/:cloud/services/:service/resources/:id/items",
method: "GET",
telemetry: { service: "cloud-proxy" },
},
],
[
apiEndpointKeys.clouds.flatItems.create,
{
path: "/clouds/:cloud/services/:service/resources/:id/items",
method: "POST",
telemetry: { service: "cloud-proxy" },
},
],
[
apiEndpointKeys.clouds.flatItems.delete,
{
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
method: "DELETE",
telemetry: { service: "cloud-proxy" },
},
],
// AWS EKS
[
apiEndpointKeys.aws.eks.clusters.list,
+43 -1
View File
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEc2SecurityGroup } from "./ec2.api";
import {
authorizeEc2SecurityGroupEgress,
authorizeEc2SecurityGroupIngress,
createEc2SecurityGroup,
revokeEc2SecurityGroupEgress,
revokeEc2SecurityGroupIngress,
} from "./ec2.api";
describe("createEc2SecurityGroup", () => {
const fetchMock = vi.fn<typeof fetch>();
@@ -42,3 +48,39 @@ describe("createEc2SecurityGroup", () => {
},
);
});
describe("EC2 security-group rule requests", () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
});
it.each([
["authorize ingress", authorizeEc2SecurityGroupIngress],
["revoke ingress", revokeEc2SecurityGroupIngress],
["authorize egress", authorizeEc2SecurityGroupEgress],
["revoke egress", revokeEc2SecurityGroupEgress],
] as const)("sends one JSON permission object for %s", async (_action, request) => {
const permission = { protocol: "tcp", fromPort: 8080, toPort: 8080, cidr: "0.0.0.0/0" };
await request("sg-web", permission);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/ec2/security-groups/sg-web");
expect(JSON.parse(String(init?.body))).toEqual(permission);
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
});
});
+17 -4
View File
@@ -12,6 +12,18 @@ export type Ec2SecurityGroupRef = {
name?: string;
};
export type Ec2NetworkInterface = {
networkInterfaceId: string;
deviceIndex?: number;
status?: string;
privateIpAddress?: string;
publicIpAddress?: string;
privateDnsName?: string;
subnetId?: string;
vpcId?: string;
securityGroups: Ec2SecurityGroupRef[];
};
export type Ec2IpPermission = {
protocol: string;
fromPort: number | null;
@@ -61,6 +73,7 @@ export type Ec2Instance = {
architecture?: string;
platform?: string;
securityGroups: Ec2SecurityGroupRef[];
networkInterfaces: Ec2NetworkInterface[];
tags: Ec2Tag[];
};
@@ -391,7 +404,7 @@ export async function authorizeEc2SecurityGroupIngress(
): Promise<void> {
await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.authorize,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
{ body: permission },
{ groupId },
);
}
@@ -402,7 +415,7 @@ export async function revokeEc2SecurityGroupIngress(
): Promise<void> {
await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.revoke,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
{ body: permission },
{ groupId },
);
}
@@ -413,7 +426,7 @@ export async function authorizeEc2SecurityGroupEgress(
): Promise<void> {
await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
{ body: permission },
{ groupId },
);
}
@@ -424,7 +437,7 @@ export async function revokeEc2SecurityGroupEgress(
): Promise<void> {
await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.revokeEgress,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
{ body: permission },
{ groupId },
);
}
+63 -1
View File
@@ -108,15 +108,34 @@ export async function getServiceSchema(
return res.data;
}
export type LoadBalancingDependencies = {
vpcs: Array<{id: string; cidrBlock: string}>;
subnets: Array<{id: string; vpcId: string; availabilityZone: string; cidrBlock: string}>;
securityGroups: Array<{id: string; vpcId: string; name: string; description: string}>;
};
export async function getLoadBalancingDependencies(
service: "loadbalancing" | "targetgroups" = "loadbalancing",
signal?: AbortSignal,
): Promise<LoadBalancingDependencies & {instances?: Array<{id: string; name: string; privateIp: string | null}>}> {
const res = await apiClient.call<LoadBalancingDependencies>(
apiEndpointKeys.clouds.dependencies,
requestOptions("aws", service, {signal}),
{cloud: "aws", service},
);
return res.data;
}
export async function listCloudResources(
cloud: CloudProvider,
service: CloudServiceType,
search?: string,
signal?: AbortSignal,
state?: string,
): Promise<CloudResource[]> {
const res = await apiClient.call<CloudResource[]>(
apiEndpointKeys.clouds.resources.list,
requestOptions(cloud, service, { signal, params: { search } }),
requestOptions(cloud, service, { signal, params: { search, state } }),
{ cloud, service },
);
return res.data;
@@ -905,6 +924,49 @@ export async function listCollectionItems(
return res.data;
}
export async function listFlatItems(
cloud: CloudProvider,
service: CloudServiceType,
resourceId: string,
cursor?: string,
signal?: AbortSignal,
): Promise<CollectionPage<ChildItem>> {
const res = await apiClient.call<CollectionPage<ChildItem>>(
apiEndpointKeys.clouds.flatItems.list,
requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }),
{ cloud, service, id: resourceId },
);
return res.data;
}
export async function deleteFlatItem(
cloud: CloudProvider,
service: CloudServiceType,
resourceId: string,
itemId: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.flatItems.delete,
requestOptions(cloud, service, { signal }),
{ cloud, service, id: resourceId, itemId },
);
}
export async function putFlatItem(
cloud: CloudProvider,
service: CloudServiceType,
resourceId: string,
body: Record<string, unknown>,
): Promise<ChildItem> {
const res = await apiClient.call<ChildItem, Record<string, unknown>>(
apiEndpointKeys.clouds.flatItems.create,
requestOptions(cloud, service, {body}),
{cloud, service, id: resourceId},
);
return res.data;
}
export async function listSqlDatabases(
cloud: CloudProvider,
serverId: string,
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
AlertTriangle,
@@ -583,6 +583,22 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
</span>
</div>
)}
{inst.networkInterfaces.length > 0 && (
<div className="meta-row" style={{ gridColumn: '1 / -1', alignItems: 'start' }}>
<span className="meta-label">Network interfaces</span>
<span className="meta-value" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{inst.networkInterfaces.map((networkInterface) => (
<span key={networkInterface.networkInterfaceId} style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<code>{networkInterface.networkInterfaceId}</code>
{networkInterface.deviceIndex !== undefined && <span>device {networkInterface.deviceIndex}</span>}
{networkInterface.status && <span>{networkInterface.status}</span>}
{networkInterface.privateIpAddress && <span>private {networkInterface.privateIpAddress}</span>}
{networkInterface.publicIpAddress && <span>public {networkInterface.publicIpAddress}</span>}
</span>
))}
</span>
</div>
)}
</div>
)}
@@ -789,6 +805,23 @@ export function LaunchInstanceForm({
const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) })
const subsQ = useQuery({ queryKey: ['ec2', 'subnets'], queryFn: ({ signal }) => listEc2Subnets(undefined, signal) })
const sgsQ = useQuery({ queryKey: ['ec2', 'security-groups'], queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal) })
const selectedSubnet = (subsQ.data ?? []).find((subnet) => subnet.subnetId === subnetId)
useEffect(() => {
if (!selectedSubnet) {
return
}
const compatibleGroupIds = new Set((sgsQ.data ?? [])
.filter((group) => group.vpcId === selectedSubnet.vpcId)
.map((group) => group.groupId))
setSelectedSgs((current) => {
const compatible = current.filter((groupId) => compatibleGroupIds.has(groupId))
if (compatible.length !== current.length) {
setStepError('Security groups from another VPC were removed. Select groups in the subnet VPC.')
}
return compatible
})
}, [selectedSubnet, sgsQ.data])
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
@@ -845,6 +878,13 @@ export function LaunchInstanceForm({
}
function launch() {
const hasIncompatibleSecurityGroup = selectedSubnet && selectedSgs.some((groupId) =>
!(sgsQ.data ?? []).some((group) => group.groupId === groupId && group.vpcId === selectedSubnet.vpcId))
if (hasIncompatibleSecurityGroup) {
setStep(3)
setStepError('Security groups must belong to the selected subnet VPC.')
return
}
createMut.mutate({
name,
imageId,
@@ -908,8 +948,8 @@ export function LaunchInstanceForm({
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
})
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId)
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId)
const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' },
@@ -1046,10 +1086,11 @@ export function LaunchInstanceForm({
<div className="sg-checklist">
{sgsQ.isLoading && <span className="muted">Loading…</span>}
{selectedSubnet && <span className="muted">Showing groups in {selectedSubnet.vpcId} only.</span>}
{sgsQ.data?.length === 0 && !showCreateSg && (
<span className="muted">No security groups — create one above.</span>
)}
{(sgsQ.data ?? []).map((sg: Ec2SecurityGroup) => (
{availableSecurityGroups.map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item">
<input
type="checkbox"
@@ -0,0 +1,67 @@
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
interface CopyValueButtonProps {
value: string;
label: string;
}
export function CopyValueButton({value, label}: CopyValueButtonProps) {
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
async function copy() {
const copied = await copyText(value);
setStatus(copied ? 'copied' : 'failed');
window.setTimeout(() => setStatus('idle'), 2_000);
}
const message = status === 'copied'
? `${label} copied`
: status === 'failed'
? `Unable to copy ${label}`
: '';
return (
<>
<button
className="icon-btn copy-value-button"
type="button"
onClick={(event) => {
event.stopPropagation();
void copy();
}}
title={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
aria-label={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
>
{status === 'copied' ? <Check size={13} /> : <Copy size={13} />}
</button>
<span className="visually-hidden" role="status">{message}</span>
</>
);
}
async function copyText(value: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// Some embedded or insecure contexts expose Clipboard API but reject writes.
}
const textarea = document.createElement('textarea');
try {
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
@@ -53,6 +53,10 @@ import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHisto
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
import { SnsTopicPanel } from "@/components/SnsTopicPanel";
import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -76,6 +80,7 @@ export function DynamicResourceView({
}: DynamicResourceViewProps) {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [state, setState] = useState("");
const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances");
const [selected, setSelected] = useState<CloudResource | undefined>();
const [selectedObject, setSelectedObject] = useState<
@@ -104,8 +109,8 @@ export function DynamicResourceView({
};
const resourcesKey = useMemo(
() => ["cloud-resources", cloud, service, search],
[cloud, service, search],
() => ["cloud-resources", cloud, service, search, state],
[cloud, service, search, state],
);
const schemaQuery = useQuery({
@@ -115,7 +120,7 @@ export function DynamicResourceView({
const resourcesQuery = useQuery({
queryKey: resourcesKey,
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal),
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal, state),
enabled:
schemaQuery.isSuccess &&
serviceAvailability === "available" &&
@@ -433,6 +438,7 @@ export function DynamicResourceView({
setEditingResource(null);
setUpdateError(null);
setSearch("");
setState("");
setDatabaseTab("instances");
setIsDeleteMulti(false);
setSelectedItems([]);
@@ -599,6 +605,18 @@ export function DynamicResourceView({
placeholder="Filter resources"
/>
</div>
{schema.filters.find((filter) => filter.name === "state" && filter.type === "select") && (
<select
className="input"
aria-label="Filter by lifecycle state"
value={state}
onChange={(event) => setState(event.target.value)}
>
{schema.filters.find((filter) => filter.name === "state")?.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{service === "email" && (
clearConfirm ? (
<>
@@ -756,6 +774,13 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : (
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
@@ -832,6 +857,15 @@ export function DynamicResourceView({
onSelectObject={setSelectedObject}
/>
)}
{service === "ecr" && cloud === "aws" && (
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "targetgroups" && cloud === "aws" && (
<TargetGroupPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -940,7 +974,15 @@ export function DynamicResourceView({
<X size={14} />
</button>
</div>
<DynamicFormRenderer
{service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
resource={editingResource}
isSubmitting={updateMut.isPending}
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
) : <DynamicFormRenderer
key={editingResource.id}
schema={schema}
fields={schema.updateFields}
@@ -951,7 +993,7 @@ export function DynamicResourceView({
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
/>}
</div>
</div>
)}
@@ -0,0 +1,70 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {deleteFlatItem, listFlatItems} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
interface EcrImageBrowserProps {
resource?: CloudResource
runtimeReachable: boolean
}
export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserProps) {
const queryClient = useQueryClient()
const imagesQuery = useQuery({
queryKey: ['ecr-images', resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'ecr', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const deleteMutation = useMutation({
mutationFn: (imageDigest: string) => deleteFlatItem('aws', 'ecr', resource?.id ?? '', imageDigest),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['ecr-images', resource?.id]}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a repository</h3><p>Choose an ECR repository to browse its images.</p></div></section>
}
const images = imagesQuery.data?.items ?? []
return (
<section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Repository images</p>
<span className="muted">{images.length} images in {resource.name}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || imagesQuery.isFetching} onClick={() => imagesQuery.refetch()}>
<RefreshCw size={14}/>{imagesQuery.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
{imagesQuery.error instanceof Error && <p className="error-text compact-text">{imagesQuery.error.message}</p>}
{imagesQuery.isLoading && <div className="muted padded">Loading images</div>}
{imagesQuery.isSuccess && images.length === 0 && <div className="empty compact"><h3>No images</h3><p>Push an image to this repository to see it here.</p></div>}
{images.length > 0 && <div className="cosmos-items-table">
<table>
<thead><tr><th>Tags</th><th>Digest</th><th>Pushed</th><th>Size</th><th/></tr></thead>
<tbody>{images.map((image) => {
const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : []
const size = typeof image.body.imageSizeInBytes === 'number' ? formatBytes(image.body.imageSizeInBytes) : '-'
return <tr key={image.id}>
<td>{tags.length ? tags.join(', ') : <span className="muted">Untagged</span>}</td>
<td><code>{shortDigest(image.id)}</code></td>
<td>{image.timestamp ? new Date(image.timestamp).toLocaleString() : '-'}</td>
<td>{size}</td>
<td><button className="icon-btn danger" type="button" title="Delete image" aria-label={`Delete ${image.id}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(image.id)}><Trash2 size={14}/></button></td>
</tr>
})}</tbody>
</table>
</div>}
</section>
)
}
function shortDigest(digest: string): string {
return digest.length > 20 ? `${digest.slice(0, 19)}...` : digest
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
}
@@ -0,0 +1,35 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
listCloudResources: vi.fn(),
listFlatItems: vi.fn(),
putFlatItem: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
const user = userEvent.setup()
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
api.putFlatItem.mockResolvedValue({})
api.deleteFlatItem.mockResolvedValue(undefined)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={client}><LoadBalancerListenerPanel runtimeReachable resource={{id: 'load-balancer-arn', name: 'web', cloud: 'aws', service: 'loadbalancing', type: 'load-balancer', region: null, createdAt: null, metadata: {}}}/></QueryClientProvider>)
await screen.findByText('web-targets (target-group-arn)')
await user.selectOptions(screen.getByLabelText(/default target group/i), 'target-group-arn')
await user.clear(screen.getByLabelText(/listener port/i))
await user.type(screen.getByLabelText(/listener port/i), '8080')
await user.click(screen.getByRole('button', {name: /create listener/i}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
})
@@ -0,0 +1,74 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [targetGroupArn, setTargetGroupArn] = useState('')
const [port, setPort] = useState('80')
const listenersQuery = useQuery({
queryKey: ['load-balancer-listeners', accountId, resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'loadbalancing', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
const targetGroupsQuery = useQuery({
queryKey: ['listener-target-groups', accountId],
queryFn: ({signal}) => listCloudResources('aws', 'targetgroups', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setTargetGroupArn('')
setPort('80')
}, [resource?.id])
const createMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
})
const deleteMutation = useMutation({
mutationFn: (listenerArn: string) => deleteFlatItem('aws', 'loadbalancing', resource?.id ?? '', listenerArn),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select an Application Load Balancer</h3><p>Choose a load balancer to create and manage HTTP listeners.</p></div></section>
}
const listeners = listenersQuery.data?.items ?? []
const targetGroups = targetGroupsQuery.data ?? []
const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535
const error = listenersQuery.error ?? targetGroupsQuery.error ?? createMutation.error ?? deleteMutation.error
const pending = createMutation.isPending || deleteMutation.isPending
return <section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Listeners</p><span className="muted">{listeners.length} listeners on {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || listenersQuery.isFetching} onClick={() => { void Promise.all([listenersQuery.refetch(), targetGroupsQuery.refetch()]) }}><RefreshCw size={14}/>{listenersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (targetGroupArn && validPort) createMutation.mutate() }}>
<label className="dynamic-field"><span>Protocol</span><input className="input" value="HTTP" disabled/></label>
<label className="dynamic-field"><span>Listener port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={pending}/></label>
<label className="dynamic-field dynamic-field--span"><span>Default target group</span><select className="input" value={targetGroupArn} onChange={(event) => setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}><option value="">Select a target group</option>{targetGroups.map((group) => <option key={group.id} value={group.id}>{group.name} ({group.id})</option>)}</select></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !targetGroupArn || !validPort || pending}>{createMutation.isPending ? 'Creating' : 'Create listener'}</button></div>
{targetGroups.length === 0 && targetGroupsQuery.isSuccess && <p className="muted">No target groups are available in this account.</p>}
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>}
{listenersQuery.isSuccess && listeners.length === 0 && <div className="empty compact"><h3>No listeners</h3><p>Create an HTTP listener that forwards requests to a target group.</p></div>}
{listeners.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Protocol</th><th>Port</th><th>Default target group</th><th/></tr></thead><tbody>{listeners.map((listener) => <tr key={listener.id}><td>{stringValue(listener.body.protocol)}</td><td>{stringValue(listener.body.port)}</td><td><code>{stringValue(listener.body.targetGroupArn)}</code></td><td><button className="icon-btn danger" type="button" title="Delete listener" aria-label={`Delete listener ${stringValue(listener.body.port)}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(listener.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
</section>
}
function stringValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
@@ -0,0 +1,40 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {LoadBalancerPanel} from './LoadBalancerPanel'
vi.mock('@/api/cloudProxyClient', () => ({
getLoadBalancingDependencies: vi.fn().mockResolvedValue({
vpcs: [{id: 'vpc-a', cidrBlock: '10.0.0.0/16'}, {id: 'vpc-b', cidrBlock: '10.1.0.0/16'}],
subnets: [
{id: 'subnet-a', vpcId: 'vpc-a', availabilityZone: 'us-east-1a', cidrBlock: '10.0.1.0/24'},
{id: 'subnet-b', vpcId: 'vpc-a', availabilityZone: 'us-east-1b', cidrBlock: '10.0.2.0/24'},
],
securityGroups: [{id: 'sg-a', vpcId: 'vpc-a', name: 'web', description: ''}],
}),
}))
test('filters network choices by VPC, resets them, and submits an application load balancer', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<QueryClientProvider client={new QueryClient()}><LoadBalancerPanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/></QueryClientProvider>)
await screen.findByText('vpc-a (10.0.0.0/16)')
await user.type(screen.getByLabelText(/load balancer name/i), 'web')
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a')
await user.click(screen.getByLabelText(/subnet-a/i))
await user.click(screen.getByLabelText(/subnet-b/i))
await user.click(screen.getByLabelText(/web \(sg-a\)/i))
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-b')
expect(screen.queryByLabelText(/subnet-a/i)).not.toBeInTheDocument()
await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a')
expect(screen.getByLabelText(/subnet-a/i)).not.toBeChecked()
expect(screen.getByLabelText(/web \(sg-a\)/i)).not.toBeChecked()
await user.click(screen.getByLabelText(/subnet-a/i))
await user.click(screen.getByLabelText(/subnet-b/i))
await user.click(screen.getByLabelText(/web \(sg-a\)/i))
await user.click(screen.getByRole('button', {name: /create load balancer/i}))
expect(onSubmit).toHaveBeenCalledWith({name: 'web', subnets: 'subnet-a,subnet-b', securityGroups: 'sg-a', scheme: 'internet-facing'})
})
@@ -0,0 +1,72 @@
import {useQuery} from '@tanstack/react-query'
import {FormEvent, useEffect, useState} from 'react'
import {getLoadBalancingDependencies} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
isSubmitting: boolean
submitError: string | null
onCancel: () => void
onSubmit: (values: Record<string, unknown>) => void
}
export function LoadBalancerPanel({resource, isSubmitting, submitError, onCancel, onSubmit}: Props) {
const dependencies = useQuery({queryKey: ['loadbalancing-dependencies'], queryFn: ({signal}) => getLoadBalancingDependencies('loadbalancing', signal)})
const [name, setName] = useState('')
const [scheme, setScheme] = useState('internet-facing')
const [vpcId, setVpcId] = useState('')
const [subnetIds, setSubnetIds] = useState<string[]>([])
const [securityGroupIds, setSecurityGroupIds] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!resource) return
setVpcId(typeof resource.metadata.vpcId === 'string' ? resource.metadata.vpcId : '')
setSecurityGroupIds(stringValues(resource.metadata.securityGroups))
}, [resource])
const subnets = (dependencies.data?.subnets ?? []).filter((subnet) => subnet.vpcId === vpcId)
const groups = (dependencies.data?.securityGroups ?? []).filter((group) => group.vpcId === vpcId)
const selectedZones = new Set(subnets.filter((subnet) => subnetIds.includes(subnet.id)).map((subnet) => subnet.availabilityZone))
function selectVpc(nextVpcId: string) {
setVpcId(nextVpcId)
setSubnetIds([])
setSecurityGroupIds([])
setError(null)
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!resource && !name.trim()) return setError('Load balancer name is required.')
if (!resource && (subnetIds.length < 2 || selectedZones.size < 2)) return setError('Select two subnets in distinct Availability Zones.')
if (securityGroupIds.length === 0) return setError('Select at least one security group.')
setError(null)
onSubmit(resource ? {securityGroups: securityGroupIds.join(',')} : {
name: name.trim(), subnets: subnetIds.join(','), securityGroups: securityGroupIds.join(','), scheme,
})
}
if (dependencies.isLoading) return <p className="muted">Loading VPC configuration...</p>
if (dependencies.isError) return <p className="form-error" role="alert">Unable to load VPC configuration.</p>
return <form className="dynamic-form" onSubmit={submit} noValidate>
<div className="dynamic-form-group">{resource ? 'Security groups' : 'Application Load Balancer'}</div>
{!resource && <label className="dynamic-field"><span>Load Balancer Name<em className="field-required">*</em></span><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></label>}
{!resource && <label className="dynamic-field"><span>Scheme</span><select className="input" value={scheme} onChange={(event) => setScheme(event.target.value)}><option value="internet-facing">internet-facing</option><option value="internal">internal</option></select></label>}
<label className="dynamic-field"><span>VPC<em className="field-required">*</em></span><select className="input" value={vpcId} required onChange={(event) => selectVpc(event.target.value)} disabled={Boolean(resource)}><option value="">Select a VPC</option>{(dependencies.data?.vpcs ?? []).map((vpc) => <option key={vpc.id} value={vpc.id}>{vpc.id} ({vpc.cidrBlock})</option>)}</select></label>
{!resource && <fieldset className="dynamic-field dynamic-field--span"><legend>Subnets<em className="field-required">*</em></legend><small>Select at least two subnets in distinct Availability Zones.</small>{subnets.map((subnet) => <label key={subnet.id}><input type="checkbox" checked={subnetIds.includes(subnet.id)} onChange={() => setSubnetIds((current) => toggle(current, subnet.id))} /> {subnet.id} ({subnet.availabilityZone}, {subnet.cidrBlock})</label>)}</fieldset>}
<fieldset className="dynamic-field dynamic-field--span"><legend>Security Groups<em className="field-required">*</em></legend>{groups.map((group) => <label key={group.id}><input type="checkbox" checked={securityGroupIds.includes(group.id)} onChange={() => setSecurityGroupIds((current) => toggle(current, group.id))} /> {group.name || group.id} ({group.id})</label>)}</fieldset>
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: 8}}><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? (resource ? 'Saving' : 'Creating') : (resource ? 'Set Security Groups' : 'Create Load Balancer')}</button></div>
{(error || submitError) && <div className="form-error" role="alert">{error ?? submitError}</div>}
</form>
}
function toggle(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value]
}
function stringValues(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -229,8 +229,8 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
No rules. Click Add rule to add one.
</div>
) : (
<div style={{border: '1px solid var(--border)', borderRadius: 4, marginBottom: 8}}>
<table style={{width: '100%', borderCollapse: 'collapse', fontSize: 12}}>
<div className="sg-edit-rule-table-wrap">
<table className="sg-edit-rule-table">
<thead>
<tr style={{borderBottom: '1px solid var(--border)'}}>
<th style={thS}>Type</th>
@@ -248,10 +248,10 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
const toErr = isCustom(r) && r.toPort !== '' && !isValidPort(r.toPort)
return (
<tr key={r.rid} style={{borderBottom: idx < rules.length - 1 ? '1px solid var(--border-faint)' : 'none'}}>
<td style={tdS}>
<td className="sg-edit-type-cell" style={tdS}>
<select
className="input"
style={{width: 130, fontSize: 12}}
style={{fontSize: 12}}
value={pKey}
onChange={(e) => handlePreset(r.rid, e.target.value)}
>
@@ -407,7 +407,7 @@ function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => v
return (
<div className="modal-overlay" onClick={(e) => {if (e.target === e.currentTarget) onClose()}}>
<div className="create-table-modal" style={{maxWidth: 680}}>
<div className="create-table-modal sg-rules-edit-modal">
<div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}>
<Shield size={14} style={{color: 'var(--accent)'}}/>
<span>Edit rules — {sg.groupName}</span>
@@ -5,6 +5,7 @@ import { LogsQueryPanel } from "@/components/LogsQueryPanel";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton";
interface ResourceInspectorProps {
resource?: CloudResource;
@@ -82,6 +83,7 @@ export function ResourceInspector({
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource ID" value={resource.id} copyable />
<InspectorItem label="Service" value={serviceName ?? resource.service} />
<InspectorItem label="Region" value={resource.region ?? "-"} />
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
@@ -163,6 +165,7 @@ export function ResourceInspector({
<InspectorItem
label="ARN"
value={getStringMetadata(resource.metadata.arn) ?? "-"}
copyable
/>
<InspectorItem
label="Last Modified"
@@ -343,11 +346,14 @@ function DatabaseLifecycleSection({ status }: { status?: string | null }) {
}
function InspectorItem({ label, value }: { label: string; value: string }) {
function InspectorItem({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) {
return (
<div>
<p className="metric-label">{label}</p>
<p className="metric-value mono">{value}</p>
<p className="metric-value mono copy-value">
{value}
{copyable && value !== "-" && <CopyValueButton value={value} label={label} />}
</p>
</div>
);
}
@@ -370,13 +376,20 @@ function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
{rows.map(([key, value]) => (
<div key={key}>
<span>{humanizeKey(key)}</span>
<code>{formatMetadataValue(value)}</code>
<span className="copy-value">
<code>{formatMetadataValue(value)}</code>
{isCopyableMetadata(key, value) && <CopyValueButton value={formatMetadataValue(value)} label={humanizeKey(key)} />}
</span>
</div>
))}
</div>
);
}
function isCopyableMetadata(key: string, value: unknown): value is string | number {
return (typeof value === "string" || typeof value === "number") && /id$|arn|ip|endpoint|url|uri|key|hash|token/i.test(key);
}
function formatMetadataValue(value: unknown): string {
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
@@ -5,6 +5,7 @@ import type {CloudResource} from '@/types/resource';
import type {ServiceSchema} from '@/types/schema';
import {getPath} from '@/lib/resourcePath';
import {renderColumnValue} from '@/lib/columnFormat';
import {CopyValueButton} from '@/components/CopyValueButton';
interface ResourceTableProps {
schema: ServiceSchema;
@@ -102,7 +103,7 @@ export function ResourceTable({
)}
{schema.columns.map((column) => (
<td key={column.name} onClick={() => onSelect(resource)}>
{renderColumnValue(getPath(resource, column.path ?? column.name), column)}
<CopyableColumnValue resource={resource} column={column} />
</td>
))}
{hasActions && (
@@ -188,3 +189,21 @@ export function ResourceTable({
</table>
);
}
function CopyableColumnValue({resource, column}: {resource: CloudResource; column: ServiceSchema['columns'][number]}) {
const value = getPath(resource, column.path ?? column.name);
const copyable = typeof value === 'string' && (
column.format === 'code' || /(^id$|id$|arn|ip|endpoint|url|uri|key)/i.test(column.name)
);
if (!copyable) {
return renderColumnValue(value, column);
}
return (
<span className="copy-value">
{renderColumnValue(value, column)}
<CopyValueButton value={value} label={column.label} />
</span>
);
}
@@ -0,0 +1,81 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type TargetInstance = {id: string; name: string; privateIp: string | null}
export function TargetGroupPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [instanceId, setInstanceId] = useState('')
const [port, setPort] = useState('')
const dependenciesQuery = useQuery({
queryKey: ['target-group-instances', accountId],
queryFn: ({signal}) => getTargetGroupInstances(signal),
enabled: Boolean(resource) && runtimeReachable,
})
const healthQuery = useQuery({
queryKey: ['target-group-health', accountId, resource?.id],
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setInstanceId('')
setPort(typeof resource?.metadata.port === 'number' ? String(resource.metadata.port) : '')
}, [resource?.id, resource?.metadata.port])
const refresh = () => {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
}
const registerMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
})
const deregisterMutation = useMutation({
mutationFn: (targetKey: string) => deleteFlatItem('aws', 'targetgroups', resource?.id ?? '', targetKey),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a target group</h3><p>Choose a target group to register running instances and inspect target health.</p></div></section>
}
const instances = dependenciesQuery.data ?? []
const targets = healthQuery.data?.items ?? []
const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535
const error = healthQuery.error ?? dependenciesQuery.error ?? registerMutation.error ?? deregisterMutation.error
return <section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Target health</p><span className="muted">{targets.length} registered targets in {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || healthQuery.isFetching} onClick={refresh}><RefreshCw size={14}/>{healthQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (instanceId && validPort) registerMutation.mutate() }}>
<label className="dynamic-field"><span>Running instance</span><select className="input" value={instanceId} onChange={(event) => setInstanceId(event.target.value)} disabled={dependenciesQuery.isLoading || registerMutation.isPending}><option value="">Select an instance</option>{instances.map((instance) => <option key={instance.id} value={instance.id}>{instance.name} ({instance.id}{instance.privateIp ? `, ${instance.privateIp}` : ''})</option>)}</select></label>
<label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={registerMutation.isPending}/></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort || registerMutation.isPending}>{registerMutation.isPending ? 'Registering' : 'Register target'}</button></div>
{instances.length === 0 && dependenciesQuery.isSuccess && <p className="muted">No running instances are available in this account.</p>}
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{healthQuery.isLoading && <div className="muted padded">Loading target health</div>}
{healthQuery.isSuccess && targets.length === 0 && <div className="empty compact"><h3>No registered targets</h3><p>Register a running instance to see its ELB health state.</p></div>}
{targets.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Instance</th><th>Port</th><th>State</th><th>Reason</th><th>Description</th><th/></tr></thead><tbody>{targets.map((target) => <tr key={target.id}><td><code>{stringValue(target.body.id)}</code></td><td>{stringValue(target.body.port)}</td><td>{stringValue(target.body.state) || '-'}</td><td>{stringValue(target.body.reason) || '-'}</td><td>{stringValue(target.body.description) || '-'}</td><td><button className="icon-btn danger" type="button" title="Deregister target" aria-label={`Deregister ${stringValue(target.body.id)}`} disabled={!runtimeReachable || deregisterMutation.isPending} onClick={() => deregisterMutation.mutate(target.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
</section>
}
async function getTargetGroupInstances(signal?: AbortSignal): Promise<TargetInstance[]> {
const dependencies = await getLoadBalancingDependencies('targetgroups', signal)
return Array.isArray(dependencies.instances) ? dependencies.instances : []
}
function stringValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
@@ -33,6 +33,7 @@ import {
*/
const SERVICE_ICONS: Record<string, LucideIcon> = {
storage: HardDrive,
ecr: Container,
database: Table2,
nosql: Database,
k8s: Boxes,
+83
View File
@@ -188,6 +188,18 @@ button {
border: 0;
}
.copy-value {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.copy-value-button {
flex: 0 0 auto;
padding: 2px;
}
#main-content:focus {
outline: none;
}
@@ -4321,6 +4333,77 @@ button.console-service-card {
overflow-y: auto;
}
.create-table-modal.sg-rules-edit-modal {
box-sizing: border-box;
width: min(980px, calc(100vw - 40px));
max-width: none;
max-height: min(900px, calc(100vh - 40px));
overflow-x: hidden;
align-items: stretch;
}
.sg-rules-edit-modal .modal-section {
box-sizing: border-box;
min-width: 0;
}
.sg-edit-rule-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 8px;
}
.sg-edit-rule-table {
width: 100%;
min-width: 700px;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.sg-edit-rule-table th:nth-child(1) { width: 34%; }
.sg-edit-rule-table th:nth-child(2) { width: 12%; }
.sg-edit-rule-table th:nth-child(3) { width: 24%; }
.sg-edit-rule-table th:nth-child(4) { width: 26%; }
.sg-edit-rule-table th:nth-child(5) { width: 44px; }
.sg-edit-rule-table td {
overflow: hidden;
}
.sg-edit-rule-table .sg-edit-type-cell .input,
.sg-edit-rule-table td:nth-child(4) .input {
box-sizing: border-box;
width: 100% !important;
min-width: 0 !important;
}
.sg-edit-rule-table td:nth-child(3) > div {
gap: 8px !important;
}
.sg-edit-rule-table td:nth-child(3) .input {
box-sizing: border-box;
width: 64px !important;
min-width: 0 !important;
}
@media (max-width: 760px) {
.create-table-modal.sg-rules-edit-modal {
width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
padding: 16px;
gap: 12px;
}
.sg-rules-edit-modal .modal-section {
padding: 10px;
}
}
.create-table-modal h3 {
margin: 0;
font-size: 15px;
+2
View File
@@ -26,6 +26,7 @@ export type KubernetesActionName =
| 'listFargateProfiles'
| 'createFargateProfile'
| 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -83,6 +84,7 @@ export interface ServiceSchema {
objectActions?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName>
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
}
filters: FieldSchema[]
columns: TableColumnSchema[]