bug fixes: subnets listing, icons resizing and some shenanigans

This commit is contained in:
Zakaria
2026-10-07 10:54:58 -04:00
parent 71c2d276b6
commit 0036ef7e51
9 changed files with 51 additions and 8 deletions
+3 -3
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<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." />
<title>Floci UI</title>
<meta name="description" content="TheCloud UI — Any Cloud. Locally. The local cloud console for TheCloud." />
<title>The Cloud</title>
<script>
(function () {
var t = localStorage.getItem("floci-theme");
@@ -16,7 +16,7 @@
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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
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"
@@ -5,6 +5,7 @@ import {
createEc2SecurityGroup,
revokeEc2SecurityGroupEgress,
revokeEc2SecurityGroupIngress,
updateEc2InstanceSecurityGroups,
} from "./ec2.api";
describe("createEc2SecurityGroup", () => {
@@ -84,3 +85,29 @@ describe("EC2 security-group rule requests", () => {
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"]});
});
});
+1 -1
View File
@@ -338,7 +338,7 @@ export async function updateEc2InstanceTags(
export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise<void> {
await apiClient.call(
apiEndpointKeys.aws.ec2.instances.securityGroups,
{ body: JSON.stringify({ groupIds }), headers: { "content-type": "application/json" } },
{ body: { groupIds } },
{ 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 groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)})
const mutation = useMutation({
mutationFn: () => updateEc2InstanceSecurityGroups(instanceId, [...selected]),
mutationFn: (groupIds: string[]) => updateEc2InstanceSecurityGroups(instanceId, groupIds),
onSuccess: () => {
void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]})
void qc.invalidateQueries({queryKey: ['ec2', 'instances']})
@@ -177,7 +177,7 @@ function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: {
</label>)}
{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>}
<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'}
</button>
</div>
@@ -40,7 +40,7 @@ function SubnetGroups({runtimeReachable}: {runtimeReachable: boolean}) {
const subnetResources = cloudSubnets.data?.subnets ?? [];
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});
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}) {
@@ -41,7 +41,7 @@ export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {
<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>
<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>
<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>
+5
View File
@@ -1419,6 +1419,11 @@ pre,
font-size: 13px;
}
.input.subnet-multiselect {
height: 78px;
padding: 4px 10px;
}
.input:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);