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,85 @@
import {apiClient} from './api'
import {HttpError} from './HttpClient'
export interface RedisCredentials {
username?: string
password?: string
}
export interface RedisTarget {
resourceId: string
resourceType: 'replication-group' | 'cache-cluster'
engine: 'redis' | 'valkey'
endpoint: {address: string; port: number; kind: 'configuration' | 'primary' | 'node'}
}
export interface RedisExplorerRequest {
resourceId: string
credentials?: RedisCredentials
}
export interface RedisKeyDetails {
key: string
type: string
ttlSeconds: number
value: unknown
}
export type ElastiCacheUser = { UserId?: string; UserName?: string; Engine?: string; AccessString?: string; Authentication?: { Type?: string } }
export type ElastiCacheSubnetGroup = { CacheSubnetGroupName?: string; CacheSubnetGroupDescription?: string; Subnets?: Array<{ SubnetIdentifier?: string }> }
export type ElastiCacheParameterGroup = { CacheParameterGroupName?: string; CacheParameterGroupFamily?: string; Description?: string }
export type ElastiCacheParameter = { ParameterName?: string; ParameterValue?: string; IsModifiable?: boolean; Description?: string; DataType?: string }
export type ElastiCacheUserInput = { userId: string; userName: string; engine: 'redis' | 'valkey'; accessString: string; authenticationMode: 'password' | 'no-password-required' | 'iam'; passwords?: string[] }
export type CacheSubnetGroupInput = { cacheSubnetGroupName: string; cacheSubnetGroupDescription: string; subnetIds: string[] }
export type CacheParameterGroupInput = { cacheParameterGroupName: string; cacheParameterGroupFamily: string; description: string }
export async function connectRedis(input: RedisExplorerRequest): Promise<{target: RedisTarget; ping: string}> {
return request('/elasticache/connect', input)
}
export async function scanRedisKeys(input: RedisExplorerRequest & {cursor?: string; count?: number; match?: string}): Promise<{cursor: string; keys: string[]}> {
return request('/elasticache/keys/scan', input)
}
export async function getRedisKeyDetails(input: RedisExplorerRequest & {key: string}): Promise<RedisKeyDetails | null> {
return request('/elasticache/keys/details', input)
}
export async function setRedisString(input: RedisExplorerRequest & {key: string; value: string; ttlSeconds?: number}): Promise<{ok: true}> {
return request('/elasticache/keys/set', input)
}
export async function deleteRedisKey(input: RedisExplorerRequest & {key: string}): Promise<{deleted: boolean}> {
return request('/elasticache/keys/delete', input)
}
export async function listElastiCacheUsers(): Promise<ElastiCacheUser[]> { return get('/elasticache/users') }
export async function createElastiCacheUser(input: ElastiCacheUserInput): Promise<unknown> { return request('/elasticache/users', input) }
export async function modifyElastiCacheUser(userId: string, input: Omit<ElastiCacheUserInput, 'userId' | 'userName' | 'engine'>): Promise<unknown> { return patch(`/elasticache/users/${encodeURIComponent(userId)}`, input) }
export async function deleteElastiCacheUser(userId: string): Promise<unknown> { return remove(`/elasticache/users/${encodeURIComponent(userId)}`) }
export async function listCacheSubnetGroups(): Promise<ElastiCacheSubnetGroup[]> { return get('/elasticache/subnet-groups') }
export async function createCacheSubnetGroup(input: CacheSubnetGroupInput): Promise<unknown> { return request('/elasticache/subnet-groups', input) }
export async function modifyCacheSubnetGroup(name: string, input: Omit<CacheSubnetGroupInput, 'cacheSubnetGroupName'>): Promise<unknown> { return patch(`/elasticache/subnet-groups/${encodeURIComponent(name)}`, input) }
export async function deleteCacheSubnetGroup(name: string): Promise<unknown> { return remove(`/elasticache/subnet-groups/${encodeURIComponent(name)}`) }
export async function listCacheParameterGroups(): Promise<ElastiCacheParameterGroup[]> { return get('/elasticache/parameter-groups') }
export async function createCacheParameterGroup(input: CacheParameterGroupInput): Promise<unknown> { return request('/elasticache/parameter-groups', input) }
export async function deleteCacheParameterGroup(name: string): Promise<unknown> { return remove(`/elasticache/parameter-groups/${encodeURIComponent(name)}`) }
export async function listCacheParameters(name: string): Promise<ElastiCacheParameter[]> { return get(`/elasticache/parameter-groups/${encodeURIComponent(name)}/parameters`) }
export async function setCacheParameters(name: string, parameters: Array<{parameterName: string; parameterValue: string}>): Promise<unknown> { return put(`/elasticache/parameter-groups/${encodeURIComponent(name)}/parameters`, {parameters}) }
async function request<TResponse, TBody>(path: string, body: TBody): Promise<TResponse> {
try {
return (await apiClient.post<TResponse, TBody>(path, body)).data
} catch (error) {
if (error instanceof HttpError) {
const apiError = error.body as {error?: {code?: unknown; message?: unknown}}
if (typeof apiError.error?.message === 'string') throw Object.assign(new Error(apiError.error.message), {cause: error})
}
throw error
}
}
async function get<T>(path: string): Promise<T> { return (await apiClient.get<T>(path)).data }
async function patch<T, B>(path: string, body: B): Promise<T> { return (await apiClient.patch<T, B>(path, body)).data }
async function put<T, B>(path: string, body: B): Promise<T> { return (await apiClient.put<T, B>(path, body)).data }
async function remove<T>(path: string): Promise<T> { return (await apiClient.delete<T>(path)).data }
@@ -72,6 +72,7 @@ import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPan
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {KinesisPanel} from "@/components/KinesisPanel";
import {SesConsolePanel} from '@/components/SesConsolePanel'
import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel'
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -1040,7 +1041,8 @@ export function DynamicResourceView({
{service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />}
{editingResource && schema.updateFields && (
<div
className="modal-overlay"
@@ -0,0 +1,35 @@
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {render, screen, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {beforeEach, expect, test, vi} from "vitest";
import {ElastiCacheManagementPanel} from "./ElastiCacheManagementPanel";
const api = vi.hoisted(() => ({
createCacheParameterGroup: vi.fn(), createCacheSubnetGroup: vi.fn(), createElastiCacheUser: vi.fn(), deleteCacheParameterGroup: vi.fn(), deleteCacheSubnetGroup: vi.fn(), deleteElastiCacheUser: vi.fn(), listCacheParameterGroups: vi.fn(), listCacheParameters: vi.fn(), listCacheSubnetGroups: vi.fn(), listElastiCacheUsers: vi.fn(), modifyCacheSubnetGroup: vi.fn(), modifyElastiCacheUser: vi.fn(), setCacheParameters: vi.fn(),
}));
const cloud = vi.hoisted(() => ({getLoadBalancingDependencies: vi.fn()}));
vi.mock("@/api/elasticacheClient", () => api);
vi.mock("@/api/cloudProxyClient", () => cloud);
beforeEach(() => {
vi.clearAllMocks();
api.listElastiCacheUsers.mockResolvedValue([]);
api.listCacheSubnetGroups.mockResolvedValue([]);
api.listCacheParameterGroups.mockResolvedValue([]);
cloud.getLoadBalancingDependencies.mockResolvedValue({subnets: [{id: "subnet-a", vpcId: "vpc-a", availabilityZone: "us-east-1a", cidrBlock: "10.0.1.0/24"}]});
});
test("creates cache subnet groups from cloud dependency subnet selections", async () => {
const user = userEvent.setup();
render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><ElastiCacheManagementPanel runtimeReachable /></QueryClientProvider>);
await user.click(screen.getByRole("button", {name: "subnet groups"}));
await screen.findByRole("option", {name: /subnet-a/});
await user.type(screen.getByLabelText("Name"), "application");
await user.type(screen.getByLabelText("Description"), "Application subnets");
await user.selectOptions(screen.getByLabelText("EC2 subnets"), "subnet-a");
await user.click(screen.getByRole("button", {name: "Create subnet group"}));
await waitFor(() => expect(api.createCacheSubnetGroup).toHaveBeenCalledWith({cacheSubnetGroupName: "application", cacheSubnetGroupDescription: "Application subnets", subnetIds: ["subnet-a"]}));
expect(cloud.getLoadBalancingDependencies).toHaveBeenCalled();
});
@@ -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; }
@@ -0,0 +1,71 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {RedisDataExplorer} from './RedisDataExplorer'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
connectRedis: vi.fn(),
scanRedisKeys: vi.fn(),
getRedisKeyDetails: vi.fn(),
setRedisString: vi.fn(),
deleteRedisKey: vi.fn(),
}))
vi.mock('@/api/elasticacheClient', () => apiMocks)
const cache: CloudResource = {
id: 'orders', name: 'orders', cloud: 'aws', service: 'elasticache', type: 'replication-group',
region: 'us-east-1', createdAt: null, engine: 'redis', metadata: {},
}
function renderExplorer() {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={queryClient}><RedisDataExplorer resource={cache} runtimeReachable/></QueryClientProvider>)
}
test('connects, scans matching keys, and inspects a typed value', async () => {
apiMocks.connectRedis.mockResolvedValue({ping: 'PONG', target: {engine: 'redis', endpoint: {address: 'redis.local', port: 6379, kind: 'primary'}}})
apiMocks.scanRedisKeys.mockResolvedValue({cursor: '0', keys: ['order:1']})
apiMocks.getRedisKeyDetails.mockResolvedValue({key: 'order:1', type: 'hash', ttlSeconds: 60, value: {status: 'new'}})
const user = userEvent.setup()
renderExplorer()
await user.type(screen.getByLabelText('Redis username'), 'app')
await user.type(screen.getByLabelText('Redis password'), 'secret')
await user.click(screen.getByRole('button', {name: 'Connect and ping'}))
await screen.findByText('redis.local:6379 (primary)')
expect(apiMocks.connectRedis).toHaveBeenCalledWith({resourceId: 'orders', credentials: {username: 'app', password: 'secret'}})
await user.type(screen.getByLabelText('Key match pattern'), 'order:*')
await user.click(screen.getByRole('button', {name: 'Scan keys'}))
expect(await screen.findByText('order:1')).toBeInTheDocument()
expect(apiMocks.scanRedisKeys).toHaveBeenCalledWith({resourceId: 'orders', credentials: {username: 'app', password: 'secret'}, match: 'order:*'})
await user.click(screen.getByRole('button', {name: 'order:1'}))
await screen.findByText('hash · 60s TTL')
expect(screen.getByText(/"status": "new"/)).toBeInTheDocument()
})
test('sets a string with a TTL and deletes the selected key', async () => {
apiMocks.connectRedis.mockResolvedValue({ping: 'PONG', target: {engine: 'valkey', endpoint: {address: 'cache.local', port: 6379, kind: 'configuration'}}})
apiMocks.scanRedisKeys.mockResolvedValue({cursor: '0', keys: ['order:1']})
apiMocks.getRedisKeyDetails.mockResolvedValue({key: 'order:1', type: 'string', ttlSeconds: -1, value: 'pending'})
apiMocks.setRedisString.mockResolvedValue({ok: true})
apiMocks.deleteRedisKey.mockResolvedValue({deleted: true})
const user = userEvent.setup()
renderExplorer()
await user.click(screen.getByRole('button', {name: 'Connect and ping'}))
await user.click(screen.getByRole('button', {name: 'Scan keys'}))
await user.click(await screen.findByRole('button', {name: 'order:1'}))
await user.click(await screen.findByRole('button', {name: 'Delete key'}))
await waitFor(() => expect(apiMocks.deleteRedisKey).toHaveBeenCalledWith({resourceId: 'orders', key: 'order:1'}))
await user.type(screen.getByLabelText('New Redis key'), 'order:2')
await user.type(screen.getByLabelText('New Redis value'), 'created')
await user.type(screen.getByLabelText('TTL seconds'), '30')
await user.click(screen.getByRole('button', {name: 'Set string'}))
await waitFor(() => expect(apiMocks.setRedisString).toHaveBeenCalledWith({resourceId: 'orders', key: 'order:2', value: 'created', ttlSeconds: 30}))
})
@@ -0,0 +1,150 @@
import {useEffect, useState} from 'react'
import {Database, KeyRound, Plus, Trash2} from 'lucide-react'
import {useMutation} from '@tanstack/react-query'
import {connectRedis, deleteRedisKey, getRedisKeyDetails, scanRedisKeys, setRedisString} from '@/api/elasticacheClient'
import type {CloudResource} from '@/types/resource'
interface RedisDataExplorerProps {
resource: CloudResource
runtimeReachable: boolean
}
export function RedisDataExplorer({resource, runtimeReachable}: RedisDataExplorerProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [connected, setConnected] = useState<Awaited<ReturnType<typeof connectRedis>>>()
const [match, setMatch] = useState('')
const [keys, setKeys] = useState<string[]>([])
const [cursor, setCursor] = useState<string | undefined>()
const [selectedKey, setSelectedKey] = useState<string>()
const [details, setDetails] = useState<Awaited<ReturnType<typeof getRedisKeyDetails>>>()
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [ttl, setTtl] = useState('')
const credentials = username || password ? {username: username || undefined, password: password || undefined} : undefined
const request = {resourceId: resource.id, ...(credentials && {credentials})}
const connection = useMutation({
mutationFn: () => connectRedis(request),
onSuccess: (result) => {
setConnected(result)
setKeys([])
setCursor(undefined)
setSelectedKey(undefined)
setDetails(undefined)
},
})
const scan = useMutation({
mutationFn: ({nextCursor}: {nextCursor?: string; append: boolean}) => scanRedisKeys({...request, cursor: nextCursor, match: match || undefined}),
onSuccess: (result, variables) => {
setKeys((current) => variables.append ? [...new Set([...current, ...result.keys])] : result.keys)
setCursor(result.cursor === '0' ? undefined : result.cursor)
},
})
const inspect = useMutation({
mutationFn: (key: string) => getRedisKeyDetails({...request, key}),
onSuccess: (result, key) => {
setSelectedKey(key)
setDetails(result)
},
})
const setString = useMutation({
mutationFn: () => setRedisString({...request, key: newKey, value: newValue, ...(ttl ? {ttlSeconds: Number(ttl)} : {})}),
onSuccess: () => {
setNewKey('')
setNewValue('')
setTtl('')
void scan.mutate({append: false})
},
})
const remove = useMutation({
mutationFn: (key: string) => deleteRedisKey({...request, key}),
onSuccess: (_result, key) => {
setKeys((current) => current.filter((item) => item !== key))
if (selectedKey === key) {
setSelectedKey(undefined)
setDetails(undefined)
}
},
})
useEffect(() => {
setConnected(undefined)
setKeys([])
setCursor(undefined)
setSelectedKey(undefined)
setDetails(undefined)
}, [resource.id])
function connect(event: React.FormEvent) {
event.preventDefault()
connection.mutate()
}
function scanKeys(event: React.FormEvent) {
event.preventDefault()
scan.mutate({append: false})
}
function addString(event: React.FormEvent) {
event.preventDefault()
if (!newKey || setString.isPending) return
setString.mutate()
}
const error = connection.error ?? scan.error ?? inspect.error ?? setString.error ?? remove.error
const endpoint = connected?.target.endpoint
return <section className="redis-explorer">
<div className="cosmos-column redis-connection">
<PanelHeader icon={<Database size={18}/>} eyebrow="Redis connection" title={resource.name} detail={connected ? `${connected.target.engine} connected` : 'Connect to inspect cache data'}/>
<form className="redis-form" onSubmit={connect}>
<label>Username<input aria-label="Redis username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="Optional"/></label>
<label>Password<input aria-label="Redis password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Optional"/></label>
<button className="button primary" type="submit" disabled={!runtimeReachable || connection.isPending}>{connection.isPending ? 'Connecting' : connected ? 'Ping again' : 'Connect and ping'}</button>
</form>
{connected && <div className="redis-endpoint"><strong>{connected.ping}</strong><span>{endpoint?.address}:{endpoint?.port} ({endpoint?.kind})</span></div>}
<p className="redis-note">Credentials are used only for this session and are not stored.</p>
</div>
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={<KeyRound size={18}/>} eyebrow="Keys" title={match ? `Match: ${match}` : 'All keys'} detail={`${keys.length} loaded`}/>
<form className="redis-scan" onSubmit={scanKeys}>
<label><span className="sr-only">Key match pattern</span><input aria-label="Key match pattern" value={match} onChange={(event) => setMatch(event.target.value)} placeholder="Match pattern, for example order:*"/></label>
<button className="button" type="submit" disabled={!connected || scan.isPending}>{scan.isPending ? 'Scanning' : 'Scan keys'}</button>
</form>
<div className="redis-key-list">
{!connected && <div className="empty compact"><h3>Connect to browse keys</h3><p>Redis commands run only after a successful ping.</p></div>}
{connected && !scan.isPending && keys.length === 0 && <div className="empty compact"><h3>No keys loaded</h3><p>Scan Redis to list keys matching the optional pattern.</p></div>}
{keys.map((key) => <button key={key} className={`redis-key ${selectedKey === key ? 'selected' : ''}`} type="button" onClick={() => inspect.mutate(key)}><KeyRound size={14}/><span>{key}</span></button>)}
{cursor && <button className="button redis-load-more" type="button" disabled={scan.isPending} onClick={() => scan.mutate({nextCursor: cursor, append: true})}>{scan.isPending ? 'Scanning' : 'Load more'}</button>}
</div>
</div>
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={<KeyRound size={18}/>} eyebrow="Value" title={details?.key ?? 'Select a key'} detail={details ? `${details.type} · ${formatTtl(details.ttlSeconds)}` : 'Type and TTL appear here'}/>
{selectedKey && <div className="redis-key-actions"><button className="button danger compact" type="button" disabled={remove.isPending} onClick={() => remove.mutate(selectedKey)}><Trash2 size={13}/>{remove.isPending ? 'Deleting' : 'Delete key'}</button></div>}
{details ? <pre className="redis-value">{formatValue(details.value)}</pre> : <div className="empty compact"><h3>Select a key</h3><p>Its type, TTL, and value will appear here.</p></div>}
<form className="redis-form redis-set-form" onSubmit={addString}>
<strong><Plus size={14}/> Set string</strong>
<label>Key<input aria-label="New Redis key" required value={newKey} onChange={(event) => setNewKey(event.target.value)}/></label>
<label>Value<textarea aria-label="New Redis value" required value={newValue} onChange={(event) => setNewValue(event.target.value)}/></label>
<label>TTL seconds<input aria-label="TTL seconds" type="number" min="1" value={ttl} onChange={(event) => setTtl(event.target.value)} placeholder="Optional"/></label>
<button className="button primary" type="submit" disabled={!connected || setString.isPending}>{setString.isPending ? 'Saving' : 'Set string'}</button>
</form>
{error instanceof Error && <div className="form-error">{error.message}</div>}
</div>
</section>
}
function PanelHeader({icon, eyebrow, title, detail}: {icon: React.ReactNode; eyebrow: string; title: string; detail: string}) {
return <div className="cosmos-panel-header">{icon}<span><small>{eyebrow}</small><strong>{title}</strong><em>{detail}</em></span></div>
}
function formatTtl(ttlSeconds: number): string {
if (ttlSeconds === -1) return 'no expiry'
if (ttlSeconds === -2) return 'expired'
return `${ttlSeconds}s TTL`
}
function formatValue(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
+34
View File
@@ -5570,8 +5570,13 @@ button.console-service-card {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
}
.data-workspace > .redis-explorer {
grid-template-columns: minmax(0, .8fr) minmax(0, 1fr) minmax(0, 1.1fr);
}
.data-workspace > .cosmos-panel,
.data-workspace > .dynamodb-explorer,
.data-workspace > .redis-explorer,
.data-workspace > .sql-panel,
.data-workspace .sql-explorer {
min-height: 0;
@@ -5633,6 +5638,7 @@ button.console-service-card {
@container (max-width: 1000px) {
.data-workspace > .cosmos-panel,
.data-workspace > .redis-explorer,
.data-workspace .sql-explorer {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
@@ -5660,6 +5666,7 @@ button.console-service-card {
@container (max-width: 600px) {
.data-workspace > .cosmos-panel,
.data-workspace > .dynamodb-explorer,
.data-workspace > .redis-explorer,
.data-workspace .sql-explorer,
.data-workspace .sql-connection-bar {
grid-template-columns: minmax(0, 1fr);
@@ -5670,3 +5677,30 @@ button.console-service-card {
grid-template-rows: auto auto;
}
}
.redis-explorer {
display: grid;
grid-template-columns: minmax(240px, .8fr) minmax(300px, 1fr) minmax(340px, 1.1fr);
gap: 10px;
min-height: 360px;
}
.redis-form { display: grid; gap: 10px; padding: 12px; }
.redis-form label { display: grid; gap: 4px; color: var(--text-2); font-size: 11px; font-weight: 600; }
.redis-form input, .redis-form textarea, .redis-scan input { width: 100%; box-sizing: border-box; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text); font: inherit; font-size: 12px; padding: 6px 8px; }
.redis-form textarea { min-height: 68px; resize: vertical; }
.redis-endpoint { display: grid; gap: 3px; padding: 10px 12px; border-top: 1px solid var(--border); color: var(--text-2); font-size: 11px; overflow-wrap: anywhere; }
.redis-endpoint strong { color: var(--status-success); font-size: 12px; }
.redis-note { margin: 0; padding: 0 12px 12px; color: var(--text-3); font-size: 11px; line-height: 1.45; }
.redis-scan { display: flex; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--raised); }
.redis-scan label { flex: 1; min-width: 0; }
.redis-key-list { flex: 1; min-height: 120px; overflow: auto; padding: 5px; }
.redis-key { display: flex; width: 100%; align-items: center; gap: 7px; padding: 8px; border: 0; border-radius: 3px; background: transparent; color: var(--text); font: inherit; font-size: 12px; text-align: left; cursor: pointer; }
.redis-key:hover, .redis-key.selected { background: var(--raised); }
.redis-key.selected { color: var(--link); }
.redis-key span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.redis-load-more { display: block; margin: 10px auto; }
.redis-key-actions { display: flex; justify-content: flex-end; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.redis-value { flex: 1; min-height: 110px; max-height: 310px; overflow: auto; margin: 0; padding: 12px; color: var(--text); font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.redis-set-form { border-top: 1px solid var(--border); background: var(--raised); }
.redis-set-form strong { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; }
@@ -25,4 +25,11 @@ describe('dataExplorerPath', () => {
test('does not expose unsupported RDS engines as SQL explorers', () => {
expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined()
})
test('enables the Redis explorer only for Redis and Valkey ElastiCache resources', () => {
const cache = (engine: string): CloudResource => ({...rdsInstance(engine), service: 'elasticache', type: 'replication-group'})
expect(dataExplorerKind(cache('redis'))).toBe('redis')
expect(dataExplorerPath({...cache('valkey'), type: 'cache-cluster'})).toBe('/cloud-explorer/aws/elasticache/orders-db/data')
expect(dataExplorerKind(cache('memcached'))).toBeUndefined()
})
})
+4 -1
View File
@@ -1,12 +1,15 @@
import type {CloudResource} from '@/types/resource'
export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | undefined {
export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | 'redis' | undefined {
if (resource.cloud === 'aws' && resource.service === 'nosql' && resource.type === 'dynamodb-table') return 'dynamodb'
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'database' &&
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'database' && resource.type === 'db-instance' &&
(resource.engine === 'postgres' || resource.engine === 'mysql')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'elasticache' &&
(resource.type === 'replication-group' || resource.type === 'cache-cluster') &&
(resource.engine === 'redis' || resource.engine === 'valkey')) return 'redis'
}
export function dataExplorerPath(resource: CloudResource): string | undefined {
@@ -7,6 +7,7 @@ import {useCloudServicesQuery, useCloudStatusQuery} from '@/api/queries/cloudQue
import {AzureSqlPanel} from '@/components/AzureSqlPanel'
import {CosmosNoSqlPanel} from '@/components/CosmosNoSqlPanel'
import {DynamoDbTableExplorer} from '@/components/DynamoDbTableExplorer'
import {RedisDataExplorer} from '@/components/RedisDataExplorer'
import {useAccountId} from '@/lib/accountStore'
import {dataExplorerKind} from '@/lib/dataExplorer'
import type {CloudProvider} from '@/types/cloud'
@@ -62,6 +63,10 @@ function DatabaseDataWorkspace({cloud, service, resourceId}: {cloud: CloudProvid
if (kind === 'dynamodb') return <DynamoDbTableExplorer cloud={cloud} resource={resource} runtimeReachable/>
if (kind === 'cosmos') return <CosmosNoSqlPanel key={containerId ?? ''} cloud={cloud} resource={resource} runtimeReachable selectedContainerId={containerId} onSelectContainer={selectContainer}/>
if (kind === 'sql') return <AzureSqlPanel cloud={cloud} resource={resource} runtimeReachable/>
if (kind === 'redis') return <RedisDataExplorer resource={resource} runtimeReachable/>
if (resource.cloud === 'aws' && resource.service === 'elasticache' && resource.engine === 'memcached') {
return <DataNotice title="Data exploration unavailable" detail="Memcached supports management operations only. Redis and Valkey caches can be explored here."/>
}
return <DataNotice title="Data exploration unavailable" detail="This resource supports management operations only."/>
}