Files
floci-ui/packages/frontend/src/components/DynamicResourceView.tsx
T
2026-10-24 13:07:57 -04:00

1387 lines
52 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, ChevronUp, Info, Loader2, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCloudResource,
createApiGatewayLambdaRoute,
createEventBridgeLambdaTrigger,
createS3LambdaTrigger,
createSqsLambdaEventSourceMapping,
clearEmailInbox,
deleteCloudResource,
getCloudResource,
getServiceSchema,
listCloudResources,
listStorageObjects,
updateCloudResource,
} from "@/api/cloudProxyClient";
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
import { ResourceInspector } from "@/components/ResourceInspector";
import { ResourceTable } from "@/components/ResourceTable";
import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
import { NetworkingPanel } from "@/components/NetworkingPanel";
import { describeDeleteImpact } from "@/lib/deleteImpact";
import { getPath } from "@/lib/resourcePath";
import {
capabilityEnabled,
capabilityFor,
normalizeCapabilities,
withRuntimeState,
withServiceAvailability,
} from "@/lib/capabilities";
import type {
CloudAvailability,
CloudProvider,
CloudServiceType,
CloudStatus,
} from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import type { ServiceSchema } from "@/types/schema";
import { ServerlessInvokePanel } from "@/components/ServerlessInvokePanel";
import { dataExplorerPath } from "@/lib/dataExplorer";
import { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel";
import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
import { SqsQueuePanel } from "@/components/SqsQueuePanel";
import { CloudFormationStackPanel } from "@/components/CloudFormationStackPanel";
import { CloudFormationCreatePanel } from "@/components/CloudFormationCreatePanel";
import { CloudFormationUpdatePanel } from "@/components/CloudFormationUpdatePanel";
import { EventBridgeRulePanel } from "@/components/EventBridgeRulePanel";
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
import { SnsTopicPanel } from "@/components/SnsTopicPanel";
import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
import { MskPanel } from "@/components/MskPanel";
import { MskConnectPanel } from "@/components/MskConnectPanel";
import { EcsServicePanel } from "@/components/EcsServicePanel";
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {KinesisPanel} from "@/components/KinesisPanel";
import {SesConsolePanel} from '@/components/SesConsolePanel'
import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel'
import {LightsailLaunchPanel} from '@/components/LightsailLaunchPanel'
import {LightsailInstancePanel} from '@/components/LightsailInstancePanel'
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate, useSearchParams} from "react-router-dom";
interface DynamicResourceViewProps {
cloud: CloudProvider;
service: CloudServiceType;
serviceAvailability?: CloudAvailability;
/** Server-supplied explanation, shown instead of generic coming-soon copy. */
serviceReason?: string;
cloudStatus?: CloudStatus;
statusLoading?: boolean;
onOpenInfo: () => void;
}
const ELASTICACHE_CREATION_MINIMUM_MS = 5_000;
interface CreateMutationVariables {
values: Record<string, unknown>;
destination?: WorkflowCreationDestination;
}
export function DynamicResourceView({
cloud,
service,
serviceAvailability = "coming_soon",
serviceReason,
cloudStatus,
statusLoading = false,
onOpenInfo,
}: DynamicResourceViewProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [state, setState] = useState("");
const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances");
const [selected, setSelected] = useState<CloudResource | undefined>();
const [selectedObject, setSelectedObject] = useState<
StorageObject | undefined
>();
const [createOpen, setCreateOpen] = useState(false);
const [clearConfirm, setClearConfirm] = useState(false);
const [isDeleteMulti, setIsDeleteMulti] = useState(false);
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
const openedResourceRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (cloud === "aws" && service === "compute") {
setCreateOpen(true);
}
}, [cloud, service]);
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
setDatabaseTab(tab);
if (tab === "snapshots") {
setSearch("");
}
};
const resourcesKey = useMemo(
() => ["cloud-resources", cloud, service, search, state],
[cloud, service, search, state],
);
const schemaQuery = useQuery({
queryKey: ["cloud-schema", cloud, service],
queryFn: ({ signal }) => getServiceSchema(cloud, service, signal),
enabled: !(cloud === "aws" && (service === "msk" || service === "msk-connect")),
});
const resourcesQuery = useQuery({
queryKey: resourcesKey,
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal, state),
enabled:
schemaQuery.isSuccess &&
serviceAvailability === "available" &&
cloudStatus?.runtime === "reachable",
});
useEffect(() => {
const resourceId = searchParams.get("resource");
if (!resourceId || !resourcesQuery.data) return;
const key = `${cloud}:${service}:${resourceId}`;
if (openedResourceRef.current === key) return;
const resource = resourcesQuery.data.find((candidate) => candidate.id === resourceId);
if (resource) setSelected(resource);
openedResourceRef.current = key;
}, [cloud, resourcesQuery.data, searchParams, service]);
// Automatically reconcile selected items when resources query data changes
useEffect(() => {
setSelectedItems((prev) => {
if (prev.length === 0) return prev;
const currentIds = new Set((resourcesQuery.data || []).map((r) => r.id));
const next = prev.filter((item) => currentIds.has(item.id));
return next.length === prev.length ? prev : next;
});
}, [resourcesQuery.data]);
const selectMultiple = (select?: CloudResource | boolean) => {
const currentResources = resourcesQuery.data || [];
if (typeof select === "boolean" || select === undefined) {
const isAllCurrentlySelected =
currentResources.length > 0 &&
currentResources.every((r) =>
selectedItems.some((item) => item.id === r.id),
);
const shouldSelectAll =
typeof select === "boolean" ? select : !isAllCurrentlySelected;
if (shouldSelectAll) {
setSelectedItems(currentResources);
} else {
setSelectedItems([]);
}
} else {
setSelectedItems((prev) => {
const exists = prev.some((item) => item.id === select.id);
return exists
? prev.filter((item) => item.id !== select.id)
: [...prev, select];
});
}
};
const createMut = useMutation({
mutationFn: async ({values}: CreateMutationVariables) => {
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
const blueprintScheduleExpression = typeof values.blueprintScheduleExpression === "string" ? values.blueprintScheduleExpression.trim() : "";
const blueprintQueueName = typeof values.blueprintQueueName === "string" ? values.blueprintQueueName.trim() : "";
const blueprintSqsBatchSize = typeof values.blueprintSqsBatchSize === "number" ? values.blueprintSqsBatchSize : undefined;
const lambdaValues = {...values};
delete lambdaValues.blueprintBucketName;
delete lambdaValues.blueprintApiName;
delete lambdaValues.blueprintApiPath;
delete lambdaValues.blueprintApiMethod;
delete lambdaValues.blueprintApiStage;
delete lambdaValues.blueprintRuleName;
delete lambdaValues.blueprintScheduleExpression;
delete lambdaValues.blueprintQueueName;
delete lambdaValues.blueprintSqsBatchSize;
const resource = await createCloudResource(cloud, service, lambdaValues);
if (cloud === "aws" && service === "elasticache") {
await new Promise<void>((resolve) => setTimeout(resolve, ELASTICACHE_CREATION_MINIMUM_MS));
return resource;
}
if (blueprintBucketName) {
let bucketCreated = false;
try {
await createCloudResource(cloud, "storage", {bucketName: blueprintBucketName});
bucketCreated = true;
await createS3LambdaTrigger(cloud, blueprintBucketName, {
functionName: resource.name,
events: ["s3:ObjectCreated:*"],
});
return resource;
} catch (error) {
if (bucketCreated) await deleteCloudResource(cloud, "storage", blueprintBucketName);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintRuleName) {
try {
await createEventBridgeLambdaTrigger(cloud, resource.name, {
ruleName: blueprintRuleName,
scheduleExpression: blueprintScheduleExpression,
});
return resource;
} catch (error) {
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintQueueName) {
let queueId: string | undefined;
try {
const queue = await createCloudResource(cloud, "messaging", {queueName: blueprintQueueName});
queueId = queue.id;
const queueArn = queue.metadata.arn;
if (typeof queueArn !== "string" || !queueArn) {
throw new Error("Created SQS queue did not return an ARN.");
}
await createSqsLambdaEventSourceMapping(cloud, resource.name, {
queueArn,
batchSize: blueprintSqsBatchSize,
});
return resource;
} catch (error) {
const rollbacks = [deleteCloudResource(cloud, service, resource.id)];
if (queueId) rollbacks.push(deleteCloudResource(cloud, "messaging", queueId));
await Promise.allSettled(rollbacks);
throw error;
}
}
if (!blueprintApiName) return resource;
let apiId: string | undefined;
try {
const api = await createCloudResource(cloud, "apigateway", {name: blueprintApiName});
apiId = api.id;
const route = await createApiGatewayLambdaRoute(cloud, api.id, {
functionName: resource.name,
path: String(values.blueprintApiPath ?? ""),
method: String(values.blueprintApiMethod ?? "GET"),
stage: String(values.blueprintApiStage ?? "dev"),
});
return {...resource, metadata: {...resource.metadata, apiGatewayId: api.id, apiGatewayInvokeUrl: route.invokeUrl}};
} catch (error) {
if (apiId) await deleteCloudResource(cloud, "apigateway", apiId);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
},
onSuccess: (resource, variables) => {
setSelected(resource);
setCreateOpen(false);
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
if (cloud === "aws" && service === "workflows" && variables.destination === "studio") {
const destinationPath = workflowCreationDestinationPath(variables.destination, resource.id);
if (destinationPath) navigate(destinationPath);
}
},
});
const deleteMut = useMutation({
mutationFn: async (resource: CloudResource) => {
if (cloud === "aws" && service === "storage") {
const contents = await listStorageObjects(cloud, resource.id);
const warning = bucketDeleteWarning(contents);
if (warning) throw new Error(warning);
}
if (cloud === "aws" && service === "iac") {
const current = await getCloudResource(cloud, service, resource.id);
if (current?.metadata.enableTerminationProtection === true) throw new Error("Termination protection is enabled. Disable it from Stack actions before deleting this stack.");
}
await deleteCloudResource(cloud, service, resource.id);
},
onSuccess: (_, resource) => {
setDeleteWarning(null);
if (selected?.id === resource.id) setSelected(undefined);
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onError: (error) => {
setDeleteWarning(error instanceof Error ? error.message : "Unable to delete resource.");
},
});
const deleteMultipleMut = useMutation({
mutationFn: async (items: CloudResource[]) => {
const results = await Promise.allSettled(
items.map(async (item) => {
await deleteCloudResource(cloud, service, item.id);
return item.id;
}),
);
const succeededIds = new Set<string>();
const failedItems: { id: string; error: unknown }[] = [];
results.forEach((res, index) => {
if (res.status === "fulfilled") {
succeededIds.add(res.value);
} else {
failedItems.push({ id: items[index].id, error: res.reason });
}
});
return { succeededIds, failedItems };
},
onSettled: () => {
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onSuccess: ({ succeededIds, failedItems }) => {
setDeleteConfirm(false);
setSelectedItems((prev) =>
prev.filter((item) => !succeededIds.has(item.id)),
);
if (selected && succeededIds.has(selected.id)) {
setSelected(undefined);
}
if (failedItems.length > 0) {
setBulkDeleteError(
`Failed to delete ${failedItems.length} of ${
succeededIds.size + failedItems.length
} selected resource(s).`,
);
} else {
setBulkDeleteError(null);
setIsDeleteMulti(false);
setSuccessToast(
`Successfully deleted ${succeededIds.size} resource(s).`,
);
}
},
});
const clearInboxMut = useMutation({
mutationFn: () => clearEmailInbox(cloud),
onSuccess: () => {
setSelected(undefined);
setClearConfirm(false);
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
});
const [editingResource, setEditingResource] = useState<CloudResource | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const [successToast, setSuccessToast] = useState<string | null>(null);
useEffect(() => {
if (successToast) {
const timer = setTimeout(() => setSuccessToast(null), 4000);
return () => clearTimeout(timer);
}
}, [successToast]);
useEffect(() => {
if (deleteWarning) {
const timer = setTimeout(() => setDeleteWarning(null), 5000);
return () => clearTimeout(timer);
}
}, [deleteWarning]);
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
updateCloudResource(cloud, service, id, values),
onSuccess: (updatedResource) => {
setEditingResource(null);
setUpdateError(null);
setSelected(updatedResource);
setSuccessToast(`Successfully updated ${updatedResource.name || updatedResource.id}`);
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onError: (err) => {
setUpdateError(err instanceof Error ? err.message : "Failed to update resource");
},
});
const initialEditValues = useMemo(() => {
if (!editingResource || !schemaQuery.data?.updateFields) return {};
const vals: Record<string, unknown> = {};
for (const field of schemaQuery.data.updateFields) {
const path = field.valuePath ?? field.name;
const currentVal = getPath(editingResource, path);
if (currentVal !== undefined && currentVal !== null) {
vals[field.name] = currentVal;
}
}
return vals;
}, [editingResource, schemaQuery.data?.updateFields]);
function handleEditSubmit(values: Record<string, unknown>) {
if (!editingResource || !schemaQuery.data?.updateFields) return;
setUpdateError(null);
const diff: Record<string, unknown> = {};
for (const field of schemaQuery.data.updateFields) {
const rawVal = values[field.name];
const initialVal = initialEditValues[field.name];
if (rawVal === undefined) continue;
if (typeof rawVal === "string") {
const trimmed = rawVal.trim();
if (!trimmed && !field.required) {
continue;
}
const initialStr = field.type === 'key-value' && initialVal && typeof initialVal === 'object'
? JSON.stringify(Object.entries(initialVal).map(([key, value]) => ({key, value: String(value)})))
: initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
if (trimmed !== initialStr) {
diff[field.name] = trimmed;
}
} else if (rawVal !== initialVal) {
diff[field.name] = rawVal;
}
}
if (Object.keys(diff).length === 0) {
setUpdateError("No fields changed.");
return;
}
updateMut.mutate({ id: editingResource.id, values: diff });
}
useEffect(() => {
setSelected(undefined);
setSelectedObject(undefined);
setCreateOpen(false);
setClearConfirm(false);
setDeleteConfirm(false);
setBulkDeleteError(null);
setEditingResource(null);
setUpdateError(null);
setSearch("");
setState("");
setDatabaseTab("instances");
setIsDeleteMulti(false);
setSelectedItems([]);
}, [cloud, service]);
useEffect(() => {
if (selectedItems.length === 0 && deleteConfirm) {
setDeleteConfirm(false);
}
}, [selectedItems.length, deleteConfirm]);
useEffect(() => {
setSelectedObject(undefined);
}, [selected?.id]);
const adapterAvailable = serviceAvailability === "available";
if (cloud === "aws" && service === "msk") {
return (
<div className="dynamic-resource-view">
<TopbarServiceInfo onOpenInfo={onOpenInfo} />
<MskPanel runtimeReachable={cloudStatus?.runtime === "reachable"} />
</div>
);
}
if (cloud === "aws" && service === "msk-connect") {
return <div className="dynamic-resource-view"><TopbarServiceInfo onOpenInfo={onOpenInfo} /><MskConnectPanel runtimeReachable={cloudStatus?.runtime === "reachable"} /></div>;
}
if (schemaQuery.isLoading) {
return (
<div className="dynamic-resource-view">
<TopbarServiceInfo onOpenInfo={onOpenInfo} />
<div className="empty compact">
<h3>Loading schema</h3>
</div>
</div>
);
}
if (schemaQuery.isError || !schemaQuery.data) {
return (
<div className="dynamic-resource-view">
<TopbarServiceInfo onOpenInfo={onOpenInfo} />
<div className="cloud-coming-soon">
<div>
<p className="eyebrow">Coming Soon</p>
<h3>
{cloud.toUpperCase()} {service}
</h3>
<p className="muted">
The proxy already exposes this provider as a placeholder. No
adapter is registered yet.
</p>
</div>
<div className="coming-soon-grid">
<StatusTile
label="Cloud"
value={cloud.toUpperCase()}
state="placeholder"
/>
<StatusTile label="Service" value={service} state="placeholder" />
<StatusTile
label="Adapter"
value="Not registered"
state="pending"
/>
<StatusTile label="Runtime" value="Future" state="pending" />
</div>
</div>
</div>
);
}
const schema = schemaQuery.data;
const resources = resourcesQuery.data ?? [];
const canCreate = schema.actions.includes("create");
const canDelete = schema.actions.includes("delete");
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
const isAllSelected =
resources.length > 0 &&
resources.every((r) => selectedItems.some((s) => s.id === r.id));
// Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot.
const activeSelected =
selected?.cloud === cloud && selected.service === service
? resources.find((resource) => resource.id === selected.id) ?? selected
: undefined;
const runtimeReachable = cloudStatus?.runtime === "reachable";
const resourceCapabilityInputs =
schema.capabilities?.resourceActions ?? schema.actions;
const resourceCapabilities = withServiceAvailability(
withRuntimeState(
normalizeCapabilities(resourceCapabilityInputs),
runtimeReachable,
),
serviceAvailability,
);
const objectCapabilities = withServiceAvailability(
withRuntimeState(
normalizeCapabilities(schema.capabilities?.objectActions),
runtimeReachable,
),
serviceAvailability,
);
const createCapability = capabilityFor(resourceCapabilities, "create");
const createResourceLabel = createCapability?.label ?? "Create resource";
const databaseCapabilities = withServiceAvailability(
withRuntimeState(
normalizeCapabilities(schema.capabilities?.databaseActions),
runtimeReachable,
),
serviceAvailability,
);
const listSnapshotCapability = capabilityFor(
databaseCapabilities,
"listSnapshots",
);
const createSnapshotCapability = capabilityFor(
databaseCapabilities,
"createSnapshot",
);
const canUseRuntime = runtimeReachable && adapterAvailable;
const canCreateResource =
canUseRuntime && capabilityEnabled(createCapability);
const isAwsDatabase = cloud === "aws" && service === "database";
const showDatabaseSnapshots = isAwsDatabase && databaseTab === "snapshots";
return (
<div className="dynamic-resource-view">
<TopbarServiceInfo onOpenInfo={onOpenInfo} />
<div
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
>
<section className="resource-main">
{isAwsDatabase && (
<div className="drawer-tabs" style={{ marginBottom: 12 }}>
<button
type="button"
className={`drawer-tab ${databaseTab === "instances" ? "active" : ""}`}
onClick={() => handleDatabaseTabChange("instances")}
>
Instances
</button>
<button
type="button"
className={`drawer-tab ${databaseTab === "snapshots" ? "active" : ""}`}
onClick={() => handleDatabaseTabChange("snapshots")}
>
Snapshots
</button>
</div>
)}
{showDatabaseSnapshots ? (
<DatabaseSnapshotsPanel
cloud={cloud}
instances={resources}
listCapability={listSnapshotCapability}
createCapability={createSnapshotCapability}
runtimeReachable={canUseRuntime}
/>
) : (
<section className="table-panel">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Resources</p>
<span className="muted">
{resources.length} normalized resources
</span>
</div>
<div className="resource-table-tools">
<div className="search-box">
<input
className="input"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Filter resources"
/>
</div>
{schema.filters.find((filter) => filter.name === "state" && filter.type === "select") && (
<select
className="input"
aria-label="Filter by lifecycle state"
value={state}
onChange={(event) => setState(event.target.value)}
>
{schema.filters.find((filter) => filter.name === "state")?.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{service === "email" && (
clearConfirm ? (
<>
<button
className="button danger"
type="button"
disabled={!canUseRuntime || clearInboxMut.isPending}
onClick={() => clearInboxMut.mutate()}
>
<Trash2 size={14} />
{clearInboxMut.isPending ? "Clearing" : "Confirm clear"}
</button>
<button className="button" type="button" disabled={clearInboxMut.isPending} onClick={() => setClearConfirm(false)}>
Cancel
</button>
</>
) : (
<button
className="button danger"
type="button"
disabled={!canUseRuntime}
onClick={() => setClearConfirm(true)}
>
<Trash2 size={14} />
Clear inbox
</button>
)
)}
{canCreate && (
<button
className="button"
type="button"
disabled={!canCreateResource}
title={createCapability?.reason}
onClick={() => setCreateOpen((open) => !open)}
>
<Plus size={14} />
{createResourceLabel}
{createOpen ? (
<ChevronUp size={13} />
) : (
<ChevronDown size={13} />
)}
</button>
)}
<button
className="button"
type="button"
disabled={!canUseRuntime || resourcesQuery.isFetching}
onClick={() => resourcesQuery.refetch()}
>
<RefreshCw size={14} />
{resourcesQuery.isFetching ? "Loading" : "Refresh"}
</button>
{canDelete && (
deleteConfirm ? (
<>
{deleteImpactText && (
<span className="delete-impact" role="status">{deleteImpactText}</span>
)}
<button
className="button danger"
type="button"
disabled={!canUseRuntime || deleteMultipleMut.isPending}
onClick={() => deleteMultipleMut.mutate(selectedItems)}
>
<Trash2 size={14} />
{deleteMultipleMut.isPending
? "Deleting..."
: `Confirm Delete (${selectedItems.length})`}
</button>
<button
className="button"
type="button"
disabled={deleteMultipleMut.isPending}
onClick={() => setDeleteConfirm(false)}
>
Cancel
</button>
</>
) : (
<button
className="button danger"
type="button"
disabled={
!canUseRuntime ||
resourcesQuery.isFetching ||
deleteMultipleMut.isPending
}
onClick={() => {
if (!isDeleteMulti) {
setIsDeleteMulti(true);
} else if (selectedItems.length > 0) {
setDeleteConfirm(true);
} else {
setIsDeleteMulti(false);
setSelectedItems([]);
}
}}
>
<Trash2 size={14} />
{isDeleteMulti && selectedItems.length > 0
? `Delete Selected (${selectedItems.length})`
: isDeleteMulti
? "Cancel Selection"
: "Delete Multiple"}
</button>
)
)}
</div>
</div>
{service === "email" && clearInboxMut.isError && (
<p className="error-text compact-text" style={{ margin: "0 12px 10px" }}>
{clearInboxMut.error instanceof Error
? clearInboxMut.error.message
: "Unable to clear the inbox."}
</p>
)}
{bulkDeleteError && (
<p className="error-text compact-text" style={{ margin: "0 12px 10px" }}>
{bulkDeleteError}
</p>
)}
{canCreate && createOpen && (
<div className="resource-create-inline">
{/*
* AWS only. LaunchInstanceForm is an EC2 form: it asks for an AMI id,
* populates its dropdowns from the legacy /api/ec2 routes, and submits
* imageId/instanceType. On any other cloud that is the wrong form
* entirely — the Azure adapter rejects it with "resourceGroup is
* required". Every other cloud falls through to DynamicFormRenderer,
* which builds the right form from the adapter's own schema.
*/}
{service === "compute" && cloud === "aws" ? (
<LaunchInstanceForm
cloud={cloud}
selectedResource={activeSelected}
onSuccess={(resource) => {
setSelected(resource);
setCreateOpen(false);
}}
onCancel={() => setCreateOpen(false)}
/>
) : service === "lightsail" && cloud === "aws" ? (
<LightsailLaunchPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "database" && cloud === "aws" ? (
<CreateRdsInstanceForm
cloud={cloud}
onSuccess={(resource) => {
setSelected(resource);
setCreateOpen(false);
}}
onCancel={() => setCreateOpen(false)}
/>
) : service === "serverless" && cloud === "aws" ? (
<LambdaCreatePanel
schema={schema}
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "workflows" && cloud === "aws" ? (
<WorkflowCreatePanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(submission) => createMut.mutate(submission)}
/>
) : service === "iac" && cloud === "aws" ? (
<CloudFormationCreatePanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "codedeploy" && cloud === "aws" ? (
<CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/>
) : (
<>
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
schema={schema}
isSubmitting={createMut.isPending}
submitLabel={createResourceLabel}
pendingLabel="Creating"
submitError={
createMut.error instanceof Error
? createMut.error.message
: null
}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
{service === "elasticache" && cloud === "aws" && createMut.isPending && <DatabaseLaunchProgress name="cache cluster" description="Starting the cache container and preparing its local endpoint." />}
</>
)}
</div>
)}
{!(cloud === "aws" && service === "compute" && createOpen) && renderResourceSurface({
schema,
resources,
selectedId: activeSelected?.id,
deletingId: deleteMut.variables?.id,
cloudStatus,
statusLoading,
serviceAvailability,
serviceReason,
resourcesLoading: resourcesQuery.isLoading,
resourcesError: resourcesQuery.error,
isRetrying: resourcesQuery.isFetching,
onSelect: setSelected,
onEdit:
schema.updateFields &&
schema.updateFields.length > 0 &&
schema.actions.includes("update")
? async (resource) => {
try {
const detailedResource = await getCloudResource(cloud, service, resource.id);
setEditingResource(detailedResource);
setUpdateError(null);
} catch (error) {
setUpdateError(error instanceof Error ? error.message : "Unable to load resource configuration");
}
}
: undefined,
onDelete: (resource) => deleteMut.mutate(resource),
onRetry: () => resourcesQuery.refetch(),
isDeleteMulti,
selectedItems,
onToggleSelect: (resource) => selectMultiple(resource),
onToggleSelectAll: () => selectMultiple(!isAllSelected),
isAllSelected,
})}
</section>
)}
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
</section>
{activeSelected && !showDatabaseSnapshots && (
<div>
{cloud === "aws" && service === "workflows" && <button className="button compact workflow-open-studio" type="button" onClick={() => navigate(workflowStudioPath(activeSelected.id))}>Open Studio</button>}
<ResourceInspector
resource={activeSelected}
object={selectedObject}
cloud={cloud}
runtimeReachable={canUseRuntime}
serviceName={schema.displayName}
/>
</div>
)}
</div>
{service === "storage" && (
<>
<StorageObjectBrowser
cloud={cloud}
resource={selected}
capabilities={objectCapabilities}
runtimeReachable={canUseRuntime}
selectedObjectKey={selectedObject?.key}
onSelectObject={setSelectedObject}
/>
{cloud === "aws" && <S3BucketWebsitePanel resource={selected} runtimeReachable={canUseRuntime} />}
</>
)}
{service === "messaging" && cloud === "aws" && <SqsQueuePanel resource={activeSelected} runtimeReachable={canUseRuntime} />}
{service === "iac" && cloud === "aws" && <CloudFormationStackPanel resource={activeSelected} />}
{service === "ecr" && cloud === "aws" && (
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "targetgroups" && cloud === "aws" && (
<TargetGroupPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={runtimeReachable}
/>
)}
{service === "networking" && (
<NetworkingPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={runtimeReachable}
/>
)}
{service === "serverless" && (
<>
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<S3LambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<ScheduledLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<SqsLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="lambda" />
{cloud === "aws" && activeSelected?.type === "lambda" && (
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
)}
</>
)}
{service === "apigateway" && (
<ApiGatewayRoutePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
)}
{service === "events" && (
<EventBridgeRulePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{service === "configuration" && cloud === "aws" && (
<AppConfigPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{service === "kms" && (
<KmsCryptoPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{service === "notifications" && (
<SnsTopicPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{service === "workflows" && cloud === "aws" && (
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codebuild" && cloud === "aws" && (
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{(service === "ecs" || service === "fargate") && cloud === "aws" && <EcsServicePanel resource={activeSelected} />}
{service === "codepipeline" && cloud === "aws" && (
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />}
{service === "lightsail" && cloud === "aws" && <LightsailInstancePanel resource={activeSelected} runtimeReachable={canUseRuntime} onDeleted={() => setSelected(undefined)} />}
{editingResource && schema.updateFields && (
<div
className="modal-overlay"
onClick={() => {
if (!updateMut.isPending) setEditingResource(null);
}}
>
<div
className="create-table-modal"
style={{ width: 520, maxWidth: "90vw" }}
onClick={(e) => e.stopPropagation()}
>
<div
className="modal-header"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<h3 style={{ margin: 0 }}>
Edit {schema.displayName}: {editingResource.name || editingResource.id}
</h3>
<button
className="icon-btn"
type="button"
disabled={updateMut.isPending}
onClick={() => setEditingResource(null)}
>
<X size={14} />
</button>
</div>
{service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
resource={editingResource}
isSubmitting={updateMut.isPending}
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
) : service === "iac" && cloud === "aws" ? <CloudFormationUpdatePanel initialTemplate={String(initialEditValues.templateBody ?? '')} isSubmitting={updateMut.isPending} submitError={updateError} onCancel={() => setEditingResource(null)} onSubmit={handleEditSubmit}/> : <DynamicFormRenderer
key={editingResource.id}
schema={schema}
fields={schema.updateFields}
initialValues={initialEditValues}
isSubmitting={updateMut.isPending}
submitLabel="Save Changes"
pendingLabel="Saving"
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>}
</div>
</div>
)}
{successToast && (
<div
role="status"
style={{
position: "fixed",
bottom: 24,
right: 24,
background: "var(--accent, #3b82f6)",
color: "#fff",
padding: "10px 18px",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
zIndex: 1000,
fontSize: 13,
fontWeight: 500,
}}
>
{successToast}
</div>
)}
{deleteWarning && (
<div
role="alert"
style={{
position: "fixed",
bottom: 24,
right: 24,
background: "var(--status-error, #dc2626)",
color: "#fff",
padding: "10px 18px",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
zIndex: 1000,
fontSize: 13,
fontWeight: 500,
}}
>
<span>{deleteWarning}</span>
<button
className="icon-btn"
type="button"
aria-label="Dismiss warning"
onClick={() => setDeleteWarning(null)}
style={{ marginLeft: 10, color: "inherit" }}
>
<X size={14} />
</button>
</div>
)}
</div>
);
}
function TopbarServiceInfo({ onOpenInfo }: { onOpenInfo: () => void }) {
const [slot, setSlot] = useState<HTMLElement | null>(null);
useLayoutEffect(() => {
setSlot(document.getElementById("topbar-status"));
}, []);
if (!slot) return null;
return createPortal(
<button
className="icon-btn"
type="button"
onClick={onOpenInfo}
title="Service information"
>
<Info size={14} />
</button>,
slot,
);
}
function StatusTile({
label,
value,
state,
}: {
label: string;
value: string;
state: "placeholder" | "pending";
}) {
return (
<div className={`status-tile ${state}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function renderResourceSurface({
schema,
resources,
selectedId,
deletingId,
cloudStatus,
statusLoading,
serviceAvailability,
serviceReason,
resourcesLoading,
resourcesError,
isRetrying,
onSelect,
onEdit,
onDelete,
onRetry,
isDeleteMulti,
selectedItems,
onToggleSelect,
onToggleSelectAll,
isAllSelected,
}: {
schema: ServiceSchema;
resources: CloudResource[];
selectedId?: string;
deletingId?: string;
cloudStatus?: CloudStatus;
statusLoading: boolean;
serviceAvailability: CloudAvailability;
serviceReason?: string;
resourcesLoading: boolean;
resourcesError: unknown;
isRetrying: boolean;
onSelect: (resource: CloudResource) => void;
onEdit?: (resource: CloudResource) => void;
onDelete: (resource: CloudResource) => void;
onRetry?: () => void;
isDeleteMulti?: boolean;
selectedItems?: CloudResource[];
onToggleSelect?: (resource: CloudResource) => void;
onToggleSelectAll?: () => void;
isAllSelected?: boolean;
}) {
if (statusLoading) {
return (
<RuntimeNotice
title="Loading service"
detail="Waiting for the proxy to describe this service and confirm its runtime."
state="pending"
/>
);
}
if (serviceAvailability !== "available") {
return (
<RuntimeNotice
title="Adapter coming soon"
detail={
serviceReason ??
`${schema.displayName} uses the same normalized schema, but a runtime adapter is not registered yet.`
}
state="pending"
/>
);
}
if (cloudStatus?.runtime === "unavailable") {
return (
<RuntimeNotice
title="Runtime unavailable"
detail={`${cloudStatus.endpoint ?? "Runtime endpoint"} is not reachable. Start the selected runtime before loading resources.`}
error={cloudStatus.error ?? undefined}
state="unavailable"
/>
);
}
if (resourcesError) {
return (
<RuntimeNotice
title={`Unable to load ${schema.displayName}.`}
detail={`The proxy could not fetch ${schema.displayName} resources from the runtime.`}
error={
resourcesError instanceof Error
? resourcesError.message
: "Unknown resource error"
}
state="unavailable"
isRetrying={isRetrying}
onRetry={onRetry}
/>
);
}
if (resourcesLoading) {
return (
<RuntimeNotice
title={`Loading ${schema.displayName}...`}
detail={`Reading ${schema.displayName} resources from the runtime.`}
state="pending"
showSpinner={true}
/>
);
}
return (
<ResourceTable
schema={schema}
resources={resources}
selectedId={selectedId}
deletingId={deletingId}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
dataPath={dataExplorerPath}
isDeleteMulti={isDeleteMulti}
selectedItems={selectedItems}
onToggleSelect={onToggleSelect}
onToggleSelectAll={onToggleSelectAll}
isAllSelected={isAllSelected}
/>
);
}
function RuntimeNotice({
title,
detail,
error,
state,
showSpinner,
isRetrying,
onRetry,
}: {
title: string;
detail: string;
error?: string;
state: "pending" | "unavailable";
showSpinner?: boolean;
isRetrying?: boolean;
onRetry?: () => void;
}) {
return (
<div className={`runtime-notice ${state}`}>
{(showSpinner || isRetrying) && (
<Loader2
size={24}
className="spin"
style={{ marginBottom: 8, color: "var(--accent)" }}
/>
)}
<h3>{title}</h3>
<p>{detail}</p>
{error && <code>{error}</code>}
{onRetry && (
<button
className="button"
type="button"
style={{ marginTop: 12 }}
disabled={isRetrying}
onClick={() => { if (!isRetrying) onRetry(); }}
>
{isRetrying ? "Retrying\u2026" : "Retry"}
</button>
)}
</div>
);
}