diff --git a/src/components/replicator-coverage/ReplicatorCoverage.tsx b/src/components/replicator-coverage/ReplicatorCoverage.tsx index 63cc2d41..efb06940 100644 --- a/src/components/replicator-coverage/ReplicatorCoverage.tsx +++ b/src/components/replicator-coverage/ReplicatorCoverage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState} from 'react'; import data from '@/data/replicator/coverage.json'; import { Table, @@ -8,251 +8,484 @@ import { TableHead, TableCell, } from '@/components/ui/table'; -import { - useReactTable, - getCoreRowModel, - flexRender, -} from '@tanstack/react-table'; -import type { ColumnDef, ColumnSizingState } from '@tanstack/react-table'; -import { useTableColumnSizing } from '@/hooks/useTableColumnSizing'; -import { useState } from 'react'; - -const coverage = Object.values(data); - -const columns: ColumnDef[] = [ - { - accessorKey: 'resource_type', - header: () => 'Resource Type', - cell: ({ row }) => row.original.resource_type, - size: 150, - minSize: 120, - maxSize: 200, - }, - { - accessorKey: 'service', - header: () => 'Service', - cell: ({ row }) => row.original.service, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - accessorKey: 'identifier', - header: () => 'Identifier', - cell: ({ row }) => row.original.single.identifier, - size: 150, - minSize: 120, - maxSize: 200, - }, - { - accessorKey: 'policy_statements', - header: () => 'Required Actions', - cell: ({ row }) => ( - <> - {row.original.single.policy_statements.map((s: string, i: number) => ( -
{s}
- ))} - - ), - size: 300, - minSize: 200, - maxSize: 500, - }, - { - id: 'arn_support', - header: () => 'Arn Support', - cell: () => '✔️', - size: 100, - minSize: 80, - maxSize: 120, - }, -]; -export default function ReplicatorCoverage() { - // Use the reusable hook for column sizing - const { columnSizing, setColumnSizing } = useTableColumnSizing(columns); - - const table = useReactTable({ - data: coverage, - columns, - state: { - columnSizing, - }, - onColumnSizingChange: setColumnSizing, - columnResizeMode: 'onChange', - getCoreRowModel: getCoreRowModel(), - debugTable: false, - }); - - // For testing purposes, we can log the column sizing state - // console.log('Column sizing state:', columnSizing); - - // Add CSS for resizer - const resizerStyle = ` - .resizer { - position: absolute; - right: 0; - top: 0; - height: 100%; - width: 5px; - background: rgba(0, 0, 0, 0.1); - cursor: col-resize; - user-select: none; - touch-action: none; - } - .resizer.isResizing { - background: rgba(0, 0, 0, 0.2); - opacity: 1; - } - @media (hover: hover) { - .resizer { - opacity: 0; - } - *:hover > .resizer { - opacity: 1; - } - } - `; +interface StrategyDetail { + policy_statements: string[]; + identifier: string | null; +} + +interface ResourceTree { + resources: string[]; + extra_policy_statements: string[]; +} + +interface ExtraConfigField { + type: string; + default: string; + description: string; +} + +interface Resource { + resource_type: string; + service: string; + single?: StrategyDetail; + batch?: StrategyDetail; + resource_tree?: ResourceTree; + extra_config?: Record; +} + +const coverage = data as Resource[]; + +type StrategyKind = 'single' | 'batch' | 'tree'; + +const STRATEGY_STYLES: Record< + StrategyKind, + { background: string; color: string; } +> = { + single: {background: '#e1e3eb', color: '#3a3c47'}, + batch: {background: '#afbcfa', color: '#1e40af'}, + tree: {background: '#c8aefd', color: '#3b05a7'}, +}; + +const STRATEGY_DESCRIPTIONS: Record = { + single: 'Replicates one specific resource identified by its ID.', + batch: + 'Replicates multiple resources in one job (e.g. all SSM parameters under a path).', + tree: 'Replicates this resource together with its dependent child resources.', +}; + +const legendStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '0.5rem', + flexWrap: 'wrap', +} + +const legendTitleStyle: React.CSSProperties = { + color: "var(--gray-neutral-400)", + fontFamily: "var(--font-aeonik-fono)", + fontSize: "14px", +} + +const legendItemsStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + gap: '1rem', + paddingLeft: '0.5rem', + flexWrap: 'wrap', + color: "var(--gray-neutral-400)", + fontSize: "14px", +}; + +const legendItemStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', +}; + +const pillStyle: React.CSSProperties = { + padding: '2px 8px', + border: '1px dashed #999CAD', + borderRadius: '4px', + fontFamily: 'var(--font-aeonik-fono)', + fontSize: '12px', + fontWeight: 500, + whiteSpace: 'nowrap', +}; + +const badgeStyle = (kind: StrategyKind): React.CSSProperties => { + const c = STRATEGY_STYLES[kind]; + return { + ...pillStyle, + background: c.background, + color: c.color, + border: `1px solid`, + }; +}; + +const configPillStyle: React.CSSProperties = { + ...pillStyle, + border: '1px dashed #999CAD', + opacity: 0.85, +}; + +const codeChipStyle: React.CSSProperties = { + fontFamily: 'var(--font-aeonik-fono)', + fontSize: '12.5px', +}; + +const cardStyle = (kind: StrategyKind): React.CSSProperties => ({ + border: `1px solid ${STRATEGY_STYLES[kind].background}`, + borderRadius: '6px', + margin: 0, + background: 'var(--sl-color-gray-7)', +}); + +const cardHeaderStyle = (kind: StrategyKind): React.CSSProperties => { + const c = STRATEGY_STYLES[kind]; + return { + background: c.background, + color: c.color, + padding: '6px 12px', + fontWeight: 600, + fontSize: '13px', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }; +}; + +const cardBodyStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '10px 12px', +}; + +const cardDescriptionStyle: React.CSSProperties = { + opacity: 0.75, + fontSize: '12px', +}; + +const cardLabelStyle: React.CSSProperties = { + marginBottom: '3px', + fontWeight: 600, +}; + +const listStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '3px', + margin: 0, + paddingLeft: 0, + listStyle: 'none', +}; + +const emptyStyle: React.CSSProperties = {opacity: 0.6}; + +const rowToggleStyle = (isOpen: boolean): React.CSSProperties => ({ + display: "inline-block", + flexShrink: 0, + opacity: 0.7, + marginRight: '4px', + transition: 'transform 0.15s', + transform: isOpen ? 'rotate(90deg)' : 'none', +}); + +const expandedCellStyle: React.CSSProperties = { + padding: '8px', + border: '1px solid #999CAD', +}; + +const strategiesGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(min(260px, 100%), 1fr))', + gap: '12px', + alignItems: 'stretch', + width: '100%', + minWidth: 0, +}; +const extraConfigStyle: React.CSSProperties = { + marginTop: '12px', + padding: '12px', + border: '1px solid #999CAD', + borderRadius: '6px', + background: 'var(--sl-color-gray-5)', + color: 'var(--sl-color-gray-1)', + textAlign: 'left', +}; + +const extraConfigTitleStyle: React.CSSProperties = { + marginBottom: '8px', + fontWeight: 600, + fontSize: '13px', + textTransform: 'uppercase', + letterSpacing: '0.5px', +}; + +const extraConfigFieldStyle: React.CSSProperties = { + marginBottom: '8px', + fontSize: '13px', +}; + +const extraConfigMetaStyle: React.CSSProperties = {opacity: 0.7}; + +const extraConfigDescriptionStyle: React.CSSProperties = { + marginTop: '4px', + opacity: 0.9, +}; + +const tableStyle: React.CSSProperties = { + borderCollapse: 'collapse', + tableLayout: 'auto', + width: '100%', + display: 'table', +} + +const headerStyle: React.CSSProperties = { + textAlign: 'center', + border: '1px solid #999CAD', + background: 'var(--sl-color-gray-5)', + color: 'var(--sl-color-gray-1)', + fontFamily: 'var(--font-aeonik-fono)', + fontSize: '14px', + fontWeight: 500, + lineHeight: '16px', + letterSpacing: '-0.15px', + padding: '12px 8px', +}; + +const cellStyle: React.CSSProperties = { + textAlign: 'center', + border: '1px solid #999CAD', + padding: '12px 8px', + whiteSpace: 'nowrap', +}; + +const badgesContainerStyle: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + gap: '4px', +}; + +const bodyStyle: React.CSSProperties = { + color: 'var(--sl-color-gray-1)', + fontSize: '14px', + fontWeight: 400, + lineHeight: '16px', +}; + +const buttonStyle: React.CSSProperties = { + fontSize: '14px', + fontWeight: 500, +}; + +function StrategyBadge({kind, label}: { kind: StrategyKind; label: string }) { + return {label}; +} + +function CodeChip({children}: { children: React.ReactNode }) { + return {children}; +} + +function StrategyCard({ + kind, + label, + identifier, + identifiers, + identifierLabel, + actions, + actionsLabel, + emptyActionsLabel, + }: { + kind: StrategyKind; + label: string; + identifier?: string | null; + identifiers?: string[] | null; + identifierLabel?: string; + actions: string[]; + actionsLabel?: string; + emptyActionsLabel?: string; +}) { return ( -
- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const meta = header.column.columnDef.meta as - | { className?: string } - | undefined; - - const getColumnWidth = (columnId: string) => { - switch (columnId) { - case 'resource_type': - return '20%'; - case 'service': - return '15%'; - case 'identifier': - return '20%'; - case 'policy_statements': - return '35%'; - case 'arn_support': - return '10%'; - default: - return 'auto'; - } - }; - - return ( - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {header.column.getCanResize() && ( -
- )} -
- ); - })} -
- ))} -
- - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const meta = cell.column.columnDef.meta as - | { className?: string } - | undefined; - - const getColumnWidth = (columnId: string) => { - switch (columnId) { - case 'resource_type': - return '20%'; - case 'service': - return '15%'; - case 'identifier': - return '20%'; - case 'policy_statements': - return '35%'; - case 'arn_support': - return '10%'; - default: - return 'auto'; - } - }; - - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ); - })} - - ))} - -
+
+
{label}
+
+
{STRATEGY_DESCRIPTIONS[kind]}
+
+
{identifierLabel ?? 'Identifier'}
+ {identifier ? ( + {identifier} + ) : identifiers ? ( +
    + {identifiers.map((iden) => ( +
  • + {iden} +
  • + ))} +
+ ) : ( + None required + )} +
+
+
+ {actionsLabel ?? 'Required IAM Actions'} +
+ {actions.length === 0 ? ( + {emptyActionsLabel ?? 'None'} + ) : ( +
    + {actions.map((a) => ( +
  • + {a} +
  • + ))} +
+ )} +
); } +function ResourceRow({isOpen, row, toggle}: { isOpen: boolean, row: Resource, toggle: () => void }) { + return ( + + + + {row.resource_type} + + {row.service} + +
+ {row.single && } + {row.batch && } + {row.resource_tree && } + {row.extra_config && (+ config)} +
+
+
+ ) +} + +function ExpandedRow({row}: { row: Resource }) { + return ( + + +
+ {row.single && ( + + )} + {row.batch && ( + + )} + {row.resource_tree && ( + 0 + ? row.resource_tree!.resources + : null + } + actions={ + row.resource_tree!.extra_policy_statements + } + actionsLabel="Extra IAM Actions (in addition to Single/Batch)" + emptyActionsLabel="No extra actions required" + /> + )} +
+ {row.extra_config && ( +
+
Extra Configuration
+ {Object.entries(row.extra_config).map( + ([name, field]) => ( +
+
+ {name} + + ( {field.type}{field.default && ( + , default: {field.default}) + ) || ' )'} + +
+
{field.description}
+
+ ) + )} +
+ )} +
+
+ ) +} + +export default function ReplicatorCoverage() { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const expandAll = () => + setExpanded(new Set(coverage.map((r) => r.resource_type))); + const collapseAll = () => setExpanded(new Set()); + + const allOpen = expanded.size === coverage.length; + + return ( + + +
+ Legend: +
+ + + One resource + + + + Many similar resources in one job + + + + Tree of related resources + +
+
+ + + + Resource Type + Service + Replication Strategies + + + + {coverage.map((row) => { + const key = row.resource_type; + const isOpen = expanded.has(key); + return ( + + toggle(key)}/> + {isOpen && } + + ); + })} + +
+
+ ); +} + // Testing instructions: // 1. Verify that the table expands to 100% width of its container // 2. Check that columns maintain their widths during pagination diff --git a/src/content/docs/aws/tooling/aws-replicator.mdx b/src/content/docs/aws/tooling/aws-replicator.mdx index ab685081..c0179d94 100644 --- a/src/content/docs/aws/tooling/aws-replicator.mdx +++ b/src/content/docs/aws/tooling/aws-replicator.mdx @@ -87,6 +87,8 @@ export AWS_DEFAULT_REGION=... localstack replicator start \ --resource-type \ --resource-identifier \ + [--replication-type ] \ + [--explore-strategy ] \ [--target-account-id ] \ [--target-region-name ] ``` @@ -124,6 +126,7 @@ The command triggers a replication job. The output will look similar to: "state": "TESTING_CONNECTION", "error_message": null, "type": "SINGLE_RESOURCE", + "explore_strategy": "SIMPLE", "replication_config": { "resource_type": "AWS::SSM::PARAMETER", "identifier": "myParameter" @@ -153,6 +156,7 @@ Use the following payload: ```json showLineNumbers { "replication_type": "SINGLE_RESOURCE", + "explore_strategy": "SIMPLE|TREE", // optional "replication_job_config": { "resource_type": "", "identifier": "" @@ -175,6 +179,7 @@ For example: ```json { "replication_type": "BATCH", + "explore_strategy": "SIMPLE", "replication_job_config": { "resource_type": "AWS::SSM::Parameter", "identifier": "/dev/" @@ -214,6 +219,7 @@ This command returns the job status in JSON format. For example, here's a single "state": "SUCCEEDED", "error_message": null, "type": "SINGLE_RESOURCE", + "explore_strategy": "SIMPLE", "replication_config": { "resource_type": "AWS::SSM::PARAMETER", "identifier": "myParameter" @@ -229,6 +235,7 @@ For a batch replication job, the output may include additional fields indicating "state": "SUCCEEDED", "error_message": null, "type": "BATCH", + "explore_strategy": "SIMPLE", "replication_config": { "resource_type": "AWS::SSM::Parameter", "identifier": "/dev/" @@ -253,6 +260,34 @@ To check the status of a replication job via the HTTP API, send a `GET` request If the replication state is `SUCCEEDED` but the resource is missing, check in account `000000000000`. ::: +## Configure the replication + +### Replication types + +The replication can be set with the `--replication-type` flag using the CLI or by the `replication_type` field in the request body. +If not specified, the default is `SINGLE_RESOURCE`. +Replication jobs can be triggered in two modes: + +- `SINGLE_RESOURCE`: +This mode replicates a single resource. +The resource is identified by its ARN or type and identifier. + +- `BATCH`: +This mode replicates all resources of a given type that match. +The resource identifier is used as a filter. +See the [Supported Resources](#supported-resources) table below for the filter format supported by each resource. + +### Explore strategies + +The replication strategy can be configured using the `--explore-strategy` flag or by the `explore_strategy` field in the request body. +If not specified, the default is `SIMPLE`. +Replication jobs can be triggered in two modes, both of which can be used either with the `SINGLE_RESOURCE` or `BATCH` replication type: + +- `SIMPLE`: This mode only replicates the resources explicitly specified in the request. + +- `TREE`: This mode replicates all resources specified in the resource tree for the resource specified in the request. +See the [Supported Resources](#supported-resources) table below for details on the resource tree. + ## Quickstart This quickstart example creates an SSM parameter in AWS and replicates it to LocalStack. @@ -320,8 +355,8 @@ This example uses an SSO profile named `ls-sandbox` for AWS configuration. LOCALSTACK_AUTH_TOKEN= \ AWS_PROFILE=ls-sandbox \ localstack replicator start \ - --resource-type AWS::SSM::Parameter \ - --resource-identifier \ + --resource-type AWS::SSM::Parameter \ + --resource-identifier ``` @@ -333,6 +368,7 @@ Configured credentials from the AWS CLI "state": "TESTING_CONNECTION", "error_message": null, "type": "SINGLE_RESOURCE", + "explore_strategy": "SIMPLE", "replication_config": { "resource_type": "AWS::SSM::PARAMETER", "identifier": "myparam" @@ -353,6 +389,7 @@ LOCALSTACK_AUTH_TOKEN= \ "state": "SUCCEEDED", "error_message": null, "type": "SINGLE_RESOURCE", + "explore_strategy": "SIMPLE", "replication_config": { "resource_arn": "arn:aws:ssm:eu-central-1::parameter/myparam" } @@ -390,12 +427,11 @@ Use the `--target-account-id` flag to specify a different account. ## Supported Resources -The project is still in preview state and we welcome feedback and bug reports. -We have opened a [github issue where you can request and upvote](https://github.com/localstack/localstack/issues/12439) to help us prioritize our efforts and support the resources with the most impact. -For any other requests or reports, please open a [new github issue](https://github.com/localstack/localstack/issues/new/choose). +The project is still in preview state and its API is subject to change. :::tip To ensure support for all resources, use the latest LocalStack Docker image. ::: - +{/* Forcing hydration since Astro doesn't send the javascript required for the coverage component interactivity. */} +