From 5b616e67c1d802de146450f62a633575da6c258b Mon Sep 17 00:00:00 2001 From: Roberto Catalano Date: Tue, 25 Aug 2026 23:27:39 +0200 Subject: [PATCH 1/2] feat: live architecture view in the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Architecture tab that draws the deployed platform from the same status.json the Monitor tab reads, and retires the Approach tab. The view is plain DOM + inline SVG with no dependencies, because the dashboard has to keep working over `python3 -m http.server` with no build step. Layout is deterministic (columns are layers, rows ordered by one barycentre pass), so the same status.json always draws the same picture and a narrated demo stays true. Three deliberate honesty constraints: - `state` is READ from monitor.py, never recomputed. monitor.py owns the one CloudFormation-status classifier; a second one in the UI was the bug the previous change removed. - A dashboard sees exactly ONE account. monitor.py now emits a `deployment` block (strategy, role, polled/platform/workload accounts) and the far side of a federated deployment is drawn "not observed" rather than given a guessed status. It is never counted as deployed or failed. - Observability is a per-card badge, not edges. TOPOLOGY keeps the real dependency (it mirrors app.py) and the filter lives at draw time, so the map and the picture disagree in one obvious place. Five lines converging on one card was the worst source of crossings. monitor.py also stops calling get_caller_identity() twice per poll and degrades to the full stack list when the contract cannot resolve a footprint, instead of blinding the dashboard. The Approach tab moves to dashboard/approach-tab.html — parked outside public/ so the static server cannot reach it, with instructions for putting it back. Monitor becomes the default tab. index.html drops 371 lines. Verified: - 33 headless assertions (DOM shim, real status.json + a federated fixture): every edge is a cubic bezier with no NaN, 5 edges survive the in-scope topology filter, out-of-scope stacks land in the tray and carry no edges, badges appear only on deployed stacks shipping to a deployed observability stack, the VPC box stays hidden until networking is deployed, a dragged card survives the next poll while an untouched one re-snaps, and the federated fixture yields two account boxes with one trust edge. - Fixed while testing: buildModel guarded status.json by truthiness only, so a malformed file with a string `stacks` passed the guard and Object.keys() walked its characters — one card per letter. The try/catch above could not help because nothing throws. Shape-checked now; mutating the guard back out makes the probe fail. - Live against the test rig (066523631817): reads 6 deployed + 4 out of scope, matching `deploy.sh verify`. - 155 tests pass; ruff clean on monitor.py. --- dashboard/approach-tab.html | 306 ++++++++++ dashboard/monitor.py | 61 +- dashboard/public/graph.css | 353 +++++++++++ dashboard/public/graph.js | 1110 +++++++++++++++++++++++++++++++++++ dashboard/public/index.html | 371 +++--------- tests/test_dashboard.py | 69 +++ 6 files changed, 1961 insertions(+), 309 deletions(-) create mode 100644 dashboard/approach-tab.html create mode 100644 dashboard/public/graph.css create mode 100644 dashboard/public/graph.js diff --git a/dashboard/approach-tab.html b/dashboard/approach-tab.html new file mode 100644 index 0000000..8be5afb --- /dev/null +++ b/dashboard/approach-tab.html @@ -0,0 +1,306 @@ + + + + +
+ + +
+
☁️ Amazon Bedrock AgentCore
+

AgentCore Platform &
Security Accelerator

+

2-Day Hands-On Workshop — Go from architecture design to production-deployed AI agents with enterprise-grade security, identity, and observability.

+
+ + +
+ +

The Problem

+

Enterprises are stuck between prototype and production. AI agent PoCs sit idle for 3–6 months while teams navigate scattered security reviews, identity integration, and infrastructure decisions.

+
+
+
+

3–6 Month Delays

+

Security reviews, compliance checks, and infrastructure decisions create months of back-and-forth before a single agent reaches production.

+
+
+
🔀
+

Scattered Reviews

+

Identity, networking, encryption, and observability are handled by different teams with no unified blueprint — leading to inconsistent implementations.

+
+
+
🚧
+

Prototype Graveyard

+

Promising agent PoCs never make it past the demo stage. Without a clear path to production, innovation stalls and momentum is lost.

+
+
+
+ + +
+ +

The Solution

+

A structured 2-day sprint with modular, progressive deployment. Every stack builds on the last, and every decision is made with production in mind from day one.

+
+
+
🏗️
+

Modular Architecture

+

10 independent CDK stacks that compose into a complete platform. Deploy what you need, skip what you don't.

+
+
+
🔐
+

Security-First

+

KMS encryption, VPC endpoints, SCPs, and enterprise identity integration baked in from the foundation layer — not bolted on later.

+
+
+
+

2 Days to Production

+

From architecture whiteboard to deployed, observable, secure AI agents — with all the IaC, runbooks, and documentation to maintain it.

+
+
+
+ + +
+ +

Progressive Deployment Layers

+

Each layer builds on the one below. Deploy bottom-up for a complete platform, or pick the layers you need.

+
+
+
5
+

Observability

Tracing, monitoring, and alerting pipeline

+
observability
+
+
+
4
+

Runtime

Agent execution, orchestration, and A2A connectivity

+
runtime-orchestratorruntime-code-agentruntime-research-agent
+
+
+
3
+

Service

API gateway and managed memory for agents

+
gatewaymemory
+
+
+
2
+

Identity

Authentication, authorization, and enterprise SSO

+
authidentity
+
+
+
1
+

Foundation

Networking, security controls, and encryption

+
networkingsecurity
+
+
+
+ + +
+ +

Workshop Flow

+

Two focused days that take you from design to deployment.

+
+
+

Day 1 — Foundation & Design

+

Architecture decisions, identity integration, core infrastructure

+
1

Platform Architecture

Multi-account strategy, networking, security controls, KMS encryption

+
2

Identity & Auth

Cognito setup, enterprise IdP integration (Entra ID / Okta), OAuth flows

+
3

Gateway & Memory

MCP gateway with Lambda/OpenAPI targets, managed memory configuration

+
4

Design Review

Architecture review, threat modeling, deployment planning for Day 2

+
+
+

Day 2 — Build & Validate

+

Agent deployment, A2A connectivity, observability, hardening

+
5

Runtime Deployment

Deploy orchestrator agent, code agent, and research agent to AgentCore Runtime

+
6

Agent-to-Agent

Configure A2A connectivity, multi-agent orchestration patterns

+
7

Observability

OpenTelemetry tracing, CloudWatch dashboards, alerting pipeline

+
8

Security Hardening

SCPs, VPC endpoints, PrivateLink, final security review and validation

+
+
+
+ + +
+ +

Customer Profiles

+

Every workshop is tailored. Select the profile that matches your starting point — we'll customize the module selection accordingly.

+
+
+
🌱
+

Greenfield

+

Starting fresh with AI agents. Need the full platform from scratch.

+
networkingsecurityauthidentitygatewaymemoryruntimeobservability
+
+
+
🔄
+

Migration

+

Moving existing agents from Bedrock Agents or custom infra to AgentCore.

+
identitygatewaymemoryruntimeobservability
+
+
+
🤖
+

Multi-Agent

+

Building complex multi-agent systems with A2A orchestration.

+
authidentitygatewayruntimeobservability
+
+
+
🏢
+

Platform Team

+

Building a shared agent platform for multiple development teams.

+
networkingsecurityauthidentitygatewaymemoryobservability
+
+
+
🛡️
+

Security-Focused

+

Hardening an existing deployment with enterprise security controls.

+
networkingsecurityauthidentityobservability
+
+
+
+ + +
+ +

Team Workstreams

+

Three teams work in parallel, each owning a set of stacks. Dependencies are managed through SSM parameters published by each stack.

+
+
+

🏗️ Platform Team

+
Infrastructure & Foundation
+
+
networking — VPC, subnets, endpoints
+
security — KMS keys, SCPs, guardrails
+
auth — Cognito user pool, app clients
+
identity — AgentCore identity, IdP integration
+
observability — Tracing, dashboards, alerts
+
+
+
+

🤖 Agent Team

+
Agent Development & Deployment
+
+
gateway — MCP gateway, tool targets
+
memory — Semantic & event memory
+
runtime-orchestrator — Main orchestrator agent
+
runtime-code-agent — Code execution agent
+
runtime-research-agent — Research agent
+
+
+
+

🛡️ Security Team

+
Security Review & Hardening
+
+
Review all stack outputs for compliance
+
Validate KMS encryption configuration
+
Verify VPC endpoint policies
+
Audit IAM roles and permissions
+
Threat model the agent architecture
+
+
+
+
+ + +
+ +

What You Take Home

+

Everything you need to operate and evolve your agent platform after the workshop.

+
+
📦

Production IaC

10 CDK stacks — fully parameterized, tested, and ready for your CI/CD pipeline.

+
🤖

Deployed Agents

Working orchestrator, code, and research agents on AgentCore Runtime with A2A connectivity.

+
🔐

Security Baseline

KMS encryption, VPC endpoints, SCPs, and enterprise identity integration — all configured.

+
📊

Observability Pipeline

OpenTelemetry tracing, CloudWatch dashboards, and alerting — ready for production traffic.

+
📋

Architecture Decision Records

Documented decisions, trade-offs, and rationale for every architectural choice made.

+
🗺️

Expansion Roadmap

Prioritized backlog for adding agents, integrations, and capabilities post-workshop.

+
+
+ +
diff --git a/dashboard/monitor.py b/dashboard/monitor.py index 246714a..d367fef 100644 --- a/dashboard/monitor.py +++ b/dashboard/monitor.py @@ -124,25 +124,53 @@ def classify(status: str) -> str: return "not-deployed" -def expected_suffixes() -> list[str]: - """Stack suffixes this configuration promises, per the contract.""" +def load_config() -> PlatformConfig: path = os.environ.get("PLATFORM_CONFIG", "platform.yaml") try: - config = ( - load_platform_config(path) if os.path.exists(path) else PlatformConfig() - ) + return load_platform_config(path) if os.path.exists(path) else PlatformConfig() except Exception as exc: # noqa: BLE001 — a bad manifest must not blind the dashboard - logger.warning("platform.yaml not usable (%s); showing every known stack", exc) - return list(STACK_META) - account = "" + logger.warning("platform.yaml not usable (%s); assuming defaults", exc) + return PlatformConfig() + + +def caller_account() -> str: try: - account = boto3.client("sts", region_name=REGION).get_caller_identity()[ - "Account" - ] - except Exception as exc: # noqa: BLE001 — federation role only needs it when federated + return boto3.client("sts", region_name=REGION).get_caller_identity()["Account"] + except Exception as exc: # noqa: BLE001 — only federation needs the account logger.debug("no caller identity yet: %s", exc) + return "" + + +def expected_suffixes(config: PlatformConfig, account: str) -> list[str]: + """Stack suffixes this configuration promises, per the contract.""" prefix = f"{config.project}-{config.environment}-" - return [s.removeprefix(prefix) for s in config.expected_stacks(account)] + try: + return [s.removeprefix(prefix) for s in config.expected_stacks(account)] + except Exception as exc: # noqa: BLE001 — e.g. a federated file from an unlisted account + logger.warning("contract could not resolve a footprint (%s)", exc) + return list(STACK_META) + + +def deployment_view(config: PlatformConfig, account: str) -> dict: + """Which accounts this deployment spans, and which one is being polled. + + A dashboard only ever sees ONE account. In a federated deployment the + other side's stacks are not observable from here, so the architecture + graph draws them as "not observed" rather than inventing a status. + """ + dep = config.deployment + role = None + try: + role = config.federated_role(account) + except Exception as exc: # noqa: BLE001 — account in neither list; app.py reports that + logger.debug("federated role undetermined: %s", exc) + return { + "strategy": dep.strategy, + "role": role, # platform | workload | None (centralized/distributed) + "polled_account": account, + "platform_account": dep.platform_account, + "workload_accounts": list(dep.workload_accounts), + } def get_stack_status(stack_name: str) -> dict: @@ -187,7 +215,9 @@ def get_ssm_params() -> dict: def poll() -> dict: - in_scope = expected_suffixes() + config = load_config() + account = caller_account() + in_scope = expected_suffixes(config, account) stacks_status = {} for suffix, meta in STACK_META.items(): name = f"{PREFIX}-{suffix}" @@ -206,7 +236,8 @@ def poll() -> dict: "project": PROJECT, "environment": ENV, "region": REGION, - "account": boto3.client("sts").get_caller_identity()["Account"], + "account": account, + "deployment": deployment_view(config, account), "expected_stacks": [f"{PREFIX}-{s}" for s in in_scope], "summary": { "total_stacks": len(in_scope), diff --git a/dashboard/public/graph.css b/dashboard/public/graph.css new file mode 100644 index 0000000..289fb22 --- /dev/null +++ b/dashboard/public/graph.css @@ -0,0 +1,353 @@ +/* AgentCore Accelerator — architecture view (graph.js) styling, v4. + * + * Scoped entirely to .rf-* so it can be dropped into the dashboard without + * touching anything else. + * + * DELIBERATELY LIGHT. v3 inherited the page's dark --bg/--bg2/--bg4 and came out + * as a wiring schematic. v4 is a bright flow map sitting inside the dark shell, + * so the pane declares its OWN --rf-* variables and every rule below reads from + * those; the dark theme now stops at the pane's edge. The one thing still coming + * from the page is --accent (AWS orange), because that is the product's colour + * and the trust edge/VPC boundary should stay recognisably it. + * + * Everything that moves is here, not in JS: state pulses, marching edges and + * arrival flashes are @keyframes, and re-layout is a transform transition. That + * is what lets graph.js run without an animation loop, and it means one + * prefers-reduced-motion block at the bottom can switch all motion off. + */ + +/* --------------------------------------------------------------- pane + grid */ +.rf-pane { + /* Own palette, not the shell's. Hex, not var(), on purpose: --bg is dark. */ + --rf-bg: #fafafa; + --rf-dot: #e3e6ea; /* the faint dot grid */ + --rf-card: #ffffff; + --rf-line: #e6e8ec; /* 1px card / panel borders */ + --rf-line2: #d8dce2; /* slightly stronger dividers */ + --rf-ink: #1f2430; /* primary text */ + --rf-ink2: #5b6472; /* secondary text */ + --rf-ink3: #8b93a1; /* captions, glyphs on pale tiles */ + --rf-zone: #f2f3f5; /* account container fill */ + --rf-green: #16a34a; + --rf-amber: #d97706; + --rf-red: #dc2626; + --rf-idle: #cbd2da; /* not deployed — light grey tile */ + --rf-void: #e2e5ea; /* not applicable / unobserved — paler still */ + --rf-edge: #c9ced6; + --rf-shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06); + --rf-shadow-up: 0 4px 10px rgba(16, 24, 40, .10), 0 1px 3px rgba(16, 24, 40, .06); + + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + border-radius: 10px; + background-color: var(--rf-bg); + /* One radial-gradient dot, tiled, at ~4% ink — present, never a texture. */ + background-image: repeating-radial-gradient(circle at 0 0, var(--rf-dot) 0 1px, transparent 1px 100%); + background-size: 22px 22px; + cursor: grab; + user-select: none; + color: var(--rf-ink); + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} +.rf-pane.is-panning { cursor: grabbing; } + +.rf-viewport { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; +} +.rf-layer { position: absolute; left: 0; top: 0; } +.rf-groups { z-index: 1; } +.rf-nodes { z-index: 3; } + +/* ------------------------------------------------------------ account groups */ +/* Quiet zone, not a box with opinions: barely-there fill, hairline border, and + a small grey caption. The cards are the subject. */ +.rf-group { + position: absolute; + border: 1px solid var(--rf-line); + border-radius: 12px; + background: var(--rf-zone); + z-index: 1; +} +.rf-group--unobserved { + border-style: dashed; + border-color: var(--rf-line2); + background: transparent; +} +.rf-group-title { + position: absolute; left: 16px; top: 10px; + font-size: 11px; font-weight: 600; letter-spacing: .04em; + text-transform: uppercase; + color: var(--rf-ink2); +} +.rf-group-caption { + position: absolute; left: 16px; top: 26px; + font-size: 10.5px; color: var(--rf-ink3); +} +.rf-group--unobserved .rf-group-caption { color: var(--rf-amber); } +.rf-layer-labels { position: absolute; left: 0; right: 0; top: -16px; height: 13px; } +.rf-layer-label { + position: absolute; top: 0; + font-size: 9.5px; font-weight: 500; + letter-spacing: .07em; text-transform: uppercase; + color: var(--rf-ink3); + text-align: center; +} + +/* Out-of-scope tray: dashed, dim, structurally separate from the columns so a + card in here can never be mistaken for something that failed to deploy. */ +.rf-tray { + position: absolute; + border: 1px dashed var(--rf-line2); + border-radius: 10px; + z-index: 2; +} +.rf-tray-label { + position: absolute; left: 12px; top: 7px; + font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; + color: var(--rf-ink3); +} + +/* Drawn only when the networking stack is deployed (see renderVpc). */ +.rf-vpc { + position: absolute; + border: 1px dashed var(--accent, #FF9900); + border-radius: 12px; + opacity: .6; + z-index: 2; + pointer-events: none; +} +.rf-vpc-label { + position: absolute; left: 6px; top: -15px; + font-size: 9.5px; letter-spacing: .04em; + color: var(--accent, #FF9900); + white-space: nowrap; +} + +/* -------------------------------------------------------------------- edges */ +.rf-edges { + position: absolute; + left: 0; top: 0; + overflow: visible; + pointer-events: none; /* cards must stay clickable through the edge layer */ + z-index: 2; +} +/* Thin, light, no opacity trickery: a hairline that reads at 100% is easier to + keep consistent than a heavier stroke faded to look thin. */ +.rf-edge { fill: none; stroke: var(--rf-edge); stroke-width: 1.2; stroke-linecap: round; } +.rf-edge--live { stroke: #86c8a4; } +.rf-edge--building { + stroke: var(--rf-amber); + stroke-dasharray: 5 5; + animation: rf-march 1s linear infinite; +} +.rf-edge--unobserved { stroke: var(--rf-void); stroke-dasharray: 5 5; } +.rf-edge--trust { + stroke: var(--accent, #FF9900); stroke-width: 1.4; + stroke-dasharray: 7 5; +} +.rf-edge-label { + fill: #b06f00; /* AWS orange is unreadable as text on white */ + font-size: 10px; + font-weight: 500; + text-anchor: middle; + font-family: 'Inter', system-ui, sans-serif; +} +.rf-mk path { fill: var(--rf-edge); } +.rf-mk--live path { fill: #86c8a4; } +.rf-mk--building path { fill: var(--rf-amber); } +.rf-mk--trust path { fill: var(--accent, #FF9900); } +.rf-mk--unobserved path { fill: var(--rf-void); } + +/* --------------------------------------------------------------- node cards */ +/* A card is a small white tile: icon square, then two ellipsised lines. Quiet + is the whole point — it should look like a step in a flow, not like a box on + an architecture diagram. */ +.rf-node { + position: absolute; + left: 0; top: 0; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 9px; + padding: 0 10px; + border: 1px solid var(--rf-line); + border-radius: 8px; + background: var(--rf-card); + box-shadow: var(--rf-shadow); + overflow: hidden; + cursor: grab; + /* Re-layout is a CSS transition, never a JS tween. */ + transition: transform .25s ease-out, box-shadow .2s, border-color .2s; +} +.rf-node:hover { box-shadow: var(--rf-shadow-up); } +.rf-node.is-dragging { transition: none; cursor: grabbing; z-index: 5; } +.rf-node:focus-visible, .rf-node:focus { + outline: 2px solid var(--accent, #FF9900); + outline-offset: 2px; +} + +/* The icon tile carries the state colour — one saturated square per card, so + the palette never has to fight the text for legibility. */ +.rf-tile { + flex: 0 0 28px; + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + border-radius: 7px; + background: var(--rf-idle); + color: #fff; /* the glyph paints with currentColor */ +} +.rf-glyph { width: 15px; height: 15px; display: block; fill: currentColor; } +.rf-node--deployed .rf-tile { background: var(--rf-green); } +.rf-node--in-progress .rf-tile { background: var(--rf-amber); } +.rf-node--failed .rf-tile { background: var(--rf-red); } +.rf-node--not-deployed .rf-tile { background: var(--rf-idle); } +.rf-node--not-applicable .rf-tile, +.rf-node--unobserved .rf-tile { background: var(--rf-void); } +/* White on a pale grey tile is invisible, so those two swap to grey ink. A + deliberate break from "the glyph is always white": contrast wins. */ +.rf-node--not-deployed .rf-tile, +.rf-node--not-applicable .rf-tile, +.rf-node--unobserved .rf-tile { color: var(--rf-ink3); } + +.rf-text { min-width: 0; flex: 1; } /* min-width:0 is what lets it ellipsise */ +.rf-name { + font-size: 12px; font-weight: 600; + color: var(--rf-ink); + line-height: 1.3; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rf-sub { + font-size: 10.5px; font-weight: 400; + color: var(--rf-ink3); + line-height: 1.35; + padding-right: 11px; /* keep the ellipsis clear of the delivery badge */ + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.rf-node--not-deployed { border-style: dashed; } +.rf-node--in-progress { animation: rf-pulse 1.6s ease-in-out infinite; } +.rf-node--failed { border-color: #f3c4c4; } +.rf-node--unobserved { + border-style: dashed; + background: #fcfcfd; + box-shadow: none; +} +.rf-node--unobserved .rf-name { color: var(--rf-ink2); font-weight: 500; } +.rf-node--tray { + padding: 0 8px; gap: 7px; + background: #fbfbfc; + box-shadow: none; + border-style: dashed; +} +.rf-node--tray .rf-tile { flex-basis: 20px; width: 20px; height: 20px; border-radius: 5px; } +.rf-node--tray .rf-glyph { width: 11px; height: 11px; } +.rf-node--tray .rf-name { font-size: 11px; font-weight: 500; color: var(--rf-ink2); } +.rf-node--tray .rf-sub { font-size: 9.5px; } +.rf-node.is-selected { + border-color: var(--accent, #FF9900); + box-shadow: 0 0 0 3px rgba(255, 153, 0, .18), var(--rf-shadow-up); + z-index: 4; +} +.rf-node.is-pinned { border-color: var(--rf-line2); border-style: solid; } + +/* Delivery badge: a small dot in the corner, not a second colour system. */ +.rf-badge { + position: absolute; right: 7px; bottom: 5px; + color: #9aa3b0; + font-size: 8px; + line-height: 1; + cursor: help; +} + +/* ------------------------------------------------------ overlays (screen space) */ +/* Small, grey, bottom-left, sitting to the right of the controls. */ +.rf-guidance { + position: absolute; left: 138px; bottom: 14px; + max-width: calc(100% - 160px); + font-size: 10.5px; color: var(--rf-ink3); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + pointer-events: none; +} +/* No panel, no chrome: four bare glyph buttons in a row. */ +.rf-controls { + position: absolute; left: 12px; bottom: 10px; + display: flex; gap: 2px; + z-index: 6; +} +.rf-btn { + width: 26px; height: 26px; + display: flex; align-items: center; justify-content: center; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--rf-ink3); + font-size: 13px; line-height: 1; + cursor: pointer; + transition: background .15s, color .15s; +} +.rf-btn:hover { background: rgba(31, 36, 48, .06); color: var(--rf-ink); } +.rf-btn:focus-visible { outline: 2px solid var(--accent, #FF9900); outline-offset: 1px; } + +.rf-details { + position: absolute; right: 12px; top: 12px; + width: 262px; + max-height: calc(100% - 140px); + overflow: auto; + padding: 10px 12px 4px; + border: 1px solid var(--rf-line); + border-radius: 10px; + background: var(--rf-card); + box-shadow: var(--rf-shadow-up); + z-index: 6; + user-select: text; /* the point of the panel: copy an ARN out of it */ +} +.rf-details-title { + font-size: 11.5px; font-weight: 600; color: var(--rf-ink); + word-break: break-all; + margin-bottom: 7px; +} +.rf-row { + display: flex; gap: 10px; + font-size: 10.5px; + padding: 4px 0; + border-top: 1px solid var(--rf-line); +} +.rf-row-k { flex: 0 0 76px; color: var(--rf-ink3); word-break: break-all; } +.rf-row-v { flex: 1; color: var(--rf-ink2); word-break: break-all; } + +/* ---------------------------------------------------------------- animations */ +@keyframes rf-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(217, 119, 6, .28), var(--rf-shadow); } + 50% { box-shadow: 0 0 0 5px rgba(217, 119, 6, .10), var(--rf-shadow); } +} +@keyframes rf-march { to { stroke-dashoffset: -10; } } +@keyframes rf-ok { + 0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, .40); } + 100% { box-shadow: 0 0 0 14px rgba(22, 163, 74, 0); } +} +@keyframes rf-bad { + 0% { box-shadow: 0 0 0 0 rgba(220, 38, 38, .40); border-color: var(--rf-red); } + 100% { box-shadow: 0 0 0 14px rgba(220, 38, 38, 0); border-color: var(--rf-line); } +} +/* One-shot, added on a state transition and removed on animationend. Neither + flash animates `transform`: the card's transform IS its layout position, so a + keyframe touching it would yank the card to the viewport origin mid-flash. + They do not touch `background` either — a white card flashing solid red + erased the text it was reporting on. */ +.rf-flash-ok { animation: rf-ok .9s ease-out 1; } +.rf-flash-bad { animation: rf-bad .9s ease-out 1; } + +@media (prefers-reduced-motion: reduce) { + .rf-node, .rf-node--in-progress, .rf-edge--building, + .rf-flash-ok, .rf-flash-bad, .rf-btn { + animation: none !important; + transition: none !important; + } +} diff --git a/dashboard/public/graph.js b/dashboard/public/graph.js new file mode 100644 index 0000000..9fd5896 --- /dev/null +++ b/dashboard/public/graph.js @@ -0,0 +1,1110 @@ +/* AgentCore Accelerator — live architecture view (Architecture tab), v4. + * + * Zero dependencies on purpose: the dashboard is a single self-contained folder + * that has to work over `python3 -m http.server`, so no React, no xyflow, no + * CDN, no build step. This is plain DOM + inline SVG. + * + * v1 was a force-directed hairball. v2 was the right diagram drawn the wrong + * way: one , so every label went through measureText and got shrunk or + * ellipsed, nothing could be selected or copied, and the whole picture was + * invisible to the accessibility tree. v3 fixed the renderer but dressed it as + * a dark engineering diagram. v4 changes ONLY the presentation layer — data + * model, layout maths, semantics and API are v3's: + * + * A. LIGHT FLOW-MAP LANGUAGE, not a wiring schematic. The pane is a bright + * design canvas inside the dark shell (its own --rf-* variables in + * graph.css, deliberately NOT the page's dark --bg), and a card is a small + * quiet white tile: coloured icon square on the left, at most two ellipsised + * lines of small type. State lives in the icon tile's fill, so the colour is + * one glance and the text stays black-on-white legible. + * B. CURVED EDGES. Cubic beziers leaving the source's right edge horizontally + * and entering the target's left edge horizontally, so a fan-out from one + * card and a merge into a shared target read as flow rather than as bus + * wiring. See bezier() for the control-point rule. + * C. LESS CHROME. The minimap is gone (a 10-card diagram that fits the pane + * never needed one) and the decorative handle dots with it: neither carried + * information. Anything that implies an action we cannot perform — xyflow + * handles, the reference's mid-edge "+" buttons — stays out, because this + * view is read-only and a dead affordance is worse than none. + * + * Inherited from v3, unchanged: + * + * 1. DOM OVER CANVAS. A stack name is text; making it real text means crisp + * subpixel rendering at any zoom, selectable/copyable output in the details + * panel, native title tooltips, tabbing, and CSS doing the styling. The + * whole diagram is ~12 cards — DOM is nowhere near a bottleneck here. + * 2. DETERMINISTIC layout, no physics. Columns are layers left→right, cards + * stack inside a column. Same status.json ⇒ same picture, so a narrated demo + * can say "the gateway is here" and still be right tomorrow. + * 3. `state` is READ, never recomputed. dashboard/monitor.py owns the one and + * only CloudFormation-status classifier; a second one in the UI was the bug + * we removed. Missing state degrades to "not-deployed", never inferred from + * the raw `status` string here. + * 4. OBSERVABILITY IS A BADGE, NOT EDGES. Five stacks ship logs+traces to the + * observability stack; as edges that is a fan-in through the middle of the + * picture, v1's worst crossing source. TOPOLOGY stays complete (it mirrors + * app.py) and those edges are filtered at draw time — see buildEdges(). + * 5. HONESTY about what a dashboard can see: exactly ONE account + * (deployment.polled_account / role). The other side of a federation is + * unknown from here, so it is drawn `unobserved` (dashed, dim, no state + * colour) and never counted as deployed or failed. + * 6. NO ANIMATION LOOP AT ALL. Every animation is a CSS @keyframes or a CSS + * transition, so idle CPU with the tab open is zero and there is no frame + * scheduler to leak on destroy(). prefers-reduced-motion kills the lot. + * 7. A DRAGGED CARD STAYS PUT, including across the 15s poll — v1 re-anchored + * every card on every poll, which made dragging look broken. layout() skips + * targets for dropped cards; only "reset layout" releases them. + */ +(function () { + 'use strict'; + + var NS = 'http://www.w3.org/2000/svg'; + + // ---------------------------------------------------------------- topology + // Mirrors app.py's stack dependency wiring. Exported so tests can assert + // every id here exists in monitor.py's STACK_META. Edges into observability + // are kept here (they are real dependencies) but never drawn — see rule 4. + var TOPOLOGY = { + 'auth': ['identity', 'gateway'], + 'identity': ['runtime-orchestrator', 'runtime-research-agent'], + 'gateway': ['runtime-orchestrator', 'runtime-research-agent', 'observability'], + 'memory': ['runtime-orchestrator', 'observability'], + 'networking': ['runtime-orchestrator', 'runtime-code-agent', 'runtime-research-agent'], + 'security': ['memory'], + // runtime-orchestrator to the two A2A agents is delegation, not deploy order + 'runtime-orchestrator': ['runtime-code-agent', 'runtime-research-agent', 'observability'], + 'runtime-code-agent': ['observability'], + 'runtime-research-agent': ['observability'] + }; + + var LAYER_ORDER = ['foundation', 'identity', 'service', 'runtime', 'observability']; + var LAYER_OF = { + 'networking': 'foundation', 'security': 'foundation', + 'auth': 'identity', 'identity': 'identity', + 'gateway': 'service', 'memory': 'service', + 'runtime-orchestrator': 'runtime', 'runtime-code-agent': 'runtime', + 'runtime-research-agent': 'runtime', + 'observability': 'observability' + }; + // The stack NAME is the primary label on a card. A module id is a workshop + // ordering hint, not an identity — v1 made it the only thing on the node and + // the picture read as a bag of numbers. + var NAME = { + 'networking': 'networking', 'security': 'security', 'auth': 'auth', + 'identity': 'identity', 'gateway': 'gateway', 'memory': 'memory', + 'runtime-orchestrator': 'orchestrator', 'runtime-code-agent': 'code agent', + 'runtime-research-agent': 'research agent', 'observability': 'observability' + }; + var PLATFORM_SIDE = ['auth', 'identity', 'gateway', 'observability']; + // identity AND observability exist on both sides: the workload account runs + // its own token vault (that is what makes the trust pure OAuth) and its own + // observability stack, because each side monitors what it runs (app.py). + var WORKLOAD_SIDE = ['identity', 'observability', 'memory', 'runtime-orchestrator', + 'runtime-code-agent', 'runtime-research-agent', 'networking', 'security']; + var RUNTIME_IDS = ['runtime-orchestrator', 'runtime-code-agent', 'runtime-research-agent']; + // Fixed tiebreak for row ordering. Determinism needs a total order that does + // not depend on object key iteration of the incoming JSON. + var BASE_RANK = {}; + Object.keys(LAYER_OF).forEach(function (b, i) { BASE_RANK[b] = i; }); + + // ------------------------------------------------------------------- state + var root = null, legendEl = null, pane = null, viewport = null; + var svg = null, edgeLayer = null, groupLayer = null, nodeLayer = null; + var trayEl = null, trayLabel = null, vpcEl = null, guidanceEl = null, detailsEl = null; + var nodes = [], edges = [], containers = [], trayBox = null; + var elById = {}, edgeElById = {}; + var status = null, pendingStatus = null, guidanceText = '', warned = false; + var mounted = false, selectedId = null; + var view = { k: 1, tx: 0, ty: 0, userAdjusted: false }; + var drag = { node: null, dx: 0, dy: 0, moved: false }; + var pan = { on: false, moved: false, x: 0, y: 0 }; + var W = 900, H = 560, bounds = { x: 0, y: 0, w: 900, h: 560 }; + var listeners = [], timers = [], ro = null; + // Card geometry is fixed; a small pane is handled by zooming the viewport, + // not by shrinking type (that was the canvas version's compromise). v4 cards + // are smaller and the gutters much airier RELATIVE TO THE CARD (gutter/card + // 0.87 vs v3's 0.65, row gap/card height 0.46 vs 0.29), which is the way to + // buy whitespace here: the drawing is auto-fitted, so widening the absolute + // gutter past ~130 only zooms the whole picture out and the type with it. + // Wide gutters are also what gives the beziers room to read as curves. + var CW = 150, CH = 56, GUT = 130, VGAP = 26, PADX = 30, HEAD = 44; + var MIN_K = 0.3, MAX_K = 2; + + function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } + function r1(v) { return Math.round(v * 10) / 10; } + + // -------------------------------------------------------------- DOM helpers + function el(tag, cls, parent) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (parent) parent.appendChild(e); + return e; + } + function sv(tag, cls, parent) { + var e = document.createElementNS(NS, tag); + if (cls) e.setAttribute('class', cls); + if (parent) parent.appendChild(e); + return e; + } + function txt(e, s) { e.textContent = (s === null || s === undefined) ? '' : String(s); return e; } + function at(e, k, v) { e.setAttribute(k, String(v)); return e; } + function on(target, type, fn, opts) { + if (!target || typeof target.addEventListener !== 'function') return; + target.addEventListener(type, fn, opts || false); + listeners.push([target, type, fn, opts || false]); + } + function later(fn, ms) { timers.push(setTimeout(fn, ms)); } + + // -------------------------------------------------------------- data → model + function suffixOf(key, project, env) { + var pre = (project || '') + '-' + (env || '') + '-'; + return key.indexOf(pre) === 0 ? key.slice(pre.length) : key; + } + + /** Which side of a federated deployment the polled account sits on. */ + function polledRole(dep) { + if (!dep) return null; + if (dep.role === 'platform' || dep.role === 'workload') return dep.role; + var acct = dep.polled_account || ''; + if (acct && acct === dep.platform_account) return 'platform'; + var wl = Array.isArray(dep.workload_accounts) ? dep.workload_accounts : []; + if (acct && wl.indexOf(acct) >= 0) return 'workload'; + return null; + } + + function buildModel(s) { + // Both blocks are shape-checked, not just truthiness-checked: a string + // `stacks` passes `||` and then Object.keys() walks its CHARACTERS, so a + // malformed file drew a card per letter instead of falling back. The + // try/catch in update() cannot catch that — nothing throws. + var dep = (s && typeof s.deployment === 'object' && s.deployment) || {}; + var strategy = typeof dep.strategy === 'string' && dep.strategy ? dep.strategy : 'centralized'; + var federated = strategy === 'federated'; + var stacks = (s && typeof s.stacks === 'object' && s.stacks) || {}; + var project = (s && s.project) || '', env = (s && s.environment) || ''; + var role = federated ? polledRole(dep) : null; + + var seen = {}; + Object.keys(stacks).forEach(function (key) { + var rec = stacks[key] || {}; + seen[suffixOf(key, project, env)] = { key: key, rec: rec }; + }); + + var wanted = []; + if (federated) { + PLATFORM_SIDE.forEach(function (b) { wanted.push({ base: b, side: 'platform' }); }); + WORKLOAD_SIDE.forEach(function (b) { wanted.push({ base: b, side: 'workload' }); }); + } else { + Object.keys(LAYER_OF).forEach(function (b) { wanted.push({ base: b, side: null }); }); + } + // Unknown extra stacks still get a card rather than disappearing silently. + Object.keys(seen).sort().forEach(function (b) { + if (!LAYER_OF[b]) wanted.push({ base: b, side: federated ? (role || 'platform') : null, extra: true }); + }); + + var byId = {}; + nodes.forEach(function (n) { byId[n.id] = n; }); + var next = [], nextIds = {}, flashes = []; + + wanted.forEach(function (w) { + var id = w.side === 'workload' && PLATFORM_SIDE.indexOf(w.base) >= 0 ? w.base + '#workload' : w.base; + if (nextIds[id]) return; + nextIds[id] = true; + // Only the polled side can be reported on. Anything on the other side of + // a federation is unknown from here, full stop. + var observed = !federated || !role || w.side === role; + var hit = observed ? seen[w.base] : null; + var rec = hit ? hit.rec : {}; + var n = byId[id] || { id: id, x: NaN, y: NaN, dropped: false, state: null }; + var st = observed ? (typeof rec.state === 'string' && rec.state ? rec.state : 'not-deployed') : 'unobserved'; + // One-shot flashes fire on the TRANSITION only, never on a steady state. + if (n.state && n.state !== st && (st === 'deployed' || st === 'failed')) { + flashes.push([n, st === 'deployed' ? 'rf-flash-ok' : 'rf-flash-bad']); + } + n.base = w.base; + n.side = w.side; + n.observed = observed; + n.state = st; + n.key = hit ? hit.key : (project && env ? project + '-' + env + '-' + w.base : w.base); + n.module = rec.module || (w.extra ? '?' : ''); + n.team = rec.team || ''; + n.status = observed ? (rec.status || '') : ''; + n.description = rec.description || ''; + n.outputs = rec.outputs && typeof rec.outputs === 'object' ? rec.outputs : {}; + n.count = Array.isArray(rec.resources) ? rec.resources.length : 0; + n.layer = LAYER_OF[w.base] || 'runtime'; + n.name = NAME[w.base] || w.base; + if (w.side === 'workload' && w.base === 'identity') n.name = 'identity (local vault)'; + // Out of scope lives in the tray, never in a column. + n.tray = n.state === 'not-applicable'; + next.push(n); + }); + + nodes = next; + if (selectedId && !nextIds[selectedId]) selectedId = null; + if (drag.node && next.indexOf(drag.node) < 0) drag.node = null; + containers = buildContainers(strategy, dep, federated); + edges = buildEdges(federated); + layout(); + guidanceText = guidance(); + render(); + fitView(); + flashes.forEach(function (f) { flash(f[0], f[1]); }); + } + + function nodeFor(base, preferSide) { + var hits = nodes.filter(function (n) { return n.base === base; }); + if (hits.length < 2) return hits[0] || null; + var pref = hits.filter(function (n) { return n.side === preferSide; })[0]; + return pref || hits[0]; + } + + function buildEdges(federated) { + var out = [], seen = {}; + Object.keys(TOPOLOGY).forEach(function (from) { + (TOPOLOGY[from] || []).forEach(function (to) { + // Observability is drawn as a per-card badge, not as edges: five lines + // converging on one card was the single biggest source of crossings in + // v1. TOPOLOGY stays complete because it mirrors app.py's real wiring; + // the filter lives here so the map and the picture can disagree on + // purpose, in one obvious place. + if (to === 'observability') return; + var a = nodeFor(from, null), b = nodeFor(to, null); + var aOnly = nodes.filter(function (n) { return n.base === from; }); + var bOnly = nodes.filter(function (n) { return n.base === to; }); + if (aOnly.length > 1 && bOnly.length === 1) a = nodeFor(from, bOnly[0].side); + if (bOnly.length > 1 && aOnly.length === 1) b = nodeFor(to, aOnly[0].side); + if (!a || !b || a === b || a.tray || b.tray || a.side !== b.side) return; + var k = a.id + '>' + b.id; + if (seen[k]) return; + seen[k] = true; + out.push({ id: k, a: a, b: b, kind: 'dep' }); + }); + }); + if (federated) { + // ONE trust edge, not one per platform service: the trust itself is + // "workload vault exchanges platform Cognito M2M credentials". Drawing it + // from gateway as well repeated the same fact and cost a crossing. + var vault = nodes.filter(function (n) { return n.base === 'identity' && n.side === 'workload'; })[0]; + var auth = nodes.filter(function (n) { return n.base === 'auth' && n.side === 'platform'; })[0]; + if (auth && vault) { + out.push({ + id: 'trust', a: auth, b: vault, kind: 'trust', + label: 'OAuth (no cross-account IAM)' + }); + } + } + return out; + } + + function buildContainers(strategy, dep, federated) { + var polled = dep.polled_account || (status && status.account) || 'unknown'; + if (federated) { + var role = polledRole(dep); + var wl = (Array.isArray(dep.workload_accounts) ? dep.workload_accounts : []).join(', ') || 'unknown'; + return [ + { + side: 'platform', title: 'Account ' + (dep.platform_account || 'unknown') + ' · federated platform', + caption: role === 'platform' ? 'polled from here' : 'not observed from this account', + polled: role === 'platform' + }, + { + side: 'workload', title: 'Account ' + wl + ' · federated workload', + caption: role === 'workload' ? 'polled from here' : 'not observed from this account', + polled: role === 'workload' + } + ]; + } + if (strategy === 'distributed') { + return [{ + side: null, title: 'Account ' + polled + ' · distributed', + caption: 'each account runs its own full copy — showing ' + polled, polled: true + }]; + } + return [{ side: null, title: 'Account ' + polled + ' · centralized', caption: 'polled from here', polled: true }]; + } + + // ------------------------------------------------------------------- layout + /* Deterministic orthogonal layout. Columns = layers, rows = cards, one pass + * of the barycentre heuristic to order rows. No randomness, no simulation: + * a card only moves when the computed slot moves, and then CSS transitions it. + * + * ponytail: ONE barycentre pass, not full Sugiyama. Ceiling — long edges + * (networking → runtimes) still pass behind intermediate cards; they are drawn + * under the opaque cards so it reads cleanly. Upgrade path is dummy nodes per + * proper Sugiyama, ~300 lines for a 10-card diagram, so: not yet. + */ + function layout() { + var boxW = PADX * 2 + LAYER_ORDER.length * CW + (LAYER_ORDER.length - 1) * GUT; + var y = 28; + + containers.forEach(function (c) { + var cols = columnsFor(c.side); + var tallest = 0; + cols.forEach(function (list) { + var h = list.length * CH + Math.max(0, list.length - 1) * VGAP; + if (h > tallest) tallest = h; + }); + var boxH = HEAD + Math.max(CH, tallest) + PADX; + c.box = { x: 0, y: y, w: boxW, h: boxH }; + // Each column is vertically centred as a group inside the box body, so a + // column of one sits on the midline instead of hugging the top. + var mid = c.box.y + HEAD + Math.max(CH, tallest) / 2; + cols.forEach(function (list, ci) { + var top = mid - (list.length * CH + Math.max(0, list.length - 1) * VGAP) / 2; + list.forEach(function (n, ri) { + n.col = ci; n.row = ri; n.w = CW; n.h = CH; + place(n, colX(ci), top + ri * (CH + VGAP)); + }); + }); + y += boxH + 34; + }); + + layoutTray(boxW, y); + laneEdges(); + } + + function colX(ci) { return PADX + ci * (CW + GUT); } + + /** In-scope cards of one side, grouped into layer columns, rows ordered by a + * single barycentre pass over already-placed predecessors. */ + function columnsFor(side) { + var mine = nodes.filter(function (n) { return n.side === side && !n.tray; }); + var cols = LAYER_ORDER.map(function (layer) { + return mine.filter(function (n) { return n.layer === layer; }) + .sort(function (a, b) { return (BASE_RANK[a.base] || 99) - (BASE_RANK[b.base] || 99); }); + }); + var rowOf = {}; + cols.forEach(function (list, ci) { + if (ci > 0) { + list.forEach(function (n, i) { + var sum = 0, k = 0; + edges.forEach(function (e) { + if (e.kind !== 'dep' || e.b !== n) return; + if (typeof rowOf[e.a.id] === 'number') { sum += rowOf[e.a.id]; k++; } + }); + n._bary = k ? sum / k : i; + n._i = i; + }); + list.sort(function (a, b) { return (a._bary - b._bary) || (a._i - b._i); }); + } + list.forEach(function (n, i) { rowOf[n.id] = i; }); + }); + return cols; + } + + /** Out-of-scope cards get a dim strip along the bottom. They are laid out + * last and never influence a column, so enabling a module in platform.yaml + * is the only thing that can move the main diagram. */ + function layoutTray(boxW, y) { + var list = nodes.filter(function (n) { return n.tray; }); + trayBox = null; + if (!list.length) return; + list.sort(function (a, b) { return (BASE_RANK[a.base] || 99) - (BASE_RANK[b.base] || 99); }); + var w = 136, h = 40, gap = 14; // room for a smaller icon tile + one line + var perRow = Math.max(1, Math.floor((boxW - PADX * 2 + gap) / (w + gap))); + var rows = Math.ceil(list.length / perRow); + trayBox = { x: 0, y: y, w: boxW, h: 30 + rows * (h + gap) }; + list.forEach(function (n, i) { + var r = Math.floor(i / perRow), c = i % perRow; + n.w = w; n.h = h; n.col = -1; n.row = r; + place(n, PADX + c * (w + gap), y + 26 + r * (h + gap)); + }); + } + + /** A card the user dropped keeps its own position across update() calls — + * re-anchoring on the next poll was v1's most annoying bug (drags looked + * like they did nothing). Everything else snaps to the computed slot and + * lets the CSS transition carry it there. */ + function place(n, x, y) { + if (n.dropped) return; + n.x = x; n.y = y; + } + + /** Lanes exist only for the edges that have to loop. A forward edge derives + * its whole shape from dx (see bezier), so it needs no lane; a same-column + * edge (auth→identity, orchestrator→agents) bulges into the gutter to the + * right, and parallel bulges of the same size would sit on top of each other. + * Lanes are assigned in a deterministic order (source row, then target row) + * so the same model always draws the same picture. */ + function laneEdges() { + var groups = {}; + edges.forEach(function (e) { + if (e.kind === 'trust') return; + e.back = e.b.col <= e.a.col; + if (!e.back) return; + e.gut = Math.max(e.a.col, e.b.col); + (groups[e.gut] = groups[e.gut] || []).push(e); + }); + Object.keys(groups).forEach(function (g) { + var list = groups[g]; + list.sort(function (p, q) { return (p.a.row - q.a.row) || (p.b.row - q.b.row); }); + list.forEach(function (e, i) { + e.lane = colX(e.gut) + CW + GUT * (i + 1) / (list.length + 1); + }); + }); + } + + function guidance() { + var scope = nodes.filter(function (n) { return n.observed && n.state !== 'not-applicable'; }); + var busy = scope.filter(function (n) { return n.state === 'in-progress'; })[0]; + if (busy) return busy.name + ' deploying — the runtime build typically takes ~7 min'; + var bad = scope.filter(function (n) { return n.state === 'failed'; })[0]; + if (bad) return bad.name + ' failed — check its CloudFormation events'; + var todo = scope.filter(function (n) { return n.state === 'not-deployed'; }).length; + if (todo) return todo + ' stack(s) not deployed yet'; + return 'All ' + scope.length + ' stacks deployed'; + } + + /** A card is badged when its stack ships logs+traces to an observability + * stack that is actually deployed on the same side. Badging an undeployed + * observability stack would claim delivery that cannot be happening. */ + function badged(n) { + if (!n.observed || n.tray || n.state !== 'deployed') return false; + if ((TOPOLOGY[n.base] || []).indexOf('observability') < 0) return false; + return nodes.some(function (o) { + return o.base === 'observability' && o.side === n.side && o.state === 'deployed'; + }); + } + + // ------------------------------------------------------------------- render + function render() { + if (!mounted) return; + renderGroups(); + renderTray(); + renderNodes(); + renderVpc(); + measureContent(); + renderEdges(); + txt(guidanceEl, guidanceText); + renderDetails(); + } + + function measureContent() { + var x0 = 0, y0 = 0, x1 = 320, y1 = 240; + function add(x, y, w, h) { + x0 = Math.min(x0, x); y0 = Math.min(y0, y); + x1 = Math.max(x1, x + w); y1 = Math.max(y1, y + h); + } + containers.forEach(function (c) { if (c.box) add(c.box.x, c.box.y - 18, c.box.w, c.box.h + 18); }); + if (trayBox) add(trayBox.x, trayBox.y, trayBox.w, trayBox.h); + nodes.forEach(function (n) { if (isFinite(n.x)) add(n.x - 8, n.y - 8, n.w + 16, n.h + 16); }); + bounds = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + // The edge sits at the viewport origin and spans the whole layout, so + // path coordinates ARE layout coordinates — no per-edge offset maths. + // overflow:visible in the CSS covers the ±8px the layout can go negative. + at(svg, 'width', Math.max(0, x1)); + at(svg, 'height', Math.max(0, y1)); + } + + function renderGroups() { + var kept = 0; + containers.forEach(function (c) { + var e = groupLayer.children[kept]; + if (!e) { + e = el('div', 'rf-group', groupLayer); + c.titleEl = txt(el('div', 'rf-group-title', e), ''); + c.capEl = txt(el('div', 'rf-group-caption', e), ''); + c.labelsEl = el('div', 'rf-layer-labels', e); + e._c = c; + } else { + c.titleEl = e.children[0]; c.capEl = e.children[1]; c.labelsEl = e.children[2]; + } + kept++; + e.className = 'rf-group' + (c.polled ? '' : ' rf-group--unobserved'); + e.style.left = c.box.x + 'px'; + e.style.top = c.box.y + 'px'; + e.style.width = c.box.w + 'px'; + e.style.height = c.box.h + 'px'; + txt(c.titleEl, c.title); + txt(c.capEl, c.caption); + if (!c.labelsEl.children.length) { + LAYER_ORDER.forEach(function (l, i) { + var t = txt(el('div', 'rf-layer-label', c.labelsEl), l); + t.style.left = colX(i) + 'px'; + t.style.width = CW + 'px'; + }); + } + }); + while (groupLayer.children.length > kept) { + groupLayer.removeChild(groupLayer.children[groupLayer.children.length - 1]); + } + } + + function renderTray() { + var show = !!trayBox; + trayEl.style.display = show ? 'block' : 'none'; + if (!show) return; + trayEl.style.left = trayBox.x + 'px'; + trayEl.style.top = trayBox.y + 'px'; + trayEl.style.width = trayBox.w + 'px'; + trayEl.style.height = trayBox.h + 'px'; + txt(trayLabel, 'Not in this profile — enable in platform.yaml'); + } + + function renderVpc() { + // Only claim a VPC when the networking stack is actually deployed — drawing + // it unconditionally would assert infrastructure that is not there. + var net = nodes.filter(function (n) { + return n.base === 'networking' && n.observed && n.state === 'deployed'; + })[0]; + var rts = net ? nodes.filter(function (n) { + return RUNTIME_IDS.indexOf(n.base) >= 0 && n.side === net.side && !n.tray; + }) : []; + if (!rts.length) { vpcEl.style.display = 'none'; return; } + var x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9; + rts.forEach(function (n) { + x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); + x1 = Math.max(x1, n.x + n.w); y1 = Math.max(y1, n.y + n.h); + }); + var m = 16; + vpcEl.style.display = 'block'; + vpcEl.style.left = (x0 - m) + 'px'; + vpcEl.style.top = (y0 - m) + 'px'; + vpcEl.style.width = (x1 - x0 + m * 2) + 'px'; + vpcEl.style.height = (y1 - y0 + m * 2) + 'px'; + } + + /* One glyph per layer, built from primitives (rect/circle/polygon) rather + * than hand-authored path data: shapes I can reason about exactly are the + * right call when the only way to check a `d` string is to look at it. + * Colour comes from CSS (fill/stroke: currentColor) so the pale not-deployed + * tiles can swap white for grey in one rule instead of in JS. */ + var GLYPH = { + // three slabs — the base everything else sits on (networking, security) + foundation: [['rect', { x: 3, y: 3.4, width: 10, height: 2.2, rx: 0.7 }], + ['rect', { x: 3, y: 6.9, width: 10, height: 2.2, rx: 0.7 }], + ['rect', { x: 3, y: 10.4, width: 10, height: 2.2, rx: 0.7 }]], + // head + shoulders — who is calling (auth, token vault) + identity: [['circle', { cx: 8, cy: 5.2, r: 2.4 }], + ['rect', { x: 3.2, y: 9.2, width: 9.6, height: 4.6, rx: 2.3 }]], + // two boxes joined by a link — a gateway/memory between callers (service) + service: [['rect', { x: 2, y: 4, width: 4, height: 8, rx: 1.1 }], + ['rect', { x: 10, y: 4, width: 4, height: 8, rx: 1.1 }], + ['rect', { x: 6, y: 7.2, width: 4, height: 1.6, rx: 0.6 }]], + // a play triangle — something that runs + runtime: [['polygon', { points: '5,3.2 12.4,8 5,12.8' }]], + // ring + pupil — something watching + observability: [['circle', { cx: 8, cy: 8, r: 4.9, fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6 }], + ['circle', { cx: 8, cy: 8, r: 1.9 }]] + }; + + function glyph(layer, parent) { + var g = sv('svg', 'rf-glyph', parent); + at(g, 'viewBox', '0 0 16 16'); + at(g, 'aria-hidden', 'true'); // the card's aria-label already says it + at(g, 'focusable', 'false'); + (GLYPH[layer] || GLYPH.runtime).forEach(function (shape) { + var s = sv(shape[0], null, g); + Object.keys(shape[1]).forEach(function (k) { at(s, k, shape[1][k]); }); + }); + return g; + } + + /* Card DOM, deliberately flat: + * .rf-node[tabindex][role=button] + * .rf-tile > svg.rf-glyph state colour + layer glyph + * .rf-text > .rf-name/.rf-sub two ellipsised lines, nothing else + * .rf-badge observability delivery, when earned + * No module pill and no handle dots: the pill was a third line of type on a + * two-line card and the module number lives in the details panel, where + * someone actually looking for it will look. */ + function makeNode(n) { + var e = el('div', 'rf-node'); + at(e, 'tabindex', '0'); + at(e, 'role', 'button'); + e._rf = n.id; + e._tile = el('span', 'rf-tile', e); + glyph(n.layer, e._tile); + var text = el('div', 'rf-text', e); + e._name = el('div', 'rf-name', text); + e._sub = el('div', 'rf-sub', text); + e._badge = at(txt(el('span', 'rf-badge', e), '●'), 'title', 'logs + traces delivered'); + return e; + } + + function syncNode(n, e) { + var parent = n.tray ? trayEl : nodeLayer; + if (e.parentNode !== parent) parent.appendChild(e); + e.className = 'rf-node rf-node--' + n.state + + (n.tray ? ' rf-node--tray' : '') + + (n.observed ? '' : ' rf-node--unobserved') + + (n.id === selectedId ? ' is-selected' : '') + + (n.dropped ? ' is-pinned' : ''); + e.style.width = n.w + 'px'; + e.style.height = n.h + 'px'; + var ox = n.tray && trayBox ? trayBox.x : 0, oy = n.tray && trayBox ? trayBox.y : 0; + e.style.transform = 'translate(' + r1(n.x - ox) + 'px,' + r1(n.y - oy) + 'px)'; + txt(e._name, n.name); + txt(e._sub, !n.observed ? 'not observed' : (n.tray ? 'not in scope' : n.count + ' resources')); + e._badge.style.display = badged(n) ? 'block' : 'none'; + var label = n.name + ' — ' + (n.observed ? n.state : 'not observed from this account'); + at(e, 'aria-label', label); + at(e, 'title', label); + } + + function renderNodes() { + var want = {}; + nodes.forEach(function (n) { + want[n.id] = true; + var e = elById[n.id]; + if (!e) { e = makeNode(n); elById[n.id] = e; } + n.el = e; + syncNode(n, e); + }); + Object.keys(elById).forEach(function (id) { + if (want[id]) return; + var e = elById[id]; + if (e.parentNode) e.parentNode.removeChild(e); + delete elById[id]; + }); + } + + /** One cubic bezier per edge, leaving the source horizontally and arriving + * horizontally — the whole reason the picture reads as flow. + * + * Forward (target in a later column): both control points sit on the + * endpoints' own y, offset horizontally by half the run, + * C (sx+c, sy) (ex-c, ey) (ex, ey) c = clamp(|dx| * 0.5, 34, 130) + * The floor keeps a short hop from collapsing into a straight line; the cap + * stops the long networking→runtime run from ballooning into an S. Because c + * depends only on dx, several edges out of one card leave at the same angle + * and separate as they travel (fan-out), and several edges into one card + * converge on the same tangent (merge) — no lane bookkeeping needed. + * + * Backward / same-column (auth→identity, orchestrator→its A2A agents): there + * is no forward room, so both control points bulge to the RIGHT of both + * cards by the edge's lane offset and the curve comes back into the target's + * right edge. Lanes keep parallel loops off each other, deterministically. + * + * Straight is a bezier too (collinear controls): never emit an L/Q elbow, + * the smoothstep geometry is exactly what v4 is replacing. */ + function bezier(sx, sy, ex, ey, bulge) { + var c1x, c2x; + if (bulge) { c1x = sx + bulge; c2x = ex + bulge; } + else { + var c = clamp(Math.abs(ex - sx) * 0.5, 34, 130); + c1x = sx + c; c2x = ex - c; + } + return 'M' + r1(sx) + ',' + r1(sy) + + 'C' + r1(c1x) + ',' + r1(sy) + ' ' + r1(c2x) + ',' + r1(ey) + ' ' + r1(ex) + ',' + r1(ey); + } + + function edgeKind(e) { + if (e.kind === 'trust') return 'trust'; + if (!e.a.observed || !e.b.observed) return 'unobserved'; + if (e.b.state === 'in-progress') return 'building'; + if (e.a.state === 'deployed' && e.b.state === 'deployed') return 'live'; + return 'default'; + } + + function renderEdges() { + var want = {}; + edges.forEach(function (e) { + want[e.id] = true; + var rec = edgeElById[e.id]; + if (!rec) { + rec = { path: sv('path', 'rf-edge', edgeLayer), label: null }; + edgeElById[e.id] = rec; + } + var kind = edgeKind(e), d; + if (e.kind === 'trust') { + // Cross-account: one dashed accent run down between the two boxes. + // Vertical, so the control points are offset in y instead of x. + var tsx = e.a.x + e.a.w / 2, tsy = e.a.y + e.a.h; + var tex = e.b.x + e.b.w / 2, tey = e.b.y; + var tc = Math.max(24, Math.abs(tey - tsy) * 0.45); + d = 'M' + r1(tsx) + ',' + r1(tsy) + + 'C' + r1(tsx) + ',' + r1(tsy + tc) + ' ' + r1(tex) + ',' + r1(tey - tc) + + ' ' + r1(tex) + ',' + r1(tey); + } else { + var sx = e.a.x + e.a.w, sy = e.a.y + e.a.h / 2; + d = bezier(sx, sy, + e.back ? e.b.x + e.b.w : e.b.x, e.b.y + e.b.h / 2, + e.back ? Math.max(28, (e.lane || sx + 40) - sx) : 0); + } + at(rec.path, 'd', d); + at(rec.path, 'class', 'rf-edge rf-edge--' + kind); + at(rec.path, 'marker-end', 'url(#rf-mk-' + kind + ')'); + if (e.label) { + if (!rec.label) rec.label = sv('text', 'rf-edge-label', edgeLayer); + at(rec.label, 'x', r1((e.a.x + e.a.w / 2 + e.b.x + e.b.w / 2) / 2)); + at(rec.label, 'y', r1((e.a.y + e.a.h + e.b.y) / 2 - 6)); + txt(rec.label, e.label); + } + }); + Object.keys(edgeElById).forEach(function (id) { + if (want[id]) return; + var rec = edgeElById[id]; + [rec.path, rec.label].forEach(function (x) { if (x && x.parentNode) x.parentNode.removeChild(x); }); + delete edgeElById[id]; + }); + } + + /** One-shot arrival/failure feedback. CSS owns the animation; the class is + * removed on animationend so a later transition can fire it again. */ + function flash(n, cls) { + var e = n.el; + if (!e || !e.classList) return; + e.classList.add(cls); + var done = function () { if (e.classList) e.classList.remove(cls); }; + if (typeof e.addEventListener === 'function') e.addEventListener('animationend', done, { once: true }); + later(done, 1400); // belt and braces: reduced-motion never fires animationend + } + + // ------------------------------------------------------------------ details + function row(parent, k, v) { + var r = el('div', 'rf-row', parent); + txt(el('span', 'rf-row-k', r), k); + txt(el('span', 'rf-row-v', r), v); + } + + function renderDetails() { + var n = selectedId ? nodes.filter(function (x) { return x.id === selectedId; })[0] : null; + detailsEl.innerHTML = ''; + detailsEl.style.display = n ? 'block' : 'none'; + if (!n) return; + // Real DOM text, so a workshop attendee can select and copy an ARN out of + // the diagram — impossible in the canvas version, and half the reason for v3. + txt(el('div', 'rf-details-title', detailsEl), n.key); + row(detailsEl, 'module', (n.module || '?') + (n.team ? ' · ' + n.team + ' team' : '')); + row(detailsEl, 'state', n.observed + ? n.state + (n.status ? ' (' + n.status + ')' : '') + : 'not observed from this account'); + if (n.observed) row(detailsEl, 'resources', String(n.count)); + if (n.description) row(detailsEl, 'about', n.description); + Object.keys(n.outputs).slice(0, 6).forEach(function (k) { + row(detailsEl, k, String(n.outputs[k])); + }); + } + + // -------------------------------------------------------------------- view + function applyView() { + viewport.style.transform = 'translate(' + r1(view.tx) + 'px,' + r1(view.ty) + 'px) scale(' + r1(view.k * 100) / 100 + ')'; + } + + function measurePane() { + W = Math.max(320, (pane && pane.clientWidth) || (root && root.clientWidth) || 900); + H = Math.max(240, (pane && pane.clientHeight) || (root && root.clientHeight) || 560); + } + + /** Centre and, if needed, shrink the whole drawing. Skipped once the user has + * panned or zoomed — their view wins until they hit fit/reset or double-click. */ + function fitView(force) { + if (view.userAdjusted && !force) { applyView(); return; } + measurePane(); + var pad = 24; + view.k = clamp(Math.min((W - pad * 2) / bounds.w, (H - pad * 2) / bounds.h), MIN_K, MAX_K); + view.tx = (W - bounds.w * view.k) / 2 - bounds.x * view.k; + view.ty = (H - bounds.h * view.k) / 2 - bounds.y * view.k; + view.userAdjusted = false; + applyView(); + } + + function zoomTo(k, cx, cy) { + k = clamp(k, MIN_K, MAX_K); + view.tx = cx - (cx - view.tx) * (k / view.k); + view.ty = cy - (cy - view.ty) * (k / view.k); + view.k = k; + view.userAdjusted = true; + applyView(); + } + + // -------------------------------------------------------------- interaction + function rect(e) { + try { return e.getBoundingClientRect(); } catch (err) { return { left: 0, top: 0 }; } + } + + function paneXY(ev) { + var r = rect(pane); + return { x: (ev.clientX || 0) - (r.left || 0), y: (ev.clientY || 0) - (r.top || 0) }; + } + + function worldXY(ev) { + var p = paneXY(ev); + return { x: (p.x - view.tx) / view.k, y: (p.y - view.ty) / view.k }; + } + + /** Walk up to the card element. No closest()/dataset so the same code path is + * exercised by the headless driver as by the browser. */ + function nodeAt(target) { + for (var e = target; e; e = e.parentNode) { + if (e._rf) return nodes.filter(function (n) { return n.id === e._rf; })[0] || null; + } + return null; + } + + function select(id) { + selectedId = id; + nodes.forEach(function (n) { if (n.el) syncNode(n, n.el); }); + renderDetails(); + } + + /** Overlays (details panel, controls) live inside the pane so they stay put + * while it pans, which means their clicks bubble here. Without this + * guard, selecting text in the details panel panned the view and the mouseup + * deselected the very card you were copying an ARN out of. */ + function inOverlay(target) { + for (var e = target; e; e = e.parentNode) { if (e._overlay) return true; } + return false; + } + + function onDown(ev) { + if (inOverlay(ev.target)) return; + var hit = nodeAt(ev.target); + drag.moved = false; + if (hit) { + var w = worldXY(ev); + drag.node = hit; + drag.dx = w.x - hit.x; + drag.dy = w.y - hit.y; + if (hit.el && hit.el.classList) hit.el.classList.add('is-dragging'); + if (ev.preventDefault) ev.preventDefault(); // no text selection mid-drag + return; + } + var p = paneXY(ev); + pan.on = true; pan.moved = false; pan.x = p.x; pan.y = p.y; + if (pane.classList) pane.classList.add('is-panning'); + } + + function onMove(ev) { + if (drag.node) { + var w = worldXY(ev); + var n = drag.node; + n.x = w.x - drag.dx; + n.y = w.y - drag.dy; + drag.moved = true; + syncNode(n, n.el); + return; + } + if (!pan.on) return; + var p = paneXY(ev); + view.tx += p.x - pan.x; view.ty += p.y - pan.y; + pan.x = p.x; pan.y = p.y; + pan.moved = true; + view.userAdjusted = true; // a hand-panned view is not auto-fitted again + applyView(); + } + + function onUp() { + var n = drag.node; + if (n) { + if (n.el && n.el.classList) n.el.classList.remove('is-dragging'); + if (drag.moved) { n.dropped = true; syncNode(n, n.el); } + else { select(n.id === selectedId ? null : n.id); } + drag.node = null; + measureContent(); + renderEdges(); + } else if (pan.on && !pan.moved) { + select(null); // a click on empty pane, not the end of a pan + } + pan.on = false; + if (pane.classList) pane.classList.remove('is-panning'); + } + + function onWheel(ev) { + if (ev.preventDefault) ev.preventDefault(); + var p = paneXY(ev); + zoomTo(view.k * Math.exp(-(ev.deltaY || 0) * 0.0015), p.x, p.y); + } + + function onDblClick() { fitView(true); } + + function onKey(ev) { + var n = nodeAt(ev.target); + if (!n) return; + if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') { + if (ev.preventDefault) ev.preventDefault(); + select(n.id === selectedId ? null : n.id); + } + } + + function resetLayout() { + nodes.forEach(function (n) { n.dropped = false; }); + layout(); + render(); + fitView(true); + } + + function button(parent, label, fn, glyph) { + var b = el('button', 'rf-btn', parent); + at(b, 'type', 'button'); + at(b, 'aria-label', label); + at(b, 'title', label); + txt(b, glyph); + on(b, 'click', fn); + return b; + } + + // ------------------------------------------------------------------ legend + var LEGEND = [ + ['deployed', 'green', 'CloudFormation reports the stack complete'], + ['in progress', 'amber', 'deploying now — border pulses, incoming edges march'], + ['failed', 'red', 'failed or rolled back'], + ['not deployed', 'text3', 'in scope, not there yet'], + ['not in scope', 'gray', 'not applicable for this configuration — shown in the bottom tray'], + ['not observed', 'gray', 'lives in another account — this dashboard polls only one'] + ]; + + function renderLegend() { + if (!legendEl || typeof legendEl.innerHTML !== 'string') return; + // Swatches are rounded squares because that is now what a card shows: the + // state colour lives in the icon tile, not in a border. + var rows = LEGEND.map(function (r) { + var dashed = (r[0] === 'not observed' || r[0] === 'not deployed'); + return '
' + + '' + + '' + r[0] + + ' — ' + r[2] + '
'; + }).join(''); + legendEl.innerHTML = rows + + '
' + + '' + + 'badge — ' + + 'logs + traces delivered to the observability stack (drawn as a badge, not as edges, ' + + 'so the diagram stays readable)
' + + '
' + + 'The colour is in each card\u2019s icon square; the glyph says which layer it ' + + 'belongs to (base, identity, service, runtime, watching).
' + + 'This reflects CloudFormation status on a ~15s poll, not live traffic — there is ' + + 'no request-level data here.
' + + 'Drag a card to move it — it sticks where you drop it, including across polls. ' + + 'Click a card to select it and read its outputs · click the background to deselect · ' + + 'drag the background to pan · scroll to zoom · zoom, fit and reset buttons bottom-left · ' + + 'double-click the background to fit.
'; + } + + // ------------------------------------------------------------------ sizing + function onResize() { + measurePane(); + fitView(); + } + + // ---------------------------------------------------------------- scaffold + function markers() { + var defs = sv('defs', null, svg); + ['default', 'live', 'building', 'trust', 'unobserved'].forEach(function (kind) { + var m = sv('marker', 'rf-mk rf-mk--' + kind, defs); + at(m, 'id', 'rf-mk-' + kind); + at(m, 'viewBox', '0 0 8 8'); + at(m, 'refX', '7'); + at(m, 'refY', '4'); + at(m, 'markerWidth', '5'); + at(m, 'markerHeight', '5'); + at(m, 'orient', 'auto'); + at(sv('path', null, m), 'd', 'M0,0 L8,4 L0,8 z'); + }); + } + + function build() { + root.innerHTML = ''; + pane = el('div', 'rf-pane', root); + viewport = el('div', 'rf-viewport', pane); + groupLayer = el('div', 'rf-layer rf-groups', viewport); + trayEl = el('div', 'rf-tray', viewport); + trayLabel = el('div', 'rf-tray-label', trayEl); + vpcEl = el('div', 'rf-vpc', viewport); + txt(el('div', 'rf-vpc-label', vpcEl), 'VPC (private runtimes)'); + svg = sv('svg', 'rf-edges', viewport); + markers(); + edgeLayer = sv('g', 'rf-edge-layer', svg); + nodeLayer = el('div', 'rf-layer rf-nodes', viewport); + + guidanceEl = el('div', 'rf-guidance', pane); + detailsEl = el('div', 'rf-details', pane); + detailsEl.style.display = 'none'; + detailsEl._overlay = true; + + // Bare icon buttons in a row, bottom-left, with the guidance line beside + // them. "reset layout" earns its place even though the reference has no + // equivalent: dragging pins a card forever, so without it there is no way + // back to the computed layout. + var ctrl = el('div', 'rf-controls', pane); + ctrl._overlay = true; + at(ctrl, 'role', 'group'); + at(ctrl, 'aria-label', 'diagram controls'); + button(ctrl, 'zoom in', function () { zoomTo(view.k * 1.25, W / 2, H / 2); }, '+'); + button(ctrl, 'zoom out', function () { zoomTo(view.k / 1.25, W / 2, H / 2); }, '−'); + button(ctrl, 'fit view', function () { fitView(true); }, '⤢'); + button(ctrl, 'reset layout', resetLayout, '⟲'); + } + + function bind() { + on(pane, 'mousedown', onDown); + on(pane, 'wheel', onWheel, { passive: false }); + on(pane, 'dblclick', onDblClick); + on(pane, 'keydown', onKey); + // Move/up live on window so a fast drag that leaves the pane still tracks + // and still terminates — a stuck drag was the worst v1 interaction bug. + if (typeof window !== 'undefined') { + on(window, 'mousemove', onMove); + on(window, 'mouseup', onUp); + } + if (typeof ResizeObserver === 'function') { + ro = new ResizeObserver(onResize); + ro.observe(root); + } else if (typeof window !== 'undefined') { + on(window, 'resize', onResize); + } + } + + // --------------------------------------------------------------------- API + function mount(containerEl, legendElement) { + if (mounted) destroy(); // mount() twice is safe + if (!containerEl || typeof containerEl.appendChild !== 'function') return; + root = containerEl; + legendEl = legendElement || null; + build(); + bind(); + mounted = true; + measurePane(); + renderLegend(); + // mount() before any poll is normal (the tab can be opened first): render an + // empty pane now and let the queued or next update() fill it. + if (pendingStatus) { var q = pendingStatus; pendingStatus = null; update(q); } + else { render(); fitView(true); } + } + + function update(statusJson) { + if (!statusJson || typeof statusJson !== 'object') return; + if (!mounted) { pendingStatus = statusJson; return; } // tab not open yet + var keep = nodes; + try { + status = statusJson; + buildModel(statusJson); + renderLegend(); + } catch (e) { + // A malformed status.json must not kill the tab: keep the last good render + // and warn once, so a broken poller does not spam the console every 15s. + nodes = keep; + if (!warned && typeof console !== 'undefined' && console.warn) { + warned = true; + console.warn('ArchGraph: bad status.json, keeping last good render', e); + } + } + } + + function destroy() { + mounted = false; + listeners.forEach(function (l) { + try { l[0].removeEventListener(l[1], l[2], l[3]); } catch (e) { /* gone already */ } + }); + listeners = []; + timers.forEach(function (t) { clearTimeout(t); }); + timers = []; + if (ro && typeof ro.disconnect === 'function') { try { ro.disconnect(); } catch (e2) { /* gone */ } } + ro = null; + if (root) root.innerHTML = ''; + elById = {}; edgeElById = {}; + nodes.forEach(function (n) { n.el = null; }); + root = null; legendEl = null; pane = null; viewport = null; svg = null; + edgeLayer = null; groupLayer = null; nodeLayer = null; trayEl = null; + trayLabel = null; vpcEl = null; guidanceEl = null; detailsEl = null; + drag.node = null; pan.on = false; + // Card positions and `dropped` flags are deliberately KEPT: coming back to + // the tab should show the platform where you left it. + } + + window.ArchGraph = { mount: mount, update: update, destroy: destroy, TOPOLOGY: TOPOLOGY }; +})(); diff --git a/dashboard/public/index.html b/dashboard/public/index.html index c68f93c..27f188c 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -33,79 +33,6 @@ .tab-content{display:none;animation:fadeIn .4s} .tab-content.active{display:block} -/* APPROACH TAB */ -.hero{text-align:center;padding:5rem 2rem 4rem;background:radial-gradient(ellipse at 50% 0%,rgba(255,153,0,.08) 0%,transparent 60%)} -.hero-badge{display:inline-block;background:rgba(255,153,0,.12);color:var(--accent);padding:.35rem 1rem;border-radius:20px;font-size:.8rem;font-weight:600;letter-spacing:.5px;margin-bottom:1.5rem;border:1px solid rgba(255,153,0,.2)} -.hero h1{font-size:3rem;font-weight:800;line-height:1.15;margin-bottom:1rem;background:linear-gradient(135deg,#fff 0%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text} -.hero p{font-size:1.25rem;color:var(--text2);max-width:640px;margin:0 auto} - -.section{max-width:1200px;margin:0 auto;padding:4rem 2rem} -.section-label{color:var(--accent);font-size:.75rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:.5rem} -.section h2{font-size:2rem;font-weight:700;margin-bottom:1rem} -.section p.desc{color:var(--text2);max-width:700px;margin-bottom:2rem;font-size:1.05rem} - -.problem-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem} -.problem-card{background:var(--bg2);border:1px solid rgba(239,68,68,.15);border-radius:var(--radius);padding:1.75rem} -.problem-card .icon{font-size:1.5rem;margin-bottom:.75rem} -.problem-card h3{font-size:1.1rem;font-weight:600;margin-bottom:.5rem} -.problem-card p{color:var(--text2);font-size:.9rem} - -.solution-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem} -.solution-card{background:var(--bg2);border:1px solid rgba(34,197,94,.15);border-radius:var(--radius);padding:1.75rem} -.solution-card .icon{font-size:1.5rem;margin-bottom:.75rem} -.solution-card h3{font-size:1.1rem;font-weight:600;margin-bottom:.5rem} -.solution-card p{color:var(--text2);font-size:.9rem} - -/* ARCHITECTURE DIAGRAM */ -.arch{display:flex;flex-direction:column;gap:1rem;max-width:900px;margin:0 auto} -.arch-layer{display:flex;align-items:center;gap:1rem;background:var(--bg2);border-radius:var(--radius);padding:1.25rem 1.5rem;border-left:4px solid var(--accent);position:relative} -.arch-layer::after{content:'';position:absolute;left:50%;bottom:-1rem;width:2px;height:1rem;background:var(--bg4)} -.arch-layer:last-child::after{display:none} -.arch-num{background:var(--accent);color:#000;width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:.85rem;flex-shrink:0} -.arch-info{flex:1} -.arch-info h4{font-weight:600;font-size:1rem;margin-bottom:.2rem} -.arch-info p{color:var(--text2);font-size:.85rem} -.arch-stacks{display:flex;gap:.5rem;flex-wrap:wrap} -.arch-stacks span{background:var(--bg4);padding:.25rem .6rem;border-radius:6px;font-size:.75rem;color:var(--text2);font-weight:500} - -/* WORKSHOP FLOW */ -.flow-days{display:grid;grid-template-columns:1fr 1fr;gap:2rem} -@media(max-width:768px){.flow-days{grid-template-columns:1fr}} -.flow-day{background:var(--bg2);border-radius:var(--radius);padding:2rem;border:1px solid var(--bg4)} -.flow-day h3{font-size:1.25rem;font-weight:700;margin-bottom:.25rem} -.flow-day .subtitle{color:var(--text2);font-size:.9rem;margin-bottom:1.5rem} -.flow-module{display:flex;gap:1rem;padding:1rem 0;border-top:1px solid var(--bg4)} -.flow-module:first-of-type{border-top:none} -.flow-module .num{background:var(--bg4);color:var(--accent);width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.8rem;flex-shrink:0} -.flow-module h4{font-weight:600;font-size:.95rem;margin-bottom:.15rem} -.flow-module p{color:var(--text2);font-size:.8rem} - -/* PROFILES */ -.profiles-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.25rem} -.profile-card{background:var(--bg2);border-radius:var(--radius);padding:1.5rem;border:1px solid var(--bg4);transition:border-color .25s} -.profile-card:hover{border-color:var(--accent)} -.profile-card .emoji{font-size:2rem;margin-bottom:.75rem} -.profile-card h3{font-size:1rem;font-weight:600;margin-bottom:.5rem} -.profile-card p{color:var(--text2);font-size:.8rem;margin-bottom:.75rem} -.profile-modules{display:flex;flex-wrap:wrap;gap:.35rem} -.profile-modules span{background:rgba(255,153,0,.1);color:var(--accent);padding:.15rem .5rem;border-radius:4px;font-size:.7rem;font-weight:500} - -/* TEAMS */ -.teams-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1.5rem} -.team-card{background:var(--bg2);border-radius:var(--radius);padding:1.75rem;border:1px solid var(--bg4)} -.team-card h3{font-size:1.1rem;font-weight:600;margin-bottom:.25rem} -.team-card .role{color:var(--accent);font-size:.85rem;margin-bottom:1rem} -.team-stacks{display:flex;flex-direction:column;gap:.5rem} -.team-stack{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--text2)} -.team-stack .dot{width:6px;height:6px;border-radius:50%;background:var(--accent)} - -/* TAKE HOME */ -.artifacts-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1.25rem} -.artifact{background:var(--bg2);border-radius:var(--radius);padding:1.5rem;border:1px solid var(--bg4)} -.artifact .icon{font-size:1.5rem;margin-bottom:.75rem} -.artifact h3{font-size:1rem;font-weight:600;margin-bottom:.35rem} -.artifact p{color:var(--text2);font-size:.85rem} - /* MONITOR TAB */ .monitor-offline{text-align:center;padding:6rem 2rem} .monitor-offline .icon{font-size:3rem;margin-bottom:1rem} @@ -185,231 +112,16 @@ - -
- - -
-
☁️ Amazon Bedrock AgentCore
-

AgentCore Platform &
Security Accelerator

-

2-Day Hands-On Workshop — Go from architecture design to production-deployed AI agents with enterprise-grade security, identity, and observability.

-
- - -
- -

The Problem

-

Enterprises are stuck between prototype and production. AI agent PoCs sit idle for 3–6 months while teams navigate scattered security reviews, identity integration, and infrastructure decisions.

-
-
-
-

3–6 Month Delays

-

Security reviews, compliance checks, and infrastructure decisions create months of back-and-forth before a single agent reaches production.

-
-
-
🔀
-

Scattered Reviews

-

Identity, networking, encryption, and observability are handled by different teams with no unified blueprint — leading to inconsistent implementations.

-
-
-
🚧
-

Prototype Graveyard

-

Promising agent PoCs never make it past the demo stage. Without a clear path to production, innovation stalls and momentum is lost.

-
-
-
- - -
- -

The Solution

-

A structured 2-day sprint with modular, progressive deployment. Every stack builds on the last, and every decision is made with production in mind from day one.

-
-
-
🏗️
-

Modular Architecture

-

10 independent CDK stacks that compose into a complete platform. Deploy what you need, skip what you don't.

-
-
-
🔐
-

Security-First

-

KMS encryption, VPC endpoints, SCPs, and enterprise identity integration baked in from the foundation layer — not bolted on later.

-
-
-
-

2 Days to Production

-

From architecture whiteboard to deployed, observable, secure AI agents — with all the IaC, runbooks, and documentation to maintain it.

-
-
-
- - -
- -

Progressive Deployment Layers

-

Each layer builds on the one below. Deploy bottom-up for a complete platform, or pick the layers you need.

-
-
-
5
-

Observability

Tracing, monitoring, and alerting pipeline

-
observability
-
-
-
4
-

Runtime

Agent execution, orchestration, and A2A connectivity

-
runtime-orchestratorruntime-code-agentruntime-research-agent
-
-
-
3
-

Service

API gateway and managed memory for agents

-
gatewaymemory
-
-
-
2
-

Identity

Authentication, authorization, and enterprise SSO

-
authidentity
-
-
-
1
-

Foundation

Networking, security controls, and encryption

-
networkingsecurity
-
-
-
- - -
- -

Workshop Flow

-

Two focused days that take you from design to deployment.

-
-
-

Day 1 — Foundation & Design

-

Architecture decisions, identity integration, core infrastructure

-
1

Platform Architecture

Multi-account strategy, networking, security controls, KMS encryption

-
2

Identity & Auth

Cognito setup, enterprise IdP integration (Entra ID / Okta), OAuth flows

-
3

Gateway & Memory

MCP gateway with Lambda/OpenAPI targets, managed memory configuration

-
4

Design Review

Architecture review, threat modeling, deployment planning for Day 2

-
-
-

Day 2 — Build & Validate

-

Agent deployment, A2A connectivity, observability, hardening

-
5

Runtime Deployment

Deploy orchestrator agent, code agent, and research agent to AgentCore Runtime

-
6

Agent-to-Agent

Configure A2A connectivity, multi-agent orchestration patterns

-
7

Observability

OpenTelemetry tracing, CloudWatch dashboards, alerting pipeline

-
8

Security Hardening

SCPs, VPC endpoints, PrivateLink, final security review and validation

-
-
-
- - -
- -

Customer Profiles

-

Every workshop is tailored. Select the profile that matches your starting point — we'll customize the module selection accordingly.

-
-
-
🌱
-

Greenfield

-

Starting fresh with AI agents. Need the full platform from scratch.

-
networkingsecurityauthidentitygatewaymemoryruntimeobservability
-
-
-
🔄
-

Migration

-

Moving existing agents from Bedrock Agents or custom infra to AgentCore.

-
identitygatewaymemoryruntimeobservability
-
-
-
🤖
-

Multi-Agent

-

Building complex multi-agent systems with A2A orchestration.

-
authidentitygatewayruntimeobservability
-
-
-
🏢
-

Platform Team

-

Building a shared agent platform for multiple development teams.

-
networkingsecurityauthidentitygatewaymemoryobservability
-
-
-
🛡️
-

Security-Focused

-

Hardening an existing deployment with enterprise security controls.

-
networkingsecurityauthidentityobservability
-
-
-
- - -
- -

Team Workstreams

-

Three teams work in parallel, each owning a set of stacks. Dependencies are managed through SSM parameters published by each stack.

-
-
-

🏗️ Platform Team

-
Infrastructure & Foundation
-
-
networking — VPC, subnets, endpoints
-
security — KMS keys, SCPs, guardrails
-
auth — Cognito user pool, app clients
-
identity — AgentCore identity, IdP integration
-
observability — Tracing, dashboards, alerts
-
-
-
-

🤖 Agent Team

-
Agent Development & Deployment
-
-
gateway — MCP gateway, tool targets
-
memory — Semantic & event memory
-
runtime-orchestrator — Main orchestrator agent
-
runtime-code-agent — Code execution agent
-
runtime-research-agent — Research agent
-
-
-
-

🛡️ Security Team

-
Security Review & Hardening
-
-
Review all stack outputs for compliance
-
Validate KMS encryption configuration
-
Verify VPC endpoint policies
-
Audit IAM roles and permissions
-
Threat model the agent architecture
-
-
-
-
- - -
- -

What You Take Home

-

Everything you need to operate and evolve your agent platform after the workshop.

-
-
📦

Production IaC

10 CDK stacks — fully parameterized, tested, and ready for your CI/CD pipeline.

-
🤖

Deployed Agents

Working orchestrator, code, and research agents on AgentCore Runtime with A2A connectivity.

-
🔐

Security Baseline

KMS encryption, VPC endpoints, SCPs, and enterprise identity integration — all configured.

-
📊

Observability Pipeline

OpenTelemetry tracing, CloudWatch dashboards, and alerting — ready for production traffic.

-
📋

Architecture Decision Records

Documented decisions, trade-offs, and rationale for every architectural choice made.

-
🗺️

Expansion Roadmap

Prioritized backlog for adding agents, integrations, and capabilities post-workshop.

-
-
- -
- -
+
@@ -454,6 +166,43 @@

📌 Published SSM Parameters

+ + +
+
+
+
+

Live architecture

+

+ Cards are CloudFormation stacks, positioned by layer and grouped by account. + Drag a card to move it, drag the background to pan, scroll to zoom. +

+
+
+
+
+
+ +
+
+
+
+
+ +
+ + +