feat: fully functioning ALB
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user