first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -0,0 +1,129 @@
import {useEffect, useRef, useState} from 'react'
import {useQueryClient} from '@tanstack/react-query'
import {Check, ChevronDown, UserRound} from 'lucide-react'
import {
DEFAULT_ACCOUNT_ID,
isAccountId,
setAccountId,
useAccountId,
useAccountRecents,
} from '@/lib/accountStore'
function formatAccount(id: string): string {
// 000000000000 -> 0000-0000-0000, matching how AWS groups account ids.
return id.replace(/(\d{4})(\d{4})(\d{4})/, '$1-$2-$3')
}
export function AccountSwitcher() {
const accountId = useAccountId()
const recents = useAccountRecents()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onClick = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
function applyAccount(id: string) {
if (!setAccountId(id)) {
setError('Account id must be exactly 12 digits.')
return
}
setDraft('')
setError(null)
setOpen(false)
// Resources are account-scoped, so anything cached under the previous
// account must not leak into the new view.
void queryClient.invalidateQueries()
}
function submitDraft() {
const trimmed = draft.trim()
if (!isAccountId(trimmed)) {
setError('Account id must be exactly 12 digits.')
return
}
applyAccount(trimmed)
}
return (
<div className="account-switcher" ref={containerRef}>
<button
type="button"
className="account-trigger"
onClick={() => setOpen((v) => !v)}
title="Switch AWS account"
aria-label={`Switch AWS account ${formatAccount(accountId)}`}
aria-haspopup="listbox"
aria-expanded={open}
>
<UserRound size={14}/>
<span className="account-meta">
<span className="account-label">Account</span>
<span className="account-value">{formatAccount(accountId)}</span>
</span>
<ChevronDown size={14}/>
</button>
{open && (
<div className="account-popover" role="listbox">
<div className="account-popover-title">Switch account</div>
<div className="account-recents">
{recents.map((id) => (
<button
key={id}
type="button"
className={`account-option${id === accountId ? ' active' : ''}`}
role="option"
aria-selected={id === accountId}
onClick={() => applyAccount(id)}
>
<span className="account-option-id">{formatAccount(id)}</span>
{id === DEFAULT_ACCOUNT_ID && <span className="account-tag">default</span>}
{id === accountId && <Check size={13}/>}
</button>
))}
</div>
<form
className="account-entry"
onSubmit={(event) => {
event.preventDefault()
submitDraft()
}}
>
<input
value={draft}
inputMode="numeric"
maxLength={12}
placeholder="12-digit account id"
aria-label="New account id"
onChange={(event) => {
setDraft(event.target.value.replace(/\D/g, ''))
setError(null)
}}
/>
<button type="submit" disabled={!isAccountId(draft.trim())}>
Switch
</button>
</form>
{error && <div className="account-error">{error}</div>}
</div>
)}
</div>
)
}
@@ -0,0 +1,50 @@
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 {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createApiGatewayLambdaRoute: vi.fn(),
listCloudResources: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => apiMocks)
const api: CloudResource = {
id: 'api-123', name: 'orders', cloud: 'aws', service: 'apigateway', type: 'rest-api',
region: null, createdAt: null, metadata: {},
}
const lambda: CloudResource = {
id: 'orders-handler', name: 'orders-handler', cloud: 'aws', service: 'serverless', type: 'lambda',
region: null, createdAt: null, metadata: {},
}
test('creates and displays a deployed Lambda proxy route', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({
resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders',
})
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/>
</QueryClientProvider>,
)
await user.clear(screen.getByPlaceholderText('/orders'))
await user.type(screen.getByPlaceholderText('/orders'), '/orders')
await user.selectOptions(screen.getAllByRole('combobox')[1]!, 'orders-handler')
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
await waitFor(() => {
expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
path: '/orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
})
})
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
})
@@ -0,0 +1,104 @@
import {useEffect, useState} from 'react'
import {Link2, Loader2, Rocket} from 'lucide-react'
import {useMutation, useQuery} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, listCloudResources} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) {
const [path, setPath] = useState('/hello')
const [method, setMethod] = useState('GET')
const [stage, setStage] = useState('dev')
const [functionName, setFunctionName] = useState('')
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setPath('/hello')
setMethod('GET')
setStage('dev')
setFunctionName('')
}, [resource?.id])
const routeMutation = useMutation({
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
})
if (!resource || resource.service !== 'apigateway') {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a REST API</h3>
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
</div>
</section>
)
}
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
return (
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">REST API Route</p>
<h3><Link2 size={15}/> Connect Lambda</h3>
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
</div>
</div>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) routeMutation.mutate()
}}>
<label className="dynamic-field">
<span>Route path</span>
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
<small>One path segment, for example <code>/qrcode</code>.</small>
</label>
<label className="dynamic-field">
<span>Method</span>
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
<small>Lambda proxy method.</small>
</label>
<label className="dynamic-field">
<span>Lambda function</span>
<select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
<small>{functionsQuery.isLoading ? 'Loading functions...' : functions.length === 0 ? 'Create a Lambda function before adding a route.' : 'Receives this route\'s proxy event.'}</small>
</label>
<label className="dynamic-field">
<span>Stage</span>
<input className="input" value={stage} onChange={(event) => setStage(event.target.value)} placeholder="dev" required/>
<small>Deployment stage name.</small>
</label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
{routeMutation.isPending ? <Loader2 size={13}/> : <Rocket size={13}/>}
{routeMutation.isPending ? 'Deploying' : 'Create and deploy route'}
</button>
</form>
{routeMutation.isError && <p className="error-text compact-text">{routeMutation.error instanceof Error ? routeMutation.error.message : 'Unable to create API route.'}</p>}
{routeMutation.data && (
<div className="apigateway-route-result">
<strong>Route deployed</strong>
<a href={routeMutation.data.invokeUrl} target="_blank" rel="noreferrer">{routeMutation.data.invokeUrl}</a>
</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,90 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {act, render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {AppConfigPanel} from './AppConfigPanel'
import {DEFAULT_ACCOUNT_ID, getAccountId, setAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
const cloudProxyMocks = vi.hoisted(() => ({
listAppConfigEnvironments: vi.fn(),
listAppConfigConfigurationProfiles: vi.fn(),
listAppConfigDeploymentStrategies: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => ({
createAppConfigConfigurationProfile: vi.fn(),
createAppConfigDeploymentStrategy: vi.fn(),
createAppConfigEnvironment: vi.fn(),
createAppConfigHostedConfigurationVersion: vi.fn(),
deleteAppConfigConfigurationProfile: vi.fn(),
deleteAppConfigDeploymentStrategy: vi.fn(),
deleteAppConfigEnvironment: vi.fn(),
deleteAppConfigHostedConfigurationVersion: vi.fn(),
getAppConfigDeployment: vi.fn(),
getAppConfigHostedConfigurationVersion: vi.fn(),
listAppConfigConfigurationProfiles: cloudProxyMocks.listAppConfigConfigurationProfiles,
listAppConfigDeploymentStrategies: cloudProxyMocks.listAppConfigDeploymentStrategies,
listAppConfigEnvironments: cloudProxyMocks.listAppConfigEnvironments,
listAppConfigHostedConfigurationVersions: vi.fn(),
startAppConfigDeployment: vi.fn(),
}))
const application: CloudResource = {
id: 'app-1',
name: 'orders',
cloud: 'aws',
service: 'configuration',
type: 'appconfig-application',
region: 'us-east-1',
createdAt: null,
metadata: {},
}
describe('AppConfigPanel account isolation', () => {
beforeEach(() => {
setAccountId(DEFAULT_ACCOUNT_ID)
cloudProxyMocks.listAppConfigEnvironments.mockReset()
cloudProxyMocks.listAppConfigConfigurationProfiles.mockReset()
cloudProxyMocks.listAppConfigDeploymentStrategies.mockReset()
cloudProxyMocks.listAppConfigConfigurationProfiles.mockResolvedValue([])
cloudProxyMocks.listAppConfigDeploymentStrategies.mockResolvedValue([])
cloudProxyMocks.listAppConfigEnvironments.mockImplementation(async () => [{
id: `environment-${getAccountId()}`,
applicationId: application.id,
name: `environment-${getAccountId()}`,
description: null,
state: 'READY_FOR_DEPLOYMENT',
}])
})
test('loads a fresh cache and resets account-scoped form state after an account switch', async () => {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<AppConfigPanel cloud="aws" resource={application} runtimeReachable/>
</QueryClientProvider>,
)
expect(await screen.findAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(2)
const environmentName = screen.getByPlaceholderText('Environment name')
await user.type(environmentName, 'private-draft')
expect(environmentName).toHaveValue('private-draft')
await act(async () => {
setAccountId('111111111111')
})
expect(await screen.findAllByText('environment-111111111111')).toHaveLength(2)
expect(screen.queryAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(0)
expect(screen.getByPlaceholderText('Environment name')).toHaveValue('')
await waitFor(() => {
expect(cloudProxyMocks.listAppConfigEnvironments).toHaveBeenCalledTimes(2)
})
expect(queryClient.getQueryData(['appconfig-environments', DEFAULT_ACCOUNT_ID, 'aws', application.id])).toBeDefined()
expect(queryClient.getQueryData(['appconfig-environments', '111111111111', 'aws', application.id])).toBeDefined()
})
})
@@ -0,0 +1,548 @@
import {FormEvent, useEffect, useMemo, useState} from 'react'
import {Plus, RefreshCw, Rocket, SlidersHorizontal, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
createAppConfigConfigurationProfile,
createAppConfigDeploymentStrategy,
createAppConfigEnvironment,
createAppConfigHostedConfigurationVersion,
deleteAppConfigConfigurationProfile,
deleteAppConfigDeploymentStrategy,
deleteAppConfigEnvironment,
deleteAppConfigHostedConfigurationVersion,
getAppConfigDeployment,
getAppConfigHostedConfigurationVersion,
listAppConfigConfigurationProfiles,
listAppConfigDeploymentStrategies,
listAppConfigEnvironments,
listAppConfigHostedConfigurationVersions,
startAppConfigDeployment,
} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {AppConfigDeployment, CloudResource} from '@/types/resource'
import {useAccountId} from '@/lib/accountStore'
const TERMINAL_DEPLOYMENT_STATES = new Set(['COMPLETE', 'ROLLED_BACK', 'STOPPED'])
const PROFILE_TYPES = ['AWS.Freeform', 'AWS.AppConfig.FeatureFlags']
interface AppConfigPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
export function AppConfigPanel({cloud, resource, runtimeReachable}: AppConfigPanelProps) {
const accountId = useAccountId()
return (
<AccountScopedAppConfigPanel
key={`${accountId}:${cloud}:${resource?.id ?? ''}`}
accountId={accountId}
cloud={cloud}
resource={resource}
runtimeReachable={runtimeReachable}
/>
)
}
interface AccountScopedAppConfigPanelProps extends AppConfigPanelProps {
accountId: string
}
function AccountScopedAppConfigPanel({accountId, cloud, resource, runtimeReachable}: AccountScopedAppConfigPanelProps) {
const qc = useQueryClient()
const applicationId = resource?.id
const [selectedProfileId, setSelectedProfileId] = useState<string>()
const [selectedVersionNumber, setSelectedVersionNumber] = useState<number>()
const [environmentName, setEnvironmentName] = useState('')
const [profileName, setProfileName] = useState('')
const [profileType, setProfileType] = useState(PROFILE_TYPES[0])
const [versionContent, setVersionContent] = useState('')
const [versionContentType, setVersionContentType] = useState('application/json')
const [versionDescription, setVersionDescription] = useState('')
const [strategyName, setStrategyName] = useState('')
const [strategyBake, setStrategyBake] = useState('')
const [deployEnvironmentId, setDeployEnvironmentId] = useState('')
const [deployProfileId, setDeployProfileId] = useState('')
const [deployVersion, setDeployVersion] = useState('')
const [deployStrategyId, setDeployStrategyId] = useState('')
const [confirmProfile, setConfirmProfile] = useState<string | null>(null)
const [confirmVersion, setConfirmVersion] = useState<number | null>(null)
const [confirmStrategy, setConfirmStrategy] = useState<string | null>(null)
const [confirmEnvironment, setConfirmEnvironment] = useState<string | null>(null)
const [activeDeployment, setActiveDeployment] = useState<AppConfigDeployment>()
const environmentsKey = useMemo(
() => ['appconfig-environments', accountId, cloud, applicationId],
[accountId, cloud, applicationId],
)
const profilesKey = useMemo(
() => ['appconfig-profiles', accountId, cloud, applicationId],
[accountId, cloud, applicationId],
)
const versionsKey = useMemo(
() => ['appconfig-versions', accountId, cloud, applicationId, selectedProfileId],
[accountId, cloud, applicationId, selectedProfileId],
)
const environmentsQuery = useQuery({
queryKey: environmentsKey,
queryFn: ({signal}) => listAppConfigEnvironments(cloud, applicationId ?? '', signal),
enabled: Boolean(applicationId) && runtimeReachable,
})
const profilesQuery = useQuery({
queryKey: profilesKey,
queryFn: ({signal}) => listAppConfigConfigurationProfiles(cloud, applicationId ?? '', signal),
enabled: Boolean(applicationId) && runtimeReachable,
})
const versionsQuery = useQuery({
queryKey: versionsKey,
queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', selectedProfileId ?? '', signal),
enabled: Boolean(applicationId && selectedProfileId) && runtimeReachable,
})
const deployVersionsQuery = useQuery({
queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId],
queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', deployProfileId ?? '', signal),
enabled: Boolean(applicationId && deployProfileId) && runtimeReachable,
})
const strategiesQuery = useQuery({
queryKey: ['appconfig-strategies', accountId, cloud],
queryFn: ({signal}) => listAppConfigDeploymentStrategies(cloud, signal),
enabled: runtimeReachable,
})
const versionContentQuery = useQuery({
queryKey: ['appconfig-version-content', accountId, cloud, applicationId, selectedProfileId, selectedVersionNumber],
queryFn: ({signal}) => getAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', selectedVersionNumber ?? 0, signal),
enabled: Boolean(applicationId && selectedProfileId && selectedVersionNumber) && runtimeReachable,
})
const deploymentQuery = useQuery({
queryKey: ['appconfig-deployment', accountId, cloud, applicationId, activeDeployment?.environmentId, activeDeployment?.deploymentNumber],
queryFn: ({signal}) => getAppConfigDeployment(cloud, applicationId ?? '', activeDeployment?.environmentId ?? '', activeDeployment?.deploymentNumber ?? 0, signal),
enabled: Boolean(applicationId && activeDeployment) && runtimeReachable,
refetchInterval: (query) => (TERMINAL_DEPLOYMENT_STATES.has(query.state.data?.state ?? '') ? false : 2000),
})
const createEnvironmentMut = useMutation({
mutationFn: () => createAppConfigEnvironment(cloud, applicationId ?? '', {name: environmentName}),
onSuccess: () => {
setEnvironmentName('')
void qc.invalidateQueries({queryKey: environmentsKey})
},
})
const deleteEnvironmentMut = useMutation({
mutationFn: (environmentId: string) => deleteAppConfigEnvironment(cloud, applicationId ?? '', environmentId),
onSuccess: (_, environmentId) => {
if (deployEnvironmentId === environmentId) setDeployEnvironmentId('')
if (activeDeployment?.environmentId === environmentId) setActiveDeployment(undefined)
setConfirmEnvironment(null)
void qc.invalidateQueries({queryKey: environmentsKey})
},
})
const createProfileMut = useMutation({
mutationFn: () => createAppConfigConfigurationProfile(cloud, applicationId ?? '', {
name: profileName,
locationUri: 'hosted',
type: profileType,
}),
onSuccess: (profile) => {
setProfileName('')
setSelectedProfileId(profile.id)
void qc.invalidateQueries({queryKey: profilesKey})
},
})
const deleteProfileMut = useMutation({
mutationFn: (profileId: string) => deleteAppConfigConfigurationProfile(cloud, applicationId ?? '', profileId),
onSuccess: (_, profileId) => {
if (selectedProfileId === profileId) setSelectedProfileId(undefined)
if (deployProfileId === profileId) {
setDeployProfileId('')
setDeployVersion('')
}
setConfirmProfile(null)
void qc.invalidateQueries({queryKey: profilesKey})
},
})
const createVersionMut = useMutation({
mutationFn: () => createAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', {
content: versionContent,
contentType: versionContentType,
...(versionDescription.trim() ? {description: versionDescription} : {}),
}),
onSuccess: (version) => {
setSelectedVersionNumber(version.versionNumber)
setVersionContent('')
setVersionDescription('')
void qc.invalidateQueries({queryKey: versionsKey})
void qc.invalidateQueries({queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId]})
},
})
const deleteVersionMut = useMutation({
mutationFn: (versionNumber: number) => deleteAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', versionNumber),
onSuccess: (_, versionNumber) => {
if (selectedVersionNumber === versionNumber) setSelectedVersionNumber(undefined)
if (deployVersion === String(versionNumber)) setDeployVersion('')
setConfirmVersion(null)
void qc.invalidateQueries({queryKey: versionsKey})
},
})
const createStrategyMut = useMutation({
mutationFn: () => createAppConfigDeploymentStrategy(cloud, {
name: strategyName,
deploymentDurationInMinutes: 0,
growthFactor: 100,
...(strategyBake.trim() ? {finalBakeTimeInMinutes: Number(strategyBake)} : {}),
}),
onSuccess: () => {
setStrategyName('')
setStrategyBake('')
void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]})
},
})
const deleteStrategyMut = useMutation({
mutationFn: (strategyId: string) => deleteAppConfigDeploymentStrategy(cloud, strategyId),
onSuccess: (_, strategyId) => {
if (deployStrategyId === strategyId) setDeployStrategyId('')
setConfirmStrategy(null)
void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]})
},
})
const startDeploymentMut = useMutation({
mutationFn: () => startAppConfigDeployment(cloud, applicationId ?? '', deployEnvironmentId, {
configurationProfileId: deployProfileId,
configurationVersion: deployVersion,
deploymentStrategyId: deployStrategyId,
}),
onSuccess: (deployment) => setActiveDeployment(deployment),
})
useEffect(() => {
setSelectedProfileId(undefined)
setSelectedVersionNumber(undefined)
setActiveDeployment(undefined)
setDeployEnvironmentId('')
setDeployProfileId('')
setDeployVersion('')
setDeployStrategyId('')
}, [cloud, applicationId])
useEffect(() => {
setSelectedVersionNumber(undefined)
}, [selectedProfileId])
if (cloud !== 'aws') return null
if (!applicationId) {
return (
<section className="cosmos-panel appconfig-panel">
<div className="empty compact">
<h3>Select an application</h3>
<p>Environments, configuration profiles, hosted versions, and deployments are loaded after an application is selected.</p>
</div>
</section>
)
}
const environments = environmentsQuery.data ?? []
const profiles = profilesQuery.data ?? []
const versions = versionsQuery.data ?? []
const strategies = strategiesQuery.data ?? []
const deployment = deploymentQuery.data ?? activeDeployment
const canDeploy = Boolean(deployEnvironmentId && deployProfileId && deployVersion && deployStrategyId) && runtimeReachable
function submitEnvironment(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (environmentName.trim()) createEnvironmentMut.mutate()
}
function submitProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (profileName.trim()) createProfileMut.mutate()
}
function submitVersion(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (versionContent.trim()) createVersionMut.mutate()
}
function submitStrategy(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (strategyName.trim()) createStrategyMut.mutate()
}
return (
<section className="cosmos-panel appconfig-panel">
<div className="cosmos-column">
<ColumnHeader eyebrow="Environments" title={resource?.name ?? ''} detail={`${environments.length} environments`}/>
<form className="cosmos-inline-form" onSubmit={submitEnvironment}>
<input className="input" value={environmentName} onChange={(event) => setEnvironmentName(event.target.value)} placeholder="Environment name" disabled={!runtimeReachable}/>
<button className="button" type="submit" disabled={!runtimeReachable || createEnvironmentMut.isPending || !environmentName.trim()}>
<Plus size={14}/>
{createEnvironmentMut.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createEnvironmentMut.error instanceof Error && <div className="form-error">{createEnvironmentMut.error.message}</div>}
{environmentsQuery.error instanceof Error && <div className="form-error">{environmentsQuery.error.message}</div>}
{deleteEnvironmentMut.error instanceof Error && <div className="form-error">{deleteEnvironmentMut.error.message}</div>}
<div className="cosmos-list">
{environmentsQuery.isLoading && <div className="muted padded">Loading environments</div>}
{!environmentsQuery.isLoading && environments.length === 0 && <div className="muted padded">No environments</div>}
{environments.map((environment) => (
<button
key={environment.id}
className={`cosmos-list-row ${deployEnvironmentId === environment.id ? 'selected' : ''}`}
type="button"
onClick={() => setDeployEnvironmentId(environment.id)}
>
<span>
<strong>{environment.name}</strong>
<small>{[environment.state, environment.description].filter(Boolean).join(' · ') || 'No details'}</small>
</span>
{confirmEnvironment === environment.id ? (
<em
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation()
deleteEnvironmentMut.mutate(environment.id)
}}
>
Confirm
</em>
) : (
<Trash2
size={13}
onClick={(event) => {
event.stopPropagation()
setConfirmEnvironment(environment.id)
}}
/>
)}
</button>
))}
</div>
</div>
<div className="cosmos-column">
<ColumnHeader eyebrow="Configuration profiles" title="Profiles" detail={`${profiles.length} profiles`}/>
<form className="cosmos-inline-form" onSubmit={submitProfile}>
<input className="input" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="Profile name" disabled={!runtimeReachable}/>
<select className="input" value={profileType} onChange={(event) => setProfileType(event.target.value)} disabled={!runtimeReachable}>
{PROFILE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<button className="button" type="submit" disabled={!runtimeReachable || createProfileMut.isPending || !profileName.trim()}>
<Plus size={14}/>
{createProfileMut.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createProfileMut.error instanceof Error && <div className="form-error">{createProfileMut.error.message}</div>}
{profilesQuery.error instanceof Error && <div className="form-error">{profilesQuery.error.message}</div>}
{deleteProfileMut.error instanceof Error && <div className="form-error">{deleteProfileMut.error.message}</div>}
<div className="cosmos-list">
{profilesQuery.isLoading && <div className="muted padded">Loading profiles</div>}
{!profilesQuery.isLoading && profiles.length === 0 && <div className="muted padded">No configuration profiles</div>}
{profiles.map((profile) => (
<button
key={profile.id}
className={`cosmos-list-row ${selectedProfileId === profile.id ? 'selected' : ''}`}
type="button"
onClick={() => setSelectedProfileId(profile.id)}
>
<span>
<strong>{profile.name}</strong>
<small>{profile.type ?? 'Unknown type'}</small>
</span>
{confirmProfile === profile.id ? (
<em
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation()
deleteProfileMut.mutate(profile.id)
}}
>
Confirm
</em>
) : (
<Trash2
size={13}
onClick={(event) => {
event.stopPropagation()
setConfirmProfile(profile.id)
}}
/>
)}
</button>
))}
</div>
</div>
<div className="cosmos-column cosmos-column--wide">
<ColumnHeader
eyebrow="Hosted configuration versions"
title={profiles.find((profile) => profile.id === selectedProfileId)?.name ?? 'Select profile'}
detail={`${versions.length} versions`}
/>
<form className="cosmos-inline-form" onSubmit={submitVersion}>
<input className="input" value={versionContentType} onChange={(event) => setVersionContentType(event.target.value)} placeholder="Content type" disabled={!runtimeReachable}/>
<input className="input" value={versionDescription} onChange={(event) => setVersionDescription(event.target.value)} placeholder="Description (optional)" disabled={!runtimeReachable}/>
<button className="button" type="submit" disabled={!selectedProfileId || !runtimeReachable || createVersionMut.isPending || !versionContent.trim()}>
<Plus size={14}/>
{createVersionMut.isPending ? 'Saving' : 'Create version'}
</button>
</form>
<textarea
className="textarea code-textarea"
value={versionContent}
onChange={(event) => setVersionContent(event.target.value)}
placeholder={selectedProfileId ? 'Configuration content' : 'Select a profile to add a version'}
spellCheck={false}
disabled={!selectedProfileId || !runtimeReachable}
/>
{createVersionMut.error instanceof Error && <div className="form-error">{createVersionMut.error.message}</div>}
{versionsQuery.error instanceof Error && <div className="form-error">{versionsQuery.error.message}</div>}
{deleteVersionMut.error instanceof Error && <div className="form-error">{deleteVersionMut.error.message}</div>}
<div className="cosmos-items-table">
<table className="table">
<thead>
<tr>
<th>Version</th>
<th>Content type</th>
<th>Description</th>
<th aria-label="Actions"/>
</tr>
</thead>
<tbody>
{versions.map((version) => (
<tr key={version.id} className={selectedVersionNumber === version.versionNumber ? 'selected' : ''}>
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.versionNumber}</td>
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.contentType ?? '-'}</td>
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.description ?? '-'}</td>
<td className="table-actions">
{confirmVersion === version.versionNumber ? (
<button className="button danger compact" type="button" onClick={() => deleteVersionMut.mutate(version.versionNumber)}>Confirm</button>
) : (
<button className="icon-btn danger" type="button" title={`Delete version ${version.versionNumber}`} onClick={() => setConfirmVersion(version.versionNumber)}>
<Trash2 size={13}/>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{selectedProfileId && !versionsQuery.isLoading && versions.length === 0 && (
<div className="empty compact"><h3>No hosted versions</h3><p>Create a version to store configuration content.</p></div>
)}
</div>
{selectedVersionNumber !== undefined && (
<>
<div className="muted padded">Version {selectedVersionNumber} content</div>
{versionContentQuery.isLoading && <div className="muted padded">Loading content</div>}
{versionContentQuery.error instanceof Error && <div className="form-error">{versionContentQuery.error.message}</div>}
{versionContentQuery.data?.content != null && (
<pre className="cosmos-query-result">{versionContentQuery.data.content}</pre>
)}
</>
)}
</div>
<div className="cosmos-column">
<ColumnHeader eyebrow="Deploy" title="Deployment" detail="Deploys a hosted version to an environment"/>
<form className="cosmos-inline-form" onSubmit={(event) => {
event.preventDefault()
if (canDeploy) startDeploymentMut.mutate()
}}>
<select className="input" value={deployEnvironmentId} onChange={(event) => setDeployEnvironmentId(event.target.value)} disabled={!runtimeReachable}>
<option value="">Select environment</option>
{environments.map((environment) => <option key={environment.id} value={environment.id}>{environment.name}</option>)}
</select>
<select className="input" value={deployProfileId} onChange={(event) => {
setDeployProfileId(event.target.value)
setDeployVersion('')
}} disabled={!runtimeReachable}>
<option value="">Select profile</option>
{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
<select className="input" value={deployVersion} onChange={(event) => setDeployVersion(event.target.value)} disabled={!runtimeReachable || !deployProfileId}>
<option value="">Select version</option>
{(deployVersionsQuery.data ?? []).map((version) => (
<option key={version.id} value={String(version.versionNumber)}>v{version.versionNumber}</option>
))}
</select>
<select className="input" value={deployStrategyId} onChange={(event) => setDeployStrategyId(event.target.value)} disabled={!runtimeReachable}>
<option value="">Select strategy</option>
{strategies.map((strategy) => <option key={strategy.id} value={strategy.id}>{strategy.name}</option>)}
</select>
<button className="button primary" type="submit" disabled={!canDeploy || startDeploymentMut.isPending}>
<Rocket size={14}/>
{startDeploymentMut.isPending ? 'Deploying' : 'Start deployment'}
</button>
</form>
{startDeploymentMut.error instanceof Error && <div className="form-error">{startDeploymentMut.error.message}</div>}
{deployment && (
<div className="cosmos-list">
<div className="cosmos-list-row">
<span>
<strong>Deployment #{deployment.deploymentNumber}</strong>
<small>
{deployment.state ?? 'Unknown state'}
{deployment.percentageComplete !== null ? ` · ${deployment.percentageComplete}%` : ''}
</small>
</span>
{!TERMINAL_DEPLOYMENT_STATES.has(deployment.state ?? '') && (
<button className="icon-btn" type="button" title="Refresh deployment" onClick={() => deploymentQuery.refetch()}>
<RefreshCw size={13}/>
</button>
)}
</div>
</div>
)}
<ColumnHeader eyebrow="Strategies" title="Deployment strategies" detail={`${strategies.length} strategies`}/>
<form className="cosmos-inline-form" onSubmit={submitStrategy}>
<input className="input" value={strategyName} onChange={(event) => setStrategyName(event.target.value)} placeholder="Immediate strategy name" disabled={!runtimeReachable}/>
<input className="input" type="number" min={0} value={strategyBake} onChange={(event) => setStrategyBake(event.target.value)} placeholder="Bake min" disabled={!runtimeReachable}/>
<button className="button" type="submit" disabled={!runtimeReachable || createStrategyMut.isPending || !strategyName.trim()}>
<Plus size={14}/>
{createStrategyMut.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createStrategyMut.error instanceof Error && <div className="form-error">{createStrategyMut.error.message}</div>}
{strategiesQuery.error instanceof Error && <div className="form-error">{strategiesQuery.error.message}</div>}
{deleteStrategyMut.error instanceof Error && <div className="form-error">{deleteStrategyMut.error.message}</div>}
<div className="cosmos-list">
{strategiesQuery.isLoading && <div className="muted padded">Loading strategies</div>}
{!strategiesQuery.isLoading && strategies.length === 0 && <div className="muted padded">No deployment strategies</div>}
{strategies.map((strategy) => (
<div key={strategy.id} className="cosmos-list-row">
<span>
<strong>{strategy.name}</strong>
<small>
{strategy.deploymentDurationInMinutes ?? 0} min · growth {strategy.growthFactor ?? 0}% · bake {strategy.finalBakeTimeInMinutes ?? 0} min
</small>
</span>
{confirmStrategy === strategy.id ? (
<em role="button" tabIndex={0} onClick={() => deleteStrategyMut.mutate(strategy.id)}>Confirm</em>
) : (
<Trash2 size={13} onClick={() => setConfirmStrategy(strategy.id)}/>
)}
</div>
))}
</div>
</div>
</section>
)
}
function ColumnHeader({eyebrow, title, detail}: {eyebrow: string; title: string; detail: string}) {
return (
<div className="cosmos-panel-header">
<SlidersHorizontal size={15}/>
<span>
<small>{eyebrow}</small>
<strong>{title}</strong>
<em>{detail}</em>
</span>
</div>
)
}
@@ -0,0 +1,471 @@
import { FormEvent, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Code2, Database, Play, Plug, RefreshCw, Table2, Unplug } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useAccountId } from "@/lib/accountStore";
import {
listSqlDatabases,
listSqlTables,
querySql,
} from "@/api/cloudProxyClient";
import type { CloudProvider } from "@/types/cloud";
import type {
CloudResource,
SqlCredentials,
SqlDatabase,
SqlEngine,
SqlQueryResult,
SqlTable,
} from "@/types/resource";
const SQL_SERVER_DEFAULT_QUERY = `SELECT
DB_NAME() AS database_name,
@@SERVERNAME AS server_name,
@@VERSION AS version;`;
const POSTGRES_DEFAULT_QUERY = `SELECT
current_database() AS database_name,
current_user AS username,
version();`;
interface AzureSqlPanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
export function AzureSqlPanel({
cloud,
resource,
runtimeReachable,
}: AzureSqlPanelProps) {
const accountId = useAccountId();
const serverId = resource?.id;
const engine: SqlEngine =
resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
const defaultDatabase = engine === "postgresql" ? "postgres" : "master";
const defaultQuery =
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
const administratorLogin = metadataString(
resource?.metadata.administratorLogin,
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [connected, setConnected] = useState(false);
const [databases, setDatabases] = useState<SqlDatabase[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const selectedDatabase = searchParams.get("database") || defaultDatabase;
const tableSchema = searchParams.get("schema");
const tableName = searchParams.get("table");
const [queryText, setQueryText] = useState(defaultQuery);
const credentials: SqlCredentials = { username, password };
const databaseExists = databases.some((database) => database.name === selectedDatabase);
const tablesQuery = useQuery({
queryKey: ['sql-tables', accountId, cloud, serverId, engine, selectedDatabase, username],
queryFn: ({signal}) => listSqlTables(cloud, serverId ?? "", engine, selectedDatabase, credentials, signal),
enabled: connected && databaseExists && runtimeReachable,
gcTime: 0,
});
const tables = connected && databaseExists ? tablesQuery.data ?? [] : [];
const selectedTable = tables.find((table) => table.schema === tableSchema && table.name === tableName);
const previewQuery = selectedTable && tablePreviewQuery(selectedTable, engine);
const databasesMut = useMutation({
mutationFn: () =>
listSqlDatabases(cloud, serverId ?? "", engine, credentials),
});
const queryMut = useMutation({
mutationFn: ({ database, query }: { database: string; query: string }) =>
querySql(cloud, serverId ?? "", engine, database, credentials, query),
});
const {mutate: executeQuery, reset: resetQuery} = queryMut;
useEffect(() => {
resetQuery();
if (!connected || !previewQuery) {
setQueryText(defaultQuery);
return;
}
setQueryText(previewQuery);
executeQuery({database: selectedDatabase, query: previewQuery});
}, [connected, selectedDatabase, previewQuery, defaultQuery, executeQuery, resetQuery]);
useEffect(() => {
setUsername(administratorLogin);
setPassword("");
setConnected(false);
setDatabases([]);
setQueryText(defaultQuery);
}, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]);
if (cloud !== "azure") return null;
if (!serverId) {
return (
<section className="sql-panel">
<div className="empty compact">
<h3>Select an Azure database server</h3>
<p>Databases, tables, rows, and SQL queries load after connection.</p>
</div>
</section>
);
}
function connect(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!username || !password) return;
queryMut.reset();
loadDatabases();
}
function loadDatabases() {
databasesMut.mutate(undefined, {
onSuccess: (items) => {
setDatabases(items);
setConnected(true);
if (!searchParams.has('database')) {
const database = items.find((item) => item.name === defaultDatabase) ?? items[0];
if (database) selectDatabase(database);
}
},
});
}
function disconnect() {
setConnected(false);
setPassword("");
setDatabases([]);
databasesMut.reset();
queryMut.reset();
}
function selectDatabase(database: SqlDatabase) {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('database', database.name);
next.delete('schema');
next.delete('table');
return next;
});
}
function previewTable(table: SqlTable) {
if (table.schema === tableSchema && table.name === tableName) {
const query = tablePreviewQuery(table, engine);
setQueryText(query);
queryMut.mutate({database: selectedDatabase, query});
return;
}
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('database', selectedDatabase);
next.set('schema', table.schema);
next.set('table', table.name);
return next;
});
}
function runQuery() {
if (!queryText.trim()) return;
queryMut.mutate({ database: selectedDatabase, query: queryText });
}
const connectionError =
databasesMut.error instanceof Error ? databasesMut.error.message : null;
return (
<section className="sql-panel">
<form className="sql-connection-bar" onSubmit={connect}>
<div className="sql-connection-title">
<Plug size={16} />
<span>
<small>{engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
<strong>{resource.name}</strong>
<em>{metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
</span>
</div>
<label>
<span>Username</span>
<input
className="input"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={connected || !runtimeReachable}
autoComplete="username"
/>
</label>
<label>
<span>Password</span>
<input
className="input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={connected || !runtimeReachable}
autoComplete="current-password"
/>
</label>
{connected ? (
<button className="button" type="button" onClick={disconnect}>
<Unplug size={14} />
Disconnect
</button>
) : (
<button
className="button primary"
type="submit"
disabled={
!runtimeReachable ||
databasesMut.isPending ||
!username ||
!password
}
>
<Plug size={14} />
{databasesMut.isPending ? "Connecting" : "Connect"}
</button>
)}
<small className="sql-credential-note">
Credentials remain in this page only and are sent to the local API per request.
</small>
{connectionError && <div className="form-error">{connectionError}</div>}
</form>
{!connected ? (
<div className="empty compact sql-connect-empty">
<h3>Connect to browse SQL data</h3>
<p>Use the administrator login configured for this local engine.</p>
</div>
) : (
<div className="sql-explorer">
<div className="sql-column">
<SqlPanelHeader
icon={Database}
eyebrow="Databases"
title={resource.name}
detail={`${databases.length} databases`}
action={
<button
className="icon-btn"
type="button"
title="Refresh databases"
disabled={databasesMut.isPending}
onClick={loadDatabases}
>
<RefreshCw size={13} />
</button>
}
/>
<div className="sql-list">
{databases.length === 0 && <div className="muted padded">No databases</div>}
{databases.length > 0 && !databaseExists && <div className="form-error">Database not found: {selectedDatabase}</div>}
{databases.map((database) => (
<button
key={database.name}
className={`sql-list-row ${selectedDatabase === database.name ? "selected" : ""}`}
type="button"
onClick={() => selectDatabase(database)}
>
<span>
<strong>{database.name}</strong>
<small>{database.state}</small>
</span>
{database.isSystem && <em>System</em>}
</button>
))}
</div>
</div>
<div className="sql-column">
<SqlPanelHeader
icon={Table2}
eyebrow="Tables and views"
title={selectedDatabase}
detail={`${tables.length} objects`}
action={
<button
className="icon-btn"
type="button"
title="Refresh tables"
disabled={!databaseExists || tablesQuery.isFetching}
onClick={() => void tablesQuery.refetch()}
>
<RefreshCw size={13} />
</button>
}
/>
{tablesQuery.error instanceof Error && (
<div className="form-error">{tablesQuery.error.message}</div>
)}
<div className="sql-list">
{tablesQuery.isLoading && <div className="muted padded">Loading objects</div>}
{databaseExists && tablesQuery.isSuccess && tables.length === 0 && (
<div className="muted padded">No user tables or views</div>
)}
{databaseExists && tablesQuery.isSuccess && tableName && !selectedTable && <div className="form-error">Table not found: {tableSchema}.{tableName}</div>}
{tables.map((table) => (
<button
key={`${table.schema}.${table.name}`}
className={`sql-list-row ${selectedTable?.schema === table.schema && selectedTable.name === table.name ? "selected" : ""}`}
type="button"
onClick={() => previewTable(table)}
>
<span>
<strong>{table.schema}.{table.name}</strong>
<small>{table.type}</small>
</span>
<em>{table.rowCount === null ? "—" : `${table.rowCount} rows`}</em>
</button>
))}
</div>
</div>
<div className="sql-column sql-workspace">
<SqlPanelHeader
icon={Code2}
eyebrow="Query editor"
title={selectedDatabase}
detail={engine === "postgresql"
? "PostgreSQL SQL against selected database"
: "T-SQL against selected database"}
/>
<div className="sql-query-editor">
<textarea
className="textarea code-textarea small"
value={queryText}
onChange={(event) => setQueryText(event.target.value)}
spellCheck={false}
/>
<button
className="button primary"
type="button"
disabled={!databaseExists || queryMut.isPending || !queryText.trim()}
onClick={runQuery}
>
<Play size={14} />
{queryMut.isPending ? "Running" : "Run query"}
</button>
</div>
{queryMut.error instanceof Error && (
<div className="form-error">{queryMut.error.message}</div>
)}
<SqlQueryResults result={queryMut.data} />
</div>
</div>
)}
</section>
);
}
function SqlQueryResults({ result }: { result?: SqlQueryResult }) {
if (!result) {
return (
<div className="empty compact sql-results-empty">
<h3>No query results</h3>
<p>Select a table for a 100-row preview or run SQL.</p>
</div>
);
}
const affected = result.rowsAffected.reduce((total, count) => total + count, 0);
return (
<div className="sql-results">
<div className="sql-results-summary">
<span>{result.resultSets.length} result sets</span>
<span>{affected} rows affected</span>
<span>{result.durationMs} ms</span>
</div>
{result.resultSets.length === 0 && (
<div className="muted padded">Command completed without a result set.</div>
)}
{result.resultSets.map((resultSet, resultSetIndex) => (
<div className="sql-result-set" key={resultSetIndex}>
<div className="sql-result-set-label">
Result {resultSetIndex + 1} · {resultSet.rows.length} rows
{resultSet.truncated && " · first 500 shown"}
</div>
<div className="sql-result-table-wrap">
<table className="table sql-result-table">
<thead>
<tr>
{resultSet.columns.map((column) => (
<th key={column.name} title={column.type}>
{column.name}
<small>{column.type}</small>
</th>
))}
</tr>
</thead>
<tbody>
{resultSet.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{resultSet.columns.map((column) => {
const value = formatSqlValue(row[column.name]);
return (
<td key={column.name} title={value}>
{value}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
);
}
function SqlPanelHeader({
icon: Icon,
eyebrow,
title,
detail,
action,
}: {
icon: typeof Database;
eyebrow: string;
title: string;
detail: string;
action?: React.ReactNode;
}) {
return (
<div className="sql-panel-header">
<Icon size={15} />
<span>
<small>{eyebrow}</small>
<strong>{title}</strong>
<em>{detail}</em>
</span>
{action}
</div>
);
}
function metadataString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string {
const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`;
return engine === 'postgresql'
? `SELECT * FROM ${tableName} LIMIT 100;`
: `SELECT TOP (100) * FROM ${tableName};`;
}
function quoteIdentifier(value: string, engine: SqlEngine): string {
return engine === "postgresql"
? `"${value.replace(/"/g, '""')}"`
: `[${value.replace(/]/g, "]]")}]`;
}
function formatSqlValue(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
@@ -0,0 +1,25 @@
import type {CloudDescriptor, CloudProvider} from '@/types/cloud'
interface CloudSelectorProps {
clouds: CloudDescriptor[]
selected: CloudProvider
onSelect: (cloud: CloudProvider) => void
}
export function CloudSelector({clouds, selected, onSelect}: CloudSelectorProps) {
return (
<div className="cloud-selector" role="group" aria-label="Cloud provider">
{clouds.map((cloud) => (
<button
key={cloud.id}
className={`selector-pill ${selected === cloud.id ? 'active' : ''} ${cloud.availability === 'coming_soon' ? 'soon' : ''}`}
onClick={() => onSelect(cloud.id)}
type="button"
>
<span>{cloud.displayName}</span>
{cloud.availability === 'coming_soon' && <span className="badge neutral">Soon</span>}
</button>
))}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
import {FormEvent, useEffect, useMemo, useRef, useState} from 'react'
import {Code2, Database, Play, Plus, RefreshCw, Table2, Trash2, type LucideIcon} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {JsonRecordModal} from '@/components/JsonRecordModal'
import {useAccountId} from '@/lib/accountStore'
import {
createCosmosContainer,
deleteCosmosContainer,
deleteCosmosItem,
listCosmosContainers,
listCosmosItems,
queryCosmosItems,
upsertCosmosItem,
} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource, CosmosItem} from '@/types/resource'
interface CosmosNoSqlPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
selectedContainerId?: string
onSelectContainer: (id: string | undefined) => void
}
export function CosmosNoSqlPanel({cloud, resource, runtimeReachable, selectedContainerId, onSelectContainer}: CosmosNoSqlPanelProps) {
const qc = useQueryClient()
const accountId = useAccountId()
const selectionChanged = useRef(false)
const databaseId = resource?.id
const [containerName, setContainerName] = useState('')
const [partitionKeyPath, setPartitionKeyPath] = useState('/id')
const [documentText, setDocumentText] = useState('{\n "id": ""\n}')
const [documentError, setDocumentError] = useState<string | null>(null)
const [selectedItem, setSelectedItem] = useState<CosmosItem | undefined>()
const [addRecordOpen, setAddRecordOpen] = useState(false)
const [sql, setSql] = useState('SELECT * FROM c')
const [confirmContainer, setConfirmContainer] = useState<string | null>(null)
const [confirmItem, setConfirmItem] = useState<string | null>(null)
const containersKey = useMemo(() => ['cosmos-containers', accountId, cloud, databaseId], [accountId, cloud, databaseId])
const itemsKey = useMemo(() => ['cosmos-items', accountId, cloud, databaseId, selectedContainerId], [accountId, cloud, databaseId, selectedContainerId])
const containersQuery = useQuery({
queryKey: containersKey,
queryFn: ({signal}) => listCosmosContainers(cloud, databaseId ?? '', signal),
enabled: Boolean(databaseId) && runtimeReachable,
})
const containers = containersQuery.data ?? []
const selectedContainer = containers.find((container) => container.id === selectedContainerId)
const itemsQuery = useQuery({
queryKey: itemsKey,
queryFn: ({signal}) => listCosmosItems(cloud, databaseId ?? '', selectedContainerId ?? '', signal),
enabled: Boolean(databaseId && selectedContainer) && runtimeReachable,
})
const createContainerMut = useMutation({
mutationFn: () => createCosmosContainer(cloud, databaseId ?? '', {containerName, partitionKeyPath}),
onSuccess: () => {
void qc.invalidateQueries({queryKey: containersKey})
},
})
const deleteContainerMut = useMutation({
mutationFn: (containerId: string) => deleteCosmosContainer(cloud, databaseId ?? '', containerId),
onSuccess: () => {
void qc.invalidateQueries({queryKey: containersKey})
},
})
const upsertItemMut = useMutation({
mutationFn: (document: Record<string, unknown>) => upsertCosmosItem(cloud, databaseId ?? '', selectedContainerId ?? '', document),
onSuccess: (item) => {
setAddRecordOpen(false)
setSelectedItem(item)
setDocumentError(null)
setDocumentText(JSON.stringify(item.document, null, 2))
void qc.invalidateQueries({queryKey: itemsKey})
},
})
const deleteItemMut = useMutation({
mutationFn: (item: CosmosItem) => deleteCosmosItem(cloud, databaseId ?? '', selectedContainerId ?? '', item.id, item.partitionKey),
onSuccess: (_, item) => {
if (selectedItem?.id === item.id) resetDocumentEditor()
setConfirmItem(null)
void qc.invalidateQueries({queryKey: itemsKey})
},
})
const queryMut = useMutation({
mutationFn: (input: {containerId: string; query: string}) => queryCosmosItems(cloud, databaseId ?? '', input.containerId, input.query),
})
const saveError = documentError ?? (upsertItemMut.error instanceof Error ? upsertItemMut.error.message : null)
const activeQueryResult = queryMut.variables?.containerId === selectedContainerId ? queryMut.data : undefined
useEffect(() => {
setSelectedItem(undefined)
setAddRecordOpen(false)
setDocumentText('{\n "id": ""\n}')
setDocumentError(null)
setSql('SELECT * FROM c')
}, [cloud, databaseId])
useEffect(() => {
setSelectedItem(undefined)
setAddRecordOpen(false)
setDocumentText('{\n "id": ""\n}')
setDocumentError(null)
}, [selectedContainerId])
if (cloud !== 'azure') return null
if (!databaseId) {
return (
<section className="cosmos-panel">
<div className="empty compact">
<h3>Select a Cosmos database</h3>
<p>Containers, documents, and SQL queries are loaded after a database is selected.</p>
</div>
</section>
)
}
function submitContainer(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!containerName.trim()) return
createContainerMut.mutate(undefined, {
onSuccess: (container) => {
if (selectionChanged.current) return
onSelectContainer(container.id)
setContainerName('')
setPartitionKeyPath('/id')
},
})
}
function submitDocument(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
const parsed = JSON.parse(documentText) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setDocumentError('Document must be a JSON object.')
return
}
if (!('id' in parsed) || !String((parsed as Record<string, unknown>).id ?? '').trim()) {
setDocumentError('Document id is required.')
return
}
setDocumentError(null)
upsertItemMut.mutate(parsed as Record<string, unknown>)
} catch (error) {
setDocumentError(error instanceof Error ? error.message : 'Invalid JSON document.')
}
}
return (
<section className="cosmos-panel">
<div className="cosmos-column">
<PanelHeader icon={Database} eyebrow="Containers" title={databaseId} detail={`${containers.length} containers`}/>
<form className="cosmos-inline-form" onSubmit={submitContainer}>
<input className="input" value={containerName} onChange={(event) => setContainerName(event.target.value)} placeholder="Container name" disabled={!runtimeReachable}/>
<input className="input" value={partitionKeyPath} onChange={(event) => setPartitionKeyPath(event.target.value)} placeholder="/id" disabled={!runtimeReachable}/>
<button className="button" type="submit" disabled={!runtimeReachable || createContainerMut.isPending || !containerName.trim()}>
<Plus size={14}/>
{createContainerMut.isPending ? 'Creating' : 'Create'}
</button>
</form>
{createContainerMut.error instanceof Error && <div className="form-error">{createContainerMut.error.message}</div>}
{containersQuery.error instanceof Error && <div className="form-error">{containersQuery.error.message}</div>}
<div className="cosmos-list">
{containersQuery.isLoading && <div className="muted padded">Loading containers</div>}
{containersQuery.isSuccess && containers.length === 0 && <div className="muted padded">No containers</div>}
{containersQuery.isSuccess && selectedContainerId && !selectedContainer && <div className="form-error">Container not found: {selectedContainerId}</div>}
{containers.map((container) => (
<button
key={container.id}
className={`cosmos-list-row ${selectedContainerId === container.id ? 'selected' : ''}`}
type="button"
onClick={() => {
if (container.id === selectedContainerId) return
selectionChanged.current = true
onSelectContainer(container.id)
}}
>
<span>
<strong>{container.name}</strong>
<small>Partition key {container.partitionKeyPath}</small>
</span>
{confirmContainer === container.id ? (
<em
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation()
deleteContainerMut.mutate(container.id, {
onSuccess: () => {
if (selectionChanged.current) return
if (selectedContainerId === container.id) onSelectContainer(undefined)
setConfirmContainer(null)
},
})
}}
>
Confirm
</em>
) : (
<Trash2
size={13}
onClick={(event) => {
event.stopPropagation()
setConfirmContainer(container.id)
}}
/>
)}
</button>
))}
</div>
</div>
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={Table2} eyebrow="Documents" title={selectedContainer?.name ?? selectedContainerId ?? 'Select container'} detail={`${itemsQuery.data?.length ?? 0} items`}/>
<div className="cosmos-toolbar">
<button
className="button primary"
type="button"
disabled={!selectedContainer || !runtimeReachable}
onClick={() => {
upsertItemMut.reset()
setAddRecordOpen(true)
}}
>
<Plus size={14}/>
Add record
</button>
<button className="button" type="button" disabled={!selectedContainer || !runtimeReachable || itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
<RefreshCw size={14}/>
Refresh
</button>
</div>
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
{deleteItemMut.error instanceof Error && <div className="form-error">{deleteItemMut.error.message}</div>}
<div className="cosmos-items-table">
<table className="table">
<thead>
<tr>
<th>Id</th>
<th>Updated</th>
<th>ETag</th>
<th aria-label="Actions"/>
</tr>
</thead>
<tbody>
{(itemsQuery.data ?? []).map((item) => (
<tr key={item.id} className={selectedItem?.id === item.id ? 'selected' : ''}>
<td onClick={() => selectItem(item)}>{item.id}</td>
<td onClick={() => selectItem(item)}>{formatValue(item.timestamp)}</td>
<td onClick={() => selectItem(item)}><code>{formatShort(item.etag)}</code></td>
<td className="table-actions">
{confirmItem === item.id ? (
<button className="button danger compact" type="button" onClick={() => deleteItemMut.mutate(item)}>Confirm</button>
) : (
<button className="icon-btn danger" type="button" title={`Delete ${item.id}`} onClick={() => setConfirmItem(item.id)}>
<Trash2 size={13}/>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{!selectedContainerId && <div className="empty compact"><h3>Select a container</h3><p>Documents are scoped to a Cosmos container.</p></div>}
{itemsQuery.isLoading && <div className="muted padded">Loading documents</div>}
{selectedContainerId && itemsQuery.isSuccess && itemsQuery.data.length === 0 && <div className="empty compact"><h3>No documents</h3><p>Create a JSON document or run a query after data exists.</p></div>}
</div>
</div>
<div className="cosmos-column">
<PanelHeader icon={Code2} eyebrow="Document editor" title={selectedItem ? `Update ${selectedItem.id}` : 'Select document'} detail={selectedItem ? 'Edits replace the selected document' : 'Choose a document to inspect or update'}/>
{selectedItem ? (
<form className="cosmos-editor" onSubmit={submitDocument}>
<textarea className="textarea code-textarea" value={documentText} onChange={(event) => setDocumentText(event.target.value)} spellCheck={false}/>
<button className="button primary" type="submit" disabled={!selectedContainer || !runtimeReachable || upsertItemMut.isPending}>
{upsertItemMut.isPending ? 'Saving' : 'Update document'}
</button>
{saveError && <div className="form-error">{saveError}</div>}
</form>
) : (
<div className="empty compact"><h3>Select a document</h3><p>Its JSON contents appear here.</p></div>
)}
<PanelHeader icon={Play} eyebrow="SQL query" title="Cosmos SQL" detail="Runs against selected container"/>
<div className="cosmos-query">
<textarea className="textarea code-textarea small" value={sql} onChange={(event) => setSql(event.target.value)} spellCheck={false}/>
<button
className="button"
type="button"
disabled={!selectedContainer || !runtimeReachable || queryMut.isPending}
onClick={() => selectedContainerId && queryMut.mutate({containerId: selectedContainerId, query: sql})}
>
<Play size={14}/>
{queryMut.isPending ? 'Running' : 'Run query'}
</button>
{queryMut.error instanceof Error && <div className="form-error">{queryMut.error.message}</div>}
{activeQueryResult && (
<>
<div className="muted">{activeQueryResult.count} query results</div>
<pre className="cosmos-query-result">{JSON.stringify(activeQueryResult.items, null, 2)}</pre>
</>
)}
</div>
</div>
<JsonRecordModal
open={addRecordOpen}
title={`Add record to ${selectedContainer?.name ?? 'container'}`}
description={cosmosModalDescription(selectedContainer?.partitionKeyPath)}
initialValue={JSON.stringify(cosmosRecordTemplate(selectedContainer?.partitionKeyPath), null, 2)}
isPending={upsertItemMut.isPending}
submitError={upsertItemMut.error instanceof Error ? upsertItemMut.error.message : undefined}
onClose={() => setAddRecordOpen(false)}
onSubmit={(document) => upsertItemMut.mutate(document)}
/>
</section>
)
function selectItem(item: CosmosItem) {
setSelectedItem(item)
setDocumentText(JSON.stringify(item.document, null, 2))
setDocumentError(null)
}
function resetDocumentEditor() {
setSelectedItem(undefined)
setDocumentText('{\n "id": ""\n}')
setDocumentError(null)
setConfirmItem(null)
}
}
function cosmosRecordTemplate(partitionKeyPath = '/id'): Record<string, unknown> {
const document: Record<string, unknown> = {id: ''}
const segments = partitionKeyPath.replace(/^\//, '').split('/').filter(Boolean)
if (segments.length === 0 || segments.length === 1 && segments[0] === 'id') return document
let target = document
for (const segment of segments.slice(0, -1)) {
const child: Record<string, unknown> = {}
target[segment] = child
target = child
}
target[segments[segments.length - 1] ?? 'id'] = ''
return document
}
function cosmosModalDescription(partitionKeyPath = '/id'): string {
const required = partitionKeyPath === '/id' ? 'Include id.' : `Include id and partition key ${partitionKeyPath}.`
return `${required} A matching id and partition key replaces the existing record.`
}
function PanelHeader({icon, eyebrow, title, detail}: {icon: LucideIcon; eyebrow: string; title: string; detail: string}) {
const Icon = icon
return (
<div className="cosmos-panel-header">
<Icon size={15}/>
<span>
<small>{eyebrow}</small>
<strong>{title}</strong>
<em>{detail}</em>
</span>
</div>
)
}
function formatValue(value: string | null): string {
return value || '-'
}
function formatShort(value: string | null): string {
if (!value) return '-'
return value.length > 16 ? `${value.slice(0, 16)}...` : value
}
@@ -0,0 +1,337 @@
import { type FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import {
createCloudResource,
listDatabaseOrderableClasses,
} from "@/api/cloudProxyClient";
import {
listEc2SecurityGroups,
type Ec2SecurityGroup,
} from "@/api/aws/ec2.api";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource } from "@/types/resource";
const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
interface CreateRdsInstanceFormProps {
cloud: CloudProvider;
onSuccess: (resource: CloudResource) => void;
onCancel: () => void;
}
export function CreateRdsInstanceForm({
cloud,
onSuccess,
onCancel,
}: CreateRdsInstanceFormProps) {
const qc = useQueryClient();
// Instance configuration
const [dbInstanceIdentifier, setDbInstanceIdentifier] = useState("");
const [engine, setEngine] = useState("postgres");
const [dbInstanceClass, setDbInstanceClass] = useState("");
const [masterUserPassword, setMasterUserPassword] = useState("");
// Networking settings
const [selectedSgs, setSelectedSgs] = useState<string[]>([]);
// Additional settings
const [masterUsername, setMasterUsername] = useState("root");
const [allocatedStorage, setAllocatedStorage] = useState("20");
const [dbName, setDbName] = useState("");
const [engineVersion, setEngineVersion] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
// Fetch available instance classes from AWS SDK via backend
const classesQuery = useQuery({
queryKey: ["cloud-database-orderable-classes", cloud, engine],
queryFn: ({ signal }) => listDatabaseOrderableClasses(cloud, engine, signal),
enabled: Boolean(engine),
});
const availableClasses = classesQuery.data ?? [];
const selectedClass =
dbInstanceClass && availableClasses.includes(dbInstanceClass)
? dbInstanceClass
: (availableClasses[0] ?? "");
// Fetch available security groups (AWS)
const sgsQuery = useQuery({
queryKey: ["ec2", "security-groups"],
queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal),
enabled: cloud === "aws",
});
function toggleSg(groupId: string, checked: boolean) {
setSelectedSgs((prev) =>
checked ? [...prev, groupId] : prev.filter((id) => id !== groupId),
);
}
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
createCloudResource(cloud, "database", values),
onSuccess: (created) => {
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, "database"],
});
onSuccess(created);
},
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
setValidationError(null);
const trimmedIdentifier = dbInstanceIdentifier.trim();
if (!trimmedIdentifier) {
setValidationError("DB Instance Identifier is required.");
return;
}
if (!IDENTIFIER_PATTERN.test(trimmedIdentifier)) {
setValidationError(
"DB Instance Identifier must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and cannot end with a hyphen or contain consecutive hyphens.",
);
return;
}
if (!selectedClass) {
setValidationError("DB Instance Class is required.");
return;
}
if (masterUserPassword.length < 8) {
setValidationError("Master User Password must be at least 8 characters.");
return;
}
createMut.mutate({
dbInstanceIdentifier: trimmedIdentifier,
engine,
dbInstanceClass: selectedClass,
masterUserPassword,
masterUsername: masterUsername.trim() || "root",
allocatedStorage: allocatedStorage.trim() || "20",
dbName: dbName.trim() || undefined,
engineVersion: engineVersion.trim() || undefined,
securityGroupIds: selectedSgs.length ? selectedSgs : undefined,
});
}
const errorMessage =
validationError ??
(createMut.error instanceof Error ? createMut.error.message : null);
return (
<form className="launch-form" onSubmit={handleSubmit} noValidate={false}>
{/* ── Required Instance Settings ─────────────────────────────── */}
<div className="launch-form-group">Instance Configuration</div>
<label className="launch-field">
<span>
DB Instance Identifier <em className="field-required">*</em>
</span>
<input
className="input"
value={dbInstanceIdentifier}
onChange={(e) => setDbInstanceIdentifier(e.target.value)}
placeholder="e.g. orders-database"
required
maxLength={63}
/>
</label>
<label className="launch-field">
<span>
Engine <em className="field-required">*</em>
</span>
<select
className="input"
value={engine}
onChange={(e) => {
const nextEngine = e.target.value;
setEngine(nextEngine);
setDbInstanceClass("");
}}
required
>
<option value="postgres">PostgreSQL</option>
<option value="mysql">MySQL</option>
<option value="mariadb">MariaDB</option>
</select>
</label>
<label className="launch-field">
<span>
DB Instance Class <em className="field-required">*</em>
{classesQuery.isFetching && (
<Loader2
size={11}
className="spin"
style={{ marginLeft: 6, display: "inline-block" }}
/>
)}
</span>
<select
className="input"
value={selectedClass}
onChange={(e) => setDbInstanceClass(e.target.value)}
disabled={classesQuery.isLoading || availableClasses.length === 0}
required
>
{classesQuery.isLoading && (
<option value="" disabled>
Loading available instance classes…
</option>
)}
{classesQuery.isError && (
<option value="" disabled>
Failed to load instance classes
</option>
)}
{!classesQuery.isLoading &&
!classesQuery.isError &&
availableClasses.length === 0 && (
<option value="" disabled>
No instance classes available
</option>
)}
{availableClasses.map((cls) => (
<option key={cls} value={cls}>
{cls}
</option>
))}
</select>
</label>
<label className="launch-field">
<span>
Master User Password <em className="field-required">*</em>
</span>
<input
type="password"
className="input"
value={masterUserPassword}
onChange={(e) => setMasterUserPassword(e.target.value)}
placeholder="Min 8 characters"
required
minLength={8}
maxLength={128}
/>
</label>
{/* ── Networking — optional ─────────────────────────────────── */}
{cloud === "aws" && (
<>
<div className="launch-form-group">Networking — optional</div>
<div className="launch-field launch-field--span">
<span style={{ fontSize: "12px", color: "var(--text-2)" }}>
Security Groups
</span>
<div className="sg-checklist" style={{ marginTop: "4px" }}>
{sgsQuery.isLoading && <span className="muted">Loading…</span>}
{!sgsQuery.isLoading && (sgsQuery.data ?? []).length === 0 && (
<span className="muted">No security groups found.</span>
)}
{(sgsQuery.data ?? []).map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item">
<input
type="checkbox"
checked={selectedSgs.includes(sg.groupId)}
onChange={(e) => toggleSg(sg.groupId, e.target.checked)}
/>
<span>{sg.groupName || sg.groupId}</span>
{sg.groupName && <code>{sg.groupId}</code>}
</label>
))}
</div>
</div>
</>
)}
{/* ── Additional Database Settings ───────────────────────────── */}
<div className="launch-form-group">Database Settings — optional</div>
<label className="launch-field">
<span>Master Username</span>
<input
className="input"
value={masterUsername}
onChange={(e) => setMasterUsername(e.target.value)}
placeholder="root"
maxLength={63}
/>
</label>
<label className="launch-field">
<span>Allocated Storage (GiB)</span>
<input
type="number"
className="input"
value={allocatedStorage}
onChange={(e) => setAllocatedStorage(e.target.value)}
placeholder="20"
min={5}
max={65536}
/>
</label>
<label className="launch-field">
<span>Database Name</span>
<input
className="input"
value={dbName}
onChange={(e) => setDbName(e.target.value)}
placeholder="e.g. myapp"
maxLength={64}
/>
</label>
<label className="launch-field">
<span>Engine Version</span>
<input
className="input"
value={engineVersion}
onChange={(e) => setEngineVersion(e.target.value)}
placeholder="e.g. 16.3"
/>
</label>
{/* ── Actions ────────────────────────────────────────────────── */}
<div className="launch-form-actions">
<button
className="button"
type="button"
onClick={onCancel}
disabled={createMut.isPending}
>
Cancel
</button>
<button
className="button primary"
type="submit"
disabled={createMut.isPending}
>
{createMut.isPending ? (
<Loader2 className="spin" size={13} />
) : (
<Plus size={13} />
)}
{createMut.isPending ? "Creating…" : "Create DB Instance"}
</button>
</div>
{errorMessage && (
<div
className="field-error launch-field--span"
role="alert"
style={{ marginTop: "4px" }}
>
{errorMessage}
</div>
)}
</form>
);
}
@@ -0,0 +1,236 @@
import { useState } from "react";
import { Camera, Loader2, Plus, RefreshCw } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createDatabaseSnapshot, listDatabaseSnapshots } from "@/api/cloudProxyClient";
import { HttpError } from "@/api/HttpClient";
import { EmptyState } from "@/components/EmptyState";
import { capabilityEnabled } from "@/lib/capabilities";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource } from "@/types/resource";
import type { CapabilitySchema, DatabaseActionName } from "@/types/schema";
export interface DatabaseSnapshotsPanelProps {
cloud: CloudProvider;
instances: CloudResource[];
listCapability?: CapabilitySchema<DatabaseActionName>;
createCapability?: CapabilitySchema<DatabaseActionName>;
runtimeReachable: boolean;
}
export function DatabaseSnapshotsPanel({
cloud,
instances,
listCapability,
createCapability,
runtimeReachable,
}: DatabaseSnapshotsPanelProps) {
const qc = useQueryClient();
const [sourceInstance, setSourceInstance] = useState("");
const [snapshotId, setSnapshotId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const canList = runtimeReachable && capabilityEnabled(listCapability);
const canCreate = runtimeReachable && capabilityEnabled(createCapability);
const snapshotsQuery = useQuery({
queryKey: ["database-snapshots", cloud],
queryFn: ({ signal }) => listDatabaseSnapshots(cloud, undefined, signal),
enabled: canList,
});
const createMut = useMutation({
mutationFn: (input: { instanceIdentifier: string; snapshotIdentifier: string }) =>
createDatabaseSnapshot(cloud, input),
onSuccess: () => {
setSourceInstance("");
setSnapshotId("");
setCreateOpen(false);
void qc.invalidateQueries({ queryKey: ["database-snapshots", cloud] });
},
});
const handleSourceChange = (newInstance: string) => {
setSourceInstance(newInstance);
if (newInstance) {
setSnapshotId(`${newInstance}-snapshot-${Date.now()}`);
} else {
setSnapshotId("");
}
};
const isFormValid = Boolean(sourceInstance.trim() && snapshotId.trim());
const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!isFormValid || !canCreate || createMut.isPending) return;
createMut.mutate({
instanceIdentifier: sourceInstance.trim(),
snapshotIdentifier: snapshotId.trim(),
});
};
const getErrorMessage = (error: unknown): string => {
if (
error instanceof HttpError &&
typeof error.body === "object" &&
error.body !== null &&
"code" in error.body &&
(error.body as { code?: unknown }).code === "operation_not_implemented"
) {
return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot.";
}
if (error instanceof Error) {
return error.message;
}
return "Snapshot creation failed.";
};
return (
<section className="table-panel">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Database Snapshots</p>
<span className="muted">
{snapshotsQuery.data ? `${snapshotsQuery.data.length} snapshots` : "Snapshots"}
</span>
</div>
<div className="resource-table-tools">
<button
className="button"
type="button"
disabled={!canCreate}
title={createCapability?.reason}
onClick={() => setCreateOpen((open) => !open)}
>
<Plus size={14} />
Create DB snapshot
</button>
<button
className="button"
type="button"
disabled={!canList || snapshotsQuery.isFetching}
onClick={() => snapshotsQuery.refetch()}
>
<RefreshCw size={14} />
{snapshotsQuery.isFetching ? "Loading" : "Refresh"}
</button>
</div>
</div>
{createOpen && (
<div className="resource-create-inline" style={{ margin: "10px 12px 16px" }}>
<form onSubmit={handleCreateSubmit} className="dynamic-form">
<div className="form-group">
<label className="form-label" htmlFor="source-instance-select">
Source Instance *
</label>
<select
id="source-instance-select"
className="input"
required
value={sourceInstance}
onChange={(e) => handleSourceChange(e.target.value)}
>
<option value="">Select a DB instance</option>
{instances.map((inst) => (
<option key={inst.id} value={inst.name}>
{inst.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label" htmlFor="snapshot-id-input">
Snapshot Identifier *
</label>
<input
id="snapshot-id-input"
className="input"
type="text"
required
value={snapshotId}
onChange={(e) => setSnapshotId(e.target.value)}
placeholder="e.g. my-db-snapshot-1"
/>
</div>
{createMut.isError && (
<div role="alert" className="error-text compact-text" style={{ marginTop: 8 }}>
{getErrorMessage(createMut.error)}
</div>
)}
<div className="form-actions" style={{ marginTop: 12 }}>
<button
className="button primary"
type="submit"
disabled={!canCreate || !isFormValid || createMut.isPending}
title={createCapability?.reason}
>
{createMut.isPending ? "Creating…" : "Create snapshot"}
</button>
<button
className="button"
type="button"
disabled={createMut.isPending}
onClick={() => {
setCreateOpen(false);
createMut.reset();
}}
>
Cancel
</button>
</div>
</form>
</div>
)}
{!canList ? (
<p className="muted compact-text" style={{ padding: "16px" }}>
{listCapability?.reason ?? "Snapshot listing is not available."}
</p>
) : snapshotsQuery.isLoading ? (
<div className="empty compact">
<Loader2 size={18} className="spin" />
<p>Loading snapshots…</p>
</div>
) : snapshotsQuery.isError ? (
<div role="alert" className="error-text compact-text" style={{ padding: "16px" }}>
{snapshotsQuery.error instanceof Error
? snapshotsQuery.error.message
: "Failed to load database snapshots."}
</div>
) : (snapshotsQuery.data?.length ?? 0) === 0 ? (
<EmptyState
icon={Camera}
title="No snapshots"
description="No snapshots returned for this account."
/>
) : (
<div className="snapshot-list" style={{ padding: "0 12px 12px" }}>
{snapshotsQuery.data?.map((snapshot) => (
<div className="snapshot-row" key={snapshot.id || snapshot.name}>
<div>
<strong>{snapshot.name}</strong>
<span>
{snapshot.instanceIdentifier
? `Instance: ${snapshot.instanceIdentifier}`
: "Unknown instance"}
{snapshot.createdAt
? ` · ${snapshot.createdAt}`
: " · No creation timestamp"}
{snapshot.engine ? ` · ${snapshot.engine}` : ""}
{snapshot.version ? ` (${snapshot.version})` : ""}
</span>
</div>
<span className="badge neutral">
{snapshot.status ?? "unknown"}
</span>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,50 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {DynamicFormRenderer} from './DynamicFormRenderer'
import type {ServiceSchema} from '@/types/schema'
const schema: ServiceSchema = {
cloud: 'aws',
service: 'serverless',
displayName: 'AWS Lambda',
fields: [
{
name: 'runtime',
label: 'Runtime',
type: 'select',
required: true,
defaultValue: 'nodejs22.x',
options: [
{label: 'Node.js 22.x', value: 'nodejs22.x'},
{label: 'Python 3.12', value: 'python3.12'},
],
},
{
name: 'code',
label: 'Hello World Source',
type: 'textarea',
required: false,
defaultValueWhen: {
field: 'runtime',
values: {
'nodejs22.x': 'exports.handler = async () => {}',
'python3.12': 'def handler(event, context):\n return {}',
},
},
},
],
actions: ['create'],
filters: [],
columns: [],
}
test('replaces Hello World source when the Lambda runtime changes', async () => {
const user = userEvent.setup()
render(<DynamicFormRenderer schema={schema} isSubmitting={false} onSubmit={vi.fn()}/>)
expect(screen.getByLabelText('Hello World Source')).toHaveValue('exports.handler = async () => {}')
await user.selectOptions(screen.getByRole('combobox'), 'python3.12')
expect(screen.getByLabelText('Hello World Source')).toHaveValue('def handler(event, context):\n return {}')
})
@@ -0,0 +1,263 @@
import {FormEvent, useState} from 'react'
import {Plus} from 'lucide-react'
import type {FieldSchema, ServiceSchema} from '@/types/schema'
interface DynamicFormRendererProps {
schema: ServiceSchema
fields?: FieldSchema[]
initialValues?: Record<string, unknown>
isSubmitting: boolean
submitLabel?: string
pendingLabel?: string
submitError?: string | null
onCancel?: () => void
onSubmit: (values: Record<string, unknown>) => void
}
export function DynamicFormRenderer({
schema,
fields,
initialValues,
isSubmitting,
submitLabel = 'Create',
pendingLabel = 'Creating',
submitError,
onCancel,
onSubmit,
}: DynamicFormRendererProps) {
const activeFields = fields ?? schema.fields
const [values, setValues] = useState<Record<string, string>>(() =>
getInitialFormValues(activeFields, initialValues),
)
const [errors, setErrors] = useState<Record<string, string>>({})
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const nextErrors = validateValues(activeFields, values)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
onSubmit(values)
}
return (
<form className="dynamic-form" onSubmit={submit} noValidate>
{activeFields.map((field) => (
<FieldRow
key={field.name}
field={field}
required={isFieldRequired(field, values)}
maxLength={fieldMaxLength(field, values).value}
value={values[field.name] ?? ''}
error={errors[field.name]}
onChange={(value) => {
setValues((prev) => withDependentDefaults(activeFields, prev, field.name, value))
setErrors((prev) => {
const next = {...prev}
delete next[field.name]
return next
})
}}
/>
))}
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px', alignItems: 'center', marginTop: 4}}>
{onCancel && (
<button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>
Cancel
</button>
)}
<button className="button primary" type="submit" disabled={isSubmitting}>
{submitLabel === 'Create' && <Plus size={14}/>}
{isSubmitting ? pendingLabel : submitLabel}
</button>
</div>
{submitError && <div className="form-error" role="alert">{submitError}</div>}
</form>
)
}
function FieldRow({field, required, maxLength, value, error, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; error?: string; onChange: (value: string) => void}) {
return (
<>
{field.group && <div className="dynamic-form-group">{field.group}</div>}
<label className={`dynamic-field${field.span ? ' dynamic-field--span' : ''}`}>
<span>
{field.label}
{required && <em className="field-required">*</em>}
</span>
<FieldInput field={field} required={required} maxLength={maxLength} value={value} invalid={Boolean(error)} messageId={`${field.name}-message`} onChange={onChange}/>
{(error || field.description) && (
<small id={`${field.name}-message`} className={error ? 'field-error' : undefined}>
{error ?? field.description}
</small>
)}
</label>
</>
)
}
function FieldInput({field, required, maxLength, value, invalid, messageId, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; invalid: boolean; messageId: string; onChange: (value: string) => void}) {
if (field.type === 'select') {
return (
<select className={`input ${invalid ? 'invalid' : ''}`} value={value} required={required} aria-invalid={invalid || undefined} aria-describedby={invalid || field.description ? messageId : undefined} onChange={(event) => onChange(event.target.value)}>
<option value="">Default</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)
}
if (field.type === 'textarea') {
return (
<textarea
className={`textarea code-textarea ${invalid ? 'invalid' : ''}`}
value={value}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
minLength={field.validation?.minLength}
maxLength={maxLength}
rows={10}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
placeholder={field.label}
/>
)
}
if (field.type === 'file') {
return (
<input
type="file"
className={`input file-input ${invalid ? 'invalid' : ''}`}
required={required}
accept={field.accept}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) {
onChange('')
return
}
void readFileAsBase64(file).then(onChange)
}}
/>
)
}
return (
<input
type={field.type === 'password' ? 'password' : 'text'}
className={`input ${invalid ? 'invalid' : ''}`}
value={value}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
minLength={field.validation?.minLength}
maxLength={maxLength}
pattern={field.validation?.pattern}
onChange={(event) => onChange(event.target.value)}
placeholder={field.label}
/>
)
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('Unable to read the selected file.'))
reader.onload = () => {
const result = reader.result
if (typeof result !== 'string') {
reject(new Error('Unable to read the selected file.'))
return
}
resolve(result.slice(result.indexOf(',') + 1))
}
reader.readAsDataURL(file)
})
}
function getInitialFormValues(
fields: FieldSchema[],
initialValues?: Record<string, unknown>,
): Record<string, string> {
const defaults = defaultValues(fields)
if (initialValues) {
for (const [key, val] of Object.entries(initialValues)) {
if (val !== undefined && val !== null) {
defaults[key] = String(val)
}
}
}
return defaults
}
function defaultValues(fields: FieldSchema[]): Record<string, string> {
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
for (const field of fields) {
const conditional = field.defaultValueWhen
if (conditional) {
values[field.name] = conditional.values[values[conditional.field] ?? ''] ?? field.defaultValue ?? ''
}
}
return values
}
function withDependentDefaults(
fields: FieldSchema[],
currentValues: Record<string, string>,
changedField: string,
value: string,
): Record<string, string> {
const nextValues = {...currentValues, [changedField]: value}
for (const field of fields) {
const conditional = field.defaultValueWhen
if (conditional?.field === changedField) {
nextValues[field.name] = conditional.values[value] ?? field.defaultValue ?? ''
}
}
return nextValues
}
function validateValues(fields: FieldSchema[], values: Record<string, string>): Record<string, string> {
const errors: Record<string, string> = {}
for (const field of fields) {
const value = (values[field.name] ?? '').trim()
if (isFieldRequired(field, values) && !value) {
errors[field.name] = `${field.label} is required.`
continue
}
if (!value) continue
if (field.validation?.minLength && value.length < field.validation.minLength) {
errors[field.name] = field.validation.message ?? `${field.label} is too short.`
continue
}
const maxLength = fieldMaxLength(field, values)
if (maxLength.value && value.length > maxLength.value) {
errors[field.name] = maxLength.message ?? `${field.label} is too long.`
continue
}
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
errors[field.name] = field.validation.message ?? `${field.label} is invalid.`
}
}
return errors
}
function fieldMaxLength(field: FieldSchema, values: Record<string, string>): {value?: number; message?: string} {
const conditional = field.validation?.maxLengthWhen
if (conditional && values[conditional.field] === conditional.equals) {
return {value: conditional.value, message: conditional.message}
}
return {value: field.validation?.maxLength, message: field.validation?.message}
}
function isFieldRequired(field: FieldSchema, values: Record<string, string>): boolean {
if (field.required) return true
if (!field.requiredWhen) return false
return values[field.requiredWhen.field] === field.requiredWhen.equals
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
import {useEffect, useMemo, useState} from 'react'
import {Braces, Plus, RefreshCw, TableProperties} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {listNoSqlItems, putNoSqlItem} from '@/api/cloudProxyClient'
import {JsonRecordModal} from '@/components/JsonRecordModal'
import {useAccountId} from '@/lib/accountStore'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource, NoSqlItem} from '@/types/resource'
interface DynamoDbTableExplorerProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
export function DynamoDbTableExplorer({cloud, resource, runtimeReachable}: DynamoDbTableExplorerProps) {
const qc = useQueryClient()
const accountId = useAccountId()
const tableName = resource?.id
const [selectedItemId, setSelectedItemId] = useState<string>()
const [addRecordOpen, setAddRecordOpen] = useState(false)
const itemsKey = useMemo(() => ['nosql-items', accountId, cloud, tableName], [accountId, cloud, tableName])
const keyFields = useMemo(() => dynamoKeyFields(resource), [resource])
const itemsQuery = useQuery({
queryKey: itemsKey,
queryFn: ({signal}) => listNoSqlItems(cloud, tableName ?? '', signal),
enabled: Boolean(tableName) && runtimeReachable,
})
const items = itemsQuery.data ?? []
const selectedItem = items.find((item) => item.id === selectedItemId)
const putItemMut = useMutation({
mutationFn: (document: Record<string, unknown>) => putNoSqlItem(cloud, tableName ?? '', document),
onSuccess: (item) => {
setAddRecordOpen(false)
setSelectedItemId(item.id)
void qc.invalidateQueries({queryKey: itemsKey})
},
})
useEffect(() => {
setSelectedItemId(undefined)
setAddRecordOpen(false)
}, [tableName])
if (!tableName) {
return (
<section className="dynamodb-explorer">
<div className="empty compact">
<h3>Select a DynamoDB table</h3>
<p>Records and their contents load after a table is selected.</p>
</div>
</section>
)
}
return (
<section className="dynamodb-explorer">
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={<TableProperties size={15}/>} eyebrow="Table explorer" title={tableName} detail={`${items.length} records`}/>
<div className="cosmos-toolbar">
<button
className="button primary"
type="button"
disabled={!runtimeReachable}
onClick={() => {
putItemMut.reset()
setAddRecordOpen(true)
}}
>
<Plus size={14}/>
Add record
</button>
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
<RefreshCw size={14}/>
{itemsQuery.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
<div className="cosmos-items-table">
<table className="table">
<thead>
<tr>
<th>Key</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className={selectedItemId === item.id ? 'selected' : ''}>
<td onClick={() => setSelectedItemId(item.id)}><code>{formatJson(item.key)}</code></td>
<td onClick={() => setSelectedItemId(item.id)}>{summarize(item)}</td>
</tr>
))}
</tbody>
</table>
{itemsQuery.isLoading && <div className="muted padded">Loading records</div>}
{itemsQuery.isSuccess && items.length === 0 && <div className="empty compact"><h3>No records</h3><p>This table does not contain any items.</p></div>}
</div>
</div>
<div className="cosmos-column">
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={selectedItem ? formatJson(selectedItem.key) : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
{selectedItem ? (
<pre className="dynamodb-document">{JSON.stringify(selectedItem.document, null, 2)}</pre>
) : (
<div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>
)}
</div>
<JsonRecordModal
open={addRecordOpen}
title={`Add record to ${tableName}`}
description={dynamoModalDescription(keyFields)}
initialValue={JSON.stringify(Object.fromEntries(keyFields.map(({name, type}) => [name, type === 'N' ? '0' : ''])), null, 2)}
isPending={putItemMut.isPending}
submitError={putItemMut.error instanceof Error ? putItemMut.error.message : undefined}
onClose={() => setAddRecordOpen(false)}
onSubmit={(document) => putItemMut.mutate(document)}
/>
</section>
)
}
interface DynamoKeyField {
name: string
type?: 'S' | 'N' | 'B'
}
function dynamoKeyFields(resource?: CloudResource): DynamoKeyField[] {
const keySchema = Array.isArray(resource?.metadata.keySchema) ? resource.metadata.keySchema : []
const definitions = Array.isArray(resource?.metadata.attributeDefinitions) ? resource.metadata.attributeDefinitions : []
const types = new Map(definitions.flatMap((definition) => {
if (!definition || typeof definition !== 'object') return []
const {AttributeName, AttributeType} = definition as {AttributeName?: unknown; AttributeType?: unknown}
return typeof AttributeName === 'string' && isDynamoKeyType(AttributeType) ? [[AttributeName, AttributeType] as const] : []
}))
return keySchema.flatMap((key) => {
if (!key || typeof key !== 'object') return []
const name = (key as {AttributeName?: unknown}).AttributeName
return typeof name === 'string' ? [{name, type: types.get(name)}] : []
})
}
function dynamoModalDescription(keyFields: DynamoKeyField[]): string {
const keys = keyFields.map(({name, type}) => `${name}${type ? ` (${type})` : ''}`).join(', ')
const binaryHint = keyFields.some(({type}) => type === 'B') ? ' Binary keys use base64.' : ''
const numberHint = keyFields.some(({type}) => type === 'N') ? ' All number keys must be quoted to preserve precision.' : ''
return `Required keys: ${keys || 'the table key attributes'}.${binaryHint}${numberHint} A matching key replaces the existing record.`
}
function isDynamoKeyType(value: unknown): value is 'S' | 'N' | 'B' {
return value === 'S' || value === 'N' || value === 'B'
}
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 summarize(item: NoSqlItem): string {
return Object.entries(item.document)
.slice(0, 3)
.map(([name, value]) => `${name}: ${formatJson(value)}`)
.join(' · ')
}
function formatJson(value: unknown): string {
return JSON.stringify(value) ?? String(value)
}
@@ -0,0 +1,20 @@
import type {LucideIcon} from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description: string
compact?: boolean
}
export function EmptyState({icon: Icon, title, description, compact}: EmptyStateProps) {
return (
<div className={`empty ${compact ? 'compact' : ''}`}>
<div className="empty-icon">
<Icon size={compact ? 18 : 24}/>
</div>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
@@ -0,0 +1,151 @@
import {useEffect, useState} from 'react'
import {AlertTriangle, Download, Loader2, X} from 'lucide-react'
import type {ObjectPreviewKind} from '@/lib/format'
interface ObjectPreviewModalProps {
kind: ObjectPreviewKind
name: string
src: string
onClose: () => void
}
/** Text previews are fetched inline, so cap how much we pull and render. */
const TEXT_PREVIEW_LIMIT = 200_000
/**
* Lightbox/preview drawer for storage objects. Reuses the existing object
* download URL as the media source — no new backend endpoint, per the
* "never invent a custom protocol" rule in CONTRIBUTING.md. Image/video/audio
* are rendered via native HTML media elements; text is fetched and shown as
* plain text, since there is no inline "view" mode on the download endpoint.
*/
export function ImagePreviewModal({kind, name, src, onClose}: ObjectPreviewModalProps) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="image-preview-modal">
<div className="image-preview-header">
<span className="image-preview-name" title={name}>{name}</span>
<div className="image-preview-actions">
<a className="icon-btn" href={src} title={`Download ${name}`}>
<Download size={14}/>
</a>
<button className="icon-btn" type="button" onClick={onClose} title="Close" aria-label="Close">
<X size={14}/>
</button>
</div>
</div>
<div className="image-preview-body">
{kind === 'image' && <ImagePreview src={src} name={name}/>}
{kind === 'video' && <video src={src} controls autoPlay={false}/>}
{kind === 'audio' && <audio src={src} controls autoPlay={false} style={{width: '100%'}}/>}
{kind === 'text' && <TextPreview src={src}/>}
</div>
</div>
</div>
)
}
function ImagePreview({src, name}: {src: string; name: string}) {
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading')
return (
<>
{status === 'loading' && <PreviewStatus label="Loading preview…"/>}
{status === 'error' && <PreviewStatus label="Could not load a preview for this object." isError/>}
<img
src={src}
alt={name}
style={{display: status === 'loaded' ? 'block' : 'none'}}
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
/>
</>
)
}
function TextPreview({src}: {src: string}) {
const [state, setState] = useState<{status: 'loading' | 'loaded' | 'error'; text: string; truncated: boolean}>({
status: 'loading', text: '', truncated: false,
})
useEffect(() => {
const controller = new AbortController()
setState({status: 'loading', text: '', truncated: false})
readTextPreview(src, controller.signal)
.then((result) => setState({status: 'loaded', ...result}))
.catch((err) => {
if ((err as {name?: string}).name === 'AbortError') return
setState({status: 'error', text: '', truncated: false})
})
return () => controller.abort()
}, [src])
if (state.status === 'loading') return <PreviewStatus label="Loading preview…"/>
if (state.status === 'error') return <PreviewStatus label="Could not load a preview for this object." isError/>
return (
<pre className="object-text-preview">
{state.text}
{state.truncated && '\n\n… truncated'}
</pre>
)
}
/**
* Fetches only as much of the object as needed for the preview. Stops
* reading (and cancels the underlying connection) once TEXT_PREVIEW_LIMIT
* characters have been decoded, instead of buffering the full response —
* important for large text-like objects, since res.text() would otherwise
* transfer and hold the entire body in memory just to show the first slice.
*/
async function readTextPreview(src: string, signal: AbortSignal): Promise<{text: string; truncated: boolean}> {
const res = await fetch(src, {signal})
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let text = ''
let truncated: boolean
try {
while (text.length < TEXT_PREVIEW_LIMIT) {
const {done, value} = await reader.read()
if (done) break
text += decoder.decode(value, {stream: true})
}
if (text.length > TEXT_PREVIEW_LIMIT) {
text = text.slice(0, TEXT_PREVIEW_LIMIT)
// A code unit landing exactly on the cut could be the high half of a
// surrogate pair (e.g. an emoji) with its low half past the boundary —
// slice() doesn't know that, and leaves a lone surrogate that renders
// as a replacement glyph. Drop it so the preview only ever ends on a
// complete character.
const lastCode = text.charCodeAt(text.length - 1)
if (lastCode >= 0xd800 && lastCode <= 0xdbff) text = text.slice(0, -1)
truncated = true
} else {
// Confirm whether more data was actually available before claiming
// completeness — the loop can exit exactly at the limit.
const {done} = await reader.read()
truncated = !done
}
} finally {
void reader.cancel().catch(() => {})
}
return {text, truncated}
}
function PreviewStatus({label, isError}: {label: string; isError?: boolean}) {
return (
<div className="image-preview-status">
{isError ? <AlertTriangle size={20}/> : <Loader2 size={20} className="spin"/>}
<span>{label}</span>
</div>
)
}
@@ -0,0 +1,94 @@
import {FormEvent, useEffect, useState} from 'react'
import {X} from 'lucide-react'
interface JsonRecordModalProps {
open: boolean
title: string
description: string
initialValue: string
isPending: boolean
submitError?: string
onClose: () => void
onSubmit: (document: Record<string, unknown>) => void
}
export function JsonRecordModal({
open,
title,
description,
initialValue,
isPending,
submitError,
onClose,
onSubmit,
}: JsonRecordModalProps) {
const [value, setValue] = useState(initialValue)
const [validationError, setValidationError] = useState<string>()
useEffect(() => {
if (!open) return
setValue(initialValue)
setValidationError(undefined)
}, [initialValue, open])
useEffect(() => {
if (!open) return
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isPending) onClose()
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [isPending, onClose, open])
if (!open) return null
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
const parsed = JSON.parse(value) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setValidationError('Record must be a JSON object.')
return
}
setValidationError(undefined)
onSubmit(parsed as Record<string, unknown>)
} catch (error) {
setValidationError(error instanceof Error ? error.message : 'Invalid JSON.')
}
}
return (
<div className="modal-overlay" onClick={(event) => event.target === event.currentTarget && !isPending && onClose()}>
<form className="record-modal" role="dialog" aria-modal="true" aria-label={title} onSubmit={submit}>
<div className="record-modal-header">
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
<button className="icon-btn" type="button" aria-label="Close" disabled={isPending} onClick={onClose}>
<X size={15}/>
</button>
</div>
<textarea
className={`textarea code-textarea record-modal-editor ${validationError ? 'invalid' : ''}`}
aria-label="Record JSON"
value={value}
onChange={(event) => {
setValue(event.target.value)
setValidationError(undefined)
}}
spellCheck={false}
autoFocus
disabled={isPending}
/>
{(validationError || submitError) && <div className="form-error">{validationError ?? submitError}</div>}
<div className="modal-footer">
<button className="button" type="button" disabled={isPending} onClick={onClose}>Cancel</button>
<button className="button primary" type="submit" disabled={isPending}>
{isPending ? 'Adding' : 'Add record'}
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,74 @@
import {act, fireEvent, render, screen} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {afterEach, describe, expect, test, vi} from "vitest";
import {encryptKmsResource, type KmsEncryptResponse} from "@/api/cloudProxyClient";
import {KmsCryptoPanel} from "@/components/KmsCryptoPanel";
import {DEFAULT_ACCOUNT_ID, setAccountId} from "@/lib/accountStore";
import type {CloudResource} from "@/types/resource";
vi.mock("@/api/cloudProxyClient", () => ({
decryptKmsResource: vi.fn(),
encryptKmsResource: vi.fn(),
}));
const resource: CloudResource = {
id: "key-1",
name: "key-1",
cloud: "aws",
service: "kms",
type: "key",
region: "us-east-1",
createdAt: null,
status: "Enabled",
metadata: {
enabled: true,
keySpec: "SYMMETRIC_DEFAULT",
keyUsage: "ENCRYPT_DECRYPT",
},
};
afterEach(() => {
vi.clearAllMocks();
setAccountId(DEFAULT_ACCOUNT_ID);
});
describe("KmsCryptoPanel", () => {
test("clears sensitive state and ignores an old response when the account changes", async () => {
setAccountId(DEFAULT_ACCOUNT_ID);
let resolveEncrypt: ((value: KmsEncryptResponse) => void) | undefined;
vi.mocked(encryptKmsResource).mockReturnValue(new Promise((resolve) => {
resolveEncrypt = resolve;
}));
const user = userEvent.setup();
render(<KmsCryptoPanel cloud="aws" resource={resource} runtimeReachable={true}/>);
await user.type(screen.getByLabelText("Plaintext (UTF-8)"), "account-a-secret");
fireEvent.change(screen.getByLabelText("Encryption context (optional JSON)"), {
target: {value: '{"tenant":"a"}'},
});
const encryptButtons = screen.getAllByRole("button", {name: "Encrypt"});
await user.click(encryptButtons[encryptButtons.length - 1]);
expect(screen.getByRole("button", {name: "Working"})).toBeDisabled();
act(() => {
setAccountId("111111111111");
});
expect(screen.getByLabelText("Plaintext (UTF-8)")).toHaveValue("");
expect(screen.getByLabelText("Encryption context (optional JSON)")).toHaveValue("");
const resetEncryptButtons = screen.getAllByRole("button", {name: "Encrypt"});
expect(resetEncryptButtons[resetEncryptButtons.length - 1]).toBeEnabled();
await act(async () => {
resolveEncrypt?.({
ciphertextBlobBase64: "old-account-result",
keyId: "key-1",
encryptionAlgorithm: "SYMMETRIC_DEFAULT",
});
await Promise.resolve();
});
expect(screen.queryByText("old-account-result")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import {useEffect, useRef, useState} from "react";
import {Copy, KeyRound, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
import {
decryptKmsResource,
encryptKmsResource,
type KmsEncryptionAlgorithm,
} from "@/api/cloudProxyClient";
import {useAccountId} from "@/lib/accountStore";
import type {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
interface KmsCryptoPanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
type CryptoMode = "encrypt" | "decrypt";
type CryptoResult = {label: string; value: string};
export function KmsCryptoPanel({cloud, resource, runtimeReachable}: KmsCryptoPanelProps) {
const accountId = useAccountId();
const keySpec = metadataString(resource, "keySpec");
const keyUsage = metadataString(resource, "keyUsage");
const keyEnabled = resource?.status === "Enabled" && resource.metadata.enabled === true;
const [mode, setMode] = useState<CryptoMode>("encrypt");
const [plaintext, setPlaintext] = useState("");
const [ciphertext, setCiphertext] = useState("");
const [algorithm, setAlgorithm] = useState<KmsEncryptionAlgorithm>(defaultAlgorithm(keySpec));
const [contextText, setContextText] = useState("");
const [result, setResult] = useState<CryptoResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const activeRequest = useRef<AbortController | null>(null);
useEffect(() => {
activeRequest.current?.abort();
activeRequest.current = null;
setMode("encrypt");
setPlaintext("");
setCiphertext("");
setAlgorithm(defaultAlgorithm(keySpec));
setContextText("");
setResult(null);
setError(null);
setPending(false);
setCopied(false);
return () => {
activeRequest.current?.abort();
activeRequest.current = null;
};
}, [accountId, resource?.id, keySpec]);
const isKmsKey = resource?.service === "kms" && resource.type === "key";
const isSupportedKey = keyUsage === "ENCRYPT_DECRYPT" && keyEnabled && isEncryptKeySpec(keySpec);
const canSubmit = Boolean(isKmsKey && isSupportedKey && runtimeReachable && !pending);
const isRsa = keySpec === "RSA_2048" || keySpec === "RSA_4096";
const changeMode = (next: CryptoMode) => {
if (next === mode) return;
activeRequest.current?.abort();
activeRequest.current = null;
setMode(next);
setPlaintext("");
setCiphertext("");
setContextText("");
setResult(null);
setError(null);
setPending(false);
setCopied(false);
};
const submit = async () => {
if (!resource || !canSubmit) return;
setError(null);
setResult(null);
setCopied(false);
activeRequest.current?.abort();
const controller = new AbortController();
activeRequest.current = controller;
setPending(true);
try {
const encryptionContext = isRsa ? undefined : parseEncryptionContext(contextText);
if (mode === "encrypt") {
if (!plaintext) throw new Error("Enter plaintext to encrypt.");
const response = await encryptKmsResource(cloud, resource.id, {
plaintextBase64: utf8ToBase64(plaintext),
encryptionAlgorithm: algorithm,
...(encryptionContext ? {encryptionContext} : {}),
}, controller.signal);
if (controller.signal.aborted) return;
setResult({label: "Ciphertext (Base64)", value: response.ciphertextBlobBase64});
} else {
if (!ciphertext.trim()) throw new Error("Enter Base64 ciphertext to decrypt.");
const response = await decryptKmsResource(cloud, resource.id, {
ciphertextBlobBase64: ciphertext.trim(),
encryptionAlgorithm: algorithm,
...(encryptionContext ? {encryptionContext} : {}),
}, controller.signal);
if (controller.signal.aborted) return;
setResult(decryptedResult(response.plaintextBase64));
}
} catch (submitError) {
if (controller.signal.aborted) return;
setError(submitError instanceof Error ? submitError.message : "KMS operation failed.");
} finally {
if (activeRequest.current === controller) {
activeRequest.current = null;
setPending(false);
}
}
};
const copyResult = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
if (!resource || resource.service !== "kms") {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select an encryption key</h3>
<p>Select an enabled ENCRYPT_DECRYPT KMS key to encrypt or decrypt data.</p>
</div>
</section>
);
}
return (
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">KMS Actions</p>
<h3><KeyRound size={15} /> Encrypt and decrypt</h3>
<p className="muted compact-text">
Values stay in this panel and are cleared when you switch keys or operations.
</p>
</div>
<span className={`runtime-state ${canSubmit ? "ready" : "pending"}`}>
{canSubmit ? "Ready" : operationUnavailableReason(keyUsage, keyEnabled, keySpec, runtimeReachable)}
</span>
</div>
<div className="resource-create-inline">
<div className="drawer-tabs">
<button type="button" className={`drawer-tab ${mode === "encrypt" ? "active" : ""}`} onClick={() => changeMode("encrypt")}>
<LockKeyhole size={13} /> Encrypt
</button>
<button type="button" className={`drawer-tab ${mode === "decrypt" ? "active" : ""}`} onClick={() => changeMode("decrypt")}>
<UnlockKeyhole size={13} /> Decrypt
</button>
</div>
<label className="metric-label" htmlFor="kms-encryption-algorithm">Encryption algorithm</label>
<select
id="kms-encryption-algorithm"
className="input"
value={algorithm}
disabled={!isRsa}
onChange={(event) => setAlgorithm(event.target.value as KmsEncryptionAlgorithm)}
>
{isRsa ? (
<>
<option value="RSAES_OAEP_SHA_256">RSAES_OAEP_SHA_256</option>
<option value="RSAES_OAEP_SHA_1">RSAES_OAEP_SHA_1</option>
</>
) : (
<option value="SYMMETRIC_DEFAULT">SYMMETRIC_DEFAULT</option>
)}
</select>
<label className="metric-label" htmlFor={`kms-${mode}-input`}>
{mode === "encrypt" ? "Plaintext (UTF-8)" : "Ciphertext (Base64)"}
</label>
<textarea
id={`kms-${mode}-input`}
className="json-editor"
value={mode === "encrypt" ? plaintext : ciphertext}
onChange={(event) => mode === "encrypt" ? setPlaintext(event.target.value) : setCiphertext(event.target.value)}
spellCheck={false}
autoComplete="off"
style={{minHeight: 120}}
/>
{!isRsa && (
<>
<label className="metric-label" htmlFor="kms-encryption-context">Encryption context (optional JSON)</label>
<textarea
id="kms-encryption-context"
className="json-editor"
value={contextText}
onChange={(event) => setContextText(event.target.value)}
spellCheck={false}
autoComplete="off"
placeholder={'{"purpose":"local-test"}'}
style={{minHeight: 72}}
/>
<p className="muted compact-text">Encryption context is authenticated metadata and may appear in audit records. Do not put secrets in it.</p>
</>
)}
<button className="button primary" type="button" disabled={!canSubmit} onClick={() => void submit()}>
{pending ? <Loader2 size={13} className="spin" /> : mode === "encrypt" ? <LockKeyhole size={13} /> : <UnlockKeyhole size={13} />}
{pending ? "Working" : mode === "encrypt" ? "Encrypt" : "Decrypt"}
</button>
{error && <p className="error-text compact-text">{error}</p>}
{result && (
<div className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">{result.label}</p>
<button className="button" type="button" onClick={() => void copyResult()}>
<Copy size={13} /> {copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="invoke-result success">{result.value}</pre>
</div>
)}
</div>
</section>
);
}
function metadataString(resource: CloudResource | undefined, key: string): string | null {
const value = resource?.metadata[key];
return typeof value === "string" ? value : null;
}
function defaultAlgorithm(keySpec: string | null): KmsEncryptionAlgorithm {
return keySpec === "RSA_2048" || keySpec === "RSA_4096"
? "RSAES_OAEP_SHA_256"
: "SYMMETRIC_DEFAULT";
}
function isEncryptKeySpec(keySpec: string | null): boolean {
return keySpec === "SYMMETRIC_DEFAULT" || keySpec === "RSA_2048" || keySpec === "RSA_4096";
}
function operationUnavailableReason(
keyUsage: string | null,
keyEnabled: boolean,
keySpec: string | null,
runtimeReachable: boolean,
): string {
if (!runtimeReachable) return "Runtime unavailable";
if (keyUsage !== "ENCRYPT_DECRYPT") return "Unsupported key usage";
if (!keyEnabled) return "Key not enabled";
if (!isEncryptKeySpec(keySpec)) return "Unsupported key spec";
return "Unavailable";
}
function parseEncryptionContext(raw: string): Record<string, string> | undefined {
if (!raw.trim()) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("Encryption context must be valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Encryption context must be a JSON object of string values.");
}
const entries = Object.entries(value);
if (entries.some(([, entryValue]) => typeof entryValue !== "string")) {
throw new Error("Encryption context values must be strings.");
}
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>;
}
function utf8ToBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window.btoa(binary);
}
function decryptedResult(plaintextBase64: string): CryptoResult {
try {
const binary = window.atob(plaintextBase64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return {label: "Plaintext (UTF-8)", value: new TextDecoder("utf-8", {fatal: true}).decode(bytes)};
} catch {
return {label: "Plaintext (Base64)", value: plaintextBase64};
}
}
+361
View File
@@ -0,0 +1,361 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, Moon, Search, Sun} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react'
import flociWhite from '@/assets/floci-white.svg'
import flociBlack from '@/assets/floci-black.svg'
import flociMarkWhite from '@/assets/floci-mark-white.svg'
import flociMarkBlack from '@/assets/floci-mark-black.svg'
import {useTheme} from '@/lib/useTheme'
import {useSidebar} from '@/lib/useSidebar'
import {useQuery} from '@tanstack/react-query'
import {getCloudStatus} from '@/api/cloudProxyClient'
import {useCloudServicesQuery} from '@/api/queries/cloudQueries'
import {AccountSwitcher} from '@/components/AccountSwitcher'
import {serviceIcon} from '@/components/serviceIcons'
import type {CloudProvider, CloudServiceDescriptor, RuntimeReachability} from '@/types/cloud'
/** The runtime's reachability, plus the state before the status query answers. */
type ConnectionStatus = RuntimeReachability | 'unknown'
/**
* Only a definitive answer gets a colour.
*
* `unknown` (status query still in flight — which is first paint and every
* cloud switch) and `coming_soon` (runtime not wired) keep the neutral base
* grey. Colouring anything that is not `reachable` red made the dot flash
* green → red → green on each switch, and start red on load.
*/
function connectionDotClass(status: ConnectionStatus): string {
if (status === 'reachable') return 'dot healthy'
if (status === 'unavailable') return 'dot unavailable'
return 'dot'
}
/** Matches today's service count, so the real nav causes no layout jump. */
const SKELETON_ROWS = 7
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ElementType; label: string; collapsed: boolean }) {
const Icon = icon
return (
<NavLink className="nav-link" to={to} title={collapsed ? label : undefined}>
<Icon size={14} aria-hidden="true"/>
<span>{label}</span>
</NavLink>
)
}
/**
* The nav is rendered entirely from `GET /clouds/:cloud/services`.
*
* It used to be a hardcoded item list plus a per-cloud boolean that never
* consulted the server, so registering an adapter did not light up the nav and
* availability could disagree with the API. Adding a service is now a catalog
* row on the server and nothing here.
*/
function CloudServiceNav({collapsed}: {collapsed: boolean}) {
const location = useLocation()
const [searchParams] = useSearchParams()
const search = (searchParams.get('search') ?? '').trim().toLowerCase()
const cloud = activeCloudFromPath(location.pathname)
const cloudLabel = cloud.toUpperCase()
const {data, isPending, isError, refetch, isFetching} = useCloudServicesQuery(cloud)
if (isPending) return <CloudServiceNavSkeleton cloudLabel={cloudLabel}/>
if (isError) {
return (
<div className="nav-section cloud-service-nav">
<span className="nav-label">Cloud Services · {cloudLabel}</span>
<div className="nav-link disabled nav-error">
<AlertTriangle size={14} aria-hidden="true"/>
<span>Services unavailable</span>
</div>
<button className="nav-retry" type="button" disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? 'Retrying…' : 'Retry'}
</button>
</div>
)
}
const allServices = data ?? []
const filteredServices = search
? allServices.filter((s) =>
s.displayName.toLowerCase().includes(search) ||
s.service.toLowerCase().includes(search) ||
s.group.toLowerCase().includes(search)
)
: allServices
const groups = groupByGroup(filteredServices)
return (
<div className="nav-section cloud-service-nav">
<span className="nav-label">Cloud Services · {cloudLabel}</span>
{filteredServices.length === 0 && search ? (
<div className="nav-link disabled">
<span>No matching services</span>
</div>
) : (
groups.map(([group, services]) => (
<div className="nav-group" key={group}>
<span className="nav-group-label">{group}</span>
{services.map((service) => (
<CloudServiceNavItem key={service.service} cloud={cloud} service={service} collapsed={collapsed}/>
))}
</div>
))
)}
</div>
)
}
function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) {
const Icon = serviceIcon(service.iconKey)
const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName
if (service.availability === 'available') {
const target = service.route.startsWith('/')
? service.route
: `/cloud-explorer/${cloud}/${service.route}`
return <NavItem to={target} icon={Icon} label={service.displayName} collapsed={collapsed}/>
}
// Title duplicates the visible label when the sidebar is expanded, which WAVE
// flags as redundant. Keep it on the rail, where the label is hidden.
return (
<div
className="nav-link disabled"
title={collapsed || service.reason ? reasonTitle : undefined}
>
<Icon size={14} aria-hidden="true"/>
<span>{service.displayName}</span>
<span className="nav-soon">Soon</span>
</div>
)
}
function CloudServiceNavSkeleton({cloudLabel}: {cloudLabel: string}) {
return (
<div className="nav-section cloud-service-nav">
<span className="nav-label">Cloud Services · {cloudLabel}</span>
{Array.from({length: SKELETON_ROWS}, (_, index) => (
<div className="nav-link nav-skeleton" key={index} aria-hidden="true">
<span className="skeleton-bar"/>
</div>
))}
</div>
)
}
/** Preserves the server's ordering while bucketing into its groups. */
function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudServiceDescriptor[]]> {
const groups = new Map<string, CloudServiceDescriptor[]>()
for (const service of services) {
const existing = groups.get(service.group)
if (existing) existing.push(service)
else groups.set(service.group, [service])
}
return [...groups]
}
export function Layout() {
const location = useLocation()
const activeCloud = activeCloudFromPath(location.pathname)
const {theme, toggle} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
refetchInterval: 5000
})
const status: ConnectionStatus = isError ? 'unavailable' : data?.runtime ?? 'unknown'
const isConnected = status === 'reachable'
const connectionLabel = isConnected ? 'Connected' : 'Not connected'
const connectionTarget = data?.endpoint ?? activeCloud
return (
<div className="app">
<a className="skip-link" href="#main-content">Skip to content</a>
<aside className="sidebar">
<div className="sidebar-inner">
<div className="brand">
<img className="brand-logo" src={isDark ? flociWhite : flociBlack} alt="Floci"/>
<img className="brand-mark" src={isDark ? flociMarkWhite : flociMarkBlack} alt="" aria-hidden="true"/>
<p>Local Cloud</p>
</div>
<nav className="nav" aria-label="Console">
<div className="nav-section">
<span className="nav-label">General</span>
<NavItem to={`/console/${activeCloud}`} icon={LayoutDashboard} label="Console Home" collapsed={collapsed}/>
</div>
<CloudServiceNav collapsed={collapsed}/>
</nav>
<div className="sidebar-footer">
<span className="sidebar-footer-text">Floci DevTools · Local</span>
<button
ref={toggleRef}
className="icon-btn"
type="button"
onClick={toggleSidebar}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronsRight size={14}/> : <ChevronsLeft size={14}/>}
</button>
</div>
</div>
</aside>
<div className="shell">
<header className="topbar">
<TopbarSearch/>
<button
className="icon-btn"
type="button"
onClick={toggle}
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
>
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
</button>
<div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/>
<div className={`connection ${isConnected ? 'connected' : 'disconnected'}`}>
<span className={connectionDotClass(status)} aria-hidden="true"/>
<span className="connection-state">{connectionLabel}</span>
<span className="connection-target">{connectionTarget}</span>
</div>
</header>
<main id="main-content" className="main" tabIndex={-1}>
<Outlet/>
</main>
</div>
</div>
)
}
/**
* Topbar search bar.
*
* Reads the initial value from the `search` URL query parameter so the input
* survives page refreshes and back/forward navigation. Writes back with a
* 300 ms debounce so the URL is not updated on every keystroke.
*
* Pressing `/` when no other focusable input is active focuses this bar,
* matching the keyboard hint shown in the widget.
* Pressing Escape clears the query and blurs the input.
*/
function TopbarSearch() {
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const initialQuery = searchParams.get('search') ?? ''
const [draft, setDraft] = useState(initialQuery)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const commit = useCallback((value: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
if (value) {
next.set('search', value)
} else {
next.delete('search')
}
return next
},
{replace: true},
)
}, [setSearchParams])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setDraft(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => commit(value), 300)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
setDraft('')
commit('')
inputRef.current?.blur()
}
}
/** Clean up pending debounce timer on unmount. */
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
}
}, [])
/** Clear pending debounce and restore draft from destination URL search param on route change. */
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
const urlSearch = searchParams.get('search') ?? ''
setDraft(urlSearch)
}, [location.pathname, searchParams])
/** Focus on `/` when no other input/textarea/select is active. */
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== '/') return
const tag = (event.target as HTMLElement).tagName
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return
event.preventDefault()
inputRef.current?.focus()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
/** Keep local draft in sync if the param changes externally (e.g. nav, browser history). */
useEffect(() => {
const external = searchParams.get('search') ?? ''
setDraft((prev) => {
if (prev !== external) {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
return external
}
return prev
})
}, [searchParams])
return (
<div className="search">
<Search size={14} aria-hidden="true"/>
<input
ref={inputRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Search services, features, docs, and more"
aria-label="Search services, features, docs, and more"
/>
<span className="kbd" aria-hidden="true">/</span>
</div>
)
}
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
}
@@ -0,0 +1,245 @@
import {useEffect, useState} from 'react'
import {createPortal} from 'react-dom'
import {ChevronRight, Maximize2, Minimize2, RefreshCw, ScrollText} from 'lucide-react'
import {useQueries} from '@tanstack/react-query'
import {listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
import {EmptyState} from '@/components/EmptyState'
import {formatBytes, formatDateTime} from '@/lib/format'
import {timeAgo} from '@/lib/utils'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
interface LogsExplorerPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
/**
* Lives in ResourceInspector next to a selected log group's metadata and the
* Insights query panel, rather than as a full-width section under the
* resource table. This is the two-level drill-in beneath a selected group:
* streams (a child collection) and events (the leaf item), both served by the
* generic child-collections SPI that AwsLogsAdapter implements — three
* min-width columns (~1000px), too wide for the inspector's narrow column, so
* the inline view is a compact summary and the real browser opens in the same
* full-page modal the query panel uses.
*
* Both streams and events use one query per loaded cursor page (via
* useQueries) rather than useInfiniteQuery, following the precedent set for
* events by the original implementation this panel ported from. Refresh
* resets the cursor array to a single page and explicitly refetches it,
* rather than only refetching the first query: with more than one page
* loaded, refetching in place would otherwise combine a fresh first page
* with stale trailing pages built from the old cursor chain.
*/
export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplorerPanelProps) {
const groupId = resource?.id
const [selectedStreamId, setSelectedStreamId] = useState<string | undefined>()
const [streamCursors, setStreamCursors] = useState<Array<string | undefined>>([undefined])
const [eventCursors, setEventCursors] = useState<Array<string | undefined>>([undefined])
const [expanded, setExpanded] = useState(false)
useEffect(() => {
setSelectedStreamId(undefined)
setStreamCursors([undefined])
setEventCursors([undefined])
}, [cloud, groupId])
useEffect(() => {
setEventCursors([undefined])
}, [selectedStreamId])
const streamPages = useQueries({
queries: streamCursors.map((cursor) => ({
queryKey: ['log-streams', cloud, groupId, cursor],
queryFn: ({signal}: {signal: AbortSignal}) => listChildCollections(cloud, 'logs', groupId ?? '', cursor, signal),
enabled: Boolean(groupId) && runtimeReachable,
})),
})
const firstStreamPage = streamPages[0]
const lastStreamPage = streamPages[streamPages.length - 1]
const streams = streamPages.flatMap((page) => page.data?.items ?? [])
const nextStreamCursor = lastStreamPage?.data?.nextCursor ?? null
const loadingMoreStreams = streamPages.length > 1 && lastStreamPage?.isLoading
const eventPages = useQueries({
queries: eventCursors.map((cursor) => ({
queryKey: ['log-events', cloud, groupId, selectedStreamId, cursor],
queryFn: ({signal}: {signal: AbortSignal}) => listCollectionItems(cloud, 'logs', groupId ?? '', selectedStreamId ?? '', cursor, signal),
enabled: Boolean(groupId && selectedStreamId) && runtimeReachable,
})),
})
const firstEventPage = eventPages[0]
const lastEventPage = eventPages[eventPages.length - 1]
const events = eventPages.flatMap((page) => page.data?.items ?? [])
const nextEventCursor = lastEventPage?.data?.nextCursor ?? null
const loadingMoreEvents = eventPages.length > 1 && lastEventPage?.isLoading
function refreshStreams() {
setStreamCursors([undefined])
void firstStreamPage?.refetch()
}
function refreshEvents() {
setEventCursors([undefined])
void firstEventPage?.refetch()
}
if (!groupId) return null
const content = (
<section className="cosmos-panel">
<div className="cosmos-column">
<div className="cosmos-panel-header">
<ScrollText size={15}/>
<span>
<small>Streams</small>
<strong>{groupId}</strong>
<em>{streams.length} log streams</em>
</span>
<button className="icon-btn" type="button" title="Collapse" onClick={() => setExpanded(false)}>
<Minimize2 size={14}/>
</button>
</div>
<div className="cosmos-toolbar">
<button className="button" type="button" disabled={!runtimeReachable || firstStreamPage?.isFetching} onClick={refreshStreams}>
<RefreshCw size={14}/>
Refresh
</button>
</div>
{firstStreamPage?.error instanceof Error && <div className="form-error">{firstStreamPage.error.message}</div>}
<div className="cosmos-list">
{firstStreamPage?.isLoading && <div className="muted padded">Loading log streams</div>}
{!firstStreamPage?.isLoading && streams.length === 0 && (
<EmptyState
icon={ScrollText}
title="No log streams"
description="Log streams created in this group will appear here."
/>
)}
{streams.map((stream) => (
<button
key={stream.id}
className={`cosmos-list-row ${selectedStreamId === stream.id ? 'selected' : ''}`}
type="button"
onClick={() => setSelectedStreamId(stream.id)}
>
<span>
<strong>{stream.name}</strong>
<small>Last event {timeAgo(typeof stream.metadata.lastEventTimestamp === 'string' ? stream.metadata.lastEventTimestamp : undefined)}</small>
</span>
<em>{typeof stream.metadata.storedBytes === 'number' ? formatBytes(stream.metadata.storedBytes) : '-'}</em>
<ChevronRight size={13}/>
</button>
))}
{nextStreamCursor && (
<div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}>
<button
className="button"
type="button"
disabled={Boolean(loadingMoreStreams)}
onClick={() => setStreamCursors((prev) => [...prev, nextStreamCursor])}
>
{loadingMoreStreams ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</div>
</div>
<div className="cosmos-column cosmos-column--wide">
<div className="cosmos-panel-header">
<ScrollText size={15}/>
<span>
<small>Events</small>
<strong>{selectedStreamId ?? 'Select a stream'}</strong>
<em>{events.length} events loaded</em>
</span>
</div>
<div className="cosmos-toolbar">
<button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
<RefreshCw size={14}/>
Refresh
</button>
</div>
{firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
<div className="cosmos-items-table">
{!selectedStreamId && (
<div className="empty compact"><h3>Select a stream</h3><p>Events are scoped to a log stream.</p></div>
)}
{selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
<EmptyState
icon={ScrollText}
title="No log events"
description="Events written to this stream will appear here."
/>
)}
{selectedStreamId && (firstEventPage?.isLoading ? (
<div className="muted padded">Loading log events</div>
) : events.length > 0 && (
<>
<table className="table">
<thead>
<tr>
<th>Timestamp</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id}>
<td className="mono" style={{color: '#8d9cad', whiteSpace: 'nowrap'}}>
{formatDateTime(event.timestamp) ?? '-'}
</td>
<td className="mono">{String(event.body.message ?? '')}</td>
</tr>
))}
</tbody>
</table>
{nextEventCursor && (
<div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}>
<button
className="button"
type="button"
disabled={Boolean(loadingMoreEvents)}
onClick={() => setEventCursors((prev) => [...prev, nextEventCursor])}
>
{loadingMoreEvents ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
))}
</div>
</div>
</section>
)
const summary = (
<section className="logs-query-panel">
<div className="logs-query-header">
<ScrollText size={15}/>
<span>
<small>Streams &amp; events</small>
<strong>{groupId}</strong>
<em>{firstStreamPage?.isLoading ? 'Loading…' : `${streams.length} log streams`}</em>
</span>
<button className="icon-btn" type="button" title="Expand to full page" onClick={() => setExpanded(true)}>
<Maximize2 size={14}/>
</button>
</div>
</section>
)
if (!expanded) return summary
return createPortal(
<div className="modal-overlay" onClick={() => setExpanded(false)}>
<div className="logs-query-modal" onClick={(event) => event.stopPropagation()}>
{content}
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,207 @@
import {useEffect, useState, type KeyboardEvent} from 'react'
import {createPortal} from 'react-dom'
import {Maximize2, Minimize2, Play, Terminal} from 'lucide-react'
import {useMutation} from '@tanstack/react-query'
import {queryLogs} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {LogsInsightsQueryResult} from '@/types/resource'
const DEFAULT_QUERY = 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20'
const RANGE_OPTIONS = [
{label: '15m', minutes: 15},
{label: '1h', minutes: 60},
{label: '6h', minutes: 360},
{label: '24h', minutes: 1440},
]
interface LogsQueryPanelProps {
cloud: CloudProvider
logGroupName?: string
runtimeReachable: boolean
}
/**
* Lives inside ResourceInspector next to a selected log group's metadata,
* rather than as a full-width section a dev has to scroll the resource table
* to find. The inspector column is narrow, so this expands into a full-page
* modal on demand via a portal — the same mounted component either way, so
* expanding/collapsing keeps the in-progress query text and results.
*
* Floci's supported StartQuery subset (fields/filter/sort/dedup/limit — see
* docs/services/cloudwatch.md) has no UI of its own; this is that surface.
* Styled after the real CloudWatch Logs Insights query editor (dark code box,
* time-range chips, run-with-shortcut), minus the capabilities Floci doesn't
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
* rule against decorative, unwired controls.
*/
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQueryPanelProps) {
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
const [expanded, setExpanded] = useState(false)
const queryMut = useMutation({
mutationFn: () => {
const endTime = Math.floor(Date.now() / 1000)
return queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
},
})
const {mutate: runQuery, reset: resetQuery} = queryMut
// ResourceInspector reuses this instance across row selections rather than
// remounting it, so without this the previous group's results linger and
// read as if they belong to the newly selected one.
useEffect(() => {
resetQuery()
setQueryText(DEFAULT_QUERY)
setRangeMinutes(RANGE_OPTIONS[1].minutes)
setExpanded(false)
}, [logGroupName, resetQuery])
if (!logGroupName) return null
function submit() {
if (!queryText.trim()) return
runQuery()
}
function onEditorKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
submit()
}
}
const panel = (
<section className="logs-query-panel">
<div className="logs-query-header">
<Terminal size={15}/>
<span>
<small>Insights query</small>
<strong>{logGroupName}</strong>
<em>fields / filter / sort / dedup / limit</em>
</span>
<div className="logs-query-range" role="group" aria-label="Time range">
{RANGE_OPTIONS.map((option) => (
<button
key={option.minutes}
type="button"
className={option.minutes === rangeMinutes ? 'active' : ''}
disabled={!runtimeReachable}
onClick={() => setRangeMinutes(option.minutes)}
>
{option.label}
</button>
))}
</div>
<button
className="icon-btn"
type="button"
title={expanded ? 'Collapse' : 'Expand to full page'}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <Minimize2 size={14}/> : <Maximize2 size={14}/>}
</button>
</div>
<div className="logs-query-editor-wrap">
<textarea
className="logs-query-textarea"
value={queryText}
onChange={(event) => setQueryText(event.target.value)}
onKeyDown={onEditorKeyDown}
spellCheck={false}
rows={expanded ? 8 : 3}
/>
<div className="logs-query-actions">
<span className="logs-query-shortcut">{macShortcut()}+Enter</span>
<button
className="button primary"
type="button"
disabled={!runtimeReachable || queryMut.isPending || !queryText.trim()}
onClick={submit}
>
<Play size={14}/>
{queryMut.isPending ? 'Running' : 'Run query'}
</button>
</div>
</div>
{queryMut.error instanceof Error && <div className="form-error">{queryMut.error.message}</div>}
<LogsQueryResults result={queryMut.data} expanded={expanded}/>
</section>
)
if (!expanded) return panel
return createPortal(
<div className="modal-overlay" onClick={() => setExpanded(false)}>
<div className="logs-query-modal" onClick={(event) => event.stopPropagation()}>
{panel}
</div>
</div>,
document.body,
)
}
function macShortcut(): string {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
}
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
if (!result) {
return (
<div className="empty compact sql-results-empty">
<h3>No query results</h3>
<p>Run an Insights query to see matching log events.</p>
</div>
)
}
// Different log events discover different fields (a query with no `fields`
// clause, or events whose shape varies row to row), so a field present only
// on a later row must not be dropped just because row 0 lacked it.
const columns = Array.from(new Set(result.rows.flatMap((row) => Object.keys(row))))
const stillRunning = STILL_RUNNING_STATUSES.has(result.status)
return (
<div className="sql-results">
<div className="sql-results-summary">
<span>Status: {result.status}</span>
<span>{result.rows.length} rows</span>
</div>
{stillRunning && (
<div className="muted padded">
Floci hadn't finished this query when the console stopped waiting — these rows are a partial,
in-progress result, not the full answer. Run the query again to see if it has finished
(query id: {result.queryId}).
</div>
)}
{!stillRunning && result.rows.length === 0 && <div className="muted padded">No events matched this query.</div>}
{columns.length > 0 && (
<div className={expanded ? 'sql-result-table-wrap logs-query-table-wrap--expanded' : 'sql-result-table-wrap'}>
<table className="table sql-result-table">
<thead>
<tr>
{columns.map((column) => <th key={column}>{column}</th>)}
</tr>
</thead>
<tbody>
{result.rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column} title={row[column]}>{row[column]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import {useState} from 'react'
import {File, FileAudio, FileSpreadsheet, FileText, FileType, FileVideo, Image} from 'lucide-react'
import type {ObjectIconKind} from '@/lib/format'
interface ObjectThumbnailProps {
kind: ObjectIconKind
src: string
name: string
/** Whether fetching object content (the Download action) is currently allowed. */
canLoad: boolean
}
const KIND_ICON: Record<Exclude<ObjectIconKind, 'image'>, typeof File> = {
video: FileVideo,
audio: FileAudio,
text: FileText,
pdf: FileType,
document: FileText,
spreadsheet: FileSpreadsheet,
}
/**
* Row icon for a recognized object type. Images get a real square thumbnail
* (falling back to the generic file icon on load failure — e.g. the
* extension heuristic guessed wrong). Every other recognized kind gets a
* type-specific icon instead of a rendered thumbnail.
*
* When `canLoad` is false (download is gated off — runtime unavailable, or
* the schema disables it), this never issues the image request: it falls
* back to a static icon instead. Otherwise the thumbnail would fetch object
* content behind the same capability check the Download button honors.
*/
export function ObjectThumbnail({kind, src, name, canLoad}: ObjectThumbnailProps) {
const [failed, setFailed] = useState(false)
if (kind !== 'image' || failed || !canLoad) {
const Icon = kind === 'image' ? Image : KIND_ICON[kind]
return <Icon size={14}/>
}
return (
<img
className="object-thumbnail"
src={src}
alt=""
title={name}
loading="lazy"
onError={() => setFailed(true)}
/>
)
}
@@ -0,0 +1,443 @@
import {useState} from 'react'
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
import { LogsQueryPanel } from "@/components/LogsQueryPanel";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format";
interface ResourceInspectorProps {
resource?: CloudResource;
object?: StorageObject;
cloud?: CloudProvider;
runtimeReachable?: boolean;
serviceName?: string;
}
export function ResourceInspector({
resource,
object,
cloud,
runtimeReachable,
serviceName,
}: ResourceInspectorProps) {
if (!resource) {
return (
<div className="resource-inspector empty compact">
<p className="eyebrow">Resource Inspector</p>
<h3>Select a resource</h3>
<p>Inspect normalized metadata returned by the cloud proxy.</p>
</div>
);
}
if (object) {
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3>{object.name}</h3>
<span className="badge neutral">{object.type}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource" value={resource.name} />
<InspectorItem label="Key" value={object.key} />
<InspectorItem
label="Size"
value={object.size === null ? "-" : formatBytes(object.size)}
/>
<InspectorItem
label="Last Modified"
value={object.lastModified ?? "-"}
/>
</div>
<MetadataPanel metadata={object.metadata} />
</aside>
);
}
if (resource.type === 'email') {
return <EmailInspector key={resource.id} resource={resource} />
}
const tags = getTags(resource.metadata.tags);
const tagsUnavailable = getBooleanMetadata(resource.metadata.tagsUnavailable) ?? false;
const versioning = getStringMetadata(resource.metadata.versioning);
const versioningEnabled = getBooleanMetadata(
resource.metadata.versioningEnabled,
);
const isDatabase =
resource.service === "database" || resource.type === "db-instance";
const isAwsDatabase = isDatabase && resource.cloud === "aws";
const isK8sEngine = resource.service === "k8s" || resource.type === "cluster";
const isLambda =
resource.service === "serverless" || resource.type === "lambda";
const isLogGroup = resource.cloud === "aws" && (resource.service === "logs" || resource.type === "log-group");
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3>{resource.name}</h3>
<span className="badge neutral">{resource.type}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Service" value={serviceName ?? resource.service} />
<InspectorItem label="Region" value={resource.region ?? "-"} />
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
{resource.status && (
<InspectorItem label="Status" value={resource.status} />
)}
{resource.engine && (
<InspectorItem label="Engine" value={resource.engine} />
)}
{resource.version && (
<InspectorItem label="Version" value={resource.version} />
)}
{resource.instanceClass && (
<InspectorItem label="Class" value={resource.instanceClass} />
)}
{versioning && <InspectorItem label="Versioning" value={versioning} />}
{versioningEnabled !== null && (
<InspectorItem
label="Versioning Enabled"
value={versioningEnabled ? "Yes" : "No"}
/>
)}
<InspectorItem
label="Tags"
value={tagsUnavailable ? "Unavailable" : `${tags.length}`}
/>
{isLambda && (
<>
<InspectorItem
label="Runtime"
value={getStringMetadata(resource.metadata.runtime) ?? "-"}
/>
<InspectorItem
label="Handler"
value={getStringMetadata(resource.metadata.handler) ?? "-"}
/>
<InspectorItem
label="Package Type"
value={getStringMetadata(resource.metadata.packageType) ?? "-"}
/>
</>
)}
</div>
<section className="inspector-section">
<p className="metric-label">Tags</p>
{tagsUnavailable ? (
<p className="muted compact-text">
Tags unavailable: the provider denied or failed the tag lookup.
</p>
) : tags.length === 0 ? (
<p className="muted compact-text">
No tags returned for this resource.
</p>
) : (
<div className="metadata-tags">
{tags.map((tag) => (
<span className="metadata-tag" key={`${tag.key}:${tag.value}`}>
<strong>{tag.key}</strong>
<span>{tag.value}</span>
</span>
))}
</div>
)}
</section>
{isDatabase && (
<DatabaseConnectionsSection metadata={resource.metadata} />
)}
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
{isDatabase && !isAwsDatabase && (
<ProviderDatabaseSection cloud={resource.cloud} />
)}
{isK8sEngine && (
<K8sEngineDetails cloud={resource.cloud} clusterName={resource.name} />
)}
{isLambda && (
<section className="inspector-section">
<p className="metric-label">Lambda Details</p>
<div className="inspector-grid compact-grid">
<InspectorItem
label="ARN"
value={getStringMetadata(resource.metadata.arn) ?? "-"}
/>
<InspectorItem
label="Last Modified"
value={getStringMetadata(resource.metadata.lastModified) ?? "-"}
/>
<InspectorItem
label="Memory"
value={
getNumberMetadata(resource.metadata.memorySize) === null
? "-"
: `${getNumberMetadata(resource.metadata.memorySize)} MB`
}
/>
<InspectorItem
label="Timeout"
value={
getNumberMetadata(resource.metadata.timeout) === null
? "-"
: `${getNumberMetadata(resource.metadata.timeout)}s`
}
/>
</div>
</section>
)}
<pre className="metadata-block">
{JSON.stringify(resource.metadata, null, 2)}
</pre>
<MetadataPanel metadata={resource.metadata} />
{isLogGroup && cloud && (
<>
<LogsQueryPanel cloud={cloud} logGroupName={resource.id} runtimeReachable={runtimeReachable ?? false} />
<LogsExplorerPanel cloud={cloud} resource={resource} runtimeReachable={runtimeReachable ?? false} />
</>
)}
</aside>
);
}
function EmailInspector({resource}: {resource: CloudResource}) {
const [tab, setTab] = useState<'preview' | 'text' | 'raw'>('preview')
const source = getStringMetadata(resource.metadata.source) ?? '-'
const toAddresses = getStringList(resource.metadata.toAddresses)
const ccAddresses = getStringList(resource.metadata.ccAddresses)
const bccAddresses = getStringList(resource.metadata.bccAddresses)
const replyToAddresses = getStringList(resource.metadata.replyToAddresses)
const textBody = getStringMetadata(resource.metadata.textBody)
const htmlBody = getStringMetadata(resource.metadata.htmlBody)
const rawData = getStringMetadata(resource.metadata.rawData)
const hasPreview = Boolean(htmlBody || textBody)
const activeTab = tab === 'preview' && !hasPreview ? (rawData ? 'raw' : 'text') : tab
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3 title={resource.name}>{resource.name}</h3>
<span className="badge neutral">{getStringMetadata(resource.metadata.messageType) ?? 'email'}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="From" value={source} />
<InspectorItem label="To" value={toAddresses.join(', ') || '-'} />
{ccAddresses.length > 0 && <InspectorItem label="Cc" value={ccAddresses.join(', ')} />}
{bccAddresses.length > 0 && <InspectorItem label="Bcc" value={bccAddresses.join(', ')} />}
{replyToAddresses.length > 0 && <InspectorItem label="Reply-To" value={replyToAddresses.join(', ')} />}
<InspectorItem label="Captured At" value={resource.createdAt ?? '-'} />
</div>
<section className="inspector-section">
<div className="drawer-tabs" style={{marginBottom: 10}}>
<button className={`drawer-tab ${activeTab === 'preview' ? 'active' : ''}`} disabled={!hasPreview} onClick={() => setTab('preview')}>Preview</button>
<button className={`drawer-tab ${activeTab === 'text' ? 'active' : ''}`} disabled={!textBody} onClick={() => setTab('text')}>Text</button>
<button className={`drawer-tab ${activeTab === 'raw' ? 'active' : ''}`} disabled={!rawData} onClick={() => setTab('raw')}>Raw</button>
</div>
{activeTab === 'preview' && htmlBody ? (
<iframe
title="Email HTML preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={safeEmailDocument(htmlBody)}
style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}}
/>
) : activeTab === 'preview' ? (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No preview content captured.'}</pre>
) : activeTab === 'text' ? (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No text body captured.'}</pre>
) : (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawData ?? 'No raw MIME data captured.'}</pre>
)}
</section>
</aside>
)
}
function safeEmailDocument(html: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"></head><body>${html}</body></html>`
}
function ProviderDatabaseSection({ cloud }: { cloud: string }) {
const label =
cloud === "azure"
? "Azure database"
: cloud === "gcp"
? "Google Cloud SQL"
: "Database";
return (
<section className="inspector-section">
<p className="metric-label">Lifecycle</p>
<p className="muted compact-text">
{label} lifecycle and snapshot actions are exposed through the
normalized resource metadata only.
</p>
</section>
);
}
function DatabaseConnectionsSection({
metadata,
}: {
metadata: Record<string, unknown>;
}) {
const endpoint = getRecordMetadata(metadata.endpoint);
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
const securityGroups = getSecurityGroups(metadata.vpcSecurityGroups);
const address = getStringMetadata(endpoint?.address);
const port = getNumberMetadata(endpoint?.port);
const vpcId = getStringMetadata(subnetGroup?.vpcId);
const subnetGroupName = getStringMetadata(subnetGroup?.name);
const subnetGroupStatus = getStringMetadata(subnetGroup?.status);
return (
<section className="inspector-section">
<p className="metric-label">Connections</p>
<div className="inspector-grid compact-grid">
<InspectorItem label="Endpoint" value={address ?? "-"} />
<InspectorItem
label="Port"
value={port === null ? "-" : String(port)}
/>
<InspectorItem label="VPC" value={vpcId ?? "-"} />
<InspectorItem label="Subnet Group" value={subnetGroupName ?? "-"} />
<InspectorItem label="Subnet Status" value={subnetGroupStatus ?? "-"} />
<InspectorItem
label="Security Groups"
value={`${securityGroups.length}`}
/>
</div>
{securityGroups.length > 0 && (
<div className="metadata-tags">
{securityGroups.map((group) => (
<span className="metadata-tag" key={`${group.id}:${group.status}`}>
<strong>{group.id}</strong>
<span>{group.status}</span>
</span>
))}
</div>
)}
</section>
);
}
function DatabaseLifecycleSection({ status }: { status?: string | null }) {
return (
<section className="inspector-section">
<p className="metric-label">Lifecycle</p>
<div className="lifecycle-actions">
<button className="button compact" type="button" disabled>
Start
</button>
<button className="button compact" type="button" disabled>
Stop
</button>
</div>
<p className="muted compact-text">
Current status: {status ?? "unknown"}. Start and stop are not supported
by the local Floci RDS runtime.
</p>
</section>
);
}
function InspectorItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="metric-label">{label}</p>
<p className="metric-value mono">{value}</p>
</div>
);
}
function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
const rows = Object.entries(metadata).filter(
([, value]) => value !== undefined && value !== null && value !== "",
);
if (rows.length === 0) {
return (
<div className="metadata-block empty-metadata">
<span>No metadata returned</span>
</div>
);
}
return (
<div className="metadata-block metadata-table">
{rows.map(([key, value]) => (
<div key={key}>
<span>{humanizeKey(key)}</span>
<code>{formatMetadataValue(value)}</code>
</div>
))}
</div>
);
}
function formatMetadataValue(value: unknown): string {
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function humanizeKey(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function getStringMetadata(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function getStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function getBooleanMetadata(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function getNumberMetadata(value: unknown): number | null {
return typeof value === "number" ? value : null;
}
function getRecordMetadata(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getTags(value: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const tag = item as Record<string, unknown>;
return typeof tag.key === "string" && typeof tag.value === "string"
? [{ key: tag.key, value: tag.value }]
: [];
});
}
function getSecurityGroups(
value: unknown,
): Array<{ id: string; status: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const group = item as Record<string, unknown>;
return typeof group.id === "string"
? [
{
id: group.id,
status: typeof group.status === "string" ? group.status : "-",
},
]
: [];
});
}
@@ -0,0 +1,190 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {Pencil, Trash2} from 'lucide-react';
import type {CloudResource} from '@/types/resource';
import type {ServiceSchema} from '@/types/schema';
import {getPath} from '@/lib/resourcePath';
import {renderColumnValue} from '@/lib/columnFormat';
interface ResourceTableProps {
schema: ServiceSchema;
resources: CloudResource[];
selectedId?: string;
onSelect: (resource: CloudResource) => void;
onEdit?: (resource: CloudResource) => void;
onDelete: (resource: CloudResource) => void;
deletingId?: string;
dataPath?: (resource: CloudResource) => string | undefined;
isDeleteMulti?: boolean;
selectedItems?: CloudResource[];
onToggleSelect?: (resource: CloudResource) => void;
onToggleSelectAll?: () => void;
setAllSelected?: (value: boolean) => void;
isAllSelected?: boolean;
}
export function ResourceTable({
schema,
resources,
selectedId,
onSelect,
onEdit,
onDelete,
deletingId,
dataPath,
isDeleteMulti,
selectedItems,
onToggleSelect,
onToggleSelectAll,
setAllSelected,
isAllSelected = false,
}: ResourceTableProps) {
const [confirmId, setConfirmId] = useState<string | null>(null);
const canDelete = schema.actions.includes('delete');
const canEdit = schema.actions.includes('update') && Boolean(schema.updateFields?.length) && Boolean(onEdit);
const hasActions = canDelete || canEdit || Boolean(dataPath && resources.some((resource) => dataPath(resource)));
if (resources.length === 0) {
const emptyTitle = `No ${schema.displayName} found.`;
const emptyDescription = `The connected runtime did not return any ${schema.displayName} resources.`;
return (
<div className="empty compact">
<h3>{emptyTitle}</h3>
<p>{emptyDescription}</p>
</div>
);
}
return (
<table className="table resource-table">
<thead>
<tr>
{isDeleteMulti && (
<th style={{ width: '1%' }}>
<input
type="checkbox"
aria-label="Select all resources"
checked={isAllSelected && resources.length > 0}
onChange={() => {
if (onToggleSelectAll) {
onToggleSelectAll();
} else {
setAllSelected?.(!isAllSelected);
}
}}
/>
</th>
)}
{schema.columns.map((column) => (
<th key={column.name} style={column.width ? { width: column.width } : undefined}>
{column.label}
</th>
))}
{hasActions && <th aria-label="Actions" />}
</tr>
</thead>
<tbody>
{resources.map((resource) => {
const explorePath = dataPath?.(resource);
return (
<tr key={resource.id} className={selectedId === resource.id ? 'selected' : ''}>
{isDeleteMulti && (
<td style={{ width: '1%' }} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select ${resource.name || resource.id}`}
checked={selectedItems?.some((item) => item.id === resource.id) ?? false}
onChange={() => {
onToggleSelect?.(resource);
}}
/>
</td>
)}
{schema.columns.map((column) => (
<td key={column.name} onClick={() => onSelect(resource)}>
{renderColumnValue(getPath(resource, column.path ?? column.name), column)}
</td>
))}
{hasActions && (
<td className="table-actions">
<div className="table-actions-group">
{explorePath && (
<Link
className="button compact"
to={explorePath}
aria-label={`Explore data in ${resource.name}`}
onClick={(e) => e.stopPropagation()}
>
Explore data
</Link>
)}
{canEdit && (
<button
className="icon-btn"
type="button"
title={`Edit ${resource.name}`}
aria-label={`Edit ${resource.name}`}
onClick={(e) => {
e.stopPropagation();
onEdit?.(resource);
}}
>
<Pencil size={13} />
</button>
)}
{canDelete && (
confirmId === resource.id ? (
<div
className="table-confirm-actions"
onClick={(e) => e.stopPropagation()}
>
<button
className="button danger compact"
type="button"
disabled={deletingId === resource.id}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
Confirm
</button>
<button
className="button success compact"
type="button"
disabled={deletingId === resource.id}
onClick={(e) => {
e.stopPropagation();
setConfirmId(null);
}}
>
Cancel
</button>
</div>
) : (
<button
className="icon-btn danger"
type="button"
title={`Delete ${resource.name}`}
aria-label={`Delete ${resource.name}`}
disabled={deletingId === resource.id}
onClick={(e) => {
e.stopPropagation();
setConfirmId(resource.id);
}}
>
<Trash2 size={13} />
</button>
)
)}
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,234 @@
import {useEffect, useState} from "react";
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2, Zap} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {invokeCloudResource} from "@/api/cloudProxyClient";
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
import type {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
interface ServerlessInvokePanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
export function ServerlessInvokePanel({
cloud,
resource,
runtimeReachable,
}: ServerlessInvokePanelProps) {
const [payload, setPayload] = useState("{\n \n}");
const [invokeResult, setInvokeResult] = useState<ServerlessInvokeResult | null>(null);
const [showLog, setShowLog] = useState(false);
const [copied, setCopied] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
setPayload("{\n \n}");
setInvokeResult(null);
setShowLog(false);
setCopied(false);
setValidationError(null);
}, [resource?.id]);
const isSupportedResource =
resource?.service === "serverless" &&
(resource.type === "lambda" ||
resource.type === "azure-function" ||
resource.type === "gcp-function");
const canInvoke = Boolean(resource && isSupportedResource && runtimeReachable);
const canSubmit = canInvoke && !validationError;
const providerLabel =
cloud === "aws"
? "Lambda function"
: cloud === "azure"
? "Azure Function"
: "Google Cloud Function";
const invokeMutation = useMutation({
mutationFn: () =>
invokeCloudResource(cloud, "serverless", resource!.id, payload),
onSuccess: (result) => {
setInvokeResult(result);
setShowLog(false);
setCopied(false);
},
});
const isInvokeError = Boolean(
invokeResult?.functionError || (invokeResult && invokeResult.statusCode >= 400),
);
const formatPayload = () => {
try {
const formatted = JSON.stringify(JSON.parse(payload), null, 2);
setPayload(formatted);
setValidationError(null);
} catch {
setValidationError("Payload must be valid JSON before formatting.");
}
};
const clearPayload = () => {
setPayload("{}");
setValidationError(null);
};
const copyResponse = async () => {
if (!invokeResult) return;
await navigator.clipboard.writeText(invokeResult.payload || "");
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
if (!resource || resource.service !== "serverless") {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a serverless function</h3>
<p>
Select a Lambda function, Azure Function, or Google Cloud Function to
invoke it from Cloud Explorer.
</p>
</div>
</section>
);
}
return (
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Serverless Actions</p>
<h3>
<Zap size={15} />
Invoke {providerLabel}
</h3>
<p className="muted compact-text">
Send a JSON event payload to the selected serverless function.
</p>
</div>
<span className={`runtime-state ${canInvoke ? "ready" : "pending"}`}>
{canInvoke ? "Ready" : "Runtime unavailable"}
</span>
</div>
<div className="resource-create-inline">
<div className="inspector-section-header">
<label className="metric-label" htmlFor="serverless-invoke-payload">
Event payload JSON
</label>
<button className="button" type="button" onClick={formatPayload}>
<Wand2 size={13} />
Format JSON
</button>
<button className="button" type="button" onClick={clearPayload}>
Clear
</button>
</div>
<textarea
id="serverless-invoke-payload"
className="json-editor serverless-payload-editor"
value={payload}
onChange={(event) => {
const value = event.target.value;
setPayload(value);
try {
JSON.parse(value);
setValidationError(null);
} catch {
setValidationError("Payload must be valid JSON.");
}
}}
spellCheck={false}
placeholder="{}"
style={{minHeight: 140}}
/>
{validationError && (
<p className="error-text compact-text">
{validationError}
</p>
)}
<button
className="button primary"
type="button"
disabled={!canSubmit || invokeMutation.isPending}
onClick={() => invokeMutation.mutate()}
>
{invokeMutation.isPending ? <Loader2 size={13} /> : <Play size={13} />}
{invokeMutation.isPending ? "Invoking" : "Invoke"}
</button>
{invokeMutation.isError && (
<p className="error-text compact-text">
{invokeMutation.error instanceof Error
? invokeMutation.error.message
: "Invocation failed"}
</p>
)}
{invokeResult && (
<div className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">Invocation Result</p>
<span className={`runtime-state ${isInvokeError ? "unavailable" : "ready"}`}>
HTTP {invokeResult.statusCode}
</span>
</div>
{invokeResult.executionDuration !== undefined && (
<p className="muted compact-text">
Completed in {invokeResult.executionDuration}ms
</p>
)}
{invokeResult.functionError && (
<p className="error-text compact-text">
{invokeResult.functionError}
</p>
)}
<button className="button" type="button" onClick={copyResponse}>
<Copy size={13} />
{copied ? "Copied" : "Copy response"}
</button>
<pre className={`invoke-result ${isInvokeError ? "error" : "success"}`}>
{tryFormatJson(invokeResult.payload) || "(empty)"}
</pre>
{invokeResult.logResult && (
<div>
<button
className="button"
type="button"
onClick={() => setShowLog((open) => !open)}
>
Log tail
{showLog ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{showLog && <div className="log-tail">{invokeResult.logResult}</div>}
</div>
)}
</div>
)}
</div>
</section>
);
}
function tryFormatJson(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
@@ -0,0 +1,641 @@
import {useEffect, useRef, useState} from 'react'
import {ChevronLeft, ChevronRight, Copy, Download, Eye, File, Folder, Loader2, RefreshCw, Search, Trash2, Upload, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
copyStorageObject,
createCloudResource,
deleteStorageObject,
listCloudResources,
listStorageObjects,
storageObjectDownloadUrl,
uploadStorageObject,
} from '@/api/cloudProxyClient'
import {capabilityEnabled, capabilityFor, normalizeCapabilities, withRuntimeState} from '@/lib/capabilities'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource, StorageObject} from '@/types/resource'
import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
interface StorageObjectBrowserProps {
cloud: CloudProvider
resource?: CloudResource
capabilities?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName>
runtimeReachable?: boolean
selectedObjectKey?: string
onSelectObject: (object?: StorageObject) => void
}
export function StorageObjectBrowser({cloud, resource, capabilities = [], runtimeReachable = false, selectedObjectKey, onSelectObject}: StorageObjectBrowserProps) {
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement | null>(null)
const [prefix, setPrefix] = useState('')
const [uploadPrefix, setUploadPrefix] = useState('')
const [folderName, setFolderName] = useState('')
const [createFolderOpen, setCreateFolderOpen] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
const [search, setSearch] = useState('')
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [bulkDeleting, setBulkDeleting] = useState(false)
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
const downloadCapability = capabilityFor(resolvedCapabilities, 'download')
const deleteCapability = capabilityFor(resolvedCapabilities, 'delete')
const createFolderCapability = capabilityFor(resolvedCapabilities, 'createFolder')
const copyCapability = capabilityFor(resolvedCapabilities, 'copy')
const canUpload = capabilityEnabled(uploadCapability)
const canDownload = capabilityEnabled(downloadCapability)
const canDelete = capabilityEnabled(deleteCapability)
const canCreateFolder = capabilityEnabled(createFolderCapability)
const canCopy = capabilityEnabled(copyCapability)
// Runtime reachability can flip while a preview is already open (it's polled
// independently of this component). Re-checking canDownload only at the
// moment the Eye button is clicked isn't enough — close an already-open
// preview the instant download capability is revoked, so its modal can't
// keep serving object content (img/video/audio src, the Download link,
// an in-flight text fetch) after the gate that allowed it closes.
useEffect(() => {
if (!canDownload) setPreviewObject(null)
}, [canDownload])
useEffect(() => {
setPrefix('')
setUploadPrefix('')
setFolderName('')
setCreateFolderOpen(false)
setDeleteConfirm(null)
setCopyObject(null)
setSearch('')
setSelectedKeys(new Set())
setPreviewObject(null)
onSelectObject(undefined)
}, [resource?.id, onSelectObject])
const query = useQuery({
queryKey: ['storage-objects', cloud, resource?.id, prefix],
queryFn: ({signal}) => listStorageObjects(cloud, resource?.id ?? '', prefix, signal),
enabled: !!resource,
})
const uploadMut = useMutation({
mutationFn: async (file: File) => {
if (!resource) return
const key = `${uploadPrefix || prefix}${file.name}`
await uploadStorageObject(cloud, resource.id, key, file)
},
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
})
const createFolderMut = useMutation({
mutationFn: async (name: string) => {
if (!resource) return
const folderKey = `${prefix}${normalizeFolderName(name)}`
await uploadStorageObject(cloud, resource.id, folderKey, new Blob([], {type: 'application/x-directory'}))
},
onSuccess: () => {
setFolderName('')
setCreateFolderOpen(false)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const deleteMut = useMutation({
mutationFn: async (object: StorageObject) => {
if (!resource) return
await deleteStorageObject(cloud, resource.id, object.key)
},
onSuccess: (_, deleted) => {
if (selectedObjectKey === deleted.key) onSelectObject(undefined)
qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const moveCopyMut = useMutation({
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
}) => {
if (!resource) return
await copyStorageObject(cloud, resource.id, srcKey, destKey, destResourceId)
if (mode === 'move') await deleteStorageObject(cloud, resource.id, srcKey)
},
onSuccess: (_, vars) => {
setCopyObject(null)
if (vars.mode === 'move') onSelectObject(undefined)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
if (vars.destResourceId) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, vars.destResourceId]})
},
})
// ── Navigation helpers ──
function navigate(nextPrefix: string) {
setPrefix(nextPrefix)
setSearch('')
setSelectedKeys(new Set())
setDeleteConfirm(null)
setPreviewObject(null)
onSelectObject(undefined)
}
// ── Bulk delete ──
async function handleBulkDelete() {
if (!resource || selectedKeys.size === 0) return
setBulkDeleting(true)
try {
await Promise.all([...selectedKeys].map((key) => deleteStorageObject(cloud, resource.id, key)))
if (selectedObjectKey && selectedKeys.has(selectedObjectKey)) onSelectObject(undefined)
setSelectedKeys(new Set())
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource.id]})
} finally {
setBulkDeleting(false)
}
}
if (!resource) {
return (
<section className="object-browser empty compact">
<h3>Select a storage resource</h3>
<p>Choose a bucket or container to browse objects and blobs.</p>
</section>
)
}
const objects = query.data?.objects ?? []
const objectLabel = cloud === 'azure' ? 'blobs' : 'objects'
// ── Filtered data ──
const q = search.toLowerCase()
const filteredObjects = q
? objects.filter((o) => o.name.toLowerCase().includes(q) || o.key.toLowerCase().includes(q))
: objects
const filteredFolders = filteredObjects.filter((o) => o.type === 'folder')
const filteredFiles = filteredObjects.filter((o) => o.type === 'object')
const totalFolders = objects.filter((o) => o.type === 'folder').length
const totalFiles = objects.filter((o) => o.type === 'object').length
// ── Bulk select state ──
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? moveCopyMut.error
return (
<section className="object-browser">
{previewObject && resource && objectPreviewKind(previewObject.name) && (
<ImagePreviewModal
kind={objectPreviewKind(previewObject.name)!}
name={previewObject.name}
src={storageObjectDownloadUrl(cloud, resource.id, previewObject.key)}
onClose={() => setPreviewObject(null)}
/>
)}
{copyObject && resource && (
<MoveOrCopyModal
cloud={cloud}
resource={resource}
srcObject={copyObject}
canCopy={canCopy}
isPending={moveCopyMut.isPending}
error={moveCopyMut.error instanceof Error ? moveCopyMut.error.message : null}
onClose={() => { setCopyObject(null); moveCopyMut.reset() }}
onConfirm={(destKey, destResourceId, mode) => moveCopyMut.mutate({srcKey: copyObject.key, destKey, destResourceId, mode})}
/>
)}
{/* ── Header ── */}
<div className="object-browser-header">
<div>
<p className="eyebrow">Objects</p>
<h3>{resource.name}</h3>
<div className="object-browser-subtitle">
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
</div>
</div>
<div className="object-browser-actions">
{prefix && (
<button className="button" type="button" onClick={() => navigate(parentPrefix(prefix))}>
<ChevronLeft size={14}/>
Back
</button>
)}
{createFolderCapability && (
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
<Folder size={14}/>
New folder
</button>
)}
{uploadCapability && (
<>
<input className="input object-prefix-input" value={uploadPrefix} onChange={(event) => setUploadPrefix(normalizePrefix(event.target.value))} placeholder={prefix ? `Upload to ${prefix}` : 'Upload prefix'}/>
<input ref={fileRef} type="file" hidden onChange={(event) => {
const file = event.target.files?.[0]
if (file) uploadMut.mutate(file)
event.currentTarget.value = ''
}}/>
<button className="button" type="button" disabled={!canUpload} title={uploadCapability.reason} onClick={() => fileRef.current?.click()}>
<Upload size={14}/>
{uploadMut.isPending ? 'Uploading' : 'Upload'}
</button>
</>
)}
<button className="button" type="button" disabled={query.isFetching} onClick={() => query.refetch()}>
<RefreshCw size={14}/>
{query.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
</div>
{/* ── Create folder ── */}
{createFolderOpen && (
<form className="object-create-folder" onSubmit={(event) => {
event.preventDefault()
if (folderName.trim()) createFolderMut.mutate(folderName)
}}>
<label>
<span>Folder name</span>
<input className="input" value={folderName} onChange={(event) => setFolderName(event.target.value)} placeholder={prefix ? `${prefix}new-folder/` : 'new-folder/'}/>
</label>
<button className="button primary" type="submit" disabled={createFolderMut.isPending || !folderName.trim()}>
<Folder size={14}/>
{createFolderMut.isPending ? 'Creating' : 'Create folder'}
</button>
</form>
)}
{/* ── Error ── */}
{error && (
<div className="inline-error">
{error instanceof Error ? error.message : 'Storage operation failed'}
</div>
)}
{/* ── Search bar ── */}
{!query.isLoading && objects.length > 0 && (
<div className="object-search-bar">
<Search size={13}/>
<input
className="input"
value={search}
onChange={(e) => { setSearch(e.target.value); setSelectedKeys(new Set()) }}
placeholder={`Search ${objectLabel} and folders…`}
/>
{search && (
<button className="icon-btn" type="button" onClick={() => setSearch('')} aria-label="Clear search" title="Clear search">
<X size={13} aria-hidden="true"/>
</button>
)}
{search && (
<span style={{fontSize: 12, color: 'var(--text-3)', whiteSpace: 'nowrap'}}>
{filteredObjects.length} / {objects.length}
</span>
)}
</div>
)}
{/* ── Table ── */}
{query.isLoading ? (
<div className="empty compact">
<h3>Loading objects</h3>
<p>Reading objects from the selected storage resource.</p>
</div>
) : objects.length === 0 ? (
<div className="empty compact">
<h3>No {objectLabel}</h3>
<p>{prefix ? `The ${prefix} prefix is empty.` : `This ${resource.type} has no ${objectLabel} yet.`}</p>
</div>
) : filteredObjects.length === 0 ? (
<div className="empty compact">
<h3>No results</h3>
<p>Nothing matches &ldquo;{search}&rdquo;.</p>
</div>
) : (
<table className="table object-table">
<thead>
<tr>
<th style={{width: 32, textAlign: 'center'}}>
<input
type="checkbox"
ref={(el) => { if (el) el.indeterminate = someSelected }}
checked={allSelected}
title={allSelected ? 'Deselect all' : 'Select all files'}
onChange={() => {
if (allSelected) setSelectedKeys(new Set())
else setSelectedKeys(new Set(filteredFiles.map((f) => f.key)))
}}
/>
</th>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Last Modified</th>
<th aria-label="Actions"/>
</tr>
</thead>
<tbody>
{filteredFolders.map((object) => (
<tr key={object.key}>
<td/>
<td onClick={() => navigate(object.key)} style={{cursor: 'pointer'}}>
<span className="object-name">
<Folder size={14}/>
{object.name}
<ChevronRight size={12}/>
</span>
</td>
<td>{object.type}</td>
<td>—</td>
<td>{object.lastModified ?? '—'}</td>
<td/>
</tr>
))}
{filteredFiles.map((object) => {
const isSelected = selectedKeys.has(object.key)
const iconKind = objectIconKind(object.name)
const previewKind = objectPreviewKind(object.name)
return (
<tr
key={object.key}
className={[
selectedObjectKey === object.key ? 'selected' : '',
isSelected ? 'bulk-selected' : '',
].filter(Boolean).join(' ')}
>
<td style={{textAlign: 'center'}}>
<input
type="checkbox"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = new Set(selectedKeys)
if (e.target.checked) next.add(object.key)
else next.delete(object.key)
setSelectedKeys(next)
}}
/>
</td>
<td onClick={() => onSelectObject(object)} style={{cursor: 'pointer'}}>
<span className="object-name">
{iconKind ? (
<ObjectThumbnail kind={iconKind} src={storageObjectDownloadUrl(cloud, resource.id, object.key)} name={object.name} canLoad={canDownload}/>
) : (
<File size={14}/>
)}
{object.name}
</span>
</td>
<td>{object.type}</td>
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
{previewKind && (
<button
className="icon-btn"
type="button"
disabled={!canDownload}
title={canDownload ? `Preview ${object.name}` : (downloadCapability?.reason ?? `Preview ${object.name}`)}
onClick={(e) => { e.stopPropagation(); if (canDownload) setPreviewObject(object) }}
>
<Eye size={13}/>
</button>
)}
{downloadCapability && (
<a className={`icon-btn ${canDownload ? '' : 'disabled'}`} href={canDownload ? storageObjectDownloadUrl(cloud, resource.id, object.key) : undefined} title={downloadCapability.reason ?? `Download ${object.name}`}>
<Download size={13}/>
</a>
)}
{copyCapability && (
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Move / Copy ${object.name}`} onClick={() => setCopyObject(object)}>
<Copy size={13}/>
</button>
)}
{canDelete && deleteConfirm === object.key ? (
<button className="button danger compact" disabled={deleteMut.isPending} onClick={() => {
deleteMut.mutate(object)
setDeleteConfirm(null)
}}>
Confirm
</button>
) : (
deleteCapability && (
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name}`} onClick={() => setDeleteConfirm(object.key)}>
<Trash2 size={13}/>
</button>
)
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* ── Bulk action bar ── */}
{selectedKeys.size > 0 && (
<div className="bulk-bar">
<span className="bulk-count">
{selectedKeys.size} {objectLabel.replace(/s$/, '')}{selectedKeys.size !== 1 ? 's' : ''} selected
</span>
<span className="bulk-spacer"/>
<button
className="button"
onClick={() => setSelectedKeys(new Set())}
disabled={bulkDeleting}
>
Deselect all
</button>
{deleteCapability && (
<button
className="button danger"
disabled={!canDelete || bulkDeleting}
title={deleteCapability.reason}
onClick={() => void handleBulkDelete()}
>
{bulkDeleting ? <Loader2 size={13}/> : <Trash2 size={13}/>}
Delete {selectedKeys.size}
</button>
)}
</div>
)}
</section>
)
}
// ─── Move / Copy modal ────────────────────────────────────────────────────────
const NEW_RESOURCE_SENTINEL = '__new__'
function MoveOrCopyModal({
cloud,
resource,
srcObject,
canCopy,
isPending,
error,
onClose,
onConfirm,
}: {
cloud: CloudProvider
resource: CloudResource
srcObject: StorageObject
canCopy: boolean
isPending: boolean
error: string | null
onClose: () => void
onConfirm: (destKey: string, destResourceId: string | undefined, mode: 'move' | 'copy') => void
}) {
const qc = useQueryClient()
const resourceLabel = cloud === 'azure' ? 'container' : 'bucket'
const [mode, setMode] = useState<'move' | 'copy'>('move')
const [destKey, setDestKey] = useState(() => {
const parts = srcObject.key.split('/')
const filename = parts.pop() ?? srcObject.key
const dir = parts.join('/')
return dir ? `${dir}/copy-of-${filename}` : `copy-of-${filename}`
})
const [destResourceId, setDestResourceId] = useState(resource.id)
const [newResourceName, setNewResourceName] = useState('')
const resourcesQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'storage'],
queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal),
})
const createResourceMut = useMutation({
mutationFn: () => {
const nameField = cloud === 'azure' ? 'containerName' : 'bucketName'
return createCloudResource(cloud, 'storage', {[nameField]: newResourceName.trim()})
},
onSuccess: (created) => {
setDestResourceId(created.id)
setNewResourceName('')
void qc.invalidateQueries({queryKey: ['cloud-resources', cloud, 'storage']})
},
})
const isCreatingNew = destResourceId === NEW_RESOURCE_SENTINEL
const resources = resourcesQuery.data ?? []
const resolvedDestId = isCreatingNew ? undefined : (destResourceId !== resource.id ? destResourceId : undefined)
return (
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="copy-modal">
<div className="drawer-tabs" style={{marginBottom: 14}}>
<button className={`drawer-tab ${mode === 'move' ? 'active' : ''}`} onClick={() => setMode('move')}>Move</button>
<button className={`drawer-tab ${mode === 'copy' ? 'active' : ''}`} onClick={() => setMode('copy')}>Copy</button>
</div>
<div style={{fontSize: 12, color: '#8d9cad', marginBottom: 12}}>
Source: <span className="mono" style={{color: 'var(--text-2)'}}>{resource.name}/{srcObject.key}</span>
</div>
<div className="form-row">
<label>Destination {resourceLabel}</label>
{resourcesQuery.isLoading ? (
<div style={{display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#8d9cad'}}>
<Loader2 size={13}/> Loading {resourceLabel}s…
</div>
) : (
<select className="input" value={destResourceId} onChange={(e) => setDestResourceId(e.target.value)}>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.name}{r.id === resource.id ? ' (current)' : ''}
</option>
))}
<option value={NEW_RESOURCE_SENTINEL}>+ New {resourceLabel}…</option>
</select>
)}
</div>
{isCreatingNew && (
<div style={{display: 'flex', gap: 6, alignItems: 'center', marginTop: -6, marginBottom: 4}}>
<input
className="input"
autoFocus
value={newResourceName}
onChange={(e) => setNewResourceName(e.target.value.toLowerCase().replace(cloud === 'azure' ? /[^a-z0-9-]/g : /[^a-z0-9.-]/g, ''))}
placeholder={`new-${resourceLabel}-name`}
onKeyDown={(e) => { if (e.key === 'Enter' && newResourceName.trim().length >= 3) createResourceMut.mutate() }}
/>
<button
className="button primary"
disabled={newResourceName.trim().length < 3 || createResourceMut.isPending}
onClick={() => createResourceMut.mutate()}
>
{createResourceMut.isPending ? <Loader2 size={13}/> : 'Create'}
</button>
</div>
)}
{createResourceMut.isError && (
<p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 6px'}}>
{createResourceMut.error instanceof Error ? createResourceMut.error.message : 'Create failed'}
</p>
)}
<div className="form-row">
<label>Destination key</label>
<input className="input" value={destKey} onChange={(e) => setDestKey(e.target.value)}/>
</div>
{error && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 4px'}}>{error}</p>}
<div className="copy-modal-footer">
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
<button
className="button primary"
disabled={!destKey.trim() || isCreatingNew || !canCopy || isPending}
onClick={() => onConfirm(destKey, resolvedDestId, mode)}
>
{isPending ? <Loader2 size={13}/> : <Copy size={13}/>}
{mode === 'move' ? 'Move' : 'Copy'}
</button>
</div>
</div>
</div>
)
}
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
function ObjectBreadcrumb({prefix, onNavigate}: {prefix: string; onNavigate: (prefix: string) => void}) {
const segments = prefix ? prefix.replace(/\/$/, '').split('/') : []
return (
<div className="object-breadcrumb">
<button type="button" onClick={() => onNavigate('')}>Root</button>
{segments.map((segment, index) => {
const path = `${segments.slice(0, index + 1).join('/')}/`
return (
<span key={path}>
<ChevronRight size={11}/>
<button type="button" onClick={() => onNavigate(path)}>{segment}</button>
</span>
)
})}
</div>
)
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function normalizePrefix(value: string): string {
const trimmed = value.trim().replace(/^\/+/, '')
return trimmed && !trimmed.endsWith('/') ? `${trimmed}/` : trimmed
}
function normalizeFolderName(value: string): string {
const normalized = value.trim().replace(/^\/+/, '')
return normalized.endsWith('/') ? normalized : `${normalized}/`
}
function parentPrefix(prefix: string): string {
const segments = prefix.replace(/\/$/, '').split('/').filter(Boolean)
segments.pop()
return segments.length ? `${segments.join('/')}/` : ''
}
@@ -0,0 +1,64 @@
import {
Boxes,
Circle,
Clock,
Container,
Database,
HardDrive,
KeyRound,
Layers,
Lock,
MessageSquare,
Network,
Radio,
ScrollText,
Scale,
Server,
ShieldCheck,
SlidersHorizontal,
Table2,
Workflow,
Waypoints,
Zap,
type LucideIcon,
} from 'lucide-react'
/**
* Maps the server's `iconKey` hint to a component.
*
* Icon keys are additive and never load-bearing: the server can ship a service
* whose key this build has never heard of, and the nav must still render. Passing
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
* would blank the whole app, so the fallback is not optional.
*/
const SERVICE_ICONS: Record<string, LucideIcon> = {
storage: HardDrive,
database: Table2,
nosql: Database,
k8s: Boxes,
compute: Server,
containers: Container,
networking: Network,
apigateway: Waypoints,
serverless: Zap,
secrets: KeyRound,
iac: Layers,
messaging: MessageSquare,
events: Radio,
email: MessageSquare,
queue: MessageSquare,
logs: ScrollText,
iam: ShieldCheck,
kms: Lock,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
scheduler: Clock,
workflows: Workflow,
}
const FALLBACK_ICON: LucideIcon = Circle
export function serviceIcon(iconKey?: string): LucideIcon {
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
}