Diagram generator calibrated from 37 Oracle ref architectures + KB health tooling

Diagram generator (oci_diagram_gen.py):
- Icon sizing calibrated: 63px target height, 45px min width (from 37 Oracle
  Architecture Center .drawio files: 12,617 cells, 234x 63x63 service icons)
- Auto-sizing: containers grow from content, never overflow (validated by script)
- DRG placed outside VCN, inside region (Oracle hub-spoke pattern)
- Gateway stacking uses _calc_service_block_h to prevent label overlap
- Edge labels: mxGeometry offset injection (15-20px away from midpoint)
- Tight icon groups: edges connect to visible icon, not invisible wide group
- VCN/subnet dash pattern corrected to "4 2", stroke to #aa643b (Oracle ref)
- jettySize=auto, dual connection merge, container=1;collapsible=0

New tools:
- scripts/validate-diagram.py: checks icon sizes, overlaps, container overflow
- tools/refresh_arch_catalog.py --check-links: HTTP HEAD check on all 123 URLs
- .gitea/workflows/kb-health.yaml: weekly link + freshness check (Mon 8am UTC)
- kb/diagram/oracle-ref-measurements.md: exact measurements from Oracle refs
- kb/CHANGELOG.md: KB change log shown in welcome banner

SKILL.md:
- Pre-flight: python→python3, silent errors, git fetch check for local users
- Changelog banner in welcome flow
- Anti-hallucination guardrails (from earlier commit, carried forward)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-14 09:22:33 -03:00
parent 886c09ad10
commit 20be7c297e
10 changed files with 1009 additions and 215 deletions

View File

@@ -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
<details>
<summary>Full report</summary>
```
$(cat /tmp/link-check.txt)
```
</details>
### 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

View File

@@ -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 --whats-new # crawl What's New pages
python tools/refresh_arch_catalog.py --url <url> # add a single entry python tools/refresh_arch_catalog.py --url <url> # add a single entry
python tools/refresh_arch_catalog.py --validate # validate catalog integrity 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 <file.drawio>` | 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 ### Feature Compatibility Matrix
@@ -296,12 +324,16 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
# Architecture diagram # Architecture diagram
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio 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) # Output orchestrator (multiple formats at once)
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/ python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
# Architecture Center catalog # Architecture Center catalog
python tools/refresh_arch_catalog.py --validate python tools/refresh_arch_catalog.py --validate
python tools/refresh_arch_catalog.py --whats-new python tools/refresh_arch_catalog.py --whats-new
python tools/refresh_arch_catalog.py --check-links
# Feature compatibility # Feature compatibility
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai

View File

@@ -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. 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. #### Check 1: KB changelog banner
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
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 (<date>): <first bullet summary>
```
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: <N> file(s) outdated (oldest: <file> — <age_days>d). ⚠️ KB freshness: <N> file(s) outdated (oldest: <file> — <age_days>d).
<M> can be auto-refreshed (SKU catalog, Architecture Center). <M> can be auto-refreshed. Refresh now? [y/N]
Refresh now before showing the menu? [y/N]
``` ```
- If the user replies `y` / `yes` / `` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu. - `y` / `yes` / `` → run `python3 tools/kb_freshness.py --auto-refresh`, then show menu.
- If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ <N> KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.` - Anything else → show menu with one-line reminder: `⚠️ <N> 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: <N> file(s) need manual review (oldest: <file> — <age_days>d). ⚠️ KB freshness: <N> file(s) need manual review (oldest: <file> — <age_days>d).
No automated refresh available — see kb/README.md for review process.
``` ```
Then show the menu directly. If `kb_freshness.py` errors out (exit ≠ 0, missing python, missing tool), **silently skip** — no error output, no banner, no mention of failure.
**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.
### Welcome Message ### Welcome Message

View File

@@ -77,20 +77,14 @@ tenancy:
- id: "drg" - id: "drg"
label: "DRG" label: "DRG"
type: "drg" type: "drg"
w: 90
h: 60
# IGW — whitelist: public subnet requires it # IGW — whitelist: public subnet requires it
- id: "igw" - id: "igw"
label: "Internet\nGateway" label: "Internet\nGateway"
type: "igw" type: "igw"
w: 100
h: 60
# SGW — whitelist: ExaCS backup requires it # SGW — whitelist: ExaCS backup requires it
- id: "sgw" - id: "sgw"
label: "Service\nGateway" label: "Service\nGateway"
type: "service_gateway" type: "service_gateway"
w: 100
h: 60
# ── DR Region: Sao Paulo ── # ── DR Region: Sao Paulo ──
- id: "region_gru" - id: "region_gru"

File diff suppressed because one or more lines are too long

19
kb/CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -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=<color>, strokeColor=none
2. Stroke layer: fillColor=none, strokeColor=<color>
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

184
scripts/validate-diagram.py Normal file
View File

@@ -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]} <diagram.drawio>")
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()

View File

@@ -78,15 +78,15 @@ STYLES = {
"fontSize=11;" "fontSize=11;"
), ),
"vcn": ( "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;" "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;" "fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
), ),
"subnet": ( "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;" "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;" "spacingLeft=8;spacingTop=4;"
), ),
"compartment": ( "compartment": (
@@ -473,6 +473,52 @@ class OCIDiagramGenerator:
.replace('"', "&quot;") .replace('"', "&quot;")
.replace("\n", "&#xa;")) .replace("\n", "&#xa;"))
# 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: def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str:
"""Get the style string for a service type.""" """Get the style string for a service type."""
category = SVC_CATEGORY.get(service_type, "svc_infra") category = SVC_CATEGORY.get(service_type, "svc_infra")
@@ -744,27 +790,61 @@ class OCIDiagramGenerator:
icon_cells = icon_entry["cells"] icon_cells = icon_entry["cells"]
# Label height: proportional to actual line count (no over-allocation). # 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 n_lines = label.count('\n') + 1
label_h = max(n_lines * 16 + 4, 20) label_h = max(n_lines * 16 + 4, 20)
# Icon area = full spec height minus label height # ── Icon sizing: Oracle ref arch standard ──
icon_area_h = max(h - label_h, 20) # 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) # For tall/narrow icons (ratio > 1.3), we need more vertical space
scale = min(w / icon_w, icon_area_h / icon_h, 1.0) # 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_w = icon_w * scale
scaled_h = icon_h * scale scaled_h = icon_h * scale
# Icon group height = scaled icon height only. # If too narrow, scale up to meet minimum width
# Connections terminate here — at the visible icon boundary. if scaled_w < ICON_MIN_W:
icon_group_h = max(int(scaled_h), 20) 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_style = (
"group;fillColor=none;strokeColor=none;pointerEvents=1;" "group;fillColor=none;strokeColor=none;pointerEvents=1;"
"connectable=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 # Build ID mapping: original cell id -> unique cell id
original_ids = set() original_ids = set()
@@ -777,8 +857,8 @@ class OCIDiagramGenerator:
key=lambda x_: int(x_) if x_.isdigit() else 0): key=lambda x_: int(x_) if x_.isdigit() else 0):
id_map[orig_id] = f"{cell_id}_i{orig_id}" id_map[orig_id] = f"{cell_id}_i{orig_id}"
# Center the icon horizontally within the group # Center the icon horizontally within the (now tight) group
icon_offset_x = (w - scaled_w) / 2 icon_offset_x = (icon_group_w - scaled_w) / 2
# Use a placeholder for the group's drawpyo ID — replaced in to_xml() # Use a placeholder for the group's drawpyo ID — replaced in to_xml()
group_placeholder = f"__DRAWPYO_{cell_id}__" 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`). # 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. # This keeps connection geometry clean: arrows connect to the icon group only.
# Oracle ref arch style: 11pt, charcoal (#312D2A), centered below icon. # Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon.
fs = font_size or 11 fs = font_size or 12
label_style = ( label_style = (
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;" f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;" 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 ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2
dx = tx - sx dx = tx - sx
dy = ty - sy 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 is_horiz = abs(dx) > abs(dy) * 1.5
if is_horiz: if is_horiz:
offset_x, offset_y = 0, -15 offset_x, offset_y = 0, -base_offset
else: else:
offset_x, offset_y = 15, 0 offset_x, offset_y = base_offset, 0
# Find the edge's mxGeometry and inject label offset. # Find the edge's mxGeometry and inject label offset.
# drawpyo emits: <mxGeometry relative="1" as="geometry" /> # drawpyo emits: <mxGeometry relative="1" as="geometry" />
# We replace with expanded form containing offset point. # We replace with expanded form containing offset point.
@@ -1282,7 +1365,8 @@ class OCIDiagramGenerator:
else: else:
gen.add_service(svc_id, label, svc.get("type", "compute"), gen.add_service(svc_id, label, svc.get("type", "compute"),
cloud_id, cx, cy, svc_w, svc_h) 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 — auto-size from regions if w/h not specified in spec
tenancy_spec = spec.get("tenancy", {}) tenancy_spec = spec.get("tenancy", {})
@@ -1290,7 +1374,8 @@ class OCIDiagramGenerator:
tenancy_y = tenancy_spec.get("y", 80) tenancy_y = tenancy_spec.get("y", 80)
# Pre-calculate tenancy size from region content if not explicit # 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", []) regions = tenancy_spec.get("regions", [])
max_region_right = 0 max_region_right = 0
max_region_bottom = 0 max_region_bottom = 0
@@ -1303,23 +1388,23 @@ class OCIDiagramGenerator:
for v in r_vcns for gw in v.get("gateways", []) for v in r_vcns for gw in v.get("gateways", [])
) )
drg_w = 120 if r_has_drg else 0 drg_w = 120 if r_has_drg else 0
# Quick subnet height calc # Subnet height calc (mirrors actual rendering)
total_sub_h = 0 total_sub_h = 0
max_sub_w = 300 max_sub_w = 300
for v in r_vcns: for v in r_vcns:
for s in v.get("subnets", []): 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", []) 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) max_sub_w = max(max_sub_w, row_w)
# VCN-internal gateways
vcn_gws = [gw for gw in v.get("gateways", []) vcn_gws = [gw for gw in v.get("gateways", [])
if gw.get("type") not in ("drg", "dynamic_routing_gateway")] if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
if vcn_gws: if vcn_gws:
max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20 max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20
est_rw = max_sub_w + drg_w + 80 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) rx_val = r.get("x", region_x_cursor)
ry_val = r.get("y", 40) ry_val = r.get("y", 40)
@@ -1327,11 +1412,10 @@ class OCIDiagramGenerator:
max_region_bottom = max(max_region_bottom, ry_val + est_rh) max_region_bottom = max(max_region_bottom, ry_val + est_rh)
region_x_cursor = max_region_right + 20 region_x_cursor = max_region_right + 20
tenancy_w = tenancy_spec.get("w", max(max_region_right + 30, 600)) auto_tw = max(max_region_right + 30, 600)
tenancy_h = tenancy_spec.get("h", max(max_region_bottom + 30, 400)) auto_th = max(max_region_bottom + 30, 400)
else: tenancy_w = max(tenancy_spec.get("w", auto_tw), auto_tw)
tenancy_w = tenancy_spec["w"] tenancy_h = max(tenancy_spec.get("h", auto_th), auto_th)
tenancy_h = tenancy_spec["h"]
gen.add_tenancy( gen.add_tenancy(
"tenancy", "tenancy",
@@ -1364,22 +1448,26 @@ class OCIDiagramGenerator:
gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20 gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20
subnets = vcn.get("subnets", []) 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 max_svc_row_w = 0
for subnet in subnets: 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", []) 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) 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_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 # Also account for gateway column height
if vcn_gateways: 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) content_h = max(content_h, total_gw_h)
# Store calculated VCN dimensions for use below # Store calculated VCN dimensions for use below
@@ -1397,9 +1485,9 @@ class OCIDiagramGenerator:
auto_rw = total_vcn_w + drg_lane_w + 50 # margins auto_rw = total_vcn_w + drg_lane_w + 50 # margins
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
# Use spec dimensions if explicitly provided, otherwise auto-calculated # Always use at least auto-calculated size even if spec is smaller
rw = region.get("w", auto_rw) rw = max(region.get("w", auto_rw), auto_rw)
rh = region.get("h", auto_rh) rh = max(region.get("h", auto_rh), auto_rh)
gen.add_region( gen.add_region(
region["id"], region["id"],
@@ -1440,8 +1528,11 @@ class OCIDiagramGenerator:
vcn_offset_x = drg_lane_w + 15 vcn_offset_x = drg_lane_w + 15
for vcn in region_vcns: for vcn in region_vcns:
vcn_parent = vcn.get("parent", region["id"]) vcn_parent = vcn.get("parent", region["id"])
vcn_w = vcn.get("w", vcn.get("_auto_w", rw - vcn_offset_x - 15)) auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15)
vcn_h = vcn.get("h", vcn.get("_auto_h", rh - 70)) 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( gen.add_vcn(
vcn["id"], vcn["id"],
f"VCN {vcn['label']}", f"VCN {vcn['label']}",
@@ -1455,32 +1546,53 @@ class OCIDiagramGenerator:
gw_lane_w = 0 gw_lane_w = 0
if vcn_gateways: if vcn_gateways:
gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20 GW_DEFAULT_W = 110
total_gw_h = sum(gw.get("h", 70) for gw in vcn_gateways) + 8 * max(0, len(vcn_gateways) - 1) 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) gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
gy = gw_start_y gy = gw_start_y
for gw in vcn_gateways: for gw in vcn_gateways:
gw_id = gw.get("id", gen._next_id()) gw_id = gw.get("id", gen._next_id())
gw_w = gw.get("w", 110) gw_w = gw.get("w", GW_DEFAULT_W)
gw_h = gw.get("h", 70)
gw_x = gw.get("x", 10) gw_x = gw.get("x", 10)
gw_y = gw.get("y", gy) gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n") 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( gen.add_service(
gw_id, label, gw["type"], gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h, 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 offset — Oracle ref arch: 42px left indent, 50px top
subnet_x = 8 + gw_lane_w VCN_LEFT_PAD = 42 if not vcn_gateways else 8
subnet_avail_w = vcn_w - subnet_x - 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) # 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", []): for subnet in vcn.get("subnets", []):
sw = subnet.get("w", subnet_avail_w) 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( gen.add_subnet(
subnet["id"], subnet["label"], subnet["id"], subnet["label"],
vcn["id"], subnet_x, subnet_y, sw, sh, vcn["id"], subnet_x, subnet_y, sw, sh,
@@ -1488,22 +1600,22 @@ class OCIDiagramGenerator:
# Auto-layout services HORIZONTALLY within subnet, centered. # Auto-layout services HORIZONTALLY within subnet, centered.
svc_top = 30 svc_top = 30
subnet_svcs = subnet.get("services", [])
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs] 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) svc_x = max(15, (sw - total_svc_w) // 2)
for svc, svc_w in zip(subnet_svcs, svc_widths): for svc, svc_w in zip(subnet_svcs, svc_widths):
svc_id = svc.get("id", gen._next_id()) 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") label = svc["label"].replace("\\n", "\n")
gen.add_service( gen.add_service(
svc_id, label, svc["type"], svc_id, label, svc["type"],
subnet["id"], svc_x, svc_top, svc_w, svc_h, subnet["id"], svc_x, svc_top, svc_w, svc_h,
font_size=svc.get("fontSize"), 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 # Compartments inside region — can contain VCNs
for comp in region.get("compartments", []): for comp in region.get("compartments", []):
@@ -1551,22 +1663,23 @@ class OCIDiagramGenerator:
font_size=svc.get("fontSize"), font_size=svc.get("fontSize"),
) )
svc_x += svc_w + 16 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 # Gateways on left side of compartment VCN, stacked vertically
gx = 10 gx = 10
gy = 40 gy = 40
for gw in vcn.get("gateways", []): for gw in vcn.get("gateways", []):
gw_id = gw.get("id", gen._next_id()) gw_id = gw.get("id", gen._next_id())
gw_w = gw.get("w", 95) gw_w = gw.get("w", 95)
gw_h = gw.get("h", 60)
gw_x = gw.get("x", gx) gw_x = gw.get("x", gx)
gw_y = gw.get("y", gy) gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n") 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( gen.add_service(
gw_id, label, gw["type"], gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h, 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) # Observability row at bottom of region — uses icons (only if explicitly requested)
if region.get("observability"): if region.get("observability"):

View File

@@ -273,14 +273,72 @@ def validate_catalog():
return errors == 0 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(): def main():
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog") parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
parser.add_argument("--url", help="Fetch a single URL and print YAML entry") 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("--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("--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") parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
args = parser.parse_args() args = parser.parse_args()
if args.check_links:
ok = check_links()
sys.exit(0 if ok else 1)
if args.validate: if args.validate:
ok = validate_catalog() ok = validate_catalog()
sys.exit(0 if ok else 1) sys.exit(0 if ok else 1)