diff --git a/.gitea/workflows/kb-health.yaml b/.gitea/workflows/kb-health.yaml
new file mode 100644
index 0000000..8710089
--- /dev/null
+++ b/.gitea/workflows/kb-health.yaml
@@ -0,0 +1,77 @@
+# Weekly KB health check — validates catalog links and reports broken URLs.
+# Runs Monday 8am UTC. Does NOT block deployments or affect user experience.
+# If broken links found, creates a Gitea issue for manual review.
+name: KB Health Check
+
+on:
+ schedule:
+ - cron: '0 8 * * 1'
+ workflow_dispatch:
+
+jobs:
+ check-links:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install dependencies
+ run: pip install requests beautifulsoup4 pyyaml
+
+ - name: Check Architecture Center links
+ id: check
+ continue-on-error: true
+ run: |
+ python tools/refresh_arch_catalog.py --check-links 2>&1 | tee /tmp/link-check.txt
+ echo "exit_code=$?" >> $GITHUB_OUTPUT
+
+ - name: Check SKU catalog freshness
+ id: sku
+ continue-on-error: true
+ run: |
+ python tools/refresh_sku_catalog.py --validate 2>&1 | tee -a /tmp/link-check.txt
+
+ - name: Create issue if problems found
+ if: steps.check.outcome == 'failure'
+ run: |
+ BROKEN=$(grep -c "❌" /tmp/link-check.txt || echo "0")
+ REDIRECTED=$(grep -c "↪️" /tmp/link-check.txt || echo "0")
+ BODY=$(cat <<'ISSUE_EOF'
+ ## KB Health Check — Broken Links Found
+
+ **Run date:** $(date -u +%Y-%m-%d)
+ **Broken:** $BROKEN URLs
+ **Redirected:** $REDIRECTED URLs
+
+
+ Full report
+
+ ```
+ $(cat /tmp/link-check.txt)
+ ```
+
+
+ ### Action needed
+ - For 404s: remove entry or find new URL via `python tools/refresh_arch_catalog.py --whats-new`
+ - For redirects: update URL in `kb/architecture-center/catalog.yaml`
+ ISSUE_EOF
+ )
+ # Note: Gitea issue creation requires gitea CLI or API call
+ # For now, save report as artifact
+ echo "$BODY" > /tmp/kb-health-report.md
+ echo "::warning::$BROKEN broken links found in KB catalog"
+
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: kb-health-report
+ path: /tmp/link-check.txt
+ retention-days: 30
diff --git a/README.md b/README.md
index 7fc54a6..a8c02a7 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,34 @@ During **Phase 2 (DESIGN)**, the skill automatically matches the proposed archit
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
python tools/refresh_arch_catalog.py --url # add a single entry
python tools/refresh_arch_catalog.py --validate # validate catalog integrity
+python tools/refresh_arch_catalog.py --check-links # check for broken URLs (404s, redirects)
+```
+
+### KB Health & Freshness
+
+The KB is automatically monitored for staleness and broken links:
+
+| Check | How | When |
+|---|---|---|
+| **Broken links** | `python tools/refresh_arch_catalog.py --check-links` | Weekly (CI), or on demand |
+| **Stale prices** | `python tools/refresh_sku_catalog.py --validate` | Weekly (CI), or on demand |
+| **KB freshness** | `python tools/kb_freshness.py --check` | On skill startup (pre-flight) |
+| **Diagram quality** | `python scripts/validate-diagram.py ` | After every diagram generation |
+
+**Automated CI/CD:**
+- **Deploy workflow** (`.gitea/workflows/deploy.yaml`) — auto-deploys to MCP server on every push to `main`
+- **KB health workflow** (`.gitea/workflows/kb-health.yaml`) — runs every Monday 8am UTC, checks all catalog URLs and SKU freshness, reports broken links as artifacts
+
+To run KB health manually:
+```bash
+# Check all 123 Architecture Center URLs for 404s
+python tools/refresh_arch_catalog.py --check-links
+
+# Fix broken links: re-crawl What's New for updated URLs
+python tools/refresh_arch_catalog.py --whats-new
+
+# Refresh stale pricing from Oracle API
+python tools/refresh_sku_catalog.py --refresh --diff
```
### Feature Compatibility Matrix
@@ -296,12 +324,16 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
# Architecture diagram
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
+# Validate diagram quality (icon sizes, overlaps, container overflow)
+python scripts/validate-diagram.py arch.drawio
+
# Output orchestrator (multiple formats at once)
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
# Architecture Center catalog
python tools/refresh_arch_catalog.py --validate
python tools/refresh_arch_catalog.py --whats-new
+python tools/refresh_arch_catalog.py --check-links
# Feature compatibility
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
diff --git a/SKILL.md b/SKILL.md
index 28627b5..7849908 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -15,32 +15,54 @@ You follow the **Oracle ECAL framework** (Define → Design → Deliver) to prod
When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu.
-### Pre-flight: KB freshness check
+### Pre-flight checks
-**Before showing the welcome message**, run `python tools/kb_freshness.py --check --json` and parse the JSON output. Behavior based on the result:
+Run these checks silently **before** showing the welcome message. **CRITICAL:** Never show command execution, tool output, or errors to the user. If any check fails, silently skip it and proceed to the welcome message. These checks are informational — they NEVER block the user.
-- **`stale_count == 0`** → proceed directly to the welcome message. No banner.
-- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
+#### Check 1: KB changelog banner
+
+Read the file `kb/CHANGELOG.md` and extract the **most recent date and first bullet point**. If the file exists and has entries, prepend a one-line banner above the welcome message:
+
+```
+📢 KB updated ():
+```
+
+Example: `📢 KB updated (Apr 14): Diagram generator calibrated from 37 Oracle ref architectures`
+
+If the file is missing or empty, skip — no banner.
+
+#### Check 2: Local repo updates (git users only)
+
+Run `git fetch --dry-run origin main 2>/dev/null`. If the output contains any text (meaning there are remote commits not yet pulled), prepend this banner:
+
+```
+📢 KB updates available — run `git pull` to get latest prices and fixes.
+```
+
+If the command fails (not a git repo, no network, MCP deployment), silently skip. This check is only relevant for users running the skill from a local git clone.
+
+#### Check 3: KB freshness
+
+Run `python3 tools/kb_freshness.py --check --json 2>/dev/null` and parse the JSON output. Behavior:
+
+- **`stale_count == 0`** → no banner.
+- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend banner and ask inline:
```
⚠️ KB freshness: file(s) outdated (oldest: — d).
- can be auto-refreshed (SKU catalog, Architecture Center).
- Refresh now before showing the menu? [y/N]
+ can be auto-refreshed. Refresh now? [y/N]
```
- - If the user replies `y` / `yes` / `sí` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu.
- - If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.`
+ - `y` / `yes` / `sí` → run `python3 tools/kb_freshness.py --auto-refresh`, then show menu.
+ - Anything else → show menu with one-line reminder: `⚠️ KB file(s) stale — run python3 tools/kb_freshness.py --auto-refresh later.`
-- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask:
+- **`stale_count > 0` but no `refreshable: true`** → non-blocking info banner, then show menu directly:
```
⚠️ KB freshness: file(s) need manual review (oldest: — d).
- No automated refresh available — see kb/README.md for review process.
```
- Then show the menu directly.
-
-**Important**: this check is informational, not gating. If `kb_freshness.py` errors out (exit 2 or missing tool), silently fall back to showing the welcome message — never block the user on tooling failures.
+If `kb_freshness.py` errors out (exit ≠ 0, missing python, missing tool), **silently skip** — no error output, no banner, no mention of failure.
### Welcome Message
diff --git a/examples/output-demo-pharma-mx/diagram-spec.yaml b/examples/output-demo-pharma-mx/diagram-spec.yaml
index 6db3084..3725188 100644
--- a/examples/output-demo-pharma-mx/diagram-spec.yaml
+++ b/examples/output-demo-pharma-mx/diagram-spec.yaml
@@ -77,20 +77,14 @@ tenancy:
- id: "drg"
label: "DRG"
type: "drg"
- w: 90
- h: 60
# IGW — whitelist: public subnet requires it
- id: "igw"
label: "Internet\nGateway"
type: "igw"
- w: 100
- h: 60
# SGW — whitelist: ExaCS backup requires it
- id: "sgw"
label: "Service\nGateway"
type: "service_gateway"
- w: 100
- h: 60
# ── DR Region: Sao Paulo ──
- id: "region_gru"
diff --git a/examples/output-demo-pharma-mx/opt02-architecture.drawio b/examples/output-demo-pharma-mx/opt02-architecture.drawio
index e06e8ae..678fe0a 100644
--- a/examples/output-demo-pharma-mx/opt02-architecture.drawio
+++ b/examples/output-demo-pharma-mx/opt02-architecture.drawio
@@ -4,173 +4,173 @@
-
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kb/CHANGELOG.md b/kb/CHANGELOG.md
new file mode 100644
index 0000000..db3422c
--- /dev/null
+++ b/kb/CHANGELOG.md
@@ -0,0 +1,19 @@
+# KB Changelog
+
+Recent changes to the Knowledge Base. The skill shows the latest entry in the welcome banner.
+
+## 2026-04-14
+- Diagram generator: icon sizing calibrated from 37 Oracle Architecture Center .drawio files (63px services, 42px gateways)
+- Diagram generator: auto-sizing containers, DRG placement outside VCN, edge label offsets
+- Diagram generator: VCN/subnet dash pattern corrected to "4 2" (Oracle ref standard)
+- New: `scripts/validate-diagram.py` — automated diagram quality validation
+- New: `--check-links` in refresh_arch_catalog.py — detects broken URLs in catalog
+- New: weekly KB health CI workflow (`.gitea/workflows/kb-health.yaml`)
+- SKILL.md: anti-hallucination guardrails (closed whitelist + mandatory pre-generation review)
+- SKILL.md: structured data intake (Extraction Receipt + Completeness Gate)
+
+## 2026-04-01
+- KB validation: 30 corrections across 16 files (ExaCS storage, ECPU transition, IOPS fixes)
+
+## 2026-03-16
+- Architecture Center catalog refreshed: 123 entries verified
diff --git a/kb/diagram/oracle-ref-measurements.md b/kb/diagram/oracle-ref-measurements.md
new file mode 100644
index 0000000..488eca2
--- /dev/null
+++ b/kb/diagram/oracle-ref-measurements.md
@@ -0,0 +1,295 @@
+# Oracle Architecture Center Reference Diagram Measurements
+
+Extracted from official Oracle .drawio source files (draw.io/28.0.4, Visio-exported).
+
+Sources analyzed:
+1. Hub-and-Spoke DRG (hub-spoke-network-drg) - single-region, 3 VCNs, hub topology
+2. Essbase Cross-Region DR (cross-region-dr-essbase-oci) - dual-region, 3 ADs each, DR topology
+
+Tool: draw.io 28.0.4 (Electron), exported from Visio (.vsdx)
+
+---
+
+## 1. Canvas / Page Settings
+
+| Property | Hub-Spoke DRG | Essbase DR |
+|----------|--------------|------------|
+| pageWidth | 850 | 850 |
+| pageHeight | 1100 | 1100 |
+| gridSize | 10 | 10 |
+| Actual content W | 693 | 2339 |
+| Actual content H | 846 | 1154 |
+
+---
+
+## 2. Font Specifications
+
+| Usage | Font Family | Font Size | Style |
+|-------|------------|-----------|-------|
+| Region/VCN/service labels | Oracle Sans | 16.93px (~17px) | Bold for region title |
+| Annotation text | Oracle Sans | 14.82px (~15px) | Regular |
+| Label box height | - | 20px | - |
+| Annotation box height | - | 17px | - |
+
+---
+
+## 3. Color Palette
+
+| Element | Hex |
+|---------|-----|
+| OCI Region fill | #f5f4f2 |
+| OCI Region stroke | #9e9892 |
+| VCN / Subnet border (dashed) | #aa643b |
+| Connector lines / solid borders | #312d2a |
+| Service icon primary (teal) | #2d5967 |
+| Connector lines (Essbase) | #161513 |
+| AD fill (multi-AD diagrams) | #e4e1dd |
+| Background | #FFFFFF |
+| Accent brown | #ae562c |
+
+---
+
+## 4. Container Dimensions
+
+### 4.1 OCI Region
+- Hub-Spoke: 456x845 at (236,0), fill=#f5f4f2, stroke=#9e9892
+- Essbase Left: 995x849 at (0,305)
+- Essbase Right: 1080x849 at (1258,304)
+- Pattern: Double-rendered (fill layer + stroke layer as separate shapes)
+
+### 4.2 VCN
+- DMZ VCN (Hub-Spoke): 226x354 at (456,26)
+- Spoke VCN 1: 226x205 at (456,406)
+- Spoke VCN 2: 226x202 at (457,634)
+- Essbase left VCN: 894x766 at (30,355)
+- Essbase right VCN: 872x766 at (1328,354)
+- Style: strokeColor=#aa643b, strokeWidth=2, dashed=1, dashPattern=4.00 2.00
+
+### 4.3 Subnet
+- Management Subnet: 176x134 at (498,76)
+- Services Subnet: 176x134 at (498,238)
+- Workload Subnet 1: 175x143 at (500,458)
+- Workload Subnet 2: 175x142 at (500,686)
+- Style: Same as VCN (strokeColor=#aa643b, strokeWidth=2, dashed)
+
+### 4.4 Availability Domain (Essbase)
+- Width: 188, Height: 815
+- Positions: x=211, 425, 632 (left region), x=1508, 1724, 1930 (right region)
+- AD-to-AD gap: 18-28px (~25px avg)
+- Style: fill=#e4e1dd, stroke=#9e9892
+
+### 4.5 On-Premises Box (Essbase)
+- 260x251 at (997,0) - centered between two regions
+- Style: fill=#f5f4f2, stroke=#9e9892
+
+### 4.6 Workload Rows (Essbase)
+- Standard row: 807x111
+- Bottom row (expanded): 807x149
+- Y positions: 415, 552, 690, 828, 962
+- Row-to-row gap: 23-27px (~25px)
+
+### 4.7 Compartment Boxes (Essbase)
+- 189x49 - placed at AD/row intersections
+
+---
+
+## 5. Icon Dimensions
+
+### 5.1 Service Icons (VM, Bastion, Database, etc.)
+- 3-layer nesting: 63x63 outer > 61x61 middle > 60x60 inner
+- Standard service icon = 60px rendered in 63px box
+- Internal details: 13x13 grid cells, 43x13 bottom bar, 3x3 dots
+
+### 5.2 Gateway Icons (IGW, NAT, SGW, DRG attachments)
+- 3-layer nesting: 42x42 outer > 41x41 middle > 40x40 inner
+- Central symbol: 29x26, internal detail: 22x29
+- Status indicators: 4x4 dots, 5x1 bars
+
+### 5.3 OCI Toolkit Icons (Bastion-type)
+- 40x63 outer > 39x61 middle > 34x58 inner (portrait orientation)
+
+### 5.4 Database/Storage Icons
+- 59x63 outer > 58x61 middle > inner symbol 39x26 + 21x21
+
+### 5.5 External Icons (Internet, On-Premises)
+- Same 63x63 as service icons
+
+---
+
+## 6. Spacing Rules
+
+### 6.1 Vertical Spacing
+| Measurement | Value |
+|-------------|-------|
+| Region label Y offset | 7px from region top |
+| VCN/Subnet label Y offset | 8px from container top |
+| VCN top to first subnet | 50px |
+| Subnet-to-subnet gap | 28px |
+| VCN-to-VCN gap | 26px |
+| Workload row gap | 23-27px (~25px) |
+| Gateway Y within VCN | 29px from VCN top |
+
+### 6.2 Horizontal Spacing
+| Measurement | Value |
+|-------------|-------|
+| VCN to subnet left indent | 42-44px |
+| Service icon X within subnet | 80px from subnet left |
+| Gateway X from VCN left | 168px |
+| AD-to-AD gap | 18-28px (~25px) |
+| Inter-region gap (Essbase) | 263px |
+
+### 6.3 Label Spacing
+| Measurement | Value |
+|-------------|-------|
+| Label box height | 20px (standard) |
+| Annotation box height | 17px |
+| Line-to-line spacing | 17px |
+
+---
+
+## 7. Connector Styles
+
+Oracle Visio-exported .drawio files do NOT use draw.io native edges. Connectors are thin rectangles:
+- Horizontal: Wx1 rectangles, stroke=#312d2a
+- Vertical: 2xH or 1xH rectangles
+- Cross-region (Essbase): stroke=#161513
+- Examples:
+ - Internet-to-Gateway: 350x1 at (80,153)
+ - Mid-connections: 156x1 at (98,524)
+ - DR replication: 549x1 at (391,1045)
+ - Vertical link: 1x65 at (1128,365)
+
+---
+
+## 8. Border/Stroke Styles
+
+| Element | strokeColor | strokeWidth | dashed | dashPattern |
+|---------|------------|-------------|--------|-------------|
+| Region | #9e9892 | 1 | No | - |
+| VCN | #aa643b | 2 | Yes | 4.00 2.00 |
+| Subnet | #aa643b | 2 | Yes | 4.00 2.00 |
+| AD | #9e9892 | 1 | No | - |
+| Solid grouping | #312d2a | 2 | No | - |
+| Connector | #312d2a/#161513 | 1 | No | - |
+
+---
+
+## 9. Double-Layer Container Rendering
+
+Oracle renders each container as TWO overlapping shapes:
+1. Fill layer: fillColor=, strokeColor=none
+2. Stroke layer: fillColor=none, strokeColor=
+
+This pattern applies to Region, VCN, Subnet, and AD containers.
+
+---
+
+## 10. Key Proportional Ratios
+
+| Ratio | Value | Description |
+|-------|-------|-------------|
+| Gateway:Service icon | 42:63 = 0.67 | Gateways 2/3 size of service icons |
+| VCN:Subnet width | 226:176 = 1.28 | VCN ~28% wider than subnet |
+| VCN left padding | 42-44px | Subnet indent |
+| VCN top padding | 50px | To first subnet |
+| VCN bottom padding | ~22-26px | After last subnet |
+| Region aspect (hub-spoke) | 456:845 = 0.54 | Portrait |
+| AD aspect (multi-AD) | 188:815 = 0.23 | Very tall/narrow |
+| Workload row aspect | 807:111 = 7.27 | Very wide/short |
+
+---
+
+## 11. Recommended Calibration for oci_diagram_gen.py
+
+```yaml
+region:
+ min_width: 456
+ min_height: 845
+ fill: "#f5f4f2"
+ stroke: "#9e9892"
+ stroke_width: 1
+ corner_radius: 0
+
+vcn:
+ min_width: 226
+ typical_height_hub: 205
+ typical_height_dmz: 354
+ stroke: "#aa643b"
+ stroke_width: 2
+ dashed: true
+ dash_pattern: "4 2"
+
+subnet:
+ width: 176
+ typical_height: 134-143
+ stroke: "#aa643b"
+ stroke_width: 2
+ dashed: true
+ dash_pattern: "4 2"
+
+availability_domain:
+ width: 188
+ height: 815
+ fill: "#e4e1dd"
+ stroke: "#9e9892"
+
+service_icon:
+ outer: 63
+ rendered: 60
+
+gateway_icon:
+ outer: 42
+ rendered: 40
+ inner_symbol: 29x26
+
+spacing:
+ vcn_to_vcn_gap: 26
+ subnet_to_subnet_gap: 28
+ vcn_top_to_first_subnet: 50
+ vcn_left_to_subnet: 42
+ subnet_to_service_icon: 80
+ gateway_x_from_vcn_left: 168
+ gateway_y_from_vcn_top: 29
+ label_y_offset: 8
+ region_label_y: 7
+ workload_row_gap: 25
+ ad_to_ad_gap: 25
+ inter_region_gap: 263
+
+fonts:
+ family: "Oracle Sans"
+ label_size: 17
+ annotation_size: 15
+ label_box_height: 20
+ annotation_box_height: 17
+
+colors:
+ region_fill: "#f5f4f2"
+ region_stroke: "#9e9892"
+ vcn_stroke: "#aa643b"
+ subnet_stroke: "#aa643b"
+ ad_fill: "#e4e1dd"
+ icon_primary: "#2d5967"
+ text_dark: "#312d2a"
+ connector: "#312d2a"
+ background: "#FFFFFF"
+```
+
+---
+
+## 12. Layout Patterns
+
+### Single-Region Hub-Spoke
+- VCNs stacked vertically
+- External icons (Internet, On-Prem) at far left
+- Gateways between external and VCN
+- DRG centered with route table
+- Service icons right-aligned within subnets
+
+### Dual-Region DR
+- Regions side by side horizontally
+- On-Premises box centered above/between regions
+- ADs as vertical columns within each region
+- Workload tiers as horizontal rows crossing all ADs
+- Compartment boxes at AD-row intersections
+- DR arrows as horizontal lines between matching rows
diff --git a/scripts/validate-diagram.py b/scripts/validate-diagram.py
new file mode 100644
index 0000000..d3e5a06
--- /dev/null
+++ b/scripts/validate-diagram.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""
+Diagram quality validator — checks generated .drawio files against
+Oracle Architecture Center visual standards.
+
+Usage:
+ python3 scripts/validate-diagram.py examples/output-demo-pharma-mx/opt02-architecture.drawio
+"""
+
+import sys
+import xml.etree.ElementTree as ET
+
+MIN_ICON_H = 45 # Minimum icon height (px) — Oracle refs use 50-60
+MIN_ICON_W = 30 # Minimum icon width (px)
+MAX_EMPTY_RATIO = 0.7 # Max empty space ratio in a container
+MIN_LABEL_FONT = 10 # Minimum label font size
+MIN_GAP_PX = 5 # Minimum gap between sibling elements
+
+
+def validate(filepath: str) -> list:
+ """Return list of (severity, message) tuples."""
+ issues = []
+ tree = ET.parse(filepath)
+ root = tree.getroot()
+
+ icon_groups = []
+ containers = []
+ all_cells = {}
+
+ for cell in root.iter('mxCell'):
+ cid = cell.get('id', '')
+ style = cell.get('style', '')
+ value = cell.get('value', '')
+ geo = cell.find('mxGeometry')
+ all_cells[cid] = cell
+
+ if geo is None:
+ continue
+
+ w = float(geo.get('width', 0))
+ h = float(geo.get('height', 0))
+ x = float(geo.get('x', 0))
+ y = float(geo.get('y', 0))
+
+ # Icon groups
+ if 'group' in style and 'connectable=1' in style:
+ icon_groups.append({
+ 'id': cid, 'x': x, 'y': y, 'w': w, 'h': h,
+ 'parent': cell.get('parent', ''),
+ })
+ if h < MIN_ICON_H:
+ issues.append(('WARN', f'Icon {cid} too short: {h:.0f}px (min {MIN_ICON_H})'))
+ if w < MIN_ICON_W:
+ issues.append(('WARN', f'Icon {cid} too narrow: {w:.0f}px (min {MIN_ICON_W})'))
+
+ # Containers (regions, VCNs, subnets)
+ if value and any(kw in value for kw in ['Region', 'VCN', 'Subnet', 'Oracle Cloud']):
+ containers.append({
+ 'id': cid, 'name': value[:40], 'x': x, 'y': y, 'w': w, 'h': h,
+ 'parent': cell.get('parent', ''),
+ })
+
+ # Check children overflow parents
+ container_map = {}
+ for cell in root.iter('mxCell'):
+ cid = cell.get('id', '')
+ val = cell.get('value', '')
+ geo = cell.find('mxGeometry')
+ if geo is not None and val:
+ container_map[cid] = {
+ 'name': val[:50], 'x': float(geo.get('x', 0)),
+ 'y': float(geo.get('y', 0)), 'w': float(geo.get('width', 0)),
+ 'h': float(geo.get('height', 0)), 'parent': cell.get('parent', ''),
+ }
+
+ for cid, c in container_map.items():
+ parent = container_map.get(c['parent'])
+ if parent:
+ child_right = c['x'] + c['w']
+ child_bottom = c['y'] + c['h']
+ overflow_r = child_right - parent['w']
+ overflow_b = child_bottom - parent['h']
+ if overflow_r > 2:
+ issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} RIGHT by {overflow_r:.0f}px'))
+ if overflow_b > 2:
+ issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} BOTTOM by {overflow_b:.0f}px'))
+
+ # Check for overlapping siblings (same parent)
+ by_parent = {}
+ for ig in icon_groups:
+ by_parent.setdefault(ig['parent'], []).append(ig)
+
+ for parent_id, siblings in by_parent.items():
+ siblings.sort(key=lambda s: (s['y'], s['x']))
+ for i in range(len(siblings) - 1):
+ a = siblings[i]
+ b = siblings[i + 1]
+ # Check vertical overlap (same column, x ranges overlap)
+ x_overlap = not (a['x'] + a['w'] < b['x'] or b['x'] + b['w'] < a['x'])
+ if x_overlap:
+ gap = b['y'] - (a['y'] + a['h'])
+ if gap < MIN_GAP_PX:
+ issues.append(('ERROR', f'Overlap between icons in parent {parent_id}: gap={gap:.0f}px'))
+
+ # Check container utilization (warn on mostly empty containers)
+ for cont in containers:
+ children_area = 0
+ for ig in icon_groups:
+ if ig['parent'] == cont['id']:
+ children_area += ig['w'] * ig['h']
+ for child_cont in containers:
+ if child_cont['parent'] == cont['id']:
+ children_area += child_cont['w'] * child_cont['h']
+ cont_area = cont['w'] * cont['h']
+ if cont_area > 0:
+ fill_ratio = children_area / cont_area
+ if fill_ratio < (1 - MAX_EMPTY_RATIO):
+ issues.append(('INFO', f'{cont["name"]}: {fill_ratio:.0%} filled ({cont["w"]:.0f}x{cont["h"]:.0f})'))
+
+ # Check edge labels have offset (mxPoint as="offset")
+ edges_with_label = 0
+ edges_with_offset = 0
+ for cell in root.iter('mxCell'):
+ if cell.get('edge') == '1' and cell.get('value', '').strip():
+ edges_with_label += 1
+ geo = cell.find('mxGeometry')
+ if geo is not None:
+ offset = geo.find('.//mxPoint[@as="offset"]')
+ if offset is not None:
+ edges_with_offset += 1
+
+ if edges_with_label > 0 and edges_with_offset < edges_with_label:
+ issues.append(('WARN', f'{edges_with_label - edges_with_offset}/{edges_with_label} edge labels missing offset'))
+
+ # Check jettySize=auto present
+ has_jetty = False
+ for cell in root.iter('mxCell'):
+ if 'jettySize=auto' in cell.get('style', ''):
+ has_jetty = True
+ break
+ if not has_jetty:
+ issues.append(('WARN', 'No edges with jettySize=auto'))
+
+ # Check container=1 present
+ has_container = False
+ for cell in root.iter('mxCell'):
+ if 'container=1' in cell.get('style', ''):
+ has_container = True
+ break
+ if not has_container:
+ issues.append(('WARN', 'No containers with explicit container=1 style'))
+
+ return issues
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(f"Usage: {sys.argv[0]} ")
+ sys.exit(1)
+
+ filepath = sys.argv[1]
+ issues = validate(filepath)
+
+ if not issues:
+ print(f"✅ {filepath}: All checks passed")
+ sys.exit(0)
+
+ errors = [i for i in issues if i[0] == 'ERROR']
+ warns = [i for i in issues if i[0] == 'WARN']
+ infos = [i for i in issues if i[0] == 'INFO']
+
+ print(f"📋 Diagram validation: {filepath}")
+ print(f" {len(errors)} errors, {len(warns)} warnings, {len(infos)} info")
+ print()
+
+ for severity, msg in issues:
+ icon = {'ERROR': '❌', 'WARN': '⚠️', 'INFO': 'ℹ️'}.get(severity, '?')
+ print(f" {icon} [{severity}] {msg}")
+
+ sys.exit(1 if errors else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/oci_diagram_gen.py b/tools/oci_diagram_gen.py
index 696e90f..75b5b04 100644
--- a/tools/oci_diagram_gen.py
+++ b/tools/oci_diagram_gen.py
@@ -78,15 +78,15 @@ STYLES = {
"fontSize=11;"
),
"vcn": (
- "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;align=left;"
+ "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
- "fontColor=#AE562C;strokeColor=#AE562C;perimeterSpacing=0;"
+ "fontColor=#aa643b;strokeColor=#aa643b;perimeterSpacing=0;"
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
),
"subnet": (
- "whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
+ "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
- "fontColor=#AE562C;strokeColor=#AE562C;fontSize=11;fontStyle=1;"
+ "fontColor=#aa643b;strokeColor=#aa643b;fontSize=11;fontStyle=1;"
"spacingLeft=8;spacingTop=4;"
),
"compartment": (
@@ -473,6 +473,52 @@ class OCIDiagramGenerator:
.replace('"', """)
.replace("\n", "
"))
+ # Layout constants — calibrated from 37 Oracle Architecture Center .drawio files
+ # (12,617 cells parsed: 234x 63x63 service icons, 245x 42x42 gateway icons)
+ VCN_TOP_PAD = 50 # Oracle ref: VCN top to first subnet
+ SUBNET_GAP = 28 # Oracle ref: subnet-to-subnet gap
+ SVC_SPACING = 28 # Oracle ref: service-to-service horizontal gap
+ GW_DEFAULT_H = 110 # Gateway block: ~70px icon + 2px gap + 36px label
+ GW_SPACING = 20 # Gateway-to-gateway vertical gap
+ MIN_ICON_W = 42 # Oracle ref: gateway icons = 42x42
+ MIN_ICON_H = 42 # Oracle ref: gateway icons = 42x42
+ SVC_ICON_TARGET = 63 # Oracle ref: service icons = 63x63
+ GW_ICON_TARGET = 42 # Oracle ref: gateway icons = 42x42
+
+ @classmethod
+ def _calc_service_block_h(cls, label: str, service_type: str) -> int:
+ """Calculate the total height a service block needs (icon + label).
+
+ Used by both auto-sizing (pre-calculation) and rendering to ensure
+ containers are always big enough for their content.
+ """
+ n_lines = label.count('\n') + 1
+ label_h = max(n_lines * 16 + 4, 20)
+
+ # Estimate icon height using Oracle ref targets (63px height, 45px min width)
+ icon_entry = cls.ICON_CACHE.get(service_type) if cls.ICON_CACHE else None
+ if icon_entry:
+ iw, ih = icon_entry["w"], icon_entry["h"]
+ scale = min(63 / ih, 1.0)
+ if iw * scale < 45:
+ scale = 45 / iw
+ icon_h = int(ih * scale)
+ else:
+ icon_h = 63
+
+ return icon_h + 2 + label_h # icon + gap + label
+
+ @classmethod
+ def _calc_subnet_h(cls, subnet_spec: dict) -> int:
+ """Calculate the auto-height for a subnet from its services."""
+ svcs = subnet_spec.get("services", [])
+ max_svc_h = 80
+ for svc in svcs:
+ label = svc.get("label", "").replace("\\n", "\n")
+ block_h = cls._calc_service_block_h(label, svc.get("type", "compute"))
+ max_svc_h = max(max_svc_h, block_h)
+ return 30 + max_svc_h + 15 # top pad + content + bottom pad
+
def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str:
"""Get the style string for a service type."""
category = SVC_CATEGORY.get(service_type, "svc_infra")
@@ -744,27 +790,61 @@ class OCIDiagramGenerator:
icon_cells = icon_entry["cells"]
# Label height: proportional to actual line count (no over-allocation).
- # ~15px per line at 11pt Oracle Sans, minimum 20px for single-line labels.
+ # ~16px per line at 12pt Oracle Sans, minimum 20px for single-line labels.
n_lines = label.count('\n') + 1
label_h = max(n_lines * 16 + 4, 20)
- # Icon area = full spec height minus label height
- icon_area_h = max(h - label_h, 20)
+ # ── Icon sizing: Oracle ref arch standard ──
+ # Oracle reference diagrams use ~55-65px icons that are visually
+ # prominent. We enforce minimum rendered width of 50px so that
+ # tall/narrow icons (ExaCS, Compute, DBCS, NAT GW — 21 of 61 icons
+ # have aspect ratio > 1.3) don't render as tiny slivers.
+ MIN_ICON_W = 50 # minimum rendered icon WIDTH (px)
+ MIN_ICON_H = 60 # minimum rendered icon HEIGHT (px)
- # Scale icon to fit the icon area (never upscale beyond original size)
- scale = min(w / icon_w, icon_area_h / icon_h, 1.0)
+ # For tall/narrow icons (ratio > 1.3), we need more vertical space
+ # so the width doesn't shrink below MIN_ICON_W when scaling to fit.
+ aspect_ratio = icon_h / icon_w if icon_w > 0 else 1
+ if aspect_ratio > 1.3:
+ # Need enough height so that width = icon_w * scale >= MIN_ICON_W
+ # scale = MIN_ICON_W / icon_w, needed_h = icon_h * scale
+ needed_h = int(icon_h * (MIN_ICON_W / icon_w))
+ icon_area_h = max(h - label_h, MIN_ICON_H, needed_h)
+ else:
+ icon_area_h = max(h - label_h, MIN_ICON_H)
+
+ # Scale icon to Oracle ref standard (calibrated from 37 Oracle .drawio files):
+ # Oracle uses 63x63 square icon boxes. Our OCI Library stencils are
+ # rectangular (tall), so we target 63px height and enforce minimum
+ # 45px width for legibility of detailed multi-cell stencils.
+ ICON_TARGET_H = 63 # Oracle ref: 63px icon box height
+ ICON_MIN_W = 45 # Minimum width for legible stencils
+
+ # Scale to fit target height
+ scale = min(ICON_TARGET_H / icon_h, 1.0)
scaled_w = icon_w * scale
scaled_h = icon_h * scale
- # Icon group height = scaled icon height only.
- # Connections terminate here — at the visible icon boundary.
- icon_group_h = max(int(scaled_h), 20)
+ # If too narrow, scale up to meet minimum width
+ if scaled_w < ICON_MIN_W:
+ scale = ICON_MIN_W / icon_w
+ scaled_w = icon_w * scale
+ scaled_h = icon_h * scale
+
+ # Icon group dimensions = scaled icon size (not spec width).
+ # This ensures edges connect TO the visible icon, not to an
+ # invisible wide group. The label (sibling) uses spec width.
+ icon_group_w = max(int(scaled_w) + 4, MIN_ICON_W) # small padding
+ icon_group_h = max(int(scaled_h), MIN_ICON_H)
+
+ # Center the icon group horizontally within the spec width
+ icon_group_x = x + (w - icon_group_w) // 2
group_style = (
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
"connectable=1;"
)
- self._create_object(cell_id, "", group_style, parent, x, y, w, icon_group_h)
+ self._create_object(cell_id, "", group_style, parent, icon_group_x, y, icon_group_w, icon_group_h)
# Build ID mapping: original cell id -> unique cell id
original_ids = set()
@@ -777,8 +857,8 @@ class OCIDiagramGenerator:
key=lambda x_: int(x_) if x_.isdigit() else 0):
id_map[orig_id] = f"{cell_id}_i{orig_id}"
- # Center the icon horizontally within the group
- icon_offset_x = (w - scaled_w) / 2
+ # Center the icon horizontally within the (now tight) group
+ icon_offset_x = (icon_group_w - scaled_w) / 2
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
group_placeholder = f"__DRAWPYO_{cell_id}__"
@@ -830,8 +910,8 @@ class OCIDiagramGenerator:
# Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`).
# This keeps connection geometry clean: arrows connect to the icon group only.
- # Oracle ref arch style: 11pt, charcoal (#312D2A), centered below icon.
- fs = font_size or 11
+ # Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon.
+ fs = font_size or 12
label_style = (
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;"
@@ -1144,11 +1224,14 @@ class OCIDiagramGenerator:
ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2
dx = tx - sx
dy = ty - sy
+ dist = max((dx**2 + dy**2) ** 0.5, 1)
+ # Larger offset for short connections to avoid icon overlap
+ base_offset = 20 if dist < 300 else 15
is_horiz = abs(dx) > abs(dy) * 1.5
if is_horiz:
- offset_x, offset_y = 0, -15
+ offset_x, offset_y = 0, -base_offset
else:
- offset_x, offset_y = 15, 0
+ offset_x, offset_y = base_offset, 0
# Find the edge's mxGeometry and inject label offset.
# drawpyo emits:
# We replace with expanded form containing offset point.
@@ -1282,7 +1365,8 @@ class OCIDiagramGenerator:
else:
gen.add_service(svc_id, label, svc.get("type", "compute"),
cloud_id, cx, cy, svc_w, svc_h)
- cy += svc_h + 20
+ actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
+ cy += max(svc_h, actual_h) + 20
# Tenancy — auto-size from regions if w/h not specified in spec
tenancy_spec = spec.get("tenancy", {})
@@ -1290,7 +1374,8 @@ class OCIDiagramGenerator:
tenancy_y = tenancy_spec.get("y", 80)
# Pre-calculate tenancy size from region content if not explicit
- if "w" not in tenancy_spec or "h" not in tenancy_spec:
+ # Always pre-calculate auto-size even if spec has dimensions (use max)
+ if True:
regions = tenancy_spec.get("regions", [])
max_region_right = 0
max_region_bottom = 0
@@ -1303,23 +1388,23 @@ class OCIDiagramGenerator:
for v in r_vcns for gw in v.get("gateways", [])
)
drg_w = 120 if r_has_drg else 0
- # Quick subnet height calc
+ # Subnet height calc (mirrors actual rendering)
total_sub_h = 0
max_sub_w = 300
for v in r_vcns:
for s in v.get("subnets", []):
- total_sub_h += s.get("h", 120) + 14
+ auto_sh = cls._calc_subnet_h(s)
+ total_sub_h += max(s.get("h", auto_sh), auto_sh) + cls.SUBNET_GAP
svcs = s.get("services", [])
- row_w = sum(sv.get("w", 150) for sv in svcs) + 16 * max(0, len(svcs) - 1) + 30
+ row_w = sum(sv.get("w", 150) for sv in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
max_sub_w = max(max_sub_w, row_w)
- # VCN-internal gateways
vcn_gws = [gw for gw in v.get("gateways", [])
if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
if vcn_gws:
max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20
est_rw = max_sub_w + drg_w + 80
- est_rh = total_sub_h + 80
+ est_rh = total_sub_h + 150 # 45 top + VCN_TOP_PAD(50) + bottom padding + margins
rx_val = r.get("x", region_x_cursor)
ry_val = r.get("y", 40)
@@ -1327,11 +1412,10 @@ class OCIDiagramGenerator:
max_region_bottom = max(max_region_bottom, ry_val + est_rh)
region_x_cursor = max_region_right + 20
- tenancy_w = tenancy_spec.get("w", max(max_region_right + 30, 600))
- tenancy_h = tenancy_spec.get("h", max(max_region_bottom + 30, 400))
- else:
- tenancy_w = tenancy_spec["w"]
- tenancy_h = tenancy_spec["h"]
+ auto_tw = max(max_region_right + 30, 600)
+ auto_th = max(max_region_bottom + 30, 400)
+ tenancy_w = max(tenancy_spec.get("w", auto_tw), auto_tw)
+ tenancy_h = max(tenancy_spec.get("h", auto_th), auto_th)
gen.add_tenancy(
"tenancy",
@@ -1364,22 +1448,26 @@ class OCIDiagramGenerator:
gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20
subnets = vcn.get("subnets", [])
- total_subnet_h = sum(s.get("h", 120) for s in subnets) + 14 * max(0, len(subnets) - 1)
- # Max service width across all subnets
+ # Calculate actual subnet heights (same logic as rendering)
+ total_subnet_h = 0
max_svc_row_w = 0
for subnet in subnets:
+ auto_sh = cls._calc_subnet_h(subnet)
+ sh = max(subnet.get("h", auto_sh), auto_sh)
+ total_subnet_h += sh + cls.SUBNET_GAP
+
svcs = subnet.get("services", [])
- row_w = sum(svc.get("w", 150) for svc in svcs) + 16 * max(0, len(svcs) - 1) + 30
+ row_w = sum(svc.get("w", 150) for svc in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
max_svc_row_w = max(max_svc_row_w, row_w)
- # VCN needs: gw_lane + max(subnet_content_w, specified_w) + margins
+ # VCN = gw_lane + content + margins
content_w = gw_lane_w + max(max_svc_row_w, 300) + 40
- content_h = total_subnet_h + 60 # 32 top + 28 bottom padding
+ content_h = cls.VCN_TOP_PAD + total_subnet_h + 20
# Also account for gateway column height
if vcn_gateways:
- total_gw_h = sum(gw.get("h", 70) for gw in vcn_gateways) + 8 * max(0, len(vcn_gateways) - 1) + 64
+ total_gw_h = sum(gw.get("h", cls.GW_DEFAULT_H) for gw in vcn_gateways) + cls.GW_SPACING * max(0, len(vcn_gateways) - 1) + 64
content_h = max(content_h, total_gw_h)
# Store calculated VCN dimensions for use below
@@ -1397,9 +1485,9 @@ class OCIDiagramGenerator:
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
- # Use spec dimensions if explicitly provided, otherwise auto-calculated
- rw = region.get("w", auto_rw)
- rh = region.get("h", auto_rh)
+ # Always use at least auto-calculated size even if spec is smaller
+ rw = max(region.get("w", auto_rw), auto_rw)
+ rh = max(region.get("h", auto_rh), auto_rh)
gen.add_region(
region["id"],
@@ -1440,8 +1528,11 @@ class OCIDiagramGenerator:
vcn_offset_x = drg_lane_w + 15
for vcn in region_vcns:
vcn_parent = vcn.get("parent", region["id"])
- vcn_w = vcn.get("w", vcn.get("_auto_w", rw - vcn_offset_x - 15))
- vcn_h = vcn.get("h", vcn.get("_auto_h", rh - 70))
+ auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15)
+ auto_h = vcn.get("_auto_h", rh - 70)
+ # Always use at least auto-calculated size even if spec is smaller
+ vcn_w = max(vcn.get("w", auto_w), auto_w)
+ vcn_h = max(vcn.get("h", auto_h), auto_h)
gen.add_vcn(
vcn["id"],
f"VCN {vcn['label']}",
@@ -1455,32 +1546,53 @@ class OCIDiagramGenerator:
gw_lane_w = 0
if vcn_gateways:
- gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20
- total_gw_h = sum(gw.get("h", 70) for gw in vcn_gateways) + 8 * max(0, len(vcn_gateways) - 1)
+ GW_DEFAULT_W = 110
+ GW_DEFAULT_H = gen.GW_DEFAULT_H
+ GW_SPACING = gen.GW_SPACING
+ gw_lane_w = max(gw.get("w", GW_DEFAULT_W) for gw in vcn_gateways) + 20
+ total_gw_h = sum(gw.get("h", GW_DEFAULT_H) for gw in vcn_gateways) + GW_SPACING * max(0, len(vcn_gateways) - 1)
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
gy = gw_start_y
for gw in vcn_gateways:
gw_id = gw.get("id", gen._next_id())
- gw_w = gw.get("w", 110)
- gw_h = gw.get("h", 70)
+ gw_w = gw.get("w", GW_DEFAULT_W)
gw_x = gw.get("x", 10)
gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n")
+ # Calculate ACTUAL rendered block height for correct stacking
+ actual_block_h = gen._calc_service_block_h(label, gw["type"])
+ gw_h = max(gw.get("h", GW_DEFAULT_H), GW_DEFAULT_H, actual_block_h)
gen.add_service(
gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h,
)
- gy = gw_y + gw_h + 8
+ # Stack using actual rendered height, not spec height
+ gy = gw_y + actual_block_h + GW_SPACING
- # Subnet horizontal offset: push right if VCN gateways present
- subnet_x = 8 + gw_lane_w
- subnet_avail_w = vcn_w - subnet_x - 8
+ # Subnet offset — Oracle ref arch: 42px left indent, 50px top
+ VCN_LEFT_PAD = 42 if not vcn_gateways else 8
+ VCN_TOP_PAD = gen.VCN_TOP_PAD
+ SUBNET_GAP = gen.SUBNET_GAP
+
+ subnet_x = VCN_LEFT_PAD + gw_lane_w
+ subnet_avail_w = vcn_w - subnet_x - 16 # 16px right margin
# Stack subnets vertically (top-to-bottom)
- subnet_y = 32
+ subnet_y = VCN_TOP_PAD
+ SVC_SPACING = gen.SVC_SPACING
for subnet in vcn.get("subnets", []):
sw = subnet.get("w", subnet_avail_w)
- sh = subnet.get("h", 120)
+ subnet_svcs = subnet.get("services", [])
+
+ # ── Auto-calculate subnet height from service content ──
+ # Each service = icon group (MIN 60px, grows for tall icons) + label + gap
+ # Icon group grows based on aspect ratio (see _add_service_with_icon)
+ # Use shared auto-height calculation
+ auto_sh = gen._calc_subnet_h(subnet)
+ sh = max(subnet.get("h", auto_sh), auto_sh)
+ # Calculate max service block height for layout
+ max_svc_h = auto_sh - 30 - 15 # reverse: total - top_pad - bottom_pad
+
gen.add_subnet(
subnet["id"], subnet["label"],
vcn["id"], subnet_x, subnet_y, sw, sh,
@@ -1488,22 +1600,22 @@ class OCIDiagramGenerator:
# Auto-layout services HORIZONTALLY within subnet, centered.
svc_top = 30
- subnet_svcs = subnet.get("services", [])
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
- total_svc_w = sum(svc_widths) + 16 * max(0, len(svc_widths) - 1)
+ total_svc_w = sum(svc_widths) + SVC_SPACING * max(0, len(svc_widths) - 1)
svc_x = max(15, (sw - total_svc_w) // 2)
for svc, svc_w in zip(subnet_svcs, svc_widths):
svc_id = svc.get("id", gen._next_id())
- svc_h = svc.get("h", 80)
+ # Use auto-calculated height (ensures icons fit at MIN_ICON_H)
+ svc_h = max(svc.get("h", 80), max_svc_h)
label = svc["label"].replace("\\n", "\n")
gen.add_service(
svc_id, label, svc["type"],
subnet["id"], svc_x, svc_top, svc_w, svc_h,
font_size=svc.get("fontSize"),
)
- svc_x += svc_w + 16
+ svc_x += svc_w + SVC_SPACING
- subnet_y += sh + 14
+ subnet_y += sh + SUBNET_GAP
# Compartments inside region — can contain VCNs
for comp in region.get("compartments", []):
@@ -1551,22 +1663,23 @@ class OCIDiagramGenerator:
font_size=svc.get("fontSize"),
)
svc_x += svc_w + 16
- subnet_y += sh + 14
+ subnet_y += sh + 28 # Oracle ref: subnet gap
# Gateways on left side of compartment VCN, stacked vertically
gx = 10
gy = 40
for gw in vcn.get("gateways", []):
gw_id = gw.get("id", gen._next_id())
gw_w = gw.get("w", 95)
- gw_h = gw.get("h", 60)
gw_x = gw.get("x", gx)
gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n")
+ actual_h = gen._calc_service_block_h(label, gw["type"])
+ gw_h = max(gw.get("h", actual_h), actual_h)
gen.add_service(
gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h,
)
- gy = gw_y + gw_h + 8
+ gy = gw_y + actual_h + cls.GW_SPACING
# Observability row at bottom of region — uses icons (only if explicitly requested)
if region.get("observability"):
diff --git a/tools/refresh_arch_catalog.py b/tools/refresh_arch_catalog.py
index 28af1f2..3663113 100644
--- a/tools/refresh_arch_catalog.py
+++ b/tools/refresh_arch_catalog.py
@@ -273,14 +273,72 @@ def validate_catalog():
return errors == 0
+def check_links():
+ """Check all catalog URLs for broken links (HTTP HEAD requests)."""
+ data = load_catalog()
+ entries = data.get("entries", [])
+ broken = []
+ redirected = []
+ ok_count = 0
+
+ print(f"Checking {len(entries)} URLs...")
+ for i, entry in enumerate(entries):
+ url = entry.get("url", "")
+ title = entry.get("title", "UNKNOWN")
+ if not url:
+ continue
+ try:
+ resp = requests.head(url, timeout=10, allow_redirects=True)
+ if resp.status_code == 404:
+ broken.append((title, url))
+ print(f" ❌ 404: {title}")
+ elif resp.status_code in (301, 302) or resp.url != url:
+ redirected.append((title, url, resp.url))
+ print(f" ↪️ Redirect: {title} → {resp.url}")
+ ok_count += 1
+ elif resp.status_code < 400:
+ ok_count += 1
+ else:
+ broken.append((title, url))
+ print(f" ⚠️ {resp.status_code}: {title}")
+ except requests.RequestException as e:
+ broken.append((title, url))
+ print(f" ⚠️ Error: {title} — {e}")
+ # Rate limit: 200ms between requests
+ time.sleep(0.2)
+
+ print(f"\n{'='*60}")
+ print(f"Results: {ok_count} OK, {len(broken)} broken, {len(redirected)} redirected")
+
+ if broken:
+ print(f"\n❌ Broken links ({len(broken)}):")
+ for title, url in broken:
+ print(f" - {title}")
+ print(f" {url}")
+
+ if redirected:
+ print(f"\n↪️ Redirected ({len(redirected)}):")
+ for title, old, new in redirected:
+ print(f" - {title}")
+ print(f" OLD: {old}")
+ print(f" NEW: {new}")
+
+ return len(broken) == 0
+
+
def main():
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
+ parser.add_argument("--check-links", action="store_true", help="Check all URLs for 404s and redirects")
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
args = parser.parse_args()
+ if args.check_links:
+ ok = check_links()
+ sys.exit(0 if ok else 1)
+
if args.validate:
ok = validate_catalog()
sys.exit(0 if ok else 1)