first commit
This commit is contained in:
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 “{search}”.</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
|
||||
}
|
||||
Reference in New Issue
Block a user