From 34656dac5b453c81920442bdc48392ea2dd2653d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Sun, 25 Oct 2026 12:57:33 +0100 Subject: [PATCH 1/2] replace the two html reports with one offline page --- cloudexit/assets/css/driver.css | 1 + cloudexit/assets/css/report.css | 305 ++++ cloudexit/assets/css/style.css | 1107 -------------- cloudexit/assets/img/csp/alibaba.svg | 12 + cloudexit/assets/img/csp/aws.svg | 15 + cloudexit/assets/img/csp/azure.svg | 23 + cloudexit/assets/img/csp/gcp.svg | 13 + cloudexit/assets/img/logo/README.md | 3 + cloudexit/assets/js/driver.js.LICENSE.txt | 21 + cloudexit/assets/js/driver.js.iife.js | 2 + cloudexit/assets/template/egress.html | 775 ---------- cloudexit/assets/template/index.html | 1625 +++++++-------------- cloudexit/cli/app.py | 23 - cloudexit/engine.py | 37 +- cloudexit/report/aggregate.py | 23 - cloudexit/report/assets.py | 2 +- cloudexit/report/context.py | 17 +- cloudexit/report/egress.py | 179 +-- cloudexit/report/egress_report.py | 4 +- cloudexit/report/evidence.py | 34 +- cloudexit/report/helpers.py | 27 +- cloudexit/report/html_data.py | 63 - cloudexit/report/html_egress.py | 248 ++++ cloudexit/report/html_sections.py | 894 ++++++++++-- cloudexit/report/notices.py | 4 +- cloudexit/report/wording.py | 415 +++++- cloudexit/report/writers.py | 116 +- tests/catalogue/test_dataset_shapes.py | 8 +- tests/cli/test_app.py | 109 +- tests/platform/test_document.py | 4 - tests/report/test_basic_report.py | 172 +-- tests/report/test_egress.py | 228 +-- tests/report/test_egress_report.py | 124 +- tests/report/test_html_report.py | 955 ++++++++++++ tests/report/test_icon_placeholder.py | 57 +- tests/report/test_no_credentials.py | 1 - tests/report/test_report_flow.py | 12 +- tests/report/test_report_pipeline.py | 90 +- tests/report/test_report_transforms.py | 61 - tests/samples/__main__.py | 3 +- tests/samples/catalogue.py | 6 +- tests/samples/render.py | 28 +- tests/samples/rendered.py | 31 + tests/samples/test_samples.py | 49 +- tests/support/egress.py | 47 + tests/support/report_terms.py | 3 + tests/test_packaging.py | 32 +- 47 files changed, 3782 insertions(+), 4226 deletions(-) create mode 100644 cloudexit/assets/css/driver.css create mode 100644 cloudexit/assets/css/report.css delete mode 100644 cloudexit/assets/css/style.css create mode 100644 cloudexit/assets/img/csp/alibaba.svg create mode 100644 cloudexit/assets/img/csp/aws.svg create mode 100644 cloudexit/assets/img/csp/azure.svg create mode 100644 cloudexit/assets/img/csp/gcp.svg create mode 100644 cloudexit/assets/img/logo/README.md create mode 100644 cloudexit/assets/js/driver.js.LICENSE.txt create mode 100644 cloudexit/assets/js/driver.js.iife.js delete mode 100644 cloudexit/assets/template/egress.html delete mode 100644 cloudexit/report/html_data.py create mode 100644 cloudexit/report/html_egress.py create mode 100644 tests/report/test_html_report.py create mode 100644 tests/samples/rendered.py create mode 100644 tests/support/egress.py diff --git a/cloudexit/assets/css/driver.css b/cloudexit/assets/css/driver.css new file mode 100644 index 0000000..7ce0bd6 --- /dev/null +++ b/cloudexit/assets/css/driver.css @@ -0,0 +1 @@ +.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none} diff --git a/cloudexit/assets/css/report.css b/cloudexit/assets/css/report.css new file mode 100644 index 0000000..70479be --- /dev/null +++ b/cloudexit/assets/css/report.css @@ -0,0 +1,305 @@ +@font-face { font-family: 'IBM Plex Sans'; font-weight: 400; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexSans-Regular.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 400; font-style: italic; font-display: swap; src: url('../fonts/IBMPlexSans-Italic.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 600; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexSans-SemiBold.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Sans'; font-weight: 600; font-style: italic; font-display: swap; src: url('../fonts/IBMPlexSans-SemiBoldItalic.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexMono-Regular.ttf') format('truetype'); } +@font-face { font-family: 'IBM Plex Mono'; font-weight: 600; font-style: normal; font-display: swap; src: url('../fonts/IBMPlexMono-SemiBold.ttf') format('truetype'); } + +:root { + --primary-50: #f0fdfc; --primary-100: #ccfbf8; --primary-600: #0d948b; --primary-700: #0f766f; --primary-800: #115e59; --primary-900: #134e4a; + --neutral-100: #f3f6f6; --neutral-200: #e5ebeb; --neutral-300: #d1dbdb; --neutral-600: #4b6361; --neutral-900: #112726; + + --surface: #fff; --surface-muted: var(--neutral-100); --border: var(--neutral-200); --border-strong: var(--neutral-300); + --text: var(--neutral-900); --text-secondary: var(--neutral-600); --text-muted: #6B7680; --brand: var(--primary-800); + --thead-bg: #E9F0F2; --thead-rule: #C9D0D6; + + --status-success: #047854; --status-success-bg: #ecfdf8; + --status-pending: #4b6361; --status-pending-bg: #eef2f2; + + --sev-critical: #6E1A14; --sev-high: #B8322A; --sev-medium: #E8A33D; --sev-low: #4A7FB5; --sev-off: #E3E7EA; + + --hotspot-bg: #FDF6EC; --hotspot-ink: #B45309; + + --radius-card: 16px; --radius-control: 8px; --radius-chip: 6px; --radius-pill: 999px; + --shadow-card: 0 1px 4px rgba(0,0,0,.10); --shadow-popover: 0 8px 24px rgba(0,0,0,.12); + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px; --space-6: 24px; --space-8: 32px; + --topbar-h: 64px; +} + +* { box-sizing: border-box; } +html { scroll-padding-top: 120px; } +body { margin: 0; background: var(--surface-muted); color: var(--text-secondary); font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.43; -webkit-font-smoothing: antialiased; } +a { color: var(--brand); } +h1, h2, h3, p { margin: 0; } +img { display: block; } +:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; } +.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.muted { color: var(--text-muted); } +code, .mono { font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } + +.t-title { font-size: 24px; line-height: 32px; font-weight: 500; color: var(--text); } +.t-section { font-size: 20px; line-height: 28px; font-weight: 500; color: var(--text); } +.t-sub { font-size: 16px; line-height: 24px; font-weight: 600; color: var(--text); } +.t-label { font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); } +.t-value { font-size: 14px; line-height: 20px; font-weight: 500; color: var(--text); } +.t-figure { font-size: 28px; line-height: 32px; font-weight: 500; color: var(--text); font-variant-numeric: tabular-nums; } +.t-caption { font-size: 12px; line-height: 16px; color: var(--text-muted); } + +/* icons: an inline SVG sprite in the page, no icon font */ +.i { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vertical-align: -3px; } +.i.sm { width: 13px; height: 13px; vertical-align: -2px; } + +/* header: logo, edition tag and Help */ +.app-header { position: relative; z-index: 50; height: var(--topbar-h); background: var(--surface); border-bottom: 1px solid var(--border); } +.app-header .inner { max-width: 1184px; height: 100%; margin: 0 auto; padding: 0 var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } +.brand { display: inline-flex; align-items: center; gap: 8px; text-decoration: none; } +.brand img { display: block; height: 28px; width: auto; } +.brand-tag { font-size: 11px; line-height: 16px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--brand); background: var(--primary-50); border: 1px solid var(--primary-100); border-radius: 6px; padding: 1px 7px; } +.help-btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border-strong); background: var(--surface); color: var(--text-secondary); font: inherit; font-weight: 500; padding: 6px 12px; border-radius: var(--radius-pill); cursor: pointer; } +.help-btn:hover { color: var(--brand); border-color: var(--brand); } +.help-btn .i { width: 16px; height: 16px; } + +/* layout: one column with the sticky section bar; from 1200 px the bar is a sidebar */ +.card { background: var(--surface); border-radius: var(--radius-card); box-shadow: var(--shadow-card); } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--border); } +.card-body { padding: var(--space-5) var(--space-6) var(--space-6); } +@media (max-width: 575.98px) { .card-head, .card-body { padding-left: var(--space-4); padding-right: var(--space-4); } } +.shell { max-width: 1184px; margin: 0 auto; padding: var(--space-5) var(--space-4) 64px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "title" "nav" "main"; } +.shell > .titlebar { grid-area: title; } +.shell > .secnav { grid-area: nav; } +.shell > main { grid-area: main; min-width: 0; } +@media (min-width: 1200px) { + .shell { max-width: 1400px; grid-template-columns: 208px minmax(0, 1fr); grid-template-areas: "nav title" "nav main"; column-gap: var(--space-8); } + .shell > .titlebar { margin-bottom: var(--space-6); } + .secnav { grid-row: 1 / span 2; align-self: start; top: var(--space-4); margin: 0; background: none; backdrop-filter: none; border-bottom: 0; } + .secnav ul { flex-direction: column; overflow: visible; gap: 2px; } + .secnav a { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 0; border-radius: 8px; white-space: normal; } + .secnav a:hover { background: var(--surface); color: var(--text); } + .secnav a[aria-current="true"] { background: var(--surface); box-shadow: inset 3px 0 0 var(--brand); } + .app-header .inner { max-width: 1400px; } +} + +/* buttons, chips, menus */ +.btn { display: inline-flex; align-items: center; gap: 8px; border-radius: var(--radius-control); border: 1px solid var(--brand); background: var(--surface); color: var(--brand); font: inherit; font-weight: 500; padding: 8px 14px; text-decoration: none; cursor: pointer; white-space: nowrap; } +.chip { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--radius-pill); padding: 2px 10px; font-size: 12px; font-weight: 600; white-space: nowrap; line-height: 20px; } +.chip.success { background: var(--status-success-bg); color: var(--status-success); } +.chip.offline { background: var(--status-pending-bg); color: var(--status-pending); } +.menu { position: relative; } +.menu > summary { list-style: none; } +.menu > summary::-webkit-details-marker { display: none; } +.menu-list { position: absolute; right: 0; top: calc(100% + 6px); z-index: 40; min-width: 260px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow-popover); padding: 6px; display: grid; } +.menu-list a { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 8px; color: var(--text); text-decoration: none; } +.menu-list a:hover, .menu-list a:focus-visible { background: var(--surface-muted); outline: none; } + +/* title bar */ +.titlebar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding: var(--space-4) var(--space-6); } +.titlebar .name { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; min-width: 0; } +.titlebar .name img.provider-logo { width: 28px; height: 24px; object-fit: contain; } +.titlebar > div:first-child { flex: 1 1 520px; min-width: 0; } +.titlebar .actions { display: flex; gap: var(--space-3); align-self: flex-start; } +a.platform-link { text-decoration: none; } +a.platform-link:hover { background: var(--primary-100); } +@media (max-width: 575.98px) { .titlebar { padding: var(--space-4); } .titlebar .actions { width: 100%; } .titlebar .actions > * { flex: 1; } .titlebar .actions .btn, .titlebar .actions summary { width: 100%; justify-content: center; } } + +/* section nav */ +.secnav { position: sticky; top: 0; z-index: 20; background: rgba(243,246,246,.94); backdrop-filter: blur(6px); border-bottom: 1px solid var(--border); margin: var(--space-4) 0 var(--space-6); } +.secnav ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; } +.secnav a { display: block; padding: 12px; color: var(--text-muted); text-decoration: none; white-space: nowrap; font-weight: 500; border-bottom: 2px solid transparent; } +.secnav a[aria-current="true"] { color: var(--brand); border-bottom-color: var(--brand); } +.secnav .n { font-weight: 400; margin-left: 4px; } +.secnav a .i { margin-left: 6px; color: var(--text-muted); } +section.block { margin-bottom: var(--space-6); scroll-margin-top: 64px; } + +/* overview cards */ +.overview { display: grid; grid-template-columns: 1.05fr 1fr 1.15fr; gap: var(--space-6); } +@media (max-width: 1099.98px) { .overview { grid-template-columns: 1fr 1fr; } .overview .costs { grid-column: 1 / -1; } } +@media (max-width: 767.98px) { .overview { grid-template-columns: 1fr; } } +.overview .card { padding: var(--space-5) var(--space-6); } +dl.scope { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: var(--space-3) 0 0; } +dl.scope dt { color: var(--text-muted); } +dl.scope dd { margin: 0; color: var(--text); font-weight: 500; overflow-wrap: anywhere; } +dl.scope dd small { display: block; font-weight: 400; color: var(--text-muted); font-size: 12px; } +.sev { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; color: var(--text); } +.pips { display: inline-flex; gap: 2px; } +.pips i { width: 8px; height: 8px; border-radius: 1px; background: var(--sev-off); } +.sev.critical .pips i { background: var(--sev-critical); } +.sev.high .pips i:nth-child(-n+3) { background: var(--sev-high); } +.sev.medium .pips i:nth-child(-n+2) { background: var(--sev-medium); } +.sev.low .pips i:nth-child(-n+1) { background: var(--sev-low); } +.sevchart { display: grid; gap: 14px; margin-top: var(--space-5); } +.sevrow { display: grid; grid-template-columns: 96px 1fr 24px; gap: 12px; align-items: center; } +.sevrow b { text-align: right; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } +.sevbar { height: 14px; border-radius: 4px; background: var(--neutral-100); overflow: hidden; } +.sevbar span { display: block; height: 100%; border-radius: 4px; } +.sevbar span.critical { background: var(--sev-critical); } +.sevbar span.high { background: var(--sev-high); } +.sevbar span.medium { background: var(--sev-medium); } +.sevbar span.low { background: var(--sev-low); } +.kpis { display: flex; gap: var(--space-6); flex-wrap: wrap; margin-top: var(--space-2); } +.bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; align-items: end; height: 132px; margin-top: var(--space-4); } +.bars .col { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; height: 100%; } +.bars .track { flex: 1; width: 100%; display: flex; align-items: flex-end; justify-content: center; } +.bars .b { width: 100%; max-width: 40px; min-height: 2px; background: var(--primary-700); border-radius: 4px 4px 0 0; } +.bars .b.zero { background: var(--neutral-300); } +.bars .b.prov { background: repeating-linear-gradient(135deg, var(--primary-100) 0 4px, #fff 4px 8px); border: 1.5px dashed var(--primary-700); border-bottom: 0; } +.bars .m { font-size: 12px; color: var(--text-muted); } +.no-costs { display: grid; gap: 8px; margin-top: 6px; } +.no-costs p { color: var(--text-secondary); } + +/* scores: platform-only results, shown without values */ +.scores { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); } +@media (max-width: 767.98px) { .scores { grid-template-columns: 1fr; } } +.grade { display: grid; grid-template-columns: auto 1fr; gap: var(--space-6); align-items: center; } +.grade-tile { width: 96px; height: 96px; border: 2px dashed var(--border-strong); border-radius: 12px; display: grid; place-items: center; text-align: center; } +.grade-tile .l { font-size: 44px; line-height: 1; font-weight: 600; color: var(--text-muted); } +.bands { display: flex; gap: 2px; } +.bands span { flex: 1; height: 24px; display: grid; place-items: center; font-size: 11px; font-weight: 600; background: var(--neutral-200); color: var(--text-muted); } +.dims { display: grid; gap: var(--space-3); } +.dim { display: grid; grid-template-columns: 110px 1fr 48px; gap: 12px; align-items: center; color: var(--text); } +.dim .track { height: 10px; border-radius: 999px; background: var(--neutral-200); } +.ghost { opacity: .55; } +.lock-note { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--brand); background: var(--primary-50); border: 1px solid var(--primary-100); border-radius: var(--radius-pill); padding: 2px 10px; white-space: nowrap; } +button.lock-note { font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +button.lock-note:hover, button.lock-note[aria-expanded="true"] { background: var(--primary-100); } +.hint { position: relative; display: inline-flex; } +.hint-pop { position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; width: min(340px, 86vw); padding: 14px 16px; border-radius: 12px; background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow-popover); font-size: 13px; line-height: 1.55; color: var(--text-secondary); text-align: left; } +.hint-pop b { display: block; color: var(--text); font-weight: 600; margin-bottom: 4px; } +.hint-pop p + p { margin-top: 8px; } +.hint-pop .foot { font-size: 12px; color: var(--text-muted); } +.hint-pop a { color: var(--brand); } +.hint-pop.flip { right: auto; left: 0; } +@media (max-width: 575.98px) { .hint-pop, .hint-pop.flip { position: fixed; left: 12px; right: 12px; top: auto; bottom: 12px; width: auto; } } + +/* tables */ +.table-wrap { overflow-x: auto; } +table.data { width: 100%; border-collapse: collapse; } +table.data thead th { text-align: left; font-size: 12px; font-weight: 600; color: var(--brand); background: var(--thead-bg); padding: 9px 12px; border-bottom: 2px solid var(--thead-rule); white-space: nowrap; } +table.data th.num, table.data td.num { text-align: right; font-variant-numeric: tabular-nums; } +table.data tbody td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; color: var(--text); } +table.data tr.group td { font-size: 12px; font-weight: 600; color: var(--text-muted); background: #fafbfb; padding: 8px 12px; } +table.data tr.total td { font-weight: 600; border-bottom: 0; } +.share { display: inline-flex; align-items: center; gap: 8px; min-width: 120px; } +.share .bar { flex: 1; height: 6px; background: var(--neutral-200); border-radius: 3px; overflow: hidden; min-width: 60px; } +.share .bar span { display: block; height: 100%; background: var(--primary-700); } +.share b { font-weight: 500; min-width: 36px; text-align: right; font-variant-numeric: tabular-nums; } +.rt { display: flex; gap: 10px; align-items: center; font-weight: 500; } +.rt img { width: 22px; height: 22px; object-fit: contain; flex: none; } +.rt .ph { width: 22px; height: 22px; flex: none; border-radius: 5px; background: var(--neutral-200); color: var(--text-secondary); font-size: 10px; font-weight: 600; display: grid; place-items: center; } +.loc { color: var(--text-muted); font-size: 12px; white-space: nowrap; } +@media (max-width: 575.98px) { .hide-sm { display: none; } } +.empty-state { display: grid; gap: 4px; justify-items: center; text-align: center; padding: 28px 16px; border: 1px dashed var(--border-strong); border-radius: 12px; color: var(--text-muted); } +.empty-state b { color: var(--text); font-weight: 600; } + +/* risks */ +.rowbtn { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: baseline; } +.rowbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; } +.rowbtn .chev { transition: transform .15s; color: var(--text-muted); } +.rowbtn[aria-expanded="true"] .chev { transform: rotate(90deg); } +.rname { font-weight: 500; } +tr.detail td { background: #fafbfb; color: var(--text-secondary); font-size: 13px; } +tr.detail[hidden] { display: none !important; } +@media (max-width: 575.98px) { + table.risks thead { display: none; } + table.risks tr.r { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); } + table.risks tr.r td { border: 0; padding: 0 4px; } + table.risks tr.r td.sevc { grid-row: 1; justify-self: end; } + table.risks tr.r td.name, table.risks tr.r td.aff { grid-column: 1 / -1; } + table.risks tr.group, table.risks tr.detail, table.risks tr.group td, table.risks tr.detail td { display: block; } +} + +/* alternatives */ +.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.searchbox { position: relative; display: inline-block; } +.searchbox .i { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; } +.search { border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 7px 10px 7px 32px; font: inherit; width: 240px; background: #fff; color: var(--text); } +@media (max-width: 575.98px) { .search, .searchbox { width: 100%; } } +.fchip { border: 1px solid var(--border-strong); border-radius: var(--radius-pill); padding: 6px 12px; background: #fff; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; } +.fchip[aria-pressed="true"] { background: var(--brand); color: #fff; border-color: var(--brand); } +details.grp { border: 1px solid var(--border); border-radius: 12px; margin-bottom: var(--space-3); overflow: hidden; background: #fff; } +details.grp > summary { list-style: none; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; } +details.grp > summary::-webkit-details-marker { display: none; } +details.grp > summary .t { font-weight: 600; color: var(--text); display: flex; gap: 8px; align-items: center; } +details.grp > summary .t img { width: 20px; height: 20px; object-fit: contain; } +details.grp > summary .m { color: var(--text-muted); font-size: 12px; text-align: right; } +details.grp[open] > summary { border-bottom: 1px solid var(--border); } +details.grp > summary .t::before, details.disc > summary::before { content: ""; width: 6px; height: 6px; border-right: 1.6px solid var(--text-muted); border-bottom: 1.6px solid var(--text-muted); transform: rotate(-45deg); transition: transform .15s; flex: none; margin: 0 2px; } +details.grp[open] > summary .t::before, details.disc[open] > summary::before { transform: rotate(45deg); } +details.grp table.data thead th { background: #f6f9fa; } +.alt-name { font-weight: 500; } +.alt-meta { display: block; color: var(--text-muted); font-size: 12px; } +.cc { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .04em; color: var(--text-secondary); border: 1px solid var(--border-strong); border-radius: 4px; padding: 0 4px; margin-left: 4px; vertical-align: 1px; } +.more { padding: 10px 16px; font-weight: 500; } +.more a { text-decoration: none; } +.alt-empty { padding: 16px; border: 1px dashed var(--border-strong); border-radius: 12px; color: var(--text-muted); text-align: center; } +table.alts th:nth-child(n+2), table.alts td:nth-child(n+2) { text-align: center; width: 120px; } +table.alts .mark { width: 18px; height: 18px; stroke-width: 2.2; vertical-align: middle; } +table.alts .yes .mark { color: var(--status-success); } +table.alts .no .mark { color: var(--neutral-300); stroke-width: 1.8; } + +/* egress */ +.kpi-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); } +@media (max-width: 767.98px) { .kpi-row { grid-template-columns: 1fr 1fr; } } +.kpi { border: 1px solid var(--border); border-radius: 12px; padding: var(--space-4); } +.kpi.primary { background: var(--primary-50); border-color: var(--primary-100); } +.kpi .t-figure { margin-top: 6px; font-size: 24px; } +.kpi .t-caption { margin-top: 4px; } +.headroom { margin-top: 10px; height: 8px; border-radius: 4px; background: var(--neutral-200); overflow: hidden; } +.headroom span { display: block; height: 100%; background: var(--primary-700); } +.vol { display: flex; height: 14px; gap: 2px; border-radius: 7px; overflow: hidden; margin: var(--space-6) 0 var(--space-2); } +.vol-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text); } +.vol-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; } +.unsized { display: inline-flex; align-items: center; gap: 4px; color: var(--hotspot-ink); font-size: 12px; font-weight: 500; white-space: nowrap; } +.fine { margin-top: var(--space-4); display: grid; gap: 6px; } +details.prices { margin-top: var(--space-4); border: 1px solid var(--border); border-radius: 12px; } +details.prices > summary { cursor: pointer; padding: 10px 14px; font-weight: 500; color: var(--brand); } +details.prices[open] > summary { border-bottom: 1px solid var(--border); } +details.prices .inner { padding: 4px 14px 14px; } + +/* about this report */ +.kv { width: 100%; border-collapse: collapse; } +.kv th { text-align: left; font-weight: 500; color: var(--text-muted); width: 200px; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; } +.kv td { color: var(--text); padding: 8px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; } +.kv-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); } +@media (max-width: 991.98px) { .kv-grid { grid-template-columns: 1fr; } } +.kv-grid h3 { margin-bottom: var(--space-2); } +.kv-grid .kv th { width: 44%; } +details.disc { border-top: 1px solid var(--border); } +details.disc:last-child { border-bottom: 1px solid var(--border); } +details.disc > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; padding: 14px 0; font-weight: 600; color: var(--text); } +details.disc > summary::-webkit-details-marker { display: none; } +details.disc > summary .m { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--text-muted); } +details.disc .inner { padding: 0 0 var(--space-5) 20px; display: grid; gap: 10px; color: var(--text-secondary); max-width: 860px; } +details.disc .inner ul { margin: 0; padding-left: 18px; display: grid; gap: 4px; } + +/* disclaimer and legal notices */ +.disclaimer { margin-top: var(--space-8); display: grid; grid-template-columns: 22px 1fr; gap: 12px; background: var(--hotspot-bg); border: 1px solid #F3D9A8; border-radius: 12px; padding: var(--space-4) var(--space-5); color: #5C4A1F; font-size: 13px; line-height: 1.55; } +.disclaimer > .i { width: 20px; height: 20px; color: var(--hotspot-ink); margin-top: 1px; } +.disclaimer h2 { font-size: 14px; font-weight: 600; color: #3D2F10; margin-bottom: 4px; } +.disclaimer p + p { margin-top: 6px; } +.disclaimer a { color: #6B4E12; } +.notices { border-top: 1px solid var(--border); margin-top: var(--space-8); padding-top: var(--space-6); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); font-size: 12px; line-height: 1.55; color: var(--text-muted); } +@media (max-width: 899.98px) { .notices { grid-template-columns: 1fr; } } +.notices h2 { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 6px; } +.notices p + p { margin-top: 6px; } +.notices a { color: var(--text-secondary); } +.disclaimer + .notices { margin-top: var(--space-5); border-top: 0; padding-top: 0; } +.copyright { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; padding-top: var(--space-4); border-top: 1px solid var(--border); } + +/* guided tour (driver.js, assets/js) */ +.driver-popover.cx-tour { font-family: 'IBM Plex Sans', system-ui, sans-serif; border-radius: 12px; padding: 16px 18px; max-width: 340px; box-shadow: var(--shadow-popover); color: var(--text-secondary); } +.driver-popover.cx-tour .driver-popover-title { font-family: inherit; font-size: 15px; font-weight: 600; color: var(--text); } +.driver-popover.cx-tour .driver-popover-description { font-family: inherit; font-size: 13px; line-height: 1.55; color: var(--text-secondary); margin-top: 4px; } +.driver-popover.cx-tour .driver-popover-progress-text { font-family: inherit; font-size: 12px; color: var(--text-muted); } +.driver-popover.cx-tour .driver-popover-footer button { font-family: inherit; font-size: 13px; font-weight: 500; text-shadow: none; border: 1px solid var(--brand); color: var(--brand); background: var(--surface); border-radius: var(--radius-control); padding: 5px 12px; } +.driver-popover.cx-tour .driver-popover-footer .driver-popover-next-btn { background: var(--brand); color: #fff; } +.driver-popover.cx-tour .driver-popover-close-btn { color: var(--text-muted); } + +@media print { + body { background: #fff; } + .help-btn, .menu, .secnav, .hint-pop, .toolbar, .more, .driver-popover, .driver-overlay, .driver-active-element { display: none !important; } + .app-header { height: auto; border-bottom: 0; } + .shell { display: block; max-width: none; padding: 0; } + .card { box-shadow: none; border: 1px solid var(--border); } + section.block, details.grp, .kpi { break-inside: avoid; } + tr.detail[hidden], table.alts tr[hidden] { display: table-row !important; } +} diff --git a/cloudexit/assets/css/style.css b/cloudexit/assets/css/style.css deleted file mode 100644 index 0835fdc..0000000 --- a/cloudexit/assets/css/style.css +++ /dev/null @@ -1,1107 +0,0 @@ -/* ======================================================== - Base: Variables - ======================================================== */ -:root { - /* Blue */ - --blue-100: #dbe6fe; - --blue-800: #1e4baf; - - /* Green */ - --green-100: #dcfce7; - --green-600: #16a34a; - --green-700: #047854; - - /* Neutral */ - --neutral-50: #f9fbfb; - --neutral-100: #f3f6f6; - --neutral-200: #e5ebeb; - --neutral-300: #d1dbdb; - --neutral-400: #9cafae; - --neutral-600: #4b6361; - --neutral-800: #1f3735; - --neutral-900: #112726; - - /* Primary */ - --primary-600: #0d948b; - --primary-800: #115e59; - --primary-950: #042f2c; - - /* Red */ - --red-50: #fef2f2; - --red-100: #fee2e2; - --red-700: #b91c1c; - --red-800: #991b1b; - - /* Yellow */ - --yellow-100: #fee4c7; - --yellow-850: #92400e; - /* Color Palette */ - --white: #fff; - /* Transition Defaults */ - --transition-speed: 0.3s; - - /* Font Sizes */ - --text-heading-2: 24px; - --text-heading-3: 20px; - --text-body: 16px; - --text-label: 14px; - --text-label-small: 12px; - /* Radius */ - --rounded-md: 12px; - --rounded-sm: 8px; - --rounded-xs: 4px; - /* Sidebar */ -} - -/* ======================================================== - Base: Reset - ======================================================== */ -* { - margin: 0; - padding: 0; - -webkit-box-sizing: border-box; - box-sizing: border-box; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - line-height: 1.375; - font-weight: 400; - color: var(--neutral-900); - background: var(--neutral-100); - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-font-smoothing: antialiased; -} - -img { - max-width: 100%; - height: auto; -} - -ul { - list-style: none; - padding: 0; - margin: 0; -} - -.divider-border { - height: 1px; - background: var(--neutral-200); -} - -.container { -} - -/* ======================================================== - Base: Typography - ======================================================== */ -a { - text-decoration: none; - -webkit-transition: all var(--transition-speed) ease; - -o-transition: all var(--transition-speed) ease; - transition: all var(--transition-speed) ease; -} - -a:hover { - text-decoration: none !important; -} - - -h2, -h3 { - color: var(--neutral-900); - font-weight: 500; -} - - -h2, -h3, -li, -p { - margin: 0; -} - - -h2 { - font-size: var(--text-heading-2); - line-height: 1.33; -} - - -h3 { - font-size: var(--text-heading-3); - line-height: 1.4; -} - - -body, -p { - font-size: var(--text-body); - line-height: 1.375; -} - -.label, -label { - font-size: var(--text-label); - line-height: 1.43; -} - -.small, -label.small { - font-size: var(--text-label-small); - line-height: 1.33; -} - -.bg-white { - background: var(--white); -} - -a*:active, -a*:focus { - outline: 0; - border: 0; -} - -main { - padding-bottom: 32px; - padding-top: 88px; -} - -/* Scrollbars */ -::-webkit-scrollbar { - width: 5px; - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-track { - background: var(--neutral-100); - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-thumb { - background: var(--primary-600); - border-radius: var(--rounded-sm); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--primary-800); - border-radius: var(--rounded-sm); -} - -.dropdown-item span { - font-size: var(--text-label); - font-weight: 400; - color: var(--neutral-900); -} - -.dropdown-item { - padding-top: 8px; - padding-bottom: 8px; -} - -.dropdown-menu { - padding: 0; -} - -.dropdown-menu li a { - line-height: 1.5; -} - -/* ======================================================== - Layout: Shared Cards And Charts - ======================================================== */ - -.chart-card-head { - padding: 16px 24px; -} - -.chart-card-head h3 { - margin-bottom: 5px; - color: var(--neutral-900); -} -.chart-card-head h6 { - color: var(--neutral-600); - font-size: var(--text-label); - font-weight: 400; -} -.chart-card-head h6 span { - display: inline-block; - font-weight: 500; -} - -.risk-header { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - /* padding: 16px 24px; */ -} - -.risk-dashboard { - display: flex; - flex-direction: column; - height: 100%; -} - -.risk-count h2 { - color: var(--neutral-600); - font-size: var(--text-label); - font-weight: 400; -} - -.risk-count .count { - font-size: var(--text-heading-3); - font-weight: 500; - color: var(--neutral-900); - margin: 0; -} - -.chart-container { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - flex: 1; -} - -.chart-box { - height: 350px; -} - -.chart-empty-state { - flex: 1; - min-height: 220px; - display: flex; - align-items: center; - justify-content: center; - text-align: center; -} - -.chart-empty-state-inner { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} - -.chart-empty-state i { - font-size: 28px; - line-height: 1; - color: var(--neutral-900); -} - -.chart-empty-state p { - margin: 0; - color: var(--neutral-700); -} - -.alt-tech-empty-state { - min-height: 260px; -} - -.scoring-empty-state { - min-height: 300px; -} - -.scoring-card { - overflow: hidden; -} - -.scoring-inner { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - min-height: 300px; -} - -.chart-wrapper { - max-width: 350px; - overflow: hidden; - position: relative; - border-radius: 12px; - mask-image: linear-gradient(to bottom, black 85%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 85%, transparent 100%); -} - -.scoring { - width: 100%; - max-width: 420px; - height: 320px !important; - margin: 0 auto; -} - -.form-title { - font-size: var(--text-heading-3); - font-weight: 500; - line-height: 1.33; - color: var(--neutral-900); -} - -.shadow-s { - -webkit-box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); -} - -.input { - border: 1px solid var(--neutral-300); -} - -/* ======================================================== - Components: Buttons - ======================================================== */ - -.dropdown-toggle::after { - float: right; - margin-top: 8px; -} - -/* ======================================================== - Components: Summary, Scoring, Resources, Alt Tech Cards - ======================================================== */ -::-webkit-input-placeholder { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - line-height: 38px; -} - -::-moz-placeholder { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - line-height: 38px; -} - -.resource-card { - border: 1px solid var(--neutral-200); -} - -.resource-card h3 { - font-weight: 500; - min-height: 44px; - font-size: var(--text-label); - font-size: 16px; - font-weight: 500; - font-stretch: normal; - line-height: 1.38; - color: var(--neutral-900); -} - -.resource-card h6 { - font-size: var(--text-label); - font-weight: 400; - margin: 0; -} - -.resource-card { - height: 100%; -} - -.resource-card p { - margin-top: 16px; - margin-bottom: 16px; -} - -.resource-card img { - max-width: 100%; - margin: 0 0 16px; - height: 32px; -} - -.resource-icon-placeholder { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - margin: 0 0 16px; - border-radius: 7px; - background: #e3e7ea; - color: #4a5560; - font-size: 13px; - font-weight: 600; - line-height: 1; -} - -.gapy-3 { - gap: 16px 0; -} - -.alttech-card { - border: 1px solid var(--neutral-200); - height: 100%; -} - -.alttech-title { -} - -.alttech-title h5 { - font-size: var(--text-body); - margin: 0; - color: var(--neutral-900); -} - -.alttech-title h6 { - font-size: var(--text-label-small); - font-weight: 400; -} - -.green-700 { - color: var(--green-700); -} - -.alttech-text p { - /*overflow: hidden;*/ - /*display: -webkit-box;*/ - font-size: 14px; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.sync-status { - gap: 28px 0; -} - -.sync-status-text { -} - -.sync-status-text h5 { - margin: 0; - font-size: var(--text-body); - color: var(--neutral-900); -} - -.sync-status-text h6 { - color: var(--neutral-600); - font-size: var(--text-label); - margin-bottom: 5px; - font-weight: 400; -} - -.sync-status-icon span{ - border-radius: 8px; - background:rgba(5, 81, 96, 0.10); - height: 32px; - width: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -.view-more button { - background: transparent; - border: 0; -} - -/* ======================================================== - Components: Risk Table - ======================================================== */ -.risk-title-cell { - position: relative; - padding-left: 40px !important; - cursor: pointer; -} - -.chevron-icon { - position: absolute; - left: 16px; - top: 50%; - -webkit-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - font-size: var(--text-label-small); - color: var(--neutral-600); - -webkit-transition: -webkit-transform 0.2s ease; - transition: -webkit-transform 0.2s ease; - -o-transition: transform 0.2s ease; - transition: transform 0.2s ease; - transition: - transform 0.2s ease, - -webkit-transform 0.2s ease; -} - -.risk-row.expanded .chevron-icon { - -webkit-transform: translateY(-50%) rotate(90deg); - -ms-transform: translateY(-50%) rotate(90deg); - transform: translateY(-50%) rotate(90deg); -} - -.expandable-content { - display: none; - background-color: #f5f5f5; -} - -.expandable-content.show { - display: table-row; -} - -.expandable-content td { - padding-left: 20px; -} - -.risk-table-container .table tr th { - font-weight: 500; - font-size: var(--text-label); -} - -.risk-table-container .table th:first-child, -.risk-table-container .table td:first-child { - width: auto !important; -} - -.risk-table-container .table tr th:nth-child(3), -.risk-table-container .table tr th:nth-child(4) { - text-align: center; -} - -.impacted-count { - text-align: center; -} - -.description { - background: var(--neutral-200); - padding-top: 12px; - padding-bottom: 12px; -} - -.description-section { - margin-bottom: 10px; -} - -.risk-title { - color: var(--neutral-900); -} - -.section-label { - font-size: var(--text-label-small); - font-weight: 400; - color: var(--neutral-600); -} - -.section-content { - font-size: var(--text-label); -} - -.impacted-resources-content { - font-size: var(--text-label); -} - -.severity-badge { - color: var(--colors-red-800); - font-size: var(--text-label); - font-weight: 500; - padding: 3px 12px; - border-radius: var(--rounded-md); -} - -.severity-high { - background-color: var(--red-100); - color: var(--red-800); -} - -.severity-medium { - background: var(--yellow-100); - color: var(--yellow-850); -} - -.severity-low { - background: var(--blue-100); - color: var(--blue-800); -} - -.btn { - border-radius: 6px; - line-height: 40px; - padding: 0 16px; - font-weight: 500; - -webkit-transition: 0.4s; - -o-transition: 0.4s; - transition: 0.4s; -} - -.btn-primary { - background: var(--primary-800); - color: var(--white); -} - -.btn-outline-primary { - color: var(--primary-800); -} - -.btn-light { - background: var(--neutral-100); - border: 1px solid var(--neutral-100); - color: var(--neutral-600); -} - -.btn-outline-primary, -.btn-primary { - border: 1px solid var(--primary-800); -} - -.btn-sm { - font-size: var(--text-label); - font-weight: 500; - line-height: 34px; -} - -.btn-primary:hover { - background: var(--primary-950); - border-color: var(--primary-950); -} - -.btn-primary:hover svg:not(.filter-icon) path { - fill: var(--white); -} - -.btn-outline-primary:focus svg:not path, -.btn-outline-primary:active svg:not path, -.btn-outline-primary:hover svg:not path { - fill: var(--white); -} - -.btn-outline-primary:hover svg.filter-icon path { - stroke: var(--white); -} - -.btn-outline-primary:active, -.btn-outline-primary:hover { - background: var(--primary-800) !important; - border-color: var(--primary-800) !important; - color: var(--white) !important; -} -.btn-outline-primary:active svg path, -.btn-outline-primary:hover svg path { - fill: white; -} - -.btn-light:hover { - background: var(--neutral-200); - border-color: var(--neutral-200); -} - -.dropdown-toggle::after { - border: 0; - display: none; -} - -.dropdown-toggle { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - gap: 8px; - padding-left: 16px; - padding-right: 16px; -} - -.btn-sm.dropdown-toggle { - padding-left: 12px; - padding-right: 12px; -} - -.btn:focus-visible, -.btn.show:focus-visible, -.btn:first-child:active:focus-visible, -:not(.btn-check) + .btn:active:focus-visible { - -webkit-box-shadow: none !important; - box-shadow: none !important; -} - -.btn-primary, -.btn-outline-primary { - /* --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - --bs-btn-focus-shadow-rgb: 13, 110, 253; - --bs-btn-disabled-bg: transparent; - --bs-btn-active-color: var(--neutral-white); - --bs-btn-hover-color: var(--neutral-white); - --bs-gradient: none; */ -} - -.btn-outline-primary { - --bs-btn-color: var(--primary-800); - --bs-btn-border-color: var(--primary-800); - --bs-btn-hover-color: var(--white); - --bs-btn-hover-bg: var(--primary-800); - --bs-btn-hover-border-color: var(--primary-800); - --bs-btn-active-color: var(--white); - --bs-btn-active-bg: var(--primary-800); - --bs-btn-active-border-color: var(--primary-800); - --bs-btn-disabled-color: var(--neutral-400); - --bs-btn-disabled-border-color: var(--neutral-300); - --bs-btn-focus-shadow-rgb: 17, 94, 89; -} - -.btn-outline-primary.show, -.btn-check:checked + .btn-outline-primary, -.btn-check:active + .btn-outline-primary, -.btn-outline-primary:focus, -.btn-outline-primary:focus-visible { - background: var(--primary-800) !important; - border-color: var(--primary-800) !important; - color: var(--white) !important; -} - -.btn-outline-primary.show svg.filter-icon path, -.btn-check:checked + .btn-outline-primary svg.filter-icon path, -.btn-check:active + .btn-outline-primary svg.filter-icon path, -.btn-outline-primary:focus svg.filter-icon path, -.btn-outline-primary:focus-visible svg.filter-icon path { - stroke: var(--white); -} - -.btn-primary.disabled, -.btn-primary:disabled { - border-color: var(--neutral-400); - background: var(--neutral-400); - color: var(--white); -} - -.btn-outline-primary.disabled, -.btn-outline-primary:disabled { - border-color: var(--neutral-300); - color: var(--neutral-400); -} - -/* ======================================================== - Components: Filter Toggles - ======================================================== */ -.toggle-switch { - position: relative; - width: 44px; - height: 24px; -} - -.toggle-switch input[type="checkbox"] { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--neutral-200); - -webkit-transition: 0.3s ease; - -o-transition: 0.3s ease; - transition: 0.3s ease; - border-radius: var(--rounded-md); -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 20px; - width: 20px; - left: 2px; - bottom: 2px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%23D1DBDB' stroke-width='1.5'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); -} - -.toggle-slider:before { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%23D1DBDB' stroke-width='1.5'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; - background-size: 20px 20px; -} - -input:checked + .toggle-slider { - background-color: var(--primary-800); -} - -input:checked + .toggle-slider:before { - -webkit-transform: translateX(20px); - -ms-transform: translateX(20px); - transform: translateX(20px); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='9.25' fill='white' stroke='%230D948B' stroke-width='1.5'/%3E%3Cpath d='M5.5 10.5L8.5 13.5L14.5 7.5' stroke='%230D948B' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; - background-size: 20px 20px; -} - -/* ======================================================== - Components: Tables - ======================================================== */ -table:not("#assessmentsTable") { - table-layout: fixed; - width: 100%; -} - -.table > :not(caption) > * > * { - color: var(--neutral-900); - font-size: var(--text-body); - padding-top: 16px; - padding-bottom: 16px; - border-color: var(--neutral-200); -} - -tr:last-child td { - border-bottom: 0; -} - -.risk-table-container { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.risk-table-container table { - min-width: 420px; -} - -.risk-table-container::-webkit-scrollbar { - height: 20px; -} - -.risk-table-container::-webkit-scrollbar-track { - background: var(--white); -} - -.risk-table-container::-webkit-scrollbar-thumb { - background: var(--neutral-300); - border-radius: 20px; -} - -.risk-table-container::-webkit-scrollbar-thumb:hover { - background: var(--neutral-600); -} - -th:first-child, -td:first-child { - width: 50px !important; - font-weight: 500; -} - -td .number, -td button { - margin-left: auto; - margin-right: auto; -} - -/* ======================================================== - Layout: Main Content And Forms - ======================================================== */ - -#main-content { - height: 100%; - width: 100%; - overflow: hidden; - padding: 20px 60px 20px 60px; - transition: all 0.3s linear; - -webkit-transition: all 0.3s linear; -} - -.visit span { - font-size: 14px; - word-break: break-all; -} - -.form-control, -.form-select { - color: var(--neutral-600); - font-size: var(--text-body); - font-style: normal; - font-weight: 400; - border-radius: 8px; - border: 1px solid var(--neutral-300); - background: var(--white); - -webkit-transition: border-color 0.3s ease, box-shadow 0.3s ease; - -o-transition: border-color 0.3s ease, box-shadow 0.3s ease; - transition: border-color 0.3s ease, box-shadow 0.3s ease; - -webkit-box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); -} - -.form-select { - -webkit-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6L8 10L12 6' stroke='%23115E59' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px 16px; - padding-right: 40px; -} - -.form-control:focus, -.form-select:focus { - border-color: var(--primary-800); - box-shadow: 0 0 0 0.2rem rgba(17, 94, 89, 0.12); -} - -.custom-search { - min-width: 240px; - height: 36px; - border-radius: 8px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3.5C5.96243 3.5 3.5 5.96243 3.5 9C3.5 12.0376 5.96243 14.5 9 14.5C10.519 14.5 11.893 13.8852 12.8891 12.8891C13.8852 11.893 14.5 10.519 14.5 9C14.5 5.96243 12.0376 3.5 9 3.5ZM2 9C2 5.13401 5.13401 2 9 2C12.866 2 16 5.13401 16 9C16 10.6625 15.4197 12.1906 14.4517 13.3911L17.7803 16.7197C18.0732 17.0126 18.0732 17.4874 17.7803 17.7803C17.4874 18.0732 17.0126 18.0732 16.7197 17.7803L13.3911 14.4517C12.1906 15.4197 10.6625 16 9 16C5.13401 16 2 12.866 2 9Z' fill='%236B807F'/%3E%3C/svg%3E") !important; - background-repeat: no-repeat !important; - background-position: 16px center !important; - padding: 0 16px 0 48px !important; - width: 400px; -} - -.btn-clear:hover { - text-decoration: underline; -} - -.alt-tech-card { - background: #f5f5f5; - border-radius: 10px; -} - -.alt-tech-card h3 { - font-size: 18px; - margin: 0; -} - -/* ======================================================== - Components: Alternative Technology Status And Hints - ======================================================== */ - -.verified { - background: var(--green-600); - color: var(--white); -} -.green-100 { - background: var(--green-100); -} -.green-700 { - color: var(--green-700); -} -.tags span { - background: var(--neutral-100); - padding: 4px 8px; - border-radius: 6px; - font-size: var(--text-label); - color: var(--neutral-700); - font-weight: 500; -} -.verified span { - font-size: var(--text-label); -} -.red-700 { - color: var(--red-700); -} -.red-50 { - background: var(--red-50); -} - -.info-hint-box { - position: relative; - display: inline-block; - margin-left: 6px; - cursor: pointer; -} -.info-hint-box i { - font-size: 16px; - color: var(--primary-600, #007bff); - transition: color 0.2s; -} -.info-hint-box:hover i { - color: var(--primary-800, #0056b3); -} -.hint-hoverbox { - display: none; - position: absolute; - top: 28px; - right: 0; - z-index: 1000; - background: #fff; - border: 1px solid var(--neutral-200, #e5e7eb); - border-radius: 8px; - padding: 12px 14px; - width: 280px; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); -} -.info-hint-box:hover .hint-hoverbox { - display: block; -} -.hint-hoverbox { - opacity: 0; - visibility: hidden; - transition: all 0.2s ease-in-out; -} -.info-hint-box:hover .hint-hoverbox { - opacity: 1; - visibility: visible; -} -.hint-hoverbox h5 { - font-size: 14px; - font-weight: 600; - margin-bottom: 6px; -} -.hint-hoverbox p { - font-size: 13px; - color: #555; - margin: 0; - line-height: 1.4; -} - -/* ======================================================== - Responsive - ======================================================== */ - -@media (max-width: 991px) { - - .alt-tech-card div { - width: 100%; - /* justify-content: flex-end; */ - } - - .alt-tech-card { - -ms-flex-wrap: wrap; - flex-wrap: wrap; - gap: 16px; - } - - .chart-card-head { - padding-left: 16px; - padding-right: 16px; - } - - #main-content { - margin-left: 0; - } -} - -@media (max-width: 767px) { - main { - padding-top: 24px; - } - - .btn { - padding-left: 12px; - padding-right: 12px; - } - - main { - padding-top: 88px; - } - -} - -@media (max-width: 575px) { - -} diff --git a/cloudexit/assets/img/csp/alibaba.svg b/cloudexit/assets/img/csp/alibaba.svg new file mode 100644 index 0000000..f352ee9 --- /dev/null +++ b/cloudexit/assets/img/csp/alibaba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/aws.svg b/cloudexit/assets/img/csp/aws.svg new file mode 100644 index 0000000..08b8262 --- /dev/null +++ b/cloudexit/assets/img/csp/aws.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/azure.svg b/cloudexit/assets/img/csp/azure.svg new file mode 100644 index 0000000..61cb535 --- /dev/null +++ b/cloudexit/assets/img/csp/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/csp/gcp.svg b/cloudexit/assets/img/csp/gcp.svg new file mode 100644 index 0000000..bc3b271 --- /dev/null +++ b/cloudexit/assets/img/csp/gcp.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/cloudexit/assets/img/logo/README.md b/cloudexit/assets/img/logo/README.md new file mode 100644 index 0000000..13ca4ea --- /dev/null +++ b/cloudexit/assets/img/logo/README.md @@ -0,0 +1,3 @@ +## EscapeCloud logo files + +The EscapeCloud logo files in this directory are not covered by this project's LICENSE. All rights reserved. diff --git a/cloudexit/assets/js/driver.js.LICENSE.txt b/cloudexit/assets/js/driver.js.LICENSE.txt new file mode 100644 index 0000000..7ceaeef --- /dev/null +++ b/cloudexit/assets/js/driver.js.LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Kamran Ahmed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/cloudexit/assets/js/driver.js.iife.js b/cloudexit/assets/js/driver.js.iife.js new file mode 100644 index 0000000..87acd6e --- /dev/null +++ b/cloudexit/assets/js/driver.js.iife.js @@ -0,0 +1,2 @@ +this.driver=this.driver||{};this.driver.js=function(D){"use strict";let F={};function z(e={}){F={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?F[e]:F}function W(e,o,t,i){return(e/=i/2)<1?t/2*e*e+o:-t/2*(--e*(e-2)-1)+o}function q(e){const o='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(t=>{const i=t.matches(o),p=Array.from(t.querySelectorAll(o));return[...i?[t]:[],...p]}).filter(t=>getComputedStyle(t).pointerEvents!=="none"&&ae(t))}function V(e){if(!e||se(e))return;const o=a("smoothScroll");e.scrollIntoView({behavior:!o||re(e)?"auto":"smooth",inline:"center",block:"center"})}function re(e){if(!e||!e.parentElement)return;const o=e.parentElement;return o.scrollHeight>o.clientHeight}function se(e){const o=e.getBoundingClientRect();return o.top>=0&&o.left>=0&&o.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&o.right<=(window.innerWidth||document.documentElement.clientWidth)}function ae(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let N={};function b(e,o){N[e]=o}function l(e){return e?N[e]:N}function K(){N={}}let E={};function O(e,o){E[e]=o}function _(e){var o;(o=E[e])==null||o.call(E)}function ce(){E={}}function le(e,o,t,i){let p=l("__activeStagePosition");const n=p||t.getBoundingClientRect(),f=i.getBoundingClientRect(),w=W(e,n.x,f.x-n.x,o),r=W(e,n.y,f.y-n.y,o),v=W(e,n.width,f.width-n.width,o),s=W(e,n.height,f.height-n.height,o);p={x:w,y:r,width:v,height:s},Y(p),b("__activeStagePosition",p)}function X(e){if(!e)return;const o=e.getBoundingClientRect(),t={x:o.x,y:o.y,width:o.width,height:o.height};b("__activeStagePosition",t),Y(t)}function de(){const e=l("__activeStagePosition"),o=l("__overlaySvg");if(!e)return;if(!o){console.warn("No stage svg found.");return}const t=window.innerWidth,i=window.innerHeight;o.setAttribute("viewBox",`0 0 ${t} ${i}`)}function pe(e){const o=ue(e);document.body.appendChild(o),G(o,t=>{t.target.tagName==="path"&&_("overlayClick")}),b("__overlaySvg",o)}function Y(e){const o=l("__overlaySvg");if(!o){pe(e);return}const t=o.firstElementChild;if((t==null?void 0:t.tagName)!=="path")throw new Error("no path element found in stage svg");t.setAttribute("d",j(e))}function ue(e){const o=window.innerWidth,t=window.innerHeight,i=document.createElementNS("http://www.w3.org/2000/svg","svg");i.classList.add("driver-overlay","driver-overlay-animated"),i.setAttribute("viewBox",`0 0 ${o} ${t}`),i.setAttribute("xmlSpace","preserve"),i.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),i.setAttribute("version","1.1"),i.setAttribute("preserveAspectRatio","xMinYMin slice"),i.style.fillRule="evenodd",i.style.clipRule="evenodd",i.style.strokeLinejoin="round",i.style.strokeMiterlimit="2",i.style.zIndex="10000",i.style.position="fixed",i.style.top="0",i.style.left="0",i.style.width="100%",i.style.height="100%";const p=document.createElementNS("http://www.w3.org/2000/svg","path");return p.setAttribute("d",j(e)),p.style.fill=a("overlayColor")||"rgb(0,0,0)",p.style.opacity=`${a("overlayOpacity")}`,p.style.pointerEvents="auto",p.style.cursor="auto",i.appendChild(p),i}function j(e){const o=window.innerWidth,t=window.innerHeight,i=a("stagePadding")||0,p=a("stageRadius")||0,n=e.width+i*2,f=e.height+i*2,w=Math.min(p,n/2,f/2),r=Math.floor(Math.max(w,0)),v=e.x-i+r,s=e.y-i,c=n-r*2,d=f-r*2;return`M${o},0L0,0L0,${t}L${o},${t}L${o},0Z + M${v},${s} h${c} a${r},${r} 0 0 1 ${r},${r} v${d} a${r},${r} 0 0 1 -${r},${r} h-${c} a${r},${r} 0 0 1 -${r},-${r} v-${d} a${r},${r} 0 0 1 ${r},-${r} z`}function ve(){const e=l("__overlaySvg");e&&e.remove()}function fe(){const e=document.getElementById("driver-dummy-element");if(e)return e;let o=document.createElement("div");return o.id="driver-dummy-element",o.style.width="0",o.style.height="0",o.style.pointerEvents="none",o.style.opacity="0",o.style.position="fixed",o.style.top="50%",o.style.left="50%",document.body.appendChild(o),o}function Q(e){const{element:o}=e;let t=typeof o=="string"?document.querySelector(o):o;t||(t=fe()),ge(t,e)}function he(){const e=l("__activeElement"),o=l("__activeStep");e&&(X(e),de(),oe(e,o))}function ge(e,o){const i=Date.now(),p=l("__activeStep"),n=l("__activeElement")||e,f=!n||n===e,w=e.id==="driver-dummy-element",r=n.id==="driver-dummy-element",v=a("animate"),s=o.onHighlightStarted||a("onHighlightStarted"),c=(o==null?void 0:o.onHighlighted)||a("onHighlighted"),d=(p==null?void 0:p.onDeselected)||a("onDeselected"),m=a(),g=l();!f&&d&&d(r?void 0:n,p,{config:m,state:g}),s&&s(w?void 0:e,o,{config:m,state:g});const u=!f&&v;let h=!1;xe(),b("previousStep",p),b("previousElement",n),b("activeStep",o),b("activeElement",e);const P=()=>{if(l("__transitionCallback")!==P)return;const x=Date.now()-i,y=400-x<=400/2;o.popover&&y&&!h&&u&&(J(e,o),h=!0),a("animate")&&x<400?le(x,400,n,e):(X(e),c&&c(w?void 0:e,o,{config:a(),state:l()}),b("__transitionCallback",void 0),b("__previousStep",p),b("__previousElement",n),b("__activeStep",o),b("__activeElement",e)),window.requestAnimationFrame(P)};b("__transitionCallback",P),window.requestAnimationFrame(P),V(e),!u&&o.popover&&J(e,o),n.classList.remove("driver-active-element","driver-no-interaction"),n.removeAttribute("aria-haspopup"),n.removeAttribute("aria-expanded"),n.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function we(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(o=>{o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls")})}function A(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),b("__resizeTimeout",window.requestAnimationFrame(he))}function me(e){var r;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),p=(r=l("popover"))==null?void 0:r.wrapper,n=q([...p?[p]:[],...i?[i]:[]]),f=n[0],w=n[n.length-1];if(e.preventDefault(),e.shiftKey){const v=n[n.indexOf(document.activeElement)-1]||w;v==null||v.focus()}else{const v=n[n.indexOf(document.activeElement)+1]||f;v==null||v.focus()}}function Z(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?_("escapePress"):e.key==="ArrowRight"?_("arrowRightPress"):e.key==="ArrowLeft"&&_("arrowLeftPress"))}function G(e,o,t){const i=(n,f)=>{const w=n.target;e.contains(w)&&((!t||t(w))&&(n.preventDefault(),n.stopPropagation(),n.stopImmediatePropagation()),f==null||f(n))};document.addEventListener("pointerdown",i,!0),document.addEventListener("mousedown",i,!0),document.addEventListener("pointerup",i,!0),document.addEventListener("mouseup",i,!0),document.addEventListener("click",n=>{i(n,o)},!0)}function ye(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",me,!1),window.addEventListener("resize",A),window.addEventListener("scroll",A)}function be(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",A),window.removeEventListener("scroll",A)}function xe(){const e=l("popover");e&&(e.wrapper.style.display="none")}function J(e,o){var C,y;let t=l("popover");t&&document.body.removeChild(t.wrapper),t=Pe(),document.body.appendChild(t.wrapper);const{title:i,description:p,showButtons:n,disableButtons:f,showProgress:w,nextBtnText:r=a("nextBtnText")||"Next →",prevBtnText:v=a("prevBtnText")||"← Previous",progressText:s=a("progressText")||"{current} of {total}"}=o.popover||{};t.nextButton.innerHTML=r,t.previousButton.innerHTML=v,t.progress.innerHTML=s,i?(t.title.innerHTML=i,t.title.style.display="block"):t.title.style.display="none",p?(t.description.innerHTML=p,t.description.style.display="block"):t.description.style.display="none";const c=n||a("showButtons"),d=w||a("showProgress")||!1,m=(c==null?void 0:c.includes("next"))||(c==null?void 0:c.includes("previous"))||d;t.closeButton.style.display=c.includes("close")?"block":"none",m?(t.footer.style.display="flex",t.progress.style.display=d?"block":"none",t.nextButton.style.display=c.includes("next")?"block":"none",t.previousButton.style.display=c.includes("previous")?"block":"none"):t.footer.style.display="none";const g=f||a("disableButtons")||[];g!=null&&g.includes("next")&&(t.nextButton.disabled=!0,t.nextButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("previous")&&(t.previousButton.disabled=!0,t.previousButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("close")&&(t.closeButton.disabled=!0,t.closeButton.classList.add("driver-popover-btn-disabled"));const u=t.wrapper;u.style.display="block",u.style.left="",u.style.top="",u.style.bottom="",u.style.right="",u.id="driver-popover-content",u.setAttribute("role","dialog"),u.setAttribute("aria-labelledby","driver-popover-title"),u.setAttribute("aria-describedby","driver-popover-description");const h=t.arrow;h.className="driver-popover-arrow";const P=((C=o.popover)==null?void 0:C.popoverClass)||a("popoverClass")||"";u.className=`driver-popover ${P}`.trim(),G(t.wrapper,k=>{var M,R,I;const T=k.target,H=((M=o.popover)==null?void 0:M.onNextClick)||a("onNextClick"),$=((R=o.popover)==null?void 0:R.onPrevClick)||a("onPrevClick"),B=((I=o.popover)==null?void 0:I.onCloseClick)||a("onCloseClick");if(T.classList.contains("driver-popover-next-btn"))return H?H(e,o,{config:a(),state:l()}):_("nextClick");if(T.classList.contains("driver-popover-prev-btn"))return $?$(e,o,{config:a(),state:l()}):_("prevClick");if(T.classList.contains("driver-popover-close-btn"))return B?B(e,o,{config:a(),state:l()}):_("closeClick")},k=>!(t!=null&&t.description.contains(k))&&!(t!=null&&t.title.contains(k))&&typeof k.className=="string"&&k.className.includes("driver-popover")),b("popover",t);const S=((y=o.popover)==null?void 0:y.onPopoverRender)||a("onPopoverRender");S&&S(t,{config:a(),state:l()}),oe(e,o),V(u);const L=e.classList.contains("driver-dummy-element"),x=q([u,...L?[]:[e]]);x.length>0&&x[0].focus()}function U(){const e=l("popover");if(!(e!=null&&e.wrapper))return;const o=e.wrapper.getBoundingClientRect(),t=a("stagePadding")||0,i=a("popoverOffset")||0;return{width:o.width+t+i,height:o.height+t+i,realWidth:o.width,realHeight:o.height}}function ee(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.top-p,window.innerHeight-i.realHeight-n.width),n.width):e==="end"?Math.max(Math.min(t.top-(i==null?void 0:i.realHeight)+t.height+p,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):e==="center"?Math.max(Math.min(t.top+t.height/2-(i==null?void 0:i.realHeight)/2,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):0}function te(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.left-p,window.innerWidth-i.realWidth-n.width),n.width):e==="end"?Math.max(Math.min(t.left-(i==null?void 0:i.realWidth)+t.width+p,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):e==="center"?Math.max(Math.min(t.left+t.width/2-(i==null?void 0:i.realWidth)/2,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):0}function oe(e,o){const t=l("popover");if(!t)return;const{align:i="start",side:p="left"}=(o==null?void 0:o.popover)||{},n=i,f=e.id==="driver-dummy-element"?"over":p,w=a("stagePadding")||0,r=U(),v=t.arrow.getBoundingClientRect(),s=e.getBoundingClientRect(),c=s.top-r.height;let d=c>=0;const m=window.innerHeight-(s.bottom+r.height);let g=m>=0;const u=s.left-r.width;let h=u>=0;const P=window.innerWidth-(s.right+r.width);let S=P>=0;const L=!d&&!g&&!h&&!S;let x=f;if(f==="top"&&d?S=h=g=!1:f==="bottom"&&g?S=h=d=!1:f==="left"&&h?S=d=g=!1:f==="right"&&S&&(h=d=g=!1),f==="over"){const C=window.innerWidth/2-r.realWidth/2,y=window.innerHeight/2-r.realHeight/2;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto"}else if(L){const C=window.innerWidth/2-(r==null?void 0:r.realWidth)/2,y=10;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.bottom=`${y}px`,t.wrapper.style.top="auto"}else if(h){const C=Math.min(u,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="left"}else if(S){const C=Math.min(P,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.right=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.left="auto",x="right"}else if(d){const C=Math.min(c,window.innerHeight-r.realHeight-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.top=`${C}px`,t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="top"}else if(g){const C=Math.min(m,window.innerHeight-(r==null?void 0:r.realHeight)-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom=`${C}px`,t.wrapper.style.top="auto",t.wrapper.style.right="auto",x="bottom"}L?t.arrow.classList.add("driver-popover-arrow-none"):Ce(n,x,e)}function Ce(e,o,t){const i=l("popover");if(!i)return;const p=t.getBoundingClientRect(),n=U(),f=i.arrow,w=n.width,r=window.innerWidth,v=p.width,s=p.left,c=n.height,d=window.innerHeight,m=p.top,g=p.height;f.className="driver-popover-arrow";let u=o,h=e;o==="top"?(s+v<=0?(u="right",h="end"):s+v-w<=0&&(u="top",h="start"),s>=r?(u="left",h="end"):s+w>=r&&(u="top",h="end")):o==="bottom"?(s+v<=0?(u="right",h="start"):s+v-w<=0&&(u="bottom",h="start"),s>=r?(u="left",h="start"):s+w>=r&&(u="bottom",h="end")):o==="left"?(m+g<=0?(u="bottom",h="end"):m+g-c<=0&&(u="left",h="start"),m>=d?(u="top",h="end"):m+c>=d&&(u="left",h="end")):o==="right"&&(m+g<=0?(u="bottom",h="start"):m+g-c<=0&&(u="right",h="start"),m>=d?(u="top",h="start"):m+c>=d&&(u="right",h="end")),u?(f.classList.add(`driver-popover-arrow-side-${u}`),f.classList.add(`driver-popover-arrow-align-${h}`)):f.classList.add("driver-popover-arrow-none")}function Pe(){const e=document.createElement("div");e.classList.add("driver-popover");const o=document.createElement("div");o.classList.add("driver-popover-arrow");const t=document.createElement("header");t.id="driver-popover-title",t.classList.add("driver-popover-title"),t.style.display="none",t.innerText="Popover Title";const i=document.createElement("div");i.id="driver-popover-description",i.classList.add("driver-popover-description"),i.style.display="none",i.innerText="Popover description is here";const p=document.createElement("button");p.type="button",p.classList.add("driver-popover-close-btn"),p.setAttribute("aria-label","Close"),p.innerHTML="×";const n=document.createElement("footer");n.classList.add("driver-popover-footer");const f=document.createElement("span");f.classList.add("driver-popover-progress-text"),f.innerText="";const w=document.createElement("span");w.classList.add("driver-popover-navigation-btns");const r=document.createElement("button");r.type="button",r.classList.add("driver-popover-prev-btn"),r.innerHTML="← Previous";const v=document.createElement("button");return v.type="button",v.classList.add("driver-popover-next-btn"),v.innerHTML="Next →",w.appendChild(r),w.appendChild(v),n.appendChild(f),n.appendChild(w),e.appendChild(p),e.appendChild(o),e.appendChild(t),e.appendChild(i),e.appendChild(n),{wrapper:e,arrow:o,title:t,description:i,footer:n,previousButton:r,nextButton:v,closeButton:p,footerButtons:w,progress:f}}function Se(){var o;const e=l("popover");e&&((o=e.wrapper.parentElement)==null||o.removeChild(e.wrapper))}const Le="";function ke(e={}){z(e);function o(){a("allowClose")&&v()}function t(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s+1;c[d]?r(d):v()}function i(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s-1;c[d]?r(d):v()}function p(s){(a("steps")||[])[s]?r(s):v()}function n(){var h;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined"||typeof l("activeIndex")=="undefined")return;const u=((h=d.popover)==null?void 0:h.onPrevClick)||a("onPrevClick");if(u)return u(m,d,{config:a(),state:l()});i()}function f(){var u;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined")return;const g=((u=d.popover)==null?void 0:u.onNextClick)||a("onNextClick");if(g)return g(m,d,{config:a(),state:l()});t()}function w(){l("isInitialized")||(b("isInitialized",!0),document.body.classList.add("driver-active",a("animate")?"driver-fade":"driver-simple"),ye(),O("overlayClick",o),O("escapePress",o),O("arrowLeftPress",n),O("arrowRightPress",f))}function r(s=0){var H,$,B,M,R,I,ie,ne;const c=a("steps");if(!c){console.error("No steps to drive through"),v();return}if(!c[s]){v();return}b("__activeOnDestroyed",document.activeElement),b("activeIndex",s);const d=c[s],m=c[s+1],g=c[s-1],u=((H=d.popover)==null?void 0:H.doneBtnText)||a("doneBtnText")||"Done",h=a("allowClose"),P=typeof(($=d.popover)==null?void 0:$.showProgress)!="undefined"?(B=d.popover)==null?void 0:B.showProgress:a("showProgress"),L=(((M=d.popover)==null?void 0:M.progressText)||a("progressText")||"{{current}} of {{total}}").replace("{{current}}",`${s+1}`).replace("{{total}}",`${c.length}`),x=((R=d.popover)==null?void 0:R.showButtons)||a("showButtons"),C=["next","previous",...h?["close"]:[]].filter(_e=>!(x!=null&&x.length)||x.includes(_e)),y=((I=d.popover)==null?void 0:I.onNextClick)||a("onNextClick"),k=((ie=d.popover)==null?void 0:ie.onPrevClick)||a("onPrevClick"),T=((ne=d.popover)==null?void 0:ne.onCloseClick)||a("onCloseClick");Q({...d,popover:{showButtons:C,nextBtnText:m?void 0:u,disableButtons:[...g?[]:["previous"]],showProgress:P,progressText:L,onNextClick:y||(()=>{m?r(s+1):v()}),onPrevClick:k||(()=>{r(s-1)}),onCloseClick:T||(()=>{v()}),...(d==null?void 0:d.popover)||{}}})}function v(s=!0){const c=l("__activeElement"),d=l("__activeStep"),m=l("__activeOnDestroyed"),g=a("onDestroyStarted");if(s&&g){const P=!c||(c==null?void 0:c.id)==="driver-dummy-element";g(P?void 0:c,d,{config:a(),state:l()});return}const u=(d==null?void 0:d.onDeselected)||a("onDeselected"),h=a("onDestroyed");if(document.body.classList.remove("driver-active","driver-fade","driver-simple"),be(),Se(),we(),ve(),ce(),K(),c&&d){const P=c.id==="driver-dummy-element";u&&u(P?void 0:c,d,{config:a(),state:l()}),h&&h(P?void 0:c,d,{config:a(),state:l()})}m&&m.focus()}return{isActive:()=>l("isInitialized")||!1,refresh:A,drive:(s=0)=>{w(),r(s)},setConfig:z,setSteps:s=>{K(),z({...a(),steps:s})},getConfig:a,getState:l,getActiveIndex:()=>l("activeIndex"),isFirstStep:()=>l("activeIndex")===0,isLastStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&c===s.length-1},getActiveStep:()=>l("activeStep"),getActiveElement:()=>l("activeElement"),getPreviousElement:()=>l("previousElement"),getPreviousStep:()=>l("previousStep"),moveNext:t,movePrevious:i,moveTo:p,hasNextStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c+1]},hasPreviousStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c-1]},highlight:s=>{w(),Q({...s,popover:s.popover?{showButtons:[],showProgress:!1,progressText:"",...s.popover}:void 0})},destroy:()=>{v(!1)}}}return D.driver=ke,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({}); diff --git a/cloudexit/assets/template/egress.html b/cloudexit/assets/template/egress.html deleted file mode 100644 index 117f62f..0000000 --- a/cloudexit/assets/template/egress.html +++ /dev/null @@ -1,775 +0,0 @@ - - - - - - - {{ page_title }} - - - - - - - - - {% set provider_name = "Microsoft Azure" if cloud_service_provider == 1 else "Amazon Web Services" if cloud_service_provider == 2 else "Unknown Provider" %} - {% set assessment_name = "Basic" if assessment_type == 1 else "Standard" if assessment_type == 2 else "Unknown" %} - -
-
-

- EscapeCloud - {{ name }} -

- -
-
-
- -
-
-
-
-
-
-
-
- - {% if cloud_service_provider == 1 %} - - - - - - - - - - - - - - - - - - - - - - - - {% elif cloud_service_provider == 2 %} - - - - - - - - - - - - - {% else %} - {{ provider_name }} - {% endif %} - -
-
-
Cloud Service Provider
-
{{ provider_name }}
-
-
-
-
- -
-
-
Total Data
-
{{ total_data_display }}
-
-
-
-
- -
-
-
Assessment Type
-
{{ assessment_name }}
-
-
-
-
- -
-
-
Document ID
-
{{ document_id }}
-
-
-
-
- -
-
-
Timestamp
-
{{ timestamp }}
-
-
-
-
-
- -
-
-
-
-
-

Estimated Egress Fee

-
{{ total_fee_display }}
-
-
- -
-
Estimated Egress Fee
-
-

- Estimated one-time charge to transfer the identified - in-scope data out of the cloud over the public - internet. Calculated from the detected data volume, - the provider's standard egress price tiers, and - archive retrieval charges where applicable. Excludes - request/transaction fees, cross-region traffic, and - any resources whose size could not be measured. A "+" - means the figure is a lower-bound estimate. -

-
-
-
-
- {% if unknown_count > 0 %} -
- - Excludes {{ unknown_count }} resource{% if unknown_count != 1 %}s{% - endif %} with unavailable size. -
- {% endif %} {% if free_tier %} -
-
- -
-
- Included in Free Tier -
-

- The scanned footprint ({{ known_data_display }}) falls entirely within the cloud provider's complimentary - monthly internet egress allowance - ({{ free_tier_limit_display }} free tier). -

-
- {% elif has_fees %} -
- -
- {% else %} -
-
- -

No fee estimate available.

-
-
- {% endif %} -
-
-
- -
-
-
-
-
-

Data Allocation

-
{{ total_data_display }}
-
-
- -
-
Data Allocation
-
-

- Breakdown of the identified in-scope data volume by - resource category. Based on the sizes detected during - the assessment and intended to show where the data - footprint sits. A "+" indicates that some resources - could not be fully measured. -

-
-
-
-
- {% if has_allocation %} -
- -
- {% else %} -
-
- -

No data volume available.

-
-
- {% endif %} -
-
-
-
-
-
-
-

{{ introduction.heading }}

-

{{ introduction.purpose_heading }}

-

{{ introduction.purpose }}

-

{{ introduction.scope_heading }}

-

{{ introduction.scope }}

-

{{ introduction.limitations_heading }}

- {% for paragraph in introduction.limitations %} -

{{ paragraph }}

- {% endfor %} - {% if coverage %} -

{{ coverage.heading }}

-

{{ coverage.intro }}

- - - - {% for header in coverage.headers %} - - {% endfor %} - - - - {% for name, note in coverage.rows %} - - - - - {% endfor %} - -
{{ header }}
{{ name }}{{ note }}
- {% endif %} -
-
-
- -
-
-
-
-

{{ landscape.heading }}

-
-
-
-

{{ landscape.lead }}

-
- {% for label, value, attention in landscape.stats %} -
-
{{ label }}
-
{{ value }}
-
- {% endfor %} -
-
-
- - - - - - - - - - - {% if type_groups %} {% for group in type_groups %} - - - - - - - - - - - {% endfor %} {% else %} - - - - {% endif %} - -
#Resource TypeCategory{{ landscape.headers[2] }}
{{ loop.index }} - - {% if group.icon %} - {{ group.label | trim }} - {% else %} - - {% endif %} - {{ group.label }} - - {{ group.category_label }} - {{ group.size_display }}
-
-
- -
{{ group.fee_display }}
-
-
- -
- {% for resource in group.resources %} -
- {{ resource.name }}{% if resource.fee_display %} - ({{ resource.fee_display }}){% endif %}{% if - resource.detail %} - – {{ resource.detail }}{% endif %} -
- {% endfor %} -
-
-
-
- No data-bearing resources were discovered during the - assessment. -
-
-
- {% if landscape.not_sized %}

{{ landscape.not_sized }}

{% endif %} -

{{ landscape.caption }}

-

{{ landscape.source_note }}

-
-
-
-
- -
-
-
-

{{ costs.heading }}

-

{{ costs.lead }}

-
- {% for label, value, attention in costs.stats %} -
-
{{ label }}
-
{{ value }}
-
- {% endfor %} -
- {% if costs.rows %} - - - - - - - - - - - {% for row in costs.rows %} - - - - - - - {% endfor %} - - - - - - - -
{{ costs.headers[0] }}{{ costs.headers[1] }}{{ costs.headers[2] }}{{ costs.headers[3] }}
{{ row.label }}{{ row.size }}{{ row.share_text }}{{ row.fee }}
{{ costs.total[0] }}{{ costs.total[1] }}{{ costs.total[2] }}
- {% endif %} -

{{ costs.caption }}

-
-
-
- - {% if pricing %} -
-
-
-

{{ pricing.heading }}

-

{{ pricing.intro }}

- - - - {% for header in pricing.headers %} - - {% endfor %} - - - - {% for row in pricing.rows %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - -
{{ header }}
{{ cell }}
-
- {% if pricing.units %}

{{ pricing.units }}

{% endif %} - {% if pricing.no_archive %}

{{ pricing.no_archive }}

{% endif %} - {% for template, url in pricing.sources %} -

{{ template.split("{link}")[0] }}{{ url }}{{ template.split("{link}")[1] }}

- {% endfor %} -

{{ pricing.note }}

-
-

{{ pricing.glossary_heading }}

-
- {% for term, definition in pricing.glossary %} -
{{ term }}
-
{{ definition }}
- {% endfor %} -
-
-
-
- {% endif %} - -
-
-
-

{{ next_steps.heading }}

- {% for paragraph in next_steps.paragraphs %} -

- {% for part in paragraph %}{% if part is string %}{{ part }}{% else %}{{ part[0] }}{% endif %}{% endfor %} -

- {% endfor %} -
-
-
-
- -
-
-

{{ notices.heading }}

- {% for block in notices.blocks %} -

{{ block.heading }}

- {% for paragraph in block.paragraphs %} -

- {% for part in paragraph %}{% if part is string %}{{ part }}{% else %}{{ part[0] }}{% endif %}{% endfor %} -

- {% endfor %} - {% endfor %} -

{{ notices.copyright }}

-
-
- - - - - - {% if has_allocation %} - - {% endif %} - - {% if has_fees %} - - {% endif %} - -
- - diff --git a/cloudexit/assets/template/index.html b/cloudexit/assets/template/index.html index 0087909..00c0873 100644 --- a/cloudexit/assets/template/index.html +++ b/cloudexit/assets/template/index.html @@ -1,1130 +1,573 @@ - - - - - - - Executive Summary – {{ name }} - - - - - - - - - {% set provider_name = "Microsoft Azure" if cloud_service_provider == 1 else "Amazon Web Services" if cloud_service_provider == 2 else "Unknown Provider" %} - {% set strategy_name = "Repatriation to On-Premises" if exit_strategy == 1 else "Migration to Alternate Cloud" if exit_strategy == 3 else "Unknown Strategy" %} - {% set assessment_name = "Basic" if assessment_type == 1 else "Standard" if assessment_type == 2 else "Unknown" %} - {% set total_risks = high_risk_count + medium_risk_count + low_risk_count %} - -
-
-

- cloudexit - {{ name }} -

- +{%- macro parts(items) -%} +{%- for part in items -%} +{%- if part is string -%}{{ part }} +{%- elif part[1].startswith('#') -%}{{ part[0] }} +{%- else -%}{{ part[0] }} +{%- endif -%} +{%- endfor -%} +{%- endmacro -%} +{%- macro icon(src, initials) -%} +{%- if src -%}{%- else -%}{%- endif -%} +{%- endmacro -%} +{%- macro pill(id, s) -%} +{%- if s.platform_url -%} +{{ s.open_platform }} +{%- else -%} + + + +{%- endif -%} +{%- endmacro -%} +{%- macro mark(on, yes, no) -%} +{{ yes if on else no }} +{%- endmacro -%} + + + + + +{{ page.title }} + + + + + + + +
+
+ EscapeCloud{{ page.header.tag }} + +
+
+ +
+
+
+
+ {% if page.titlebar.logo %}{% endif %} +

{{ page.titlebar.name }}

+ {{ page.titlebar.chip }}
-
-
{% if upload %} -
- {% if upload.status == "uploaded" %} -
- Uploaded to the platform.{% if upload.url %} - Open the platform report{% endif %} +
+
+ {% if page.titlebar.platform_url %}{{ page.open_platform }}{% endif %} + +
+
+ + + +
+
+

{{ page.nav[0].label }}

+
+
+ {{ page.overview.scope.label }} +
+ {% for row in page.overview.scope.rows %}
{{ row.label }}
{{ row.value }}{% if row.small %}{{ row.small }}{% endif %}
+ {% endfor %} +
- {% else %} - - {% endif %} -
- {% if summary %} -
-
-

{{ summary.brief.heading }}

-

{{ summary.brief.lead | safe }}

-

{{ summary.brief.findings_heading }}

-
- - - - {% for header in summary.brief.headers %} - - {% endfor %} - - - - {% for row in summary.brief.rows %} - - - - - - {% endfor %} - -
{{ header }}
{{ row.area }}{{ row.finding | safe }}{{ row.details }}
-
-

{{ summary.brief.caption | safe }}

+
+ {% set c = page.overview.costs %} + {{ c.label }} + {% if c.kind == 'message' %} +
+
{{ c.figure }}
+

{{ c.message }}

+ {% if c.hint %}

{{ c.hint }}

{% endif %}
-
-

{{ summary.provenance.heading }}

-
- {% for label, value in summary.provenance.rows %} -
{{ label }}
-
{{ value }}
- {% endfor %} - {% if summary.provenance.platform_url %} -
 
-
- {{ summary.provenance.platform_url }} -
- {% endif %} -
+ {% else %} +
+
{{ c.figure }}
{{ c.caption }}
+
{{ c.change }}
{% if c.change_caption %}
{{ c.change_caption }}
{% endif %}
-
-

{{ summary.introduction.heading }}

-

{{ summary.introduction.purpose_heading }}

-

{{ summary.introduction.purpose }}

-

{{ summary.introduction.scope_heading }}

- {% for paragraph in summary.introduction.scope %} -

{{ paragraph | safe }}

- {% endfor %} -

{{ summary.introduction.method_heading }}

- {% for paragraph in summary.introduction.method %} -

{{ paragraph }}

- {% endfor %} -
    - {% for phase, text in summary.introduction.phases %} -
  • {{ phase }} {{ text }}
  • - {% endfor %} -
-

{{ summary.introduction.method_catalogue }}

-

{{ summary.introduction.limitations_heading }}

- {% for paragraph in summary.introduction.limitations %} -

{{ paragraph }}

+ +

{{ c.footnote }}

+ {% endif %}
- {% endif %} -
-
-
-
-
-
-
- - {% if cloud_service_provider == 1 %} - - - - - - - - - - - - - - - - - - - - - - - - {% elif cloud_service_provider == 2 %} - - - - - - - - - - - - - {% else %} - {{ provider_name }} - {% endif %} - -
-
-
Cloud Service Provider
-
{{ provider_name }}
-
-
-
-
- -
-
-
Exit Strategy
-
{{ strategy_name }}
-
-
-
-
- -
-
-
Assessment Type
-
{{ assessment_name }}
-
-
-
-
- -
-
-
Timestamp
-
{{ timestamp }}
-
-
-
-
-
- -
-
-
-
-
-

Risk Inventory

-
{{ total_risks }}
-
-
- {% if total_risks > 0 %} -
- -
- {% else %} -
-
- -

No risk data available.

-
-
- {% endif %} -
-
-
- -
-
-
-
-
-

Costs Overview

-
- {% if total_cost > 0 %}{{ currency_symbol }}{{ total_cost }}{% else - %}-{% endif %} -
-
-
- {% if total_cost > 0 %} -
- -
- {% else %} -
-
- -

No cost data available.

-
-
- {% endif %} - {% if summary.costs_egress_note %} -

{{ summary.costs_egress_note }}

- {% endif %} -
-
-
+
+
+ +
+

{{ page.nav[1].label }}

+ {% set s = page.scores %} +
+
+

{{ s.grade.title }}

+ {{ pill('hint-grade', s) }}
- -
-
-
-
-

Risk Inventory

-
- - -
-
-
- -
- - - - - - - - - - - {% if risks %} {% for risk in risks %} - - - - - - - - - - - {% endfor %} {% else %} - - - - {% endif %} - -
#RiskImpacted ResourcesSeverity
{{ loop.index }} - - {{ risk.name }} - - {% if risk.impacted_resources_count is none %}-{% else %}{{ - risk.impacted_resources_count }}{% endif %} - - {{ risk.severity | capitalize }} -
-
-
- -
{{ risk.description }}
-
- {% if risk.impacted_resources_count %} -
- -
- {{ risk.impacted_resources | join(", ") }} -
-
- {% endif %} -
-
- No risks were identified for this assessment. -
-
-
+
+
{{ s.grade.placeholder }}
{{ s.grade.scale }}
+
+ +

{{ s.grade.text }}

- -
-
-
-
-

Resource Inventory ({{ total_resources }})

-
-
-
-
-
- {% if resource_inventory %} {% for resource in resource_inventory %} -
-
- {% if resource.icon | trim %} - {{ resource.name | trim }} - {% else %} - - {% endif %} -

{{ resource.name | trim }}

-
- {{ resource.count }} Resource{% if resource.count != 1 %}s{% - endif %} Available -
-
-
- {% endfor %} {% else %} -
-

- No resources were discovered during the assessment. -

-
- {% endif %} -
-
- {% if resource_inventory|length > 8 %} -
-
- -
- {% endif %} -
-
-
+
+
+

{{ s.lock.title }}

+ {{ pill('hint-lock', s) }}
- -
-
-
-
-

- Alternative Technologies ({{ alternative_technologies | length }}) -

- {% if alternative_technologies %} -
-
- -
-
- - - -
-
- {% endif %} -
-
- -
-
-
- {% if alternative_technologies %} {% for alt_tech in - alternative_technologies %} -
-
-
-
-
- - {{ alt_tech.organization_name }} - {% if alt_tech.organization_url %} - - - - {% endif %} - | - {% if alt_tech.organization_flag %}{{ alt_tech.organization_flag }} {% endif %}{{ alt_tech.organization_country_code }} • {{ alt_tech.organization_region_label }} -
-

{{ alt_tech.product_name }}

-
-
-
-

{{ alt_tech.product_description }}

-
-
-
- {% if alt_tech.open_source %} - - {% else %} - - {% endif %} - Open Source -
-
- {% if alt_tech.support_plan %} - - {% else %} - - {% endif %} - Enterprise Support -
-
-
-
- {% if alt_tech.product_url %} - - - Visit {{ alt_tech.product_name }} - - {% endif %} -
-
-
- {% endfor %} {% else %} -
-
-
- -

- No alternative technologies are available for this - assessment. -

-
-
-
- {% endif %} -
-
-
-
+
+ +

{{ s.lock.text }}

+
+
- + {% if e.empty %} +

{{ e.empty }}

+ {% else %} + {% if e.volume %} + +
+ {% for item in e.volume.legend %}{{ item.text }} + {% endfor %} + {% if e.volume.unsized %}{{ e.volume.unsized }}{% endif %} +
+ {% endif %} - {% if total_risks > 0 %} - +
+ + + + {% for row in e.table.rows %} + {% if row.size is none %} + {% else %} + {% endif %} + {% endfor %} + + +
{{ e.table.headers[0] }}{{ e.table.headers[1] }}{{ e.table.headers[2] }}{{ e.table.headers[3] }}
{{ icon(row.icon, row.initials) }}{{ row.label }}
{{ e.table.not_sized }}{{ page.dash }}{{ page.dash }}
{{ icon(row.icon, row.initials) }}{{ row.label }}
{{ row.size }}{{ row.fee }}
{{ e.table.total[0] }}{{ e.table.total[1] }}{{ e.table.total[2] }}
+
{% endif %} - {% if total_cost > 0 %} - + {% if e.sized.facts %} +
+ {{ e.sized.summary }} +
+ + + + {% for name, note in e.sized.facts.rows %} + {% endfor %} + +
{{ e.sized.facts.headers[0] }}{{ e.sized.facts.headers[1] }}
{{ name }}{{ note }}
+
+
{% endif %} - - {% if summary %} -
-
-

{{ summary.severity.heading }}

- {% for paragraph in summary.severity.lead %} -

{{ paragraph }}

- {% endfor %} - - - - {% for header in summary.severity.headers %} - - {% endfor %} - - + {% if e.prices %} +
+ {{ e.prices.summary }} +
+
{{ header }}
+ {% for header in e.prices.facts.headers %}{{ header }}{% endfor %} - {% for level, meaning in summary.severity.rows %} - - - - + {% for row in e.prices.facts.rows %}{% for cell in row %}{{ cell }}{% endfor %} {% endfor %}
{{ level }}{{ meaning }}
+ {% if e.prices.facts.units %}

{{ e.prices.facts.units }}

{% endif %} + {% for template, url in e.prices.facts.sources %}{% set pieces = template.split('{link}') %}

{{ pieces[0] }}{{ url }}{{ pieces[1] }}

+ {% endfor %} + {% if e.prices.facts.no_archive %}

{{ e.prices.facts.no_archive }}

{% endif %} +

{{ e.prices.facts.note }}

+
+ + {% endif %} +
+ + {% endif %} + +
+ {% set a = page.about %} +
+

{{ a.heading }}

{{ a.caption }}

+
+
+
+
+

{{ a.document.heading }}

+ + {% for label, value, mono in a.document.rows %}{{ value }} + {% endfor %} +
{{ label }}
-
-

{{ summary.glossary.heading }}

-

{{ summary.glossary.lead }}

-
- {% for term, definition in summary.glossary.entries %} -
{{ term }}
-
{{ definition }}
+
+

{{ a.scope.heading }}

+ + {% for label, value, mono in a.scope.rows %}{{ value }} {% endfor %} - +
{{ label }}
+
+
+

{{ a.provenance.heading }}

+ + {% if a.provenance.run_id %}{% endif %} + + +
{{ a.provenance.run_id[0] }}{{ a.provenance.run_id[1] }}
{{ a.provenance.catalogue[0] }}{{ a.provenance.catalogue[1] }}{% if a.provenance.catalogue[2] %} · {{ a.provenance.catalogue[2] }}{% endif %}
{{ a.provenance.platform[0] }}{{ parts(a.provenance.platform[1]) }}
-
-

{{ summary.closing.heading }}

-

{{ summary.closing.lead }}

- {% if summary.closing.uploaded %} - {% if summary.closing.url %} -

- {{ summary.closing.url }} -

- {% elif summary.closing.assessment_id %} -

Assessment ID: {{ summary.closing.assessment_id }}

- {% endif %} - {% else %} +
+

{{ a.stored }}

+ +
+ {{ a.purpose.summary }}{{ a.purpose.meta }} +
+ {% for paragraph in a.purpose.paragraphs %}

{{ paragraph }}

+ {% endfor %}
    - {% for term, text in summary.closing.points %} -
  • {{ term }} {{ text }}
  • + {% for name, text in a.purpose.phases %}
  • {{ name }} {{ text }}
  • {% endfor %}
-

{{ summary.closing.how_heading }}

-

{{ summary.closing.how }}

-

{{ summary.closing.command }}

-

- {% for name, url in summary.closing.sites %} - {{ url }}{% if not loop.last %} · {% endif %} - {% endfor %} -

- {% endif %} +

{{ a.purpose.closing }}

-
-

{{ summary.notices.heading }}

- {% for block in summary.notices.blocks %} -

{{ block.heading }}

- {% for paragraph in block.paragraphs %} -

- {% for part in paragraph %}{% if part is string %}{{ part }}{% else %}{{ part[0] }}{% endif %}{% endfor %} -

+
+
+ {{ a.limitations.summary }} +
+ {% for paragraph in a.limitations.paragraphs %}

{{ paragraph }}

{% endfor %} - {% endfor %} -

{{ summary.notices.copyright }}

-
- {% endif %} -
- + +
+ {{ a.severity.summary }}{{ a.severity.meta }} +
+ + + + {% for level, label, meaning in a.severity.rows %} + {% endfor %} + +
{{ a.severity.headers[0] }}{{ a.severity.headers[1] }}
{{ label }}{{ meaning }}
+

{{ a.severity.note }}

+
+
+
+ {{ a.glossary.summary }}{{ a.glossary.meta }} +
+ + {% for term, definition in a.glossary.entries %} + {% endfor %} +
{{ term }}{{ definition }}
+
+
+
+ + + {% set n = page.notices %} + +
+ {% for block in n.blocks %}
+

{{ block.heading }}

+

{{ parts(block.parts) }}

+
+ {% endfor %} + +
+ +
+ + + + + + + diff --git a/cloudexit/cli/app.py b/cloudexit/cli/app.py index 69e218c..1c44cbf 100644 --- a/cloudexit/cli/app.py +++ b/cloudexit/cli/app.py @@ -29,7 +29,6 @@ create_subscription_client, ) from cloudexit.egress.estimate import estimate_egress -from cloudexit.report.egress import generate_egress_html_report from cloudexit.platform.client import ApiError, check_key from cloudexit.platform.run_output import build_run_json, exit_code_of from cloudexit.platform.status import host_catalogue_lines, run_status @@ -988,7 +987,6 @@ def run_assessment( egress_failed = False egress_collected = False egress_json_path = None - egress_html_path = None if egress: console.print("Stage #5 – Egress Estimation", style="bold") @@ -1007,25 +1005,6 @@ def run_assessment( print_step("Estimating egress data...", status="ok") egress_json_path = egress_result.get("json_path") egress_collected = True - - with console.status("Generating egress report...", spinner="dots"): - egress_report_result = generate_egress_html_report( - report_path, - egress_json_path, - config["providerDetails"], - sync_id, - ) - - if egress_report_result["success"]: - print_step("Generating egress report...", status="ok") - egress_html_path = egress_report_result.get("html_path") - else: - print_step( - "Generating egress report...", - status="error", - logs=egress_report_result["logs"], - ) - egress_failed = True else: print_step( "Estimating egress data...", @@ -1219,8 +1198,6 @@ def run_assessment( json_report_path = report_status.get("reports", {}).get("JSON") if json_report_path: console.print(f"JSON Report: {json_report_path}", style="cyan") - if egress_html_path: - console.print(f"Egress Report: {egress_html_path}", style="cyan") if egress_pdf_path: console.print(f"Egress PDF: {egress_pdf_path}", style="cyan") console.print("-------------------------------------------") diff --git a/cloudexit/engine.py b/cloudexit/engine.py index f5da2a8..5507910 100644 --- a/cloudexit/engine.py +++ b/cloudexit/engine.py @@ -27,9 +27,9 @@ build_context, collect_provenance, ) +from cloudexit.report.egress import load_egress_estimate from cloudexit.report.egress_report import generate_egress_report from cloudexit.report.executive_summary import generate_executive_summary -from cloudexit.report.html_sections import html_sections from cloudexit.report.writers import ( generate_html_report, generate_json_report, @@ -499,25 +499,22 @@ def generate_report( provenance=provenance, report_path=report_path, ) + # The estimate is read once, for the page and for the egress PDF. When + # it cannot be read there is no egress in any report, and the failure + # is reported with the egress PDF's. + egress_estimate, egress_error = None, None if egress_json_path: - context.egress_document_id = context.document_id_for(EGRESS_DOCUMENT_CODE) + try: + egress_estimate = load_egress_estimate(report_path, egress_json_path) + context.egress_document_id = context.document_id_for( + EGRESS_DOCUMENT_CODE + ) + except Exception as e: + logger.error("Error reading the egress estimate: %s", e, exc_info=True) + egress_error = str(e) # Generate HTML report - reports["HTML"] = generate_html_report( - report_path, - metadata, - resource_type_mapping, - resource_inventory, - cost_data, - scoring_data, - risk_data, - risk_definitions, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations, - summary=html_sections(context), - ) + reports["HTML"] = generate_html_report(context, egress_estimate) # Generate PDF report reports["PDF"] = generate_executive_summary(context) @@ -542,10 +539,12 @@ def generate_report( # The Egress & Data Landscape is written last, from the same context: a # problem with it never costs the Executive Summary - if egress_json_path: + if egress_error: + result["egress_pdf_logs"] = egress_error + elif egress_estimate is not None: try: reports["EGRESS_PDF"] = generate_egress_report( - context, egress_json_path + context, egress_json_path, estimate=egress_estimate ) except Exception as e: logger.error("Error generating the egress PDF: %s", e, exc_info=True) diff --git a/cloudexit/report/aggregate.py b/cloudexit/report/aggregate.py index c983a26..ea24404 100644 --- a/cloudexit/report/aggregate.py +++ b/cloudexit/report/aggregate.py @@ -80,29 +80,6 @@ def sort_cost_data(cost_data: list[dict[str, Any]]) -> list[dict[str, Any]]: return sorted(cost_data, key=lambda x: datetime.strptime(x["month"], "%Y-%m-%d")) -def summarize_costs( - cost_data: list[dict[str, Any]], *, last_n: int | None = None -) -> tuple[list[str], list[float], float, str, str]: - sorted_costs = sort_cost_data(cost_data) - if last_n is not None: - sorted_costs = sorted_costs[-last_n:] - - months = [ - datetime.strptime(item["month"], "%Y-%m-%d").strftime("%b") - for item in sorted_costs - ] - values = [item["cost"] for item in sorted_costs] - total_cost = round(sum(values), 2) - - if sorted_costs: - currency_code = sorted_costs[0].get("currency", "USD") - else: - currency_code = "USD" - currency_symbol = CURRENCY_SYMBOLS.get(currency_code, currency_code) - - return months, values, total_cost, currency_code, currency_symbol - - def summarize_risks( risk_data: list[dict[str, Any]], risk_definitions: list[dict[str, Any]], diff --git a/cloudexit/report/assets.py b/cloudexit/report/assets.py index 0bb3589..784d0dd 100644 --- a/cloudexit/report/assets.py +++ b/cloudexit/report/assets.py @@ -43,7 +43,7 @@ def copy_assets(report_path: str, cloud_service_provider: int) -> None: # Create the 'assets' directory if it doesn't exist os.makedirs(assets_path, exist_ok=True) - for folder in ("css", "img"): + for folder in ("css", "fonts", "img", "js"): src_path = asset_path(folder) dest_path = os.path.join(assets_path, folder) diff --git a/cloudexit/report/context.py b/cloudexit/report/context.py index 9f24c96..da43ca6 100644 --- a/cloudexit/report/context.py +++ b/cloudexit/report/context.py @@ -38,6 +38,8 @@ class Provenance: uploaded: bool = False platform_url: str | None = None assessment_id: str | None = None + # Why a requested upload did not happen, when it did not + upload_reason: str | None = None @property def run_short_id(self): @@ -77,6 +79,9 @@ def collect_provenance(db_path, *, run_id=None, upload=None, collected_at=None): uploaded=upload.get("status") == "uploaded", platform_url=upload.get("url") if upload.get("status") == "uploaded" else None, assessment_id=upload.get("assessment_id"), + upload_reason=( + upload.get("reason") if upload.get("status") != "uploaded" else None + ), ) @@ -87,6 +92,9 @@ class ResourceTypeEntry: icon: str | None resources: int = 0 locations: set = field(default_factory=set) + location_counts: dict = field(default_factory=dict) + # The icon as the catalogue names it, relative to the report's assets + icon_rel: str = "" @dataclass(frozen=True) @@ -127,6 +135,7 @@ class AlternativesEntry: icon: str | None resources: int alternatives: list = field(default_factory=list) + icon_rel: str = "" @dataclass(frozen=True) @@ -197,11 +206,16 @@ def _resource_entries(resource_inventory, resource_type_mapping, report_path): key, (info.get("name") or "").strip() or "–", _icon_path(report_path, info.get("icon")), + icon_rel=str(info.get("icon") or "").lstrip("/"), ), ) - entry.resources += int(row.get("count") or 0) + count = int(row.get("count") or 0) + entry.resources += count if row.get("location"): entry.locations.add(row["location"]) + entry.location_counts[row["location"]] = ( + entry.location_counts.get(row["location"], 0) + count + ) return sorted( entries.values(), key=lambda entry: (-entry.resources, entry.name.lower()) ) @@ -296,6 +310,7 @@ def _alternative_entries( resource.icon, resource.resources, found, + icon_rel=resource.icon_rel, ) ) return entries diff --git a/cloudexit/report/egress.py b/cloudexit/report/egress.py index d4ca4a0..099eacd 100644 --- a/cloudexit/report/egress.py +++ b/cloudexit/report/egress.py @@ -4,12 +4,9 @@ import os import sqlite3 from collections import Counter -from datetime import datetime from typing import Any -from jinja2 import Environment from cloudexit.report.helpers import format_gib, gib_number, initials, share_label -from cloudexit.resources import asset_path from cloudexit.catalogue.db import load_data from cloudexit.egress.model import ( GIB, @@ -18,14 +15,8 @@ ) from cloudexit.providers.aws.egress import ARCHIVE_TIERS as AWS_ARCHIVE_TIERS from cloudexit.providers.azure.egress import ARCHIVE_TIERS as AZURE_ARCHIVE_TIERS -from cloudexit.report.context import ( - DOCUMENT_CODE, - EGRESS_DOCUMENT_CODE, - PROVIDER_NAMES, - format_document_id, - scope_facts, -) -from cloudexit.report.notices import notices_html, text_parts +from cloudexit.report.context import PROVIDER_NAMES +from cloudexit.report.notices import text_parts from cloudexit.report.sections import BOOK_DEMO_URL, PRODUCT_SITES from cloudexit.report.wording import BLOCKS @@ -37,39 +28,6 @@ ARCHIVE_TIERS_BY_CSP = {1: AZURE_ARCHIVE_TIERS, 2: AWS_ARCHIVE_TIERS} -CATEGORIES = { - "object": { - "label": "Object Storage", - "badge": "Object", - "color": "rgba(34, 197, 94, 1)", - "in_allocation": True, - }, - "block": { - "label": "Block (allocated)", - "badge": "Block", - "color": "rgba(83, 155, 255, 1)", - "in_allocation": True, - }, - "file": { - "label": "File Storage", - "badge": "File", - "color": "rgba(245, 158, 11, 1)", - "in_allocation": True, - }, - "database": { - "label": "Databases", - "badge": "Database", - "color": "rgba(168, 85, 247, 1)", - "in_allocation": True, - }, - "backup": { - "label": None, - "badge": "Backup", - "color": None, - "in_allocation": False, - }, -} - PRICING_SOURCE_LINKS = { 1: { "internet_egress": "https://azure.microsoft.com/pricing/details/bandwidth/", @@ -183,7 +141,11 @@ def build_fee_estimate( def format_fee(fee: float | None) -> str: if fee is None: return "n/a" - return f"${fee:,.2f}" + return f"${fee_number(fee)}" + + +def fee_number(fee: float) -> str: + return f"{fee:,.2f}" def _free_tier( @@ -218,6 +180,23 @@ def free_allowance_bytes( return tier["tier_to"] * _unit_divisor(tier["unit"]) if tier else None +def free_tier_headroom( + prices: list[dict[str, Any]], cloud_service_provider: int, known_bytes: int +) -> dict[str, Any] | None: + tier = _free_tier(prices, cloud_service_provider) + if tier is None: + return None + limit = tier["tier_to"] + used = known_bytes / _unit_divisor(tier["unit"]) + return { + "unit": tier["unit"], + "limit": limit, + "used": used, + "left": max(0.0, limit - used), + "used_share": min(100.0, used / limit * 100) if limit else 100.0, + } + + def _summarize_totals( totals: dict[str, Any], total_fee: float, @@ -271,29 +250,6 @@ def _resolve_icon(resource_type: str, icon_lookup: dict[str, str]) -> str | None return FALLBACK_ICONS.get(code) -def _build_allocation(rows: list[dict[str, Any]]) -> tuple[list, list, list]: - allocation_categories = { - key: info for key, info in CATEGORIES.items() if info["in_allocation"] - } - totals_by_category: dict[str, int] = {} - for row in rows: - if row["size_bytes"] is not None: - category = row.get("category") - if category in allocation_categories: - totals_by_category[category] = ( - totals_by_category.get(category, 0) + row["size_bytes"] - ) - - labels, values, colors = [], [], [] - for category, info in allocation_categories.items(): - size_bytes = totals_by_category.get(category, 0) - if size_bytes > 0: - labels.append(info["label"]) - values.append(size_bytes) - colors.append(info["color"]) - return labels, values, colors - - def _build_type_groups( rows: list[dict[str, Any]], fees_by_id: dict[str, float | None], @@ -307,10 +263,6 @@ def _build_type_groups( "label": row["label"], "icon": _resolve_icon(row["type"], icon_lookup), "initials": initials(row["label"]), - "category": row["category"], - "category_label": ( - CATEGORIES.get(row["category"], {}).get("badge") or row["category"] - ), "size_bytes": None, "fee": None, "resources": [], @@ -383,14 +335,6 @@ def load_egress_estimate(report_path: str, json_path: str) -> dict[str, Any]: } -def _generated_at(timestamp): - # The run's own time, as the estimate stored it; now when it cannot be read - try: - return datetime.strptime(str(timestamp), "%Y-%m-%d %H:%M:%S UTC") - except ValueError: - return datetime.now() - - def _share(part, whole): return part / whole * 100 if whole else 0 @@ -765,78 +709,3 @@ def next_steps_facts( ) ) return {"heading": b["eg_heading_next_steps"], "paragraphs": paragraphs} - - -def generate_egress_html_report( - report_path: str, - json_path: str, - provider_details: dict[str, Any] | None = None, - run_id: str | None = None, -) -> dict[str, Any]: - try: - estimate = load_egress_estimate(report_path, json_path) - meta, rows = estimate["meta"], estimate["rows"] - csp = meta["cloud_service_provider"] - summary = estimate["summary"] - b = BLOCKS - - allocation_labels, allocation_values, allocation_colors = _build_allocation( - rows - ) - - type_groups = estimate["type_groups"] - fee_groups = [group for group in type_groups if (group["fee"] or 0.0) > 0] - fee_labels = [group["label"] for group in fee_groups] - fee_values = [round(group["fee"], 2) for group in fee_groups] - - template_content = asset_path("template", "egress.html").read_text( - encoding="utf-8" - ) - - scope = scope_facts(provider_details or {}, csp, None) - executive_id = format_document_id(DOCUMENT_CODE, run_id) - env = Environment(autoescape=True) - template = env.from_string(template_content) - html_content = template.render( - page_title=f"{b['cover_title_egress']} – {meta['name']}", - document_id=format_document_id(EGRESS_DOCUMENT_CODE, run_id), - name=meta["name"], - cloud_service_provider=csp, - assessment_type=meta["assessment_type"], - timestamp=meta["timestamp"], - total_data_display=summary["total_data_display"], - total_fee_display=summary["total_fee_display"], - unknown_count=summary["unknown_count"], - has_allocation=bool(allocation_values), - allocation_labels_json=json.dumps(allocation_labels), - allocation_values_json=json.dumps(allocation_values), - allocation_colors_json=json.dumps(allocation_colors), - has_fees=bool(fee_values), - fee_labels_json=json.dumps(fee_labels), - fee_values_json=json.dumps(fee_values), - free_tier=summary["free_tier"], - free_tier_limit_display=summary["free_tier_limit"], - known_data_display=format_gib(estimate["totals"]["known_size_bytes"]), - type_groups=type_groups, - introduction=introduction_facts(scope, b), - coverage=coverage_facts(csp, b), - landscape=landscape_facts(estimate, b), - costs=cost_facts(estimate, b), - pricing=pricing_facts(estimate, b), - next_steps=next_steps_facts(executive_id, b=b), - notices=notices_html(csp, _generated_at(meta["timestamp"]), b, egress=True), - ) - - html_path = os.path.join(report_path, "egress.html") - with open(html_path, "w", encoding="utf-8") as report_file: - report_file.write(html_content) - - return { - "success": True, - "logs": "Egress report generated successfully.", - "html_path": html_path, - } - - except Exception as e: - logger.error(f"Error generating egress report: {str(e)}", exc_info=True) - return {"success": False, "logs": str(e)} diff --git a/cloudexit/report/egress_report.py b/cloudexit/report/egress_report.py index 6acc846..8e108f6 100644 --- a/cloudexit/report/egress_report.py +++ b/cloudexit/report/egress_report.py @@ -48,10 +48,10 @@ def build_egress_content(context, estimate, styles, b): return content -def generate_egress_report(context, json_path, path=None): +def generate_egress_report(context, json_path, path=None, estimate=None): path = path or os.path.join(context.report_path, "egress.pdf") b = BLOCKS - estimate = load_egress_estimate(context.report_path, json_path) + estimate = estimate or load_egress_estimate(context.report_path, json_path) cover = RecordCover( b["cover_title_egress"], context, diff --git a/cloudexit/report/evidence.py b/cloudexit/report/evidence.py index 63600e3..e0ea97e 100644 --- a/cloudexit/report/evidence.py +++ b/cloudexit/report/evidence.py @@ -53,54 +53,62 @@ def build_document_table( return key_value_table(rows, context.appearance) -def _scope_rows(context, b): +def scope_rows(context, b): + # (label, text, monospaced), the same for the PDF and the HTML report scope = context.scope values = dict(scope.rows) - rows = [(b["ev_cloud_provider"], scope.provider or b["ev_dash"])] + rows = [(b["ev_cloud_provider"], scope.provider or b["ev_dash"], False)] if scope.kind == "aws": if values.get("account"): - rows.append((b["ev_account_id"], _mono_value(values["account"]))) - rows.append((b["ev_region"], values.get("region") or b["ev_dash"])) + rows.append((b["ev_account_id"], values["account"], True)) + rows.append((b["ev_region"], values.get("region") or b["ev_dash"], False)) elif scope.kind == "azure": + rows.append((b["ev_tenant_id"], values.get("tenant") or b["ev_dash"], True)) rows.append( - (b["ev_tenant_id"], _mono_value(values.get("tenant")) or b["ev_dash"]) + (b["ev_subscription_id"], values.get("subscription") or b["ev_dash"], True) ) rows.append( ( - b["ev_subscription_id"], - _mono_value(values.get("subscription")) or b["ev_dash"], + b["ev_resource_group"], + values.get("resource_group") or b["ev_dash"], + False, ) ) - rows.append( - (b["ev_resource_group"], values.get("resource_group") or b["ev_dash"]) - ) else: file_text = values.get("file") or b["ev_dash"] if values.get("sha256"): file_text = f"{file_text} (SHA-256 {_short(values['sha256'])})" - rows.append((b["ev_file"], file_text)) + rows.append((b["ev_file"], file_text, False)) if values.get("lineage"): state = values["lineage"] if values.get("serial") is not None: state = f"{state} (serial {values['serial']})" - rows.append((b["ev_state"], _mono_value(state))) + rows.append((b["ev_state"], state, True)) for key, label in ( ("subscriptions", "ev_subscriptions"), ("resource_groups", "ev_resource_groups"), ("regions", "ev_regions"), ): if key in values: - rows.append((b[label], values[key] or b["ev_not_determinable"])) + rows.append((b[label], values[key] or b["ev_not_determinable"], False)) source = b["ev_source_tfstate" if scope.kind == "tfstate" else "ev_source_live"] rows.append( ( b["ev_source_label"], b["ev_source_value"].format(source=source, completed=context.completed), + False, ) ) return rows +def _scope_rows(context, b): + return [ + (label, _mono_value(text) if mono and text != b["ev_dash"] else text) + for label, text, mono in scope_rows(context, b) + ] + + def build_scope_table(context, b): return key_value_table(_scope_rows(context, b), context.appearance) diff --git a/cloudexit/report/helpers.py b/cloudexit/report/helpers.py index b79acf4..3536541 100644 --- a/cloudexit/report/helpers.py +++ b/cloudexit/report/helpers.py @@ -1,3 +1,5 @@ +import os + CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£"} @@ -19,10 +21,13 @@ def format_money(value, currency): return f"{symbol}{amount}" if symbol else f"{currency} {amount}" -def gib_number(size_bytes): - # One decimal; a size that is not zero never reads as 0.0 +def gib_number(size_bytes, places=1): + # A size that is not zero never reads as 0.0 gib = size_bytes / 2**30 - return "<0.1" if 0 < gib < 0.1 else f"{gib:,.1f}" + smallest = 10**-places + if 0 < gib < smallest: + return f"<{smallest:.{places}f}" + return f"{gib:,.{places}f}" def format_gib(size_bytes): @@ -71,3 +76,19 @@ def initials(name): if len(letters) == 2: break return "".join(letters) or "?" + + +REPORT_ASSETS = "assets" + + +def report_icon(report_path, icon_rel): + # The page only points at files in its own folder: an icon the report + # folder does not hold is left out, and the page draws a placeholder + if icon_rel and os.path.isfile(os.path.join(report_path, REPORT_ASSETS, icon_rel)): + return f"{REPORT_ASSETS}/{icon_rel}" + return None + + +def percent(share): + # With a no-break space, so a figure and its sign stay together + return share_label(share).replace(" ", " ") diff --git a/cloudexit/report/html_data.py b/cloudexit/report/html_data.py deleted file mode 100644 index 40377d5..0000000 --- a/cloudexit/report/html_data.py +++ /dev/null @@ -1,63 +0,0 @@ -# cloudexit/report/html_data.py -import logging -from typing import Any - -from cloudexit.report.aggregate import ( - summarize_alternative_technologies, - summarize_costs, - summarize_risks, -) - -# Configure logger -logger = logging.getLogger(__name__) - - -def transform_cost_inventory_for_html( - cost_data: list[dict[str, Any]], -) -> tuple[list[str], list[float], float, str, str]: - return summarize_costs(cost_data) - - -def transform_risk_inventory_for_html( - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - resource_inventory: dict[str, dict[str, Any]], -) -> tuple[list[dict[str, Any]], dict[str, int]]: - severity_order = {"high": 1, "medium": 2, "low": 3} - resource_name_map = { - str(key): value["name"] for key, value in resource_inventory.items() - } - risks, severity_counts = summarize_risks( - risk_data, - risk_definitions, - resource_name_map=resource_name_map, - ) - risks.sort(key=lambda x: severity_order.get(x["severity"], 4)) - return risks, severity_counts - - -def transform_alt_tech_for_html( - resource_inventory: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - alternative_technology_organizations: list[dict[str, Any]] | None = None, -) -> list[dict[str, Any]]: - alt_tech_data = [] - grouped_alt_tech = summarize_alternative_technologies( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations=alternative_technology_organizations, - ) - for resource in resource_inventory: - resource_type = str(resource.get("resource_type")) - for tech in grouped_alt_tech.get(resource_type, []): - alt_tech_data.append( - { - "resource_type_id": resource.get("resource_type"), - **tech, - } - ) - return alt_tech_data diff --git a/cloudexit/report/html_egress.py b/cloudexit/report/html_egress.py new file mode 100644 index 0000000..fa19b09 --- /dev/null +++ b/cloudexit/report/html_egress.py @@ -0,0 +1,248 @@ +from .egress import ( + DEFAULT_PRICING_ZONE, + coverage_facts, + fee_number, + free_tier_headroom, + pricing_facts, +) +from .context import EGRESS_DOCUMENT_CODE, PROVIDER_NAMES +from .helpers import gib_number, percent, report_icon +from .wording import BLOCKS + +SEGMENT_COLORS = ( + "var(--primary-800)", + "var(--primary-600)", + "#2dd4c8", + "#5eeae1", + "#99f6f0", +) +OTHER_COLOR = "var(--neutral-300)" + + +def _share(part, whole): + return part / whole * 100 if whole else 0 + + +def _internet_unit(prices, csp): + return next( + ( + price["unit"] + for price in prices + if price["csp"] == csp + and price["component"] == "internet_egress" + and price["zone"] == DEFAULT_PRICING_ZONE + ), + "GiB", + ) + + +def _kpis(estimate, sized, unsized, groups, b): + summary = estimate["summary"] + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + headroom = free_tier_headroom(estimate["pricing"], csp, total) + plus = "+" if summary["unknown_count"] > 0 else "" + + kpis = [ + { + "label": b["html_eg_kpi_fee"], + "figure": summary["total_fee_display"], + "unit": "", + "caption": b[ + "html_eg_fee_free" if summary["free_tier"] else "html_eg_fee_paid" + ], + "primary": True, + }, + { + "label": b["eg_st_volume"], + "figure": gib_number(total), + "unit": f"GiB{plus}", + "caption": ( + b["html_eg_types_sized"].format(sized=len(sized), types=len(groups)) + if groups + else "" + ), + }, + ] + if headroom: + kpis.append( + { + "label": b["html_eg_kpi_headroom"], + "figure": f"{headroom['left']:,.1f}", + "unit": headroom["unit"], + "caption": b["html_eg_headroom_left"].format( + limit=f"{headroom['limit']:g} {headroom['unit']}" + ), + "bar": round(headroom["used_share"]), + "aria": b["html_eg_headroom_aria"].format( + share=percent(headroom["used_share"]) + ), + } + ) + else: + kpis.append( + { + "label": b["eg_st_billable"], + "figure": gib_number(total), + "unit": "GiB", + "caption": b["html_eg_billable_caption"], + } + ) + kpis.append( + { + "label": b["eg_st_not_sized"], + "figure": str(len(unsized)), + "unit": "", + "caption": b["html_eg_not_sized_caption"], + } + ) + return kpis, headroom + + +def _volume(sized, unsized, total, b): + if not sized or total <= 0: + return None + shown, rest = sized[: len(SEGMENT_COLORS)], sized[len(SEGMENT_COLORS) :] + segments = [ + ( + group["label"], + _share(group["size_bytes"], total), + SEGMENT_COLORS[index], + ) + for index, group in enumerate(shown) + ] + if rest: + segments.append( + ( + b["html_eg_vol_other"], + _share(sum(group["size_bytes"] for group in rest), total), + OTHER_COLOR, + ) + ) + legend = [ + {"text": f"{label} {percent(share)}", "color": color} + for label, share, color in segments + ] + return { + "segments": [ + {"flex": round(share, 1), "color": color} for _, share, color in segments + ], + "legend": legend, + "unsized": ( + b["html_eg_vol_unsized"].format( + types=", ".join(group["label"] for group in unsized) + ) + if unsized + else None + ), + "aria": b["html_eg_vol_aria"].format( + parts=", ".join(item["text"] for item in legend) + ), + } + + +def _table(context, estimate, sized, unsized, total, b): + plus = "+" if estimate["summary"]["unknown_count"] > 0 else "" + + def row(group): + icon = group["icon"].lstrip("/") if group["icon"] else "" + return { + "label": group["label"], + "initials": group["initials"], + "icon": report_icon(context.report_path, icon), + } + + rows = [ + { + **row(group), + "size": gib_number(group["size_bytes"], 2), + "share": percent(_share(group["size_bytes"], total)), + "share_value": round(_share(group["size_bytes"], total)), + "fee": fee_number(group["fee"] or 0.0), + } + for group in sized + ] + rows += [ + {**row(group), "size": None, "share": None, "share_value": 0, "fee": None} + for group in unsized + ] + return { + "headers": ( + b["eg_th_resource_type"], + b["eg_th_size_gib"], + b["eg_th_share"], + b["eg_th_fee"], + ), + "rows": rows, + "not_sized": b["html_eg_not_sized"], + "total": ( + b["eg_total"], + f"{gib_number(total, 2)}{plus}", + fee_number(estimate["total_fee"]), + ), + } + + +def _notes(context, estimate, headroom, b): + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + provider = PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"] + unit = _internet_unit(estimate["pricing"], csp) + if unit == "GB" and headroom: + used_gb = total / 10**9 + units = b["html_eg_units_gb"].format( + provider=provider, + gib=gib_number(total), + gb=f"{used_gb:,.1f}", + limit=f"{headroom['limit']:g} {headroom['unit']}", + ) + elif unit == "GB": + units = b["html_eg_units_gb_plain"].format(provider=provider) + else: + units = b["html_eg_units_gib"].format(provider=provider) + + currencies = {entry.currency for entry in context.costs} + if len(currencies) == 1: + amounts = b["html_eg_amounts_costs"].format(currency=next(iter(currencies))) + else: + amounts = b["html_eg_amounts"] + return [f"{units} {amounts}", b["html_eg_scope_note"]] + + +def egress_page(context, estimate, b=BLOCKS): + csp = estimate["meta"]["cloud_service_provider"] + total = estimate["totals"]["known_size_bytes"] + groups = estimate["type_groups"] + sized = sorted( + (group for group in groups if group["size_bytes"] is not None), + key=lambda group: -group["size_bytes"], + ) + unsized = [group for group in groups if group["size_bytes"] is None] + kpis, headroom = _kpis(estimate, sized, unsized, groups, b) + prices = pricing_facts(estimate, b) + return { + "heading": b["ev_egress_report"], + "caption": b["html_eg_caption"].format( + document_id=context.egress_document_id + or context.document_id_for(EGRESS_DOCUMENT_CODE) + ), + "empty": b["eg_no_data_notice"] if not estimate["rows"] else None, + "kpis": kpis, + "volume": _volume(sized, unsized, total, b), + "table": _table(context, estimate, sized, unsized, total, b), + "notes": _notes(context, estimate, headroom, b), + "sized": { + "summary": b["html_eg_sized_summary"], + "facts": coverage_facts(csp, b), + }, + "prices": ( + { + "summary": b["html_eg_prices_summary"].format( + provider=PROVIDER_NAMES.get(csp) or b["eg_provider_fallback"] + ), + "facts": prices, + } + if prices + else None + ), + } diff --git a/cloudexit/report/html_sections.py b/cloudexit/report/html_sections.py index a087a15..e41ef37 100644 --- a/cloudexit/report/html_sections.py +++ b/cloudexit/report/html_sections.py @@ -1,126 +1,854 @@ -from xml.sax.saxutils import escape +from markupsafe import Markup -from .brief_summary import collect_brief_facts, finding_rows, lead_text -from .helpers import severity_label -from .notices import notices_html +from .evidence import scope_rows +from .helpers import ( + format_change, + initials, + format_money, + percent, + report_icon, + severity_label, + strategy_name, +) +from .html_egress import egress_page +from .notices import text_parts from .palette import SEVERITIES -from .sections import egress_note, glossary_entries, scope_boundary, scope_strategy +from .sections import ( + AWS_ICONS_URL, + AWS_TRADEMARK_URL, + AZURE_ICONS_URL, + PRODUCT_SITES, + REPOSITORY_URL, +) from .wording import BLOCKS +ALT_VISIBLE = 8 +ALT_OPEN = 2 +OPEN_RISKS_UP_TO = 3 +AFFECTED_SHOWN = 3 +REGIONS_SHOWN = 5 +PROVIDER_LOGOS = {1: "azure", 2: "aws"} +ABOUT_URL = "#about" -def _version(text): - return f"{escape(text)}" +def _count(b, key, n): + return b[f"{key}_one"] if n == 1 else b[f"{key}_many"].format(n=n) -def html_sections(context, b=BLOCKS): - # The same wording as the PDF; strings that carry markup are already - # escaped, so the template may mark them safe. - facts = collect_brief_facts(context, b) + +def _share(part, whole): + return part / whole * 100 if whole else 0 + + +def _month(month, b): + return b["html_months"][int(month[5:7]) - 1] + + +def _mono(text): + return Markup('{}').format(text) + + +def _regions_line(context, b): + if not context.total_resources: + return b["html_scope_no_resources"] + names = sorted(context.regions) + return f"{_count(b, 'scope_regions', len(names))} · {', '.join(names)}" + + +def _title(context, has_egress, b): provenance = context.provenance + platform_url = provenance.platform_url if provenance.uploaded else None + logo = PROVIDER_LOGOS.get(context.cloud_service_provider) + downloads = [("report.pdf", b["html_download_summary"])] + if has_egress: + downloads.append(("egress.pdf", b["html_download_egress"])) + return { + "logo": f"assets/img/csp/{logo}.svg" if logo else None, + "provider": context.provider, + "name": context.name, + "uploaded": provenance.uploaded, + "chip": b["html_chip_uploaded" if provenance.uploaded else "html_chip_self"], + "chip_title": b[ + ( + "html_chip_uploaded_title" + if provenance.uploaded + else "html_chip_self_title" + ) + ], + "platform_url": platform_url, + "downloads": downloads, + } + - if provenance.catalogue_version and provenance.catalogue_sha256: - catalogue = ( - f"{provenance.catalogue_version} · (sha256 " - f"{provenance.catalogue_sha256[:12]})" +def _scope_card(context, b): + scope = context.scope + values = dict(scope.rows) + regions = _regions_line(context, b) + rows = [{"label": b["ev_cloud_provider"], "value": scope.provider or b["ev_dash"]}] + if scope.kind == "azure": + rows.append( + { + "label": b["ev_resource_group"], + "value": scope.resource_group or b["ev_dash"], + "small": regions, + } + ) + elif scope.kind == "aws": + rows.append( + { + "label": b["ev_region"], + "value": scope.region or b["ev_dash"], + "small": regions, + } ) else: - catalogue = provenance.catalogue_version or b["ev_catalogue_unknown"] + serial = values.get("serial") + small = " · ".join( + part + for part in ( + b["html_scope_serial"].format(serial=serial) if serial else "", + regions, + ) + if part + ) + rows.append( + { + "label": b["ev_file"], + "value": scope.file_name or b["ev_dash"], + "small": small, + } + ) + rows.append( + { + "label": b["cover_exit_strategy"], + "value": strategy_name(context.strategy_code, b), + } + ) + provenance = context.provenance + source = b["html_run_tfstate" if scope.kind == "tfstate" else "html_run_live"] + rows.append( + { + "label": b["html_run_label"], + "value": context.completed, + "small": Markup(b["html_run_small"]).format( + run=_mono(provenance.run_short_id or b["ev_dash"]), source=source + ), + } + ) + sha = provenance.catalogue_sha256 + rows.append( + { + "label": b["ev_catalogue_label"], + "value": provenance.catalogue_version or b["ev_catalogue_unknown"], + "small": b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None, + "small_mono": True, + } + ) + return {"label": b["html_scope_label"], "rows": rows} + +def _risks_card(context, b): + risks = context.risks + counts = { + severity: sum(1 for risk in risks if risk.severity == severity) + for severity in SEVERITIES + } return { - "title": b["ev_executive_report"], - "brief": { - "heading": b["bs_heading"], - "lead": lead_text(facts, b), - "findings_heading": b["bs_findings_heading"], - "headers": (b["bs_th_area"], b["bs_th_finding"], b["bs_th_details"]), - "rows": [ + "label": b["html_risks_label"], + "total": len(risks), + "none": b["bs_find_risks_none"] if not risks else None, + "aria": b["html_sev_aria"].format( + counts=", ".join( + f"{counts[severity]} {severity_label(severity, b).lower()}" + for severity in SEVERITIES + ) + ), + "rows": [ + { + "severity": severity, + "label": severity_label(severity, b), + "count": counts[severity], + "width": round(_share(counts[severity], len(risks))), + } + for severity in SEVERITIES + ], + } + + +def _costs_card(context, b): + if context.scope.kind == "tfstate": + return { + "kind": "message", + "label": b["html_costs_label_short"], + "figure": b["ev_dash"], + "message": b["cost_tfstate"], + "hint": b["html_costs_tfstate_hint"], + } + entries = context.costs + if not entries or len({entry.currency for entry in entries}) > 1: + return { + "kind": "message", + "label": b["html_costs_label"], + "figure": b["ev_dash"], + "message": (b["cost_mixed_currency"] if entries else b["tbl_cost_no_data"]), + "hint": None, + } + + currency = entries[-1].currency + total = sum(entry.amount for entry in entries) + latest = entries[-1] + before = entries[-2] if len(entries) > 1 else None + biggest = max(entry.amount for entry in entries) + provisional = [entry for entry in entries if not entry.final] + + def label(entry): + return _month(entry.month, b) + ("" if entry.final else "*") + + parts = [ + b[ + "html_costs_aria_part" if entry.final else "html_costs_aria_provisional" + ].format( + month=_month(entry.month, b), amount=format_money(entry.amount, currency) + ) + for entry in entries + ] + return { + "kind": "figures", + "label": b["html_costs_label"], + "figure": format_money(total, currency), + "caption": ( + b["html_costs_none"] + if total == 0 + else b["html_costs_average"].format( + amount=format_money(total / len(entries), currency) + ) + ), + "change": format_change( + before.amount if before else None, latest.amount + ).replace(" %", " %"), + "change_caption": ( + b["html_costs_change"].format( + latest=_month(latest.month, b), previous=_month(before.month, b) + ) + if before + else "" + ), + "bars": [ + { + "month": label(entry), + "height": round(_share(entry.amount, biggest)) if biggest else 0, + "zero": entry.amount <= 0, + "provisional": not entry.final, + } + for entry in entries + ], + "aria": b["html_costs_aria"].format(parts=", ".join(parts)), + "footnote": ( + b["html_costs_provisional"].format(month=_month(provisional[0].month, b)) + if provisional + else b["html_costs_context"] + ), + } + + +def _hint(b): + return { + "title": b["html_hint_title"], + "text": b["html_hint_text"], + "sites": [ + (b["html_site_exitcloud"], dict(PRODUCT_SITES)["exitcloud"]), + (b["html_site_escapecloud"], dict(PRODUCT_SITES)["escapecloud"]), + ], + } + + +def _scores(alternatives_total, platform_url, b): + on_platform = f" {b['html_scores_on_platform']}" if platform_url else "" + lock_text = ( + b["html_scores_lock_text"].format( + alternatives=_count(b, "html_alt_n", alternatives_total) + ) + if alternatives_total + else b["html_scores_lock_text_plain"] + ) + return { + "platform_url": platform_url, + "open_platform": b["html_open_platform"], + "pill": b["html_scores_pill"], + "hint": _hint(b), + "grade": { + "title": b["html_scores_grade"], + "placeholder": b["ev_dash"], + "scale": b["html_scores_scale"], + "bands": b["html_scores_bands"], + "text": b["html_scores_grade_text"] + on_platform, + }, + "lock": { + "title": b["html_scores_lock"], + "dims": [ + (name, b["html_scores_dim_value"].format(dash=b["ev_dash"])) + for name in b["html_scores_dims"] + ], + "text": lock_text + on_platform, + }, + } + + +def _risks(context, b): + risks = context.risks + names = {entry.resource_type_id: entry.name for entry in context.resources} + wide = sum(1 for risk in risks if risk.scope_wide) + if not risks: + caption = b["html_risks_none_caption"] + else: + count = _count(b, "html_risks_count", len(risks)) + if not wide: + caption = b["html_risks_caption_only_count"].format(count=count) + else: + if wide == len(risks): + key = "html_risks_wide_sole" if wide == 1 else "html_risks_wide_all" + else: + key = "html_risks_wide_one" if wide == 1 else "html_risks_wide_many" + caption = b["html_risks_caption"].format( + count=count, scope=b[key].format(n=wide) + ) + + def affected(risk): + if risk.scope_wide: + return None + listed = sorted( + names.get(type_id, b["ev_dash"]) for type_id in risk.resource_type_ids + ) + text = ", ".join(listed[:AFFECTED_SHOWN]) + if len(listed) > AFFECTED_SHOWN: + text += " " + b["html_risks_more"].format(n=len(listed) - AFFECTED_SHOWN) + return text + + groups, position = [], 0 + for severity in SEVERITIES: + members = [risk for risk in risks if risk.severity == severity] + rows = [] + for risk in members: + position += 1 + rows.append( { - "area": area, - "finding": finding, - "details": details, - "attention": attention, + "id": f"risk-{position}", + "code": risk.code, + "name": risk.name, + "description": risk.description, + "severity": severity, + "label": severity_label(severity, b), + "affected": affected(risk), } - for area, finding, details, attention in finding_rows(facts, b) - ], - "caption": b["bs_caption"].format( - version=_version(facts.version), completed=escape(facts.completed) + ) + if rows: + groups.append( + { + "heading": b["risk_group"].format( + severity=severity_label(severity, b), n=len(rows) + ), + "rows": rows, + } + ) + return { + "heading": b["html_nav_risks"], + "caption": caption, + "headers": ( + b["risk_th_code"], + b["risk_th_risk"], + b["risk_th_severity"], + b["html_risks_th_affected"], + ), + "scope_wide": b["risk_scope_wide"], + "groups": groups, + "open": len(risks) <= OPEN_RISKS_UP_TO, + "footnote": text_parts( + b["html_risks_footnote"], {"link": (b["html_nav_about"], ABOUT_URL)} + ), + "empty": ( + { + "title": b["bs_find_risks_none"], + "text": b[ + ( + "html_risks_empty_clean" + if context.total_resources + else "html_risks_empty_scope" + ) + ], + } + if not risks + else None + ), + } + + +def _inventory(context, b): + total = context.total_resources + scope = context.scope + regions = {} + for entry in context.resources: + for location, count in entry.location_counts.items(): + regions[location] = regions.get(location, 0) + count + ranked = sorted(regions.items(), key=lambda item: (-item[1], item[0])) + shown = [ + f"{name} {percent(_share(count, total))}" + for name, count in ranked[:REGIONS_SHOWN] + ] + if len(ranked) > REGIONS_SHOWN: + shown.append(b["html_risks_more"].format(n=len(ranked) - REGIONS_SHOWN)) + caption_parts = [_count(b, "scope_resources", total)] + if total: + caption_parts += [ + _count(b, "scope_types", len(context.resources)), + b["html_inv_regions"].format( + regions=_count(b, "scope_regions", len(regions)), + shares=", ".join(shown), ), - }, - "provenance": { - "heading": b["ev_provenance_heading"], + ] + if scope.kind == "tfstate": + caption_parts.append(b["html_inv_not_managed"]) + + if scope.kind == "azure": + empty = b["html_inv_empty_resource_group"].format( + scope=scope.resource_group or b["ev_dash"] + ) + elif scope.kind == "aws": + empty = b["html_inv_empty_region"].format(scope=scope.region or b["ev_dash"]) + else: + empty = b["html_inv_empty_tfstate"].format( + scope=scope.file_name or b["ev_dash"] + ) + + return { + "heading": b["es_heading_resource_inventory"], + "caption": " · ".join(caption_parts), + "headers": ( + b["res_th_resource_type"], + b["html_inv_th_locations"], + b["res_th_share"], + b["res_th_resources"], + ), + "rows": [ + { + "name": entry.name, + "initials": initials(entry.name), + "icon": report_icon(context.report_path, entry.icon_rel), + "locations": ", ".join( + f"{name} · {count}" + for name, count in sorted( + entry.location_counts.items(), + key=lambda item: (-item[1], item[0]), + ) + ), + "share": percent(_share(entry.resources, total)), + "share_value": round(_share(entry.resources, total)), + "count": entry.resources, + } + for entry in context.resources + ], + "total": (b["res_total"], total), + "footnote": b["es_res_caption"], + "empty": ( + {"title": b["html_inv_empty_title"], "text": empty} if not total else None + ), + } + + +def _alternatives(context, b): + entries = sorted( + context.alternatives, + key=lambda entry: ( + not entry.alternatives, + -len(entry.alternatives), + -entry.resources, + entry.name.lower(), + ), + ) + total = sum(len(entry.alternatives) for entry in entries) + with_alternatives = sum(1 for entry in entries if entry.alternatives) + provenance = context.provenance + + groups = [] + for position, entry in enumerate(entries): + rows = [ + { + "name": alt.name, + "vendor": alt.vendor or b["ev_dash"], + "cc": alt.country or "", + "os": alt.open_source, + "sp": alt.support_plan, + "extra": index >= ALT_VISIBLE, + } + for index, alt in enumerate(entry.alternatives) + ] + resources = _count(b, "scope_resources", entry.resources) + hidden = max(0, len(rows) - ALT_VISIBLE) + groups.append( + { + "name": entry.name, + "initials": initials(entry.name), + "icon": report_icon(context.report_path, entry.icon_rel), + "meta": ( + b["html_alt_meta"].format( + resources=resources, + alternatives=_count(b, "html_alt_n", len(rows)), + ) + if rows + else b["html_alt_meta_none"].format(resources=resources) + ), + "rows": rows, + "open": position < ALT_OPEN and bool(rows), + "hidden": hidden, + "more": (_count_more(b, hidden) if hidden else None), + } + ) + + if not entries: + caption = _count(b, "html_alt_n", 0) + else: + key = "html_alt_caption" if len(entries) != 1 else "html_alt_caption_one_type" + caption = b[key].format( + alternatives=_count(b, "html_alt_n", total), + with_alternatives=with_alternatives, + types=len(entries), + ) + return { + "heading": b["es_heading_alternative_technologies"], + "caption": caption, + "total": total, + "search": b["html_alt_search"], + "filters": [ + ("os", b["html_filter_os"]), + ("sp", b["html_filter_sp"]), + ("eu", b["html_filter_eu"]), + ], + "headers": (b["html_alt_th_name"], b["html_filter_os"], b["html_filter_sp"]), + "yes": b["html_yes"], + "no": b["html_no"], + "no_match": b["html_alt_no_match"], + "groups": groups, + "footnote": b["html_alt_footnote"].format( + version=provenance.catalogue_version or b["ev_catalogue_unknown"] + ), + "empty": ( + {"title": b["html_alt_empty_title"], "text": b["html_alt_empty_text"]} + if not entries + else None + ), + } + + +def _count_more(b, n): + return b["html_alt_more_one"] if n == 1 else b["html_alt_more_many"].format(n=n) + + +def _platform_cell(context, b): + provenance = context.provenance + if not provenance.uploaded: + if provenance.upload_reason: + return [ + b["html_platform_not_uploaded"].format(reason=provenance.upload_reason) + ] + return [b["ev_platform_none"]] + parts = [b["ev_platform_uploaded"]] + if provenance.platform_url: + label = provenance.platform_url.split("://", 1)[-1] + if len(label) > 40: + label = label[:40] + "…" + return [f"{parts[0]}: ", (label, provenance.platform_url)] + if provenance.assessment_id: + parts.append(b["fa_uploaded_id"].format(assessment_id=provenance.assessment_id)) + return [" · ".join(parts)] + + +def _scope_table(context, b): + rows = scope_rows(context, b) + if context.scope.kind != "tfstate": + return rows + # A state file names no tenant, subscription or resource group of a run: its + # own file and state, and the regions of what it holds + dropped = {b["ev_subscriptions"], b["ev_resource_groups"]} + rows = [row for row in rows if row[0] not in dropped] + if not any(row[0] == b["ev_regions"] for row in rows): + regions = ", ".join(sorted(context.regions)) or b["ev_not_determinable"] + rows.insert(-1, (b["ev_regions"], regions, False)) + return rows + + +def _about(context, egress, b): + provenance = context.provenance + tfstate = context.scope.kind == "tfstate" + sha = provenance.catalogue_sha256 + glossary = [ + entry + for entry in b["html_glossary"] + if (entry["id"] != "aws" or context.cloud_service_provider == 2) + and (entry["id"] not in ("egress", "free_tier", "gib_gb") or egress is not None) + ] + phases = [ + ( + name, + b["html_method_discovery_tfstate"] if tfstate and index == 0 else text, + ) + for index, (name, text) in enumerate(b["es_method_phases"]) + ] + return { + "heading": b["html_nav_about"], + "caption": b["html_about_caption"], + "document": { + "heading": b["html_about_document"], "rows": [ + (b["ev_document_id"], context.document_id, True), + (b["ev_document_type"], b["ev_executive_report"], False), + (b["ev_language"], b["language_name"], False), ( b["ev_collected_by"], b["ev_collected_by_value"].format( version=provenance.collector_version ), + False, ), - (b["ev_collected_at"], provenance.collected_date), - *([(b["ev_run_id"], provenance.run_id)] if provenance.run_id else []), - (b["ev_catalogue_label"], catalogue), ( - b["ev_platform_label"], - ( - b["ev_platform_uploaded"] - if provenance.uploaded - else b["ev_platform_none"] - ), + b["ev_generated_at"], + provenance.generated_at.strftime("%Y-%m-%d %H:%M UTC"), + False, ), ], - "platform_url": provenance.platform_url, - "document_id": context.document_id, }, - "costs_egress_note": egress_note(context, b), - "introduction": { - "heading": b["es_heading_introduction"], - "purpose_heading": b["es_heading_purpose"], - "purpose": b["es_purpose_block"], - "scope_heading": b["es_heading_scope"], - "scope": [scope_boundary(context, b), scope_strategy(context, b)], - "method_heading": b["es_heading_method"], - "method": [b["es_method_lead"], b["es_method_phases_lead"]], - "phases": b["es_method_phases"], - "method_catalogue": b["es_method_catalogue"], - "limitations_heading": b["es_heading_limitations"], - "limitations": [b["err_limitations"], b["err_limitations_data"]], + "scope": { + "heading": b["ev_assessment_scope_heading"], + "rows": _scope_table(context, b), + }, + "provenance": { + "heading": b["ev_provenance_heading"], + "run_id": ( + (b["ev_run_id"], provenance.run_id) if provenance.run_id else None + ), + "catalogue": ( + b["ev_catalogue_label"], + provenance.catalogue_version or b["ev_catalogue_unknown"], + b["html_catalogue_sha"].format(sha=sha[:12]) if sha else None, + ), + "platform": (b["ev_platform_label"], _platform_cell(context, b)), + }, + "stored": b["ev_stored_footnote"].format( + run=provenance.run_short_id or b["ev_dash"] + ), + "purpose": { + "summary": b["html_about_purpose"], + "meta": b["html_about_phases_meta"].format(n=len(phases)), + "paragraphs": [b["es_purpose_block"], b["html_method_lead"]], + "phases": phases, + "closing": b["html_method_catalogue"], + }, + "limitations": { + "summary": b["es_heading_limitations"], + "paragraphs": [b["err_limitations"], b["html_limitations_data"]], }, "severity": { - "heading": b["es_heading_severity"], - "lead": [b["es_severity_lead"], b["es_severity_note"]], + "summary": b["es_heading_severity"], + "meta": b["html_about_levels_meta"].format(n=len(SEVERITIES)), "headers": (b["risk_th_severity"], b["es_severity_th_meaning"]), "rows": [ - (severity_label(level, b), b[f"es_severity_{level}"]) + (level, severity_label(level, b), b[f"es_severity_{level}"]) for level in SEVERITIES ], + "note": b["es_severity_note"], }, "glossary": { - "heading": b["es_heading_glossary"], - "lead": b["es_glossary_block"], + "summary": b["es_heading_glossary"], + "meta": b["html_about_terms_meta"].format(n=len(glossary)), "entries": [ (entry["term"], entry["definition"]) - for entry in sorted( - glossary_entries(context, b), key=lambda e: e["term"].lower() - ) + for entry in sorted(glossary, key=lambda e: e["term"].lower()) ], }, - "closing": { - "heading": b[ - "fa_heading_uploaded" if provenance.uploaded else "fa_heading" - ], - "uploaded": provenance.uploaded, - "lead": b["fa_uploaded"] if provenance.uploaded else b["fa_lead"], - "points": [] if provenance.uploaded else b["fa_items"], - "how_heading": b["fa_how_heading"], - "how": b["fa_how"], - "command": b["fa_command"], - "sites": [ - ("EscapeCloud", "https://escapecloud.io"), - ("ExitCloud", "https://exitcloud.io"), + } + + +def _notices(context, egress, b): + provenance = context.provenance + icons = None + if context.cloud_service_provider == 2: + icons = text_parts( + b["notices_icons_aws"], + { + "icons_url": (b["html_label_aws_icons"], AWS_ICONS_URL), + "guidelines_url": (b["html_label_aws_guidelines"], AWS_TRADEMARK_URL), + }, + ) + elif context.cloud_service_provider == 1: + icons = text_parts( + b["notices_icons_azure"], + {"icons_url": (b["html_label_azure_icons"], AZURE_ICONS_URL)}, + ) + if context.scope.kind == "tfstate": + figures = b["html_figures_tfstate"] + else: + figures = b["html_figures_egress" if egress is not None else "html_figures"] + return { + "disclaimer": { + "heading": b["notices_disclaimer_heading"], + "paragraphs": [ + [b["notices_disclaimer"]], + [f"{b['notices_generated']} {b['html_notice_advice']}"], + *([icons] if icons else []), ], - "url": provenance.platform_url, - "assessment_id": provenance.assessment_id, }, - "notices": notices_html( - context.cloud_service_provider, provenance.generated_at, b + "blocks": [ + { + "heading": b["notices_trademarks_heading"], + "parts": [ + f"{b['notices_trademark_own']} {b['notices_trademark_others']}" + ], + }, + {"heading": b["html_figures_heading"], "parts": [figures]}, + { + "heading": b["notices_software_heading"], + "parts": text_parts( + b["html_software"], + {"repository_link": (b["html_label_repository"], REPOSITORY_URL)}, + ), + }, + ], + "copyright": b["notices_copyright"].format(year=provenance.generated_at.year), + "ids": b["html_footer_ids"].format( + document_id=context.document_id, version=provenance.collector_version + ), + } + + +def _tour(context, egress, linked, b): + tfstate = context.scope.kind == "tfstate" + sections = [b["html_tour_sections"]] + if not linked: + sections.append(b["html_tour_sections_lock"]) + report = [b["html_tour_report"]] + if linked: + report.append(b["html_tour_report_platform"]) + if egress is not None: + report.append( + b[ + ( + "html_tour_download_both_platform" + if linked + else "html_tour_download_both" + ) + ] + ) + else: + report.append(b["html_tour_download_one"]) + scores = ( + b["html_tour_scores_platform"] + if linked + else f"{b['html_tour_scores_available']} {b['html_hint_text']}" + ) + steps = [ + (".secnav", b["html_tour_sections_title"], " ".join(sections), "right"), + (".titlebar", b["html_tour_report_title"], " ".join(report), "bottom"), + ( + "#overview .overview", + b["html_tour_overview_title"], + b["html_tour_overview_tfstate" if tfstate else "html_tour_overview"], + "bottom", + ), + ("#scores .scores", b["html_tour_scores_title"], scores, "bottom"), + ( + "#risks .card-head", + b["html_tour_risks_title"], + b["html_tour_risks"], + "bottom", ), + ( + "#inventory .card-head", + b["html_tour_inventory_title"], + b["html_tour_inventory"], + "bottom", + ), + ( + "#alternatives .card-head", + b["html_tour_alternatives_title"], + b["html_tour_alternatives"], + "bottom", + ), + ] + if egress is not None: + steps.append( + ( + "#egress .kpi-row", + b["html_tour_egress_title"], + b["html_tour_egress"], + "bottom", + ) + ) + steps.append( + ("#about .kv-grid", b["html_tour_about_title"], b["html_tour_about"], "top") + ) + return [ + {"element": element, "title": title, "description": text, "side": side} + for element, title, text, side in steps + ] + + +def html_sections(context, egress=None, b=BLOCKS): + # Everything the page says, as plain data; the template only lays it out + provenance = context.provenance + platform_url = provenance.platform_url if provenance.uploaded else None + alternatives = _alternatives(context, b) + nav = [ + {"id": "overview", "label": b["html_nav_overview"]}, + { + "id": "scores", + "label": b["html_nav_scores"], + "lock": b["html_lock_label"] if not platform_url else None, + }, + {"id": "risks", "label": b["html_nav_risks"], "count": len(context.risks)}, + { + "id": "inventory", + "label": b["es_heading_resource_inventory"], + "count": context.total_resources, + }, + { + "id": "alternatives", + "label": b["es_heading_alternative_technologies"], + "count": alternatives["total"], + }, + ] + if egress is not None: + nav.append({"id": "egress", "label": b["ev_egress_report"]}) + nav.append({"id": "about", "label": b["html_nav_about"]}) + + return { + "title": f"{b['ev_executive_report']} – {context.name}", + "language": "en", + "dash": b["ev_dash"], + "header": { + "tag": b["cover_edition_value"], + "logo_link": b["html_logo_link"], + "help": b["html_help"], + "help_aria": b["html_help_aria"], + "tour": { + "next": b["html_tour_next"], + "back": b["html_tour_back"], + "done": b["html_tour_done"], + "progress": b["html_tour_progress"], + }, + }, + "titlebar": _title(context, egress is not None, b), + "download": b["html_download"], + "open_platform": b["html_open_platform"], + "nav": nav, + "nav_label": b["html_nav_label"], + "overview": { + "scope": _scope_card(context, b), + "risks": _risks_card(context, b), + "costs": _costs_card(context, b), + }, + "scores": _scores(alternatives["total"], platform_url, b), + "risks": _risks(context, b), + "inventory": _inventory(context, b), + "alternatives": alternatives, + "egress": egress_page(context, egress, b) if egress is not None else None, + "about": _about(context, egress, b), + "notices": _notices(context, egress, b), + "tour": _tour(context, egress, bool(platform_url), b), } diff --git a/cloudexit/report/notices.py b/cloudexit/report/notices.py index 866a7c8..60c96d7 100644 --- a/cloudexit/report/notices.py +++ b/cloudexit/report/notices.py @@ -27,7 +27,9 @@ def text_parts(template, links, values=None): for piece in FIELD.split(template): field = piece[1:-1] if field in links: - parts.append((links[field], links[field])) + link = links[field] + # A (label, url) pair shows the label; a bare url shows itself + parts.append(link if isinstance(link, tuple) else (link, link)) elif values and field in values: parts.append(str(values[field])) elif piece: diff --git a/cloudexit/report/wording.py b/cloudexit/report/wording.py index 246033b..86cc08f 100644 --- a/cloudexit/report/wording.py +++ b/cloudexit/report/wording.py @@ -280,10 +280,10 @@ "alt_hotspot_short": "No alternative in the data catalogue — lock-in hot-spot", "alt_more": "+{k} more", "alt_flag_open_source": "Open source", - "alt_flag_support_plan": "Support plan", + "alt_flag_support_plan": "Enterprise support", "alt_no_resources": "No resources were discovered in this assessment, so there are no alternatives to show.", "alt_empty_set": "No alternative technologies were found for the discovered resource types.", - "es_alttech_caption": "Examples are listed alphabetically; this report does not rank the alternatives. Open source and support plan as recorded in the data catalogue.", + "es_alttech_caption": "Examples are listed alphabetically; this report does not rank the alternatives. Open source and enterprise support as recorded in the data catalogue.", "es_heading_severity": "Risk Severity Levels", "es_severity_lead": ( "Severity expresses the expected impact of a risk on the effort and " @@ -639,14 +639,17 @@ ), "notices_trademarks_heading": "Trademarks", "notices_trademark_own": ( - "EscapeCloud, cloudexit and the EscapeCloud logo are trademarks. The " - "AGPL-3.0 licence does not grant any right to use them; modified " - "versions must not use them in a way that suggests they are official." + "EscapeCloud® is a registered trademark in the European Union and the " + "United States. The AGPL-3.0 licence applies solely to the cloudexit " + "software, not to the EscapeCloud name, logo, or brand assets. You may " + "not use EscapeCloud trademarks in connection with modified versions or " + "downstream distributions in any manner that implies endorsement, " + "affiliation, or origin with EscapeCloud." ), "notices_trademark_others": ( - "Other product and company names in this report, including those of " - "alternative technologies, are trademarks of their respective owners. " - "Naming them does not imply endorsement or affiliation." + "Other product and company names and logos are trademarks of their " + "respective owners; naming them does not imply endorsement or " + "affiliation." ), "notices_icons_aws": ( "AWS service icons are taken from the AWS Architecture Icons " @@ -671,6 +674,365 @@ "licence." ), "notices_copyright": "© 2024–{year} EscapeCloud", + "html_help": "Help", + "html_help_aria": "Help: how to read this report", + "html_logo_link": "EscapeCloud website (opens in a new tab)", + "html_chip_self": "Self-collected", + "html_chip_self_title": "Collected and evaluated on this machine; not uploaded", + "html_chip_uploaded": "Uploaded", + "html_chip_uploaded_title": "Collected on this machine and uploaded to the platform", + "html_download": "Download", + "html_download_summary": "Executive Summary (PDF)", + "html_download_egress": "Egress & Data Landscape (PDF)", + "html_open_platform": "Open on the platform", + "html_nav_label": "Sections", + "html_nav_overview": "Overview", + "html_nav_scores": "Scores", + "html_nav_risks": "Risks", + "html_nav_about": "About This Report", + "html_lock_label": "Light and Enterprise", + "html_scope_label": "Scope", + "html_run_label": "Run", + "html_run_small": "run {run} · {source}", + "html_run_live": "provider APIs", + "html_run_tfstate": "Terraform state", + "html_scope_no_resources": "no resources found", + "html_scope_serial": "serial {serial}", + "html_catalogue_sha": "sha256 {sha}", + "html_risks_label": "Unique Risks", + "html_sev_aria": "Risks by severity: {counts}", + "html_costs_label": "Costs, Last 6 Months", + "html_costs_label_short": "Costs", + "html_costs_average": "{amount} per month on average", + "html_costs_none": "no costs in the last six months", + "html_costs_change": "{latest} vs. {previous}", + "html_costs_aria": "Monthly costs: {parts}", + "html_costs_aria_part": "{month} {amount}", + "html_costs_aria_provisional": "{month} {amount} provisional", + "html_costs_provisional": ( + "* {month} is provisional: the month was still accruing. Costs are " + "context only; they are not an exit-cost estimate." + ), + "html_costs_context": "Costs are context only; they are not an exit-cost estimate.", + "html_costs_tfstate_hint": ( + "Run cloudexit against the provider APIs to include the last six " + "months of costs." + ), + "html_months": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + "html_scores_grade": "Exit Readiness Grade", + "html_scores_lock": "Vendor Lock-in Score", + "html_scores_pill": "Light & Enterprise", + "html_scores_scale": "of 100", + "html_scores_bands": ["F", "E", "D", "C", "B", "A"], + "html_scores_dims": ["Human", "Technology", "Operational"], + "html_scores_dim_value": "{dash} / 5", + "html_scores_grade_text": ( + "A grade from A to F for how ready this scope is for an exit, computed " + "from the risks in this report." + ), + "html_scores_lock_text": ( + "How easily each resource type can be replaced, in skills, technology " + "and operations, with the {alternatives} rated and ranked." + ), + "html_scores_lock_text_plain": ( + "How easily each resource type can be replaced, in skills, technology " + "and operations, with the alternatives rated and ranked." + ), + "html_scores_on_platform": "For this run it is on the platform.", + "html_platform_not_uploaded": "Not uploaded: {reason}", + "html_hint_title": "EscapeCloud Light & Enterprise", + "html_hint_text": ( + "EscapeCloud's scoring engine offers several scoring methods to assess " + "how feasible an exit is, and tracks the continuously changing vendor " + "landscape of alternative technologies." + ), + "html_site_exitcloud": "exitcloud.io", + "html_site_escapecloud": "escapecloud.io", + "html_risks_caption": "{count} · {scope} · ordered by severity", + "html_risks_caption_only_count": "{count} · ordered by severity", + "html_risks_count_one": "1 unique risk", + "html_risks_count_many": "{n} unique risks", + "html_risks_wide_sole": "applies to the scope as a whole", + "html_risks_wide_all": "all apply to the scope as a whole", + "html_risks_wide_one": "1 applies to the scope as a whole", + "html_risks_wide_many": "{n} apply to the scope as a whole", + "html_risks_none_caption": "No exit risks", + "html_risks_th_affected": "Affected Resource Types", + "html_risks_more": "+{n} more", + "html_risks_footnote": ( + "Risk codes are stable across runs. Severity levels and their meaning: " + "{link}." + ), + "html_risks_empty_scope": ( + "With no resources in the scope, no risk from the data catalogue applies." + ), + "html_risks_empty_clean": ( + "Every resource type has enough alternatives with enterprise support, " + "and the size of the scope is below the thresholds." + ), + "html_inv_caption": "{resources} · {types} · {regions}", + "html_inv_regions": "{regions} ({shares})", + "html_inv_not_managed": "resources that Terraform does not manage are not assessed", + "html_inv_th_locations": "Locations", + "html_inv_empty_title": "No resources were found in the defined scope.", + "html_inv_empty_resource_group": ( + "Check that the resource group {scope} is the intended scope and that " + "the credentials can read it. Resource types not in the data catalogue " + "are not counted." + ), + "html_inv_empty_region": ( + "Check that the region {scope} is the intended scope and that the " + "credentials can read it. Resource types not in the data catalogue are " + "not counted." + ), + "html_inv_empty_tfstate": ( + "Check that the state file {scope} holds resources of this provider. " + "Resource types not in the data catalogue are not counted." + ), + "html_alt_caption": "{alternatives} for {with_alternatives} of {types} resource types", + "html_alt_caption_one_type": "{alternatives} for {with_alternatives} of 1 resource type", + "html_alt_n_one": "1 alternative", + "html_alt_n_many": "{n} alternatives", + "html_alt_search": "Search alternatives", + "html_filter_os": "Open Source", + "html_filter_sp": "Enterprise Support", + "html_filter_eu": "European Vendors", + "html_alt_th_name": "Alternative", + "html_alt_meta": "{resources} · {alternatives}", + "html_alt_meta_none": "{resources} · no alternative in the data catalogue", + "html_alt_more_one": "Show 1 more alternative", + "html_alt_more_many": "Show {n} more alternatives", + "html_alt_no_match": "No alternative matches the search and filters.", + "html_alt_footnote": ( + "Alternatives are listed alphabetically from the data catalogue " + "({version}); this report doesn't rank them. Open source and " + "enterprise support as recorded in the data catalogue. Country = " + "vendor's registered country." + ), + "html_alt_empty_title": "No resource types to list alternatives for.", + "html_alt_empty_text": ( + "Alternatives appear here for each resource type found in the scope." + ), + "html_yes": "Yes", + "html_no": "No", + "html_eg_caption": ( + "Data held by the data-bearing resources, and the one-off fee to " + "transfer it out at list prices · also as PDF {document_id}" + ), + "html_eg_kpi_fee": "Estimated Transfer Fee", + "html_eg_kpi_headroom": "Free-Tier Headroom", + "html_eg_fee_free": "within the monthly free tier · USD list prices", + "html_eg_fee_paid": "at list prices · USD", + "html_eg_types_sized": "{sized} of {types} types sized", + "html_eg_headroom_left": "of {limit} per month left", + "html_eg_headroom_aria": "{share} of the free tier used", + "html_eg_billable_caption": "no free tier in the prices used", + "html_eg_not_sized_caption": "listed, not in the fee", + "html_eg_not_sized": "not sized", + "html_eg_vol_aria": "Share of data volume: {parts}", + "html_eg_vol_part": "{type} {share}", + "html_eg_vol_other": "Other types", + "html_eg_vol_unsized": "{types} not sized", + "html_eg_units_gb": ( + "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 " + "GB), so {gib} GiB is {gb} GB of the {limit} monthly free tier." + ), + "html_eg_units_gb_plain": ( + "Data is shown in GiB; {provider} prices egress per GB (1 GiB = 1.074 GB)." + ), + "html_eg_units_gib": "Data is shown in GiB; {provider} prices egress per GiB.", + "html_eg_amounts": "Amounts in USD list prices; not converted.", + "html_eg_amounts_costs": ( + "Amounts in USD list prices; not converted and not added to the costs " + "above ({currency}, monthly)." + ), + "html_eg_scope_note": ( + "Network transfer fees only: request charges, cross-region traffic, " + "private connections and negotiated discounts are not included. Some " + "types report provisioned rather than used capacity. Not an estimate " + "of the full cost of an exit." + ), + "html_eg_sized_summary": "What is sized, and how", + "html_eg_prices_summary": "Prices used ({provider}, zone 1, public internet routing)", + "html_about_caption": ( + "Document, scope and provenance · method, limitations, severity levels " + "and terms" + ), + "html_about_document": "Document", + "html_about_purpose": "Purpose and Method", + "html_about_phases_meta": "{n} phases", + "html_about_levels_meta": "{n} levels", + "html_about_terms_meta": "{n} terms", + "html_method_lead": ( + "The assessment was collected and evaluated locally with cloudexit. " + "Once the scope is defined, it runs three phases:" + ), + "html_method_discovery_tfstate": ( + "reads the resources managed in the Terraform state and classifies " + "them by type and region; a state file holds no costs." + ), + "html_method_catalogue": ( + "Risks and alternatives are taken from the data catalogue version shown " + "above, so the results of this run can be reproduced." + ), + "html_limitations_data": ( + "The analysis assumes that the discovered resources are correctly " + "configured and accessible. Dependencies outside the defined scope, " + "such as on-premises systems or other cloud accounts, are not covered; " + "results are indicative, not exhaustive." + ), + "html_glossary": [ + { + "id": "aws", + "term": "AWS", + "definition": "Amazon Web Services", + }, + { + "id": "data_catalogue", + "term": "Data catalogue", + "definition": "The catalogue of resource types, exit risks and alternative technologies used for this assessment.", + }, + { + "id": "hotspot", + "term": "Lock-in hot-spot", + "definition": "A discovered resource type for which the data catalogue lists no alternative.", + }, + { + "id": "run", + "term": "Run", + "definition": "One completed assessment of the defined scope, identified by its run ID.", + }, + { + "id": "egress", + "term": "Egress", + "definition": "Data transferred out of the provider's network, which the provider bills per unit of volume.", + }, + { + "id": "free_tier", + "term": "Free tier", + "definition": "The volume of internet egress per month that the provider does not bill.", + }, + { + "id": "gib_gb", + "term": "GiB / GB", + "definition": "Gibibyte (2³⁰ bytes) and gigabyte (10⁹ bytes); 1 GiB = 1.074 GB.", + }, + ], + "html_notice_advice": ( + "It is not legal, financial or investment advice, an audit or an exit plan." + ), + "html_label_aws_icons": "AWS Architecture Icons", + "html_label_aws_guidelines": "AWS Trademark Guidelines", + "html_label_azure_icons": "Azure Architecture Icons", + "html_label_repository": "github.com/escapecloud/cloudexit", + "html_figures_heading": "Figures", + "html_figures": ( + "Costs come from the provider's billing data for the scope. " + "Alternatives are entries from the data catalogue, not recommendations." + ), + "html_figures_egress": ( + "Costs come from the provider's billing data for the scope; egress " + "prices are public list prices at the time of the run. Alternatives " + "are entries from the data catalogue, not recommendations." + ), + "html_figures_tfstate": ( + "Resources are read from the Terraform state file; a state file holds " + "no billing data, so this report shows no costs. Alternatives are " + "entries from the data catalogue, not recommendations." + ), + "html_software": ( + "cloudexit is free software, licensed under the GNU Affero General " + "Public License v3.0 (AGPL-3.0). Source code and issues: " + "{repository_link}. The provider icons are not covered by this " + "licence. Fonts: IBM Plex, SIL Open Font License 1.1. Guided tour: " + "driver.js, MIT License, included in this report's assets." + ), + "html_footer_ids": "{document_id} · cloudexit {version}", + "html_tour_next": "Next", + "html_tour_back": "Back", + "html_tour_done": "Done", + "html_tour_progress": "{{current}} of {{total}}", + "html_tour_sections_title": "Sections", + "html_tour_sections": ( + "Jump to any part of the report; the section in view is highlighted." + ), + "html_tour_sections_lock": ( + "Scores, marked with a lock, are available in EscapeCloud Light & " + "Enterprise." + ), + "html_tour_report_title": "This Report", + "html_tour_report": "The assessed scope and its cloud provider.", + "html_tour_report_platform": ( + "This run was uploaded: Open on the platform shows its full results." + ), + "html_tour_download_both": ( + "Download has the same results as PDF: the Executive Summary and the " + "Egress & Data Landscape." + ), + "html_tour_download_both_platform": ( + "Download has the Executive Summary and the Egress & Data Landscape as " "PDF." + ), + "html_tour_download_one": "Download has the Executive Summary as PDF.", + "html_tour_overview_title": "Overview", + "html_tour_overview": ( + "Scope, unique risks by severity and the last six months of costs. The " + "current month is provisional while it is still accruing; costs are " + "context, not an exit-cost estimate." + ), + "html_tour_overview_tfstate": ( + "Scope and unique risks by severity. A Terraform state holds no " + "billing data, so there are no costs; resources that Terraform does " + "not manage are not assessed." + ), + "html_tour_scores_title": "Exit Readiness Grade & Vendor Lock-in Score", + "html_tour_scores_available": "Available in EscapeCloud Light & Enterprise.", + "html_tour_scores_platform": ( + "This run was uploaded. Its Exit Readiness Grade and Vendor Lock-in " + "Score are on the platform: use Open on the platform. This local " + "report never shows platform scores." + ), + "html_tour_risks_title": "Risks", + "html_tour_risks": ( + "Exit risks found in the scope, ordered by severity. Each risk has a " + "stable code, so a finding can be followed from one run to the next. " + "Open a row for its description." + ), + "html_tour_inventory_title": "Resource Inventory", + "html_tour_inventory": ( + "One row per resource type, with its regions and its share of all " + "resources in the scope." + ), + "html_tour_alternatives_title": "Alternative Technologies", + "html_tour_alternatives": ( + "Replacement options from the data catalogue for each resource type, " + "listed alphabetically and not ranked. Search, or filter by open " + "source, enterprise support or European vendors." + ), + "html_tour_egress_title": "Egress & Data Landscape", + "html_tour_egress": ( + "Data held in the scope and the one-off fee to transfer it out at " + "public list prices. Data is shown in GiB; the provider prices per GB. " + "Types marked 'not sized' are listed but not in the fee." + ), + "html_tour_about_title": "About This Report", + "html_tour_about": ( + "Document ID, scope and provenance. The run ID and the data catalogue " + "version make the result reproducible; method, limitations, severity " + "levels and terms are below." + ), } # Labels and headings that follow English Title Case, for the wording test. @@ -784,4 +1146,41 @@ "fa_heading", "fa_how_heading", "fa_heading_uploaded", + "html_download", + "html_download_summary", + "html_download_egress", + "html_nav_overview", + "html_nav_scores", + "html_nav_risks", + "html_nav_about", + "html_scope_label", + "html_risks_label", + "html_costs_label", + "html_costs_label_short", + "html_scores_grade", + "html_scores_lock", + "html_scores_pill", + "html_risks_th_affected", + "html_inv_th_locations", + "html_filter_os", + "html_filter_sp", + "html_filter_eu", + "html_alt_th_name", + "html_eg_kpi_fee", + "html_eg_kpi_headroom", + "html_about_document", + "html_about_purpose", + "html_figures_heading", + "html_tour_sections_title", + "html_tour_report_title", + "html_tour_overview_title", + "html_tour_scores_title", + "html_tour_risks_title", + "html_tour_inventory_title", + "html_tour_alternatives_title", + "html_tour_egress_title", + "html_tour_about_title", + "html_nav_label", + "html_run_label", + "html_help", ) diff --git a/cloudexit/report/writers.py b/cloudexit/report/writers.py index 7be8d5f..6f55445 100644 --- a/cloudexit/report/writers.py +++ b/cloudexit/report/writers.py @@ -5,16 +5,9 @@ from typing import Any from jinja2 import Environment -# ReportLab - -# Utils -from cloudexit.report.helpers import initials +from cloudexit.report.context import ReportContext +from cloudexit.report.html_sections import html_sections from cloudexit.resources import asset_path -from cloudexit.report.html_data import ( - transform_cost_inventory_for_html, - transform_risk_inventory_for_html, - transform_alt_tech_for_html, -) from cloudexit.report.json_data import ( transform_resource_inventory_for_json, transform_cost_inventory_for_json, @@ -27,114 +20,15 @@ def generate_html_report( - report_path: str, - metadata: dict[str, Any], - resource_type_mapping: dict[str, dict[str, Any]], - resource_inventory: list[dict[str, Any]], - cost_data: list[dict[str, Any]], - scoring_data: dict[str, Any] | None, - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, - alternative_technology_organizations: list[dict[str, Any]] | None = None, - summary: dict[str, Any] | None = None, + context: ReportContext, egress_estimate: dict[str, Any] | None = None ) -> str: - - # Transform resource inventory - resource_inventory_dict = { - str(item["resource_type"]): { - **item, - "name": resource_type_mapping.get(str(item["resource_type"]), {}).get( - "name", "Unknown Resource" - ), - "icon": ( - "/assets" + icon - if ( - icon := resource_type_mapping.get( - str(item["resource_type"]), {} - ).get("icon") - ) - else "" - ), - } - for item in resource_inventory - } - - # Transform risks - risks, severity_counts = transform_risk_inventory_for_html( - risk_data, risk_definitions, resource_inventory_dict - ) - - # Transform costs - months, cost_values, total_cost, currency, currency_symbol = ( - transform_cost_inventory_for_html(cost_data) - ) - - # Transform resource data with names and icons - resource_counts = [] - for resource_type, resource in resource_inventory_dict.items(): - count = resource.get("count", 0) - resource_info = resource_type_mapping.get(str(resource_type), {}) - name = resource_info.get("name", "Unknown Resource") - icon = (resource_info.get("icon") or "").lstrip("/") - - resource_counts.append( - { - "resource_type": resource_type, - "name": name, - "icon": icon, - "initials": initials(name), - "count": count, - } - ) - - # Calculate total resources - total_resources = sum(item["count"] for item in resource_counts) - - # Transform alternative technologies - alternative_technologies_data = transform_alt_tech_for_html( - resource_inventory, - alternatives, - alternative_technologies, - exit_strategy, - alternative_technology_organizations=alternative_technology_organizations, - ) - - # Scoring Data - scoring_context = { - "scoring_data": bool(scoring_data), - "exit_score": scoring_data.get("exit_score", 0) if scoring_data else 0, - "human": scoring_data.get("human_score", 0) if scoring_data else 0, - "technology": scoring_data.get("technology_score", 0) if scoring_data else 0, - "operational": scoring_data.get("operational_score", 0) if scoring_data else 0, - } - - # Render the HTML template template_content = asset_path("template", "index.html").read_text(encoding="utf-8") env = Environment(autoescape=True) template = env.from_string(template_content) - html_content = template.render( - **metadata, - **scoring_context, - risks=risks, - high_risk_count=severity_counts["high"], - medium_risk_count=severity_counts["medium"], - low_risk_count=severity_counts["low"], - total_cost=total_cost, - months_json=json.dumps(months), - costs_json=json.dumps(cost_values), - currency_symbol=currency_symbol, - total_resources=total_resources, - resource_inventory=resource_counts, - alternative_technologies=alternative_technologies_data, - summary=summary, - ) + html_content = template.render(page=html_sections(context, egress_estimate)) - # Save HTML report - html_path = os.path.join(report_path, "index.html") + html_path = os.path.join(context.report_path, "index.html") with open(html_path, "w", encoding="utf-8") as report_file: report_file.write(html_content) diff --git a/tests/catalogue/test_dataset_shapes.py b/tests/catalogue/test_dataset_shapes.py index acdcdea..be5f09b 100644 --- a/tests/catalogue/test_dataset_shapes.py +++ b/tests/catalogue/test_dataset_shapes.py @@ -118,7 +118,8 @@ def test_a_legacy_dataset_runs_with_an_unknown_version_and_numbered_risks(self): result = self.run_shape("legacy") self.assert_a_complete_offline_run(result) - self.assertIn("High: R2 No alternatives", result.text()) + self.assertRegex(result.text(), r"R2 No alternatives High") + self.assertIn("High · 1", result.text()) self.assertIn("unknown", result.html().lower()) self.assertIsNone(result.report_json()["provenance"]["catalogue_version"]) # Without catalogue_meta the sync document makes no claim about a version @@ -130,7 +131,8 @@ def test_the_current_dataset_gives_real_codes_and_the_version(self): self.assert_a_complete_offline_run(result) text = result.text() # catalogue_severity wins over severity, and risk.code over R - self.assertIn("Critical: LOCK-002 No alternatives", text) + self.assertRegex(text, r"LOCK-002 No alternatives Critical") + self.assertIn("Critical · 1", text) self.assertNotIn("R2 No alternatives", text) self.assertIn(VERSION, text) provenance = result.report_json()["provenance"] @@ -159,7 +161,7 @@ def test_a_future_additive_dataset_works_exactly_like_the_current_one(self): future.report_json()["data"]["resource_inventory"], current.report_json()["data"]["resource_inventory"], ) - self.assertIn("Critical: LOCK-002 No alternatives", future.text()) + self.assertRegex(future.text(), r"LOCK-002 No alternatives Critical") class RejectedShapeTests(ShapeTestCase): diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 7fe1f83..0af07c8 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -624,11 +624,6 @@ class EgressStageTests(unittest.TestCase): "logs": "", "json_path": "/tmp/report/raw/egress_inventory_raw_data.json", } - _REPORT_OK = { - "success": True, - "logs": "", - "html_path": "/tmp/report/egress.html", - } # The egress PDF is written with the report, from the same context _WITH_PDF = { "success": True, @@ -658,7 +653,7 @@ def _azure_config(): }, } - def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs): + def _run(self, config, provider, report=None, estimate=None, **kwargs): patches = _base_patches() for p in patches: p.start() @@ -671,10 +666,6 @@ def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs "cloudexit.cli.app.estimate_egress", return_value=estimate or self._ESTIMATE_OK, ).start(), - "html": patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value=html or self._REPORT_OK, - ).start(), "print": patch("cloudexit.cli.app.console.print").start(), "step": patch("cloudexit.cli.app.print_step").start(), } @@ -684,14 +675,19 @@ def _run(self, config, provider, report=None, estimate=None, html=None, **kwargs patch.stopall() return mocks + def _steps(self, mocks): + return [call.args[0] for call in mocks["step"].call_args_list] + + def _printed(self, mocks): + return [str(call) for call in mocks["print"].call_args_list] + def test_egress_module_not_invoked_without_flag(self): mocks = self._run(self._azure_config(), "azure") mocks["estimate"].assert_not_called() - mocks["html"].assert_not_called() self.assertIsNone(mocks["report"].call_args.kwargs["egress_json_path"]) - def test_egress_runs_in_the_collection_stages_before_the_report(self): + def test_the_estimate_comes_before_the_report_and_goes_into_it(self): manager = MagicMock() patches = _base_patches() for p in patches: @@ -702,13 +698,8 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self): mock_estimate = patch( "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK ).start() - mock_render = patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value=self._REPORT_OK, - ).start() manager.attach_mock(mock_report, "generate_report") manager.attach_mock(mock_estimate, "estimate_egress") - manager.attach_mock(mock_render, "generate_egress_html_report") config = self._azure_config() try: app.run_assessment(config, "azure", egress=True) @@ -724,39 +715,34 @@ def test_egress_runs_in_the_collection_stages_before_the_report(self): exit_strategy=config["exitStrategy"], assessment_type=config["assessmentType"], ) - # The HTML carries the scope and the run, so its ID matches the PDF's - mock_render.assert_called_once_with( - "/tmp/report", - "/tmp/report/raw/egress_inventory_raw_data.json", - config["providerDetails"], - ANY, - ) - sync_id = mock_render.call_args.args[3] self.assertEqual( mock_report.call_args.kwargs["egress_json_path"], "/tmp/report/raw/egress_inventory_raw_data.json", ) - self.assertEqual(mock_report.call_args.kwargs["sync_id"], sync_id) call_names = [name for name, _, _ in manager.mock_calls] self.assertLess( - call_names.index("estimate_egress"), - call_names.index("generate_egress_html_report"), - ) - # The PDF is part of the report, after the upload - self.assertLess( - call_names.index("generate_egress_html_report"), - call_names.index("generate_report"), + call_names.index("estimate_egress"), call_names.index("generate_report") ) - def test_the_egress_pdf_step_is_printed_and_the_path_listed(self): + def test_there_is_no_separate_egress_report_step_any_more(self): mocks = self._run(self._azure_config(), "azure", egress=True) - steps = [call.args[0] for call in mocks["step"].call_args_list] + steps = self._steps(mocks) + self.assertNotIn("Generating egress report...", steps) + self.assertIn("Estimating egress data...", steps) self.assertIn("Generating egress PDF...", steps) - printed = [str(call) for call in mocks["print"].call_args_list] + printed = self._printed(mocks) + self.assertFalse(any("Egress Report:" in line for line in printed)) + self.assertFalse(any("egress.html" in line for line in printed)) + + def test_the_egress_pdf_path_is_listed(self): + mocks = self._run(self._azure_config(), "azure", egress=True) + + printed = self._printed(mocks) self.assertTrue( any("Egress PDF: /tmp/report/egress.pdf" in line for line in printed) ) + self.assertTrue(any("HTML Report:" in line for line in printed)) def test_egress_failure_still_prints_outputs_and_exits_egress(self): patches = _base_patches() @@ -766,31 +752,8 @@ def test_egress_failure_still_prints_outputs_and_exits_egress(self): "cloudexit.cli.app.estimate_egress", return_value={"success": False, "logs": "metrics api error"}, ).start() - mock_render = patch("cloudexit.cli.app.generate_egress_html_report").start() - mock_print = patch("cloudexit.cli.app.console.print").start() - try: - with self.assertRaises(SystemExit) as ctx: - app.run_assessment(self._azure_config(), "azure", egress=True) - finally: - patch.stopall() - - self.assertEqual(ctx.exception.code, codes.EGRESS) - mock_render.assert_not_called() - printed = [str(call) for call in mock_print.call_args_list] - self.assertTrue(any("Outputs:" in line for line in printed)) - - def test_egress_report_failure_still_prints_outputs_and_exits_egress(self): - patches = _base_patches() - for p in patches: - p.start() - patch( - "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK - ).start() - patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value={"success": False, "logs": "template error"}, - ).start() - patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start() + report = patch("cloudexit.cli.app.generate_report").start() + report.return_value = {"success": True, "reports": {"HTML": "/tmp/index.html"}} mock_print = patch("cloudexit.cli.app.console.print").start() try: with self.assertRaises(SystemExit) as ctx: @@ -801,12 +764,7 @@ def test_egress_report_failure_still_prints_outputs_and_exits_egress(self): self.assertEqual(ctx.exception.code, codes.EGRESS) printed = [str(call) for call in mock_print.call_args_list] self.assertTrue(any("Outputs:" in line for line in printed)) - # The JSON path is internal input for the reports; it is never listed - # in Outputs. The failed HTML report is not listed, but the PDF is - # written independently and succeeded, so it is. - self.assertFalse(any("Egress Estimate:" in line for line in printed)) - self.assertFalse(any("Egress Report:" in line for line in printed)) - self.assertTrue(any("Egress PDF:" in line for line in printed)) + self.assertIsNone(report.call_args.kwargs["egress_json_path"]) def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): patches = _base_patches() @@ -815,10 +773,6 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): patch( "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK ).start() - patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value=self._REPORT_OK, - ).start() patch( "cloudexit.cli.app.generate_report", return_value=self._PDF_FAILED ).start() @@ -835,7 +789,6 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): printed = [str(call) for call in mock_print.call_args_list] self.assertTrue(any("Outputs:" in line for line in printed)) self.assertTrue(any("HTML Report:" in line for line in printed)) - self.assertTrue(any("Egress Report:" in line for line in printed)) self.assertFalse(any("Egress PDF:" in line for line in printed)) failed = [ call @@ -846,9 +799,13 @@ def test_egress_pdf_failure_still_prints_outputs_and_exits_egress(self): self.assertEqual(failed[0].kwargs["logs"], "reportlab error") def test_a_failed_report_is_still_a_report_failure(self): - mocks_report = {"success": False, "logs": "boom"} with self.assertRaises(SystemExit) as ctx: - self._run(self._azure_config(), "azure", report=mocks_report, egress=True) + self._run( + self._azure_config(), + "azure", + report={"success": False, "logs": "boom"}, + egress=True, + ) self.assertEqual(ctx.exception.code, codes.REPORT) @@ -859,10 +816,6 @@ def test_egress_invoked_for_aws_with_provider_code(self): mock_estimate = patch( "cloudexit.cli.app.estimate_egress", return_value=self._ESTIMATE_OK ).start() - patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value=self._REPORT_OK, - ).start() patch("cloudexit.cli.app.generate_report", return_value=self._WITH_PDF).start() config = VALID_CONFIG.copy() try: diff --git a/tests/platform/test_document.py b/tests/platform/test_document.py index 6397e75..016b86a 100644 --- a/tests/platform/test_document.py +++ b/tests/platform/test_document.py @@ -863,10 +863,6 @@ def run_assessment(self, report, *, egress_ok=None, **kwargs): "cloudexit.cli.app.estimate_egress", return_value={"success": egress_ok, "logs": "x", "json_path": "p"}, ), - patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value={"success": True, "html_path": "h"}, - ), ] for item in patches: item.start() diff --git a/tests/report/test_basic_report.py b/tests/report/test_basic_report.py index a4b7dae..24606d8 100644 --- a/tests/report/test_basic_report.py +++ b/tests/report/test_basic_report.py @@ -6,7 +6,6 @@ import unittest from datetime import datetime, timezone from pathlib import Path -from unittest.mock import patch from pypdf import PdfReader @@ -14,7 +13,6 @@ from cloudexit.report import fonts from cloudexit.report.context import Provenance from cloudexit.report.executive_summary import generate_executive_summary -from cloudexit.report.egress import generate_egress_html_report from cloudexit.report.notices import notices_html from cloudexit.report.palette import ( DEFAULT_APPEARANCE, @@ -31,8 +29,6 @@ REPOSITORY_URL, ) from cloudexit.report.wording import BLOCKS, TITLE_CASE_KEYS -from tests.report import test_egress as egress_helpers -from tests.report.test_no_credentials import pricing_for_both_clouds from tests.support.flow import build_flow_run from tests.support.html import html_text from tests.support.report import ( @@ -584,66 +580,6 @@ def test_the_closing_page_only_names_what_the_full_assessment_adds(self): ) self.assertNotRegex(closing, r"\d+ / (100|5)") - def test_the_html_report_has_no_scores_before_the_closing_section(self): - report = build_flow_run( - self.tmp / "html", - resources=[(1, "eu-central-1", 3)], - costs=[("2026-09-01", 10.0, "USD")], - ) - perform_risk_assessment(1, report) - generate_report( - 2, - {"region": "eu-central-1"}, - 1, - 1, - "Flow", - report, - str(Path(report) / "raw_data"), - sync_id=SYNC_ID, - ) - text = html_text((Path(report) / "index.html").read_text(encoding="utf-8")) - before = text[: text.index("What the Full Assessment Adds")] - - for pattern in SCORE_PATTERNS: - self.assertIsNone(re.search(pattern, before, re.IGNORECASE), pattern) - - def test_the_html_follows_the_same_sections(self): - report = build_flow_run( - self.tmp / "html2", - resources=[(1, "eu-central-1", 3)], - costs=[("2026-09-01", 10.0, "USD")], - ) - perform_risk_assessment(1, report) - generate_report( - 2, - {"region": "eu-central-1"}, - 1, - 1, - "Flow", - report, - str(Path(report) / "raw_data"), - sync_id=SYNC_ID, - ) - text = html_text((Path(report) / "index.html").read_text(encoding="utf-8")) - - # The findings table names the sections too, so each heading is searched - # for after the one before it. - position = 0 - for heading in ( - "Brief Summary", - "Provenance", - "Introduction", - "Risk Inventory", - "Costs Overview", - "Resource Inventory", - "Alternative Technologies", - "Risk Severity Levels", - "Glossary", - "What the Full Assessment Adds", - "Legal Notices", - ): - position = text.index(heading, position) - class TermGuardTests(BasicReportTestCase): def test_no_regulatory_word_or_foreign_brand_in_pdf_html_or_metadata(self): @@ -835,109 +771,6 @@ def test_the_year_comes_from_the_generation_time(self): self.assertEqual(notices["copyright"], f"© 2024–{year} EscapeCloud") -class NoticesHtmlTests(BasicReportTestCase): - def index_text(self, provider, resources): - report = build_flow_run( - self.tmp / f"html-{provider}", - resources=resources, - costs=[("2026-09-01", 10.0, "USD")], - ) - perform_risk_assessment(1, report) - details = ( - {"region": "eu-central-1"} if provider == 2 else {"resourceGroupName": "rg"} - ) - generate_report( - provider, - details, - 1, - 1, - "Flow", - report, - str(Path(report) / "raw_data"), - sync_id=SYNC_ID, - ) - return html_text((Path(report) / "index.html").read_text(encoding="utf-8")) - - def test_index_html_ends_with_the_notices_and_the_providers_icon_notice(self): - for provider, resources, own, other in ( - (2, [(1, "eu-central-1", 3)], "Amazon.com", "Microsoft group"), - (1, [(3, "westeurope", 3)], "Microsoft group", "Amazon.com"), - ): - with self.subTest(provider=provider): - text = self.index_text(provider, resources) - - self.assertGreater( - text.index("Legal Notices"), - text.index("What the Full Assessment Adds"), - ) - tail = text[text.index("Legal Notices") :] - self.assertIn(BLOCKS["notices_disclaimer"], tail) - self.assertIn(own, tail) - self.assertNotIn(other, text) - self.assertIn(REPOSITORY_URL, tail) - self.assertRegex(tail.rstrip(), r"© 2024–\d{4} EscapeCloud$") - self.assertEqual(find_terms(text), []) - - def test_the_links_in_index_html_are_real_links(self): - report = build_flow_run( - self.tmp / "html-links", resources=[(1, "eu-central-1", 3)], costs=[] - ) - perform_risk_assessment(1, report) - generate_report( - 2, - {"region": "eu-central-1"}, - 1, - 1, - "Flow", - report, - str(Path(report) / "raw_data"), - sync_id=SYNC_ID, - ) - html = (Path(report) / "index.html").read_text(encoding="utf-8") - - for url in (REPOSITORY_URL, LICENCE_URL, AWS_ICONS_URL, AWS_TRADEMARK_URL): - self.assertIn(f'href="{url}"', html) - - def egress_text(self, provider): - rows = [ - egress_helpers._row( - "proddata", "Storage", "object", 2048 * egress_helpers.GIB - ) - ] - payload = egress_helpers._payload(rows) - payload["meta"]["cloud_service_provider"] = provider - folder = self.report_dir() - json_path = folder / "egress_inventory_raw_data.json" - json_path.write_text(json.dumps(payload), encoding="utf-8") - egress_helpers._seed_egress_inventory( - str(folder), rows, egress_helpers._archive_tiers(rows) - ) - with patch( - "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds - ): - result = generate_egress_html_report(str(folder), str(json_path)) - self.assertTrue(result["success"], result["logs"]) - html = Path(result["html_path"]).read_text(encoding="utf-8") - return html, html_text(html) - - def test_egress_html_ends_with_the_same_notices(self): - for provider, own, other in ( - (1, "Microsoft group", "Amazon.com"), - (2, "Amazon.com", "Microsoft group"), - ): - with self.subTest(provider=provider): - html, text = self.egress_text(provider) - tail = text[text.index("Legal Notices") :] - - self.assertIn(BLOCKS["notices_disclaimer"], tail) - self.assertIn(own, tail) - self.assertNotIn(other, text) - # The timestamp of the estimate gives the year - self.assertTrue(tail.rstrip().endswith("© 2024–2026 EscapeCloud")) - self.assertIn(f'href="{LICENCE_URL}"', html) - self.assertEqual(find_terms(text), []) - - class WordingGuardTests(unittest.TestCase): def test_the_licence_name_is_a_proper_name_and_other_license_is_not(self): self.assertEqual( @@ -1015,8 +848,9 @@ def test_no_wording_uses_a_retired_term_or_american_spelling(self): def test_no_wording_carries_regulatory_words_or_scores(self): for key, text in self.strings(): - # The pages that point to the platforms name what they add - if key.startswith(("fa_", "eg_next_")): + # The pages that point to the platforms name what they add, and the + # Scores row of the HTML report names the results they compute + if key.startswith(("fa_", "eg_next_", "html_scores_", "html_tour_")): continue with self.subTest(key): self.assertEqual(find_terms(text), []) diff --git a/tests/report/test_egress.py b/tests/report/test_egress.py index b1c33f9..3c9b3d6 100644 --- a/tests/report/test_egress.py +++ b/tests/report/test_egress.py @@ -1,5 +1,4 @@ # tests/report/test_egress.py -import json import os import sqlite3 import tempfile @@ -7,19 +6,18 @@ from unittest.mock import patch -from cloudexit.resources import asset_path from cloudexit.egress.model import GIB, write_egress_inventory +from cloudexit.report.helpers import format_gib, gib_number, percent from cloudexit.catalogue.store import active_catalogue_path from cloudexit.report.egress import ( - CATEGORIES, - _build_allocation, _build_type_groups, _resolve_icon, build_fee_estimate, calculate_tiered_cost, + fee_number, format_fee, + free_tier_headroom, free_tier_limit_display, - generate_egress_html_report, load_pricing, ) @@ -180,34 +178,6 @@ def _seed_egress_inventory(report_path, rows, archive_tiers=frozenset()): return db_path -class CategoryCoverageTests(unittest.TestCase): - # The CHECK constraint on resourcetype_data.data_category. A category the - # report does not know about renders an unstyled badge and, worse, drops - # out of the allocation chart while still counting towards the totals. - DATA_CATEGORIES = {"object", "block", "file", "database", "backup"} - - def test_every_master_data_category_is_known_to_the_report(self): - self.assertEqual(set(CATEGORIES), self.DATA_CATEGORIES) - - def test_only_backup_is_left_out_of_the_allocation_chart(self): - excluded = {k for k, v in CATEGORIES.items() if not v["in_allocation"]} - - # Backup is enumerated but never sized, so it has nothing to allocate. - self.assertEqual(excluded, {"backup"}) - - def test_allocated_categories_have_a_colour_and_a_label(self): - for key, info in CATEGORIES.items(): - if info["in_allocation"]: - self.assertIsNotNone(info["color"], key) - self.assertIsNotNone(info["label"], key) - - def test_template_styles_every_category_badge(self): - css = asset_path("template", "egress.html").read_text(encoding="utf-8") - - for key in CATEGORIES: - self.assertIn(f".category-{key} {{", css, f"no badge rule for {key!r}") - - class LoadPricingTests(unittest.TestCase): @staticmethod def _make_report_db(report_path, rows): @@ -392,34 +362,6 @@ def test_unknown_type_has_no_icon(self): self.assertIsNone(_resolve_icon("Vendor.Unknown/things", {})) -class AllocationSeriesTests(unittest.TestCase): - def test_groups_known_bytes_by_category_in_fixed_order(self): - rows = [ - _row("disk1", "Managed Disk", "block", 40 * GIB), - _row("sa1", "Storage Account", "object", 60 * GIB), - _row("db1", "SQL Database", "database", 10 * GIB), - _row("vault1", "Recovery Services Vault", "backup", None), - _row("db2", "SQL Database", "database", None, size_unknown=True), - ] - - labels, values, colors = _build_allocation(rows) - - self.assertEqual( - labels, - ["Object Storage", "Block (allocated)", "Databases"], - ) - self.assertEqual(values, [60 * GIB, 40 * GIB, 10 * GIB]) - self.assertEqual(len(colors), 3) - - def test_zero_categories_are_omitted(self): - rows = [_row("sa1", "Storage Account", "object", 5 * GIB)] - - labels, values, _ = _build_allocation(rows) - - self.assertEqual(labels, ["Object Storage"]) - self.assertEqual(values, [5 * GIB]) - - class TypeGroupTests(unittest.TestCase): def test_groups_by_label_and_sorts_by_fee(self): rows = [ @@ -466,124 +408,52 @@ def test_flags_and_notes_become_resource_detail(self): ) -class GenerateEgressHtmlReportTests(unittest.TestCase): - def _generate(self, payload): - with tempfile.TemporaryDirectory() as tmp_dir: - json_path = os.path.join(tmp_dir, "egress_inventory_raw_data.json") - with open(json_path, "w", encoding="utf-8") as json_file: - json.dump(payload, json_file) - _seed_egress_inventory( - tmp_dir, - payload["data"]["resources"], - _archive_tiers(payload["data"]["resources"]), - ) - - with patch( - "cloudexit.report.egress.load_data", - side_effect=_fake_load_data, - ): - result = generate_egress_html_report(tmp_dir, json_path) - - html = None - if result["success"]: - self.assertEqual( - result["html_path"], os.path.join(tmp_dir, "egress.html") - ) - with open(result["html_path"], encoding="utf-8") as html_file: - html = html_file.read() - return result, html - - def test_renders_report_with_fees_table_and_charts(self): - rows = [ - _row( - "proddata", - "Storage Account", - "object", - 2048 * GIB, - resource_type="Microsoft.Storage/storageAccounts", - ), - _row( - "vm-osdisk", - "Managed Disk", - "block", - 512 * GIB, - resource_type="Microsoft.Compute/disks", - flags=["allocated (upper bound)"], - ), - ] - result, html = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("Exit Assessment Test", html) - normalized_html = " ".join(html.split()) - # The alpha banner is a limitation in the Introduction now - self.assertNotIn("alpha", html) - self.assertNotIn("mailto:beta@escapecloud.io", html) - # (2560 - 100) GiB * 0.087 = $214.02 total internet egress fee - self.assertIn("$214.02", html) - self.assertIn("Estimated Egress Fee", html) - self.assertIn("Estimated one-time charge to transfer the identified", html) - self.assertIn( - 'any resources whose size could not be measured. A "+" means', - normalized_html, - ) - self.assertIn("lower-bound estimate.", html) - self.assertIn("feesChart", html) - self.assertIn("allocationChart", html) - self.assertIn("proddata", html) - self.assertIn("category-object", html) - self.assertIn("allocated (upper bound)", html) - self.assertNotIn("Estimates only", html) - self.assertNotIn("At least", html) - - def test_unknown_sizes_add_plus_suffix_to_headlines(self): - rows = [ - _row("sa1", "Storage Account", "object", 2048 * GIB), - _row("db1", "SQL Database", "database", None, size_unknown=True), +class GibAndFeeFormatTests(unittest.TestCase): + def test_a_size_that_is_not_zero_never_reads_as_zero(self): + self.assertEqual(gib_number(0), "0.0") + self.assertEqual(gib_number(GIB // 100), "<0.1") + self.assertEqual(gib_number(GIB // 50, places=2), "0.02") + self.assertEqual(gib_number(GIB // 1000, places=2), "<0.01") + + def test_places_and_grouping(self): + self.assertEqual(gib_number(2048 * GIB), "2,048.0") + self.assertEqual(gib_number(22 * GIB, places=2), "22.00") + self.assertEqual(format_gib(20 * GIB), "20.0 GiB") + self.assertEqual(format_gib(None), "n/a") + + def test_the_fee_has_one_format_with_and_without_the_symbol(self): + self.assertEqual(fee_number(0), "0.00") + self.assertEqual(fee_number(1732.1), "1,732.10") + self.assertEqual(format_fee(1732.1), f"${fee_number(1732.1)}") + + def test_a_share_keeps_its_sign_and_never_reads_zero_when_it_is_not(self): + self.assertEqual(percent(63.4), "63\u00a0%") + self.assertEqual(percent(0.2), "<1\u00a0%") + self.assertEqual(percent(0), "0\u00a0%") + + +class FreeTierHeadroomTests(unittest.TestCase): + def test_the_headroom_is_in_the_unit_the_provider_prices_in(self): + prices = [ + _price_row(1, "internet_egress", 0, 100, 0.0, unit="GB"), + _price_row(1, "internet_egress", 100, None, 0.1, unit="GB"), ] - result, html = self._generate(_payload(rows, unknown_count=1)) - - self.assertTrue(result["success"], result["logs"]) - # 2048 GiB known: (2048 - 100) * 0.087 = $169.48, "+" marks the lower bound - self.assertIn("2,048.0 GiB+", html) - self.assertNotIn("TiB", html) - self.assertIn("$169.48+", html) - self.assertIn("Excludes 1 resource", html) - self.assertIn("n/a", html) - - def test_free_tier_environment_shows_included_in_free_tier_card(self): - rows = [_row("sa1", "Storage Account", "object", 5 * GIB)] - result, html = self._generate(_payload(rows)) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("Included in Free Tier", html) - # The fee headline stays visible above the card, in the one money format - self.assertIn("Estimated Egress Fee", html) - self.assertIn('
$0.00
', html) - self.assertIn("5.0 GiB", html) - self.assertIn("100 GiB free tier", html) - self.assertNotIn("No fee estimate available.", html) - self.assertNotIn("feesChart", html) - - def test_empty_inventory_renders_empty_states(self): - result, html = self._generate(_payload([])) - - self.assertTrue(result["success"], result["logs"]) - self.assertIn("$0.00", html) - # Zero known bytes is the empty state, not the free-tier success card. - self.assertIn("No fee estimate available.", html) - self.assertNotIn("Included in Free Tier", html) - self.assertIn("No data volume available.", html) - self.assertIn( - "No data-bearing resources were discovered during the", - html, - ) - self.assertNotIn("allocationChart", html) - def test_missing_json_returns_failure(self): - with tempfile.TemporaryDirectory() as tmp_dir: - result = generate_egress_html_report( - tmp_dir, os.path.join(tmp_dir, "missing.json") - ) + headroom = free_tier_headroom(prices, 1, 40 * 10**9) + + self.assertEqual(headroom["unit"], "GB") + self.assertAlmostEqual(headroom["left"], 60.0) + self.assertAlmostEqual(headroom["used_share"], 40.0) + + def test_a_footprint_over_the_free_tier_has_none_left(self): + headroom = free_tier_headroom(SIMPLE_PRICING, 1, 150 * GIB) + + self.assertEqual(headroom["left"], 0.0) + self.assertEqual(headroom["used_share"], 100.0) + + def test_no_free_tier_means_no_headroom(self): + self.assertIsNone(free_tier_headroom(SIMPLE_PRICING, 2, GIB)) + - self.assertFalse(result["success"]) +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_egress_report.py b/tests/report/test_egress_report.py index d1bfc4d..85e1087 100644 --- a/tests/report/test_egress_report.py +++ b/tests/report/test_egress_report.py @@ -14,7 +14,6 @@ Provenance, format_document_id, ) -from cloudexit.report.egress import generate_egress_html_report from cloudexit.report.egress_report import generate_egress_report from cloudexit.report.sections import AWS_ICONS_URL, AZURE_ICONS_URL from cloudexit.report.wording import BLOCKS @@ -30,6 +29,7 @@ uploaded, ) from tests.report.test_no_credentials import pricing_for_both_clouds +from tests.support.egress import empty_estimate from tests.support.flow import build_flow_run from tests.support.html import html_text from tests.support.report import ( @@ -112,18 +112,6 @@ def render(self, rows=MULTI_TIB, provider=1, prov=None): path = generate_egress_report(context, json_path) return path, context, PdfReader(path) - def render_html(self, rows=MULTI_TIB, provider=1, details=None): - folder, json_path = self.prepare(rows, provider) - with patch( - "cloudexit.report.egress.load_data", side_effect=pricing_for_both_clouds - ): - result = generate_egress_html_report( - str(folder), json_path, details, SYNC_ID - ) - self.assertTrue(result["success"], result["logs"]) - html = Path(result["html_path"]).read_text(encoding="utf-8") - return html, html_text(html) - def scenarios(self): for provider, name in ((1, "azure"), (2, "aws")): for state, prov in (("offline", provenance()), ("uploaded", uploaded())): @@ -437,55 +425,6 @@ def test_the_term_wording_and_retired_term_guards_pass_on_every_page(self): self.assertEqual(retired_term_hits(text), []) self.assertEqual(american_hits(text), []) - def test_the_html_follows_the_pdf_and_passes_the_guards(self): - for provider, details in ( - (1, {"resourceGroupName": "Prod-RG", "tenantId": "t-1"}), - (2, {"region": "eu-central-1", "accountId": "123456789012"}), - ): - with self.subTest(provider=provider): - html, text = self.render_html(MULTI_TIB, provider, details) - - position = 0 - for heading in ( - "Introduction", - "Purpose", - "Scope of the Estimation", - "Data Landscape", - "Estimated Costs", - "Appendix – Egress Prices", - "Glossary", - "Next Steps", - "Legal Notices", - ): - position = text.index(heading, position) - self.assertIn(EDL_ID, text) - self.assertIn(f"({ES_ID})", text) - self.assertTrue( - text.rstrip().endswith("© 2024–2026 EscapeCloud"), text[-80:] - ) - self.assertEqual(find_terms(text), []) - self.assertEqual(find_terms(html.split("Egress & Data Landscape – Exit Assessment Test", html - ) - self.assertNotIn("EscapeCloud Community Edition -", html.split("")[0]) - self.assertNotIn("TiB", html) - self.assertIn(BLOCKS["notices_egress"], html.replace("'", "'")) - - def test_the_html_scope_names_no_account_or_tenant(self): - _html, text = self.render_html( - MULTI_TIB, 2, {"region": "eu-central-1", "accountId": "123456789012"} - ) - - self.assertIn("AWS region eu-central-1", text) - self.assertNotIn("123456789012", text) - class GenerationOrderTests(unittest.TestCase): def setUp(self): @@ -518,13 +457,18 @@ def es_text(self): def test_the_egress_pdf_is_made_from_the_context_of_the_report(self): upload = {"status": "uploaded", "url": URL, "assessment_id": "a-1"} seen = {} + estimate = empty_estimate() - def fake(context, json_path): - seen["context"] = context - seen["json_path"] = json_path + def fake(context, json_path, path=None, estimate=None): + seen.update(context=context, json_path=json_path, estimate=estimate) return "egress.pdf" - with patch("cloudexit.engine.generate_egress_report", side_effect=fake): + with ( + patch( + "cloudexit.engine.load_egress_estimate", return_value=estimate + ) as load, + patch("cloudexit.engine.generate_egress_report", side_effect=fake), + ): result = self.generate(upload=upload, egress_json_path="estimate.json") self.assertTrue(result["success"]) @@ -536,10 +480,17 @@ def fake(context, json_path): self.assertEqual(context.provenance.platform_url, URL) self.assertEqual(context.egress_document_id, EDL_ID) self.assertEqual(seen["json_path"], "estimate.json") - - def test_the_summary_names_the_egress_document_only_when_there_is_one(self): - with patch( - "cloudexit.engine.generate_egress_report", return_value="egress.pdf" + # The estimate is read once, and the page and the PDF share it + load.assert_called_once_with(self.report, "estimate.json") + self.assertIs(seen["estimate"], estimate) + + def test_the_summary_and_the_page_name_the_egress_document_only_with_one(self): + with ( + patch( + "cloudexit.engine.load_egress_estimate", + return_value=empty_estimate(), + ), + patch("cloudexit.engine.generate_egress_report", return_value="egress.pdf"), ): self.generate(egress_json_path="estimate.json") with_egress = self.es_text() @@ -547,34 +498,49 @@ def test_the_summary_names_the_egress_document_only_when_there_is_one(self): sentence = BLOCKS["es_costs_egress_note"].format(document_id=EDL_ID) self.assertIn(sentence, with_egress) - self.assertIn("A separate Egress & Data Landscape", html) - self.assertIn(EDL_ID, html) + # The page carries it in its egress section, not as a sentence + self.assertIn(f"also as PDF {EDL_ID}", html_text(html)) + self.assertIn('id="egress"', html) + self.assertIn('href="egress.pdf"', html) self.generate() self.assertNotIn("A separate Egress", self.es_text()) html = (Path(self.report) / "index.html").read_text(encoding="utf-8") - self.assertNotIn("A separate Egress", html) + self.assertNotIn(EDL_ID, html) + self.assertNotIn('id="egress"', html) + self.assertNotIn("egress.pdf", html) - def test_a_failing_egress_pdf_leaves_the_summary_written(self): + def test_a_failing_egress_pdf_leaves_the_summary_and_the_page_written(self): (Path(self.report) / "report.pdf").unlink(missing_ok=True) - with patch( - "cloudexit.engine.generate_egress_report", - side_effect=RuntimeError("reportlab went wrong"), + with ( + patch( + "cloudexit.engine.load_egress_estimate", + return_value=empty_estimate(), + ), + patch( + "cloudexit.engine.generate_egress_report", + side_effect=RuntimeError("reportlab went wrong"), + ), ): result = self.generate(egress_json_path="estimate.json") self.assertTrue(result["success"]) self.assertTrue((Path(self.report) / "report.pdf").is_file()) - self.assertTrue((Path(self.report) / "index.html").is_file()) + html = (Path(self.report) / "index.html").read_text(encoding="utf-8") + self.assertIn('id="egress"', html) self.assertNotIn("EGRESS_PDF", result["reports"]) self.assertEqual(result["egress_pdf_logs"], "reportlab went wrong") - def test_an_unreadable_estimate_is_the_same_failure(self): + def test_an_unreadable_estimate_leaves_egress_out_of_every_report(self): result = self.generate(egress_json_path="/nowhere/estimate.json") self.assertTrue(result["success"]) self.assertIn("egress_pdf_logs", result) + self.assertNotIn("EGRESS_PDF", result["reports"]) self.assertTrue((Path(self.report) / "report.pdf").is_file()) + self.assertNotIn("A separate Egress", self.es_text()) + html = (Path(self.report) / "index.html").read_text(encoding="utf-8") + self.assertNotIn('id="egress"', html) def test_without_an_estimate_nothing_is_added(self): result = self.generate() diff --git a/tests/report/test_html_report.py b/tests/report/test_html_report.py new file mode 100644 index 0000000..128569d --- /dev/null +++ b/tests/report/test_html_report.py @@ -0,0 +1,955 @@ +import hashlib +import json +import posixpath +import re +import tempfile +import unittest +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlparse + +from pypdf import PdfReader + +from cloudexit.engine import generate_report, perform_risk_assessment +from cloudexit.report.sections import AWS_ICONS_URL, AZURE_ICONS_URL, REPOSITORY_URL +from cloudexit.report.wording import BLOCKS +from cloudexit.resources import asset_path +from tests.report.test_basic_report import page_texts +from tests.samples.rendered import rendered +from tests.support.flow import SERVER_SCORES, build_flow_run +from tests.support.html import html_text +from tests.support.report_terms import find_terms +from tests.support.sync import SYNC_ID + +DRIVER_JS_SHA256 = "3e3790dea8118c80a7a1d81af9454f8f6fe9a5dd180e843ce8ba638c5b446753" +DRIVER_CSS_SHA256 = "4b398481a7ce8375af4d9f58f39410c73a0b70726fe513686d3d51c10ad76cb5" +BASE_SECTIONS = ["overview", "scores", "risks", "inventory", "alternatives", "about"] +WITH_EGRESS = [ + "overview", + "scores", + "risks", + "inventory", + "alternatives", + "egress", + "about", +] +TOUR_TITLES = [ + "Sections", + "This Report", + "Overview", + "Exit Readiness Grade & Vendor Lock-in Score", + "Risks", + "Resource Inventory", + "Alternative Technologies", + "Egress & Data Landscape", + "About This Report", +] +PROVIDER_LOGOS = {"aws": "Amazon Web Services", "azure": "Microsoft Azure"} +PLATFORM_URL = "https://platform.example.test/assessments/a-1" + +STATE = {} + + +def setUpModule(): + STATE.update(rendered()) + + +def tearDownModule(): + STATE.clear() + + +class References(HTMLParser): + # What the page loads: stylesheets, scripts and images + def __init__(self): + super().__init__() + self.found = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "link" and attrs.get("href"): + self.found.append((tag, attrs["href"])) + elif tag in ("script", "img") and attrs.get("src"): + self.found.append((tag, attrs["src"])) + + +def references(html): + parser = References() + parser.feed(html) + return parser.found + + +def is_local(value, base=""): + # A relative path that stays inside the report folder; base is the folder of + # the file that holds the reference, relative to the report folder + parsed = urlparse(value) + if parsed.scheme or parsed.netloc or value.startswith(("/", "\\")): + return False + return not posixpath.normpath(posixpath.join(base, value)).startswith("..") + + +def css_references(css): + found = re.findall(r"@import\s+(?:url\()?\s*[\"']?([^\"')\s;]+)", css) + return found + re.findall(r"url\(\s*[\"']?([^\"')]+)", css) + + +def offline_problems(html, stylesheets=()): + # Anything the page would load from outside its own folder; a stylesheet is + # given as (folder, text), the folder relative to the report folder + problems = [ + f"<{tag}> {value}" for tag, value in references(html) if not is_local(value) + ] + for base, css in (("", html),) + tuple(stylesheets): + problems += [ + f"css {value}" for value in css_references(css) if not is_local(value, base) + ] + return problems + + +def composed_of(text, pieces): + # Whether the text is built only from the pieces, joined by single spaces + if not text: + return True + return any( + text.startswith(piece) + and (len(text) == len(piece) or text[len(piece)] == " ") + and composed_of(text[len(piece) :].lstrip(" "), pieces) + for piece in pieces + if piece + ) + + +class SampleTestCase(unittest.TestCase): + def cases(self): + yield from STATE["scenarios"].items() + + def folder(self, case): + return STATE["results"][case].directory + + def page(self, case): + return (self.folder(case) / "index.html").read_text(encoding="utf-8") + + def text(self, case): + return html_text(self.page(case)) + + def section(self, case, name): + match = re.search( + rf']*id="{name}".*?', self.page(case), re.DOTALL + ) + self.assertIsNotNone(match, f"{case}: no section {name}") + return match.group(0) + + def tour(self, case): + match = re.search( + r'', + self.page(case), + re.DOTALL, + ) + return json.loads(match.group(1)) + + def sections(self, case): + return re.findall(r']*id="([a-z]+)"', self.page(case)) + + +class StructureTests(SampleTestCase): + def test_the_sections_come_in_the_planned_order_with_egress_only_when_it_ran(self): + for case, scenario in self.cases(): + with self.subTest(case): + expected = WITH_EGRESS if scenario.egress else BASE_SECTIONS + self.assertEqual(self.sections(case), expected) + nav = re.search(r"", self.page(case), re.DOTALL).group(0) + self.assertEqual(re.findall(r'', nav), expected) + + def test_the_download_menu_has_one_pdf_or_two_and_no_json(self): + for case, scenario in self.cases(): + with self.subTest(case): + menu = re.search( + r'")]) + + self.assertIn("egress prices are public list prices", figures("aws_typical")) + self.assertNotIn("egress", figures("aws_no_egress")) + self.assertIn("a state file holds no billing data", figures("tfstate_aws")) + + def test_the_trademark_sentences_are_in_the_page_and_both_pdfs(self): + for case, scenario in self.cases(): + with self.subTest(case): + text = self.text(case) + self.assertIn(BLOCKS["notices_trademark_own"], text) + self.assertIn(BLOCKS["notices_trademark_others"], text) + for pdf in ("report.pdf", "egress.pdf"): + if (self.folder(case) / pdf).is_file(): + last = page_texts(PdfReader(str(self.folder(case) / pdf)))[-1] + self.assertIn(BLOCKS["notices_trademark_own"], last) + self.assertIn(BLOCKS["notices_trademark_others"], last) + + def test_no_trademark_claim_for_cloudexit_or_the_logo(self): + texts = [value for value in BLOCKS.values() if isinstance(value, str)] + texts += [self.text(case) for case, _scenario in self.cases()] + for text in texts: + self.assertIsNone( + re.search(r"(?i)cloudexit[^.]{0,60}trademark", text), text[:80] + ) + self.assertIsNone( + re.search( + r"(?i)(escapecloud|cloudexit)\b[^.]{0,60}\blogo\b[^.]{0,30}trademark", + text, + ), + text[:80], + ) + for case, _scenario in self.cases(): + self.assertEqual(find_terms(self.text(case)), []) + + def test_the_logo_files_carry_their_own_notice(self): + note = asset_path("img", "logo", "README.md").read_text(encoding="utf-8") + + self.assertIn( + "The EscapeCloud logo files in this directory are not covered by this " + "project's LICENSE. All rights reserved.", + note, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/report/test_icon_placeholder.py b/tests/report/test_icon_placeholder.py index 1efc2a0..fbafdbd 100644 --- a/tests/report/test_icon_placeholder.py +++ b/tests/report/test_icon_placeholder.py @@ -2,6 +2,7 @@ import json import os import re +import shutil import tempfile import unittest from io import BytesIO @@ -19,8 +20,8 @@ from cloudexit.report.placeholder import IconPlaceholder, LabelWithPlaceholder from cloudexit.report.sections import ALT_BODY from cloudexit.egress.model import GIB +from cloudexit.resources import asset_path from cloudexit.report.writers import generate_html_report -from cloudexit.report.egress import generate_egress_html_report from cloudexit.report.egress_report import generate_egress_report from cloudexit.report.egress_sections import _type_cell from cloudexit.report.aggregate import enrich_resource_inventory @@ -40,7 +41,7 @@ EC2_ICON = "/icons/aws/Compute/EC2.png" LAMBDA_ICON = "/icons/aws/Compute/Lambda.png" -PLACEHOLDER = '' +PLACEHOLDER = '' def with_initials(initials_text, name): @@ -231,23 +232,15 @@ def test_an_icon_file_that_does_not_exist_is_a_placeholder_too(self): class HtmlReportTests(TempDirTestCase): - def render(self, fixture): + def render(self, fixture, with_icon=True): directory = self.directory() - html_path = generate_html_report( - str(directory), - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - return Path(html_path).read_text(encoding="utf-8") + if with_icon: + # The page points only at files in its own folder + icon = directory / "assets" / EC2_ICON.lstrip("/") + icon.parent.mkdir(parents=True) + shutil.copyfile(asset_path("icons", "aws", "Compute", "EC2.png"), icon) + context = build_fixture_context(fixture, directory) + return Path(generate_html_report(context)).read_text(encoding="utf-8") def test_a_type_without_an_icon_renders_the_placeholder_not_a_broken_image(self): fixture = fixture_with( @@ -265,8 +258,8 @@ def test_a_type_without_an_icon_renders_the_placeholder_not_a_broken_image(self) html = self.render(fixture) self.assertIn(PLACEHOLDER.format("VM"), html) - # A type missing from the mapping is named "Unknown Resource" - self.assertIn(PLACEHOLDER.format("UR"), html) + # A type missing from the mapping has no name, so its initials are "?" + self.assertIn(PLACEHOLDER.format("?"), html) self.assertNotIn("default.png", html) self.assertNotIn('src="assets/"', html) self.assertNotIn("None", re.findall(r'src="assets/[^"]*"', html)) @@ -275,7 +268,14 @@ def test_a_type_with_an_icon_keeps_its_image(self): html = self.render(fixture_with({})) self.assertIn(f'src="assets{EC2_ICON}"', html) - self.assertNotIn('resource-icon-placeholder" aria-hidden', html) + self.assertNotIn(PLACEHOLDER.format("EI"), html) + + def test_an_icon_the_report_folder_does_not_hold_is_a_placeholder(self): + # The page never points outside the report folder + html = self.render(fixture_with({}), with_icon=False) + + self.assertNotIn(f'src="assets{EC2_ICON}"', html) + self.assertIn(PLACEHOLDER.format("EI"), html) def test_both_kinds_appear_side_by_side(self): fixture = fixture_with( @@ -294,10 +294,8 @@ def test_both_kinds_appear_side_by_side(self): self.assertIn(PLACEHOLDER.format("VM"), html) def test_the_stylesheet_styles_the_placeholder(self): - from cloudexit.resources import asset_path - - css = asset_path("css", "style.css").read_text(encoding="utf-8") - self.assertIn(".resource-icon-placeholder", css) + css = asset_path("css", "report.css").read_text(encoding="utf-8") + self.assertIn(".rt .ph", css) class EnrichedInventoryTests(unittest.TestCase): @@ -390,15 +388,6 @@ def test_the_pdf_has_one_image_less_when_a_type_loses_its_icon(self): self.assertEqual(image_count(self.run_pdf(one)), count_both - 1) - def test_the_html_shows_a_placeholder_and_keeps_the_images(self): - icons = {"microsoft.storage/storageaccounts": EC2_ICON} - result = self.run_report(generate_egress_html_report, icons) - - html = Path(result["html_path"]).read_text(encoding="utf-8") - self.assertRegex(html, rf'src="assets/+{EC2_ICON.lstrip("/")}"') - self.assertIn(PLACEHOLDER.format("MD"), html) - self.assertNotIn("no_image", html) - if __name__ == "__main__": unittest.main() diff --git a/tests/report/test_no_credentials.py b/tests/report/test_no_credentials.py index 5edd280..59783b1 100644 --- a/tests/report/test_no_credentials.py +++ b/tests/report/test_no_credentials.py @@ -176,7 +176,6 @@ def assert_complete_run(self, report): for name in ( "index.html", "report.pdf", - "egress.html", "egress.pdf", "run.log", "raw_data/assessment_result.json", diff --git a/tests/report/test_report_flow.py b/tests/report/test_report_flow.py index 2361328..1990737 100644 --- a/tests/report/test_report_flow.py +++ b/tests/report/test_report_flow.py @@ -37,6 +37,7 @@ sync_body, whoami_reply, ) +from tests.support.egress import empty_estimate from tests.support.flow import LOCAL_RISKS, SERVER_SCORES, build_flow_run from tests.support.http import fake_http, make_response from tests.support.sync import SYNC_ID, egress_row @@ -225,7 +226,7 @@ def test_a_successful_upload_records_only_the_link(self): self.assertTrue(result["success"], result.get("logs")) html = self.html(report) self.assertIn(f'href="{URL}"', html) - self.assertIn("Open the platform report", html) + self.assertIn("Open on the platform", html) self.assertEqual(self.json_report(report)["meta"]["upload"], info) def test_banner_text_is_escaped(self): @@ -422,11 +423,11 @@ def run_assessment( "cloudexit.cli.app.estimate_egress", return_value={"success": egress_ok, "logs": "x", "json_path": "p"}, ), + # The estimate file is a stub: the report is made from this one patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value={"success": True, "html_path": "h"}, + "cloudexit.engine.load_egress_estimate", + return_value=empty_estimate(), ), - # The PDF is written by generate_report, from the estimate file patch( "cloudexit.engine.generate_egress_report", return_value="egress.pdf", @@ -738,8 +739,7 @@ def traced_risk(*args, **kwargs): or {"success": True, "json_path": "p"}, ), patch( - "cloudexit.cli.app.generate_egress_html_report", - return_value={"success": True}, + "cloudexit.engine.load_egress_estimate", return_value=empty_estimate() ), patch("cloudexit.engine.generate_egress_report", return_value="egress.pdf"), ): diff --git a/tests/report/test_report_pipeline.py b/tests/report/test_report_pipeline.py index cb4ee57..de7bcdb 100644 --- a/tests/report/test_report_pipeline.py +++ b/tests/report/test_report_pipeline.py @@ -18,69 +18,35 @@ class ReportPipelineSmokeTests(unittest.TestCase): - def test_generate_html_report_creates_expected_output(self): - fixture = build_report_fixture() - + def html(self, fixture, **overrides): with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) + context = build_fixture_context(fixture, report_dir, **overrides) + html_path = generate_html_report(context) self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") + self.assertEqual(Path(html_path).name, "index.html") + return Path(html_path).read_text(encoding="utf-8") + + def test_generate_html_report_creates_expected_output(self): + html = self.html(build_report_fixture()) self.assertIn("Smoke Test Assessment", html) self.assertIn("Amazon Web Services", html) self.assertIn("OpenStack", html) self.assertIn("OpenInfra Foundation", html) - self.assertIn("All Regions", html) - self.assertIn("data-org-region", html) self.assertIn("EC2 Instance", html) + self.assertIn('data-os="1"', html) def test_generate_html_report_renders_empty_state_output(self): - fixture = build_empty_report_fixture() - - with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - fixture["cost_data"], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - - self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") + html = self.html(build_empty_report_fixture()) self.assertIn("Empty State Assessment", html) - self.assertIn("No risk data available.", html) - self.assertIn("No cost data available.", html) - self.assertNotIn("exit score", html.lower()) - self.assertNotIn("lock-in score", html.lower()) - self.assertIn("No resources were discovered during the assessment.", html) - self.assertIn("No alternative technologies are available", html) - self.assertNotIn('id="risksChart"', html) - self.assertNotIn('id="costsChart"', html) - self.assertNotIn('id="exitScoreChart"', html) - self.assertNotIn('id="vendorLockInScoreChart"', html) + self.assertIn("No exit risks were identified.", html) + self.assertIn("No cost data available for this assessment.", html) + self.assertIn("No resources were found in the defined scope.", html) + self.assertIn("No resource types to list alternatives for.", html) + # No filters when there is nothing to filter + self.assertNotIn('class="toolbar"', html) def test_generate_json_report_creates_expected_structure(self): fixture = build_report_fixture() @@ -137,29 +103,13 @@ class EmptyCostInventoryReportTests(unittest.TestCase): def test_html_report_renders_resources_without_cost_data(self): fixture = build_report_fixture() - with tempfile.TemporaryDirectory() as report_dir: - html_path = generate_html_report( - report_dir, - fixture["metadata"], - fixture["resource_type_mapping"], - fixture["resource_inventory"], - [], - None, - fixture["risk_data"], - fixture["risk_definitions"], - fixture["alternatives"], - fixture["alternative_technologies"], - fixture["exit_strategy"], - fixture["alternative_technology_organizations"], - ) - - self.assertTrue(Path(html_path).exists()) - html = Path(html_path).read_text(encoding="utf-8") + context = build_fixture_context(fixture, report_dir, cost_data=[]) + html = Path(generate_html_report(context)).read_text(encoding="utf-8") self.assertIn("EC2 Instance", html) - self.assertIn("No cost data available.", html) - self.assertNotIn('id="costsChart"', html) + self.assertIn("No cost data available for this assessment.", html) + self.assertNotIn('class="bars"', html) def test_json_report_has_empty_cost_inventory(self): fixture = build_report_fixture() diff --git a/tests/report/test_report_transforms.py b/tests/report/test_report_transforms.py index 8fcbbf5..cf58cff 100644 --- a/tests/report/test_report_transforms.py +++ b/tests/report/test_report_transforms.py @@ -1,10 +1,5 @@ import unittest -from cloudexit.report.html_data import ( - transform_alt_tech_for_html, - transform_cost_inventory_for_html, - transform_risk_inventory_for_html, -) from cloudexit.report.json_data import ( transform_alt_tech_for_json, transform_resource_inventory_for_json, @@ -120,62 +115,6 @@ def build_alternative_technology_organizations(): ] -class HtmlTransformTests(unittest.TestCase): - def test_transform_cost_inventory_for_html_sorts_and_sums_costs(self): - months, cost_values, total_cost, currency_code, currency_symbol = ( - transform_cost_inventory_for_html( - [ - {"month": "2026-02-01", "cost": 11.25, "currency": "USD"}, - {"month": "2026-01-01", "cost": 14.75, "currency": "USD"}, - ] - ) - ) - - self.assertEqual(months, ["Jan", "Feb"]) - self.assertEqual(cost_values, [14.75, 11.25]) - self.assertEqual(total_cost, 26.0) - self.assertEqual(currency_code, "USD") - self.assertEqual(currency_symbol, "$") - - def test_transform_risk_inventory_for_html_counts_overall_and_resource_risks(self): - resource_inventory = { - "101": {"name": "EC2 Instance"}, - "202": {"name": "S3 Bucket"}, - } - - risks, severity_counts = transform_risk_inventory_for_html( - build_risk_data(), - build_risk_definitions(), - resource_inventory, - ) - - self.assertEqual([risk["severity"] for risk in risks], ["high", "medium"]) - self.assertEqual(risks[0]["impacted_resources_count"], 2) - self.assertCountEqual( - risks[0]["impacted_resources"], ["EC2 Instance", "S3 Bucket"] - ) - self.assertIsNone(risks[1]["impacted_resources_count"]) - self.assertEqual(severity_counts, {"high": 1, "medium": 1, "low": 0}) - - def test_transform_alt_tech_for_html_filters_by_strategy_and_status(self): - transformed = transform_alt_tech_for_html( - build_resource_inventory(), - build_alternatives(), - build_alternative_technologies(), - exit_strategy=1, - alternative_technology_organizations=build_alternative_technology_organizations(), - ) - - self.assertEqual(len(transformed), 2) - self.assertEqual(transformed[0]["product_name"], "OpenStack") - self.assertEqual(transformed[1]["product_name"], "MinIO") - self.assertTrue(transformed[0]["open_source"]) - self.assertFalse(transformed[1]["support_plan"]) - self.assertEqual(transformed[0]["organization_name"], "OpenInfra Foundation") - self.assertEqual(transformed[0]["organization_region"], "united-states") - self.assertEqual(transformed[1]["organization_region"], "european-union") - - class JsonTransformTests(unittest.TestCase): def test_transform_resource_inventory_for_json_maps_names_and_codes(self): transformed = transform_resource_inventory_for_json( diff --git a/tests/samples/__main__.py b/tests/samples/__main__.py index 1a7856c..c4fe0fd 100644 --- a/tests/samples/__main__.py +++ b/tests/samples/__main__.py @@ -101,9 +101,8 @@ def overview(scenarios, results, failures, sheets): link(case, name, title) for name, title in ( ("report.pdf", "Executive Summary PDF"), - ("index.html", "Executive Summary HTML"), + ("index.html", "HTML report"), ("egress.pdf", "Egress PDF"), - ("egress.html", "Egress HTML"), ) if name in result.files ] diff --git a/tests/samples/catalogue.py b/tests/samples/catalogue.py index 668a2a0..dc11903 100644 --- a/tests/samples/catalogue.py +++ b/tests/samples/catalogue.py @@ -109,6 +109,8 @@ "Microsoft.ContainerRegistry/registries": (0, 0), "Microsoft.DocumentDB/databaseAccounts": (2, 2), "Microsoft.Compute/virtualMachines": (5, 4), + # More than the page shows at first, so the rest is collapsed + "AWS.ec2.describe_instances.Reservations": (12, 10), } DEFAULT_ALTERNATIVES = (3, 3) @@ -196,12 +198,14 @@ def build_sample_catalogue(path, scenarios): conn.execute( "INSERT INTO alternativetechnology (id, product_name, " "product_description, product_url, open_source, support_plan, " - "status, organization_id) VALUES (?, ?, '', NULL, ?, ?, 't', 1)", + "status, organization_id) VALUES (?, ?, '', NULL, ?, ?, 't', ?)", ( technology, f"Sample Technology {technology}", "t" if position % 2 else "f", "t" if position < supported else "f", + # Some have no recorded vendor + 1 if technology % 4 else None, ), ) for strategy in (1, 2, 3): diff --git a/tests/samples/render.py b/tests/samples/render.py index afe9947..de5d4b5 100644 --- a/tests/samples/render.py +++ b/tests/samples/render.py @@ -19,19 +19,13 @@ perform_risk_assessment, ) from cloudexit.report.assets import copy_assets -from cloudexit.report.egress import generate_egress_html_report from tests.support.html import html_blocks -# What a rendered case holds. Both HTML reports have a text dump, so a change -# to either can be reviewed with a diff. +# What a rendered case holds. The HTML report and each PDF have a text dump, so +# a change to any of them can be reviewed with a diff. EXPECTED_FILES = { "always": ("report.pdf", "index.html", "texts/report.txt", "texts/index-html.txt"), - "egress": ( - "egress.pdf", - "egress.html", - "texts/egress.txt", - "texts/egress-html.txt", - ), + "egress": ("egress.pdf", "texts/egress.txt"), } ASSESSMENT_TYPE = 1 @@ -186,13 +180,8 @@ def _write_dumps(scenario, directory, result): text, pages = pdf_dump(directory / pdf) (texts / dump).write_text(text, encoding="utf-8") result.pages[pdf] = pages - for html, dump in ( - ("index.html", "index-html.txt"), - ("egress.html", "egress-html.txt"), - ): - if (directory / html).is_file(): - source = (directory / html).read_text(encoding="utf-8") - (texts / dump).write_text(html_blocks(source), encoding="utf-8") + source = (directory / "index.html").read_text(encoding="utf-8") + (texts / "index-html.txt").write_text(html_blocks(source), encoding="utf-8") def _render(scenario, directory, catalogue_path): @@ -221,13 +210,6 @@ def _render(scenario, directory, catalogue_path): if not risks["success"]: raise RenderError(f"risk assessment failed: {risks['logs']}") - if egress_json: - html = generate_egress_html_report( - report_path, egress_json, details, scenario.run_id - ) - if not html["success"]: - raise RenderError(f"egress.html failed: {html['logs']}") - report = generate_report( scenario.csp, details, diff --git a/tests/samples/rendered.py b/tests/samples/rendered.py new file mode 100644 index 0000000..6eae4c3 --- /dev/null +++ b/tests/samples/rendered.py @@ -0,0 +1,31 @@ +import atexit +import tempfile +from pathlib import Path + +from tests.samples.catalogue import build_sample_catalogue +from tests.samples.render import render_cases +from tests.samples.scenarios import load_scenarios + +_STATE = {} + + +def rendered(): + # Every sample case rendered once per test run, for all the tests that read + # a finished report folder; rendering is the slow part + if not _STATE: + tmp = tempfile.TemporaryDirectory() + atexit.register(tmp.cleanup) + root = Path(tmp.name) + scenarios = load_scenarios() + catalogue = root / "catalogue.db" + build_sample_catalogue(catalogue, scenarios.values()) + results, failures = render_cases( + scenarios.values(), root / "out", catalogue_path=catalogue + ) + _STATE.update( + scenarios=scenarios, + catalogue=catalogue, + results={result.case: result for result in results}, + failures=failures, + ) + return _STATE diff --git a/tests/samples/test_samples.py b/tests/samples/test_samples.py index fea1e96..8026ed3 100644 --- a/tests/samples/test_samples.py +++ b/tests/samples/test_samples.py @@ -15,13 +15,13 @@ from tests.report.test_no_credentials import find_leaks, folder_text from tests.samples import __main__ as samples_main from tests.samples.catalogue import build_sample_catalogue +from tests.samples.rendered import rendered from tests.samples.render import ( expected_files, pdf_dump, render_case, render_cases, ) -from tests.samples.scenarios import load_scenarios from tests.support.credentials import AWS_DETAILS, AZURE_DETAILS from tests.support.html import html_blocks, html_text from tests.support.report_terms import american_hits, find_terms, retired_term_hits @@ -31,9 +31,7 @@ ICON_MARKERS = {"azure": "Microsoft group", "aws": "Amazon.com"} EGRESS_FILES = ( "egress.pdf", - "egress.html", "texts/egress.txt", - "texts/egress-html.txt", ) STATE = {} @@ -44,22 +42,10 @@ def squashed(text): def setUpModule(): - tmp = tempfile.TemporaryDirectory() - STATE["tmp"] = tmp - root = Path(tmp.name) - STATE["scenarios"] = scenarios = load_scenarios() - STATE["catalogue"] = catalogue = root / "catalogue.db" - build_sample_catalogue(catalogue, scenarios.values()) - STATE["out"] = root / "out" - results, failures = render_cases( - scenarios.values(), STATE["out"], catalogue_path=catalogue - ) - STATE["results"] = {result.case: result for result in results} - STATE["failures"] = failures + STATE.update(rendered()) def tearDownModule(): - STATE["tmp"].cleanup() STATE.clear() @@ -111,15 +97,19 @@ def test_the_dumps_match_the_files(self): text, pages = pdf_dump(folder / pdf) self.assertEqual(self.text(case, f"texts/{dump}"), text) self.assertEqual(text.count("--- page "), pages) - for html, dump in ( - ("index.html", "index-html.txt"), - ("egress.html", "egress-html.txt"), - ): - if (folder / html).is_file(): - self.assertEqual( - self.text(case, f"texts/{dump}"), - html_blocks(self.text(case, html)), - ) + self.assertEqual( + self.text(case, "texts/index-html.txt"), + html_blocks(self.text(case, "index.html")), + ) + # One HTML report: there is no second page for the egress cases + self.assertFalse((folder / "egress.html").exists()) + + def test_the_html_dump_has_the_egress_section_for_the_egress_cases(self): + for case, scenario in self.cases(): + with self.subTest(case): + dump = self.text(case, "texts/index-html.txt") + has_section = "## Egress & Data Landscape" in dump + self.assertEqual(has_section, scenario.egress is not None) def test_rendering_a_case_twice_gives_the_same_texts(self): scenario = STATE["scenarios"]["aws_typical"] @@ -172,11 +162,9 @@ def test_the_term_wording_and_retired_term_guards(self): for text in metadata_texts(reader): self.assertEqual(find_terms(text), []) texts += page_texts(reader) - for html in ("index.html", "egress.html"): - if (folder / html).is_file(): - source = self.text(case, html) - texts.append(html_text(source)) - self.assertEqual(find_terms(source.split(" Date: Sun, 25 Oct 2026 13:07:09 +0100 Subject: [PATCH 2/2] keep line endings of the bundled driver.js files as they are --- .gitattributes | 5 +++++ tests/test_portability.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/.gitattributes b/.gitattributes index 770e167..16b426a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ # The definition copy is compared by file hash, so it must not get CRLF line endings docs/api-v2/** text eol=lf + +# The bundled driver.js files are checked against a pinned hash and shipped +# unmodified, so no line ending conversion may touch them +cloudexit/assets/js/** -text +cloudexit/assets/css/driver.css -text diff --git a/tests/test_portability.py b/tests/test_portability.py index 3882407..966f8be 100644 --- a/tests/test_portability.py +++ b/tests/test_portability.py @@ -191,3 +191,11 @@ class LineEndingTests(unittest.TestCase): def test_the_definition_copy_is_checked_out_with_unix_line_endings(self): attributes = (ROOT / ".gitattributes").read_text(encoding="utf-8") self.assertIn("docs/api-v2/** text eol=lf", attributes) + + def test_the_bundled_scripts_are_never_converted(self): + attributes = (ROOT / ".gitattributes").read_text(encoding="utf-8") + for pattern in ( + "cloudexit/assets/js/** -text", + "cloudexit/assets/css/driver.css -text", + ): + self.assertIn(pattern, attributes)