bug fixes: subnets listing, icons resizing and some shenanigans
This commit is contained in:
@@ -216,6 +216,17 @@ describe('PUT /instances/:id/security-groups', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(receivedGroupIds).toEqual(['sg-123'])
|
expect(receivedGroupIds).toEqual(['sg-123'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('rejects an empty selection', async () => {
|
||||||
|
const res = await app.request('/instances/i-test1/security-groups', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: JSON.stringify({groupIds: []}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await res.json()).toEqual({error: 'At least one security group is required'})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /amis/:imageId/deregister', () => {
|
describe('POST /amis/:imageId/deregister', () => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Floci UI — Any Cloud. Locally. The local cloud console for Floci." />
|
<meta name="description" content="TheCloud UI — Any Cloud. Locally. The local cloud console for TheCloud." />
|
||||||
<title>Floci UI</title>
|
<title>The Cloud</title>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var t = localStorage.getItem("floci-theme");
|
var t = localStorage.getItem("floci-theme");
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="icon" href="/src/assets/floci-black.svg" />
|
<link rel="icon" href="/src/assets/TheCloud.png" />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createEc2SecurityGroup,
|
createEc2SecurityGroup,
|
||||||
revokeEc2SecurityGroupEgress,
|
revokeEc2SecurityGroupEgress,
|
||||||
revokeEc2SecurityGroupIngress,
|
revokeEc2SecurityGroupIngress,
|
||||||
|
updateEc2InstanceSecurityGroups,
|
||||||
} from "./ec2.api";
|
} from "./ec2.api";
|
||||||
|
|
||||||
describe("createEc2SecurityGroup", () => {
|
describe("createEc2SecurityGroup", () => {
|
||||||
@@ -84,3 +85,29 @@ describe("EC2 security-group rule requests", () => {
|
|||||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
|
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("updateEc2InstanceSecurityGroups", () => {
|
||||||
|
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("sends the selected group IDs as a JSON object", async () => {
|
||||||
|
await updateEc2InstanceSecurityGroups("i-test", ["sg-default", "sg-web"]);
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toContain("/api/ec2/instances/i-test/security-groups");
|
||||||
|
expect(init?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({groupIds: ["sg-default", "sg-web"]});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export async function updateEc2InstanceTags(
|
|||||||
export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise<void> {
|
export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise<void> {
|
||||||
await apiClient.call(
|
await apiClient.call(
|
||||||
apiEndpointKeys.aws.ec2.instances.securityGroups,
|
apiEndpointKeys.aws.ec2.instances.securityGroups,
|
||||||
{ body: JSON.stringify({ groupIds }), headers: { "content-type": "application/json" } },
|
{ body: { groupIds } },
|
||||||
{ instanceId },
|
{ instanceId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
@@ -154,7 +154,7 @@ function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: {
|
|||||||
const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[]))
|
const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[]))
|
||||||
const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)})
|
const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)})
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => updateEc2InstanceSecurityGroups(instanceId, [...selected]),
|
mutationFn: (groupIds: string[]) => updateEc2InstanceSecurityGroups(instanceId, groupIds),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]})
|
void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]})
|
||||||
void qc.invalidateQueries({queryKey: ['ec2', 'instances']})
|
void qc.invalidateQueries({queryKey: ['ec2', 'instances']})
|
||||||
@@ -177,7 +177,7 @@ function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: {
|
|||||||
</label>)}
|
</label>)}
|
||||||
{groups.isError && <p className="error-text compact-text">Unable to load security groups.</p>}
|
{groups.isError && <p className="error-text compact-text">Unable to load security groups.</p>}
|
||||||
{mutation.isError && <p className="error-text compact-text">{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}</p>}
|
{mutation.isError && <p className="error-text compact-text">{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}</p>}
|
||||||
<button className="button primary compact" disabled={groups.isLoading || selected.size === 0 || mutation.isPending} onClick={() => mutation.mutate()}>
|
<button className="button primary compact" disabled={groups.isLoading || selected.size === 0 || mutation.isPending} onClick={() => mutation.mutate([...selected])}>
|
||||||
<Save size={13}/>{mutation.isPending ? 'Saving' : 'Save security groups'}
|
<Save size={13}/>{mutation.isPending ? 'Saving' : 'Save security groups'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function SubnetGroups({runtimeReachable}: {runtimeReachable: boolean}) {
|
|||||||
const subnetResources = cloudSubnets.data?.subnets ?? [];
|
const subnetResources = cloudSubnets.data?.subnets ?? [];
|
||||||
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "subnet-groups"]});
|
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "subnet-groups"]});
|
||||||
const create = useMutation({mutationFn: () => createCacheSubnetGroup({cacheSubnetGroupName: name.trim(), cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: () => {setName(""); setDescription(""); setSubnets([]); refresh();}}); const remove = useMutation({mutationFn: deleteCacheSubnetGroup, onSuccess: refresh}); const modify = useMutation({mutationFn: (group: string) => modifyCacheSubnetGroup(group, {cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: refresh});
|
const create = useMutation({mutationFn: () => createCacheSubnetGroup({cacheSubnetGroupName: name.trim(), cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: () => {setName(""); setDescription(""); setSubnets([]); refresh();}}); const remove = useMutation({mutationFn: deleteCacheSubnetGroup, onSuccess: refresh}); const modify = useMutation({mutationFn: (group: string) => modifyCacheSubnetGroup(group, {cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: refresh});
|
||||||
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} required/></label><label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} required/></label><label className="dynamic-field"><span>EC2 subnets</span><select className="input" aria-label="EC2 subnets" multiple value={subnets} onChange={(e) => setSubnets(Array.from(e.currentTarget.selectedOptions, (option) => option.value))} required>{subnetResources.map((subnet) => <option key={subnet.id} value={subnet.id}>{subnet.id} ({subnet.availabilityZone})</option>)}</select><small>Subnets are loaded from AWS cloud resource dependencies.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !subnets.length || create.isPending}><Plus size={13}/>Create subnet group</button></form><ActionError error={create.error ?? remove.error ?? modify.error}/><ListHeader onRefresh={() => groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) => <div className="apigateway-route-item" key={group.CacheSubnetGroupName}><div className="apigateway-route-select"><code>{group.CacheSubnetGroupName}</code><small>{group.CacheSubnetGroupDescription} · {group.Subnets?.map((subnet) => subnet.SubnetIdentifier).join(", ")}</small></div><button className="button compact" type="button" disabled={modify.isPending} onClick={() => group.CacheSubnetGroupName && modify.mutate(group.CacheSubnetGroupName)}>Apply selected subnets</button><button className="icon-btn danger" type="button" aria-label={`Delete subnet group ${group.CacheSubnetGroupName}`} onClick={() => group.CacheSubnetGroupName && remove.mutate(group.CacheSubnetGroupName)}><Trash2 size={14}/></button></div>)}</>;
|
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} required/></label><label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} required/></label><label className="dynamic-field"><span>EC2 subnets</span><select className="input subnet-multiselect" aria-label="EC2 subnets" multiple size={3} value={subnets} onChange={(e) => setSubnets(Array.from(e.currentTarget.selectedOptions, (option) => option.value))} required>{subnetResources.map((subnet) => <option key={subnet.id} value={subnet.id}>{subnet.id} ({subnet.availabilityZone})</option>)}</select><small>Subnets are loaded from AWS cloud resource dependencies.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !subnets.length || create.isPending}><Plus size={13}/>Create subnet group</button></form><ActionError error={create.error ?? remove.error ?? modify.error}/><ListHeader onRefresh={() => groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) => <div className="apigateway-route-item" key={group.CacheSubnetGroupName}><div className="apigateway-route-select"><code>{group.CacheSubnetGroupName}</code><small>{group.CacheSubnetGroupDescription} · {group.Subnets?.map((subnet) => subnet.SubnetIdentifier).join(", ")}</small></div><button className="button compact" type="button" disabled={modify.isPending} onClick={() => group.CacheSubnetGroupName && modify.mutate(group.CacheSubnetGroupName)}>Apply selected subnets</button><button className="icon-btn danger" type="button" aria-label={`Delete subnet group ${group.CacheSubnetGroupName}`} onClick={() => group.CacheSubnetGroupName && remove.mutate(group.CacheSubnetGroupName)}><Trash2 size={14}/></button></div>)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParameterGroups({runtimeReachable}: {runtimeReachable: boolean}) {
|
function ParameterGroups({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
|||||||
<section className="table-panel">
|
<section className="table-panel">
|
||||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Broker access is open: no IAM, SASL, or TLS authentication is configured.</p></div><button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button></div>
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Broker access is open: no IAM, SASL, or TLS authentication is configured.</p></div><button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button></div>
|
||||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
||||||
<label className="dynamic-field"><span>Cluster name</span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} required /></label><label className="dynamic-field"><span>Broker nodes</span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label><label className="dynamic-field"><span>Kafka version</span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label><label className="dynamic-field"><span>Client subnets</span><select className="input" multiple value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button>
|
<label className="dynamic-field"><span>Cluster name</span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} required /></label><label className="dynamic-field"><span>Broker nodes</span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label><label className="dynamic-field"><span>Kafka version</span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label><label className="dynamic-field"><span>Client subnets</span><select className="input subnet-multiselect" multiple size={3} value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="apigateway-route-explorer"><label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}</div>
|
<div className="apigateway-route-explorer"><label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1419,6 +1419,11 @@ pre,
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input.subnet-multiselect {
|
||||||
|
height: 78px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.input:focus-visible {
|
.input:focus-visible {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user