70cfd4bc96
CI / checks (pull_request) Successful in 26m54s
Create groups from a launch template (in the default subnets unless given), change their minimum, desired and maximum capacity, replace or remove an instance, and follow the launches and terminations TheCloud's reconciler makes. Deleting a group terminates its instances. Scaling policies are not offered: the runtime stores them but its alarms only drive ECS scaling.
1419 lines
53 KiB
TypeScript
1419 lines
53 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 { HttpApiPanel } from "@/components/HttpApiPanel";
|
|
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 { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
|
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
|
import { CertificatePanel } from "@/components/CertificatePanel";
|
|
import { PipePanel } from "@/components/PipePanel";
|
|
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
|
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
|
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 === "certificates" && cloud === "aws" && (
|
|
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
)}
|
|
{service === "pipes" && cloud === "aws" && (
|
|
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
)}
|
|
{service === "autoscaling" && cloud === "aws" && (
|
|
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
)}
|
|
{service === "cdn" && cloud === "aws" && (
|
|
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
)}
|
|
{service === "monitoring" && cloud === "aws" && (
|
|
<>
|
|
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
<CloudWatchMetricsExplorer 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" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
|
|
<HttpApiPanel
|
|
cloud={cloud}
|
|
resource={activeSelected}
|
|
runtimeReachable={canUseRuntime}
|
|
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
|
/>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|