feat: ElastiCache

This commit is contained in:
Zakaria
2026-10-05 16:03:05 -04:00
parent c0d3e5f402
commit c86b4554c0
27 changed files with 2454 additions and 5 deletions
@@ -0,0 +1,54 @@
import {useEffect, useState} from "react";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {Plus, RefreshCw, Trash2} from "lucide-react";
import {getLoadBalancingDependencies} from "@/api/cloudProxyClient";
import {
createCacheParameterGroup, createCacheSubnetGroup, createElastiCacheUser, deleteCacheParameterGroup, deleteCacheSubnetGroup,
deleteElastiCacheUser, listCacheParameterGroups, listCacheParameters, listCacheSubnetGroups, listElastiCacheUsers,
modifyCacheSubnetGroup, modifyElastiCacheUser, setCacheParameters,
} from "@/api/elasticacheClient";
type Tab = "users" | "subnet-groups" | "parameter-groups";
export function ElastiCacheManagementPanel({runtimeReachable}: {runtimeReachable: boolean}) {
const [tab, setTab] = useState<Tab>("users");
return <section className="table-panel" style={{marginTop: 16}}>
<div className="dynamic-stage-header"><div><p className="eyebrow">ElastiCache management</p><h3>Users and configuration groups</h3></div></div>
<div className="drawer-tabs">
{(["users", "subnet-groups", "parameter-groups"] as Tab[]).map((item) => <button className={`drawer-tab ${tab === item ? "active" : ""}`} type="button" key={item} onClick={() => setTab(item)}>{item.replace("-", " ")}</button>)}
</div>
{tab === "users" && <Users runtimeReachable={runtimeReachable}/>} {tab === "subnet-groups" && <SubnetGroups runtimeReachable={runtimeReachable}/>} {tab === "parameter-groups" && <ParameterGroups runtimeReachable={runtimeReachable}/>}
</section>;
}
function Users({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [userId, setUserId] = useState(""); const [userName, setUserName] = useState(""); const [engine, setEngine] = useState<"redis" | "valkey">("redis"); const [accessString, setAccessString] = useState("on ~* +@all"); const [mode, setMode] = useState<"password" | "no-password-required" | "iam">("no-password-required"); const [passwords, setPasswords] = useState("");
const query = useQuery({queryKey: ["elasticache", "users"], queryFn: listElastiCacheUsers, enabled: runtimeReachable});
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "users"]});
const create = useMutation({mutationFn: () => createElastiCacheUser({userId: userId.trim(), userName: userName.trim(), engine, accessString: accessString.trim(), authenticationMode: mode, ...(passwords.trim() && {passwords: passwords.split("\n").map((value) => value.trim()).filter(Boolean)})}), onSuccess: () => {setUserId(""); setUserName(""); setPasswords(""); refresh();}});
const remove = useMutation({mutationFn: deleteElastiCacheUser, onSuccess: refresh});
const modify = useMutation({mutationFn: (id: string) => modifyElastiCacheUser(id, {accessString: accessString.trim(), authenticationMode: mode, ...(passwords.trim() && {passwords: passwords.split("\n").map((value) => value.trim()).filter(Boolean)})}), onSuccess: refresh});
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>User ID</span><input className="input" value={userId} onChange={(e) => setUserId(e.target.value)} required/></label><label className="dynamic-field"><span>User name</span><input className="input" value={userName} onChange={(e) => setUserName(e.target.value)} required/></label><label className="dynamic-field"><span>Engine</span><select className="input" value={engine} onChange={(e) => setEngine(e.target.value as typeof engine)}><option value="redis">Redis</option><option value="valkey">Valkey</option></select></label><UserSettings accessString={accessString} setAccessString={setAccessString} mode={mode} setMode={setMode} passwords={passwords} setPasswords={setPasswords}/><button className="button primary" type="submit" disabled={!runtimeReachable || create.isPending}><Plus size={13}/>Create user</button></form><ActionError error={create.error ?? remove.error ?? modify.error}/><ListHeader onRefresh={() => query.refetch()} loading={query.isFetching}/>{query.data?.map((user) => <div className="apigateway-route-item" key={user.UserId}><div className="apigateway-route-select"><code>{user.UserName ?? user.UserId}</code><small>{user.Engine} · {user.Authentication?.Type ?? "authentication unset"} · {user.AccessString}</small></div><button className="button compact" type="button" onClick={() => user.UserId && modify.mutate(user.UserId)} disabled={!runtimeReachable || modify.isPending}>Apply settings</button><button className="icon-btn danger" type="button" aria-label={`Delete user ${user.UserId}`} onClick={() => user.UserId && remove.mutate(user.UserId)}><Trash2 size={14}/></button></div>)}</>;
}
function UserSettings(props: {accessString: string; setAccessString: (value: string) => void; mode: "password" | "no-password-required" | "iam"; setMode: (value: "password" | "no-password-required" | "iam") => void; passwords: string; setPasswords: (value: string) => void}) { return <><label className="dynamic-field"><span>Access string</span><input className="input" value={props.accessString} onChange={(e) => props.setAccessString(e.target.value)} required/></label><label className="dynamic-field"><span>Authentication</span><select className="input" value={props.mode} onChange={(e) => props.setMode(e.target.value as typeof props.mode)}><option value="no-password-required">No password</option><option value="password">Password</option><option value="iam">IAM</option></select></label>{props.mode === "password" && <label className="dynamic-field"><span>Passwords</span><textarea className="textarea" value={props.passwords} onChange={(e) => props.setPasswords(e.target.value)} required/><small>One password per line.</small></label>}</> }
function SubnetGroups({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [subnets, setSubnets] = useState<string[]>([]);
const groups = useQuery({queryKey: ["elasticache", "subnet-groups"], queryFn: listCacheSubnetGroups, enabled: runtimeReachable});
const cloudSubnets = useQuery({queryKey: ["cloud-dependencies", "aws", "subnets"], queryFn: () => getLoadBalancingDependencies(), enabled: runtimeReachable});
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>)}</>;
}
function ParameterGroups({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [name, setName] = useState(""); const [family, setFamily] = useState("redis7"); const [description, setDescription] = useState(""); const [selected, setSelected] = useState(""); const [parameterName, setParameterName] = useState(""); const [parameterValue, setParameterValue] = useState("");
const groups = useQuery({queryKey: ["elasticache", "parameter-groups"], queryFn: listCacheParameterGroups, enabled: runtimeReachable}); const parameters = useQuery({queryKey: ["elasticache", "parameters", selected], queryFn: () => listCacheParameters(selected), enabled: runtimeReachable && Boolean(selected)}); useEffect(() => {if (!selected && groups.data?.[0]?.CacheParameterGroupName) setSelected(groups.data[0].CacheParameterGroupName);}, [groups.data, selected]);
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "parameter-groups"]}); const create = useMutation({mutationFn: () => createCacheParameterGroup({cacheParameterGroupName: name.trim(), cacheParameterGroupFamily: family.trim(), description: description.trim()}), onSuccess: () => {setName(""); refresh();}}); const remove = useMutation({mutationFn: deleteCacheParameterGroup, onSuccess: refresh}); const set = useMutation({mutationFn: () => setCacheParameters(selected, [{parameterName: parameterName.trim(), parameterValue: parameterValue.trim()}]), onSuccess: () => {setParameterName(""); setParameterValue(""); void qc.invalidateQueries({queryKey: ["elasticache", "parameters", selected]});}});
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>Family</span><input className="input" value={family} onChange={(e) => setFamily(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><button className="button primary" type="submit" disabled={!runtimeReachable || create.isPending}><Plus size={13}/>Create parameter group</button></form><ActionError error={create.error ?? remove.error ?? set.error}/><ListHeader onRefresh={() => groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) => <div className="apigateway-route-item" key={group.CacheParameterGroupName}><div className="apigateway-route-select"><code>{group.CacheParameterGroupName}</code><small>{group.CacheParameterGroupFamily} · {group.Description}</small></div><button className="button compact" type="button" onClick={() => setSelected(group.CacheParameterGroupName ?? "")}>Parameters</button><button className="icon-btn danger" type="button" aria-label={`Delete parameter group ${group.CacheParameterGroupName}`} onClick={() => group.CacheParameterGroupName && remove.mutate(group.CacheParameterGroupName)}><Trash2 size={14}/></button></div>)}{selected && <form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); set.mutate();}}><label className="dynamic-field"><span>Parameter group</span><input className="input" value={selected} readOnly/></label><label className="dynamic-field"><span>Parameter name</span><input className="input" value={parameterName} onChange={(e) => setParameterName(e.target.value)} required/></label><label className="dynamic-field"><span>Parameter value</span><input className="input" value={parameterValue} onChange={(e) => setParameterValue(e.target.value)} required/></label><button className="button primary" type="submit" disabled={set.isPending}>Set parameter</button></form>}{parameters.data?.map((parameter) => <div className="apigateway-route-item" key={parameter.ParameterName}><div className="apigateway-route-select"><code>{parameter.ParameterName}</code><small>{parameter.ParameterValue} {parameter.IsModifiable ? "(modifiable)" : ""}</small></div></div>)}</>;
}
function ListHeader({onRefresh, loading}: {onRefresh: () => void; loading: boolean}) { return <div className="resource-table-tools" style={{margin: "12px 0"}}><button className="button compact" type="button" onClick={onRefresh} disabled={loading}><RefreshCw size={13}/>{loading ? "Loading" : "Refresh"}</button></div>; }
function ActionError({error}: {error: Error | null}) { return error ? <p className="error-text compact-text">{error.message}</p> : null; }