feat: Cloudformation + RDS
@@ -26,6 +26,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"yaml": "^2.9.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
||||
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
||||
|
||||
export default function App() {
|
||||
@@ -18,6 +19,8 @@ export default function App() {
|
||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/:itemId/edit" element={<DynamoDbItemEditorPage/>}/>
|
||||
<Route path="/cloud-explorer/aws/workflows/:resourceId/studio" element={<WorkflowStudioPage/>}/>
|
||||
<Route path="/secretsmanager" element={<SecretsManagerPage/>}/>
|
||||
<Route path="*" element={<Navigate to="/console/aws" replace/>}/>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { emitApiRequest, type CloudProvider } from "@/telemetry";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface HttpClientConfig {
|
||||
@@ -9,11 +7,6 @@ export interface HttpClientConfig {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RequestTelemetry {
|
||||
provider?: CloudProvider;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export type EndpointRegistry = Map<string, EndpointDefinition>;
|
||||
|
||||
export interface RequestOptions<TBody = unknown>
|
||||
@@ -24,7 +17,6 @@ export interface RequestOptions<TBody = unknown>
|
||||
cache?: RequestCache;
|
||||
timeout?: number;
|
||||
baseUrlOverride?: string;
|
||||
telemetry?: RequestTelemetry;
|
||||
}
|
||||
|
||||
export interface HttpResponse<TData> {
|
||||
@@ -127,7 +119,6 @@ export type EndpointDefinition = {
|
||||
method: HttpMethod;
|
||||
defaults?: Partial<EndpointDefaults>;
|
||||
baseUrlOverride?: string;
|
||||
telemetry?: RequestTelemetry;
|
||||
};
|
||||
|
||||
export class HttpClient {
|
||||
@@ -159,7 +150,6 @@ export class HttpClient {
|
||||
endpoint: string,
|
||||
options: RequestOptions<TBody> = {},
|
||||
): Promise<HttpResponse<TResponse>> {
|
||||
const started = performance.now();
|
||||
const {
|
||||
params,
|
||||
body,
|
||||
@@ -167,11 +157,9 @@ export class HttpClient {
|
||||
timeout,
|
||||
cache,
|
||||
baseUrlOverride,
|
||||
telemetry,
|
||||
...restOptions
|
||||
} = options;
|
||||
let finalUrl = endpoint;
|
||||
let statusCode = 0;
|
||||
|
||||
try {
|
||||
const base = baseUrlOverride ?? this.config.baseUrl ?? "";
|
||||
@@ -210,7 +198,6 @@ export class HttpClient {
|
||||
init,
|
||||
timeout ?? this.config.timeout,
|
||||
);
|
||||
statusCode = response.status;
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
response = await interceptor(response);
|
||||
@@ -228,18 +215,6 @@ export class HttpClient {
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error, finalUrl, timeout ?? this.config.timeout);
|
||||
} finally {
|
||||
if (telemetry?.service && statusCode > 0) {
|
||||
emitApiRequest({
|
||||
provider: telemetry.provider ?? "aws",
|
||||
service: telemetry.service,
|
||||
method,
|
||||
path: endpoint,
|
||||
statusCode,
|
||||
latencyMs: Math.round(performance.now() - started),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +237,6 @@ export class HttpClient {
|
||||
...endpoint.defaults,
|
||||
...options,
|
||||
baseUrlOverride: endpoint.baseUrlOverride,
|
||||
telemetry: {
|
||||
...endpoint.telemetry,
|
||||
...options.telemetry,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,13 @@ export const apiEndpointKeys = {
|
||||
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
|
||||
createEventBridgeRule: "clouds.services.resources.events.rule.create",
|
||||
listEventBridgeRules: "clouds.services.resources.events.rules.list",
|
||||
deleteEventBridgeRule: "clouds.services.resources.events.rule.delete",
|
||||
deleteEventBridgeRule: "clouds.services.resources.events.rule.delete",
|
||||
putEventBridgeEvent: "clouds.services.resources.events.event.put",
|
||||
listCloudFormationChangeSets: "clouds.services.resources.iac.change-sets.list",
|
||||
createCloudFormationChangeSet: "clouds.services.resources.iac.change-set.create",
|
||||
executeCloudFormationChangeSet: "clouds.services.resources.iac.change-set.execute",
|
||||
deleteCloudFormationChangeSet: "clouds.services.resources.iac.change-set.delete",
|
||||
updateCloudFormationTerminationProtection: "clouds.services.resources.iac.termination-protection.update",
|
||||
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
|
||||
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
|
||||
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
|
||||
@@ -79,10 +85,6 @@ export const apiEndpointKeys = {
|
||||
query: "clouds.services.nosql.cosmos.items.query",
|
||||
},
|
||||
},
|
||||
items: {
|
||||
list: "clouds.services.nosql.items.list",
|
||||
put: "clouds.services.nosql.items.put",
|
||||
},
|
||||
},
|
||||
database: {
|
||||
sql: {
|
||||
@@ -100,6 +102,11 @@ export const apiEndpointKeys = {
|
||||
},
|
||||
logs: {
|
||||
query: "clouds.services.logs.query",
|
||||
filter: "clouds.services.logs.filter",
|
||||
},
|
||||
messaging: {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
@@ -126,6 +133,7 @@ export const apiEndpointKeys = {
|
||||
},
|
||||
flatItems: {
|
||||
list: "clouds.services.flatItems.list",
|
||||
query: "clouds.services.flatItems.query",
|
||||
create: "clouds.services.flatItems.create",
|
||||
delete: "clouds.services.flatItems.delete",
|
||||
},
|
||||
@@ -276,7 +284,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds",
|
||||
method: "GET",
|
||||
telemetry: { provider: "system", service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -284,7 +291,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -292,7 +298,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/status",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -300,7 +305,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/status",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -308,7 +312,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/schema",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -316,7 +319,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/dependencies",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -324,7 +326,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -332,7 +333,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -340,7 +340,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/routes",
|
||||
method: "POST",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -348,7 +347,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/publish",
|
||||
method: "POST",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -356,7 +354,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
|
||||
method: "POST",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -364,7 +361,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
|
||||
method: "GET",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -372,7 +368,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -380,7 +375,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/routes",
|
||||
method: "GET",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -388,7 +382,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -396,7 +389,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/routes/test",
|
||||
method: "POST",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -404,7 +396,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/methods/test",
|
||||
method: "POST",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -412,7 +403,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/cors",
|
||||
method: "POST",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -420,7 +410,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
|
||||
method: "POST",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -428,7 +417,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
|
||||
method: "GET",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -436,7 +424,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -444,7 +431,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/website",
|
||||
method: "GET",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -452,7 +438,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/website",
|
||||
method: "PUT",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -460,7 +445,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/website",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "storage" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -468,7 +452,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
|
||||
method: "POST",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -476,7 +459,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
|
||||
method: "GET",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -484,7 +466,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -492,7 +473,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules",
|
||||
method: "POST",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -500,7 +480,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules",
|
||||
method: "GET",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -508,15 +487,25 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules/:ruleName",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.putEventBridgeEvent,
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/events",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.resources.listCloudFormationChangeSets, {path: "/clouds/:cloud/services/iac/resources/:id/change-sets", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.resources.createCloudFormationChangeSet, {path: "/clouds/:cloud/services/iac/resources/:id/change-sets", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.resources.executeCloudFormationChangeSet, {path: "/clouds/:cloud/services/iac/resources/:id/change-sets/:name/execute", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.resources.deleteCloudFormationChangeSet, {path: "/clouds/:cloud/services/iac/resources/:id/change-sets/:name", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.resources.updateCloudFormationTerminationProtection, {path: "/clouds/:cloud/services/iac/resources/:id/termination-protection", method: "PUT"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
|
||||
method: "POST",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -524,7 +513,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
|
||||
method: "GET",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -532,35 +520,33 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.startWorkflowExecution,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST", telemetry: { service: "workflows" } },
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST" },
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.listWorkflowExecutions,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "GET", telemetry: { service: "workflows" } },
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "GET" },
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.describeWorkflowExecution,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn", method: "GET", telemetry: { service: "workflows" } },
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn", method: "GET" },
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.getWorkflowExecutionHistory,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/history", method: "GET", telemetry: { service: "workflows" } },
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/history", method: "GET" },
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.stopWorkflowExecution,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST", telemetry: { service: "workflows" } },
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST" },
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -568,7 +554,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/kms/resources/:id/encrypt",
|
||||
method: "POST",
|
||||
telemetry: { service: "kms" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -576,7 +561,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/kms/resources/:id/decrypt",
|
||||
method: "POST",
|
||||
telemetry: { service: "kms" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -584,7 +568,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -592,7 +575,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "PATCH",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -600,7 +582,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -609,7 +590,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -618,7 +598,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/objects",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -626,7 +605,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "PUT",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -634,7 +612,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -642,7 +619,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -650,7 +626,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object/copy",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -658,7 +633,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -666,7 +640,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -674,7 +647,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -682,7 +654,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -690,7 +661,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -698,7 +668,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -706,7 +675,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -714,7 +682,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/databases",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -722,7 +689,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/tables",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -730,7 +696,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -738,15 +703,25 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/logs/resources/:id/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.logs.filter,
|
||||
{
|
||||
path: "/clouds/:cloud/services/logs/resources/:id/filter",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.messaging.messages.send, {path: "/clouds/:cloud/services/messaging/resources/:id/messages", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.messaging.messages.receive, {path: "/clouds/:cloud/services/messaging/resources/:id/messages/receive", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.messaging.messages.delete, {path: "/clouds/:cloud/services/messaging/resources/:id/messages/delete", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.messaging.messages.purge, {path: "/clouds/:cloud/services/messaging/resources/:id/messages", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.messaging.moveTasks, {path: "/clouds/:cloud/services/messaging/resources/:id/move-tasks", method: "POST"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.snapshots.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/snapshots",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -754,7 +729,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/snapshots",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -762,23 +736,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/orderable-classes",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.items.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.items.put,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/items",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -786,7 +743,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -794,7 +750,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -802,7 +757,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups/:nodegroupId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -810,7 +764,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -818,7 +771,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -826,7 +778,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles/:profileId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -834,7 +785,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/email/inbox",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -842,7 +792,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -850,7 +799,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -858,7 +806,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -866,7 +813,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -874,7 +820,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments/:deploymentNumber",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -882,7 +827,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -890,7 +834,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -898,7 +841,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -906,7 +848,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -914,7 +855,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -922,7 +862,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -930,7 +869,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -938,7 +876,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -946,7 +883,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -954,7 +890,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies/:strategyId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -963,7 +898,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/collections",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -971,7 +905,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/collections/:cid/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -979,7 +912,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.flatItems.query,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/query",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -987,7 +926,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/items",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -995,7 +933,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
// AWS EKS
|
||||
@@ -1004,7 +941,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1012,7 +948,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1020,7 +955,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1028,7 +962,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups/:nodegroup",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1036,7 +969,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1044,7 +976,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups/:nodegroup",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1052,7 +983,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1060,7 +990,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles/:profile",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1068,7 +997,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1076,7 +1004,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles/:profile",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1086,7 +1013,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secrets",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1094,7 +1020,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secrets",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1102,7 +1027,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secret",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1110,7 +1034,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secret",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1118,7 +1041,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secret/value",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1126,65 +1048,64 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
{
|
||||
path: "/secretsmanager/secret/value",
|
||||
method: "PUT",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
|
||||
// AWS EC2
|
||||
[apiEndpointKeys.aws.ec2.instances.list, { path: "/ec2/instances", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.describe, { path: "/ec2/instances/:instanceId", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.create, { path: "/ec2/instances", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.start, { path: "/ec2/instances/:instanceId/start", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.stop, { path: "/ec2/instances/:instanceId/stop", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.create, { path: "/ec2/key-pairs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.delete, { path: "/ec2/key-pairs/:name", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.list, { path: "/ec2/security-groups", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.create, { path: "/ec2/security-groups", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.delete, { path: "/ec2/security-groups/:groupId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorize, { path: "/ec2/security-groups/:groupId/ingress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revoke, { path: "/ec2/security-groups/:groupId/ingress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.list, { path: "/ec2/vpcs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.create, { path: "/ec2/vpcs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.delete, { path: "/ec2/vpcs/:vpcId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.getAttributes, { path: "/ec2/vpcs/:vpcId/attributes", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.modifyAttribute, { path: "/ec2/vpcs/:vpcId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.list, { path: "/ec2/subnets", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.create, { path: "/ec2/subnets", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.delete, { path: "/ec2/subnets/:subnetId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.modifyAttribute, { path: "/ec2/subnets/:subnetId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.list, { path: "/ec2/internet-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.create, { path: "/ec2/internet-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.attach, { path: "/ec2/internet-gateways/:igwId/attach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.detach, { path: "/ec2/internet-gateways/:igwId/detach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.delete, { path: "/ec2/internet-gateways/:igwId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.list, { path: "/ec2/nat-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.create, { path: "/ec2/nat-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.delete, { path: "/ec2/nat-gateways/:natId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.list, { path: "/ec2/route-tables", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.create, { path: "/ec2/route-tables", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.delete, { path: "/ec2/route-tables/:rtbId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.createRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.deleteRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.associate, { path: "/ec2/route-tables/:rtbId/associations", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.disassociate,{ path: "/ec2/route-table-associations/:associationId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.list, { path: "/ec2/elastic-ips", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.create, { path: "/ec2/elastic-ips", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.release, { path: "/ec2/elastic-ips/:allocationId/release", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.associate, { path: "/ec2/elastic-ips/:allocationId/associate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.disassociate, { path: "/ec2/elastic-ips/:allocationId/disassociate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.availabilityZones, { path: "/ec2/availability-zones", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instanceTypes, { path: "/ec2/instance-types", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcWizard, { path: "/ec2/vpc-wizard", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.list, { path: "/ec2/instances", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.describe, { path: "/ec2/instances/:instanceId", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.create, { path: "/ec2/instances", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.start, { path: "/ec2/instances/:instanceId/start", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.stop, { path: "/ec2/instances/:instanceId/stop", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.create, { path: "/ec2/key-pairs", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.delete, { path: "/ec2/key-pairs/:name", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.list, { path: "/ec2/security-groups", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.create, { path: "/ec2/security-groups", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.delete, { path: "/ec2/security-groups/:groupId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorize, { path: "/ec2/security-groups/:groupId/ingress", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revoke, { path: "/ec2/security-groups/:groupId/ingress", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.list, { path: "/ec2/vpcs", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.create, { path: "/ec2/vpcs", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.delete, { path: "/ec2/vpcs/:vpcId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.getAttributes, { path: "/ec2/vpcs/:vpcId/attributes", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.modifyAttribute, { path: "/ec2/vpcs/:vpcId/attributes", method: "PUT" }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.list, { path: "/ec2/subnets", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.create, { path: "/ec2/subnets", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.delete, { path: "/ec2/subnets/:subnetId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.modifyAttribute, { path: "/ec2/subnets/:subnetId/attributes", method: "PUT" }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.list, { path: "/ec2/internet-gateways", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.create, { path: "/ec2/internet-gateways", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.attach, { path: "/ec2/internet-gateways/:igwId/attach", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.detach, { path: "/ec2/internet-gateways/:igwId/detach", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.delete, { path: "/ec2/internet-gateways/:igwId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.list, { path: "/ec2/nat-gateways", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.create, { path: "/ec2/nat-gateways", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.delete, { path: "/ec2/nat-gateways/:natId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.list, { path: "/ec2/route-tables", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.create, { path: "/ec2/route-tables", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.delete, { path: "/ec2/route-tables/:rtbId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.createRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.deleteRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.associate, { path: "/ec2/route-tables/:rtbId/associations", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.disassociate,{ path: "/ec2/route-table-associations/:associationId", method: "DELETE" }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.list, { path: "/ec2/elastic-ips", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.create, { path: "/ec2/elastic-ips", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.release, { path: "/ec2/elastic-ips/:allocationId/release", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.associate, { path: "/ec2/elastic-ips/:allocationId/associate", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.disassociate, { path: "/ec2/elastic-ips/:allocationId/disassociate", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.availabilityZones, { path: "/ec2/availability-zones", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.instanceTypes, { path: "/ec2/instance-types", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.vpcWizard, { path: "/ec2/vpc-wizard", method: "POST" }],
|
||||
|
||||
]);
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ import type {
|
||||
KubernetesNodegroup,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
NoSqlItem,
|
||||
LogsFilterInput,
|
||||
SqsMessage,
|
||||
SqsSendMessageInput,
|
||||
SqlCredentials,
|
||||
SqlDatabase,
|
||||
SqlEngine,
|
||||
@@ -257,6 +259,8 @@ export interface WorkflowExecutionHistoryPage {
|
||||
|
||||
export interface CreateApiGatewayLambdaRouteInput {
|
||||
path: string;
|
||||
parentResourceId?: string;
|
||||
pathPart?: string;
|
||||
method: string;
|
||||
stage: string;
|
||||
functionName: string;
|
||||
@@ -311,7 +315,16 @@ export interface ApiGatewayRouteOverview {
|
||||
integrationType: string | null;
|
||||
}
|
||||
|
||||
export interface ApiGatewayResourceOverview {
|
||||
resourceId: string;
|
||||
parentId: string | null;
|
||||
path: string;
|
||||
pathPart: string | null;
|
||||
methods: ApiGatewayRouteOverview[];
|
||||
}
|
||||
|
||||
export interface ApiGatewayRoutesResult {
|
||||
resources: ApiGatewayResourceOverview[];
|
||||
routes: ApiGatewayRouteOverview[];
|
||||
stages: string[];
|
||||
}
|
||||
@@ -401,6 +414,20 @@ export interface EventBridgeRuleOverview extends CreateEventBridgeRuleInput {
|
||||
state: "ENABLED" | "DISABLED";
|
||||
}
|
||||
|
||||
export interface PutEventBridgeEventInput {
|
||||
source: string;
|
||||
detailType: string;
|
||||
detail: string;
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
export interface PutEventBridgeEventResult {
|
||||
eventId: string | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
export interface CloudFormationChangeSet { name: string; id: string; status: string | null; executionStatus: string | null; changes: Array<{action: string | null; logicalResourceId: string | null; resourceType: string | null}> }
|
||||
|
||||
export interface CreateSqsLambdaEventSourceMappingInput {
|
||||
queueArn: string;
|
||||
batchSize?: number;
|
||||
@@ -697,6 +724,26 @@ export async function deleteEventBridgeRule(
|
||||
);
|
||||
}
|
||||
|
||||
export async function putEventBridgeEvent(
|
||||
cloud: CloudProvider,
|
||||
eventBusId: string,
|
||||
input: PutEventBridgeEventInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PutEventBridgeEventResult> {
|
||||
const res = await apiClient.call<PutEventBridgeEventResult, PutEventBridgeEventInput>(
|
||||
apiEndpointKeys.clouds.resources.putEventBridgeEvent,
|
||||
requestOptions(cloud, "events", {signal, body: input}),
|
||||
{cloud, id: eventBusId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCloudFormationChangeSets(cloud: CloudProvider, stackName: string, signal?: AbortSignal): Promise<CloudFormationChangeSet[]> { const res = await apiClient.call<CloudFormationChangeSet[]>(apiEndpointKeys.clouds.resources.listCloudFormationChangeSets, requestOptions(cloud, 'iac', {signal}), {cloud, id: stackName}); return res.data }
|
||||
export async function createCloudFormationChangeSet(cloud: CloudProvider, stackName: string, input: {name: string; templateBody: string; parameters?: Record<string, string>; capabilities?: string[]}): Promise<CloudFormationChangeSet> { const res = await apiClient.call<CloudFormationChangeSet, typeof input>(apiEndpointKeys.clouds.resources.createCloudFormationChangeSet, requestOptions(cloud, 'iac', {body: input}), {cloud, id: stackName}); return res.data }
|
||||
export async function executeCloudFormationChangeSet(cloud: CloudProvider, stackName: string, name: string): Promise<void> { await apiClient.call<void>(apiEndpointKeys.clouds.resources.executeCloudFormationChangeSet, requestOptions(cloud, 'iac', {}), {cloud, id: stackName, name}) }
|
||||
export async function deleteCloudFormationChangeSet(cloud: CloudProvider, stackName: string, name: string): Promise<void> { await apiClient.call<void>(apiEndpointKeys.clouds.resources.deleteCloudFormationChangeSet, requestOptions(cloud, 'iac', {}), {cloud, id: stackName, name}) }
|
||||
export async function updateCloudFormationTerminationProtection(cloud: CloudProvider, stackName: string, enabled: boolean): Promise<void> { await apiClient.call<void, {enabled: boolean}>(apiEndpointKeys.clouds.resources.updateCloudFormationTerminationProtection, requestOptions(cloud, 'iac', {body: {enabled}}), {cloud, id: stackName}) }
|
||||
|
||||
export async function createSqsLambdaEventSourceMapping(
|
||||
cloud: CloudProvider,
|
||||
functionName: string,
|
||||
@@ -1107,6 +1154,23 @@ export async function listFlatItems(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function queryFlatItems(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
resourceId: string,
|
||||
query: Record<string, unknown>,
|
||||
cursor?: string,
|
||||
limit = 100,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectionPage<ChildItem>> {
|
||||
const res = await apiClient.call<CollectionPage<ChildItem>, {query: Record<string, unknown>}>(
|
||||
apiEndpointKeys.clouds.flatItems.query,
|
||||
requestOptions(cloud, service, {signal, params: {cursor, limit}, body: {query}}),
|
||||
{cloud, service, id: resourceId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
@@ -1215,6 +1279,44 @@ export async function queryLogs(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function filterLogEvents(
|
||||
cloud: CloudProvider,
|
||||
logGroupName: string,
|
||||
input: LogsFilterInput,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectionPage<ChildItem>> {
|
||||
const res = await apiClient.call<CollectionPage<ChildItem>, LogsFilterInput>(
|
||||
apiEndpointKeys.clouds.logs.filter,
|
||||
requestOptions(cloud, "logs", { signal, body: input, params: cursor ? { cursor } : undefined }),
|
||||
{ cloud, id: logGroupName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function sendSqsMessage(cloud: CloudProvider, queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
|
||||
const res = await apiClient.call<{messageId: string}, SqsSendMessageInput>(apiEndpointKeys.clouds.messaging.messages.send, requestOptions(cloud, 'messaging', {body: input}), {cloud, id: queueId})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function receiveSqsMessages(cloud: CloudProvider, queueId: string, maxNumberOfMessages: number): Promise<SqsMessage[]> {
|
||||
const res = await apiClient.call<SqsMessage[], {maxNumberOfMessages: number}>(apiEndpointKeys.clouds.messaging.messages.receive, requestOptions(cloud, 'messaging', {body: {maxNumberOfMessages}}), {cloud, id: queueId})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteSqsMessage(cloud: CloudProvider, queueId: string, receiptHandle: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.clouds.messaging.messages.delete, requestOptions(cloud, 'messaging', {body: {receiptHandle}}), {cloud, id: queueId})
|
||||
}
|
||||
|
||||
export async function purgeSqsQueue(cloud: CloudProvider, queueId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.clouds.messaging.messages.purge, requestOptions(cloud, 'messaging'), {cloud, id: queueId})
|
||||
}
|
||||
|
||||
export async function startSqsMoveTask(cloud: CloudProvider, queueId: string, destinationArn?: string): Promise<{taskHandle: string}> {
|
||||
const res = await apiClient.call<{taskHandle: string}, {destinationArn?: string}>(apiEndpointKeys.clouds.messaging.moveTasks, requestOptions(cloud, 'messaging', {body: {destinationArn}}), {cloud, id: queueId})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listDatabaseSnapshots(
|
||||
cloud: CloudProvider,
|
||||
instanceIdentifier?: string,
|
||||
@@ -1260,33 +1362,6 @@ export async function listDatabaseOrderableClasses(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listNoSqlItems(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NoSqlItem[]> {
|
||||
const res = await apiClient.call<NoSqlItem[]>(
|
||||
apiEndpointKeys.clouds.nosql.items.list,
|
||||
requestOptions(cloud, "nosql", { signal }),
|
||||
{ cloud, id: resourceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putNoSqlItem(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
document: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NoSqlItem> {
|
||||
const res = await apiClient.call<NoSqlItem, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.nosql.items.put,
|
||||
requestOptions(cloud, "nosql", { signal, body: document }),
|
||||
{ cloud, id: resourceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listKubernetesNodegroups(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
@@ -1560,8 +1635,8 @@ export async function getAppConfigDeployment(
|
||||
}
|
||||
|
||||
function requestOptions<TBody = unknown>(
|
||||
cloud: CloudProvider,
|
||||
service: string,
|
||||
_cloud: CloudProvider,
|
||||
_service: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
@@ -1574,7 +1649,6 @@ function requestOptions<TBody = unknown>(
|
||||
) {
|
||||
return {
|
||||
...options,
|
||||
telemetry: { provider: cloud, service },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { apiClient } from "./api";
|
||||
import type { HttpMethod } from "./HttpClient";
|
||||
import {
|
||||
subscribeApiRequests,
|
||||
type ApiRequestEvent,
|
||||
} from "@/telemetry";
|
||||
|
||||
export type FlociRequestEvent = ApiRequestEvent;
|
||||
|
||||
export function subscribeRequests(
|
||||
cb: (event: FlociRequestEvent) => void,
|
||||
): () => void {
|
||||
return subscribeApiRequests(cb);
|
||||
}
|
||||
|
||||
export function apiGet<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("GET", path, service, undefined, signal);
|
||||
}
|
||||
|
||||
export function apiPost<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("POST", path, service, body, signal);
|
||||
}
|
||||
|
||||
export function apiPut<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("PUT", path, service, body, signal);
|
||||
}
|
||||
|
||||
export function apiDelete<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("DELETE", path, service, undefined, signal);
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
service: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await apiClient.request<T>(method, path, {
|
||||
signal,
|
||||
...(body === undefined ? {} : { body }),
|
||||
telemetry: { provider: "aws", service },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
apiRequestEventBus,
|
||||
emitApiRequest,
|
||||
subscribeApiRequests,
|
||||
} from "@/telemetry/requestEventBus";
|
||||
export type { ApiRequestEvent, CloudProvider } from "@/telemetry/types";
|
||||
@@ -0,0 +1,17 @@
|
||||
# AWS Architecture Icons
|
||||
|
||||
The SVG files in this directory are AWS Architecture Icons provided by Amazon
|
||||
Web Services. They are used to identify AWS services in the Floci UI.
|
||||
|
||||
Source and usage guidelines:
|
||||
https://aws.amazon.com/architecture/icons/
|
||||
|
||||
AWS and the AWS service names and icons are trademarks of Amazon.com, Inc. or
|
||||
its affiliates. Their inclusion does not imply sponsorship or endorsement.
|
||||
|
||||
The source pack did not include a license or notice file. Only the service icons
|
||||
used by Floci UI were copied here, with normalized filenames. The generic Elastic
|
||||
Load Balancing, CloudWatch, and Systems Manager icons represent Application Load
|
||||
Balancer, CloudWatch Logs, and Parameter Store where the pack has no dedicated
|
||||
service icon. Services without matching artwork continue to use the documented
|
||||
Lucide fallback.
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Icon-Architecture/64/Arch_ Amazon-API-Gateway_64</title>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_-Amazon-API-Gateway_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Rectangle" fill="url(#linearGradient-1)">
|
||||
<rect x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<g id="Icon-Service/64/Amazon-API-Gateway_64" transform="translate(8.000000, 8.000000)" fill="#FFFFFF">
|
||||
<path d="M26.065,47.6323642 L29,47.6323642 L29,45.6152989 L26.065,45.6152989 L26.065,47.6323642 Z M31,47.6323642 L34,47.6323642 L34,45.6152989 L31,45.6152989 L31,47.6323642 Z M19,6.62946112 L6,13.082053 L6,53.1934132 L19,57.589607 L19,6.62946112 Z M21,19.3934502 L21,45.6152989 L24,45.6152989 L24,47.6323642 L21,47.6323642 L21,58.9914674 C21,59.3162149 20.845,59.6207917 20.584,59.8103959 C20.412,59.9354539 20.207,60 20,60 C19.894,60 19.786,59.9828549 19.682,59.9475563 L4.682,54.8756456 C4.274,54.7374767 4,54.3522172 4,53.9195567 L4,12.4537371 C4,12.0704947 4.217,11.7185168 4.559,11.5490833 L19.559,4.10409539 C19.868,3.94978989 20.235,3.96794348 20.528,4.15250495 C20.821,4.33605789 21,4.65979687 21,5.00874917 L21,17.376385 L24,17.376385 L24,19.3934502 L21,19.3934502 Z M36,47.6323642 L39,47.6323642 L39,45.6152989 L36,45.6152989 L36,47.6323642 Z M36.065,19.3934502 L39,19.3934502 L39,17.376385 L36.065,17.376385 L36.065,19.3934502 Z M31.065,19.3934502 L34,19.3934502 L34,17.376385 L31.065,17.376385 L31.065,19.3934502 Z M26.065,19.3934502 L29,19.3934502 L29,17.376385 L26.065,17.376385 L26.065,19.3934502 Z M58,13.082053 L45,6.62946112 L45,57.589607 L58,53.1934132 L58,13.082053 Z M60,53.9195567 C60,54.3522172 59.726,54.7374767 59.318,54.8756456 L44.318,59.9475563 C44.214,59.9828549 44.106,60 44,60 C43.793,60 43.588,59.9354539 43.416,59.8103959 C43.155,59.6207917 43,59.3162149 43,58.9914674 L43,47.6323642 L41.065,47.6323642 L41.065,45.6152989 L43,45.6152989 L43,19.3934502 L41.065,19.3934502 L41.065,17.376385 L43,17.376385 L43,5.00874917 C43,4.65979687 43.179,4.33605789 43.472,4.15250495 C43.765,3.96794348 44.131,3.94978989 44.441,4.10409539 L59.441,11.5490833 C59.783,11.7185168 60,12.0704947 60,12.4537371 L60,53.9195567 Z M34.934,25.8067093 L33.066,25.0825829 L28.066,38.1935072 L29.934,38.9176337 L34.934,25.8067093 Z M41.707,32.2088745 C42.098,31.8145383 42.098,31.1761371 41.707,30.7828094 L37.707,26.7486788 L36.293,28.174744 L39.586,31.4958419 L36.293,34.8169399 L37.707,36.2430051 L41.707,32.2088745 Z M26.293,36.2430051 L22.293,32.2088745 C21.902,31.8145383 21.902,31.1761371 22.293,30.7828094 L26.293,26.7486788 L27.707,28.174744 L24.414,31.4958419 L27.707,34.8169399 L26.293,36.2430051 Z" id="Amazon-API-Gateway_Icon_64_Squid"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-App-Config_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-App-Config_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Management-Governance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M50.871,37.366 C50.871,42.053 47.058,45.866 42.371,45.866 C37.684,45.866 33.871,42.053 33.871,37.366 C33.871,32.679 37.684,28.866 42.371,28.866 C47.058,28.866 50.871,32.679 50.871,37.366 L50.871,37.366 Z M52.871,37.366 C52.871,31.576 48.161,26.866 42.371,26.866 C36.581,26.866 31.871,31.576 31.871,37.366 C31.871,43.156 36.581,47.866 42.371,47.866 C48.161,47.866 52.871,43.156 52.871,37.366 L52.871,37.366 Z M47.91,62.418 C48.1,62.218 48.197,61.949 48.182,61.674 L47.999,58.473 C49.846,57.979 51.611,57.248 53.265,56.291 L55.4,58.685 C55.583,58.89 55.843,59.011 56.118,59.019 C56.394,59.042 56.659,58.921 56.853,58.726 L63.702,51.878 C63.896,51.683 64.003,51.417 63.995,51.142 C63.987,50.867 63.866,50.608 63.661,50.425 L61.259,48.283 C62.214,46.631 62.944,44.868 63.437,43.023 L66.649,43.207 C66.934,43.223 67.193,43.124 67.393,42.936 C67.594,42.747 67.707,42.484 67.707,42.209 L67.707,32.524 C67.707,32.249 67.594,31.986 67.393,31.797 C67.194,31.608 66.921,31.51 66.65,31.525 L63.434,31.708 C62.941,29.867 62.211,28.106 61.255,26.453 L63.661,24.307 C63.866,24.124 63.987,23.865 63.995,23.589 C64.003,23.314 63.896,23.048 63.702,22.854 L56.853,16.006 C56.659,15.81 56.392,15.696 56.118,15.713 C55.843,15.721 55.584,15.842 55.4,16.047 L53.247,18.46 C51.597,17.505 49.838,16.776 47.999,16.284 L48.182,13.057 C48.197,12.783 48.1,12.513 47.91,12.314 C47.722,12.114 47.459,12 47.183,12 L37.499,12 C37.224,12 36.961,12.114 36.772,12.314 C36.583,12.513 36.485,12.783 36.501,13.057 L36.683,16.282 C34.844,16.773 33.084,17.502 31.432,18.456 L29.282,16.047 C29.099,15.842 28.84,15.721 28.564,15.713 C28.295,15.698 28.023,15.811 27.829,16.006 L20.98,22.854 C20.786,23.048 20.68,23.314 20.687,23.589 C20.695,23.864 20.816,24.124 21.021,24.307 L23.433,26.458 C22.476,28.109 21.745,29.869 21.251,31.708 L18.032,31.525 C17.759,31.509 17.488,31.608 17.289,31.797 C17.089,31.986 16.975,32.249 16.975,32.524 L16.975,42.209 C16.975,42.484 17.089,42.747 17.289,42.936 C17.489,43.125 17.761,43.229 18.034,43.207 L21.985,42.976 L21.868,40.979 L18.975,41.149 L18.975,33.582 L21.966,33.752 C22.454,33.776 22.889,33.451 22.998,32.975 C23.496,30.783 24.358,28.704 25.563,26.799 C25.824,26.386 25.748,25.844 25.384,25.518 L23.143,23.519 L28.495,18.168 L30.492,20.407 C30.817,20.771 31.359,20.846 31.771,20.586 C33.678,19.385 35.756,18.525 37.949,18.03 C38.427,17.922 38.756,17.485 38.727,16.997 L38.558,14 L46.125,14 L45.955,16.999 C45.927,17.487 46.256,17.924 46.732,18.032 C48.925,18.527 51.002,19.388 52.906,20.591 C53.317,20.849 53.86,20.776 54.186,20.411 L56.187,18.168 L61.539,23.519 L59.304,25.513 C58.939,25.839 58.863,26.38 59.124,26.793 C60.328,28.7 61.19,30.78 61.686,32.974 C61.795,33.451 62.237,33.776 62.719,33.752 L65.707,33.582 L65.707,41.15 L62.723,40.979 C62.237,40.954 61.798,41.28 61.689,41.757 C61.192,43.956 60.331,46.037 59.128,47.942 C58.867,48.355 58.943,48.897 59.308,49.222 L61.539,51.212 L56.188,56.564 L54.204,54.34 C53.878,53.973 53.336,53.899 52.924,54.16 C51.017,55.365 48.934,56.228 46.732,56.725 C46.256,56.833 45.927,57.27 45.955,57.758 L46.125,60.731 L38.591,60.731 L38.727,59.185 L36.735,59.009 L36.503,61.643 C36.478,61.923 36.572,62.199 36.762,62.407 C36.951,62.614 37.219,62.731 37.499,62.731 L47.183,62.731 C47.459,62.731 47.722,62.618 47.91,62.418 L47.91,62.418 Z M37.101,54.554 L35.726,53.102 L23.215,64.956 L24.59,66.408 L37.101,54.554 Z M25.246,46.654 L23.875,45.198 L12.68,55.735 L14.051,57.191 L25.246,46.654 Z M13.414,67.706 L12,66.292 L31.756,46.536 L33.17,47.95 L13.414,67.706 Z" id="AWS-App-Config_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-CloudFormation_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-CloudFormation_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Management-Governance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M53,39.9632039 L58,39.9632039 L58,37.9601375 L53,37.9601375 L53,39.9632039 Z M28,51.9816019 L33,51.9816019 L33,49.9785356 L28,49.9785356 L28,51.9816019 Z M18,51.9816019 L25,51.9816019 L25,49.9785356 L18,49.9785356 L18,51.9816019 Z M18,45.9724029 L30,45.9724029 L30,43.9693366 L18,43.9693366 L18,45.9724029 Z M18,33.9540048 L27,33.9540048 L27,31.9509385 L18,31.9509385 L18,33.9540048 Z M18,39.9632039 L51,39.9632039 L51,37.9601375 L18,37.9601375 L18,39.9632039 Z M37,61.9969337 L14,61.9969337 L14,27.9448058 L37,27.9448058 L37,35.9570712 L39,35.9570712 L39,26.9432726 C39,26.3904263 38.552,25.9417395 38,25.9417395 L13,25.9417395 C12.447,25.9417395 12,26.3904263 12,26.9432726 L12,62.9984668 C12,63.5513131 12.447,64 13,64 L38,64 C38.552,64 39,63.5513131 39,62.9984668 L39,42.9678034 L37,42.9678034 L37,61.9969337 Z M68,36.9586044 C68,43.4305117 62.173,45.6819583 59.092,45.9683968 L43,45.9724029 L43,43.9693366 L59,43.9693366 C59.195,43.9463013 66,43.2121775 66,36.9586044 C66,31.2638867 60.863,30.1081175 59.834,29.9338507 C59.321,29.8467173 58.96,29.3820059 59.004,28.8632117 C59.005,28.8441826 59.007,28.826155 59.009,28.8081274 C58.954,25.5902013 56.981,24.584662 56.126,24.3002266 C54.53,23.769414 52.751,24.2771913 51.81,25.5391231 C51.591,25.8355769 51.229,25.9868085 50.861,25.9307226 C50.497,25.8756383 50.192,25.625255 50.068,25.2767214 C49.447,23.5360568 48.546,22.4083304 47.293,21.1534094 C44.159,18.0386412 39.905,17.1783242 35.925,18.8528877 C33.837,19.7332353 32.012,21.7282894 30.922,24.327268 L29.078,23.5500782 C30.37,20.4743699 32.584,18.0887179 35.15,17.007062 C39.905,15.0049972 44.971,16.0255595 48.704,19.7342369 C49.774,20.8068789 50.66,21.851478 51.35,23.2035478 C52.843,22.0978551 54.857,21.7673492 56.757,22.3993166 C59.189,23.2085554 60.727,25.3207889 60.975,28.1290879 C64.381,28.9884034 68,31.7115721 68,36.9586044 L68,36.9586044 Z" id="AWS-CloudFormation_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-CloudWatch_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-CloudWatch_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Management-Governance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M55.0592315,46.7773707 C55.0592315,42.8680281 51.8575588,39.6876305 47.9220646,39.6876305 C43.9865705,39.6876305 40.785903,42.8680281 40.785903,46.7773707 C40.785903,50.6867133 43.9865705,53.8671108 47.9220646,53.8671108 C51.8575588,53.8671108 55.0592315,50.6867133 55.0592315,46.7773707 M57.0697011,46.7773707 C57.0697011,51.7881194 52.9663327,55.8642207 47.9220646,55.8642207 C42.8788018,55.8642207 38.7754334,51.7881194 38.7754334,46.7773707 C38.7754334,41.7666219 42.8788018,37.6905206 47.9220646,37.6905206 C52.9663327,37.6905206 57.0697011,41.7666219 57.0697011,46.7773707 M65.5096522,60.4735504 L58.5011554,54.2026253 C57.9352082,54.9944794 57.2808004,55.7174332 56.5540156,56.3634982 L63.5524601,62.6334248 C64.1495696,63.1686502 65.0784065,63.1187225 65.6182176,62.5255808 C66.155013,61.9324392 66.1067617,61.010773 65.5096522,60.4735504 M47.9220646,57.6616197 C53.9645309,57.6616197 58.8801289,52.7786859 58.8801289,46.7773707 C58.8801289,40.7750569 53.9645309,35.8931217 47.9220646,35.8931217 C41.8806036,35.8931217 36.9650056,40.7750569 36.9650056,46.7773707 C36.9650056,52.7786859 41.8806036,57.6616197 47.9220646,57.6616197 M67.1119965,63.8626459 C66.4264264,64.6165549 65.47849,65 64.5285431,65 C63.7002296,65 62.8699057,64.708422 62.207456,64.1172774 L54.9305615,57.5987107 C52.9070239,58.8968321 50.505518,59.6587296 47.9220646,59.6587296 C40.7718297,59.6587296 34.9545361,53.8800921 34.9545361,46.7773707 C34.9545361,39.6746493 40.7718297,33.8960118 47.9220646,33.8960118 C55.0733048,33.8960118 60.8905985,39.6746493 60.8905985,46.7773707 C60.8905985,48.8154213 60.3990387,50.7366411 59.5465996,52.4511599 L66.8556616,58.9906963 C68.2750531,60.265851 68.3896499,62.4496907 67.1119965,63.8626459 M21.2803274,29.392529 C21.2803274,29.9117776 21.3124949,30.429029 21.3738143,30.9293051 C21.4089975,31.2138932 21.3205368,31.4984814 21.1295422,31.7131707 C20.9777518,31.8839236 20.7736891,31.9967603 20.550527,32.0347054 C18.0786547,32.6687878 14.0104695,34.5880104 14.0104695,40.3456782 C14.0104695,44.6933865 16.4240382,47.0929141 18.4495863,48.3411077 C19.1411878,48.7744806 19.9594489,49.0051468 20.8229456,49.0141338 L32.9450717,49.0251179 L32.9430613,51.0222278 L20.811888,51.0112437 C19.5664021,50.9982625 18.384246,50.6607509 17.3840374,50.0346569 C15.3765836,48.7974474 12,45.8896553 12,40.3456782 C12,33.66235 16.5999543,31.191925 19.3000149,30.319188 C19.2799102,30.0116331 19.2698579,29.702081 19.2698579,29.392529 C19.2698579,23.9324305 22.9982737,18.2696254 27.9420183,16.2215892 C33.7241287,13.8150717 39.8500294,15.0083449 44.3263399,19.4109737 C45.7135638,20.7749998 46.8545053,22.4316024 47.7300648,24.3478294 C48.9061895,23.3802296 50.355738,22.8460027 51.8836949,22.8460027 C54.8863312,22.8460027 58.2659305,25.1097268 58.8680661,30.0605622 C61.6797078,30.7046302 67.6206453,32.9553731 67.6206453,40.422567 C67.6206453,43.4042521 66.6797455,45.8666886 64.8230769,47.7419748 L63.3896121,46.3410022 C64.8632863,44.8531553 65.6101757,42.8620367 65.6101757,40.422567 C65.6101757,33.891019 60.1055101,32.2663701 57.737177,31.8719409 C57.4677741,31.827006 57.2295334,31.6752256 57.0757325,31.4515493 C56.9259525,31.2358614 56.8686541,30.9712444 56.9138897,30.7146157 C56.5851779,26.6604826 54.1605516,24.8431126 51.8836949,24.8431126 C50.4472144,24.8431126 49.1001998,25.5381069 48.1874466,26.7503526 C47.9652897,27.0439277 47.6044105,27.193711 47.2344841,27.139789 C46.8695838,27.085867 46.5629872,26.8362283 46.4373329,26.4917268 C45.6140456,24.2260057 44.4278686,22.3207628 42.9119745,20.8309188 C39.0327735,17.0154404 33.7281496,15.9809374 28.7170543,18.0649216 C24.5463352,19.7924217 21.2803274,24.7672224 21.2803274,29.392529" id="Amazon-CloudWatch_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-EC2_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-EC2_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Compute" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M27,53 L52,53 L52,28 L27,28 L27,53 Z M54,28 L58,28 L58,30 L54,30 L54,34 L58,34 L58,36 L54,36 L54,39 L58,39 L58,41 L54,41 L54,45 L58,45 L58,47 L54,47 L54,51 L58,51 L58,53 L54,53 L54,53.136 C54,54.164 53.164,55 52.136,55 L52,55 L52,59 L50,59 L50,55 L46,55 L46,59 L44,59 L44,55 L41,55 L41,59 L39,59 L39,55 L35,55 L35,59 L33,59 L33,55 L29,55 L29,59 L27,59 L27,55 L26.864,55 C25.836,55 25,54.164 25,53.136 L25,53 L22,53 L22,51 L25,51 L25,47 L22,47 L22,45 L25,45 L25,41 L22,41 L22,39 L25,39 L25,36 L22,36 L22,34 L25,34 L25,30 L22,30 L22,28 L25,28 L25,27.864 C25,26.836 25.836,26 26.864,26 L27,26 L27,22 L29,22 L29,26 L33,26 L33,22 L35,22 L35,26 L39,26 L39,22 L41,22 L41,26 L44,26 L44,22 L46,22 L46,26 L50,26 L50,22 L52,22 L52,26 L52.136,26 C53.164,26 54,26.836 54,27.864 L54,28 Z M41,65.876 C41,65.944 40.944,66 40.876,66 L14.124,66 C14.056,66 14,65.944 14,65.876 L14,39.124 C14,39.056 14.056,39 14.124,39 L20,39 L20,37 L14.124,37 C12.953,37 12,37.953 12,39.124 L12,65.876 C12,67.047 12.953,68 14.124,68 L40.876,68 C42.047,68 43,67.047 43,65.876 L43,61 L41,61 L41,65.876 Z M68,14.124 L68,40.876 C68,42.047 67.047,43 65.876,43 L60,43 L60,41 L65.876,41 C65.944,41 66,40.944 66,40.876 L66,14.124 C66,14.056 65.944,14 65.876,14 L39.124,14 C39.056,14 39,14.056 39,14.124 L39,20 L37,20 L37,14.124 C37,12.953 37.953,12 39.124,12 L65.876,12 C67.047,12 68,12.953 68,14.124 L68,14.124 Z" id="Amazon-EC2_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Elastic-Container-Registry_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Elastic-Container-Registry_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Containers" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M38.7880741,37.5776945 C38.2933845,37.8655125 37.9855777,38.4000317 37.9855777,38.973662 L37.9855777,66.7195186 L16.9987459,54.9841655 L16.9987459,27.7076428 L40.5569643,14.0588515 L61.484833,24.4283226 L38.7880741,37.5776945 Z M63.9203049,24.4022485 C63.9203049,23.8276153 63.6134974,23.2920933 63.0598448,22.9751927 L41.3464689,12.2146099 C40.8507799,11.9287976 40.2351662,11.9277948 39.7404766,12.2156128 L15.8024965,26.0840283 C15.3078069,26.3718463 15,26.9063655 15,27.4799958 L15,55.2158239 C15,55.7894542 15.3078069,56.3239734 15.8174871,56.6198142 L37.573836,68.7853901 C37.8216805,68.9287976 38.1005056,69 38.3783312,69 C38.6551575,69 38.9339826,68.9287976 39.1808277,68.7853901 C39.6765167,68.4985749 39.9843236,67.9640557 39.9843236,67.3894226 L39.9843236,39.201309 L63.1178084,25.798216 C63.6134974,25.5114008 63.9203049,24.9768817 63.9203049,24.4022485 L63.9203049,24.4022485 Z M63.9692742,55.0473451 L44.9811883,66.6613533 L44.9811883,56.2617967 L54.881976,49.872638 L54.9499334,49.424364 C54.9679221,49.3070305 54.9749177,49.3070305 54.9749177,48.7664942 L55.0058983,36.3602344 L64.0002547,31.1484218 L63.9692742,55.0473451 Z M65.1965042,29.0715191 C64.6998158,28.7816953 64.0842021,28.7826982 63.5885131,29.0715191 L54.0135209,34.6182835 C53.713709,34.7817481 53.0081517,35.1658398 53.0081517,35.9580914 L52.9761718,48.7173546 L43.8508975,54.606091 C43.3062392,54.9310145 42.9824424,55.4454766 42.9824424,55.9890214 L42.9824424,67.3081917 C42.9824424,67.8748021 43.2872511,68.3912699 43.7979307,68.6871107 C44.0647633,68.8405468 44.361577,68.9187691 44.6573914,68.9187691 C44.948209,68.9187691 45.2390265,68.8445582 45.5018616,68.692125 L65.1675223,56.6639396 C65.6622119,56.3761216 65.9680201,55.8416024 65.9680201,55.2709807 L66,30.4664837 C66,29.8928534 65.6921931,29.3583342 65.1965042,29.0715191 L65.1965042,29.0715191 Z" id="Amazon-Elastic-Container-Registry_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Containers" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M64,48.2340095 L56,43.4330117 L56,32.0000169 C56,31.6440171 55.812,31.3150172 55.504,31.1360173 L44,24.4260204 L44,14.7520248 L64,26.5710194 L64,48.2340095 Z M65.509,25.13902 L43.509,12.139026 C43.199,11.9560261 42.818,11.9540261 42.504,12.131026 C42.193,12.3090259 42,12.6410257 42,13.0000256 L42,25.0000201 C42,25.3550199 42.189,25.6840198 42.496,25.8640197 L54,32.5740166 L54,44.0000114 C54,44.3510113 54.185,44.6770111 54.486,44.857011 L64.486,50.8570083 C64.644,50.9520082 64.822,51 65,51 C65.17,51 65.34,50.9570082 65.493,50.8700083 C65.807,50.6930084 66,50.3600085 66,50 L66,26.0000196 C66,25.6460198 65.814,25.31902 65.509,25.13902 L65.509,25.13902 Z M40.445,66.863001 L17,54.3990067 L17,26.5710194 L37,14.7520248 L37,24.4510204 L26.463,31.1560173 C26.175,31.3400172 26,31.6580171 26,32.0000169 L26,49.0000091 C26,49.373009 26.208,49.7150088 26.538,49.8870087 L39.991,56.8870055 C40.28,57.0370055 40.624,57.0380055 40.912,56.8880055 L53.964,50.1440086 L61.996,54.9640064 L40.445,66.863001 Z M64.515,54.1420068 L54.515,48.1420095 C54.217,47.9640096 53.849,47.9520096 53.541,48.1120095 L40.455,54.8730065 L28,48.3930094 L28,32.5490167 L38.537,25.8440197 C38.825,25.6600198 39,25.3420199 39,25.0000201 L39,13.0000256 C39,12.6410257 38.808,12.3090259 38.496,12.131026 C38.184,11.9540261 37.802,11.9560261 37.491,12.139026 L15.491,25.13902 C15.187,25.31902 15,25.6460198 15,26.0000196 L15,55 C15,55.3690062 15.204,55.7090061 15.53,55.883006 L39.984,68.8830001 C40.131,68.961 40.292,69 40.453,69 C40.62,69 40.786,68.958 40.937,68.8750001 L64.484,55.875006 C64.797,55.7020061 64.993,55.3750062 65.0001416,55.0180064 C65.006,54.6600066 64.821,54.3260067 64.515,54.1420068 L64.515,54.1420068 Z" id="Amazon-Elastic-Container-Service_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Elastic-Container-Kubernetes_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Elastic-Container-Kubernetes_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Containers" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M46.842,32.1990168 L40.013,40.1200132 L47.441,48.7840092 L44.669,48.7840092 L38,41.0000169 L38,49.0000169 L36,49.0000169 L36,32.0000169 L38,32.0000169 L38,39.0000169 L44.181,32.1990168 L46.842,32.1990168 Z M64,48.2340095 L56,43.4330117 L56,32.0000169 C56,31.6440171 55.812,31.3150172 55.504,31.1360173 L44,24.4260204 L44,14.7520248 L64,26.5710194 L64,48.2340095 Z M65.509,25.13902 L43.509,12.139026 C43.199,11.9560261 42.818,11.9540261 42.504,12.131026 C42.193,12.3090259 42,12.6410257 42,13.0000256 L42,25.0000201 C42,25.3550199 42.189,25.6840198 42.496,25.8640197 L54,32.5740166 L54,44.0000114 C54,44.3510113 54.185,44.6770111 54.486,44.857011 L64.486,50.8570083 C64.644,50.9520082 64.822,51 65,51 C65.17,51 65.34,50.9570082 65.493,50.8700083 C65.807,50.6930084 66,50.3600085 66,50 L66,26.0000196 C66,25.6460198 65.814,25.31902 65.509,25.13902 L65.509,25.13902 Z M40.445,66.863001 L17,54.3990067 L17,26.5710194 L37,14.7520248 L37,24.4510204 L26.463,31.1560173 C26.175,31.3400172 26,31.6580171 26,32.0000169 L26,49.0000091 C26,49.373009 26.208,49.7150088 26.538,49.8870087 L39.991,56.8870055 C40.28,57.0370055 40.624,57.0380055 40.912,56.8880055 L53.964,50.1440086 L61.996,54.9640064 L40.445,66.863001 Z M64.515,54.1420068 L54.515,48.1420095 C54.217,47.9640096 53.849,47.9520096 53.541,48.1120095 L40.455,54.8730065 L28,48.3930094 L28,32.5490167 L38.537,25.8440197 C38.825,25.6600198 39,25.3420199 39,25.0000201 L39,13.0000256 C39,12.6410257 38.808,12.3090259 38.496,12.131026 C38.184,11.9540261 37.802,11.9560261 37.491,12.139026 L15.491,25.13902 C15.187,25.31902 15,25.6460198 15,26.0000196 L15,55 C15,55.3690062 15.204,55.7090061 15.53,55.883006 L39.984,68.8830001 C40.131,68.961 40.292,69 40.453,69 C40.62,69 40.786,68.958 40.937,68.8750001 L64.484,55.875006 C64.797,55.7020061 64.993,55.3750062 65.0001416,55.0180064 C65.006,54.6600066 64.821,54.3260067 64.515,54.1420068 L64.515,54.1420068 Z" id="Amazon-Elastic-Container-Kubernetes_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Elastic-Load-Balancing_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#4D27A8" offset="0%"></stop>
|
||||
<stop stop-color="#A166FF" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Elastic-Load-Balancing_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Networking-Content-Delivery" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M30,53 C22.832,53 17,47.168 17,40 C17,32.832 22.832,27 30,27 C37.168,27 43,32.832 43,40 C43,47.168 37.168,53 30,53 M60,59 C60,61.206 58.206,63 56,63 C53.794,63 52,61.206 52,59 C52,56.794 53.794,55 56,55 C58.206,55 60,56.794 60,59 M56,17 C58.206,17 60,18.794 60,21 C60,23.206 58.206,25 56,25 C53.794,25 52,23.206 52,21 C52,18.794 53.794,17 56,17 M59,36 C61.206,36 63,37.794 63,40 C63,42.206 61.206,44 59,44 C56.794,44 55,42.206 55,40 C55,37.794 56.794,36 59,36 M44.949,41 L53.09,41 C53.568,43.833 56.033,46 59,46 C62.309,46 65,43.309 65,40 C65,36.691 62.309,34 59,34 C56.033,34 53.568,36.167 53.09,39 L44.949,39 C44.823,37.099 44.344,35.297 43.572,33.654 L52.446,25.823 C53.442,26.559 54.669,27 56,27 C59.309,27 62,24.309 62,21 C62,17.691 59.309,15 56,15 C52.691,15 50,17.691 50,21 C50,22.256 50.389,23.421 51.051,24.386 L42.581,31.859 C39.904,27.738 35.271,25 30,25 C21.729,25 15,31.729 15,40 C15,48.271 21.729,55 30,55 C35.271,55 39.904,52.262 42.581,48.141 L51.051,55.614 C50.389,56.579 50,57.744 50,59 C50,62.309 52.691,65 56,65 C59.309,65 62,62.309 62,59 C62,55.691 59.309,53 56,53 C54.669,53 53.442,53.441 52.446,54.177 L43.572,46.346 C44.344,44.703 44.823,42.901 44.949,41" id="Elastic-Load-Balancing_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Icon-Architecture/64/Arch_Amazon-EventBridge_64</title>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-EventBridge_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Application-Integration" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M53.656744,66 C51.5137882,66 49.7695913,64.247 49.7695913,62.091 C49.7695913,59.935 51.5137882,58.182 53.656744,58.182 C55.7996998,58.182 57.5438968,59.935 57.5438968,62.091 C57.5438968,64.247 55.7996998,66 53.656744,66 L53.656744,66 Z M45.1326526,49 L34.7977386,49 L29.6297845,40 L34.7977386,31 L45.1326526,31 L50.2996124,40 L45.1326526,49 Z M27.6210741,21.818 C25.4781184,21.818 23.7339214,20.065 23.7339214,17.909 C23.7339214,15.753 25.4781184,14 27.6210741,14 C29.7650244,14 31.5092213,15.753 31.5092213,17.909 C31.5092213,20.065 29.7650244,21.818 27.6210741,21.818 L27.6210741,21.818 Z M53.656744,56.182 C52.918891,56.182 52.214848,56.325 51.5635088,56.576 L47.3402451,49.41 L47.117497,49.543 L52.3093171,40.5 C52.4863222,40.19 52.4863222,39.81 52.3093171,39.5 L46.5675877,29.5 C46.3895881,29.19 46.0614325,29 45.7064278,29 L35.2581509,29 L35.2989218,28.976 L31.4266852,22.404 C32.691576,21.32 33.4980434,19.708 33.4980434,17.909 C33.4980434,14.651 30.8618597,12 27.6210741,12 C24.381283,12 21.7450993,14.651 21.7450993,17.909 C21.7450993,21.167 24.381283,23.818 27.6210741,23.818 C28.3599216,23.818 29.0639646,23.675 29.7153038,23.424 L33.3299879,29.557 L27.6200797,39.5 C27.4430746,39.81 27.4430746,40.19 27.6200797,40.5 L33.3628035,50.5 C33.5408031,50.81 33.8689587,51 34.2239635,51 L45.7064278,51 C45.7889639,51 45.8695112,50.987 45.9470752,50.968 L49.8521274,57.596 C48.5862421,58.68 47.7807692,60.292 47.7807692,62.091 C47.7807692,65.349 50.4159585,68 53.656744,68 C56.8975296,68 59.5327189,65.349 59.5327189,62.091 C59.5327189,58.833 56.8975296,56.182 53.656744,56.182 L53.656744,56.182 Z M59.5844283,32.104 C57.4414725,32.104 55.6982699,30.351 55.6982699,28.195 C55.6982699,26.039 57.4414725,24.286 59.5844283,24.286 C61.7273841,24.286 63.471581,26.039 63.471581,28.195 C63.471581,30.351 61.7273841,32.104 59.5844283,32.104 L59.5844283,32.104 Z M66.8665003,39.5 L63.091716,32.925 C64.5266511,31.846 65.4604031,30.13 65.4604031,28.195 C65.4604031,24.937 62.8252138,22.286 59.5844283,22.286 C58.7600615,22.286 57.9744768,22.459 57.2614841,22.77 L54.1837819,17.409 C54.0067767,17.1 53.6776267,16.909 53.3226219,16.909 L40.9949082,16.909 L40.9949082,18.909 L52.7488468,18.909 L55.5958456,23.869 C54.4383511,24.949 53.7094478,26.486 53.7094478,28.195 C53.7094478,31.453 56.3446371,34.104 59.5844283,34.104 C60.1880358,34.104 60.771755,34.011 61.3206699,33.84 L64.85779,40 L59.8917013,48.648 L61.6140212,49.648 L66.8665003,40.5 C67.0444999,40.19 67.0444999,39.81 66.8665003,39.5 L66.8665003,39.5 Z M21.229,55.247 C19.0850498,55.247 17.3408528,53.493 17.3408528,51.338 C17.3408528,49.182 19.0850498,47.429 21.229,47.429 C23.3719558,47.429 25.1161527,49.182 25.1161527,51.338 C25.1161527,53.493 23.3719558,55.247 21.229,55.247 L21.229,55.247 Z M24.4767464,56.258 C26.0588544,55.198 27.1049748,53.389 27.1049748,51.338 C27.1049748,48.08 24.4687911,45.429 21.229,45.429 C20.2902759,45.429 19.4062445,45.657 18.618671,46.052 L15.1432044,40 L20.9018387,29.971 L19.1785244,28.971 L13.1334997,39.5 C12.9555001,39.81 12.9555001,40.19 13.1334997,40.5 L17.0047419,47.24 C15.9834817,48.304 15.3520307,49.747 15.3520307,51.338 C15.3520307,54.596 17.9882144,57.247 21.229,57.247 C21.7172558,57.247 22.189601,57.18 22.6440469,57.066 L25.8162181,62.591 C25.9942177,62.9 26.3223733,63.091 26.6773781,63.091 L39.0060862,63.091 L39.0060862,61.091 L27.2511532,61.091 L24.4767464,56.258 Z" id="Amazon-EventBridge_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Identity-and-Access-Management_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#BD0816" offset="0%"></stop>
|
||||
<stop stop-color="#FF5252" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Identity-and-Access-Management_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Security-Identity-Compliance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M14,59 L66,59 L66,21 L14,21 L14,59 Z M68,20 L68,60 C68,60.552 67.553,61 67,61 L13,61 C12.447,61 12,60.552 12,60 L12,20 C12,19.448 12.447,19 13,19 L67,19 C67.553,19 68,19.448 68,20 L68,20 Z M44,48 L59,48 L59,46 L44,46 L44,48 Z M57,42 L62,42 L62,40 L57,40 L57,42 Z M44,42 L52,42 L52,40 L44,40 L44,42 Z M29,46 C29,45.449 28.552,45 28,45 C27.448,45 27,45.449 27,46 C27,46.551 27.448,47 28,47 C28.552,47 29,46.551 29,46 L29,46 Z M31,46 C31,47.302 30.161,48.401 29,48.816 L29,51 L27,51 L27,48.815 C25.839,48.401 25,47.302 25,46 C25,44.346 26.346,43 28,43 C29.654,43 31,44.346 31,46 L31,46 Z M19,53.993 L36.994,54 L36.996,50 L33,50 L33,48 L36.996,48 L36.998,45 L33,45 L33,43 L36.999,43 L37,40.007 L19.006,40 L19,53.993 Z M22,38.001 L34,38.006 L34,31 C34.001,28.697 31.197,26.677 28,26.675 L27.996,26.675 C24.804,26.675 22.004,28.696 22.002,31 L22,38.001 Z M17,54.992 L17.006,39 C17.006,38.734 17.111,38.48 17.299,38.292 C17.486,38.105 17.741,38 18.006,38 L20,38.001 L20.002,31 C20.004,27.512 23.59,24.675 27.996,24.675 L28,24.675 C32.412,24.677 36.001,27.515 36,31 L36,38.007 L38,38.008 C38.553,38.008 39,38.456 39,39.008 L38.994,55 C38.994,55.266 38.889,55.52 38.701,55.708 C38.514,55.895 38.259,56 37.994,56 L18,55.992 C17.447,55.992 17,55.544 17,54.992 L17,54.992 Z M60,36 L62,36 L62,34 L60,34 L60,36 Z M44,36 L55,36 L55,34 L44,34 L44,36 Z" id="AWS-Identity-and-Access-Management_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Key-Management-Services_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#BD0816" offset="0%"></stop>
|
||||
<stop stop-color="#FF5252" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Key-Management-Services_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Security-Identity-Compliance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M51.9968943,52.4963312 L58.9968943,52.4963312 L58.9968943,50.4963312 L51.9968943,50.4963312 L51.9968943,52.4963312 Z M42.9968943,52.4963312 L49.9968943,52.4963312 L49.9968943,50.4963312 L42.9968943,50.4963312 L42.9968943,52.4963312 Z M33.9968943,52.4963312 L40.9968943,52.4963312 L40.9968943,50.4963312 L33.9968943,50.4963312 L33.9968943,52.4963312 Z M44.9968943,44.4963312 C44.9968943,43.3933312 45.8948943,42.4963312 46.9968943,42.4963312 C48.0998943,42.4963312 48.9968943,43.3933312 48.9968943,44.4963312 C48.9968943,45.5993312 48.0998943,46.4963312 46.9968943,46.4963312 C45.8948943,46.4963312 44.9968943,45.5993312 44.9968943,44.4963312 L44.9968943,44.4963312 Z M50.9968943,44.4963312 C50.9968943,42.2903312 49.2038943,40.4963312 46.9968943,40.4963312 C44.7908943,40.4963312 42.9968943,42.2903312 42.9968943,44.4963312 C42.9968943,46.7023312 44.7908943,48.4963312 46.9968943,48.4963312 C49.2038943,48.4963312 50.9968943,46.7023312 50.9968943,44.4963312 L50.9968943,44.4963312 Z M36.9968943,42.4963312 C38.0998943,42.4963312 38.9968943,43.3933312 38.9968943,44.4963312 C38.9968943,45.5993312 38.0998943,46.4963312 36.9968943,46.4963312 C35.8948943,46.4963312 34.9968943,45.5993312 34.9968943,44.4963312 C34.9968943,43.3933312 35.8948943,42.4963312 36.9968943,42.4963312 L36.9968943,42.4963312 Z M36.9968943,48.4963312 C39.2038943,48.4963312 40.9968943,46.7023312 40.9968943,44.4963312 C40.9968943,42.2903312 39.2038943,40.4963312 36.9968943,40.4963312 C34.7908943,40.4963312 32.9968943,42.2903312 32.9968943,44.4963312 C32.9968943,46.7023312 34.7908943,48.4963312 36.9968943,48.4963312 L36.9968943,48.4963312 Z M65.9968943,33.4963312 L65.9968943,56.4963312 C65.9968943,57.0483312 65.5498943,57.4963312 64.9968943,57.4963312 L33.9968943,57.4963312 L33.9968943,55.4963312 L63.9968943,55.4963312 L63.9968943,34.4963312 L41.9968943,34.4963312 L41.9968943,32.4963312 L64.9968943,32.4963312 C65.5498943,32.4963312 65.9968943,32.9443312 65.9968943,33.4963312 L65.9968943,33.4963312 Z M30.6958943,38.5433312 C30.2798943,38.6743312 29.9968943,39.0603312 29.9968943,39.4963312 L29.9968943,60.5363312 L27.5978943,62.5933312 L24.9968943,59.6203312 L24.9968943,56.9963312 C24.9968943,56.7313312 24.8918943,56.4763312 24.7038943,56.2893312 L22.4118943,53.9963312 L24.7038943,51.7033312 C24.8918943,51.5163312 24.9968943,51.2613312 24.9968943,50.9963312 L24.9968943,48.9963312 C24.9968943,48.7313312 24.8918943,48.4763312 24.7038943,48.2893312 L22.4118943,45.9963312 L24.7038943,43.7033312 C24.8918943,43.5163312 24.9968943,43.2613312 24.9968943,42.9963312 L24.9968943,39.5063312 C24.9968943,39.0703312 24.7148943,38.6843312 24.2988943,38.5533312 C19.1568943,36.9233312 16.1168943,31.6343312 17.2298943,26.2493312 C18.0318943,22.3693312 21.0158943,19.2583312 24.8308943,18.3233312 C28.0798943,17.5263312 31.4118943,18.2233312 33.9718943,20.2303312 C36.5298943,22.2373312 37.9968943,25.2503312 37.9968943,28.4963312 C37.9968943,33.0493312 34.9948943,37.1813312 30.6958943,38.5433312 L30.6958943,38.5433312 Z M39.9968943,28.4963312 C39.9968943,24.6323312 38.2508943,21.0453312 35.2058943,18.6563312 C32.1608943,16.2673312 28.2088943,15.4363312 24.3548943,16.3813312 C19.7938943,17.4983312 16.2288943,21.2133312 15.2708943,25.8443312 L15.2708943,25.8453312 C14.0008943,31.9903312 17.2998943,38.0343312 22.9968943,40.2153312 L22.9968943,42.5823312 L20.2898943,45.2893312 C19.8998943,45.6803312 19.8998943,46.3123312 20.2898943,46.7033312 L22.9968943,49.4103312 L22.9968943,50.5823312 L20.2898943,53.2893312 C19.8998943,53.6803312 19.8998943,54.3123312 20.2898943,54.7033312 L22.9968943,57.4103312 L22.9968943,59.9963312 C22.9968943,60.2383312 23.0848943,60.4723312 23.2448943,60.6553312 L26.7448943,64.6553312 C26.9428943,64.8803312 27.2188943,64.9963312 27.4968943,64.9963312 C27.7278943,64.9963312 27.9588943,64.9173312 28.1478943,64.7553312 L31.6478943,61.7553312 C31.8698943,61.5653312 31.9968943,61.2883312 31.9968943,60.9963312 L31.9968943,40.2043312 C36.7428943,38.3723312 39.9968943,33.6623312 39.9968943,28.4963312 L39.9968943,28.4963312 Z M27.4968943,30.8663312 C26.1188943,30.8663312 24.9968943,29.7453312 24.9968943,28.3663312 C24.9968943,26.9883312 26.1188943,25.8663312 27.4968943,25.8663312 C28.8758943,25.8663312 29.9968943,26.9883312 29.9968943,28.3663312 C29.9968943,29.7453312 28.8758943,30.8663312 27.4968943,30.8663312 L27.4968943,30.8663312 Z M27.4968943,23.8663312 C25.0158943,23.8663312 22.9968943,25.8853312 22.9968943,28.3663312 C22.9968943,30.8483312 25.0158943,32.8663312 27.4968943,32.8663312 C29.9788943,32.8663312 31.9968943,30.8483312 31.9968943,28.3663312 C31.9968943,25.8853312 29.9788943,23.8663312 27.4968943,23.8663312 L27.4968943,23.8663312 Z" id="AWS-Key-Management-Services_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Lambda_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Lambda_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Compute" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M28.0075352,66 L15.5907274,66 L29.3235885,37.296 L35.5460249,50.106 L28.0075352,66 Z M30.2196674,34.553 C30.0512768,34.208 29.7004629,33.989 29.3175745,33.989 L29.3145676,33.989 C28.9286723,33.99 28.5778583,34.211 28.4124746,34.558 L13.097944,66.569 C12.9495999,66.879 12.9706487,67.243 13.1550766,67.534 C13.3374998,67.824 13.6582439,68 14.0020416,68 L28.6420072,68 C29.0299071,68 29.3817234,67.777 29.5481094,67.428 L37.563706,50.528 C37.693006,50.254 37.6920037,49.937 37.5586944,49.665 L30.2196674,34.553 Z M64.9953491,66 L52.6587274,66 L32.866809,24.57 C32.7014253,24.222 32.3486067,24 31.9617091,24 L23.8899822,24 L23.8990031,14 L39.7197081,14 L59.4204149,55.429 C59.5857986,55.777 59.9386172,56 60.3255148,56 L64.9953491,56 L64.9953491,66 Z M65.9976745,54 L60.9599868,54 L41.25928,12.571 C41.0938963,12.223 40.7410777,12 40.3531778,12 L22.89768,12 C22.3453987,12 21.8963569,12.447 21.8953545,12.999 L21.884329,24.999 C21.884329,25.265 21.9885708,25.519 22.1780103,25.707 C22.3654452,25.895 22.6200358,26 22.8866544,26 L31.3292417,26 L51.1221625,67.43 C51.2885485,67.778 51.6393624,68 52.02626,68 L65.9976745,68 C66.5519605,68 67,67.552 67,67 L67,55 C67,54.448 66.5519605,54 65.9976745,54 L65.9976745,54 Z" id="AWS-Lambda_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-RDS_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#2E27AD" offset="0%"></stop>
|
||||
<stop stop-color="#527FFF" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-RDS_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Database" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M15.414,14 L24.707,23.293 L23.293,24.707 L14,15.414 L14,23 L12,23 L12,13 C12,12.448 12.447,12 13,12 L23,12 L23,14 L15.414,14 Z M68,13 L68,23 L66,23 L66,15.414 L56.707,24.707 L55.293,23.293 L64.586,14 L57,14 L57,12 L67,12 C67.553,12 68,12.448 68,13 L68,13 Z M66,57 L68,57 L68,67 C68,67.552 67.553,68 67,68 L57,68 L57,66 L64.586,66 L55.293,56.707 L56.707,55.293 L66,64.586 L66,57 Z M65.5,39.213 C65.5,35.894 61.668,32.615 55.25,30.442 L55.891,28.548 C63.268,31.045 67.5,34.932 67.5,39.213 C67.5,43.495 63.268,47.383 55.89,49.879 L55.249,47.984 C61.668,45.812 65.5,42.534 65.5,39.213 L65.5,39.213 Z M14.556,39.213 C14.556,42.393 18.143,45.585 24.152,47.753 L23.473,49.634 C16.535,47.131 12.556,43.333 12.556,39.213 C12.556,35.094 16.535,31.296 23.473,28.792 L24.152,30.673 C18.143,32.842 14.556,36.034 14.556,39.213 L14.556,39.213 Z M24.707,56.707 L15.414,66 L23,66 L23,68 L13,68 C12.447,68 12,67.552 12,67 L12,57 L14,57 L14,64.586 L23.293,55.293 L24.707,56.707 Z M40,31.286 C32.854,31.286 29,29.44 29,28.686 C29,27.931 32.854,26.086 40,26.086 C47.145,26.086 51,27.931 51,28.686 C51,29.44 47.145,31.286 40,31.286 L40,31.286 Z M40.029,39.031 C33.187,39.031 29,37.162 29,36.145 L29,31.284 C31.463,32.643 35.832,33.286 40,33.286 C44.168,33.286 48.537,32.643 51,31.284 L51,36.145 C51,37.163 46.835,39.031 40.029,39.031 L40.029,39.031 Z M40.029,46.667 C33.187,46.667 29,44.798 29,43.781 L29,38.862 C31.431,40.291 35.742,41.031 40.029,41.031 C44.292,41.031 48.578,40.292 51,38.867 L51,43.781 C51,44.799 46.835,46.667 40.029,46.667 L40.029,46.667 Z M40,53.518 C32.883,53.518 29,51.605 29,50.622 L29,46.498 C31.431,47.927 35.742,48.667 40.029,48.667 C44.292,48.667 48.578,47.929 51,46.503 L51,50.622 C51,51.605 47.117,53.518 40,53.518 L40,53.518 Z M40,24.086 C33.739,24.086 27,25.525 27,28.686 L27,50.622 C27,53.836 33.54,55.518 40,55.518 C46.46,55.518 53,53.836 53,50.622 L53,28.686 C53,25.525 46.261,24.086 40,24.086 L40,24.086 Z" id="Amazon-RDS_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Simple-Storage-Service_64</title>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#1B660F" offset="0%"></stop>
|
||||
<stop stop-color="#6CAE3E" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Simple-Storage-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Rectangle" fill="url(#linearGradient-1)">
|
||||
<rect x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<g id="Icon-Service/64/Amazon-Simple-Storage-Service_64" transform="translate(8.000000, 8.000000)" fill="#FFFFFF">
|
||||
<path d="M52.8359,34.8926 L53.2199,32.1886 C56.7609,34.3096 56.8069,35.1856 56.8059132,35.2096 C56.7999,35.2146 56.1959,35.7186 52.8359,34.8926 L52.8359,34.8926 Z M50.8929,34.3526 C44.7729,32.5006 36.2499,28.5906 32.8009,26.9606 C32.8009,26.9466 32.8049,26.9336 32.8049,26.9196 C32.8049,25.5946 31.7269,24.5166 30.4009,24.5166 C29.0769,24.5166 27.9989,25.5946 27.9989,26.9196 C27.9989,28.2446 29.0769,29.3226 30.4009,29.3226 C30.9829,29.3226 31.5109,29.1056 31.9279,28.7606 C35.9859,30.6816 44.4429,34.5346 50.6079,36.3546 L48.1699,53.5606 C48.1629,53.6076 48.1599,53.6546 48.1599,53.7016 C48.1599,55.2166 41.4529,57.9996 30.4939,57.9996 C19.4189,57.9996 12.6409,55.2166 12.6409,53.7016 C12.6409,53.6556 12.6379,53.6106 12.6319,53.5656 L7.5379,16.3586 C11.9469,19.3936 21.4299,20.9996 30.4999,20.9996 C39.5559,20.9996 49.0229,19.3996 53.4409,16.3736 L50.8929,34.3526 Z M6.9999,12.4776 C7.0719,11.1616 14.6339,5.9996 30.4999,5.9996 C46.3639,5.9996 53.9269,11.1606 53.9999,12.4776 L53.9999,12.9266 C53.1299,15.8776 43.3299,18.9996 30.4999,18.9996 C17.6479,18.9996 7.8429,15.8676 6.9999,12.9126 L6.9999,12.4776 Z M55.9999,12.4996 C55.9999,9.0346 46.0659,3.9996 30.4999,3.9996 C14.9339,3.9996 4.9999,9.0346 4.9999,12.4996 L5.0939,13.2536 L10.6419,53.7776 C10.7749,58.3096 22.8609,59.9996 30.4939,59.9996 C39.9659,59.9996 50.0289,57.8216 50.1589,53.7806 L52.5549,36.8836 C53.8879,37.2026 54.9849,37.3656 55.8659,37.3656 C57.0489,37.3656 57.8489,37.0766 58.3339,36.4986 C58.7319,36.0246 58.8839,35.4506 58.7699,34.8396 C58.5109,33.4556 56.8679,31.9636 53.5219,30.0546 L55.8979,13.2926 L55.9999,12.4996 Z" id="Amazon-Simple-Storage-Service-Icon_64_Squid"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Secrets-Manager_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#BD0816" offset="0%"></stop>
|
||||
<stop stop-color="#FF5252" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Secrets-Manager_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Security-Identity-Compliance" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M38.76,43.36 C38.76,44.044 39.317,44.6 40,44.6 C40.684,44.6 41.24,44.044 41.24,43.36 C41.24,42.676 40.684,42.12 40,42.12 C39.317,42.12 38.76,42.676 38.76,43.36 L38.76,43.36 Z M36.76,43.36 C36.76,41.573 38.213,40.12 40,40.12 C41.787,40.12 43.24,41.573 43.24,43.36 C43.24,44.796 42.296,46.002 41,46.426 L41,49 L39,49 L39,46.426 C37.704,46.002 36.76,44.796 36.76,43.36 L36.76,43.36 Z M49,38 L31,38 L31,51 L49,51 L49,48 L46,48 L46,46 L49,46 L49,43 L46,43 L46,41 L49,41 L49,38 Z M34,36 L45.999,36 L46,31 C46.001,28.384 43.143,26.002 40.004,26 L40.001,26 C38.472,26 36.928,26.574 35.763,27.575 C34.643,28.537 34,29.786 34,31.001 L34,36 Z M48,31.001 L47.999,36 L50,36 C50.553,36 51,36.448 51,37 L51,52 C51,52.552 50.553,53 50,53 L30,53 C29.447,53 29,52.552 29,52 L29,37 C29,36.448 29.447,36 30,36 L32,36 L32,31 C32.001,29.202 32.897,27.401 34.459,26.058 C35.982,24.75 38.001,24 40.001,24 L40.004,24 C44.265,24.002 48.001,27.273 48,31.001 L48,31.001 Z M19.207,55.049 L20.828,53.877 C18.093,50.097 16.581,45.662 16.396,41 L19,41 L19,39 L16.399,39 C16.598,34.366 18.108,29.957 20.828,26.198 L19.207,25.025 C16.239,29.128 14.599,33.942 14.399,39 L12,39 L12,41 L14.396,41 C14.582,46.086 16.224,50.926 19.207,55.049 L19.207,55.049 Z M53.838,59.208 C50.069,61.936 45.648,63.446 41,63.639 L41,61 L39,61 L39,63.639 C34.352,63.447 29.93,61.937 26.159,59.208 L24.988,60.828 C29.1,63.805 33.928,65.445 39,65.639 L39,68 L41,68 L41,65.639 C46.072,65.445 50.898,63.805 55.01,60.828 L53.838,59.208 Z M26.159,20.866 C29.93,18.138 34.352,16.628 39,16.436 L39,19 L41,19 L41,16.436 C45.648,16.628 50.069,18.138 53.838,20.866 L55.01,19.246 C50.898,16.27 46.072,14.63 41,14.436 L41,12 L39,12 L39,14.436 C33.928,14.629 29.1,16.269 24.988,19.246 L26.159,20.866 Z M65.599,39 C65.399,33.942 63.759,29.128 60.79,25.025 L59.169,26.198 C61.89,29.957 63.4,34.366 63.599,39 L61,39 L61,41 L63.602,41 C63.416,45.662 61.905,50.097 59.169,53.877 L60.79,55.049 C63.774,50.926 65.415,46.086 65.602,41 L68,41 L68,39 L65.599,39 Z M56.386,25.064 L64.226,17.224 L62.812,15.81 L54.972,23.65 L56.386,25.064 Z M23.612,55.01 L15.772,62.85 L17.186,64.264 L25.026,56.424 L23.612,55.01 Z M28.666,27.253 L13.825,12.413 L12.411,13.827 L27.252,28.667 L28.666,27.253 Z M54.193,52.78 L67.586,66.173 L66.172,67.587 L52.779,54.194 L54.193,52.78 Z" id="AWS-Secrets-Manager_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Simple-Email-Service_64</title>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#BD0816" offset="0%"></stop>
|
||||
<stop stop-color="#FF5252" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Simple-Email-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Rectangle" fill="url(#linearGradient-1)">
|
||||
<rect x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M57,60.999875 C57,59.373846 55.626,57.9998214 54,57.9998214 C52.374,57.9998214 51,59.373846 51,60.999875 C51,62.625904 52.374,63.9999286 54,63.9999286 C55.626,63.9999286 57,62.625904 57,60.999875 L57,60.999875 Z M40,59.9998571 C38.374,59.9998571 37,61.3738817 37,62.9999107 C37,64.6259397 38.374,65.9999643 40,65.9999643 C41.626,65.9999643 43,64.6259397 43,62.9999107 C43,61.3738817 41.626,59.9998571 40,59.9998571 L40,59.9998571 Z M26,57.9998214 C24.374,57.9998214 23,59.373846 23,60.999875 C23,62.625904 24.374,63.9999286 26,63.9999286 C27.626,63.9999286 29,62.625904 29,60.999875 C29,59.373846 27.626,57.9998214 26,57.9998214 L26,57.9998214 Z M28.605,42.9995536 L51.395,42.9995536 L43.739,36.1104305 L40.649,38.7584778 C40.463,38.9194807 40.23,38.9994821 39.999,38.9994821 C39.768,38.9994821 39.535,38.9194807 39.349,38.7584778 L36.26,36.1104305 L28.605,42.9995536 Z M27,28.1732888 L27,41.7545313 L34.729,34.7984071 L27,28.1732888 Z M51.297,26.9992678 L28.703,26.9992678 L39.999,36.6824408 L51.297,26.9992678 Z M53,41.7545313 L53,28.1732888 L45.271,34.7974071 L53,41.7545313 Z M59,60.999875 C59,63.7099234 56.71,65.9999643 54,65.9999643 C51.29,65.9999643 49,63.7099234 49,60.999875 C49,58.6308327 50.75,56.5837961 53,56.1057876 L53,52.9997321 L41,52.9997321 L41,58.1058233 C43.25,58.5838319 45,60.6308684 45,62.9999107 C45,65.7099591 42.71,68 40,68 C37.29,68 35,65.7099591 35,62.9999107 C35,60.6308684 36.75,58.5838319 39,58.1058233 L39,52.9997321 L27,52.9997321 L27,56.1057876 C29.25,56.5837961 31,58.6308327 31,60.999875 C31,63.7099234 28.71,65.9999643 26,65.9999643 C23.29,65.9999643 21,63.7099234 21,60.999875 C21,58.6308327 22.75,56.5837961 25,56.1057876 L25,51.9997143 C25,51.4477044 25.447,50.9996964 26,50.9996964 L39,50.9996964 L39,44.9995893 L26,44.9995893 C25.447,44.9995893 25,44.5515813 25,43.9995714 L25,25.99925 C25,25.4472401 25.447,24.9992321 26,24.9992321 L54,24.9992321 C54.553,24.9992321 55,25.4472401 55,25.99925 L55,43.9995714 C55,44.5515813 54.553,44.9995893 54,44.9995893 L41,44.9995893 L41,50.9996964 L54,50.9996964 C54.553,50.9996964 55,51.4477044 55,51.9997143 L55,56.1057876 C57.25,56.5837961 59,58.6308327 59,60.999875 L59,60.999875 Z M68,39.9995 C68,45.9066055 66.177,51.5597064 62.727,56.3447919 L61.104,55.174771 C64.307,50.7316916 66,45.4845979 66,39.9995 C66,25.664244 54.337,14.0000357 40.001,14.0000357 C25.664,14.0000357 14,25.664244 14,39.9995 C14,45.4845979 15.693,50.7316916 18.896,55.174771 L17.273,56.3447919 C13.823,51.5597064 12,45.9066055 12,39.9995 C12,24.5612243 24.561,12 39.999,12 C55.438,12 68,24.5612243 68,39.9995 L68,39.9995 Z" id="Amazon-Simple-Email-Service_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Simple-Notification-Service_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Simple-Notification-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Application-Integration" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M17,38 C18.103,38 19,38.897 19,40 C19,41.103 18.103,42 17,42 C15.897,42 15,41.103 15,40 C15,38.897 15.897,38 17,38 L17,38 Z M41,64 C29.314,64 19.289,55.466 17.194,43.98 C18.965,43.894 20.427,42.659 20.857,41 L27,41 L27,39 L20.857,39 C20.427,37.342 18.966,36.107 17.195,36.02 C19.285,24.71 29.511,16 41,16 C45.313,16 49.832,17.622 54.429,20.821 L55.571,19.179 C50.633,15.743 45.73,14 41,14 C28.27,14 16.949,23.865 15.063,36.521 C13.839,37.207 13,38.5 13,40 C13,41.5 13.839,42.793 15.063,43.478 C16.97,56.341 28.056,66 41,66 C46.407,66 51.942,64.157 56.585,60.811 L55.415,59.189 C51.11,62.292 45.991,64 41,64 L41,64 Z M30.101,36.442 C31.955,36.895 34.275,37 36,37 C37.642,37 39.823,36.905 41.629,36.506 L37.105,45.553 C37.036,45.691 37,45.845 37,46 L37,50.453 C36.199,50.964 34.833,51.812 34,51.986 L34,46 C34,45.868 33.974,45.737 33.923,45.615 L30.101,36.442 Z M36,33 C40.025,33 42.174,33.604 42.841,34 C42.174,34.396 40.025,35 36,35 C31.975,35 29.826,34.396 29.159,34 C29.826,33.604 31.975,33 36,33 L36,33 Z M33,54 L34,54 C34.043,54 34.086,53.997 34.128,53.992 C35.352,53.833 36.909,52.887 38.272,52.013 L38.535,51.845 C38.824,51.661 39,51.342 39,51 L39,46.236 L44.559,35.12 C44.833,34.801 45,34.434 45,34 C45,31.39 39.361,31 36,31 C32.639,31 27,31.39 27,34 C27,34.366 27.12,34.684 27.32,34.967 L32,46.2 L32,53 C32,53.552 32.447,54 33,54 L33,54 Z M62,53 C63.103,53 64,53.897 64,55 C64,56.103 63.103,57 62,57 C60.897,57 60,56.103 60,55 C60,53.897 60.897,53 62,53 L62,53 Z M62,23 C63.103,23 64,23.897 64,25 C64,26.103 63.103,27 62,27 C60.897,27 60,26.103 60,25 C60,23.897 60.897,23 62,23 L62,23 Z M64,38 C65.103,38 66,38.897 66,40 C66,41.103 65.103,42 64,42 C62.897,42 62,41.103 62,40 C62,38.897 62.897,38 64,38 L64,38 Z M54,41 L60.143,41 C60.589,42.72 62.142,44 64,44 C66.206,44 68,42.206 68,40 C68,37.794 66.206,36 64,36 C62.142,36 60.589,37.28 60.143,39 L54,39 L54,26 L58.143,26 C58.589,27.72 60.142,29 62,29 C64.206,29 66,27.206 66,25 C66,22.794 64.206,21 62,21 C60.142,21 58.589,22.28 58.143,24 L53,24 C52.447,24 52,24.448 52,25 L52,39 L45,39 L45,41 L52,41 L52,55 C52,55.552 52.447,56 53,56 L58.143,56 C58.589,57.72 60.142,59 62,59 C64.206,59 66,57.206 66,55 C66,52.794 64.206,51 62,51 C60.142,51 58.589,52.28 58.143,54 L54,54 L54,41 Z" id="AWS-Simple-Notification-Service_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_AWS-Step-Functions_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#B0084D" offset="0%"></stop>
|
||||
<stop stop-color="#FF4F8B" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_AWS-Step-Functions_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Application-Integration" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M45,44 L66,44 L66,37 L45,37 L45,44 Z M67,35 L44,35 C43.447,35 43,35.448 43,36 L43,45 C43,45.552 43.447,46 44,46 L67,46 C67.553,46 68,45.552 68,45 L68,36 C68,35.448 67.553,35 67,35 L67,35 Z M43.5,62.5 C43.5,64.43 41.93,66 40,66 C38.07,66 36.5,64.43 36.5,62.5 C36.5,60.57 38.07,59 40,59 C41.93,59 43.5,60.57 43.5,62.5 L43.5,62.5 Z M14,47 L32,47 L32,43 L14,43 L14,47 Z M14,37 L32,37 L32,33 L14,33 L14,37 Z M36.5,17.5 C36.5,15.57 38.07,14 40,14 C41.93,14 43.5,15.57 43.5,17.5 C43.5,19.43 41.93,21 40,21 C38.07,21 36.5,19.43 36.5,17.5 L36.5,17.5 Z M56,53 L24,53 L24,49 L33,49 C33.553,49 34,48.552 34,48 L34,42 C34,41.448 33.553,41 33,41 L24,41 L24,39 L33,39 C33.553,39 34,38.552 34,38 L34,32 C34,31.448 33.553,31 33,31 L24,31 L24,28 L56,28 L56,32 L58,32 L58,27 C58,26.448 57.553,26 57,26 L41,26 L41,22.904 C43.556,22.432 45.5,20.191 45.5,17.5 C45.5,14.467 43.032,12 40,12 C36.968,12 34.5,14.467 34.5,17.5 C34.5,20.191 36.444,22.432 39,22.904 L39,26 L23,26 C22.447,26 22,26.448 22,27 L22,31 L13,31 C12.447,31 12,31.448 12,32 L12,38 C12,38.552 12.447,39 13,39 L22,39 L22,41 L13,41 C12.447,41 12,41.448 12,42 L12,48 C12,48.552 12.447,49 13,49 L22,49 L22,54 C22,54.552 22.447,55 23,55 L39,55 L39,57.096 C36.444,57.568 34.5,59.809 34.5,62.5 C34.5,65.533 36.968,68 40,68 C43.032,68 45.5,65.533 45.5,62.5 C45.5,59.809 43.556,57.568 41,57.096 L41,55 L57,55 C57.553,55 58,54.552 58,54 L58,49.027 L56,49.027 L56,53 Z" id="AWS_Step_Functions_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Virtual-Private-Cloud_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#4D27A8" offset="0%"></stop>
|
||||
<stop stop-color="#A166FF" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Virtual-Private-Cloud_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Networking-Content-Delivery" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M61,41.3717025 L55.5,39.1717025 L55.5,59.1227025 C57.106,58.9627025 58.401,58.4347025 59.321,57.5007025 C61.021,55.7727025 61.001,53.2397025 61,53.2147025 L61,41.3717025 Z M53.5,59.1357025 L53.5,39.1717025 L48,41.3717025 L48,53.1947025 C48.007,53.4017025 48.234,58.5517025 53.5,59.1357025 L53.5,59.1357025 Z M63,53.1947025 C63.003,53.3077025 63.053,56.5417025 60.765,58.8847025 C59.269,60.4177025 57.16,61.1947025 54.5,61.1947025 C47.927,61.1947025 46.065,55.9767025 46,53.2187025 L46,40.6947025 C46,40.2857025 46.249,39.9177025 46.629,39.7657025 L54.129,36.7657025 C54.367,36.6707025 54.633,36.6707025 54.871,36.7657025 L62.371,39.7657025 C62.751,39.9177025 63,40.2857025 63,40.6947025 L63,53.1947025 Z M66.001,51.2287025 L66,37.8717025 L54.5,33.2717025 L43,37.8717025 L43,51.1947025 C42.998,51.2857025 42.865,57.4227025 46.534,61.1987025 C48.466,63.1867025 51.146,64.1947025 54.5,64.1947025 C57.877,64.1947025 60.57,63.1797025 62.504,61.1787025 C66.168,57.3867025 66.003,51.2897025 66.001,51.2287025 L66.001,51.2287025 Z M63.942,62.5677025 C61.617,64.9747025 58.44,66.1947025 54.5,66.1947025 C50.579,66.1947025 47.413,64.9787025 45.09,62.5807025 C40.831,58.1877025 40.991,51.4477025 41,51.1637025 L41,37.1947025 C41,36.7857025 41.249,36.4177025 41.629,36.2657025 L54.129,31.2657025 C54.367,31.1707025 54.633,31.1707025 54.871,31.2657025 L67.371,36.2657025 C67.751,36.4177025 68,36.7857025 68,37.1947025 L68,51.1947025 C68.009,51.4447025 68.189,58.1727025 63.942,62.5677025 L63.942,62.5677025 Z M23.055,47.1947025 L38,47.1947025 L38,49.1947025 L23.055,49.1947025 C16.977,49.1947025 12.337,45.1427025 12.024,39.5597025 C12.002,39.3317025 12,39.0657025 12,38.7997025 C12,31.9307025 16.803,29.4017025 19.604,28.5117025 C19.584,28.1907025 19.574,27.8657025 19.574,27.5387025 C19.574,22.0857025 23.464,16.3807025 28.621,14.2687025 C34.677,11.7887025 41.062,12.9897025 45.695,17.4757025 C47.256,18.9967025 48.446,20.6207025 49.307,22.4097025 C50.513,21.3747025 51.969,20.8187025 53.529,20.8187025 C56.834,20.8187025 60.33,23.4127025 60.931,28.3817025 C63.114,28.9317025 65.854,30.0997025 67.788,32.5797025 L66.212,33.8097025 C64.43,31.5237025 61.741,30.5717025 59.8,30.1737025 C59.355,30.0837025 59.028,29.7057025 59.002,29.2537025 C58.741,24.8327025 55.982,22.8187025 53.529,22.8187025 C52.067,22.8187025 50.771,23.5087025 49.78,24.8137025 C49.559,25.1057025 49.199,25.2567025 48.835,25.1987025 C48.474,25.1437025 48.17,24.8967025 48.045,24.5537025 C47.278,22.4637025 46.054,20.6177025 44.302,18.9107025 C40.258,14.9967025 34.676,13.9517025 29.379,16.1197025 C24.93,17.9417025 21.574,22.8507025 21.574,27.5387025 C21.574,28.0797025 21.605,28.6147025 21.668,29.1277025 C21.729,29.6287025 21.405,30.0967025 20.915,30.2187025 C18.333,30.8577025 14,32.8237025 14,38.7997025 C14,39.0017025 13.998,39.2047025 14.018,39.4077025 C14.272,43.9357025 18.072,47.1947025 23.055,47.1947025 L23.055,47.1947025 Z" id="Amazon-Virtual-Private-Cloud_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,7 +1,7 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -18,6 +18,10 @@ const apiMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const api: CloudResource = {
|
||||
id: 'api-123', name: 'orders', cloud: 'aws', service: 'apigateway', type: 'rest-api',
|
||||
region: null, createdAt: null, metadata: {},
|
||||
@@ -28,9 +32,27 @@ const lambda: CloudResource = {
|
||||
region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
const rootResource = {resourceId: 'root-123', parentId: null, path: '/', pathPart: null, methods: []}
|
||||
|
||||
function routesResult(routes: Array<{resourceId: string; path: string; method: string; functionName: string | null; integrationType?: string | null}> = [], stages = ['dev']) {
|
||||
const byResource = new Map<string, typeof routes>()
|
||||
for (const route of routes) byResource.set(route.resourceId, [...(byResource.get(route.resourceId) ?? []), route])
|
||||
return {
|
||||
resources: [rootResource, ...[...byResource.entries()].map(([resourceId, methods]) => ({
|
||||
resourceId,
|
||||
parentId: 'root-123',
|
||||
path: methods[0]!.path,
|
||||
pathPart: methods[0]!.path.slice(1),
|
||||
methods: methods.map((method) => ({...method, integrationType: method.integrationType ?? null})),
|
||||
}))],
|
||||
routes: routes.map((route) => ({...route, integrationType: route.integrationType ?? null})),
|
||||
stages,
|
||||
}
|
||||
}
|
||||
|
||||
test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({
|
||||
resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders',
|
||||
})
|
||||
@@ -43,22 +65,88 @@ test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('/orders'))
|
||||
await user.type(screen.getByPlaceholderText('/orders'), '/orders')
|
||||
await user.selectOptions(screen.getAllByRole('combobox')[1]!, 'orders-handler')
|
||||
await user.clear(screen.getByLabelText('Path part'))
|
||||
await user.type(screen.getByLabelText('Path part'), 'orders')
|
||||
await user.selectOptions(screen.getByLabelText('Lambda function'), 'orders-handler')
|
||||
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
path: '/orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
|
||||
path: '/orders', parentResourceId: 'root-123', pathPart: 'orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders root, empty resources, nested resources, and methods as a tree', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
resources: [
|
||||
rootResource,
|
||||
{resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []},
|
||||
{resourceId: 'order-123', parentId: 'orders-123', path: '/orders/{id}', pathPart: '{id}', methods: [
|
||||
{resourceId: 'order-123', path: '/orders/{id}', method: 'PATCH', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
|
||||
]},
|
||||
],
|
||||
routes: [{resourceId: 'order-123', path: '/orders/{id}', method: 'PATCH', functionName: 'orders-handler', integrationType: 'AWS_PROXY'}],
|
||||
stages: ['dev'],
|
||||
})
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
const nodes = await screen.findAllByRole('treeitem')
|
||||
expect(nodes).toHaveLength(3)
|
||||
expect(nodes.map((node) => node.getAttribute('aria-level'))).toEqual(['1', '2', '3'])
|
||||
expect(screen.getByRole('button', {name: '/ordersNo methods'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: /PATCH.*\/orders\/\{id}.*orders-handler/})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates a path parameter under the selected parent resource', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
resources: [rootResource, {resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []}],
|
||||
routes: [],
|
||||
stages: ['dev'],
|
||||
})
|
||||
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({resourceId: 'order-123', deploymentId: 'deployment-123', invokeUrl: '/orders/{id}'})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
await screen.findByRole('option', {name: '/orders'})
|
||||
await user.selectOptions(screen.getByLabelText('Parent resource'), 'orders-123')
|
||||
fireEvent.change(screen.getByLabelText('Path part'), {target: {value: '{id}'}})
|
||||
await user.selectOptions(screen.getByLabelText('Method'), 'PATCH')
|
||||
await user.selectOptions(screen.getByLabelText('Lambda function'), 'orders-handler')
|
||||
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}', method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
|
||||
}))
|
||||
})
|
||||
|
||||
test('uses a concrete HTTP verb when deployed-testing an ANY method', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'ANY', functionName: 'orders-handler'},
|
||||
]))
|
||||
apiMocks.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '', durationMs: 1})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: /ANY.*\/orders.*orders-handler/}))
|
||||
expect(screen.getByText('ANY is API Gateway configuration, not an HTTP request verb.')).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('HTTP verb for deployed request'), 'HEAD')
|
||||
await user.click(screen.getByRole('button', {name: 'Send deployed HTTP request'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', expect.objectContaining({method: 'HEAD'})))
|
||||
})
|
||||
|
||||
test('displays and copies the selected API stage base invoke URL', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
@@ -77,7 +165,7 @@ test('displays and copies the selected API stage base invoke URL', async () => {
|
||||
|
||||
test('uses the browser host for a Docker bridge runtime endpoint', () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
@@ -91,7 +179,7 @@ test('uses the browser host for a Docker bridge runtime endpoint', () => {
|
||||
|
||||
test('sends a route test request and displays its response', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
apiMocks.testApiGatewayRoute.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
@@ -123,9 +211,9 @@ test('sends a route test request and displays its response', async () => {
|
||||
|
||||
test('tests a selected method through TestInvokeMethod without a deployed stage', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: [],
|
||||
})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
], []))
|
||||
apiMocks.testApiGatewayMethod.mockResolvedValue({
|
||||
statusCode: 200, body: '{"message":"Hello"}', headers: {'content-type': 'application/json'},
|
||||
multiValueHeaders: {'set-cookie': ['session=1']}, log: 'Execution completed', latency: 8,
|
||||
@@ -150,7 +238,7 @@ test('tests a selected method through TestInvokeMethod without a deployed stage'
|
||||
|
||||
test('formats the request body as JSON', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -169,10 +257,9 @@ test('formats the request body as JSON', async () => {
|
||||
|
||||
test('selects a deployed route for testing', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}],
|
||||
stages: ['dev'],
|
||||
})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
]))
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -184,16 +271,17 @@ test('selects a deployed route for testing', async () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: /POST.*\/orders.*orders-handler/}))
|
||||
|
||||
expect(screen.getByPlaceholderText('/orders')).toHaveValue('/orders')
|
||||
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
|
||||
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
|
||||
expect(screen.getByText(/POST \/dev\/orders/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Method')).toHaveValue('POST')
|
||||
expect(screen.getByLabelText('Lambda function')).toHaveValue('orders-handler')
|
||||
expect(screen.getByRole('link', {name: 'Open Lambda logs'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Forders-handler')
|
||||
})
|
||||
|
||||
test('confirms before deleting a deployed route', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: ['dev'],
|
||||
})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
]))
|
||||
apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
@@ -209,12 +297,10 @@ test('confirms before deleting a deployed route', async () => {
|
||||
|
||||
test('enables CORS for one resource and shows AWS proxy guidance', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'GET', functionName: null, integrationType: 'HTTP'},
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
|
||||
], stages: ['dev'],
|
||||
})
|
||||
]))
|
||||
apiMocks.enableApiGatewayCors.mockResolvedValue({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||
import {useEffect, useState, type CSSProperties} from 'react'
|
||||
import {ArrowRight, Copy, Link2, ListTree, Loader2, Rocket, ScrollText, Send, Trash2, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayMethod, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import type {ApiGatewayMethodTestResult} from '@/api/cloudProxyClient'
|
||||
import type {ApiGatewayMethodTestResult, ApiGatewayResourceOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -17,7 +18,10 @@ interface ApiGatewayRoutePanelProps {
|
||||
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [parentResourceId, setParentResourceId] = useState('')
|
||||
const [pathPart, setPathPart] = useState('hello')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [anyTestMethod, setAnyTestMethod] = useState('GET')
|
||||
const [stage, setStage] = useState('dev')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [testBody, setTestBody] = useState('{}')
|
||||
@@ -48,7 +52,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
|
||||
useEffect(() => {
|
||||
setPath('/hello')
|
||||
setParentResourceId('')
|
||||
setPathPart('hello')
|
||||
setMethod('GET')
|
||||
setAnyTestMethod('GET')
|
||||
setStage('dev')
|
||||
setFunctionName('')
|
||||
setTestBody('{}')
|
||||
@@ -68,13 +75,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {
|
||||
path: createPath,
|
||||
parentResourceId: selectedParent?.resourceId,
|
||||
pathPart,
|
||||
method,
|
||||
stage,
|
||||
functionName,
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}),
|
||||
})
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => testApiGatewayRoute(cloud, resource!.id, {
|
||||
path,
|
||||
method,
|
||||
method: deployedHttpMethod,
|
||||
stage,
|
||||
body: testBody,
|
||||
headers: parseHeaders(testHeaders),
|
||||
@@ -132,9 +146,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}
|
||||
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
|
||||
const apiResources = routesQuery.data?.resources ?? []
|
||||
const rootResource = apiResources.find((item) => item.path === '/')
|
||||
const selectedParent = apiResources.find((item) => item.resourceId === parentResourceId) ?? rootResource
|
||||
const createPath = selectedParent ? childResourcePath(selectedParent.path, pathPart) : `/${pathPart}`
|
||||
const deployedHttpMethod = method === 'ANY' ? anyTestMethod : method
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName && selectedParent && pathPart) && !routeMutation.isPending
|
||||
const existingPath = apiResources.some((item) => item.path === createPath)
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, deployedHttpMethod, testHeaders, testQuery, testBody)
|
||||
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
|
||||
|
||||
@@ -154,6 +173,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
autoRunKey={logsRefreshKey}
|
||||
/>
|
||||
)}
|
||||
{functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
{baseInvokeUrl && <div className="dynamic-field">
|
||||
@@ -168,20 +188,27 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
if (canCreate) routeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Route path</span>
|
||||
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
||||
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
|
||||
<span>Parent resource</span>
|
||||
<select aria-label="Parent resource" className="input" value={selectedParent?.resourceId ?? ''} onChange={(event) => setParentResourceId(event.target.value)} required>
|
||||
{apiResources.map((item) => <option key={item.resourceId} value={item.resourceId}>{item.path}</option>)}
|
||||
</select>
|
||||
<small>Select where the new resource belongs.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Path part</span>
|
||||
<input aria-label="Path part" className="input" value={pathPart} onChange={(event) => setPathPart(event.target.value)} placeholder="orders or {id}" required/>
|
||||
<small>{existingPath ? `Adds ${method} to existing ${createPath}.` : <>Creates <code>{createPath}</code>. Path parameters and greedy parameters are supported.</>}</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Method</span>
|
||||
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
<select aria-label="Method" className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{CONFIGURABLE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<small>Lambda proxy method.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Lambda function</span>
|
||||
<select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<select aria-label="Lambda function" className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<option value="">Select a Lambda function</option>
|
||||
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
|
||||
</select>
|
||||
@@ -215,18 +242,28 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
{routesQuery.isLoading && <p className="muted compact-text">Loading configured methods...</p>}
|
||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
|
||||
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No configured methods yet. Create a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
const resourceMethods = routesQuery.data.routes
|
||||
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
|
||||
.map((item) => item.method)
|
||||
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
{routesQuery.data && apiResources.length === 0 && <p className="muted compact-text">API Gateway returned no resources.</p>}
|
||||
{routesQuery.data && apiResources.length > 0 && (
|
||||
<div className="apigateway-resource-tree" role="tree" aria-label="API Gateway resources">
|
||||
{resourceTree(apiResources).map(({resource: treeResource, children, depth}) => {
|
||||
const resourceMethods = treeResource.methods.filter((item) => CORS_METHODS.includes(item.method)).map((item) => item.method)
|
||||
return <div className="apigateway-resource-node" role="treeitem" aria-level={depth + 1} key={treeResource.resourceId} style={{'--tree-depth': depth} as CSSProperties}>
|
||||
<div className="apigateway-resource-heading">
|
||||
<button type="button" className="apigateway-resource-select" onClick={() => setParentResourceId(treeResource.resourceId)}>
|
||||
<code>{treeResource.path}</code>
|
||||
<small>{treeResource.methods.length === 0 ? 'No methods' : `${treeResource.methods.length} method${treeResource.methods.length === 1 ? '' : 's'}`}</small>
|
||||
</button>
|
||||
{resourceMethods.length > 0 && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: treeResource.resourceId, path: treeResource.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
</div>
|
||||
{treeResource.methods.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
setPath(route.path)
|
||||
setMethod(route.method)
|
||||
setFunctionName(route.functionName ?? '')
|
||||
@@ -239,28 +276,26 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<code>{route.path}</code>
|
||||
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
|
||||
</button>
|
||||
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
<span>Delete {route.method}?</span>
|
||||
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
|
||||
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
|
||||
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
|
||||
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-confirm">
|
||||
<strong>Enable CORS for {corsConfirmation.path}</strong>
|
||||
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((method) => method !== value) : [...current, value])}/>{value}</label>)}</div>
|
||||
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
|
||||
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
|
||||
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
|
||||
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
{corsConfirmation?.resourceId === treeResource.resourceId && <div className="apigateway-route-confirm apigateway-cors-confirm">
|
||||
<strong>Enable CORS for {corsConfirmation.path}</strong>
|
||||
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div>
|
||||
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
|
||||
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
|
||||
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
|
||||
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
|
||||
</div>}
|
||||
{children.length > 0 && <span className="sr-only">{children.length} child resources</span>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
@@ -274,11 +309,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Request path</span>
|
||||
<input aria-label="Request path" className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders/123" required/>
|
||||
<small>Replace path parameters with concrete values for testing.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Stage variables (JSON)</span>
|
||||
<textarea className="textarea code-textarea small" value={stageVariables} onChange={(event) => setStageVariables(event.target.value)} spellCheck={false}/>
|
||||
<small>Optional variables available during this method test only.</small>
|
||||
@@ -294,22 +334,22 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<div>
|
||||
<p className="eyebrow">Deployed HTTP Test</p>
|
||||
<h3><Send size={15}/> Send deployed request</h3>
|
||||
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||
<p className="muted compact-text">Invokes <code>{deployedHttpMethod} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-request-flow" aria-label="API request flow">
|
||||
<div className="apigateway-flow-step">
|
||||
<Send size={16}/>
|
||||
<div><strong>Test request</strong><small>{method} {path}</small></div>
|
||||
<div><strong>Test request</strong><small>{deployedHttpMethod} {path}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Waypoints size={16}/>
|
||||
<ServiceIcon cloud="aws" iconKey="apigateway" size={22}/>
|
||||
<div><strong>API Gateway</strong><small>{resource.name} · {stage}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Braces size={16}/>
|
||||
<ServiceIcon cloud="aws" iconKey="serverless" size={22}/>
|
||||
<div><strong>Lambda proxy</strong><small>{functionName || 'Connected Lambda'}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
@@ -319,11 +359,18 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!testMutation.isPending) testMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!testMutation.isPending) testMutation.mutate()
|
||||
}}>
|
||||
{method === 'ANY' && <label className="dynamic-field">
|
||||
<span>HTTP verb for deployed request</span>
|
||||
<select aria-label="HTTP verb for deployed request" className="input" value={anyTestMethod} onChange={(event) => setAnyTestMethod(event.target.value)}>
|
||||
{DEPLOYED_HTTP_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<small>ANY is API Gateway configuration, not an HTTP request verb.</small>
|
||||
</label>}
|
||||
<label className="dynamic-field">
|
||||
<span>Request headers (JSON)</span>
|
||||
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
|
||||
</label>
|
||||
@@ -400,7 +447,33 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
)
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD']
|
||||
const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
|
||||
|
||||
function childResourcePath(parentPath: string, pathPart: string): string {
|
||||
return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}`
|
||||
}
|
||||
|
||||
function resourceTree(resources: ApiGatewayResourceOverview[]): Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> {
|
||||
const byParent = new Map<string | null, ApiGatewayResourceOverview[]>()
|
||||
for (const resource of resources) {
|
||||
const siblings = byParent.get(resource.parentId) ?? []
|
||||
siblings.push(resource)
|
||||
byParent.set(resource.parentId, siblings)
|
||||
}
|
||||
for (const siblings of byParent.values()) siblings.sort((left, right) => left.path.localeCompare(right.path))
|
||||
|
||||
const flattened: Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> = []
|
||||
const visit = (resource: ApiGatewayResourceOverview, depth: number) => {
|
||||
const children = byParent.get(resource.resourceId) ?? []
|
||||
flattened.push({resource, children, depth})
|
||||
for (const child of children) visit(child, depth + 1)
|
||||
}
|
||||
const roots = resources.filter((resource) => resource.parentId === null || !resources.some((candidate) => candidate.resourceId === resource.parentId))
|
||||
for (const root of roots.sort((left, right) => left.path.localeCompare(right.path))) visit(root, 0)
|
||||
return flattened
|
||||
}
|
||||
|
||||
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
|
||||
return <div className="error-text compact-text">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import {describe, expect, test, vi} from 'vitest'
|
||||
import {CloudFormationCreatePanel} from './CloudFormationCreatePanel'
|
||||
|
||||
describe('CloudFormationCreatePanel', () => {
|
||||
test('loads a YAML template file into the template editor', async () => {
|
||||
render(<CloudFormationCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={vi.fn()}/>)
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, {target: {files: [new File(['Resources:\n Queue:\n Type: AWS::SQS::Queue\n'], 'queue.yaml', {type: 'text/yaml'})]}})
|
||||
await waitFor(() => expect(screen.getByLabelText('Template')).toHaveValue('Resources:\n Queue:\n Type: AWS::SQS::Queue\n'))
|
||||
expect(screen.getByText('Loaded queue.yaml')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('submits a stack with an S3 template URL without inline content', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<CloudFormationCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
||||
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {Upload} from 'lucide-react'
|
||||
|
||||
export function CloudFormationCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: {isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void}) {
|
||||
const [stackName, setStackName] = useState('')
|
||||
const [templateBody, setTemplateBody] = useState('')
|
||||
const [templateUrl, setTemplateUrl] = useState('')
|
||||
const [parameters, setParameters] = useState('{}')
|
||||
const [capabilities, setCapabilities] = useState('')
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function loadTemplate(file?: File) {
|
||||
if (!file) return
|
||||
if (!/\.(json|ya?ml)$/i.test(file.name)) {
|
||||
setFileError('Choose a JSON, YAML, or YML template file.')
|
||||
return
|
||||
}
|
||||
setTemplateBody(await file.text())
|
||||
setFileName(file.name)
|
||||
setFileError(null)
|
||||
}
|
||||
|
||||
const hasTemplate = Boolean(templateBody.trim() || templateUrl.trim())
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (hasTemplate) onSubmit({stackName, templateBody, templateUrl, parameters, capabilities}) }}>
|
||||
<label className="dynamic-field"><span>Stack Name<em className="field-required">*</em></span><input className="input" value={stackName} required pattern="^[a-zA-Z][a-zA-Z0-9\-]{0,127}$" maxLength={128} onChange={(event) => setStackName(event.target.value)} placeholder="orders-stack"/><small>Start with a letter. Use letters, numbers, and hyphens.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Template</span><textarea aria-label="Template" className="textarea code-textarea" value={templateBody} rows={14} spellCheck={false} onChange={(event) => setTemplateBody(event.target.value)} placeholder="Paste CloudFormation JSON or YAML, or upload a template file."/><small>Provide this or a Template S3 URL below.</small></label>
|
||||
<input ref={fileInput} type="file" accept=".json,.yaml,.yml,application/json,application/x-yaml,text/yaml" hidden onChange={(event) => void loadTemplate(event.target.files?.[0])}/>
|
||||
<div style={{gridColumn: '1 / -1', display: 'flex', gap: '8px', alignItems: 'center'}}><button className="button" type="button" disabled={isSubmitting} onClick={() => fileInput.current?.click()}><Upload size={14}/> Upload JSON or YAML</button>{fileName && <span className="muted compact-text">Loaded {fileName}</span>}</div>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Template S3 URL</span><input aria-label="Template S3 URL" className="input" value={templateUrl} onChange={(event) => setTemplateUrl(event.target.value)} placeholder="https://bucket.s3.amazonaws.com/templates/stack.yaml"/><small>Provide this or an inline/uploaded template above.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={parameters} onChange={(event) => setParameters(event.target.value)} spellCheck={false}/><small>Optional object of parameter keys to string values.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/><small>Optional comma-separated AWS capabilities.</small></label>
|
||||
{fileError && <div className="form-error" role="alert">{fileError}</div>}
|
||||
{submitError && <div className="form-error" role="alert">{submitError}</div>}
|
||||
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px'}}><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting || !hasTemplate}>Create stack</button></div>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {Copy, Play, Plus, ShieldCheck, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createCloudFormationChangeSet, deleteCloudFormationChangeSet, executeCloudFormationChangeSet, getCloudResource, listCloudFormationChangeSets, updateCloudFormationTerminationProtection} from '@/api/cloudProxyClient'
|
||||
import {useState} from 'react'
|
||||
import {CloudFormationTemplateUpload} from './CloudFormationTemplateUpload'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
export function CloudFormationStackPanel({resource}: {resource?: CloudResource}) {
|
||||
if (!resource || resource.service !== 'iac' || resource.type !== 'stack') return null
|
||||
return <CloudFormationStackPanelContent resource={resource}/>
|
||||
}
|
||||
|
||||
function CloudFormationStackPanelContent({resource}: {resource: CloudResource}) {
|
||||
const detail = useQuery({queryKey: ['cloudformation-stack-detail', resource.id], queryFn: ({signal}) => getCloudResource('aws', 'iac', resource.id, signal), refetchInterval: (query) => /(?:_IN_PROGRESS|_PENDING)$/.test(String(query.state.data?.status ?? resource.status ?? '')) ? 1000 : false})
|
||||
const stack = detail.data ?? resource
|
||||
const [changeSetName, setChangeSetName] = useState('')
|
||||
const [templateBody, setTemplateBody] = useState(String(resource.metadata.templateBody ?? ''))
|
||||
const [templateError, setTemplateError] = useState<string | null>(null)
|
||||
const [changeSetParameters, setChangeSetParameters] = useState('{}')
|
||||
const [changeSetCapabilities, setChangeSetCapabilities] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const changeSets = useQuery({queryKey: ['cloudformation-change-sets', resource.id], queryFn: ({signal}) => listCloudFormationChangeSets('aws', resource.id, signal)})
|
||||
const refresh = () => void queryClient.invalidateQueries({queryKey: ['cloudformation-change-sets', resource.id]})
|
||||
const createChangeSet = useMutation({mutationFn: () => createCloudFormationChangeSet('aws', resource.id, {name: changeSetName.trim(), templateBody, parameters: JSON.parse(changeSetParameters) as Record<string, string>, capabilities: changeSetCapabilities.split(',').map((item) => item.trim()).filter(Boolean)}), onSuccess: refresh})
|
||||
const executeChangeSet = useMutation({mutationFn: (name: string) => executeCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
||||
const deleteChangeSet = useMutation({mutationFn: (name: string) => deleteCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
||||
const protection = useMutation({mutationFn: (enabled: boolean) => updateCloudFormationTerminationProtection('aws', resource.id, enabled), onSuccess: () => void queryClient.invalidateQueries({queryKey: ['cloudformation-stack-detail', resource.id]})})
|
||||
const outputs = array(stack.metadata.outputs)
|
||||
const resources = array(stack.metadata.resources).map((item) => ({...item, consolePath: consolePath(String(item.resourceType ?? ''), String(item.physicalId ?? ''))}))
|
||||
const events = array(stack.metadata.events)
|
||||
const template = typeof stack.metadata.templateBody === 'string' ? stack.metadata.templateBody : null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Stack workspace</p><h3>{stack.name}</h3><p className="muted compact-text">Stack lifecycle is asynchronous. Refresh the stack list to observe status transitions.</p></div></div>
|
||||
<StackTable title="Outputs" columns={['key', 'value', 'description']} rows={outputs}/>
|
||||
<StackTable title="Resources" columns={['logicalId', 'physicalId', 'resourceType', 'status']} rows={resources}/>
|
||||
<StackTable title="Recent events" columns={['timestamp', 'logicalId', 'status', 'reason']} rows={events}/>
|
||||
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Stack actions</p><h3><ShieldCheck size={15}/> Termination protection: {stack.metadata.enableTerminationProtection === true ? 'Enabled' : 'Disabled'}</h3></div><button className="button compact" type="button" disabled={protection.isPending} onClick={() => protection.mutate(stack.metadata.enableTerminationProtection !== true)}>{stack.metadata.enableTerminationProtection === true ? 'Disable protection' : 'Enable protection'}</button></div><p className="muted compact-text">{stack.metadata.enableTerminationProtection === true ? 'Enabled: DeleteStack is blocked until this is disabled.' : 'Disabled: the stack can be deleted.'}</p></section>
|
||||
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Change sets</p><h3>Preview and deploy</h3><p className="muted compact-text">Resource import is unavailable because Floci does not yet implement AWS resource adoption.</p></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (changeSetName.trim() && templateBody.trim()) createChangeSet.mutate() }}><label className="dynamic-field"><span>Change set name</span><input className="input" value={changeSetName} onChange={(event) => setChangeSetName(event.target.value)} required/></label><label className="dynamic-field dynamic-field--span"><span>Proposed template</span><textarea className="textarea code-textarea" value={templateBody} onChange={(event) => setTemplateBody(event.target.value)} rows={10} required spellCheck={false}/></label><div><CloudFormationTemplateUpload onLoad={(template) => { setTemplateBody(template); setTemplateError(null) }} onError={setTemplateError}/></div><label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={changeSetParameters} onChange={(event) => setChangeSetParameters(event.target.value)} spellCheck={false}/></label><label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={changeSetCapabilities} onChange={(event) => setChangeSetCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/></label>{templateError && <div className="form-error" role="alert">{templateError}</div>}<button className="button primary" type="submit" disabled={createChangeSet.isPending}><Plus size={13}/> Create change set</button></form>{changeSets.data?.map((changeSet) => <div className="apigateway-route-item" key={changeSet.id}><div className="apigateway-route-select"><code>{changeSet.name}</code><small>{changeSet.status} / {changeSet.executionStatus}</small>{changeSet.changes.map((change, index) => <small key={index}>{change.action} {change.resourceType} {change.logicalResourceId}</small>)}</div><button className="button compact" type="button" disabled={changeSet.status !== 'CREATE_COMPLETE' && changeSet.status !== 'AVAILABLE' || executeChangeSet.isPending} onClick={() => executeChangeSet.mutate(changeSet.name)}><Play size={13}/> Execute</button><button className="icon-btn" type="button" aria-label={`Delete change set ${changeSet.name}`} disabled={deleteChangeSet.isPending} onClick={() => deleteChangeSet.mutate(changeSet.name)}><Trash2 size={13}/></button></div>)}</section>
|
||||
{template && <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Submitted template</p><h3>Original template</h3></div><button className="button compact" type="button" onClick={() => void navigator.clipboard.writeText(template)}><Copy size={13}/> Copy</button></div><pre className="metadata-block">{template}</pre></section>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StackTable({title, columns, rows}: {title: string; columns: string[]; rows: Array<Record<string, unknown>>}) {
|
||||
return <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">{title}</p><h3>{rows.length}</h3></div></div>{rows.length === 0 ? <p className="muted compact-text">No {title.toLowerCase()} reported.</p> : <div className="cosmos-items-table"><table className="table"><thead><tr>{columns.map((column) => <th key={column}>{label(column)}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={`${title}-${index}`}>{columns.map((column) => <td key={column}>{column === 'physicalId' && typeof row.consolePath === 'string' ? <a href={row.consolePath}><code>{string(row[column])}</code></a> : <code>{string(row[column])}</code>}</td>)}</tr>)}</tbody></table></div>}</section>
|
||||
}
|
||||
|
||||
function array(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : [] }
|
||||
function string(value: unknown): string { return value === null || value === undefined ? '-' : String(value) }
|
||||
function label(value: string): string { return value.replace(/([A-Z])/g, ' $1').replace(/^./, (letter) => letter.toUpperCase()) }
|
||||
function consolePath(resourceType: string, physicalId: string): string | null {
|
||||
const parts = physicalId.split('/').filter(Boolean)
|
||||
const name = parts[parts.length - 1] ?? physicalId
|
||||
const service = resourceType === 'AWS::DynamoDB::Table' ? 'nosql' : resourceType === 'AWS::SQS::Queue' ? 'messaging' : resourceType === 'AWS::Lambda::Function' ? 'serverless' : resourceType === 'AWS::Events::EventBus' ? 'events' : resourceType === 'AWS::StepFunctions::StateMachine' ? 'workflows' : null
|
||||
if (!service || !name) return null
|
||||
const id = service === 'events' ? `event-bus:${name}` : name
|
||||
return `/cloud-explorer/aws/${service}?resource=${encodeURIComponent(id)}`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useRef} from 'react'
|
||||
import {parse} from 'yaml'
|
||||
import {Upload} from 'lucide-react'
|
||||
|
||||
export function CloudFormationTemplateUpload({onLoad, onError}: {onLoad: (template: string, name: string) => void; onError: (message: string) => void}) {
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
async function load(file?: File) {
|
||||
if (!file) return
|
||||
if (!/\.(json|ya?ml)$/i.test(file.name)) return onError('Choose a JSON, YAML, or YML template file.')
|
||||
const template = await file.text()
|
||||
try {
|
||||
if (/\.json$/i.test(file.name)) JSON.parse(template)
|
||||
else parse(template)
|
||||
onLoad(template, file.name)
|
||||
} catch {
|
||||
onError(`${file.name} is not valid ${/\.json$/i.test(file.name) ? 'JSON' : 'YAML'}.`)
|
||||
}
|
||||
}
|
||||
return <><input ref={input} type="file" accept=".json,.yaml,.yml,application/json,application/x-yaml,text/yaml" hidden onChange={(event) => void load(event.target.files?.[0])}/><button className="button compact" type="button" onClick={() => input.current?.click()}><Upload size={13}/> Upload JSON or YAML</button></>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {useState} from 'react'
|
||||
import {CloudFormationTemplateUpload} from './CloudFormationTemplateUpload'
|
||||
|
||||
export function CloudFormationUpdatePanel({initialTemplate, isSubmitting, submitError, onCancel, onSubmit}: {initialTemplate: string; isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void}) {
|
||||
const [templateBody, setTemplateBody] = useState(initialTemplate)
|
||||
const [parameters, setParameters] = useState('{}')
|
||||
const [capabilities, setCapabilities] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({templateBody, parameters, capabilities}) }}><label className="dynamic-field dynamic-field--span"><span>Template</span><textarea className="textarea code-textarea" value={templateBody} onChange={(event) => setTemplateBody(event.target.value)} rows={14} required spellCheck={false}/><small>Paste/edit JSON or YAML, or replace it from a file.</small></label><div style={{gridColumn: '1 / -1'}}><CloudFormationTemplateUpload onLoad={(template) => { setTemplateBody(template); setError(null) }} onError={setError}/></div><label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={parameters} onChange={(event) => setParameters(event.target.value)} spellCheck={false}/></label><label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/></label>{(error || submitError) && <div className="form-error" role="alert">{error ?? submitError}</div>}<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px'}}><button className="button" type="button" onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>Save Changes</button></div></form>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
@@ -49,6 +49,10 @@ 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";
|
||||
@@ -62,7 +66,7 @@ import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugge
|
||||
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
import {workflowStudioPath} from "@/lib/workflowStudio";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
interface DynamicResourceViewProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -90,6 +94,7 @@ export function DynamicResourceView({
|
||||
onOpenInfo,
|
||||
}: DynamicResourceViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
@@ -105,6 +110,7 @@ export function DynamicResourceView({
|
||||
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") {
|
||||
@@ -139,6 +145,16 @@ export function DynamicResourceView({
|
||||
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) => {
|
||||
@@ -284,6 +300,10 @@ export function DynamicResourceView({
|
||||
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) => {
|
||||
@@ -797,13 +817,20 @@ export function DynamicResourceView({
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(values) => createMut.mutate({values})}
|
||||
/>
|
||||
) : service === "workflows" && cloud === "aws" ? (
|
||||
) : 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})}
|
||||
/>
|
||||
) : (
|
||||
<DynamicFormRenderer
|
||||
key={`${cloud}:${service}:create`}
|
||||
@@ -886,6 +913,8 @@ export function DynamicResourceView({
|
||||
{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} />
|
||||
)}
|
||||
@@ -1015,7 +1044,7 @@ export function DynamicResourceView({
|
||||
onCancel={() => setEditingResource(null)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
) : <DynamicFormRenderer
|
||||
) : 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}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DynamoDbItemEditor} from './DynamoDbItemEditor'
|
||||
|
||||
test('preserves typed attributes while switching between form and DynamoDB JSON', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'id', type: 'S'}]}
|
||||
initialDocument={{id: {S: 'order-1'}, total: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={vi.fn()}
|
||||
/>)
|
||||
|
||||
await user.click(screen.getByRole('tab', {name: 'DynamoDB JSON'}))
|
||||
const json = screen.getByLabelText('DynamoDB AttributeValue JSON')
|
||||
expect((json as HTMLTextAreaElement).value).toContain('"N": "9007199254740993"')
|
||||
expect((json as HTMLTextAreaElement).value).toContain('"SS"')
|
||||
|
||||
await user.click(screen.getByRole('tab', {name: 'Form'}))
|
||||
expect(screen.getAllByLabelText('Attribute value').map((input) => input.getAttribute('value'))).toContain('9007199254740993')
|
||||
expect(screen.getByLabelText('Attribute values')).toHaveValue('new\npriority')
|
||||
})
|
||||
|
||||
test('submits DynamoDB AttributeValue JSON from the typed form', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'id', type: 'S'}]}
|
||||
initialDocument={{id: {S: 'order-1'}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', {name: 'Add new attribute'}), 'S')
|
||||
const names = screen.getAllByLabelText('Attribute name')
|
||||
await user.type(names[names.length - 1], 'active')
|
||||
const values = screen.getAllByLabelText('Attribute value')
|
||||
await user.type(values[values.length - 1], 'yes')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({id: {S: 'order-1'}, active: {S: 'yes'}})
|
||||
})
|
||||
|
||||
test('renders a blank numeric key draft and validates it on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'customerId', type: 'N'}]}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>)
|
||||
|
||||
const keyInput = screen.getByLabelText('Attribute value')
|
||||
expect(keyInput).toHaveValue('')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
expect(screen.getByText('Attribute customerId must be a DynamoDB number.')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(keyInput, '42')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
expect(onSubmit).toHaveBeenCalledWith({customerId: {N: '42'}})
|
||||
})
|
||||
|
||||
test('renders required keys before other attributes in the Attributes workspace', () => {
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'pk', type: 'S'}, {name: 'sk', type: 'N'}]}
|
||||
initialDocument={{description: {S: 'order'}, sk: {N: '2'}, pk: {S: 'customer'}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={vi.fn()}
|
||||
/>)
|
||||
|
||||
expect(screen.getByRole('heading', {name: 'Create item'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', {name: 'Attributes'})).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Attribute name').map((input) => input.getAttribute('value'))).toEqual(['pk', 'sk', 'description'])
|
||||
expect(screen.getAllByText('Required key')).toHaveLength(2)
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import {FormEvent, useEffect, useState} from 'react'
|
||||
import {Plus, Trash2} from 'lucide-react'
|
||||
import type {DynamoKeyField} from '@/lib/dynamodbItem'
|
||||
|
||||
export type DynamoAttributeType = 'S' | 'N' | 'BOOL' | 'NULL' | 'L' | 'M' | 'SS' | 'NS' | 'B' | 'BS'
|
||||
|
||||
interface AttributeRow {
|
||||
id: number
|
||||
name: string
|
||||
type: DynamoAttributeType
|
||||
value: string
|
||||
children: AttributeRow[]
|
||||
}
|
||||
|
||||
interface DynamoDbItemEditorProps {
|
||||
editing?: boolean
|
||||
keyFields: DynamoKeyField[]
|
||||
initialDocument?: Record<string, unknown>
|
||||
isPending: boolean
|
||||
submitError?: string
|
||||
onClose: () => void
|
||||
onSubmit: (item: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<DynamoAttributeType, string> = {
|
||||
S: 'String', N: 'Number', BOOL: 'Boolean', NULL: 'Null', L: 'List', M: 'Map',
|
||||
SS: 'String Set', NS: 'Number Set', B: 'Binary', BS: 'Binary Set',
|
||||
}
|
||||
|
||||
const NESTED_TYPES: DynamoAttributeType[] = ['S', 'N', 'BOOL', 'NULL', 'SS', 'NS', 'B', 'BS']
|
||||
const NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?$/
|
||||
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
export function DynamoDbItemEditor({
|
||||
editing = false, keyFields, initialDocument, isPending, submitError, onClose, onSubmit,
|
||||
}: DynamoDbItemEditorProps) {
|
||||
const [rows, setRows] = useState<AttributeRow[]>([])
|
||||
const [json, setJson] = useState('{}')
|
||||
const [mode, setMode] = useState<'form' | 'json'>('form')
|
||||
const [validationError, setValidationError] = useState<string>()
|
||||
const [nextId, setNextId] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const document = initialDocument ?? defaultDocument(keyFields)
|
||||
const initialRows = documentRows(document, keyFields)
|
||||
setRows(initialRows)
|
||||
setJson(JSON.stringify(document, null, 2))
|
||||
setMode('form')
|
||||
setValidationError(undefined)
|
||||
setNextId(initialRows.reduce((maximum, row) => Math.max(maximum, row.id, ...row.children.map((child) => child.id)), 0) + 1)
|
||||
}, [initialDocument, keyFields])
|
||||
|
||||
function addRow(type: DynamoAttributeType = 'S', childrenOf?: number) {
|
||||
const row: AttributeRow = {id: nextId, name: '', type, value: '', children: []}
|
||||
setNextId(nextId + 1)
|
||||
if (childrenOf === undefined) {
|
||||
setRows([...rows, row])
|
||||
} else {
|
||||
setRows(updateRow(rows, childrenOf, (parent) => ({...parent, children: [...parent.children, row]})))
|
||||
}
|
||||
}
|
||||
|
||||
function changeRow(id: number, change: Partial<AttributeRow>) {
|
||||
setRows(updateRow(rows, id, (row) => ({...row, ...change, children: change.type && change.type !== row.type ? [] : row.children})))
|
||||
setValidationError(undefined)
|
||||
}
|
||||
|
||||
function removeRow(id: number) {
|
||||
setRows(removeRowById(rows, id))
|
||||
setValidationError(undefined)
|
||||
}
|
||||
|
||||
function changeMode(nextMode: 'form' | 'json') {
|
||||
try {
|
||||
if (nextMode === 'json') {
|
||||
setJson(JSON.stringify(rowsToDocument(rows), null, 2))
|
||||
} else {
|
||||
const parsed = parseAttributeValueDocument(json)
|
||||
const parsedRows = documentRows(parsed, keyFields)
|
||||
setRows(parsedRows)
|
||||
setNextId(parsedRows.reduce((maximum, row) => Math.max(maximum, row.id, ...row.children.map((child) => child.id)), 0) + 1)
|
||||
}
|
||||
setMode(nextMode)
|
||||
setValidationError(undefined)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : 'Invalid DynamoDB AttributeValue JSON.')
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const item = mode === 'form' ? rowsToDocument(rows) : parseAttributeValueDocument(json)
|
||||
validateRequiredKeys(item, keyFields)
|
||||
setValidationError(undefined)
|
||||
onSubmit(item)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : 'Invalid DynamoDB item.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dynamodb-item-editor">
|
||||
<form className="dynamodb-item-editor-form" aria-label={editing ? 'Edit item' : 'Create item'} onSubmit={submit}>
|
||||
<header className="dynamodb-item-editor-header">
|
||||
<div>
|
||||
<h1>{editing ? 'Edit item' : 'Create item'}</h1>
|
||||
<p>Primary key attributes are required. Binary values use base64. A matching key replaces the item.</p>
|
||||
</div>
|
||||
<div className="dynamodb-editor-tabs" role="tablist" aria-label="Item editor mode">
|
||||
<button className={mode === 'form' ? 'active' : ''} type="button" role="tab" aria-selected={mode === 'form'} onClick={() => changeMode('form')}>Form</button>
|
||||
<button className={mode === 'json' ? 'active' : ''} type="button" role="tab" aria-selected={mode === 'json'} onClick={() => changeMode('json')}>DynamoDB JSON</button>
|
||||
</div>
|
||||
</header>
|
||||
{mode === 'form' ? (
|
||||
<div className="dynamodb-attribute-rows">
|
||||
<section className="dynamodb-attributes-section" aria-labelledby="dynamodb-attributes-heading">
|
||||
<div className="dynamodb-attribute-table-heading"><h2 id="dynamodb-attributes-heading">Attributes</h2><select className="select" aria-label="Add new attribute" value="" disabled={isPending} onChange={(event) => { if (event.target.value) addRow(event.target.value as DynamoAttributeType) }}><option value="">Add new attribute</option>{(Object.keys(TYPE_LABELS) as DynamoAttributeType[]).map((type) => <option key={type} value={type}>{TYPE_LABELS[type]}</option>)}</select></div>
|
||||
<div className="dynamodb-attribute-column-headings" aria-hidden="true"><span>Attribute name</span><span>Value</span><span>Type</span><span>Action</span></div>
|
||||
{rows.map((row) => <AttributeEditorRow key={row.id} row={row} keyFields={keyFields} nested={false} disabled={isPending} onChange={changeRow} onRemove={removeRow} onAddChild={(parentId) => addRow('S', parentId)}/>) }
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<textarea className={`textarea code-textarea record-modal-editor ${validationError ? 'invalid' : ''}`} aria-label="DynamoDB AttributeValue JSON" value={json} onChange={(event) => { setJson(event.target.value); setValidationError(undefined) }} spellCheck={false} disabled={isPending}/>
|
||||
)}
|
||||
{(validationError || submitError) && <div className="form-error">{validationError ?? submitError}</div>}
|
||||
<div className="dynamodb-item-editor-actions">
|
||||
<button className="button" type="button" disabled={isPending} onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" type="submit" disabled={isPending}>{isPending ? 'Saving' : editing ? 'Save item' : 'Create item'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AttributeEditorRow({row, keyFields, nested, disabled, onChange, onRemove, onAddChild}: {row: AttributeRow; keyFields: DynamoKeyField[]; nested: boolean; disabled: boolean; onChange: (id: number, change: Partial<AttributeRow>) => void; onRemove: (id: number) => void; onAddChild: (parentId: number) => void}) {
|
||||
const key = !nested ? keyFields.find((field) => field.name === row.name) : undefined
|
||||
const types = nested ? NESTED_TYPES : Object.keys(TYPE_LABELS) as DynamoAttributeType[]
|
||||
const isContainer = row.type === 'L' || row.type === 'M'
|
||||
return <div className={`dynamodb-attribute-row ${nested ? 'nested' : ''} ${key ? 'key' : ''}`}>
|
||||
<input className="input" aria-label={nested ? 'Nested attribute name' : 'Attribute name'} placeholder={row.type === 'L' ? 'List item' : 'Attribute name'} value={row.name} disabled={disabled || (!nested && Boolean(key)) || (nested && row.type === 'L')} onChange={(event) => onChange(row.id, {name: event.target.value})}/>
|
||||
{isContainer ? <div className="dynamodb-nested-rows">
|
||||
{row.children.map((child) => <AttributeEditorRow key={child.id} row={child} keyFields={[]} nested disabled={disabled} onChange={onChange} onRemove={onRemove} onAddChild={onAddChild}/>) }
|
||||
<button className="button compact" type="button" disabled={disabled} onClick={() => onAddChild(row.id)}><Plus size={12}/>Add {row.type === 'M' ? 'field' : 'item'}</button>
|
||||
</div> : <AttributeValueInput row={row} disabled={disabled} onChange={onChange}/>}
|
||||
<select className="select" aria-label="Attribute type" value={row.type} disabled={disabled || Boolean(key)} onChange={(event) => onChange(row.id, {type: event.target.value as DynamoAttributeType})}>
|
||||
{types.map((type) => <option key={type} value={type}>{TYPE_LABELS[type]}</option>)}
|
||||
</select>
|
||||
{!key && <button className="icon-btn" type="button" aria-label={`Remove ${row.name || 'attribute'}`} disabled={disabled} onClick={() => onRemove(row.id)}><Trash2 size={14}/></button>}
|
||||
{key && <small className="dynamodb-key-badge">Required key</small>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function AttributeValueInput({row, disabled, onChange}: {row: AttributeRow; disabled: boolean; onChange: (id: number, change: Partial<AttributeRow>) => void}) {
|
||||
if (row.type === 'NULL') return <span className="muted">null</span>
|
||||
if (row.type === 'BOOL') return <select className="select" aria-label="Boolean value" value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}><option value="true">true</option><option value="false">false</option></select>
|
||||
const set = row.type === 'SS' || row.type === 'NS' || row.type === 'BS'
|
||||
return set
|
||||
? <textarea className="textarea" aria-label="Attribute values" placeholder="One value per line" value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}/>
|
||||
: <input className="input" aria-label="Attribute value" placeholder={row.type === 'B' ? 'Base64 value' : 'Value'} value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}/>
|
||||
}
|
||||
|
||||
function defaultDocument(keyFields: DynamoKeyField[]): Record<string, unknown> {
|
||||
return Object.fromEntries(keyFields.map(({name, type}) => [name, {[type ?? 'S']: ''}]))
|
||||
}
|
||||
|
||||
function documentRows(document: Record<string, unknown>, keyFields: DynamoKeyField[]): AttributeRow[] {
|
||||
let nextId = 1
|
||||
const required = keyFields.map((key) => [key.name, document[key.name] ?? {[key.type ?? 'S']: ''}] as const)
|
||||
const otherAttributes = Object.entries(document).filter(([name]) => !keyFields.some((key) => key.name === name))
|
||||
return [...required, ...otherAttributes].map(([name, value]) => attributeValueRow(name, value, () => nextId++, false, keyFields.some((key) => key.name === name)))
|
||||
}
|
||||
|
||||
function attributeValueRow(name: string, value: unknown, id: () => number, nested: boolean, allowEmptyKey = false): AttributeRow {
|
||||
const attribute = parseAttributeValue(value, allowEmptyKey)
|
||||
const type = Object.keys(attribute)[0] as DynamoAttributeType
|
||||
const raw = attribute[type]
|
||||
if (type === 'L') {
|
||||
if (nested) throw new Error('The form editor supports List and Map one nesting level deep.')
|
||||
return {id: id(), name, type, value: '', children: (raw as unknown[]).map((item) => attributeValueRow('', item, id, true))}
|
||||
}
|
||||
if (type === 'M') {
|
||||
if (nested) throw new Error('The form editor supports List and Map one nesting level deep.')
|
||||
return {id: id(), name, type, value: '', children: Object.entries(raw as Record<string, unknown>).map(([childName, child]) => attributeValueRow(childName, child, id, true))}
|
||||
}
|
||||
if (type === 'BOOL') return {id: id(), name, type, value: String(raw), children: []}
|
||||
if (type === 'NULL') return {id: id(), name, type, value: '', children: []}
|
||||
if (type === 'B') return {id: id(), name, type, value: String(raw), children: []}
|
||||
return {id: id(), name, type, value: Array.isArray(raw) ? raw.join('\n') : String(raw), children: []}
|
||||
}
|
||||
|
||||
function rowsToDocument(rows: AttributeRow[]): Record<string, unknown> {
|
||||
const document: Record<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
if (!row.name) throw new Error('Every attribute needs a name.')
|
||||
if (Object.prototype.hasOwnProperty.call(document, row.name)) throw new Error(`Attribute ${row.name} is defined more than once.`)
|
||||
document[row.name] = rowToAttributeValue(row)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
function rowToAttributeValue(row: AttributeRow): Record<string, unknown> {
|
||||
if (row.type === 'L') return {L: row.children.map(rowToAttributeValue)}
|
||||
if (row.type === 'M') return {M: rowsToDocument(row.children)}
|
||||
if (row.type === 'NULL') return {NULL: true}
|
||||
if (row.type === 'BOOL') return {BOOL: row.value === 'true'}
|
||||
if (row.type === 'N') {
|
||||
if (!NUMBER_PATTERN.test(row.value)) throw new Error(`Attribute ${row.name} must be a DynamoDB number.`)
|
||||
return {N: row.value}
|
||||
}
|
||||
if (row.type === 'B') {
|
||||
if (!isBase64(row.value)) throw new Error(`Binary attribute ${row.name} must be base64.`)
|
||||
return {B: row.value}
|
||||
}
|
||||
if (row.type === 'SS' || row.type === 'NS' || row.type === 'BS') {
|
||||
const values = row.value.split('\n').map((value) => value.trim()).filter(Boolean)
|
||||
if (!values.length) throw new Error(`Attribute ${row.name} must contain at least one value.`)
|
||||
if (row.type === 'NS' && values.some((value) => !NUMBER_PATTERN.test(value))) throw new Error(`Attribute ${row.name} must contain DynamoDB numbers.`)
|
||||
if (row.type === 'BS' && values.some((value) => !isBase64(value))) throw new Error(`Attribute ${row.name} must contain base64 values.`)
|
||||
return {[row.type]: values}
|
||||
}
|
||||
return {S: row.value}
|
||||
}
|
||||
|
||||
function parseAttributeValueDocument(json: string): Record<string, unknown> {
|
||||
let value: unknown
|
||||
try { value = JSON.parse(json) } catch { throw new Error('Invalid JSON.') }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Item must be a DynamoDB AttributeValue JSON object.')
|
||||
for (const attribute of Object.values(value as Record<string, unknown>)) parseAttributeValue(attribute)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseAttributeValue(value: unknown, allowEmptyKey = false): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Each attribute must be a DynamoDB AttributeValue object.')
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length !== 1 || !Object.prototype.hasOwnProperty.call(TYPE_LABELS, entries[0][0])) throw new Error('Each attribute must have exactly one DynamoDB AttributeValue type.')
|
||||
const [type, raw] = entries[0]
|
||||
if (type === 'S' && typeof raw === 'string') return value as Record<string, unknown>
|
||||
if (type === 'N' && typeof raw === 'string' && (NUMBER_PATTERN.test(raw) || allowEmptyKey && raw === '')) return value as Record<string, unknown>
|
||||
if (type === 'B' && typeof raw === 'string' && (isBase64(raw) || allowEmptyKey && raw === '')) return value as Record<string, unknown>
|
||||
if (type === 'BOOL' && typeof raw === 'boolean') return value as Record<string, unknown>
|
||||
if (type === 'NULL' && raw === true) return value as Record<string, unknown>
|
||||
if (type === 'SS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string')) return value as Record<string, unknown>
|
||||
if (type === 'NS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && NUMBER_PATTERN.test(item))) return value as Record<string, unknown>
|
||||
if (type === 'BS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isBase64(item))) return value as Record<string, unknown>
|
||||
if (type === 'L' && Array.isArray(raw) && raw.every((item) => {
|
||||
try { parseAttributeValue(item); return true } catch { return false }
|
||||
})) return value as Record<string, unknown>
|
||||
if (type === 'M' && raw && typeof raw === 'object' && !Array.isArray(raw) && Object.values(raw).every((item) => {
|
||||
try { parseAttributeValue(item); return true } catch { return false }
|
||||
})) return value as Record<string, unknown>
|
||||
throw new Error('Each attribute must use a valid DynamoDB AttributeValue.')
|
||||
}
|
||||
|
||||
function validateRequiredKeys(item: Record<string, unknown>, keyFields: DynamoKeyField[]) {
|
||||
for (const key of keyFields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(item, key.name)) throw new Error(`Key attribute ${key.name} is required.`)
|
||||
const value = parseAttributeValue(item[key.name])
|
||||
if (key.type && !Object.prototype.hasOwnProperty.call(value, key.type)) throw new Error(`Key attribute ${key.name} must use type ${key.type}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(rows: AttributeRow[], id: number, update: (row: AttributeRow) => AttributeRow): AttributeRow[] {
|
||||
return rows.map((row) => row.id === id ? update(row) : {...row, children: updateRow(row.children, id, update)})
|
||||
}
|
||||
|
||||
function removeRowById(rows: AttributeRow[], id: number): AttributeRow[] {
|
||||
return rows.filter((row) => row.id !== id).map((row) => ({...row, children: removeRowById(row.children, id)}))
|
||||
}
|
||||
|
||||
function isBase64(value: string): boolean {
|
||||
return value.length > 0 && value.length % 4 === 0 && BASE64_PATTERN.test(value)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {MemoryRouter, useLocation} from 'react-router-dom'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DynamoDbTableExplorer} from './DynamoDbTableExplorer'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
queryFlatItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const table: CloudResource = {
|
||||
id: 'orders', name: 'orders', cloud: 'aws', service: 'nosql', type: 'dynamodb-table',
|
||||
region: null, createdAt: null, metadata: {
|
||||
keySchema: [{AttributeName: 'pk', KeyType: 'HASH'}, {AttributeName: 'sk', KeyType: 'RANGE'}],
|
||||
attributeDefinitions: [
|
||||
{AttributeName: 'pk', AttributeType: 'S'}, {AttributeName: 'sk', AttributeType: 'N'},
|
||||
{AttributeName: 'status', AttributeType: 'S'}, {AttributeName: 'createdAt', AttributeType: 'S'},
|
||||
],
|
||||
globalSecondaryIndexes: [{IndexName: 'status-created', KeySchema: [
|
||||
{AttributeName: 'status', KeyType: 'HASH'}, {AttributeName: 'createdAt', KeyType: 'RANGE'},
|
||||
]}],
|
||||
},
|
||||
}
|
||||
|
||||
function Location() {
|
||||
return <output data-testid="location">{useLocation().pathname}</output>
|
||||
}
|
||||
|
||||
function renderExplorer() {
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<MemoryRouter initialEntries={['/cloud-explorer/aws/nosql/orders/data']}><QueryClientProvider client={queryClient}><DynamoDbTableExplorer cloud="aws" resource={table} runtimeReachable/><Location/></QueryClientProvider></MemoryRouter>)
|
||||
}
|
||||
|
||||
test('renders returned DynamoDB attributes as columns and bulk deletes selected items', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [
|
||||
{id: '{"pk":"order-1"}', collectionId: null, timestamp: null, body: {pk: 'order-1', status: 'new', total: 12, tags: ['priority', 'web']}, metadata: {key: {pk: 'order-1'}}},
|
||||
{id: '{"pk":"order-2"}', collectionId: null, timestamp: null, body: {pk: 'order-2', active: true, total: 8}, metadata: {key: {pk: 'order-2'}}},
|
||||
], nextCursor: null})
|
||||
apiMocks.deleteFlatItem.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderExplorer()
|
||||
|
||||
expect(await screen.findByRole('columnheader', {name: 'pk'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', {name: 'status'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', {name: 'active'})).toBeInTheDocument()
|
||||
expect(screen.queryByRole('columnheader', {name: 'Key'})).not.toBeInTheDocument()
|
||||
expect(screen.getByText('priority, web')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByLabelText('Select all items'))
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.click(screen.getByRole('button', {name: 'Delete item'}))
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Delete 2 items?')
|
||||
expect(screen.getByText('Actions').closest('details')).not.toHaveAttribute('open')
|
||||
await user.click(screen.getByRole('button', {name: 'Delete items'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-1"}')
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-2"}')
|
||||
})
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/cloud-explorer/aws/nosql/orders/data')
|
||||
})
|
||||
|
||||
test('delete menu action does not navigate to the item editor', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [
|
||||
{id: 'display-id', collectionId: null, timestamp: null, body: {pk: 'order-1'}, metadata: {key: {pk: 'order-1', sk: '2026-09-29'}}},
|
||||
], nextCursor: null})
|
||||
apiMocks.deleteFlatItem.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderExplorer()
|
||||
|
||||
await user.click(await screen.findByLabelText('Select item display-id'))
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.click(screen.getByRole('button', {name: 'Delete item'}))
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Delete item?')
|
||||
expect(screen.getByText('Actions').closest('details')).not.toHaveAttribute('open')
|
||||
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: 'Delete item'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-1","sk":"2026-09-29"}')
|
||||
})
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/cloud-explorer/aws/nosql/orders/data')
|
||||
})
|
||||
|
||||
test('runs Scan with a typed server-side filter and loads the next cursor', async () => {
|
||||
apiMocks.queryFlatItems.mockImplementation(async (_cloud, _service, _resource, query, cursor) => {
|
||||
if (!query.filter) return {items: [], nextCursor: null, count: 0}
|
||||
return cursor ? {
|
||||
items: [{id: 'two', collectionId: null, timestamp: null, body: {pk: 'two'}, metadata: {key: {pk: 'two'}}}],
|
||||
nextCursor: null, count: 1, scannedCount: 2, consumedCapacity: 0.25,
|
||||
} : {
|
||||
items: [{id: 'one', collectionId: null, timestamp: null, body: {pk: 'one'}, metadata: {key: {pk: 'one'}}}],
|
||||
nextCursor: 'opaque-page-2', count: 1, scannedCount: 7, consumedCapacity: 0.5,
|
||||
}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
await screen.findByText('No records')
|
||||
await user.type(screen.getByLabelText('Filter attribute'), 'total')
|
||||
await user.selectOptions(screen.getByLabelText('Filter operator'), 'GE')
|
||||
await user.selectOptions(screen.getByLabelText('Filter value type'), 'N')
|
||||
await user.type(screen.getByLabelText('Filter value'), '10')
|
||||
await user.click(screen.getByRole('button', {name: 'scan'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', {operation: 'scan', filter: {attribute: 'total', operator: 'GE', type: 'N', value: '10'}}, undefined, 100, expect.any(AbortSignal),
|
||||
))
|
||||
await user.click(await screen.findByRole('button', {name: 'Load more'}))
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', expect.any(Object), 'opaque-page-2', 100, expect.any(AbortSignal),
|
||||
))
|
||||
expect(await screen.findByText('two')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 returned, 9 scanned, 0.75 capacity units')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('adapts Query key fields to a selected index and submits conditions', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [], nextCursor: null, count: 0})
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Query'}))
|
||||
await user.selectOptions(screen.getByLabelText('Index'), 'status-created')
|
||||
expect(screen.getByText('status (S)')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText('Partition key value'), 'OPEN')
|
||||
await user.selectOptions(screen.getByLabelText('Sort key condition'), 'begins_with')
|
||||
await user.type(screen.getByLabelText('Sort key value'), '2026-09')
|
||||
await user.click(screen.getByRole('button', {name: 'query'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', {
|
||||
operation: 'query', indexName: 'status-created', partitionKeyValue: 'OPEN',
|
||||
sortKeyCondition: {operator: 'begins_with', value: '2026-09'},
|
||||
}, undefined, 100, expect.any(AbortSignal),
|
||||
))
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {Braces, Plus, RefreshCw, TableProperties} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {listNoSqlItems, putNoSqlItem} from '@/api/cloudProxyClient'
|
||||
import {JsonRecordModal} from '@/components/JsonRecordModal'
|
||||
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Braces, Download, Plus, RefreshCw, TriangleAlert, Trash2} from 'lucide-react'
|
||||
import {useInfiniteQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {useNavigate} from 'react-router-dom'
|
||||
import {deleteFlatItem, queryFlatItems} from '@/api/cloudProxyClient'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource, NoSqlItem} from '@/types/resource'
|
||||
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||
|
||||
interface DynamoDbTableExplorerProps {
|
||||
cloud: CloudProvider
|
||||
@@ -13,166 +14,276 @@ interface DynamoDbTableExplorerProps {
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const EMPTY_ITEMS: ChildItem[] = []
|
||||
const PAGE_LIMIT = 100
|
||||
|
||||
type DynamoOperation = 'scan' | 'query'
|
||||
type DynamoType = 'S' | 'N' | 'B' | 'BOOL'
|
||||
|
||||
interface DynamoRequest extends Record<string, unknown> {
|
||||
operation: DynamoOperation
|
||||
indexName?: string
|
||||
partitionKeyValue?: string
|
||||
sortKeyCondition?: {operator: string; value: string; secondValue?: string}
|
||||
filter?: {attribute: string; operator: string; type: DynamoType; value?: string; secondValue?: string}
|
||||
}
|
||||
|
||||
export function DynamoDbTableExplorer({cloud, resource, runtimeReachable}: DynamoDbTableExplorerProps) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const accountId = useAccountId()
|
||||
const tableName = resource?.id
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>()
|
||||
const [addRecordOpen, setAddRecordOpen] = useState(false)
|
||||
const itemsKey = useMemo(() => ['nosql-items', accountId, cloud, tableName], [accountId, cloud, tableName])
|
||||
const keyFields = useMemo(() => dynamoKeyFields(resource), [resource])
|
||||
const itemsQuery = useQuery({
|
||||
const actionsRef = useRef<HTMLDetailsElement>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [inspectedItemId, setInspectedItemId] = useState<string>()
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ChildItem[]>()
|
||||
const [operation, setOperation] = useState<DynamoOperation>('scan')
|
||||
const [indexName, setIndexName] = useState('')
|
||||
const [partitionValue, setPartitionValue] = useState('')
|
||||
const [sortOperator, setSortOperator] = useState('')
|
||||
const [sortValue, setSortValue] = useState('')
|
||||
const [sortSecondValue, setSortSecondValue] = useState('')
|
||||
const [filterAttribute, setFilterAttribute] = useState('')
|
||||
const [filterOperator, setFilterOperator] = useState('EQ')
|
||||
const [filterType, setFilterType] = useState<DynamoType>('S')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [filterSecondValue, setFilterSecondValue] = useState('')
|
||||
const [request, setRequest] = useState<DynamoRequest>({operation: 'scan'})
|
||||
const indexes = useMemo(() => dynamoIndexes(resource), [resource])
|
||||
const selectedIndex = indexes.find((index) => index.name === indexName) ?? indexes[0]!
|
||||
const itemsKey = useMemo(() => ['flat-items', accountId, cloud, 'nosql', tableName, request], [accountId, cloud, tableName, request])
|
||||
const itemsQuery = useInfiniteQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: ({signal}) => listNoSqlItems(cloud, tableName ?? '', signal),
|
||||
queryFn: ({pageParam, signal}) => queryFlatItems(cloud, 'nosql', tableName ?? '', request, pageParam, PAGE_LIMIT, signal),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: Boolean(tableName) && runtimeReachable,
|
||||
})
|
||||
const items = itemsQuery.data ?? []
|
||||
const selectedItem = items.find((item) => item.id === selectedItemId)
|
||||
const putItemMut = useMutation({
|
||||
mutationFn: (document: Record<string, unknown>) => putNoSqlItem(cloud, tableName ?? '', document),
|
||||
onSuccess: (item) => {
|
||||
setAddRecordOpen(false)
|
||||
setSelectedItemId(item.id)
|
||||
void qc.invalidateQueries({queryKey: itemsKey})
|
||||
const pages = itemsQuery.data?.pages ?? []
|
||||
const items = pages.length ? pages.flatMap((page) => page.items) : EMPTY_ITEMS
|
||||
const count = pages.reduce((total, page) => total + (page.count ?? page.items.length), 0)
|
||||
const scannedCount = pages.some((page) => page.scannedCount !== undefined) ? pages.reduce((total, page) => total + (page.scannedCount ?? 0), 0) : undefined
|
||||
const consumedCapacity = pages.some((page) => page.consumedCapacity !== undefined) ? pages.reduce((total, page) => total + (page.consumedCapacity ?? 0), 0) : undefined
|
||||
const attributes = useMemo(() => itemAttributes(items), [items])
|
||||
const selectedItems = items.filter((item) => selectedIds.has(item.id))
|
||||
const inspectedItem = items.find((item) => item.id === inspectedItemId)
|
||||
const deleteItems = useMutation({
|
||||
mutationFn: async (itemsToDelete: ChildItem[]) => Promise.all(itemsToDelete.map((item) => deleteFlatItem(cloud, 'nosql', tableName ?? '', immutableItemKey(item)))),
|
||||
onSuccess: () => {
|
||||
setDeleteConfirmation(undefined)
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
void queryClient.invalidateQueries({queryKey: itemsKey})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItemId(undefined)
|
||||
setAddRecordOpen(false)
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
setOperation('scan')
|
||||
setIndexName('')
|
||||
setRequest({operation: 'scan'})
|
||||
}, [tableName])
|
||||
|
||||
useEffect(() => {
|
||||
const availableIds = new Set(items.map((item) => item.id))
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set([...current].filter((id) => availableIds.has(id)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [items])
|
||||
|
||||
if (!tableName) {
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="empty compact">
|
||||
<h3>Select a DynamoDB table</h3>
|
||||
<p>Records and their contents load after a table is selected.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
return <section className="dynamodb-explorer"><div className="empty compact"><h3>Select a DynamoDB table</h3><p>Records and their contents load after a table is selected.</p></div></section>
|
||||
}
|
||||
|
||||
function toggleItem(itemId: string) {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(itemId)) next.delete(itemId)
|
||||
else next.add(itemId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function requestDeleteSelected() {
|
||||
if (!selectedItems.length || deleteItems.isPending) return
|
||||
closeActions()
|
||||
setDeleteConfirmation(selectedItems)
|
||||
}
|
||||
|
||||
function closeActions() {
|
||||
if (actionsRef.current) actionsRef.current.open = false
|
||||
}
|
||||
|
||||
function run(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const next: DynamoRequest = {operation, ...(indexName ? {indexName} : {})}
|
||||
if (operation === 'query') {
|
||||
next.partitionKeyValue = partitionValue
|
||||
if (sortOperator) next.sortKeyCondition = {
|
||||
operator: sortOperator,
|
||||
value: sortValue,
|
||||
...(sortOperator === 'BETWEEN' ? {secondValue: sortSecondValue} : {}),
|
||||
}
|
||||
}
|
||||
if (filterAttribute) next.filter = {
|
||||
attribute: filterAttribute,
|
||||
operator: filterOperator,
|
||||
type: filterType,
|
||||
...(!filterOperator.startsWith('attribute_') ? {value: filterValue} : {}),
|
||||
...(filterOperator === 'BETWEEN' ? {secondValue: filterSecondValue} : {}),
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
setRequest(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<PanelHeader icon={<TableProperties size={15}/>} eyebrow="Table explorer" title={tableName} detail={`${items.length} records`}/>
|
||||
<PanelHeader icon={<ServiceIcon cloud="aws" iconKey="nosql" size={18}/>} eyebrow="Table explorer" title={tableName} detail={`${count} returned${scannedCount === undefined ? '' : `, ${scannedCount} scanned`}${consumedCapacity === undefined ? '' : `, ${formatCapacity(consumedCapacity)} capacity units`}`}/>
|
||||
<form className="dynamodb-query" onSubmit={run}>
|
||||
<div className="dynamodb-mode" role="group" aria-label="Read mode">
|
||||
<button type="button" className={operation === 'scan' ? 'active' : ''} onClick={() => setOperation('scan')}>Scan</button>
|
||||
<button type="button" className={operation === 'query' ? 'active' : ''} onClick={() => setOperation('query')}>Query</button>
|
||||
</div>
|
||||
<label>Index<select aria-label="Index" value={indexName} onChange={(event) => { setIndexName(event.target.value); setSortOperator('') }}>
|
||||
{indexes.map((index) => <option key={index.name} value={index.name}>{index.label}</option>)}
|
||||
</select></label>
|
||||
{operation === 'query' && <>
|
||||
<label>{selectedIndex.partition.name} ({selectedIndex.partition.type ?? 'S'})<input aria-label="Partition key value" required value={partitionValue} onChange={(event) => setPartitionValue(event.target.value)}/></label>
|
||||
{selectedIndex.sort && <><label>Sort condition<select aria-label="Sort key condition" value={sortOperator} onChange={(event) => setSortOperator(event.target.value)}><option value="">None</option>{['EQ', 'LT', 'LE', 'GT', 'GE', 'BETWEEN', 'begins_with'].map((value) => <option key={value}>{value}</option>)}</select></label>
|
||||
{sortOperator && <label>{selectedIndex.sort.name} ({selectedIndex.sort.type ?? 'S'})<input aria-label="Sort key value" required value={sortValue} onChange={(event) => setSortValue(event.target.value)}/></label>}
|
||||
{sortOperator === 'BETWEEN' && <label>And<input aria-label="Second sort key value" required value={sortSecondValue} onChange={(event) => setSortSecondValue(event.target.value)}/></label>}</>}
|
||||
</>}
|
||||
<label>Filter attribute<input aria-label="Filter attribute" placeholder="Optional" value={filterAttribute} onChange={(event) => setFilterAttribute(event.target.value)}/></label>
|
||||
{filterAttribute && <><label>Operator<select aria-label="Filter operator" value={filterOperator} onChange={(event) => setFilterOperator(event.target.value)}>{['EQ', 'NE', 'LT', 'LE', 'GT', 'GE', 'BETWEEN', 'begins_with', 'contains', 'attribute_exists', 'attribute_not_exists'].map((value) => <option key={value}>{value}</option>)}</select></label>
|
||||
{!filterOperator.startsWith('attribute_') && <><label>Type<select aria-label="Filter value type" value={filterType} onChange={(event) => setFilterType(event.target.value as DynamoType)}><option value="S">String</option><option value="N">Number</option><option value="B">Binary (base64)</option><option value="BOOL">Boolean</option></select></label><label>Value<input aria-label="Filter value" required value={filterValue} onChange={(event) => setFilterValue(event.target.value)}/></label></>}
|
||||
{filterOperator === 'BETWEEN' && <label>And<input aria-label="Second filter value" required value={filterSecondValue} onChange={(event) => setFilterSecondValue(event.target.value)}/></label>}</>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || itemsQuery.isFetching}>{itemsQuery.isFetching ? 'Running' : operation}</button>
|
||||
</form>
|
||||
<div className="cosmos-toolbar">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!runtimeReachable}
|
||||
onClick={() => {
|
||||
putItemMut.reset()
|
||||
setAddRecordOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14}/>
|
||||
Add record
|
||||
</button>
|
||||
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
|
||||
<RefreshCw size={14}/>
|
||||
{itemsQuery.isFetching ? 'Loading' : 'Refresh'}
|
||||
</button>
|
||||
<button className="button primary" type="button" disabled={!runtimeReachable} onClick={() => navigate(`/cloud-explorer/${cloud}/nosql/${encodeURIComponent(tableName)}/data/items/new`)}><Plus size={14}/>Add record</button>
|
||||
<details className="dynamodb-actions" ref={actionsRef}>
|
||||
<summary>Actions</summary>
|
||||
<div className="dynamodb-actions-menu" onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button className="dynamodb-action-edit" type="button" disabled={selectedItems.length !== 1 || !runtimeReachable} onClick={(event) => { event.preventDefault(); event.stopPropagation(); closeActions(); navigate(`/cloud-explorer/${cloud}/nosql/${encodeURIComponent(tableName)}/data/items/${encodeURIComponent(selectedItems[0].id)}/edit`) }}>Edit item</button>
|
||||
<button className="dynamodb-action-delete" type="button" disabled={!selectedItems.length || !runtimeReachable || deleteItems.isPending} onClick={(event) => { event.preventDefault(); event.stopPropagation(); requestDeleteSelected() }}><Trash2 size={14}/>Delete item</button>
|
||||
<button className="dynamodb-action-download" type="button" disabled={!items.length} onClick={(event) => { event.preventDefault(); event.stopPropagation(); closeActions(); downloadCsv(tableName, attributes, items) }}><Download size={14}/>Download CSV</button>
|
||||
</div>
|
||||
</details>
|
||||
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}><RefreshCw size={14}/>{itemsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
|
||||
{(itemsQuery.error instanceof Error || deleteItems.error instanceof Error) && <div className="form-error">{itemsQuery.error instanceof Error ? itemsQuery.error.message : deleteItems.error instanceof Error ? deleteItems.error.message : undefined}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Contents</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className={selectedItemId === item.id ? 'selected' : ''}>
|
||||
<td onClick={() => setSelectedItemId(item.id)}><code>{formatJson(item.key)}</code></td>
|
||||
<td onClick={() => setSelectedItemId(item.id)}>{summarize(item)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<thead><tr>
|
||||
<th className="dynamodb-selection"><input aria-label="Select all items" type="checkbox" checked={items.length > 0 && selectedItems.length === items.length} onChange={() => setSelectedIds(selectedItems.length === items.length ? new Set() : new Set(items.map((item) => item.id)))}/></th>
|
||||
{attributes.map((attribute) => <th key={attribute}>{attribute}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{items.map((item) => <tr key={item.id} className={inspectedItemId === item.id ? 'selected' : ''}>
|
||||
<td className="dynamodb-selection"><input aria-label={`Select item ${item.id}`} type="checkbox" checked={selectedIds.has(item.id)} onChange={() => toggleItem(item.id)}/></td>
|
||||
{attributes.map((attribute) => <td key={attribute} onClick={() => setInspectedItemId(item.id)}>{formatAttributeValue(item.body[attribute])}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{itemsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||
{itemsQuery.isSuccess && items.length === 0 && <div className="empty compact"><h3>No records</h3><p>This table does not contain any items.</p></div>}
|
||||
</div>
|
||||
{itemsQuery.hasNextPage && <button className="button dynamodb-load-more" type="button" disabled={itemsQuery.isFetchingNextPage} onClick={() => itemsQuery.fetchNextPage()}>{itemsQuery.isFetchingNextPage ? 'Loading' : 'Load more'}</button>}
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={selectedItem ? formatJson(selectedItem.key) : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
|
||||
{selectedItem ? (
|
||||
<pre className="dynamodb-document">{JSON.stringify(selectedItem.document, null, 2)}</pre>
|
||||
) : (
|
||||
<div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>
|
||||
)}
|
||||
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={inspectedItem ? 'Selected record' : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
|
||||
{inspectedItem ? <pre className="dynamodb-document">{JSON.stringify(inspectedItem.body, null, 2)}</pre> : <div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>}
|
||||
</div>
|
||||
|
||||
<JsonRecordModal
|
||||
open={addRecordOpen}
|
||||
title={`Add record to ${tableName}`}
|
||||
description={dynamoModalDescription(keyFields)}
|
||||
initialValue={JSON.stringify(Object.fromEntries(keyFields.map(({name, type}) => [name, type === 'N' ? '0' : ''])), null, 2)}
|
||||
isPending={putItemMut.isPending}
|
||||
submitError={putItemMut.error instanceof Error ? putItemMut.error.message : undefined}
|
||||
onClose={() => setAddRecordOpen(false)}
|
||||
onSubmit={(document) => putItemMut.mutate(document)}
|
||||
/>
|
||||
{deleteConfirmation && <div className="modal-overlay" onClick={(event) => event.target === event.currentTarget && !deleteItems.isPending && setDeleteConfirmation(undefined)}>
|
||||
<div className="dynamodb-delete-dialog" role="alertdialog" aria-modal="true" aria-labelledby="dynamodb-delete-title">
|
||||
<div className="dynamodb-delete-dialog-heading"><span><TriangleAlert size={20}/></span><div><h3 id="dynamodb-delete-title">Delete {deleteConfirmation.length === 1 ? 'item' : `${deleteConfirmation.length} items`}?</h3><p>This permanently removes the selected {deleteConfirmation.length === 1 ? 'item' : 'items'} from <strong>{tableName}</strong>.</p></div></div>
|
||||
<div className="dynamodb-delete-warning">This action cannot be undone.</div>
|
||||
{deleteItems.error instanceof Error && <div className="form-error">{deleteItems.error.message}</div>}
|
||||
<div className="dynamodb-delete-dialog-actions"><button className="button" type="button" disabled={deleteItems.isPending} onClick={() => setDeleteConfirmation(undefined)}>Cancel</button><button className="button danger" type="button" disabled={deleteItems.isPending} onClick={() => deleteItems.mutate(deleteConfirmation)}><Trash2 size={14}/>{deleteItems.isPending ? 'Deleting' : `Delete ${deleteConfirmation.length === 1 ? 'item' : 'items'}`}</button></div>
|
||||
</div>
|
||||
</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface DynamoKeyField {
|
||||
interface DynamoIndex {
|
||||
name: string
|
||||
type?: 'S' | 'N' | 'B'
|
||||
label: string
|
||||
partition: {name: string; type?: 'S' | 'N' | 'B'}
|
||||
sort?: {name: string; type?: 'S' | 'N' | 'B'}
|
||||
}
|
||||
|
||||
function dynamoKeyFields(resource?: CloudResource): DynamoKeyField[] {
|
||||
const keySchema = Array.isArray(resource?.metadata.keySchema) ? resource.metadata.keySchema : []
|
||||
function dynamoIndexes(resource?: CloudResource): DynamoIndex[] {
|
||||
const definitions = Array.isArray(resource?.metadata.attributeDefinitions) ? resource.metadata.attributeDefinitions : []
|
||||
const types = new Map(definitions.flatMap((definition) => {
|
||||
const types = new Map<string, 'S' | 'N' | 'B'>(definitions.flatMap((definition) => {
|
||||
if (!definition || typeof definition !== 'object') return []
|
||||
const {AttributeName, AttributeType} = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof AttributeName === 'string' && isDynamoKeyType(AttributeType) ? [[AttributeName, AttributeType] as const] : []
|
||||
const value = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof value.AttributeName === 'string' && (value.AttributeType === 'S' || value.AttributeType === 'N' || value.AttributeType === 'B') ? [[value.AttributeName, value.AttributeType]] : []
|
||||
}))
|
||||
|
||||
return keySchema.flatMap((key) => {
|
||||
if (!key || typeof key !== 'object') return []
|
||||
const name = (key as {AttributeName?: unknown}).AttributeName
|
||||
return typeof name === 'string' ? [{name, type: types.get(name)}] : []
|
||||
})
|
||||
const result = [indexFromSchema('', 'Base table', resource?.metadata.keySchema, types)]
|
||||
for (const [kind, rawIndexes] of [['GSI', resource?.metadata.globalSecondaryIndexes], ['LSI', resource?.metadata.localSecondaryIndexes]] as const) {
|
||||
if (!Array.isArray(rawIndexes)) continue
|
||||
for (const rawIndex of rawIndexes) {
|
||||
if (!rawIndex || typeof rawIndex !== 'object') continue
|
||||
const index = rawIndex as {IndexName?: unknown; KeySchema?: unknown}
|
||||
if (typeof index.IndexName === 'string') result.push(indexFromSchema(index.IndexName, `${index.IndexName} (${kind})`, index.KeySchema, types))
|
||||
}
|
||||
}
|
||||
return result.filter((index): index is DynamoIndex => Boolean(index.partition.name))
|
||||
}
|
||||
|
||||
function dynamoModalDescription(keyFields: DynamoKeyField[]): string {
|
||||
const keys = keyFields.map(({name, type}) => `${name}${type ? ` (${type})` : ''}`).join(', ')
|
||||
const binaryHint = keyFields.some(({type}) => type === 'B') ? ' Binary keys use base64.' : ''
|
||||
const numberHint = keyFields.some(({type}) => type === 'N') ? ' All number keys must be quoted to preserve precision.' : ''
|
||||
return `Required keys: ${keys || 'the table key attributes'}.${binaryHint}${numberHint} A matching key replaces the existing record.`
|
||||
function indexFromSchema(name: string, label: string, rawSchema: unknown, types: Map<string, 'S' | 'N' | 'B'>): DynamoIndex {
|
||||
const schema = Array.isArray(rawSchema) ? rawSchema : []
|
||||
const field = (keyType: string) => {
|
||||
const raw = schema.find((entry) => entry && typeof entry === 'object' && (entry as {KeyType?: unknown}).KeyType === keyType) as {AttributeName?: unknown} | undefined
|
||||
const keyName = typeof raw?.AttributeName === 'string' ? raw.AttributeName : ''
|
||||
return {name: keyName, type: types.get(keyName)}
|
||||
}
|
||||
const partition = field('HASH')
|
||||
const sort = field('RANGE')
|
||||
return {name, label, partition, ...(sort.name ? {sort} : {})}
|
||||
}
|
||||
|
||||
function isDynamoKeyType(value: unknown): value is 'S' | 'N' | 'B' {
|
||||
return value === 'S' || value === 'N' || value === 'B'
|
||||
function formatCapacity(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
function PanelHeader({icon, eyebrow, title, detail}: {icon: React.ReactNode; eyebrow: string; title: string; detail: string}) {
|
||||
return (
|
||||
<div className="cosmos-panel-header">
|
||||
{icon}
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return <div className="cosmos-panel-header">{icon}<span><small>{eyebrow}</small><strong>{title}</strong><em>{detail}</em></span></div>
|
||||
}
|
||||
|
||||
function summarize(item: NoSqlItem): string {
|
||||
return Object.entries(item.document)
|
||||
.slice(0, 3)
|
||||
.map(([name, value]) => `${name}: ${formatJson(value)}`)
|
||||
.join(' · ')
|
||||
function itemAttributes(items: ChildItem[]): string[] {
|
||||
return [...new Set(items.flatMap((item) => Object.keys(item.body)))]
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
function immutableItemKey(item: ChildItem): string {
|
||||
const key = item.metadata.key
|
||||
if (!key || typeof key !== 'object' || Array.isArray(key)) {
|
||||
throw new Error('DynamoDB item is missing its primary key.')
|
||||
}
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
|
||||
function formatAttributeValue(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) return value.map(formatAttributeValue).join(', ')
|
||||
return JSON.stringify(value) ?? ''
|
||||
}
|
||||
|
||||
function downloadCsv(tableName: string, attributes: string[], items: ChildItem[]) {
|
||||
const csv = [attributes, ...items.map((item) => attributes.map((attribute) => formatAttributeValue(item.body[attribute])))]
|
||||
.map((row) => row.map(csvCell).join(',')).join('\r\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], {type: 'text/csv;charset=utf-8'}))
|
||||
link.download = `${tableName}-items.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
function csvCell(value: string): string {
|
||||
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {CalendarClock, Loader2, Plus, Radio, Trash2} from 'lucide-react'
|
||||
import {CalendarClock, Loader2, Plus, Send, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
|
||||
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, putEventBridgeEvent, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider, CloudServiceType} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface EventBridgeRulePanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -30,6 +31,9 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
const [targetService, setTargetService] = useState<TargetService>('lambda')
|
||||
const [targetArn, setTargetArn] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<EventBridgeRuleOverview | null>(null)
|
||||
const [eventSource, setEventSource] = useState('orders')
|
||||
const [eventDetailType, setEventDetailType] = useState('order.created')
|
||||
const [eventDetail, setEventDetail] = useState('{\n "orderId": "example-123"\n}')
|
||||
const queryClient = useQueryClient()
|
||||
const targetQueries = {
|
||||
serverless: useQuery({
|
||||
@@ -69,6 +73,9 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
setTargetService('lambda')
|
||||
setTargetArn('')
|
||||
setDeleteTarget(null)
|
||||
setEventSource('orders')
|
||||
setEventDetailType('order.created')
|
||||
setEventDetail('{\n "orderId": "example-123"\n}')
|
||||
}, [resource?.id])
|
||||
|
||||
const resourcesByTargetService = (Object.keys(targetServiceDetails) as TargetService[]).reduce<Record<TargetService, CloudResource[]>>((resources, service) => {
|
||||
@@ -118,6 +125,11 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: () => putEventBridgeEvent(cloud, resource!.id, {source: eventSource.trim(), detailType: eventDetailType.trim(), detail: eventDetail.trim()}),
|
||||
})
|
||||
const eventDetailValid = isJsonObject(eventDetail)
|
||||
const canPublish = Boolean(eventSource.trim() && eventDetailType.trim() && eventDetail.trim() && eventDetailValid) && !publishMutation.isPending
|
||||
|
||||
if (!resource || resource.service !== 'events' || resource.type !== 'event-bus') return null
|
||||
|
||||
@@ -125,7 +137,7 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">EventBridge Rules</p>
|
||||
<h3><Radio size={15}/> Add rule to {resource.name}</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="events" size={18}/> Add rule to {resource.name}</h3>
|
||||
<p className="muted compact-text">Route a schedule or matching events from this event bus to one existing target.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +158,17 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
</form>
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to create EventBridge rule.'}</p>}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Test event delivery</p><h3><Send size={15}/> Publish to {resource.name}</h3><p className="muted compact-text">Sends one real EventBridge PutEvents entry to this bus.</p></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canPublish) publishMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Source</span><input className="input" value={eventSource} onChange={(event) => setEventSource(event.target.value)} required/></label>
|
||||
<label className="dynamic-field"><span>Detail type</span><input className="input" value={eventDetailType} onChange={(event) => setEventDetailType(event.target.value)} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Detail (JSON)</span><textarea className="textarea code-textarea small" value={eventDetail} onChange={(event) => setEventDetail(event.target.value)} spellCheck={false} required/>{!eventDetailValid && <small className="error-text">Detail must be a JSON object.</small>}</label>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canPublish}>{publishMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{publishMutation.isPending ? 'Publishing event' : 'Publish event'}</button>
|
||||
</form>
|
||||
{publishMutation.isSuccess && <p className={publishMutation.data.errorCode ? 'error-text compact-text' : 'success-text compact-text'}>{publishMutation.data.errorCode ? `${publishMutation.data.errorCode}: ${publishMutation.data.errorMessage}` : `Published event ${publishMutation.data.eventId}`}</p>}
|
||||
{publishMutation.isError && <p className="error-text compact-text">{publishMutation.error instanceof Error ? publishMutation.error.message : 'Unable to publish EventBridge event.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Rules</p><h3><CalendarClock size={15}/> Rules and targets</h3></div></div>
|
||||
{rulesQuery.isLoading && <p className="muted compact-text">Loading EventBridge rules...</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {Copy, KeyRound, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
|
||||
import {Copy, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
|
||||
import {
|
||||
decryptKmsResource,
|
||||
encryptKmsResource,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {useAccountId} from "@/lib/accountStore";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
|
||||
interface KmsCryptoPanelProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -136,7 +137,7 @@ export function KmsCryptoPanel({cloud, resource, runtimeReachable}: KmsCryptoPan
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">KMS Actions</p>
|
||||
<h3><KeyRound size={15} /> Encrypt and decrypt</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="kms" size={18} /> Encrypt and decrypt</h3>
|
||||
<p className="muted compact-text">
|
||||
Values stay in this panel and are cleared when you switch keys or operations.
|
||||
</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {useQuery} from '@tanstack/react-query'
|
||||
import {getCloudStatus} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery} from '@/api/queries/cloudQueries'
|
||||
import {AccountSwitcher} from '@/components/AccountSwitcher'
|
||||
import {serviceIcon} from '@/components/serviceIcons'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import type {CloudProvider, CloudServiceDescriptor, RuntimeReachability} from '@/types/cloud'
|
||||
|
||||
/** The runtime's reachability, plus the state before the status query answers. */
|
||||
@@ -34,11 +34,10 @@ function connectionDotClass(status: ConnectionStatus): string {
|
||||
/** Matches today's service count, so the real nav causes no layout jump. */
|
||||
const SKELETON_ROWS = 7
|
||||
|
||||
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ElementType; label: string; collapsed: boolean }) {
|
||||
const Icon = icon
|
||||
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ReactNode; label: string; collapsed: boolean }) {
|
||||
return (
|
||||
<NavLink className="nav-link" to={to} title={collapsed ? label : undefined}>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
@@ -110,14 +109,14 @@ function CloudServiceNav({collapsed}: {collapsed: boolean}) {
|
||||
}
|
||||
|
||||
function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) {
|
||||
const Icon = serviceIcon(service.iconKey)
|
||||
const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName
|
||||
const icon = <ServiceIcon cloud={cloud} iconKey={service.iconKey} size={14}/>
|
||||
|
||||
if (service.availability === 'available') {
|
||||
const target = service.route.startsWith('/')
|
||||
? service.route
|
||||
: `/cloud-explorer/${cloud}/${service.route}`
|
||||
return <NavItem to={target} icon={Icon} label={service.displayName} collapsed={collapsed}/>
|
||||
return <NavItem to={target} icon={icon} label={service.displayName} collapsed={collapsed}/>
|
||||
}
|
||||
|
||||
// Title duplicates the visible label when the sidebar is expanded, which WAVE
|
||||
@@ -127,7 +126,7 @@ function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider;
|
||||
className="nav-link disabled"
|
||||
title={collapsed || service.reason ? reasonTitle : undefined}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
{icon}
|
||||
<span>{service.displayName}</span>
|
||||
<span className="nav-soon">Soon</span>
|
||||
</div>
|
||||
@@ -188,7 +187,7 @@ export function Layout() {
|
||||
<nav className="nav" aria-label="Console">
|
||||
<div className="nav-section">
|
||||
<span className="nav-label">General</span>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={LayoutDashboard} label="Console Home" collapsed={collapsed}/>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={14} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||
</div>
|
||||
<CloudServiceNav collapsed={collapsed}/>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {LogsExplorerPanel} from './LogsExplorerPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
filterLogEvents: vi.fn(),
|
||||
listChildCollections: vi.fn(),
|
||||
listCollectionItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const group: CloudResource = {
|
||||
id: '/aws/lambda/orders', name: '/aws/lambda/orders', cloud: 'aws', service: 'logs',
|
||||
type: 'log-group', region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><LogsExplorerPanel cloud="aws" resource={group} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMocks.listChildCollections.mockResolvedValue({items: [{id: 'stream-a', name: 'stream-a', parentId: group.id, createdAt: null, metadata: {}}], nextCursor: null})
|
||||
apiMocks.listCollectionItems.mockResolvedValue({items: [], nextCursor: null})
|
||||
})
|
||||
|
||||
test('filters events across streams and pages with the AWS cursor', async () => {
|
||||
apiMocks.filterLogEvents
|
||||
.mockResolvedValueOnce({items: [{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'ERROR one'}, metadata: {}}], nextCursor: 'next'})
|
||||
.mockResolvedValueOnce({items: [{id: 'two', collectionId: 'stream-b', timestamp: '2026-01-01T00:00:01.000Z', body: {message: 'ERROR two'}, metadata: {}}], nextCursor: null})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTitle('Expand to full page'))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter all streams'}))
|
||||
await user.type(screen.getByLabelText('Filter pattern'), 'ERROR')
|
||||
await user.click(screen.getByRole('button', {name: 'Filter'}))
|
||||
|
||||
expect(await screen.findByText('ERROR one')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Load more'}))
|
||||
expect(await screen.findByText('ERROR two')).toBeInTheDocument()
|
||||
expect(screen.getByText('stream-b')).toBeInTheDocument()
|
||||
expect(apiMocks.filterLogEvents).toHaveBeenNthCalledWith(2, 'aws', group.id, {filterPattern: 'ERROR'}, 'next', undefined)
|
||||
})
|
||||
|
||||
test('live tail appends new events without duplicating an overlapping event', async () => {
|
||||
apiMocks.filterLogEvents
|
||||
.mockResolvedValueOnce({items: [{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'first'}, metadata: {}}], nextCursor: null})
|
||||
.mockResolvedValueOnce({items: [
|
||||
{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'first'}, metadata: {}},
|
||||
{id: 'two', collectionId: 'stream-b', timestamp: '2026-01-01T00:00:01.000Z', body: {message: 'second'}, metadata: {}},
|
||||
], nextCursor: null})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTitle('Expand to full page'))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter all streams'}))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter'}))
|
||||
expect(await screen.findByText('first')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Live tail'}))
|
||||
|
||||
expect(await screen.findByText('second')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('first')).toHaveLength(1)
|
||||
await waitFor(() => expect(apiMocks.filterLogEvents).toHaveBeenLastCalledWith('aws', group.id, {filterPattern: undefined, startTime: Date.parse('2026-01-01T00:00:00.000Z')}, undefined, expect.any(AbortSignal)))
|
||||
})
|
||||
@@ -1,14 +1,17 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {ChevronRight, Maximize2, Minimize2, RefreshCw, ScrollText} from 'lucide-react'
|
||||
import {ChevronRight, Maximize2, Minimize2, Radio, RefreshCw, Search, ScrollText, Square} from 'lucide-react'
|
||||
import {useQueries} from '@tanstack/react-query'
|
||||
import {listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
|
||||
import {filterLogEvents, listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {formatBytes, formatDateTime} from '@/lib/format'
|
||||
import {timeAgo} from '@/lib/utils'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const LIVE_TAIL_INTERVAL_MS = 2_000
|
||||
|
||||
interface LogsExplorerPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
@@ -39,11 +42,31 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
const [streamCursors, setStreamCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [eventCursors, setEventCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [eventMode, setEventMode] = useState<'stream' | 'filter'>('stream')
|
||||
const [filterPattern, setFilterPattern] = useState('')
|
||||
const [appliedFilterPattern, setAppliedFilterPattern] = useState('')
|
||||
const [filteredEvents, setFilteredEvents] = useState<Awaited<ReturnType<typeof filterLogEvents>>['items']>([])
|
||||
const [filterCursor, setFilterCursor] = useState<string | null>(null)
|
||||
const [filterLoading, setFilterLoading] = useState(false)
|
||||
const [filterError, setFilterError] = useState<string>()
|
||||
const [liveTail, setLiveTail] = useState(false)
|
||||
const filteredEventsRef = useRef(filteredEvents)
|
||||
|
||||
useEffect(() => {
|
||||
filteredEventsRef.current = filteredEvents
|
||||
}, [filteredEvents])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStreamId(undefined)
|
||||
setStreamCursors([undefined])
|
||||
setEventCursors([undefined])
|
||||
setEventMode('stream')
|
||||
setFilterPattern('')
|
||||
setAppliedFilterPattern('')
|
||||
setFilteredEvents([])
|
||||
setFilterCursor(null)
|
||||
setFilterError(undefined)
|
||||
setLiveTail(false)
|
||||
}, [cloud, groupId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,13 +109,62 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
void firstEventPage?.refetch()
|
||||
}
|
||||
|
||||
async function loadFilteredEvents(pattern: string, cursor?: string, append = false, signal?: AbortSignal) {
|
||||
setFilterLoading(true)
|
||||
setFilterError(undefined)
|
||||
try {
|
||||
const page = await filterLogEvents(cloud, groupId ?? '', {filterPattern: pattern || undefined}, cursor, signal)
|
||||
setFilteredEvents((current) => append ? mergeEvents(current, page.items) : page.items)
|
||||
setFilterCursor(page.nextCursor)
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) setFilterError(error instanceof Error ? error.message : 'Unable to filter log events.')
|
||||
} finally {
|
||||
if (!signal?.aborted) setFilterLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveTail || !runtimeReachable || !groupId) return
|
||||
const controller = new AbortController()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const latestTimestamp = filteredEventsRef.current.reduce((latest, event) => {
|
||||
const timestamp = event.timestamp ? Date.parse(event.timestamp) : 0
|
||||
return Math.max(latest, timestamp)
|
||||
}, 0)
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await filterLogEvents(cloud, groupId, {
|
||||
filterPattern: appliedFilterPattern || undefined,
|
||||
startTime: latestTimestamp || undefined,
|
||||
}, cursor, controller.signal)
|
||||
setFilteredEvents((current) => mergeEvents(current, page.items))
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor && !controller.signal.aborted)
|
||||
if (!controller.signal.aborted) timer = setTimeout(poll, LIVE_TAIL_INTERVAL_MS)
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setFilterError(error instanceof Error ? error.message : 'Live tail stopped after an error.')
|
||||
setLiveTail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
void poll()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [appliedFilterPattern, cloud, groupId, liveTail, runtimeReachable])
|
||||
|
||||
if (!groupId) return null
|
||||
|
||||
const content = (
|
||||
<section className="cosmos-panel">
|
||||
<div className="cosmos-column">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Streams</small>
|
||||
<strong>{groupId}</strong>
|
||||
@@ -150,7 +222,7 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Events</small>
|
||||
<strong>{selectedStreamId ?? 'Select a stream'}</strong>
|
||||
@@ -158,24 +230,43 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
</span>
|
||||
</div>
|
||||
<div className="cosmos-toolbar">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
|
||||
<RefreshCw size={14}/>
|
||||
Refresh
|
||||
</button>
|
||||
<div className="logs-mode-toggle" role="group" aria-label="Log event mode">
|
||||
<button className={`button compact${eventMode === 'stream' ? ' active' : ''}`} type="button" onClick={() => { setEventMode('stream'); setLiveTail(false) }}>Selected stream</button>
|
||||
<button className={`button compact${eventMode === 'filter' ? ' active' : ''}`} type="button" onClick={() => setEventMode('filter')}>Filter all streams</button>
|
||||
</div>
|
||||
{eventMode === 'stream' ? <button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
|
||||
<RefreshCw size={14}/> Refresh
|
||||
</button> : <form className="logs-filter-toolbar" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setAppliedFilterPattern(filterPattern.trim())
|
||||
setLiveTail(false)
|
||||
void loadFilteredEvents(filterPattern.trim())
|
||||
}}>
|
||||
<label><span className="sr-only">Filter pattern</span><input className="input logs-filter-pattern" aria-label="Filter pattern" value={filterPattern} onChange={(event) => setFilterPattern(event.target.value)} placeholder='Filter pattern, for example "ERROR"'/></label>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || filterLoading}><Search size={14}/>{filterLoading ? 'Filtering' : 'Filter'}</button>
|
||||
<button className={`button${liveTail ? ' danger' : ''}`} type="button" disabled={!runtimeReachable || filterLoading} onClick={() => {
|
||||
if (!liveTail && appliedFilterPattern !== filterPattern.trim()) {
|
||||
setAppliedFilterPattern(filterPattern.trim())
|
||||
void loadFilteredEvents(filterPattern.trim())
|
||||
}
|
||||
setLiveTail((current) => !current)
|
||||
}}>{liveTail ? <Square size={13}/> : <Radio size={13}/>} {liveTail ? 'Stop tail' : 'Live tail'}</button>
|
||||
</form>}
|
||||
</div>
|
||||
{firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
|
||||
{eventMode === 'stream' && firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
|
||||
{eventMode === 'filter' && filterError && <div className="form-error">{filterError}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
{!selectedStreamId && (
|
||||
{eventMode === 'stream' && !selectedStreamId && (
|
||||
<div className="empty compact"><h3>Select a stream</h3><p>Events are scoped to a log stream.</p></div>
|
||||
)}
|
||||
{selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
|
||||
{eventMode === 'stream' && selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No log events"
|
||||
description="Events written to this stream will appear here."
|
||||
/>
|
||||
)}
|
||||
{selectedStreamId && (firstEventPage?.isLoading ? (
|
||||
{eventMode === 'stream' && selectedStreamId && (firstEventPage?.isLoading ? (
|
||||
<div className="muted padded">Loading log events</div>
|
||||
) : events.length > 0 && (
|
||||
<>
|
||||
@@ -211,6 +302,12 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{eventMode === 'filter' && !filterLoading && filteredEvents.length === 0 && <EmptyState icon={Search} title="No matching events" description="Apply a filter pattern or start live tail to search every stream in this group."/>}
|
||||
{eventMode === 'filter' && filteredEvents.length > 0 && <>
|
||||
{liveTail && <div className="logs-live-status"><Radio size={12}/> Live tail active</div>}
|
||||
<table className="table logs-events-table"><thead><tr><th>Timestamp</th><th>Log stream</th><th>Message</th></tr></thead><tbody>{filteredEvents.map((event) => <tr key={event.id}><td className="mono">{formatDateTime(event.timestamp) ?? '-'}</td><td className="mono logs-event-stream">{event.collectionId ?? '-'}</td><td className="mono">{String(event.body.message ?? '')}</td></tr>)}</tbody></table>
|
||||
{filterCursor && !liveTail && <div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}><button className="button" type="button" disabled={filterLoading} onClick={() => void loadFilteredEvents(appliedFilterPattern, filterCursor, true)}>{filterLoading ? 'Loading…' : 'Load more'}</button></div>}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -219,7 +316,7 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
const summary = (
|
||||
<section className="logs-query-panel">
|
||||
<div className="logs-query-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Streams & events</small>
|
||||
<strong>{groupId}</strong>
|
||||
@@ -243,3 +340,9 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeEvents(current: Awaited<ReturnType<typeof filterLogEvents>>['items'], incoming: Awaited<ReturnType<typeof filterLogEvents>>['items']) {
|
||||
const byId = new Map(current.map((event) => [event.id, event]))
|
||||
for (const event of incoming) byId.set(event.id, event)
|
||||
return [...byId.values()].sort((left, right) => Date.parse(left.timestamp ?? '') - Date.parse(right.timestamp ?? ''))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ResourceInspector({
|
||||
)}
|
||||
</section>
|
||||
{isDatabase && (
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} />
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} />
|
||||
)}
|
||||
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
|
||||
{isDatabase && !isAwsDatabase && (
|
||||
@@ -282,8 +282,10 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) {
|
||||
|
||||
function DatabaseConnectionsSection({
|
||||
metadata,
|
||||
engine,
|
||||
}: {
|
||||
metadata: Record<string, unknown>;
|
||||
engine?: string | null;
|
||||
}) {
|
||||
const endpoint = getRecordMetadata(metadata.endpoint);
|
||||
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
|
||||
@@ -311,6 +313,7 @@ function DatabaseConnectionsSection({
|
||||
value={`${securityGroups.length}`}
|
||||
/>
|
||||
</div>
|
||||
{address && port !== null && engine && <DatabaseConnectionCommand engine={engine} address={address} port={port} database={getStringMetadata(metadata.dbName) ?? (engine.includes('postgres') ? 'postgres' : '')} username={getStringMetadata(metadata.masterUsername) ?? (engine.includes('postgres') ? 'postgres' : 'root')} />}
|
||||
{securityGroups.length > 0 && (
|
||||
<div className="metadata-tags">
|
||||
{securityGroups.map((group) => (
|
||||
@@ -325,6 +328,16 @@ function DatabaseConnectionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
|
||||
const host = address.startsWith('172.') ? 'localhost' : address
|
||||
const command = engine.includes('postgres')
|
||||
? `psql "host=${host} port=${port} dbname=${database} user=${username} password=<password>"`
|
||||
: engine.includes('mysql') || engine.includes('mariadb')
|
||||
? `mysql --host=${host} --port=${port} --user=${username} --password ${database}`
|
||||
: `sqlcmd -S ${host},${port} -U ${username} -P <password> -d ${database}`
|
||||
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="muted compact-text">Use the endpoint, port, database, and username above in DBeaver or another database client. The password is never retained by the console.</p></div>
|
||||
}
|
||||
|
||||
function DatabaseLifecycleSection({ status }: { status?: string | null }) {
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {HardDrive, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {Loader2, Plus, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createS3LambdaTrigger, deleteS3LambdaTrigger, listCloudResources, listS3LambdaTriggers, type S3LambdaTriggerOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
|
||||
interface S3LambdaTriggerPanelProps {
|
||||
@@ -72,7 +73,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Event Source</p>
|
||||
<h3><Zap size={15}/> Add trigger</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> Add trigger</h3>
|
||||
<p className="muted compact-text">Invoke <code>{resource.name}</code> when an S3 object is created.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +118,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add S3 trigger.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><HardDrive size={15}/> S3 object created</h3></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><ServiceIcon cloud="aws" iconKey="storage" size={18}/> S3 object created</h3></div></div>
|
||||
{triggersQuery.isLoading && <p className="muted compact-text">Loading S3 triggers...</p>}
|
||||
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load S3 triggers.'}</p>}
|
||||
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No S3 triggers configured for this Lambda.</p>}
|
||||
@@ -128,7 +129,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
</div>
|
||||
{triggersQuery.data && triggersQuery.data.length > 0 && (
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Recent Activity</p><h3><Zap size={15}/> Lambda logs</h3><p className="muted compact-text">Inspect the function logs generated by S3-triggered invocations.</p></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Recent Activity</p><h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> Lambda logs</h3><p className="muted compact-text">Inspect the function logs generated by S3-triggered invocations.</p></div></div>
|
||||
<LogsQueryPanel cloud={cloud} logGroupName={`/aws/lambda/${resource.name}`} runtimeReachable={runtimeReachable} autoRunKey={triggersQuery.data.length}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {CalendarClock, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {CalendarClock, Loader2, Plus, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createEventBridgeLambdaTrigger, deleteEventBridgeLambdaTrigger, listEventBridgeLambdaTriggers, type EventBridgeLambdaTriggerOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface ScheduledLambdaTriggerPanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -77,7 +78,7 @@ export function ScheduledLambdaTriggerPanel({cloud, resource, runtimeReachable}:
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add scheduled trigger.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Schedules</p><h3><Zap size={15}/> EventBridge rules</h3></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Schedules</p><h3><ServiceIcon cloud="aws" iconKey="events" size={18}/> EventBridge rules</h3></div></div>
|
||||
{triggersQuery.isLoading && <p className="muted compact-text">Loading scheduled triggers...</p>}
|
||||
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load scheduled triggers.'}</p>}
|
||||
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No scheduled triggers configured for this Lambda.</p>}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2, Zap} from "lucide-react";
|
||||
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {invokeCloudResource} from "@/api/cloudProxyClient";
|
||||
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
import {LogsQueryPanel} from "./LogsQueryPanel";
|
||||
|
||||
interface ServerlessInvokePanelProps {
|
||||
@@ -107,7 +108,7 @@ const clearPayload = () => {
|
||||
<div>
|
||||
<p className="eyebrow">Serverless Actions</p>
|
||||
<h3>
|
||||
<Zap size={15} />
|
||||
<ServiceIcon cloud={cloud} iconKey="serverless" size={18} />
|
||||
Invoke {providerLabel}
|
||||
</h3>
|
||||
<p className="muted compact-text">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {BellRing, Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createSnsSubscription, deleteSnsSubscription, listCloudResources, listSnsSubscriptions, publishNotification, type PublishNotificationResult, type SnsSubscriptionOverview} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
|
||||
interface SnsTopicPanelProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -130,7 +131,7 @@ export function SnsTopicPanel({cloud, resource, runtimeReachable}: SnsTopicPanel
|
||||
</section>
|
||||
|
||||
{cloud === "aws" && <section className="table-panel sns-subscriptions-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><BellRing size={15} /> Subscribe an endpoint</h3></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><ServiceIcon cloud="aws" iconKey="notifications" size={18} /> Subscribe an endpoint</h3></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canCreateSubscription) createSubscriptionMutation.mutate(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-subscription-protocol"><span>Protocol</span><select id="sns-subscription-protocol" className="input" value={protocol} onChange={(event) => { setProtocol(event.target.value as SubscriptionProtocol); setEndpoint(""); }}><option value="lambda">Lambda</option><option value="sqs">SQS</option><option value="http">HTTP</option><option value="https">HTTPS</option><option value="email">Email</option><option value="email-json">Email JSON</option><option value="sms">SMS</option></select></label>
|
||||
{needsResourceSelector ? <label className="dynamic-field" htmlFor="sns-subscription-resource-endpoint"><span>{resourceProtocols[protocol].label} endpoint</span><select id="sns-subscription-resource-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} required disabled={!subscriptionsEnabled}><option value="">Select a {resourceProtocols[protocol].label} resource</option>{endpointResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{resourcesLoading ? `Loading ${resourceProtocols[protocol].label} resources...` : endpointResources.length === 0 ? `No ${resourceProtocols[protocol].label} resources with an ARN are available.` : "Uses the resource ARN returned by the cloud API."}</small></label> : <label className="dynamic-field" htmlFor="sns-subscription-endpoint"><span>Endpoint</span><input id="sns-subscription-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder={manualEndpointPlaceholder(protocol)} required disabled={!subscriptionsEnabled} /><small>SNS may require endpoint confirmation before delivery can begin.</small></label>}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, MessageSquare, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {Loader2, Plus, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createSqsLambdaEventSourceMapping, deleteSqsLambdaEventSourceMapping, listCloudResources, listSqsLambdaEventSourceMappings, type SqsLambdaEventSourceMappingOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface SqsLambdaTriggerPanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -57,7 +58,7 @@ export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLa
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Event Source</p>
|
||||
<h3><Zap size={15}/> Add SQS trigger</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> Add SQS trigger</h3>
|
||||
<p className="muted compact-text">Poll an SQS queue and invoke <code>{resource.name}</code> for each batch.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +87,7 @@ export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLa
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add SQS trigger.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><MessageSquare size={15}/> SQS event source mappings</h3></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><ServiceIcon cloud="aws" iconKey="messaging" size={18}/> SQS event source mappings</h3></div></div>
|
||||
{mappingsQuery.isLoading && <p className="muted compact-text">Loading SQS triggers...</p>}
|
||||
{mappingsQuery.isError && <p className="error-text compact-text">{mappingsQuery.error instanceof Error ? mappingsQuery.error.message : 'Unable to load SQS triggers.'}</p>}
|
||||
{mappingsQuery.data?.length === 0 && <p className="muted compact-text">No SQS triggers configured for this Lambda.</p>}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {SqsQueuePanel} from './SqsQueuePanel'
|
||||
|
||||
const api = vi.hoisted(() => ({deleteSqsMessage: vi.fn(), listCloudResources: vi.fn(), purgeSqsQueue: vi.fn(), receiveSqsMessages: vi.fn(), sendSqsMessage: vi.fn(), startSqsMoveTask: vi.fn(), updateCloudResource: vi.fn()}))
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
const queue = {id: 'orders', name: 'orders', cloud: 'aws' as const, service: 'messaging' as const, type: 'queue', region: null, createdAt: null, metadata: {arn: 'arn:aws:sqs:us-east-1:000000000000:orders', visibilityTimeout: 5, approximateMessages: 1}}
|
||||
|
||||
function renderPanel() { return render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><SqsQueuePanel resource={queue} runtimeReachable/></QueryClientProvider>) }
|
||||
beforeEach(() => { vi.clearAllMocks(); api.listCloudResources.mockResolvedValue([queue]) })
|
||||
|
||||
test('receives without deleting and deletes only on explicit action', async () => {
|
||||
api.receiveSqsMessages.mockResolvedValue([{messageId: 'message-1', receiptHandle: 'receipt-1', body: 'hello', attributes: {ApproximateReceiveCount: '1'}, messageAttributes: {}}])
|
||||
api.deleteSqsMessage.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
await user.click(screen.getByRole('button', {name: 'Poll for messages'}))
|
||||
expect(await screen.findByText('hello')).toBeInTheDocument()
|
||||
expect(api.deleteSqsMessage).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', {name: 'Delete message message-1'}))
|
||||
await waitFor(() => expect(api.deleteSqsMessage).toHaveBeenCalledWith('aws', 'orders', 'receipt-1'))
|
||||
})
|
||||
|
||||
test('requires purge confirmation and labels DLQ controls experimental', async () => {
|
||||
api.purgeSqsQueue.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
expect(screen.getByText('Experimental: dead-letter queues')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Purge queue'}))
|
||||
expect(api.purgeSqsQueue).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm purge'}))
|
||||
await waitFor(() => expect(api.purgeSqsQueue).toHaveBeenCalledWith('aws', 'orders'))
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, Send, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {deleteSqsMessage, listCloudResources, purgeSqsQueue, receiveSqsMessages, sendSqsMessage, startSqsMoveTask, updateCloudResource} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource, SqsMessageAttribute} from '@/types/resource'
|
||||
|
||||
export function SqsQueuePanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
|
||||
const [body, setBody] = useState('')
|
||||
const [attributes, setAttributes] = useState('{}')
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [deduplicationId, setDeduplicationId] = useState('')
|
||||
const [messages, setMessages] = useState<Awaited<ReturnType<typeof receiveSqsMessages>>>([])
|
||||
const [purgeConfirm, setPurgeConfirm] = useState(false)
|
||||
const [dlqArn, setDlqArn] = useState('')
|
||||
const [maxReceiveCount, setMaxReceiveCount] = useState('2')
|
||||
const [moveDestination, setMoveDestination] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const queuesQuery = useQuery({queryKey: ['cloud-resources', 'aws', 'messaging'], queryFn: ({signal}) => listCloudResources('aws', 'messaging', undefined, signal), enabled: Boolean(resource) && runtimeReachable})
|
||||
const isQueue = resource?.service === 'messaging' && (resource.type === 'queue' || resource.type === 'fifo-queue')
|
||||
const isFifo = resource?.type === 'fifo-queue'
|
||||
|
||||
useEffect(() => { setBody(''); setAttributes('{}'); setGroupId(''); setDeduplicationId(''); setMessages([]); setPurgeConfirm(false); setDlqArn(''); setMoveDestination('') }, [resource?.id])
|
||||
|
||||
const send = useMutation({mutationFn: () => sendSqsMessage('aws', resource!.id, {messageBody: body, messageAttributes: parseAttributes(attributes), ...(isFifo ? {messageGroupId: groupId, ...(deduplicationId ? {messageDeduplicationId: deduplicationId} : {})} : {})}), onSuccess: () => { setBody(''); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const receive = useMutation({mutationFn: () => receiveSqsMessages('aws', resource!.id, 10), onSuccess: (received) => { setMessages(received); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const remove = useMutation({mutationFn: (message: Awaited<ReturnType<typeof receiveSqsMessages>>[number]) => deleteSqsMessage('aws', resource!.id, message.receiptHandle), onSuccess: (_, message) => { setMessages((current) => current.filter((item) => item.receiptHandle !== message.receiptHandle)); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const purge = useMutation({mutationFn: () => purgeSqsQueue('aws', resource!.id), onSuccess: () => { setMessages([]); setPurgeConfirm(false); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const configureDlq = useMutation({mutationFn: () => updateCloudResource('aws', 'messaging', resource!.id, {redrivePolicy: {deadLetterTargetArn: dlqArn, maxReceiveCount}}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})})
|
||||
const move = useMutation({mutationFn: () => startSqsMoveTask('aws', resource!.id, moveDestination || undefined)})
|
||||
|
||||
if (!isQueue) return null
|
||||
const queues = (queuesQuery.data ?? []).filter((queue) => queue.id !== resource.id && queue.type === resource.type && typeof queue.metadata.arn === 'string')
|
||||
const error = send.error ?? receive.error ?? remove.error ?? purge.error ?? configureDlq.error ?? move.error
|
||||
return <section className="table-panel sqs-queue-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">SQS Messages</p><h3>Send and receive</h3><p className="muted compact-text">Polling receives messages and starts their configured visibility timeout. Messages are deleted only when you explicitly delete them.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (body && (!isFifo || groupId)) send.mutate() }}>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message body</span><textarea className="textarea code-textarea" value={body} onChange={(event) => setBody(event.target.value)} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message attributes (JSON)</span><textarea className="textarea code-textarea small" value={attributes} onChange={(event) => setAttributes(event.target.value)} placeholder={'{"kind":{"dataType":"String","stringValue":"order"}}'}/><small>Supports String, Number, and Binary attributes. Binary values are base64.</small></label>
|
||||
{isFifo && <><label className="dynamic-field"><span>Message group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required/></label><label className="dynamic-field"><span>Deduplication ID (optional)</span><input className="input" value={deduplicationId} onChange={(event) => setDeduplicationId(event.target.value)}/></label></>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !body || (isFifo && !groupId) || send.isPending}>{send.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send message'}</button>
|
||||
</form>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Receive</p><h3>Received messages</h3></div><button className="button" type="button" disabled={!runtimeReachable || receive.isPending} onClick={() => receive.mutate()}>{receive.isPending ? 'Polling' : 'Poll for messages'}</button></div>
|
||||
{messages.length === 0 && <p className="muted compact-text">Poll to receive up to 10 messages. Leaving this panel or waiting for the visibility timeout does not delete them.</p>}
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><pre>{message.body}</pre><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre>{JSON.stringify({attributes: message.attributes, messageAttributes: message.messageAttributes, receiptHandle: message.receiptHandle}, null, 2)}</pre></details></div><button className="icon-btn danger" type="button" aria-label={`Delete message ${message.messageId}`} disabled={remove.isPending} onClick={() => remove.mutate(message)}><Trash2 size={14}/></button></article>)}
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Queue management</p>{!purgeConfirm ? <button className="button danger" type="button" onClick={() => setPurgeConfirm(true)}>Purge queue</button> : <div className="apigateway-route-confirm"><span>Purge approximately {String(resource.metadata.approximateMessages ?? 0)} visible messages?</span><button className="button danger compact" type="button" disabled={purge.isPending} onClick={() => purge.mutate()}>Confirm purge</button><button className="button compact" type="button" onClick={() => setPurgeConfirm(false)}>Cancel</button></div>}</section>
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Experimental: dead-letter queues</p><p className="muted compact-text">DLQ redrive threshold behavior is not fully AWS-compatible in this runtime. Use only for local experiments.</p><div className="dynamic-form"><label className="dynamic-field"><span>Target DLQ</span><select className="input" value={dlqArn} onChange={(event) => setDlqArn(event.target.value)}><option value="">Select a matching queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><label className="dynamic-field"><span>Max receive count</span><input className="input" type="number" min="1" max="1000" value={maxReceiveCount} onChange={(event) => setMaxReceiveCount(event.target.value)}/></label><button className="button" type="button" disabled={!dlqArn || configureDlq.isPending} onClick={() => configureDlq.mutate()}>Save experimental DLQ</button></div><div className="dynamic-form"><label className="dynamic-field"><span>Move destination</span><select className="input" value={moveDestination} onChange={(event) => setMoveDestination(event.target.value)}><option value="">Original source queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><button className="button" type="button" disabled={move.isPending} onClick={() => move.mutate()}>Move DLQ messages</button>{move.data && <code>{move.data.taskHandle}</code>}</div></section>
|
||||
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function parseAttributes(value: string): Record<string, SqsMessageAttribute> | undefined {
|
||||
if (!value.trim()) return undefined
|
||||
const parsed = JSON.parse(value) as Record<string, SqsMessageAttribute>
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Message attributes must be a JSON object.')
|
||||
return parsed
|
||||
}
|
||||
@@ -37,11 +37,11 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionSucceeded') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText('ExecutionStarted')).toBeInTheDocument()
|
||||
expect(await screen.findByText('ExecutionSucceeded')).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(1, machine.id, executionArn, undefined, expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(2, machine.id, executionArn, 'next', expect.any(AbortSignal))
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toHaveLength(1)
|
||||
expect(screen.getAllByText('ExecutionStarted')).toHaveLength(1)
|
||||
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
|
||||
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
||||
@@ -87,7 +87,7 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
|
||||
expect(await screen.findByText('Unable to load the next history page')).toBeInTheDocument()
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
expect(screen.getByText('ExecutionStarted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('polls only the trailing history page for a running execution and appends new events', async () => {
|
||||
@@ -101,9 +101,9 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskStateEntered') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText('TaskStateEntered')).toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskSucceeded') === true, {}, {timeout: 3_000})).toBeInTheDocument()
|
||||
expect(await screen.findByText('TaskSucceeded', {}, {timeout: 3_000})).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(3, machine.id, executionArn, 'page-2', expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -123,6 +123,33 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
|
||||
expect(await screen.findByText('History incomplete: reached the 10-page limit.')).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(10)
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('Event') === true)).toHaveLength(10)
|
||||
expect(screen.getAllByText('Event')).toHaveLength(10)
|
||||
})
|
||||
|
||||
test('renders state status, selected state input, definition, and state events', async () => {
|
||||
const workflow: CloudResource = {...machine, metadata: {machineType: 'STANDARD', definition: JSON.stringify({StartAt: 'ChargePayment', States: {ChargePayment: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:charge-payment:$LATEST'}}, PaymentFailed: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', End: true}}})}}
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id, input: '{"orderId":"1"}'})
|
||||
vi.mocked(getWorkflowExecutionHistory).mockResolvedValue({events: [
|
||||
{id: 1, type: 'TaskStateEntered', timestamp: '2026-07-28T10:01:00.000Z', stateEnteredEventDetails: {name: 'ChargePayment', input: '{"orderId":"1"}'}},
|
||||
{id: 2, previousEventId: 1, type: 'TaskFailed', taskFailedEventDetails: {error: 'Exception'}},
|
||||
{id: 3, previousEventId: 2, type: 'TaskStateExited', stateExitedEventDetails: {name: 'ChargePayment'}},
|
||||
{id: 4, type: 'TaskStateEntered', timestamp: '2026-07-28T10:01:01.000Z', stateEnteredEventDetails: {name: 'PaymentFailed', input: '{"orderId":"1"}'}},
|
||||
{id: 5, previousEventId: 4, type: 'TaskSucceeded', taskSucceededEventDetails: {output: '{"messageId":"1"}'}},
|
||||
{id: 6, previousEventId: 5, type: 'TaskStateExited', stateExitedEventDetails: {name: 'PaymentFailed', output: '{"messageId":"1"}'}},
|
||||
]})
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
render(<QueryClientProvider client={client}><WorkflowExecutionDebugger resource={workflow} runtimeReachable={true}/></QueryClientProvider>)
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText('Caught error')).toBeInTheDocument()
|
||||
await user.click(screen.getAllByRole('button', {name: /ChargePayment/})[0])
|
||||
expect(screen.getByRole('link', {name: 'Open logs'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fcharge-payment')
|
||||
expect(screen.getByText('{"orderId":"1"}')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', {name: 'definition'}))
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'PRE' && element.textContent?.includes('arn:aws:states:::lambda:invoke') === true)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', {name: 'events'}))
|
||||
expect(screen.getAllByText('TaskFailed')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Play, RefreshCw, Square} from 'lucide-react'
|
||||
import {CheckCircle2, CircleAlert, Clock3, Play, RefreshCw, ScrollText, Square, XCircle} from 'lucide-react'
|
||||
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution, type WorkflowExecutionDetail} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -20,6 +20,19 @@ interface ExecutionHistory {
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
type ExecutionStateStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'caught'
|
||||
type ExecutionEvent = Record<string, unknown>
|
||||
|
||||
interface ExecutionState {
|
||||
name: string
|
||||
type: string
|
||||
definition?: Record<string, unknown>
|
||||
status: ExecutionStateStatus
|
||||
input?: string
|
||||
output?: string
|
||||
events: ExecutionEvent[]
|
||||
}
|
||||
|
||||
export function WorkflowExecutionDebugger({resource, runtimeReachable}: WorkflowExecutionDebuggerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [input, setInput] = useState('{}')
|
||||
@@ -80,24 +93,147 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
|
||||
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start execution.')}</p>}
|
||||
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
|
||||
<table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table>
|
||||
{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}
|
||||
{selectedExecutionArn && <ExecutionDetail detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
|
||||
{selectedExecutionArn && <ExecutionDetail resource={resource} detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
|
||||
<section className={`workflow-execution-list${selectedExecutionArn ? ' compact' : ''}`}><div className="workflow-execution-list-heading"><h4>Recent executions</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</section>
|
||||
</section>
|
||||
}
|
||||
|
||||
function ExecutionDetail({detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: Array<Record<string, unknown>>; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
|
||||
function ExecutionDetail({resource, detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {resource: CloudResource; detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: ExecutionEvent[]; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
|
||||
const [selectedStateName, setSelectedStateName] = useState<string>()
|
||||
const [tab, setTab] = useState<'input' | 'output' | 'details' | 'definition' | 'events'>('input')
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStateName(undefined)
|
||||
setTab('input')
|
||||
}, [detail?.executionArn])
|
||||
if (loading) return <p className="muted compact-text">Loading execution...</p>
|
||||
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load execution.')}</p>
|
||||
if (!detail) return null
|
||||
return <div style={{marginTop: 16}}><div className="dynamic-stage-header"><div><h3>{detail.name} <span className="muted">{detail.status}</span></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
|
||||
|
||||
const states = executionStates(resource, events)
|
||||
const selectedState = states.find((state) => state.name === selectedStateName) ?? states.find((state) => state.status === 'running') ?? states[states.length - 1]
|
||||
return <div className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Execution</p><h3>{detail.name} <ExecutionStatus status={detail.status}/></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
|
||||
{Boolean(stopError) && <p className="error-text compact-text">{message(stopError, 'Unable to stop execution.')}</p>}
|
||||
<ExecutionData label="Input" value={detail.input}/><ExecutionData label="Output" value={detail.output}/>{detail.error && <ExecutionData label="Error" value={detail.error}/>} {detail.cause && <ExecutionData label="Cause" value={detail.cause}/>}
|
||||
<h4>Timeline</h4>{loadingHistory && <p className="muted compact-text">Loading history...</p>}{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
|
||||
{events.map((event, index) => <details key={`${String(event.id ?? index)}-${index}`}><summary>{String(event.timestamp ?? '')} {String(event.type ?? 'Event')}</summary><pre>{JSON.stringify(event, null, 2)}</pre></details>)}
|
||||
<div className="workflow-execution-workspace">
|
||||
<ExecutionGraph states={states} selectedStateName={selectedState?.name} onSelect={setSelectedStateName}/>
|
||||
<ExecutionInspector state={selectedState} detail={detail} tab={tab} onTabChange={setTab}/>
|
||||
</div>
|
||||
<section className="workflow-execution-history"><div className="workflow-execution-history-heading"><div><h4>Event history <span>{events.length}</span></h4><p className="muted compact-text">Complete execution history, newest events appear as the execution runs.</p></div>{loadingHistory && <span className="workflow-live-indicator"><Clock3 size={13}/> Updating</span>}</div>{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
|
||||
<ol>{events.map((event, index) => { const stateName = eventStateName(event, events); return <li key={`${String(event.id ?? index)}-${index}`}><button type="button" onClick={() => stateName && setSelectedStateName(stateName)} disabled={!stateName}><time>{String(event.timestamp ?? '')}</time><strong>{String(event.type ?? 'Event')}</strong>{stateName && <span>{stateName}</span>}</button></li> })}</ol>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExecutionGraph({states, selectedStateName, onSelect}: {states: ExecutionState[]; selectedStateName?: string; onSelect: (name: string) => void}) {
|
||||
return <section className="workflow-execution-graph"><div className="workflow-execution-pane-heading"><div><h4>Graph view</h4><p>Live state progression</p></div><span>{states.length} states</span></div><div className="workflow-execution-canvas"><div className="workflow-execution-start">Start</div>{states.map((state) => <div className="workflow-execution-graph-row" key={state.name}><span className="workflow-execution-connector"/><button className={`workflow-execution-node ${state.status}${selectedStateName === state.name ? ' selected' : ''}`} type="button" onClick={() => onSelect(state.name)}><ExecutionStatus status={state.status}/><strong>{state.name}</strong><small>{state.type}</small></button></div>)}<div className="workflow-execution-graph-row"><span className="workflow-execution-connector"/><div className="workflow-execution-end">End</div></div></div><div className="workflow-execution-legend"><span><i className="running"/>In progress</span><span><i className="failed"/>Failed</span><span><i className="caught"/>Caught error</span><span><i className="succeeded"/>Succeeded</span></div></section>
|
||||
}
|
||||
|
||||
function ExecutionInspector({state, detail, tab, onTabChange}: {state?: ExecutionState; detail: WorkflowExecutionDetail; tab: 'input' | 'output' | 'details' | 'definition' | 'events'; onTabChange: (tab: 'input' | 'output' | 'details' | 'definition' | 'events') => void}) {
|
||||
const tabs = ['input', 'output', 'details', 'definition', 'events'] as const
|
||||
const value = tab === 'input' ? state?.input ?? detail.input : tab === 'output' ? state?.output ?? detail.output : undefined
|
||||
const functionName = lambdaFunctionName(state)
|
||||
return <section className="workflow-execution-inspector"><div className="workflow-execution-pane-heading"><div><h4>{state?.name ?? detail.name}</h4><p>{state ? `${state.type} state` : 'Execution details'}</p></div><div className="workflow-execution-heading-actions">{functionName && <a className="button compact" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={12}/> Open logs</a>}{state && <ExecutionStatus status={state.status}/>}</div></div><div className="workflow-execution-tabs" role="tablist">{tabs.map((item) => <button key={item} type="button" role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} onClick={() => onTabChange(item)}>{item}</button>)}</div><div className="workflow-execution-tab-content">{(tab === 'input' || tab === 'output') && <ExecutionData label={tab === 'input' ? 'Input' : 'Output'} value={value}/>} {tab === 'details' && <ExecutionData label="Details" value={JSON.stringify({status: state?.status ?? detail.status, startDate: detail.startDate, stopDate: detail.stopDate, error: detail.error, cause: detail.cause}, null, 2)}/>} {tab === 'definition' && <ExecutionData label="State definition" value={state?.definition ? JSON.stringify(state.definition, null, 2) : 'No state definition is available.'}/>} {tab === 'events' && <ol className="workflow-state-events">{(state?.events ?? []).map((event, index) => <li key={`${String(event.id ?? index)}-${index}`}><strong>{String(event.type ?? 'Event')}</strong><time>{String(event.timestamp ?? '')}</time></li>)}</ol>}</div></section>
|
||||
}
|
||||
|
||||
function ExecutionStatus({status}: {status: string}) {
|
||||
const normalized = status.toLowerCase() as ExecutionStateStatus
|
||||
const Icon = normalized === 'succeeded' ? CheckCircle2 : normalized === 'running' || normalized === 'pending' ? Clock3 : normalized === 'caught' ? CircleAlert : XCircle
|
||||
return <span className={`workflow-execution-status ${normalized}`}><Icon size={14}/>{status}</span>
|
||||
}
|
||||
|
||||
function executionStates(resource: CloudResource, events: ExecutionEvent[]): ExecutionState[] {
|
||||
const definitions = stateDefinitions(resource)
|
||||
const byName = new Map<string, ExecutionState>()
|
||||
for (const [name, definition] of definitions) {
|
||||
byName.set(name, {name, type: typeof definition.Type === 'string' ? definition.Type : 'State', definition, status: 'pending', events: []})
|
||||
}
|
||||
for (const event of events) {
|
||||
const name = eventStateName(event, events)
|
||||
if (!name) continue
|
||||
const state = byName.get(name) ?? {name, type: 'State', status: 'pending' as const, events: []}
|
||||
if (!byName.has(name)) byName.set(name, state)
|
||||
state.events.push(event)
|
||||
const type = String(event.type ?? '')
|
||||
const details = eventDetails(event)
|
||||
if (type.endsWith('StateEntered')) {
|
||||
state.status = 'running'
|
||||
state.input = stringField(details, 'input') ?? state.input
|
||||
} else if (type.endsWith('StateExited')) {
|
||||
const taskFailed = state.events.some((stateEvent) => String(stateEvent.type) === 'TaskFailed')
|
||||
const taskSucceeded = state.events.some((stateEvent) => String(stateEvent.type) === 'TaskSucceeded')
|
||||
state.status = taskFailed && !taskSucceeded ? 'failed' : 'succeeded'
|
||||
state.output = stringField(details, 'output') ?? state.output
|
||||
} else if (type === 'TaskSucceeded') {
|
||||
state.status = 'succeeded'
|
||||
state.output = stringField(details, 'output') ?? state.output
|
||||
} else if (type === 'TaskFailed' || type.endsWith('StateFailed')) {
|
||||
state.status = 'failed'
|
||||
}
|
||||
}
|
||||
const ordered = [...byName.values()]
|
||||
for (let index = 0; index < ordered.length - 1; index++) {
|
||||
const state = ordered[index]
|
||||
if (state.status !== 'failed') continue
|
||||
const next = ordered.slice(index + 1).find((candidate) => candidate.events.some((event) => String(event.type).endsWith('StateEntered')))
|
||||
if (next) state.status = 'caught'
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
function stateDefinitions(resource: CloudResource): Array<[string, Record<string, unknown>]> {
|
||||
const raw = resource.metadata.definition
|
||||
if (typeof raw !== 'string') return []
|
||||
try {
|
||||
const definition = JSON.parse(raw) as {States?: unknown}
|
||||
if (!definition.States || typeof definition.States !== 'object' || Array.isArray(definition.States)) return []
|
||||
return Object.entries(definition.States).flatMap(([name, state]) => state && typeof state === 'object' && !Array.isArray(state) ? [[name, state as Record<string, unknown>]] : [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function eventStateName(event: ExecutionEvent, allEvents: ExecutionEvent[]): string | undefined {
|
||||
const details = eventDetails(event)
|
||||
const direct = stringField(details, 'name')
|
||||
if (direct) return direct
|
||||
const byId = new Map(allEvents.map((item) => [String(item.id), item]))
|
||||
let previousId = typeof event.previousEventId === 'number' ? String(event.previousEventId) : undefined
|
||||
while (previousId) {
|
||||
const previous = byId.get(previousId)
|
||||
if (!previous) return undefined
|
||||
const name = stringField(eventDetails(previous), 'name')
|
||||
if (name) return name
|
||||
previousId = typeof previous.previousEventId === 'number' ? String(previous.previousEventId) : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function eventDetails(event: ExecutionEvent): Record<string, unknown> {
|
||||
const details = Object.entries(event).find(([key, value]) => key.endsWith('EventDetails') && value && typeof value === 'object' && !Array.isArray(value))?.[1]
|
||||
return details as Record<string, unknown> ?? {}
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, key: string): string | undefined {
|
||||
return typeof value[key] === 'string' ? value[key] : undefined
|
||||
}
|
||||
|
||||
function lambdaFunctionName(state?: ExecutionState): string | undefined {
|
||||
const definition = state?.definition
|
||||
if (!definition || definition.Type !== 'Task') return undefined
|
||||
const resource = typeof definition.Resource === 'string' ? definition.Resource : ''
|
||||
if (resource.includes(':lambda:') && resource.includes(':function:')) return functionNameFromReference(resource)
|
||||
if (!resource.includes(':::lambda:invoke')) return undefined
|
||||
const parameters = definition.Parameters
|
||||
if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) return undefined
|
||||
const functionName = (parameters as Record<string, unknown>).FunctionName
|
||||
return typeof functionName === 'string' ? functionNameFromReference(functionName) : undefined
|
||||
}
|
||||
|
||||
function functionNameFromReference(reference: string): string {
|
||||
const arnName = reference.split(':function:')[1]
|
||||
return arnName ? arnName.split(':')[0] : reference.split(':')[0]
|
||||
}
|
||||
|
||||
function useExecutionHistory(stateMachineArn: string | undefined, executionArn: string | undefined, enabled: boolean, running: boolean): ExecutionHistory {
|
||||
const [history, setHistory] = useState<ExecutionHistory>({events: [], loading: false})
|
||||
|
||||
|
||||
@@ -614,10 +614,12 @@ describe('WorkflowStudio', () => {
|
||||
expect(screen.getByRole('heading', {name: 'SNS Publish Task'})).toBeInTheDocument()
|
||||
expect(listCloudResources).toHaveBeenCalledWith('aws', 'notifications', undefined, expect.any(AbortSignal))
|
||||
await user.selectOptions(screen.getByRole('combobox', {name: 'SNS topic'}), 'arn:aws:sns:us-east-1:000000000000:orders')
|
||||
await user.clear(screen.getByRole('textbox', {name: 'SNS message'}))
|
||||
await user.type(screen.getByRole('textbox', {name: 'SNS message'}), 'Order succeeded')
|
||||
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
|
||||
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'keep'})
|
||||
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'Order succeeded'})
|
||||
})
|
||||
|
||||
test('edits SQS parameters from queue inventory and keeps shared task policy controls', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Mark
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import {getCloudResource, listCloudResources, updateCloudResource} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys, useCloudResourceQuery} from '@/api/queries/cloudQueries'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type AslObject = Record<string, unknown>
|
||||
@@ -38,21 +39,22 @@ interface TaskIntegration {
|
||||
stateName: string
|
||||
resource: string
|
||||
icon: LucideIcon
|
||||
iconKey: string
|
||||
parameters: AslObject
|
||||
}
|
||||
|
||||
const TASK_INTEGRATIONS: TaskIntegration[] = [
|
||||
{action: 'lambda', service: 'Lambda', label: 'Invoke', nodeLabel: 'Lambda: Invoke', stateName: 'LambdaInvoke', resource: 'arn:aws:states:::lambda:invoke', icon: Zap, parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'}},
|
||||
{action: 'sns', service: 'SNS', label: 'Publish', nodeLabel: 'SNS: Publish', stateName: 'SnsPublish', resource: 'arn:aws:states:::sns:publish', icon: BellRing, parameters: {TopicArn: ''}},
|
||||
{action: 'sqs-send-message', service: 'SQS', label: 'SendMessage', nodeLabel: 'SQS: SendMessage', stateName: 'SqsSendMessage', resource: 'arn:aws:states:::sqs:sendMessage', icon: MessageSquare, parameters: {QueueUrl: '', MessageBody: ''}},
|
||||
{action: 'dynamodb-put-item', service: 'DynamoDB', label: 'PutItem', nodeLabel: 'DynamoDB: PutItem', stateName: 'DynamoDbPutItem', resource: 'arn:aws:states:::dynamodb:putItem', icon: Database, parameters: {TableName: '', Item: {}}},
|
||||
{action: 'dynamodb-get-item', service: 'DynamoDB', label: 'GetItem', nodeLabel: 'DynamoDB: GetItem', stateName: 'DynamoDbGetItem', resource: 'arn:aws:states:::dynamodb:getItem', icon: Database, parameters: {TableName: '', Key: {}}},
|
||||
{action: 'dynamodb-update-item', service: 'DynamoDB', label: 'UpdateItem', nodeLabel: 'DynamoDB: UpdateItem', stateName: 'DynamoDbUpdateItem', resource: 'arn:aws:states:::dynamodb:updateItem', icon: Database, parameters: {TableName: '', Key: {}, UpdateExpression: '', ExpressionAttributeValues: {}}},
|
||||
{action: 'dynamodb-delete-item', service: 'DynamoDB', label: 'DeleteItem', nodeLabel: 'DynamoDB: DeleteItem', stateName: 'DynamoDbDeleteItem', resource: 'arn:aws:states:::dynamodb:deleteItem', icon: Database, parameters: {TableName: '', Key: {}}},
|
||||
{action: 'eventbridge-put-events', service: 'EventBridge', label: 'PutEvents', nodeLabel: 'EventBridge: PutEvents', stateName: 'EventBridgePutEvents', resource: 'arn:aws:states:::events:putEvents', icon: Radio, parameters: {Entries: [{EventBusName: '', Source: '', DetailType: '', Detail: {}}]}},
|
||||
{action: 'ecs-run-task', service: 'ECS', label: 'RunTask', nodeLabel: 'ECS: RunTask', stateName: 'EcsRunTask', resource: 'arn:aws:states:::ecs:runTask', icon: Container, parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'ecs-run-task-sync', service: 'ECS', label: 'RunTask (.sync)', nodeLabel: 'ECS: RunTask (.sync)', stateName: 'EcsRunTaskSync', resource: 'arn:aws:states:::ecs:runTask.sync', icon: Container, parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'states-start-execution', service: 'Step Functions', label: 'StartExecution', nodeLabel: 'Step Functions: StartExecution', stateName: 'StartExecution', resource: 'arn:aws:states:::states:startExecution', icon: Workflow, parameters: {StateMachineArn: '', Input: {}}},
|
||||
{action: 'lambda', service: 'Lambda', label: 'Invoke', nodeLabel: 'Lambda: Invoke', stateName: 'LambdaInvoke', resource: 'arn:aws:states:::lambda:invoke', icon: Zap, iconKey: 'serverless', parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'}},
|
||||
{action: 'sns', service: 'SNS', label: 'Publish', nodeLabel: 'SNS: Publish', stateName: 'SnsPublish', resource: 'arn:aws:states:::sns:publish', icon: BellRing, iconKey: 'notifications', parameters: {TopicArn: ''}},
|
||||
{action: 'sqs-send-message', service: 'SQS', label: 'SendMessage', nodeLabel: 'SQS: SendMessage', stateName: 'SqsSendMessage', resource: 'arn:aws:states:::sqs:sendMessage', icon: MessageSquare, iconKey: 'messaging', parameters: {QueueUrl: '', MessageBody: ''}},
|
||||
{action: 'dynamodb-put-item', service: 'DynamoDB', label: 'PutItem', nodeLabel: 'DynamoDB: PutItem', stateName: 'DynamoDbPutItem', resource: 'arn:aws:states:::dynamodb:putItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Item: {}}},
|
||||
{action: 'dynamodb-get-item', service: 'DynamoDB', label: 'GetItem', nodeLabel: 'DynamoDB: GetItem', stateName: 'DynamoDbGetItem', resource: 'arn:aws:states:::dynamodb:getItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}}},
|
||||
{action: 'dynamodb-update-item', service: 'DynamoDB', label: 'UpdateItem', nodeLabel: 'DynamoDB: UpdateItem', stateName: 'DynamoDbUpdateItem', resource: 'arn:aws:states:::dynamodb:updateItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}, UpdateExpression: '', ExpressionAttributeValues: {}}},
|
||||
{action: 'dynamodb-delete-item', service: 'DynamoDB', label: 'DeleteItem', nodeLabel: 'DynamoDB: DeleteItem', stateName: 'DynamoDbDeleteItem', resource: 'arn:aws:states:::dynamodb:deleteItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}}},
|
||||
{action: 'eventbridge-put-events', service: 'EventBridge', label: 'PutEvents', nodeLabel: 'EventBridge: PutEvents', stateName: 'EventBridgePutEvents', resource: 'arn:aws:states:::events:putEvents', icon: Radio, iconKey: 'events', parameters: {Entries: [{EventBusName: '', Source: '', DetailType: '', Detail: {}}]}},
|
||||
{action: 'ecs-run-task', service: 'ECS', label: 'RunTask', nodeLabel: 'ECS: RunTask', stateName: 'EcsRunTask', resource: 'arn:aws:states:::ecs:runTask', icon: Container, iconKey: 'containers', parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'ecs-run-task-sync', service: 'ECS', label: 'RunTask (.sync)', nodeLabel: 'ECS: RunTask (.sync)', stateName: 'EcsRunTaskSync', resource: 'arn:aws:states:::ecs:runTask.sync', icon: Container, iconKey: 'containers', parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'states-start-execution', service: 'Step Functions', label: 'StartExecution', nodeLabel: 'Step Functions: StartExecution', stateName: 'StartExecution', resource: 'arn:aws:states:::states:startExecution', icon: Workflow, iconKey: 'workflows', parameters: {StateMachineArn: '', Input: {}}},
|
||||
]
|
||||
|
||||
const TASK_INTEGRATION_BY_ACTION = new Map(TASK_INTEGRATIONS.map((integration) => [integration.action, integration]))
|
||||
@@ -449,7 +451,7 @@ export function WorkflowStudio({resource}: WorkflowStudioProps) {
|
||||
<p>{paletteTab === 'actions' ? 'AWS service integrations' : 'Flow states'}</p>
|
||||
{paletteTab === 'actions' ? integrationGroups.map((service) => <section className="workflow-palette-service-group" key={service}>
|
||||
<h4>{service}</h4>
|
||||
<div className="workflow-palette-cards">{visibleIntegrations.filter((integration) => integration.service === service).map(({action, label, icon: Icon}) => <button key={action} aria-label={`${service} ${label}`} type="button" draggable onDragStart={(event) => event.dataTransfer.setData('application/x-workflow-action', action)} onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><Icon size={17}/></span><span><strong>{label}</strong><small>Optimized integration</small></span></button>)}</div>
|
||||
<div className="workflow-palette-cards">{visibleIntegrations.filter((integration) => integration.service === service).map(({action, label, icon, iconKey}) => <button key={action} aria-label={`${service} ${label}`} type="button" draggable onDragStart={(event) => event.dataTransfer.setData('application/x-workflow-action', action)} onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><ServiceIcon cloud="aws" iconKey={iconKey} fallback={icon} size={28}/></span><span><strong>{label}</strong><small>Optimized integration</small></span></button>)}</div>
|
||||
</section>) : <div className="workflow-palette-cards">{visibleFlowItems.map(({action, label, icon: Icon}) => <button key={action} aria-label={label} type="button" onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><Icon size={17}/></span><span><strong>{label}</strong><small>Workflow control</small></span></button>)}</div>}
|
||||
{activeBranch && <div className="workflow-branch-hint" role="status">Choose an AWS service integration for the selected branch.</div>}
|
||||
</aside>
|
||||
@@ -542,8 +544,11 @@ function TaskEditorSection({title, addLabel, disabled, onAdd, children}: {title:
|
||||
}
|
||||
|
||||
function SnsTaskPicker({state, topics, loading, failed, onChange}: {state: AslObject; topics: CloudResource[]; loading: boolean; failed: boolean; onChange: (change: (state: AslObject) => void) => void}) {
|
||||
const topicArn = isObject(state.Parameters) ? stringValue(state.Parameters.TopicArn) ?? '' : ''
|
||||
return <div className="workflow-task-editor workflow-sns-task-picker">
|
||||
const parameters = parametersOf(state)
|
||||
const topicArn = stringValue(parameters.TopicArn) ?? ''
|
||||
const pathMode = typeof parameters['Message.$'] === 'string'
|
||||
const message = pathMode ? stringValue(parameters['Message.$']) ?? '' : stringValue(parameters.Message) ?? ''
|
||||
return <div className="workflow-task-editor workflow-service-editor workflow-sns-task-picker">
|
||||
<h4>SNS Publish Task</h4>
|
||||
<label htmlFor="workflow-sns-topic">Topic</label>
|
||||
<select id="workflow-sns-topic" aria-label="SNS topic" value={topicArn} disabled={loading || failed} onChange={(event) => onChange((next) => {
|
||||
@@ -554,6 +559,17 @@ function SnsTaskPicker({state, topics, loading, failed, onChange}: {state: AslOb
|
||||
<option value="" disabled>{loading ? 'Loading SNS topics...' : failed ? 'Unable to load SNS topics' : 'Select an SNS topic'}</option>
|
||||
{topics.map((topic) => <option key={topic.id} value={topic.id}>{topic.name}</option>)}
|
||||
</select>
|
||||
<label>Message mode<select aria-label="SNS message mode" value={pathMode ? 'path' : 'literal'} onChange={(event) => onChange((next) => {
|
||||
const nextParameters = mutableParameters(next)
|
||||
if (event.target.value === 'path') {
|
||||
nextParameters['Message.$'] = stringValue(nextParameters.Message) || '$'
|
||||
delete nextParameters.Message
|
||||
} else {
|
||||
nextParameters.Message = stringValue(nextParameters['Message.$']) || ''
|
||||
delete nextParameters['Message.$']
|
||||
}
|
||||
})}><option value="literal">Literal</option><option value="path">JSONPath</option></select></label>
|
||||
<label>Message<textarea aria-label="SNS message" value={message} onChange={(event) => onChange((next) => setTaskParameter(next, pathMode ? 'Message.$' : 'Message', event.target.value))}/></label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -743,7 +759,7 @@ function StateGraphNode({data}: NodeProps<StateNode>) {
|
||||
return <div className={`workflow-state-node workflow-state-${data.stateType.toLowerCase()}${integration ? ` workflow-node-${integration.action}` : ''}${data.unattached ? ' workflow-state-unattached' : ''}`}>
|
||||
<Handle type="target" position={Position.Top}/>
|
||||
<button className="workflow-node-delete nodrag nopan" type="button" aria-label={`Delete ${data.label}`} onClick={(event) => { event.stopPropagation(); data.onDelete?.() }}><Trash2 size={12}/></button>
|
||||
<span className="workflow-state-type"><Icon size={13}/>{data.stateType}</span>
|
||||
<span className="workflow-state-type">{integration ? <ServiceIcon cloud="aws" iconKey={integration.iconKey} fallback={Icon} size={15}/> : <Icon size={13}/>} {data.stateType}</span>
|
||||
<strong>{integration?.nodeLabel ?? data.label}</strong>
|
||||
{integration && <span className="workflow-state-name">{data.label}</span>}
|
||||
{data.retry && <span className="workflow-retry-marker">Retry</span>}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
*
|
||||
* Icon keys are additive and never load-bearing: the server can ship a service
|
||||
* whose key this build has never heard of, and the nav must still render. Passing
|
||||
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
|
||||
* would blank the whole app, so the fallback is not optional.
|
||||
*/
|
||||
const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
storage: HardDrive,
|
||||
ecr: Container,
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
apigateway: Waypoints,
|
||||
serverless: Zap,
|
||||
secrets: KeyRound,
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: LucideIcon = Circle
|
||||
|
||||
export function serviceIcon(iconKey?: string): LucideIcon {
|
||||
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import apiGatewayIcon from '@/assets/aws-services/api-gateway.svg'
|
||||
import appConfigIcon from '@/assets/aws-services/appconfig.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
import ec2Icon from '@/assets/aws-services/ec2.svg'
|
||||
import ecrIcon from '@/assets/aws-services/ecr.svg'
|
||||
import ecsIcon from '@/assets/aws-services/ecs.svg'
|
||||
import eksIcon from '@/assets/aws-services/eks.svg'
|
||||
import elasticLoadBalancingIcon from '@/assets/aws-services/elastic-load-balancing.svg'
|
||||
import eventBridgeIcon from '@/assets/aws-services/eventbridge.svg'
|
||||
import iamIcon from '@/assets/aws-services/iam.svg'
|
||||
import kmsIcon from '@/assets/aws-services/kms.svg'
|
||||
import lambdaIcon from '@/assets/aws-services/lambda.svg'
|
||||
import parameterStoreIcon from '@/assets/aws-services/parameter-store.svg'
|
||||
import rdsIcon from '@/assets/aws-services/rds.svg'
|
||||
import s3Icon from '@/assets/aws-services/s3.svg'
|
||||
import secretsManagerIcon from '@/assets/aws-services/secrets-manager.svg'
|
||||
import sesIcon from '@/assets/aws-services/ses.svg'
|
||||
import snsIcon from '@/assets/aws-services/sns.svg'
|
||||
import sqsIcon from '@/assets/aws-services/sqs.svg'
|
||||
import stepFunctionsIcon from '@/assets/aws-services/step-functions.svg'
|
||||
import vpcIcon from '@/assets/aws-services/vpc.svg'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
*
|
||||
* Icon keys are additive and never load-bearing: the server can ship a service
|
||||
* whose key this build has never heard of, and the nav must still render. Passing
|
||||
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
|
||||
* would blank the whole app, so the fallback is not optional.
|
||||
*/
|
||||
const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
storage: HardDrive,
|
||||
ecr: Container,
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
apigateway: Waypoints,
|
||||
serverless: Zap,
|
||||
secrets: KeyRound,
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
}
|
||||
|
||||
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
apigateway: apiGatewayIcon,
|
||||
compute: ec2Icon,
|
||||
configuration: appConfigIcon,
|
||||
containers: ecsIcon,
|
||||
database: rdsIcon,
|
||||
ecr: ecrIcon,
|
||||
email: sesIcon,
|
||||
events: eventBridgeIcon,
|
||||
iac: cloudFormationIcon,
|
||||
iam: iamIcon,
|
||||
kms: kmsIcon,
|
||||
k8s: eksIcon,
|
||||
loadbalancing: elasticLoadBalancingIcon,
|
||||
logs: cloudWatchLogsIcon,
|
||||
messaging: sqsIcon,
|
||||
nosql: dynamoDbIcon,
|
||||
notifications: snsIcon,
|
||||
networking: vpcIcon,
|
||||
parameters: parameterStoreIcon,
|
||||
secrets: secretsManagerIcon,
|
||||
serverless: lambdaIcon,
|
||||
storage: s3Icon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: LucideIcon = Circle
|
||||
|
||||
function serviceIcon(iconKey?: string): LucideIcon {
|
||||
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
|
||||
}
|
||||
|
||||
interface ServiceIconProps {
|
||||
cloud?: CloudProvider
|
||||
iconKey?: string
|
||||
size?: number
|
||||
fallback?: LucideIcon
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders official local AWS artwork, with the existing Lucide map as a safe fallback. */
|
||||
export function ServiceIcon({cloud, iconKey, size = 16, fallback, className}: ServiceIconProps) {
|
||||
const asset = cloud === 'aws' && iconKey ? AWS_SERVICE_ICON_ASSETS[iconKey] : undefined
|
||||
const classes = ['service-icon-glyph', asset ? 'aws-service-icon' : '', className ?? ''].filter(Boolean).join(' ')
|
||||
|
||||
if (asset) {
|
||||
return <img className={classes} src={asset} width={size} height={size} alt="" aria-hidden="true"/>
|
||||
}
|
||||
|
||||
const FallbackIcon = fallback ?? serviceIcon(iconKey)
|
||||
return <FallbackIcon className={classes} size={size} aria-hidden="true"/>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {type ElementType} from 'react'
|
||||
import {Cloud, Database, ExternalLink, Radio, Route, ShieldCheck} from 'lucide-react'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {adapterLabel, cloudName, providerDescription, runtimeName} from './cloudConsoleHome.utils'
|
||||
import type {ProviderBannerProps, RuntimeFlowProps, ServiceGridProps, SummarySectionProps} from './types'
|
||||
|
||||
@@ -67,13 +68,12 @@ export function ServiceGrid({services, runtimeReachable, onNavigate, searchQuery
|
||||
return (
|
||||
<section className="console-service-grid">
|
||||
{services.map((service) => {
|
||||
const Icon = service.icon
|
||||
const isAvailable = service.status === 'available'
|
||||
const canOpen = isAvailable && service.route && runtimeReachable
|
||||
const content = (
|
||||
<>
|
||||
<div className="service-card-header">
|
||||
<div className="service-icon"><Icon size={18}/></div>
|
||||
<div className="service-icon"><ServiceIcon cloud={service.cloud} iconKey={service.iconKey} size={36}/></div>
|
||||
<div>
|
||||
<h3>{service.label}</h3>
|
||||
<span className={isAvailable ? 'status healthy' : 'status unknown'}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type {ElementType} from 'react'
|
||||
import type {CloudAvailability, CloudProvider, CloudStatus} from '@/types/cloud'
|
||||
|
||||
export interface ConsoleServiceCard {
|
||||
@@ -6,7 +5,8 @@ export interface ConsoleServiceCard {
|
||||
label: string
|
||||
status: CloudAvailability
|
||||
count?: number
|
||||
icon: ElementType
|
||||
cloud: CloudProvider
|
||||
iconKey: string
|
||||
route?: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useCloudStatusQuery,
|
||||
useCloudsQuery,
|
||||
} from '@/api/queries/cloudQueries'
|
||||
import {serviceIcon} from '@/components/serviceIcons'
|
||||
import {
|
||||
resourceDetailFor,
|
||||
runtimeClassFor,
|
||||
@@ -76,7 +75,8 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
label: service.displayName,
|
||||
status: service.availability,
|
||||
count: counts?.count,
|
||||
icon: serviceIcon(service.iconKey),
|
||||
cloud,
|
||||
iconKey: service.iconKey,
|
||||
route:
|
||||
service.availability === 'available'
|
||||
? isLegacyPage
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { ServiceIcon } from '@/components/serviceIcons'
|
||||
import type { SecretSummary } from '@/api/aws/secretsmanager.api'
|
||||
import {
|
||||
secretsManagerQueryKeys,
|
||||
@@ -690,6 +691,7 @@ export function SecretsManagerPage() {
|
||||
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<ServiceIcon cloud="aws" iconKey="secrets" size={32} />
|
||||
<h1>Secrets Manager</h1>
|
||||
<span className="info-link">
|
||||
<Info size={11} />
|
||||
@@ -755,7 +757,7 @@ export function SecretsManagerPage() {
|
||||
onClick={() => setSelected(selected === id ? null : id)}
|
||||
>
|
||||
<td className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<KeyRound size={13} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<ServiceIcon cloud="aws" iconKey="secrets" size={18} />
|
||||
{secret.name}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-3)' }}>{secret.description ?? '—'}</td>
|
||||
|
||||
@@ -384,6 +384,7 @@ pre,
|
||||
|
||||
/* The 14px chrome size is too small once the icon is the only thing naming a
|
||||
service. CSS overrides lucide's width/height attributes. */
|
||||
[data-sidebar="collapsed"] .sidebar:not(:has(.nav:focus-within)) .nav-link .service-icon-glyph,
|
||||
[data-sidebar="collapsed"] .sidebar:not(:has(.nav:focus-within)) .nav-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -442,6 +443,16 @@ pre,
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.service-icon-glyph {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.aws-service-icon {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
@@ -929,6 +940,7 @@ pre,
|
||||
.workflow-palette-cards strong { font-size: 12px; }
|
||||
.workflow-palette-cards small { color: var(--text-3); font-size: 9px; }
|
||||
.workflow-palette-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border-radius: 3px; background: var(--accent-tint); color: var(--accent); }
|
||||
.workflow-palette-icon:has(.aws-service-icon) { background: transparent; }
|
||||
.workflow-palette-service-group { display: grid; gap: 6px; margin: 0 0 12px; }
|
||||
.workflow-palette-service-group h4 { margin: 0; color: var(--text-2); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.workflow-palette-lambda { background: rgba(255, 153, 0, .15); color: #d97706; }
|
||||
@@ -975,6 +987,57 @@ pre,
|
||||
.workflow-retry-marker { margin-top: 7px; color: #854d0e; font-size: 10px; font-weight: 700; }
|
||||
.workflow-state-name { display: block; margin-top: 4px; color: var(--workflow-node-muted); font-size: 10px; }
|
||||
.workflow-unattached-marker { display: inline-block; margin-top: 7px; padding: 2px 5px; border: 1px solid #a85f13; border-radius: 2px; color: #7a3f00; font-size: 9px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.workflow-execution-detail { margin-top: 16px; }
|
||||
.workflow-execution-list { margin-top: 16px; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
|
||||
.workflow-execution-list-heading { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.workflow-execution-list-heading h4 { margin: 0; color: var(--text); font-size: 13px; }
|
||||
.workflow-execution-list-heading span { color: var(--text-3); font-size: 11px; }
|
||||
.workflow-execution-list-table { overflow-x: auto; }
|
||||
.workflow-execution-list.compact .workflow-execution-list-table { max-height: 264px; overflow-y: auto; }
|
||||
.workflow-execution-list.compact thead { position: sticky; top: 0; z-index: 1; }
|
||||
.workflow-execution-list .table { margin: 0; }
|
||||
.workflow-execution-status { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; color: var(--text-2); font-size: 11px; font-weight: 700; vertical-align: middle; }
|
||||
.workflow-execution-status.running { color: #0369a1; }
|
||||
.workflow-execution-status.succeeded { color: #16743e; }
|
||||
.workflow-execution-status.failed { color: #b42318; }
|
||||
.workflow-execution-status.caught { color: #a15c00; }
|
||||
.workflow-execution-workspace { display: grid; grid-template-columns: minmax(340px, 1.1fr) minmax(320px, .9fr); min-height: 510px; border: 1px solid var(--border); }
|
||||
.workflow-execution-graph, .workflow-execution-inspector { min-width: 0; background: var(--surface); }
|
||||
.workflow-execution-graph { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-right: 1px solid var(--border); }
|
||||
.workflow-execution-pane-heading, .workflow-execution-history-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 15px 18px; border-bottom: 1px solid var(--border); }
|
||||
.workflow-execution-pane-heading h4, .workflow-execution-history-heading h4 { margin: 0; color: var(--text); font-size: 15px; }
|
||||
.workflow-execution-pane-heading p { margin: 3px 0 0; color: var(--text-3); font-size: 11px; }
|
||||
.workflow-execution-pane-heading > span { color: var(--text-3); font-size: 11px; }
|
||||
.workflow-execution-canvas { display: grid; justify-items: center; align-content: start; min-height: 410px; max-height: 620px; overflow: auto; padding: 26px 22px; background: radial-gradient(circle at 1px 1px, var(--workflow-canvas-dot) 1px, transparent 0) 0 0 / 20px 20px, var(--workflow-canvas-bg); }
|
||||
.workflow-execution-start, .workflow-execution-end { display: grid; width: 58px; height: 58px; place-items: center; border: 2px solid #a76f12; border-radius: 50%; background: #fff1be; color: #60430b; font-size: 12px; font-weight: 800; }
|
||||
.workflow-execution-end { width: 48px; height: 30px; border-color: #b42318; border-radius: 999px; background: #fff1f0; color: #9f1c13; }
|
||||
.workflow-execution-graph-row { display: grid; justify-items: center; }
|
||||
.workflow-execution-connector { width: 1px; height: 25px; background: #758195; }
|
||||
.workflow-execution-node { display: grid; grid-template-columns: auto 1fr; width: min(280px, 100%); gap: 2px 8px; align-items: center; padding: 11px 13px; border: 2px solid #94a1b5; border-radius: 5px; background: var(--elevated); color: #172033; text-align: left; box-shadow: 0 2px 5px rgba(31, 41, 55, .12); }
|
||||
.workflow-execution-node strong { min-width: 0; overflow: hidden; color: #172033; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-execution-node small { grid-column: 2; color: #566273; font-size: 10px; }
|
||||
.workflow-execution-node .workflow-execution-status { margin: 0; }
|
||||
.workflow-execution-node .workflow-execution-status svg { grid-row: span 2; }
|
||||
.workflow-execution-node.pending { border-style: dashed; }
|
||||
.workflow-execution-node.running { border-color: #0284c7; background: #e0f2fe; }
|
||||
.workflow-execution-node.succeeded { border-color: #268847; background: #e7f8ea; }
|
||||
.workflow-execution-node.failed { border-color: #c9362d; background: #fff0ef; }
|
||||
.workflow-execution-node.caught { border-color: #d48a17; background: #fff8e3; }
|
||||
.workflow-execution-node.selected { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.workflow-execution-legend { display: flex; flex-wrap: wrap; gap: 10px 16px; padding: 11px 16px; border-top: 1px solid var(--border); color: var(--text-2); font-size: 10px; }
|
||||
.workflow-execution-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.workflow-execution-legend i { width: 11px; height: 11px; border: 1px solid #94a1b5; border-radius: 2px; background: var(--surface); }
|
||||
.workflow-execution-legend i.running { border-color: #0284c7; background: #e0f2fe; }.workflow-execution-legend i.failed { border-color: #c9362d; background: #fff0ef; }.workflow-execution-legend i.caught { border-color: #d48a17; background: #fff8e3; }.workflow-execution-legend i.succeeded { border-color: #268847; background: #e7f8ea; }
|
||||
.workflow-execution-inspector { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
|
||||
.workflow-execution-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid var(--border); }
|
||||
.workflow-execution-tabs button { flex: 1 0 auto; padding: 11px 9px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-3); font-size: 11px; font-weight: 700; text-transform: capitalize; }
|
||||
.workflow-execution-tabs button.active { border-bottom-color: var(--accent); color: var(--accent); }
|
||||
.workflow-execution-tab-content { min-width: 0; padding: 17px; overflow: auto; }
|
||||
.workflow-execution-tab-content h4 { margin: 0 0 9px; color: var(--text-2); font-size: 12px; }
|
||||
.workflow-execution-tab-content pre { max-height: 365px; margin: 0; overflow: auto; padding: 14px; border: 1px solid var(--border); background: var(--raised); color: var(--text); font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.workflow-state-events { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }.workflow-state-events li { display: flex; justify-content: space-between; gap: 8px; padding: 8px; border: 1px solid var(--border-faint); font-size: 11px; }.workflow-state-events time { color: var(--text-3); }
|
||||
.workflow-execution-history { margin-top: 16px; border: 1px solid var(--border); background: var(--surface); }.workflow-execution-history-heading { border: 0; }.workflow-execution-history-heading h4 span { color: var(--text-3); font-weight: 400; }.workflow-execution-history-heading p { margin: 4px 0 0; }.workflow-live-indicator { display: inline-flex; align-items: center; gap: 5px; color: #0369a1; font-size: 11px; font-weight: 700; }.workflow-execution-history > ol { max-height: 330px; margin: 0; overflow: auto; padding: 0; border-top: 1px solid var(--border); list-style: none; }.workflow-execution-history li + li { border-top: 1px solid var(--border-faint); }.workflow-execution-history li button { display: grid; width: 100%; grid-template-columns: minmax(170px, .8fr) minmax(170px, 1fr) minmax(120px, 1fr); gap: 10px; padding: 10px 16px; border: 0; border-radius: 0; background: transparent; color: var(--text); text-align: left; }.workflow-execution-history li button:not(:disabled):hover { background: var(--hover); }.workflow-execution-history li button:disabled { color: var(--text-2); cursor: default; }.workflow-execution-history time { color: var(--text-3); font-size: 11px; }.workflow-execution-history strong { font-size: 11px; }.workflow-execution-history span { color: var(--accent); font-size: 11px; }
|
||||
@media (max-width: 900px) { .workflow-execution-workspace { grid-template-columns: 1fr; }.workflow-execution-graph { border-right: 0; border-bottom: 1px solid var(--border); }.workflow-execution-canvas { min-height: 320px; max-height: 440px; }.workflow-execution-history li button { grid-template-columns: 1fr; gap: 3px; } }
|
||||
.workflow-state-task { border-left-color: var(--workflow-task); }
|
||||
.workflow-node-lambda { border-left-color: #d97706; }
|
||||
.workflow-node-sns { border-left-color: #c4387b; }
|
||||
@@ -1136,6 +1199,10 @@ pre,
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.service-icon:has(.aws-service-icon) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.service-card h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
@@ -2077,6 +2144,73 @@ pre,
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.logs-mode-toggle,
|
||||
.logs-filter-toolbar,
|
||||
.workflow-execution-heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-mode-toggle {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.logs-mode-toggle .active {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
background: var(--link-tint);
|
||||
}
|
||||
|
||||
.logs-filter-toolbar {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.logs-filter-toolbar label {
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.logs-filter-pattern {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-live-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--status-success);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs-events-table td:first-child,
|
||||
.logs-event-stream {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-execution-heading-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.cosmos-toolbar,
|
||||
.logs-filter-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-mode-toggle {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.logs-filter-toolbar label {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cosmos-items-table {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -2526,6 +2660,57 @@ pre,
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dynamodb-query {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--raised) 55%, transparent);
|
||||
}
|
||||
|
||||
.dynamodb-query label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
|
||||
.dynamodb-query input,
|
||||
.dynamodb-query select {
|
||||
min-width: 120px;
|
||||
height: 30px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dynamodb-mode {
|
||||
display: flex;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.dynamodb-mode button {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dynamodb-mode button:first-child { border-radius: 4px 0 0 4px; }
|
||||
.dynamodb-mode button:last-child { margin-left: -1px; border-radius: 0 4px 4px 0; }
|
||||
.dynamodb-mode button.active { position: relative; border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--accent); }
|
||||
.dynamodb-load-more { align-self: center; margin: 10px; }
|
||||
|
||||
.dynamodb-document {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
@@ -2537,6 +2722,93 @@ pre,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dynamodb-selection {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dynamodb-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dynamodb-actions > summary {
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dynamodb-actions > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu button:hover:not(:disabled) {
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu .dynamodb-action-edit:hover:not(:disabled) {
|
||||
background: rgb(57 73 171 / 16%);
|
||||
color: #5c6bc0;
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu .dynamodb-action-delete:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.dynamodb-actions-menu .dynamodb-action-download:hover:not(:disabled) {
|
||||
background: rgb(14 116 144 / 16%);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.dynamodb-delete-dialog {
|
||||
display: grid;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.dynamodb-delete-dialog-heading { display: flex; gap: 12px; }
|
||||
.dynamodb-delete-dialog-heading > span { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: rgb(239 68 68 / 14%); color: var(--status-error); }
|
||||
.dynamodb-delete-dialog-heading h3 { margin: 0; color: var(--text); font-size: 17px; }
|
||||
.dynamodb-delete-dialog-heading p { margin: 6px 0 0; color: var(--text-2); font-size: 13px; line-height: 1.5; }
|
||||
.dynamodb-delete-warning { padding: 10px 12px; border-left: 3px solid var(--status-error); background: rgb(239 68 68 / 9%); color: var(--text-2); font-size: 12px; }
|
||||
.dynamodb-delete-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.launch-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -3133,13 +3405,42 @@ pre,
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.apigateway-route-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.apigateway-resource-tree {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.apigateway-resource-node {
|
||||
border-left: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-left: calc(var(--tree-depth) * 18px);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.apigateway-resource-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.apigateway-resource-select {
|
||||
align-items: baseline;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.apigateway-resource-select small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.apigateway-route-item {
|
||||
align-items: center;
|
||||
background: var(--surface-subtle);
|
||||
@@ -3150,6 +3451,13 @@ pre,
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.apigateway-cors-confirm {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.apigateway-route-select {
|
||||
@@ -4818,6 +5126,181 @@ button.console-service-card {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dynamodb-editor-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.dynamodb-editor-tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.dynamodb-editor-tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.dynamodb-attribute-rows,
|
||||
.dynamodb-nested-rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-page {
|
||||
width: min(1240px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-back {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-form {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-header h1,
|
||||
.dynamodb-item-editor-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-header h1 {
|
||||
color: var(--text-bright);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-header p {
|
||||
max-width: 680px;
|
||||
margin-top: 7px;
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dynamodb-attributes-section {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dynamodb-attribute-table-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dynamodb-attribute-table-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--text-bright);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-table-heading .select {
|
||||
min-width: 175px;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(200px, 2fr) 130px auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-column-headings,
|
||||
.dynamodb-attribute-row {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-column-headings {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(200px, 2fr) 130px auto;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-row + .dynamodb-attribute-row {
|
||||
border-top: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.dynamodb-attribute-row.key {
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
|
||||
}
|
||||
|
||||
.dynamodb-nested-rows {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-row.nested {
|
||||
grid-template-columns: minmax(100px, 1fr) minmax(120px, 2fr) 120px auto;
|
||||
}
|
||||
|
||||
.dynamodb-key-badge {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dynamodb-attribute-row,
|
||||
.dynamodb-attribute-row.nested,
|
||||
.dynamodb-attribute-column-headings {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.dynamodb-attribute-row > .select,
|
||||
.dynamodb-attribute-row > .textarea,
|
||||
.dynamodb-attribute-row > .dynamodb-nested-rows {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-page {
|
||||
width: min(100% - 24px, 1240px);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.dynamodb-item-editor-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Shared drawer tabs ─────────────────────────────────────────────────── */
|
||||
.sns-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
export interface DynamoKeyField {
|
||||
name: string
|
||||
type?: 'S' | 'N' | 'B'
|
||||
}
|
||||
|
||||
export function dynamoKeyFields(resource?: CloudResource): DynamoKeyField[] {
|
||||
const keySchema = Array.isArray(resource?.metadata.keySchema) ? resource.metadata.keySchema : []
|
||||
const definitions = Array.isArray(resource?.metadata.attributeDefinitions) ? resource.metadata.attributeDefinitions : []
|
||||
const types = new Map(definitions.flatMap((definition) => {
|
||||
if (!definition || typeof definition !== 'object') return []
|
||||
const {AttributeName, AttributeType} = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof AttributeName === 'string' && isDynamoKeyType(AttributeType) ? [[AttributeName, AttributeType] as const] : []
|
||||
}))
|
||||
|
||||
return keySchema.flatMap((key) => {
|
||||
if (!key || typeof key !== 'object') return []
|
||||
const name = (key as {AttributeName?: unknown}).AttributeName
|
||||
return typeof name === 'string' ? [{name, type: types.get(name)}] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function documentToAttributeValues(document: Record<string, unknown>, keyFields: DynamoKeyField[] = []): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(document).map(([name, value]) => {
|
||||
const keyType = keyFields.find((key) => key.name === name)?.type
|
||||
if (keyType === 'N') return [name, {N: String(value)}]
|
||||
if (keyType === 'B') return [name, {B: String(value)}]
|
||||
if (keyType === 'S') return [name, {S: String(value)}]
|
||||
return [name, valueToAttributeValue(value)]
|
||||
}))
|
||||
}
|
||||
|
||||
function isDynamoKeyType(value: unknown): value is 'S' | 'N' | 'B' {
|
||||
return value === 'S' || value === 'N' || value === 'B'
|
||||
}
|
||||
|
||||
function valueToAttributeValue(value: unknown): Record<string, unknown> {
|
||||
if (value === null) return {NULL: true}
|
||||
if (typeof value === 'string') return {S: value}
|
||||
if (typeof value === 'number') return {N: String(value)}
|
||||
if (typeof value === 'boolean') return {BOOL: value}
|
||||
if (Array.isArray(value)) return {L: value.map(valueToAttributeValue)}
|
||||
if (typeof value === 'object') return {M: documentToAttributeValues(value as Record<string, unknown>)}
|
||||
return {S: String(value)}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {useCloudServicesQuery, useCloudStatusQuery, useCloudsQuery} from '@/api/
|
||||
import {CloudSelector} from '@/components/CloudSelector'
|
||||
import {DynamicResourceView} from '@/components/DynamicResourceView'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {normalizeCapabilities, withRuntimeState, withServiceAvailability} from '@/lib/capabilities'
|
||||
import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
@@ -53,7 +54,9 @@ export function CloudExplorerPage() {
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
{selectedService
|
||||
? <ServiceIcon cloud={cloud} iconKey={selectedService.iconKey} size={32}/>
|
||||
: <Cloud size={20}/>}
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {ArrowLeft} from 'lucide-react'
|
||||
import {useMemo} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom'
|
||||
import {getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery, useCloudStatusQuery} from '@/api/queries/cloudQueries'
|
||||
import {DynamoDbItemEditor} from '@/components/DynamoDbItemEditor'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {documentToAttributeValues, dynamoKeyFields} from '@/lib/dynamodbItem'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
export function DynamoDbItemEditorPage() {
|
||||
const {cloud, resourceId, itemId} = useParams()
|
||||
const accountId = useAccountId()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const isCreate = !itemId
|
||||
const validCloud = cloud === 'aws' || cloud === 'azure' || cloud === 'gcp'
|
||||
const servicesQuery = useCloudServicesQuery(validCloud ? cloud : 'aws')
|
||||
const statusQuery = useCloudStatusQuery(validCloud ? cloud : 'aws')
|
||||
const resourceQuery = useQuery({
|
||||
queryKey: ['cloud-data-resource', accountId, cloud, 'nosql', resourceId],
|
||||
queryFn: ({signal}) => getCloudResource(cloud as CloudProvider, 'nosql', resourceId ?? '', signal),
|
||||
enabled: validCloud && Boolean(resourceId) && statusQuery.data?.runtime === 'reachable',
|
||||
})
|
||||
const itemsKey = useMemo(() => ['nosql-items', accountId, cloud, resourceId], [accountId, cloud, resourceId])
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: ({signal}) => listFlatItems(cloud as CloudProvider, 'nosql', resourceId ?? '', undefined, signal),
|
||||
enabled: validCloud && Boolean(resourceId) && statusQuery.data?.runtime === 'reachable' && !isCreate,
|
||||
})
|
||||
const destination = validCloud && resourceId ? `/cloud-explorer/${cloud}/nosql/${encodeURIComponent(resourceId)}/data` : '/console/aws'
|
||||
const descriptor = servicesQuery.data?.find((candidate) => candidate.service === 'nosql')
|
||||
const resource = resourceQuery.data
|
||||
const keyFields = useMemo(() => dynamoKeyFields(resource), [resource])
|
||||
const item = itemsQuery.data?.items.find((candidate) => candidate.id === itemId)
|
||||
const initialDocument = useMemo(() => item ? documentToAttributeValues(item.body, keyFields) : undefined, [item, keyFields])
|
||||
const putItemMutation = useMutation({
|
||||
mutationFn: (document: Record<string, unknown>) => putFlatItem(cloud as CloudProvider, 'nosql', resourceId ?? '', document),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({queryKey: itemsKey})
|
||||
navigate(destination)
|
||||
},
|
||||
})
|
||||
|
||||
if (!validCloud || !resourceId) return <EditorNotice title="Invalid item editor" detail="Check the cloud and table in this link."/>
|
||||
if (servicesQuery.isPending || statusQuery.isPending || resourceQuery.isPending || (!isCreate && itemsQuery.isPending)) return <EditorNotice title="Loading item editor" detail="Loading the table and item data."/>
|
||||
if (servicesQuery.isError || statusQuery.isError || resourceQuery.isError || itemsQuery.isError) return <EditorNotice title="Unable to load item editor" detail={(servicesQuery.error ?? statusQuery.error ?? resourceQuery.error ?? itemsQuery.error)?.message ?? 'The table or item data could not be loaded.'}/>
|
||||
if (!descriptor || descriptor.availability !== 'available') return <EditorNotice title="Service unavailable" detail={descriptor?.reason ?? 'DynamoDB item editing is unavailable for this runtime.'}/>
|
||||
if (statusQuery.data?.runtime !== 'reachable') return <EditorNotice title="Runtime unavailable" detail={statusQuery.data?.error ?? 'Start the selected runtime to edit DynamoDB items.'}/>
|
||||
if (!resource) return <EditorNotice title="Table not found" detail="The DynamoDB table no longer exists."/>
|
||||
if (!isCreate && !item) return <EditorNotice title="Item not found" detail="The selected DynamoDB item no longer exists."/>
|
||||
|
||||
return (
|
||||
<div className="dynamodb-item-editor-page">
|
||||
<Link className="button compact dynamodb-item-editor-back" to={destination}><ArrowLeft size={14}/>Back to table data</Link>
|
||||
<DynamoDbItemEditor
|
||||
editing={!isCreate}
|
||||
keyFields={keyFields}
|
||||
initialDocument={initialDocument}
|
||||
isPending={putItemMutation.isPending}
|
||||
submitError={putItemMutation.error instanceof Error ? putItemMutation.error.message : undefined}
|
||||
onClose={() => navigate(destination)}
|
||||
onSubmit={(document) => putItemMutation.mutate(document)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorNotice({title, detail}: {title: string; detail: string}) {
|
||||
return <div className="content"><div className="runtime-notice" role="status"><h3>{title}</h3><p>{detail}</p></div></div>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {ArrowLeft, Workflow} from 'lucide-react'
|
||||
import {ArrowLeft} from 'lucide-react'
|
||||
import {Link, useParams} from 'react-router-dom'
|
||||
import {useCloudResourceQuery} from '@/api/queries/cloudQueries'
|
||||
import {WorkflowStudio} from '@/components/WorkflowStudio'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
export function WorkflowStudioPage() {
|
||||
const {resourceId} = useParams()
|
||||
@@ -11,7 +12,7 @@ export function WorkflowStudioPage() {
|
||||
<header className="page-header data-workspace-header">
|
||||
<Link className="button compact" to="/cloud-explorer/aws/workflows"><ArrowLeft size={14}/>Back to workflows</Link>
|
||||
<div className="page-title">
|
||||
<Workflow size={20}/>
|
||||
<ServiceIcon cloud="aws" iconKey="workflows" size={32}/>
|
||||
<div><h1>{resourceQuery.data?.name ?? resourceId ?? 'Workflow Studio'}</h1><p className="muted">AWS Step Functions · Workflow Studio</p></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
export type EventListener<TEvent> = (event: TEvent) => void;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface EventBusListenerError<TEvent> {
|
||||
error: unknown;
|
||||
event: TEvent;
|
||||
listener: EventListener<TEvent>;
|
||||
}
|
||||
|
||||
export interface EventBusOptions<TEvent> {
|
||||
onListenerError?: (error: EventBusListenerError<TEvent>) => void;
|
||||
}
|
||||
|
||||
export class EventBus<TEvent> {
|
||||
private readonly listeners = new Set<EventListener<TEvent>>();
|
||||
|
||||
constructor(private readonly options: EventBusOptions<TEvent> = {}) {}
|
||||
|
||||
subscribe(listener: EventListener<TEvent>): Unsubscribe {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
publish(event: TEvent) {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
this.options.onListenerError?.({
|
||||
error,
|
||||
event,
|
||||
listener,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.listeners.size;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export type ScheduledJob = {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
run: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export class Scheduler {
|
||||
private readonly timers = new Map<string, number>();
|
||||
|
||||
add(job: ScheduledJob) {
|
||||
if (this.timers.has(job.name)) {
|
||||
throw new Error(`Scheduled job "${job.name}" already exists.`);
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void job.run();
|
||||
}, job.intervalMs);
|
||||
|
||||
this.timers.set(job.name, timer);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const timer of this.timers.values()) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
|
||||
this.timers.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface TelemetryIngestor {
|
||||
name: string;
|
||||
flushIntervalMs: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
flush(): void | Promise<void>;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { assert } from "@/lib/assertions";
|
||||
import type { ApiRequestEvent, CloudProvider } from "./types";
|
||||
|
||||
const CLOUD_PROVIDERS: ReadonlySet<CloudProvider> = new Set([
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"system",
|
||||
]);
|
||||
|
||||
export function assertApiRequestEvent(
|
||||
event: ApiRequestEvent,
|
||||
): asserts event is ApiRequestEvent {
|
||||
assertCloudProvider(event.provider);
|
||||
assertNonEmptyString(event.service, "service");
|
||||
assertNonEmptyString(event.method, "method");
|
||||
assertNonEmptyString(event.path, "path");
|
||||
assertNonNegativeFiniteNumber(event.statusCode, "statusCode");
|
||||
assertNonNegativeFiniteNumber(event.latencyMs, "latencyMs");
|
||||
assertPositiveFiniteNumber(event.timestamp, "timestamp");
|
||||
}
|
||||
|
||||
export function assertCloudProvider(
|
||||
provider: CloudProvider,
|
||||
): asserts provider is CloudProvider {
|
||||
assert(
|
||||
CLOUD_PROVIDERS.has(provider),
|
||||
`Invalid cloud provider: ${String(provider)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: string, field: string) {
|
||||
assert(
|
||||
typeof value === "string" && value.trim() !== "",
|
||||
`Invalid API request event "${field}": expected string.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonNegativeFiniteNumber(value: number, field: string) {
|
||||
assert(
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0,
|
||||
`Invalid API request event "${field}": expected non-negative number.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertPositiveFiniteNumber(value: number, field: string) {
|
||||
assert(
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||
`Invalid API request event "${field}": expected positive number.`,
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export { EventBus } from "./EventBus";
|
||||
export { Scheduler } from "./Scheduler";
|
||||
export {
|
||||
apiRequestEventBus,
|
||||
emitApiRequest,
|
||||
subscribeApiRequests,
|
||||
} from "./requestEventBus";
|
||||
export { assertApiRequestEvent, assertCloudProvider } from "./assertions";
|
||||
export { assert, assertDefined, assertNever } from "@/lib/assertions";
|
||||
export type { ApiRequestEvent, CloudProvider } from "./types";
|
||||
export type {
|
||||
EventBusListenerError,
|
||||
EventBusOptions,
|
||||
EventListener,
|
||||
Unsubscribe,
|
||||
} from "./EventBus";
|
||||
export type { ScheduledJob } from "./Scheduler";
|
||||
export type { TelemetryIngestor } from "./TelemetryIngestor";
|
||||
@@ -1,23 +0,0 @@
|
||||
import { EventBus } from "./EventBus";
|
||||
import { assertApiRequestEvent } from "./assertions";
|
||||
import type { ApiRequestEvent } from "./types";
|
||||
|
||||
export const apiRequestEventBus = new EventBus<ApiRequestEvent>({
|
||||
onListenerError: ({ error, event }) => {
|
||||
console.warn("[Telemetry] API request listener failed", {
|
||||
error,
|
||||
event,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function subscribeApiRequests(
|
||||
listener: (event: ApiRequestEvent) => void,
|
||||
) {
|
||||
return apiRequestEventBus.subscribe(listener);
|
||||
}
|
||||
|
||||
export function emitApiRequest(event: ApiRequestEvent) {
|
||||
assertApiRequestEvent(event);
|
||||
apiRequestEventBus.publish(event);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export type CloudProvider = "aws" | "azure" | "gcp" | "system";
|
||||
|
||||
export interface ApiRequestEvent {
|
||||
provider: CloudProvider;
|
||||
service: string;
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -102,6 +102,9 @@ export interface ChildItem {
|
||||
export interface CollectionPage<T> {
|
||||
items: T[]
|
||||
nextCursor: string | null
|
||||
count?: number
|
||||
scannedCount?: number
|
||||
consumedCapacity?: number
|
||||
}
|
||||
|
||||
export interface SqlCredentials {
|
||||
@@ -159,12 +162,17 @@ export interface LogsInsightsQueryResult {
|
||||
rows: Array<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface NoSqlItem {
|
||||
id: string
|
||||
key: Record<string, unknown>
|
||||
document: Record<string, unknown>
|
||||
export interface LogsFilterInput {
|
||||
filterPattern?: string
|
||||
logStreamNames?: string[]
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface SqsMessageAttribute {dataType: string; stringValue?: string; binaryValue?: string}
|
||||
export interface SqsMessage {messageId: string; receiptHandle: string; body: string; attributes: Record<string, string>; messageAttributes: Record<string, SqsMessageAttribute>}
|
||||
export interface SqsSendMessageInput {messageBody: string; messageAttributes?: Record<string, SqsMessageAttribute>; messageGroupId?: string; messageDeduplicationId?: string}
|
||||
|
||||
export interface KubernetesNodegroup {
|
||||
id: string
|
||||
name: string
|
||||
|
||||