first commit
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import {Cloud} from 'lucide-react'
|
||||
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
||||
import {CloudSelector} from '@/components/CloudSelector'
|
||||
import {
|
||||
ProviderBanner,
|
||||
RuntimeFlow,
|
||||
ServiceGrid,
|
||||
SummarySection,
|
||||
} from '@/features/cloud-console/CloudConsoleSections'
|
||||
import {useCloudConsoleHomeData} from '@/features/cloud-console/useCloudConsoleHomeData'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
export function CloudConsoleHomePage() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const search = (searchParams.get('search') ?? '').trim().toLowerCase()
|
||||
const routeCloud = normalizeCloud(params.cloud)
|
||||
const cloud = routeCloud ?? 'aws'
|
||||
const data = useCloudConsoleHomeData(cloud)
|
||||
|
||||
if (!routeCloud) return <Navigate to="/console/aws" replace/>
|
||||
|
||||
const filteredServices = search
|
||||
? data.serviceCards.filter((s) =>
|
||||
s.label.toLowerCase().includes(search) ||
|
||||
s.id.toLowerCase().includes(search) ||
|
||||
s.meta?.toLowerCase().includes(search)
|
||||
)
|
||||
: data.serviceCards
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Console Home</h1>
|
||||
<p className="muted">Cloud-aware local runtime overview</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-header-selectors">
|
||||
<label>
|
||||
<span>Cloud</span>
|
||||
<CloudSelector
|
||||
clouds={data.cloudsQuery.data ?? []}
|
||||
selected={cloud}
|
||||
onSelect={(nextCloud) => navigate(`/console/${nextCloud}`)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content cloud-console-home">
|
||||
<ProviderBanner
|
||||
cloud={cloud}
|
||||
runtimeClass={data.runtimeClass}
|
||||
runtimeReachable={data.status?.runtime === 'reachable'}
|
||||
onOpenStorage={() => navigate(`/cloud-explorer/${cloud}/storage`)}
|
||||
/>
|
||||
|
||||
<SummarySection
|
||||
cloud={cloud}
|
||||
runtimeLabel={data.runtimeLabel}
|
||||
runtimeState={data.runtimeState}
|
||||
runtimeClass={data.runtimeClass}
|
||||
runtimeDetail={data.runtimeDetail}
|
||||
activeServices={data.activeServices}
|
||||
activeServicesDetail={data.activeServicesDetail}
|
||||
resourceCount={data.resourceCount}
|
||||
resourceDetail={data.resourceDetail}
|
||||
/>
|
||||
|
||||
<RuntimeFlow cloud={cloud} status={data.status}/>
|
||||
|
||||
<ServiceGrid
|
||||
services={filteredServices}
|
||||
searchQuery={search}
|
||||
runtimeReachable={data.status?.runtime === 'reachable'}
|
||||
onNavigate={(route) => navigate(route)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCloud(value?: string): CloudProvider | null {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {Cloud, X} from 'lucide-react'
|
||||
import {Navigate, useNavigate, useParams} from 'react-router-dom'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {getServiceSchema} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery, useCloudStatusQuery, useCloudsQuery} from '@/api/queries/cloudQueries'
|
||||
import {CloudSelector} from '@/components/CloudSelector'
|
||||
import {DynamicResourceView} from '@/components/DynamicResourceView'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {normalizeCapabilities, withRuntimeState, withServiceAvailability} from '@/lib/capabilities'
|
||||
import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
export function CloudExplorerPage() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const routeCloud = normalizeCloud(params.cloud)
|
||||
const cloud = routeCloud ?? 'aws'
|
||||
const service = params.service ?? ''
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
|
||||
const cloudsQuery = useCloudsQuery()
|
||||
const servicesQuery = useCloudServicesQuery(cloud)
|
||||
const statusQuery = useCloudStatusQuery(cloud)
|
||||
const schemaQuery = useQuery({
|
||||
queryKey: ['cloud-schema', cloud, service],
|
||||
queryFn: ({signal}) => getServiceSchema(cloud, service, signal),
|
||||
enabled: Boolean(service),
|
||||
})
|
||||
|
||||
const selectedService = useMemo(
|
||||
() => servicesQuery.data?.find((item) => item.service === service),
|
||||
[service, servicesQuery.data],
|
||||
)
|
||||
|
||||
// Until the catalog resolves, availability is unknown — not "coming soon".
|
||||
// Treating it as unknown keeps a registered service from flashing the
|
||||
// adapter-coming-soon notice on every load.
|
||||
const catalogPending = servicesQuery.isPending
|
||||
|
||||
if (!routeCloud) {
|
||||
return <Navigate to="/cloud-explorer/aws/storage" replace/>
|
||||
}
|
||||
|
||||
// Never redirect while the catalog is still loading: doing so silently sent
|
||||
// any unknown or slow-loading service to storage.
|
||||
if (servicesQuery.isSuccess && !selectedService) {
|
||||
return <UnknownServiceNotice cloud={cloud} service={service} services={servicesQuery.data}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-header-selectors">
|
||||
<label>
|
||||
<span>Service</span>
|
||||
<div className="service-selector-readonly">{selectedService?.displayName ?? service}</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Cloud</span>
|
||||
<CloudSelector
|
||||
clouds={cloudsQuery.data ?? []}
|
||||
selected={cloud}
|
||||
onSelect={(nextCloud) => navigate(`/cloud-explorer/${nextCloud}/storage`)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content cloud-explorer">
|
||||
<DynamicResourceView
|
||||
cloud={cloud}
|
||||
service={service}
|
||||
serviceAvailability={selectedService?.availability}
|
||||
serviceReason={selectedService?.reason}
|
||||
cloudStatus={statusQuery.data}
|
||||
statusLoading={statusQuery.isLoading || catalogPending}
|
||||
onOpenInfo={() => setInfoOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{infoOpen && (
|
||||
<ServiceInfoDialog
|
||||
cloud={cloud}
|
||||
service={service}
|
||||
descriptor={selectedService}
|
||||
status={statusQuery.data}
|
||||
statusLoading={statusQuery.isLoading}
|
||||
schema={schemaQuery.data}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCloud(value?: string): CloudProvider | null {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||
}
|
||||
|
||||
function ServiceInfoDialog({
|
||||
cloud,
|
||||
service,
|
||||
descriptor,
|
||||
status,
|
||||
statusLoading,
|
||||
schema,
|
||||
onClose,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
service: CloudServiceType
|
||||
descriptor?: CloudServiceDescriptor
|
||||
status?: CloudStatus
|
||||
statusLoading: boolean
|
||||
schema?: ServiceSchema
|
||||
onClose: () => void
|
||||
}) {
|
||||
const capabilityList = schema?.capabilities
|
||||
? withServiceAvailability(
|
||||
withRuntimeState(
|
||||
normalizeCapabilities([
|
||||
...(schema.capabilities.resourceActions ?? []),
|
||||
...(schema.capabilities.objectActions ?? []),
|
||||
...(schema.capabilities.kubernetesActions ?? []),
|
||||
...(schema.capabilities.databaseActions ?? []),
|
||||
]),
|
||||
status?.runtime === 'reachable',
|
||||
),
|
||||
descriptor?.availability ?? 'coming_soon',
|
||||
)
|
||||
: []
|
||||
const actionFallback = schema?.actions.join(', ') ?? 'Loading actions'
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="service-info-dialog" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="service-info-header">
|
||||
<div>
|
||||
<p className="eyebrow">Service Information</p>
|
||||
<h3>{schema?.displayName ?? descriptor?.displayName ?? service}</h3>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label="Close" title="Close">
|
||||
<X size={14} aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="service-info-grid">
|
||||
<InfoCard label="Proxy API" value={`/api/clouds/${cloud}/services/${service}`} detail="Single entry point used by the UI"/>
|
||||
<InfoCard label="Service" value={descriptor?.displayName ?? service} detail={serviceAvailability(descriptor)}/>
|
||||
<InfoCard label="Runtime" value={runtimeValue(status)} detail={runtimeDetail(status, statusLoading)} state={runtimeState(status, statusLoading)}/>
|
||||
<InfoCard label="Adapter" value={adapterValue(cloud, status)} detail={adapterDetail(status, statusLoading)} state={adapterState(cloud, status)}/>
|
||||
<InfoCard label="Connection" value={connectionValue(status, statusLoading)} detail={status?.endpoint ?? 'No runtime endpoint'} state={runtimeState(status, statusLoading)}/>
|
||||
<InfoCard label="Normalized Contract" value={schema?.columns.length ? `${schema.columns.length} columns · ${schema.filters.length} filters` : 'Loading schema'} detail="Shared table and resource inspector"/>
|
||||
</div>
|
||||
<div className="service-info-section">
|
||||
<p className="eyebrow">Supported Actions</p>
|
||||
{capabilityList.length ? (
|
||||
<div className="capability-strip">
|
||||
{capabilityList.map((capability) => (
|
||||
<span
|
||||
key={capability.name}
|
||||
className={`capability-pill ${capability.status}`}
|
||||
title={capability.reason}
|
||||
>
|
||||
{capability.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="service-info-copy">{actionFallback}</p>
|
||||
)}
|
||||
</div>
|
||||
{descriptor?.reason && (
|
||||
<div className="service-info-section">
|
||||
<p className="eyebrow">Availability</p>
|
||||
<p className="service-info-copy">{descriptor.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
state,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
state?: 'ready' | 'pending' | 'unavailable'
|
||||
}) {
|
||||
return (
|
||||
<div className={`service-info-card ${state ?? ''}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function serviceAvailability(service?: CloudServiceDescriptor): string {
|
||||
if (!service) return 'Loading service schema'
|
||||
return service.availability === 'available' ? 'Schema available' : 'Coming soon'
|
||||
}
|
||||
|
||||
function runtimeValue(status?: CloudStatus): string {
|
||||
return status?.endpoint?.replace(/^https?:\/\//, '') ?? 'Unknown'
|
||||
}
|
||||
|
||||
function runtimeDetail(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking runtime'
|
||||
if (!status) return 'Status unavailable'
|
||||
if (status.runtime === 'reachable') return 'Runtime reachable'
|
||||
if (status.runtime === 'unavailable') return status.error ?? 'Runtime unavailable'
|
||||
return 'Runtime coming soon'
|
||||
}
|
||||
|
||||
function runtimeState(status?: CloudStatus, loading?: boolean): 'ready' | 'pending' | 'unavailable' {
|
||||
if (loading || !status || status.runtime === 'coming_soon') return 'pending'
|
||||
return status.runtime === 'reachable' ? 'ready' : 'unavailable'
|
||||
}
|
||||
|
||||
function adapterValue(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (cloud === 'gcp') return 'GCP Adapter'
|
||||
if (status?.adapterRegistered === false) return 'Not registered'
|
||||
return `${cloud.toUpperCase()} Adapter`
|
||||
}
|
||||
|
||||
function adapterDetail(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking adapter'
|
||||
if (!status) return 'Adapter status unknown'
|
||||
return status.adapterRegistered ? 'Adapter ready' : 'Coming soon'
|
||||
}
|
||||
|
||||
function adapterState(_cloud: CloudProvider, status?: CloudStatus): 'ready' | 'pending' | 'unavailable' {
|
||||
if (!status || !status.adapterRegistered) return 'pending'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
function connectionValue(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking'
|
||||
if (!status) return 'Unknown'
|
||||
if (status.runtime === 'reachable') return 'Connected'
|
||||
if (status.runtime === 'unavailable') return 'Not connected'
|
||||
return 'Coming soon'
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A slug that is not in the server's catalog. Previously this silently redirected
|
||||
* to storage, so a typo or a stale bookmark looked like a working page.
|
||||
*/
|
||||
function UnknownServiceNotice({
|
||||
cloud,
|
||||
service,
|
||||
services,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
service: string
|
||||
services?: CloudServiceDescriptor[]
|
||||
}) {
|
||||
const available = (services ?? []).filter((item) => item.availability === 'available')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content cloud-explorer">
|
||||
<EmptyState
|
||||
icon={Cloud}
|
||||
title={service ? `${cloud.toUpperCase()} has no service called "${service}"` : 'No service selected'}
|
||||
description="Pick one of the services this runtime exposes."
|
||||
/>
|
||||
<div className="unknown-service-links">
|
||||
{available.map((item) => (
|
||||
<Link
|
||||
className="button compact"
|
||||
key={item.service}
|
||||
to={item.route.startsWith('/') ? item.route : `/cloud-explorer/${cloud}/${item.route}`}
|
||||
>
|
||||
{item.displayName}
|
||||
</Link>
|
||||
))}
|
||||
<Link className="button compact" to={`/console/${cloud}`}>Console Home</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {ArrowLeft, Database} from 'lucide-react'
|
||||
import {Link, useParams, useSearchParams} from 'react-router-dom'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {getCloudResource} from '@/api/cloudProxyClient'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import {useCloudServicesQuery, useCloudStatusQuery} from '@/api/queries/cloudQueries'
|
||||
import {AzureSqlPanel} from '@/components/AzureSqlPanel'
|
||||
import {CosmosNoSqlPanel} from '@/components/CosmosNoSqlPanel'
|
||||
import {DynamoDbTableExplorer} from '@/components/DynamoDbTableExplorer'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {dataExplorerKind} from '@/lib/dataExplorer'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
export function DatabaseDataPage() {
|
||||
const {cloud, service, resourceId} = useParams()
|
||||
const accountId = useAccountId()
|
||||
if ((cloud !== 'aws' && cloud !== 'azure' && cloud !== 'gcp') || !service || !resourceId) {
|
||||
return <div className="content"><DataNotice title="Invalid data workspace" detail="Check the cloud, service, and resource in this link."/>
|
||||
<Link to="/console/aws">Console Home</Link></div>
|
||||
}
|
||||
return <DatabaseDataWorkspace key={`${accountId}:${cloud}:${service}:${resourceId}`} cloud={cloud} service={service} resourceId={resourceId}/>
|
||||
}
|
||||
|
||||
function DatabaseDataWorkspace({cloud, service, resourceId}: {cloud: CloudProvider; service: string; resourceId: string}) {
|
||||
const accountId = useAccountId()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const servicesQuery = useCloudServicesQuery(cloud)
|
||||
const statusQuery = useCloudStatusQuery(cloud)
|
||||
const descriptor = servicesQuery.data?.find((item) => item.service === service)
|
||||
const resourceQuery = useQuery({
|
||||
queryKey: ['cloud-data-resource', accountId, cloud, service, resourceId],
|
||||
queryFn: ({signal}) => getCloudResource(cloud, service, resourceId, signal),
|
||||
enabled: descriptor?.availability === 'available' && statusQuery.data?.runtime === 'reachable',
|
||||
})
|
||||
const resource = resourceQuery.data
|
||||
const kind = resource && dataExplorerKind(resource)
|
||||
const containerId = searchParams.get('container') || undefined
|
||||
|
||||
function retry() {
|
||||
void servicesQuery.refetch()
|
||||
void statusQuery.refetch()
|
||||
if (descriptor?.availability === 'available' && statusQuery.data?.runtime === 'reachable') void resourceQuery.refetch()
|
||||
}
|
||||
|
||||
function selectContainer(id: string | undefined) {
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous)
|
||||
if (id) next.set('container', id)
|
||||
else next.delete('container')
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderWorkspace() {
|
||||
if (servicesQuery.isPending || statusQuery.isPending) return <DataNotice title="Loading workspace" detail="Checking service availability and runtime connection."/>
|
||||
if (servicesQuery.isError || statusQuery.isError) return <DataNotice title="Unable to load workspace" detail={(servicesQuery.error ?? statusQuery.error)?.message} onRetry={retry}/>
|
||||
if (!descriptor) return <DataNotice title="Unknown service" detail={`No ${service} service exists for ${cloud.toUpperCase()}.`}/>
|
||||
if (descriptor.availability !== 'available') return <DataNotice title="Service unavailable" detail={descriptor.reason ?? 'This service has no available adapter.'}/>
|
||||
if (statusQuery.data?.runtime !== 'reachable') return <DataNotice title="Runtime unavailable" detail={statusQuery.data?.error ?? 'Start the selected runtime to explore data.'} onRetry={retry}/>
|
||||
if (resourceQuery.isError) return <DataNotice title={resourceQuery.error instanceof HttpError && resourceQuery.error.status === 404 ? 'Resource not found' : 'Unable to load resource'} detail={resourceQuery.error.message} onRetry={retry}/>
|
||||
if (!resource) return <DataNotice title="Loading resource" detail="Reading resource details from the runtime."/>
|
||||
if (kind === 'dynamodb') return <DynamoDbTableExplorer cloud={cloud} resource={resource} runtimeReachable/>
|
||||
if (kind === 'cosmos') return <CosmosNoSqlPanel key={containerId ?? ''} cloud={cloud} resource={resource} runtimeReachable selectedContainerId={containerId} onSelectContainer={selectContainer}/>
|
||||
if (kind === 'sql') return <AzureSqlPanel cloud={cloud} resource={resource} runtimeReachable/>
|
||||
return <DataNotice title="Data exploration unavailable" detail="This resource supports management operations only."/>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="database-data-page">
|
||||
<header className="page-header data-workspace-header">
|
||||
<Link className="button compact" to={`/cloud-explorer/${cloud}/${service}`}><ArrowLeft size={14}/>Back to {descriptor?.displayName ?? service}</Link>
|
||||
<div className="page-title">
|
||||
<Database size={20}/>
|
||||
<div>
|
||||
<h1>{resource?.name ?? resourceId}</h1>
|
||||
<p className="muted">{cloud.toUpperCase()} · {descriptor?.displayName ?? service} · Explore data</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="data-workspace">{renderWorkspace()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataNotice({title, detail, onRetry}: {title: string; detail?: string; onRetry?: () => void}) {
|
||||
return <div className="runtime-notice" role="status"><h3>{title}</h3>{detail && <p>{detail}</p>}
|
||||
{onRetry && <button className="button" type="button" onClick={onRetry}>Retry</button>}
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user