Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ce6ea33
Update documentation to reflect current codebase state
claude Feb 9, 2026
5f38659
Add ECS container visualization with managed-by source detection
claude Feb 10, 2026
57c2e9a
Fix test mocks and Prettier formatting for ECS additions
claude Feb 10, 2026
9cd794a
Merge pull request #86 from strick-j/claude/add-ecs-visualization-VK6jq
strick-j Feb 10, 2026
ea206a8
Add ECS Containers assertions to sidebar and app tests
claude Feb 10, 2026
e9d91a2
Merge pull request #87 from strick-j/claude/add-ecs-visualization-VK6jq
strick-j Feb 10, 2026
e9c0e19
Fix ECS containers showing as unmanaged by adding Terraform state sync
claude Feb 10, 2026
2ba1fdf
Merge pull request #88 from strick-j/claude/add-ecs-visualization-VK6jq
strick-j Feb 10, 2026
d4e46bd
Add ECS overview to dashboard with summary card and recent containers
claude Feb 10, 2026
dc43f8f
Merge pull request #89 from strick-j/claude/add-ecs-visualization-VK6jq
strick-j Feb 10, 2026
eabfc57
Fix topology edges overlapping subnet containers
claude Feb 10, 2026
fc6aa37
Merge pull request #90 from strick-j/claude/fix-topology-overlaps-qvHq5
strick-j Feb 10, 2026
5a75574
Bump version to 1.4.0 and update documentation
claude Feb 10, 2026
41dea61
Merge pull request #91 from strick-j/claude/update-readme-docs-2czUt
strick-j Feb 10, 2026
e23c11f
Fix IGW-to-subnet edge overlap and reduce excessive row gap
claude Feb 10, 2026
e51e3f0
Merge pull request #92 from strick-j/claude/fix-topology-overlaps-qvHq5
strick-j Feb 10, 2026
45f6e46
Restructure dashboard layout: semantic 3+2 card grouping
claude Feb 10, 2026
a017272
Smooth edge corners and raise edges above containers
claude Feb 10, 2026
2be91d6
Merge pull request #93 from strick-j/claude/add-ecs-visualization-VK6jq
strick-j Feb 10, 2026
630f75e
Merge pull request #94 from strick-j/claude/fix-topology-overlaps-qvHq5
strick-j Feb 10, 2026
5e13b11
Increase edge borderRadius to 100 for flowing curves
claude Feb 10, 2026
7ad957b
Merge pull request #96 from strick-j/claude/fix-topology-overlaps-qvHq5
strick-j Feb 10, 2026
27d3f6f
Add ECS IAM permissions and fix architecture diagram
claude Feb 10, 2026
48e9e1a
Fix architecture diagram alignment in README.md and CLAUDE.md
claude Feb 10, 2026
61549f2
Update ECS IAM permissions in docs to match expanded policy
claude Feb 10, 2026
cf6015b
Merge pull request #95 from strick-j/claude/update-readme-docs-2czUt
strick-j Feb 10, 2026
85dd8c4
Fix undefined variable warnings in frontend Dockerfile LABEL
claude Feb 10, 2026
7198b01
Switch edges from smoothstep to bezier for smooth flowing curves
claude Feb 10, 2026
e2c046f
Merge pull request #97 from strick-j/claude/fix-ecs-variable-declarat…
strick-j Feb 10, 2026
a566705
Merge pull request #98 from strick-j/claude/fix-topology-overlaps-qvHq5
strick-j Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 174 additions & 46 deletions CLAUDE.md

Large diffs are not rendered by default.

234 changes: 180 additions & 54 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.0
1.4.0
65 changes: 65 additions & 0 deletions backend/app/api/routes/ecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
ECSClusterSummary,
ECSContainerDetail,
ECSContainerResponse,
ECSSummaryResponse,
ListResponse,
ManagedBy,
MetaInfo,
)

Expand Down Expand Up @@ -88,6 +90,12 @@ async def list_ecs_clusters(
cluster_containers[0].region.name if cluster_containers[0].region else None
)

# Determine cluster-level managed_by
if any_tf:
cluster_managed_by = ManagedBy.TERRAFORM
else:
cluster_managed_by = ManagedBy.UNMANAGED

container_responses = [_container_to_response(c) for c in cluster_containers]

cluster_summaries.append(
Expand All @@ -98,6 +106,7 @@ async def list_ecs_clusters(
stopped_tasks=stopped,
pending_tasks=pending,
tf_managed=any_tf,
managed_by=cluster_managed_by,
region_name=region_name,
containers=container_responses,
)
Expand Down Expand Up @@ -219,6 +228,46 @@ async def get_ecs_container(
return _container_to_detail(container)


@router.get("/ecs/summary", response_model=ECSSummaryResponse)
async def get_ecs_summary(
db: AsyncSession = Depends(get_db),
):
"""
Get summary counts for ECS resources.

Returns cluster count, running/stopped/pending task counts.
"""
query = (
select(ECSContainer)
.where(ECSContainer.is_deleted == False)
)
result = await db.execute(query)
containers = result.scalars().all()

cluster_names = set()
running = 0
stopped = 0
pending = 0

for c in containers:
cluster_names.add(c.cluster_name)
if c.status == "RUNNING":
running += 1
elif c.status == "STOPPED":
stopped += 1
elif c.status in ("PENDING", "PROVISIONING", "ACTIVATING"):
pending += 1

return ECSSummaryResponse(
clusters=len(cluster_names),
services=0,
running_tasks=running,
stopped_tasks=stopped,
pending_tasks=pending,
total_tasks=len(containers),
)


def _get_statuses_for_display(status: DisplayStatus) -> list[str]:
"""Map display status to ECS task statuses."""
mapping = {
Expand All @@ -238,6 +287,18 @@ def _get_statuses_for_display(status: DisplayStatus) -> list[str]:
return mapping.get(status, [])


def _resolve_managed_by(container: ECSContainer) -> ManagedBy:
"""Resolve the management source for a container."""
if container.tf_managed:
return ManagedBy.TERRAFORM
managed = getattr(container, "managed_by", "unmanaged")
if managed == "github_actions":
return ManagedBy.GITHUB_ACTIONS
if managed == "terraform":
return ManagedBy.TERRAFORM
return ManagedBy.UNMANAGED


def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
"""Convert ECSContainer model to response schema."""
tags = None
Expand All @@ -260,6 +321,7 @@ def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
task_definition_arn=container.task_definition_arn,
desired_status=container.desired_status,
image=container.image,
image_tag=getattr(container, "image_tag", None),
container_port=container.container_port,
private_ip=container.private_ip,
subnet_id=container.subnet_id,
Expand All @@ -270,6 +332,7 @@ def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
tf_managed=container.tf_managed,
tf_state_source=container.tf_state_source,
tf_resource_address=container.tf_resource_address,
managed_by=_resolve_managed_by(container),
region_name=container.region.name if container.region else None,
is_deleted=container.is_deleted,
deleted_at=container.deleted_at,
Expand Down Expand Up @@ -299,6 +362,7 @@ def _container_to_detail(container: ECSContainer) -> ECSContainerDetail:
task_definition_arn=container.task_definition_arn,
desired_status=container.desired_status,
image=container.image,
image_tag=getattr(container, "image_tag", None),
container_port=container.container_port,
private_ip=container.private_ip,
subnet_id=container.subnet_id,
Expand All @@ -309,6 +373,7 @@ def _container_to_detail(container: ECSContainer) -> ECSContainerDetail:
tf_managed=container.tf_managed,
tf_state_source=container.tf_state_source,
tf_resource_address=container.tf_resource_address,
managed_by=_resolve_managed_by(container),
region_name=container.region.name if container.region else None,
is_deleted=container.is_deleted,
deleted_at=container.deleted_at,
Expand Down
31 changes: 31 additions & 0 deletions backend/app/api/routes/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,13 +807,15 @@ async def _sync_ecs_containers(
existing.cpu = container_data.get("cpu", 0)
existing.memory = container_data.get("memory", 0)
existing.image = container_data.get("image")
existing.image_tag = container_data.get("image_tag")
existing.container_port = container_data.get("container_port")
existing.private_ip = container_data.get("private_ip")
existing.subnet_id = container_data.get("subnet_id")
existing.vpc_id = container_data.get("vpc_id")
existing.availability_zone = container_data.get("availability_zone")
existing.started_at = container_data.get("started_at")
existing.tags = json.dumps(container_data.get("tags", {}))
existing.managed_by = container_data.get("managed_by", "unmanaged")
existing.is_deleted = False
existing.deleted_at = None
else:
Expand All @@ -829,13 +831,15 @@ async def _sync_ecs_containers(
cpu=container_data.get("cpu", 0),
memory=container_data.get("memory", 0),
image=container_data.get("image"),
image_tag=container_data.get("image_tag"),
container_port=container_data.get("container_port"),
private_ip=container_data.get("private_ip"),
subnet_id=container_data.get("subnet_id"),
vpc_id=container_data.get("vpc_id"),
availability_zone=container_data.get("availability_zone"),
started_at=container_data.get("started_at"),
tags=json.dumps(container_data.get("tags", {})),
managed_by=container_data.get("managed_by", "unmanaged"),
is_deleted=False,
)
db.add(new_container)
Expand Down Expand Up @@ -968,6 +972,33 @@ async def _sync_terraform_state(db: AsyncSession) -> int:
eip.tf_resource_address = tf_resource.resource_address
count += 1

# Update ECS containers - mark all containers in a Terraform-managed
# cluster as terraform-managed
tf_cluster_names = set()
tf_cluster_lookup = {}
for tf_resource in tf_resources.get("ecs_cluster", []):
tf_cluster_names.add(tf_resource.resource_id)
tf_cluster_lookup[tf_resource.resource_id] = tf_resource

if tf_cluster_names:
result = await db.execute(
select(ECSContainer).where(
ECSContainer.cluster_name.in_(tf_cluster_names),
ECSContainer.is_deleted == False,
)
)
for container in result.scalars():
container.tf_managed = True
tf_res = tf_cluster_lookup.get(container.cluster_name)
if tf_res:
container.tf_state_source = tf_res.state_source
container.tf_resource_address = tf_res.resource_address
# Containers deployed by CI/CD keep github_actions;
# others in a TF cluster get terraform
if container.managed_by != "github_actions":
container.managed_by = "terraform"
count += 1

await db.flush()
return count

Expand Down
15 changes: 12 additions & 3 deletions backend/app/api/routes/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,11 @@ async def get_topology(
)
)

# Get ECS containers in this subnet
# Get ECS containers in this subnet (include both TF-managed
# and CI/CD-deployed containers for full visibility)
ecs_result = await db.execute(
select(ECSContainer).where(
ECSContainer.subnet_id == subnet.subnet_id,
ECSContainer.tf_managed == True,
ECSContainer.is_deleted == False,
)
)
Expand All @@ -293,6 +293,13 @@ async def get_topology(
topology_ecs = []
for ecs_container in ecs_containers:
total_ecs_containers += 1
# Resolve managed_by: TF flag takes precedence,
# then fall back to the stored managed_by value
managed_by_value = "unmanaged"
if ecs_container.tf_managed:
managed_by_value = "terraform"
elif getattr(ecs_container, "managed_by", "") == "github_actions":
managed_by_value = "github_actions"
topology_ecs.append(
TopologyECSContainer(
id=ecs_container.task_id,
Expand All @@ -304,10 +311,12 @@ async def get_topology(
cpu=ecs_container.cpu,
memory=ecs_container.memory,
image=ecs_container.image,
image_tag=getattr(ecs_container, "image_tag", None),
container_port=ecs_container.container_port,
private_ip=ecs_container.private_ip,
tf_managed=True,
tf_managed=ecs_container.tf_managed,
tf_resource_address=ecs_container.tf_resource_address,
managed_by=managed_by_value,
)
)

Expand Down
44 changes: 44 additions & 0 deletions backend/app/collectors/ecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
ECS Container collector.

Collects ECS task and container data from AWS using the boto3 SDK.
Detects management source (Terraform, GitHub Actions, unmanaged) based on
container image tags and resource tags.
"""

import logging
import re
from typing import Any, Dict, List, Optional

from botocore.exceptions import ClientError
Expand All @@ -13,6 +16,13 @@

logger = logging.getLogger(__name__)

# Patterns that indicate CI/CD deployment (GitHub Actions)
GITHUB_ACTIONS_PATTERNS = [
re.compile(r"^[a-f0-9]{40}$"), # Full git SHA
re.compile(r"^[a-f0-9]{7,8}$"), # Short git SHA
re.compile(r"^v\d+\.\d+\.\d+"), # Semver (v1.2.3)
]


class ECSCollector(BaseCollector):
"""Collector for ECS containers (tasks running in clusters)."""
Expand Down Expand Up @@ -102,11 +112,15 @@ def _parse_task(
container_name = None
container_port = None
image = None
image_tag = None

if containers:
first_container = containers[0]
container_name = first_container.get("name")
image = first_container.get("image")
# Extract image tag
if image and ":" in image:
image_tag = image.split(":")[-1]
network_bindings = first_container.get("networkBindings", [])
if network_bindings:
container_port = network_bindings[0].get("containerPort")
Expand All @@ -133,6 +147,9 @@ def _parse_task(
cpu = task.get("cpu")
memory = task.get("memory")

# Detect management source from image tag and tags
managed_by = self._detect_managed_by(image_tag, tags_dict)

return {
"task_id": task_id,
"task_arn": task_arn,
Expand All @@ -146,12 +163,14 @@ def _parse_task(
"memory": int(memory) if memory else 0,
"container_port": container_port,
"image": image,
"image_tag": image_tag,
"subnet_id": subnet_id,
"private_ip": private_ip,
"vpc_id": None, # Resolved from subnet if needed
"availability_zone": task.get("availabilityZone"),
"started_at": task.get("startedAt"),
"tags": tags_dict,
"managed_by": managed_by,
"region": self.region,
}

Expand All @@ -162,6 +181,31 @@ def _parse_task(
logger.warning(f"Error parsing ECS task: {e}")
return None

@staticmethod
def _detect_managed_by(
image_tag: Optional[str], tags: Dict[str, str]
) -> str:
"""
Detect who manages this task based on image tag patterns and resource tags.

Returns:
"github_actions" if deployed by CI/CD, "unmanaged" otherwise.
Terraform management is resolved later during aggregation.
"""
# Check image tag against CI/CD patterns
if image_tag:
for pattern in GITHUB_ACTIONS_PATTERNS:
if pattern.match(image_tag):
return "github_actions"

# Check resource tags for deployment markers
if tags.get("deployed-by") == "github-actions":
return "github_actions"
if tags.get("managed-by") == "github-actions":
return "github_actions"

return "unmanaged"

async def collect_task(
self, cluster_name: str, task_id: str
) -> Optional[Dict[str, Any]]:
Expand Down
6 changes: 6 additions & 0 deletions backend/app/models/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ class ECSContainer(Base):

# Container details
image: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
image_tag: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
container_port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

# Network
Expand All @@ -621,6 +622,11 @@ class ECSContainer(Base):
String(500), nullable=True
)

# Management source tracking (terraform, github_actions, unmanaged)
managed_by: Mapped[str] = mapped_column(
String(30), nullable=False, default="unmanaged"
)

# Deletion tracking
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
Expand Down
9 changes: 9 additions & 0 deletions backend/app/parsers/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class TerraformStateParser:
"aws_internet_gateway": "igw",
"aws_nat_gateway": "nat_gateway",
"aws_eip": "eip",
"aws_ecs_cluster": "ecs_cluster",
"aws_ecs_service": "ecs_service",
"aws_ecs_task_definition": "ecs_task_definition",
}

def __init__(self, bucket: Optional[str] = None):
Expand Down Expand Up @@ -271,6 +274,9 @@ def _extract_resource_id(
"aws_internet_gateway": "id", # IGW ID
"aws_nat_gateway": "id", # NAT Gateway ID
"aws_eip": "id", # Elastic IP allocation ID
"aws_ecs_cluster": "name", # ECS cluster name
"aws_ecs_service": "name", # ECS service name
"aws_ecs_task_definition": "family", # Task definition family
}

id_field = id_mappings.get(resource_type, "id")
Expand Down Expand Up @@ -522,6 +528,9 @@ async def aggregate_all(self) -> Dict[str, List[TerraformResource]]:
"igw": [],
"nat_gateway": [],
"eip": [],
"ecs_cluster": [],
"ecs_service": [],
"ecs_task_definition": [],
}

bucket_entries = await self._get_all_bucket_configs()
Expand Down
Loading
Loading