Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 934c61b24b |
@@ -10,6 +10,11 @@ from dash import dcc, html
|
||||
from dash.dependencies import Input, Output
|
||||
|
||||
from dashboard.config import folder_path_overview
|
||||
from dashboard.lib.cloud_promo import (
|
||||
cloud_promo_card,
|
||||
cloud_tour_modal,
|
||||
register_cloud_promo_callbacks,
|
||||
)
|
||||
from prowler.config.config import orange_color
|
||||
from prowler.lib.banner import print_banner
|
||||
|
||||
@@ -20,7 +25,7 @@ print_banner()
|
||||
print(
|
||||
f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}"
|
||||
)
|
||||
cli.show_server_banner = lambda *x: click.echo(
|
||||
cli.show_server_banner = lambda *_: click.echo(
|
||||
f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3://<your-bucket>/output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files."
|
||||
)
|
||||
|
||||
@@ -136,6 +141,8 @@ dashboard.layout = html.Div(
|
||||
],
|
||||
className="grid custom-grid 2xl:custom-grid-large h-screen",
|
||||
),
|
||||
# Prowler Cloud product tour (image-only experience).
|
||||
cloud_tour_modal(),
|
||||
],
|
||||
className="h-screen mx-auto",
|
||||
)
|
||||
@@ -157,20 +164,7 @@ def update_nav_bar(pathname):
|
||||
),
|
||||
html.Nav(
|
||||
[
|
||||
html.A(
|
||||
[
|
||||
html.Span(
|
||||
[
|
||||
html.Img(src="assets/favicon.ico", className="w-5"),
|
||||
"Subscribe to Prowler Cloud",
|
||||
],
|
||||
className="flex items-center gap-x-3 text-white",
|
||||
),
|
||||
],
|
||||
href="https://prowler.com/",
|
||||
target="_blank",
|
||||
className="block p-3 uppercase text-xs hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime",
|
||||
),
|
||||
cloud_promo_card(),
|
||||
html.Ul(generate_help_menu(), className=""),
|
||||
],
|
||||
className="flex flex-col gap-y-6 mt-auto",
|
||||
@@ -178,3 +172,7 @@ def update_nav_bar(pathname):
|
||||
],
|
||||
className="flex flex-col bg-prowler-stone-900 py-7 h-full",
|
||||
)
|
||||
|
||||
|
||||
# Wire the Prowler Cloud product tour open/close/navigation callbacks.
|
||||
register_cloud_promo_callbacks(dashboard)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/* Prowler Cloud promo: sidebar hero + product tour modal.
|
||||
Styled to match the new Prowler Cloud app (cool-dark surface + mint accent),
|
||||
intentionally distinct from the warm stone sidebar so it stands out. */
|
||||
|
||||
:root {
|
||||
--cloud-bg: #0a0e16;
|
||||
--cloud-bg-2: #111a2b;
|
||||
--cloud-mint: #6ee7b7;
|
||||
--cloud-mint-2: #2dd4bf;
|
||||
--cloud-ink: #e6f0ef;
|
||||
--cloud-muted: #9fb1c4;
|
||||
}
|
||||
|
||||
/* ---------- Sidebar hero card ---------- */
|
||||
.cloud-promo-card-wrap {
|
||||
padding: 0 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cloud-promo-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.95rem 0.95rem 0.85rem;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: var(--cloud-ink);
|
||||
background:
|
||||
radial-gradient(120% 120% at 0% 0%, rgba(45, 212, 191, 0.18) 0%, transparent 55%),
|
||||
linear-gradient(160deg, var(--cloud-bg-2) 0%, var(--cloud-bg) 100%);
|
||||
border: 1px solid rgba(110, 231, 183, 0.28);
|
||||
box-shadow: 0 10px 24px -14px rgba(45, 212, 191, 0.55);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.cloud-promo-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(110, 231, 183, 0.6);
|
||||
box-shadow: 0 16px 30px -12px rgba(45, 212, 191, 0.7);
|
||||
}
|
||||
|
||||
/* Soft sheen sweep on hover. */
|
||||
.cloud-promo-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.08) 50%, transparent 70%);
|
||||
transform: translateX(-120%);
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
.cloud-promo-card:hover::after {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
|
||||
.cloud-promo-eyebrow {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cloud-mint);
|
||||
}
|
||||
|
||||
.cloud-promo-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cloud-promo-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--cloud-mint);
|
||||
}
|
||||
|
||||
.cloud-promo-arrow {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.cloud-promo-card:hover .cloud-promo-arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* ---------- Tour modal ---------- */
|
||||
.cloud-tour-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.cloud-tour-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(4, 7, 12, 0.74);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloud-tour-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(960px, 100%);
|
||||
max-height: 92vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, var(--cloud-bg-2) 0%, var(--cloud-bg) 100%);
|
||||
border: 1px solid rgba(110, 231, 183, 0.22);
|
||||
box-shadow: 0 40px 90px -30px rgba(0, 0, 0, 0.85);
|
||||
animation: cloud-tour-pop 0.22s ease;
|
||||
}
|
||||
|
||||
@keyframes cloud-tour-pop {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.cloud-tour-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--cloud-ink);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.cloud-tour-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.cloud-tour-stage {
|
||||
padding: 1.4rem 1.4rem 0;
|
||||
background:
|
||||
radial-gradient(80% 60% at 50% 0%, rgba(45, 212, 191, 0.12) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.cloud-tour-image {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
/* Fixed 2:1 frame keeps the stage height identical across slides, while the
|
||||
viewport cap guarantees the caption + nav controls always stay visible
|
||||
(never clipped by the dialog's max-height). Tour images are pre-composited
|
||||
to 2000x1000 (2:1), so the ratio is honored with no cropping. */
|
||||
aspect-ratio: 2 / 1;
|
||||
max-width: 100%;
|
||||
max-height: 48vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 50px -24px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.cloud-tour-caption {
|
||||
padding: 1.1rem 1.6rem 0.4rem;
|
||||
}
|
||||
|
||||
.cloud-tour-eyebrow {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cloud-mint);
|
||||
}
|
||||
|
||||
.cloud-tour-title {
|
||||
margin: 0.35rem 0 0.4rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cloud-tour-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: var(--cloud-muted);
|
||||
max-width: 60ch;
|
||||
/* Reserve ~3 lines so captions of different lengths don't shift height. */
|
||||
min-height: 4.3rem;
|
||||
}
|
||||
|
||||
a.cloud-tour-cta {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: #04231d;
|
||||
background: linear-gradient(135deg, var(--cloud-mint) 0%, var(--cloud-mint-2) 100%);
|
||||
box-shadow: 0 12px 24px -12px rgba(45, 212, 191, 0.8);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
a.cloud-tour-cta:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 30px -12px rgba(45, 212, 191, 0.95);
|
||||
}
|
||||
|
||||
.cloud-tour-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.6rem 1.4rem;
|
||||
}
|
||||
|
||||
.cloud-tour-nav {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
color: var(--cloud-ink);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.cloud-tour-nav:hover {
|
||||
background: rgba(110, 231, 183, 0.14);
|
||||
border-color: rgba(110, 231, 183, 0.5);
|
||||
}
|
||||
|
||||
.cloud-tour-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.cloud-tour-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cloud-tour-dot--active {
|
||||
background: linear-gradient(135deg, var(--cloud-mint) 0%, var(--cloud-mint-2) 100%);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cloud-tour-title { font-size: 1.15rem; }
|
||||
.cloud-tour-stage { padding: 1rem 1rem 0; }
|
||||
.cloud-tour-caption { padding: 0.9rem 1rem 0.3rem; }
|
||||
.cloud-tour-controls { padding: 0.8rem 1rem 1.1rem; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,328 @@
|
||||
"""Prowler Cloud promo: sidebar hero + image-only product tour.
|
||||
|
||||
Drives CLI dashboard users toward Prowler Cloud. The sidebar shows a catchy,
|
||||
on-brand hero card; clicking it opens a modal that walks through the real
|
||||
Prowler Cloud experience using product screenshots.
|
||||
|
||||
The visual styling lives in ``dashboard/assets/cloud-promo.css`` (Dash loads
|
||||
every CSS file under ``assets/`` automatically, so this does not depend on the
|
||||
precompiled Tailwind bundle).
|
||||
"""
|
||||
|
||||
from dash import dcc, html
|
||||
from dash.dependencies import Input, Output, State
|
||||
|
||||
# Where every CTA points. Single source of truth so links stay consistent.
|
||||
CLOUD_URL = "https://prowler.com/"
|
||||
|
||||
# Ordered product tour. Each slide is one real Prowler Cloud screenshot plus a
|
||||
# short, benefit-led caption. The last slide carries the closing call to action.
|
||||
TOUR_SLIDES = [
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-1-overview.png",
|
||||
"eyebrow": "01 · Overview",
|
||||
"title": "Your whole cloud, one view",
|
||||
"text": (
|
||||
"Prowler ThreatScore, severity breakdown, resource inventory and "
|
||||
"attack surface — thousands of findings, prioritized at a glance."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-2-attack-paths.png",
|
||||
"eyebrow": "02 · Attack Paths",
|
||||
"title": "See the attack path before they do",
|
||||
"text": (
|
||||
"Visualize how attackers chain misconfigurations across roles, "
|
||||
"policies and identities to reach your crown jewels."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-3-lighthouse.png",
|
||||
"eyebrow": "03 · Lighthouse AI",
|
||||
"title": "Ask your cloud anything",
|
||||
"text": (
|
||||
"Lighthouse AI + MCP delivers autonomous triage, prioritization and "
|
||||
"remediation — risk answers in plain English, not spreadsheets."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-4-scans.png",
|
||||
"eyebrow": "04 · Continuous scanning",
|
||||
"title": "Scan every account, on a schedule",
|
||||
"text": (
|
||||
"Onboard whole organizations and scan all your AWS, Azure, GCP, "
|
||||
"M365 and Kubernetes accounts at once, with full history and trends."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-5-compliance.png",
|
||||
"eyebrow": "05 · Compliance",
|
||||
"title": "50+ frameworks, always live",
|
||||
"text": (
|
||||
"CIS, NIST, PCI DSS, ISO 27001, HIPAA, SOC 2, ENS and more — scored "
|
||||
"continuously with one-click evidence export."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-6-integrations.png",
|
||||
"eyebrow": "06 · Integrations",
|
||||
"title": "Plugs into your stack",
|
||||
"text": (
|
||||
"Jira, Slack, AWS Security Hub, Amazon S3, SAML SSO and RBAC — push "
|
||||
"findings where your team already works."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-7-alerts.png",
|
||||
"eyebrow": "07 · Alerts",
|
||||
"title": "Stay ahead with smart alerts",
|
||||
"text": (
|
||||
"Get notified the moment findings match your conditions — daily "
|
||||
"digests or real-time, routed to the people who need them."
|
||||
),
|
||||
},
|
||||
{
|
||||
"image": "/assets/images/cloud/tour-8-import.png",
|
||||
"eyebrow": "From CLI to Cloud",
|
||||
"title": "Bring what you already have",
|
||||
"text": (
|
||||
"Already scanning with the Prowler CLI? Import your existing findings "
|
||||
"into Prowler Cloud in one click — no rescan, no lost history."
|
||||
),
|
||||
"cta": "Start free on Prowler Cloud",
|
||||
},
|
||||
]
|
||||
|
||||
# Component ids reused by the layout and the callbacks.
|
||||
TRIGGER_ID = "cloud-promo-trigger"
|
||||
CLOSE_ID = "cloud-tour-close"
|
||||
BACKDROP_ID = "cloud-tour-backdrop"
|
||||
PREV_ID = "cloud-tour-prev"
|
||||
NEXT_ID = "cloud-tour-next"
|
||||
OVERLAY_ID = "cloud-tour-overlay"
|
||||
OPEN_STORE_ID = "cloud-tour-open"
|
||||
INDEX_STORE_ID = "cloud-tour-index"
|
||||
|
||||
_OVERLAY_HIDDEN = {"display": "none"}
|
||||
_OVERLAY_VISIBLE = {"display": "flex"}
|
||||
|
||||
|
||||
def next_tour_state(trigger_id: str, is_open: bool, index: int, total: int):
|
||||
"""Pure reducer for the tour modal.
|
||||
|
||||
Returns the next ``(is_open, index)`` given which control fired. Kept free of
|
||||
Dash so the navigation logic is unit-testable in isolation.
|
||||
"""
|
||||
if total <= 0:
|
||||
return False, 0
|
||||
if trigger_id == TRIGGER_ID:
|
||||
return True, 0
|
||||
if trigger_id in (CLOSE_ID, BACKDROP_ID):
|
||||
return False, index
|
||||
if trigger_id == NEXT_ID:
|
||||
return is_open, (index + 1) % total
|
||||
if trigger_id == PREV_ID:
|
||||
return is_open, (index - 1) % total
|
||||
return is_open, index
|
||||
|
||||
|
||||
def cloud_promo_card() -> html.Div:
|
||||
"""Catchy, on-brand sidebar hero. Click opens the product tour."""
|
||||
return html.Div(
|
||||
html.Div(
|
||||
[
|
||||
html.Span("Prowler Cloud", className="cloud-promo-eyebrow"),
|
||||
html.Span(
|
||||
"Your cloud, continuously secured.",
|
||||
className="cloud-promo-title",
|
||||
),
|
||||
html.Span(
|
||||
[
|
||||
"See it in action",
|
||||
html.Span("→", className="cloud-promo-arrow"),
|
||||
],
|
||||
className="cloud-promo-cta",
|
||||
),
|
||||
],
|
||||
className="cloud-promo-card",
|
||||
id=TRIGGER_ID,
|
||||
n_clicks=0,
|
||||
),
|
||||
className="cloud-promo-card-wrap",
|
||||
)
|
||||
|
||||
|
||||
def _slide_dots(total: int) -> list:
|
||||
return [
|
||||
html.Span(className="cloud-tour-dot", id={"type": "cloud-tour-dot", "index": i})
|
||||
for i in range(total)
|
||||
]
|
||||
|
||||
|
||||
def cloud_tour_modal() -> html.Div:
|
||||
"""Root-level modal that plays the image-only Prowler Cloud tour."""
|
||||
first = TOUR_SLIDES[0]
|
||||
total = len(TOUR_SLIDES)
|
||||
return html.Div(
|
||||
[
|
||||
dcc.Store(id=OPEN_STORE_ID, data=False),
|
||||
dcc.Store(id=INDEX_STORE_ID, data=0),
|
||||
html.Div(
|
||||
[
|
||||
html.Div(
|
||||
id=BACKDROP_ID, className="cloud-tour-backdrop", n_clicks=0
|
||||
),
|
||||
html.Div(
|
||||
[
|
||||
html.Button(
|
||||
"✕",
|
||||
id=CLOSE_ID,
|
||||
className="cloud-tour-close",
|
||||
n_clicks=0,
|
||||
**{"aria-label": "Close tour"},
|
||||
),
|
||||
html.Div(
|
||||
html.Img(
|
||||
id="cloud-tour-image",
|
||||
src=first["image"],
|
||||
className="cloud-tour-image",
|
||||
alt="Prowler Cloud product screenshot",
|
||||
),
|
||||
className="cloud-tour-stage",
|
||||
),
|
||||
html.Div(
|
||||
[
|
||||
html.Span(
|
||||
first["eyebrow"],
|
||||
id="cloud-tour-eyebrow",
|
||||
className="cloud-tour-eyebrow",
|
||||
),
|
||||
html.H3(
|
||||
first["title"],
|
||||
id="cloud-tour-title",
|
||||
className="cloud-tour-title",
|
||||
),
|
||||
html.P(
|
||||
first["text"],
|
||||
id="cloud-tour-text",
|
||||
className="cloud-tour-text",
|
||||
),
|
||||
html.A(
|
||||
first.get("cta", "Start free on Prowler Cloud"),
|
||||
id="cloud-tour-cta",
|
||||
href=CLOUD_URL,
|
||||
target="_blank",
|
||||
className="cloud-tour-cta",
|
||||
style={"display": "none"},
|
||||
),
|
||||
],
|
||||
className="cloud-tour-caption",
|
||||
),
|
||||
html.Div(
|
||||
[
|
||||
html.Button(
|
||||
"‹",
|
||||
id=PREV_ID,
|
||||
className="cloud-tour-nav",
|
||||
n_clicks=0,
|
||||
**{"aria-label": "Previous"},
|
||||
),
|
||||
html.Div(
|
||||
_slide_dots(total),
|
||||
id="cloud-tour-dots",
|
||||
className="cloud-tour-dots",
|
||||
),
|
||||
html.Button(
|
||||
"›",
|
||||
id=NEXT_ID,
|
||||
className="cloud-tour-nav",
|
||||
n_clicks=0,
|
||||
**{"aria-label": "Next"},
|
||||
),
|
||||
],
|
||||
className="cloud-tour-controls",
|
||||
),
|
||||
],
|
||||
className="cloud-tour-dialog",
|
||||
),
|
||||
],
|
||||
id=OVERLAY_ID,
|
||||
className="cloud-tour-overlay",
|
||||
style=_OVERLAY_HIDDEN,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def register_cloud_promo_callbacks(app) -> None:
|
||||
"""Wire the tour open/close/navigation into a Dash app."""
|
||||
|
||||
@app.callback(
|
||||
Output(OPEN_STORE_ID, "data"),
|
||||
Output(INDEX_STORE_ID, "data"),
|
||||
Input(TRIGGER_ID, "n_clicks"),
|
||||
Input(CLOSE_ID, "n_clicks"),
|
||||
Input(BACKDROP_ID, "n_clicks"),
|
||||
Input(PREV_ID, "n_clicks"),
|
||||
Input(NEXT_ID, "n_clicks"),
|
||||
State(OPEN_STORE_ID, "data"),
|
||||
State(INDEX_STORE_ID, "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _drive_tour(_t, _c, _b, _p, _n, is_open, index):
|
||||
from dash import ctx
|
||||
from dash.exceptions import PreventUpdate
|
||||
|
||||
# The trigger button lives inside the dynamically rendered sidebar, so it
|
||||
# appears after the initial load. Ignore the spurious fire when a control
|
||||
# merely mounts (n_clicks 0/None) — only react to real clicks.
|
||||
if not ctx.triggered or not ctx.triggered[0]["value"]:
|
||||
raise PreventUpdate
|
||||
trigger_id = ctx.triggered_id
|
||||
return next_tour_state(
|
||||
trigger_id, bool(is_open), int(index or 0), len(TOUR_SLIDES)
|
||||
)
|
||||
|
||||
@app.callback(
|
||||
Output(OVERLAY_ID, "style"),
|
||||
Output("cloud-tour-image", "src"),
|
||||
Output("cloud-tour-eyebrow", "children"),
|
||||
Output("cloud-tour-title", "children"),
|
||||
Output("cloud-tour-text", "children"),
|
||||
Output("cloud-tour-cta", "children"),
|
||||
Output("cloud-tour-cta", "style"),
|
||||
Output("cloud-tour-dots", "children"),
|
||||
Input(OPEN_STORE_ID, "data"),
|
||||
Input(INDEX_STORE_ID, "data"),
|
||||
)
|
||||
def _render_tour(is_open, index):
|
||||
index = int(index or 0) % len(TOUR_SLIDES)
|
||||
slide = TOUR_SLIDES[index]
|
||||
overlay_style = _OVERLAY_VISIBLE if is_open else _OVERLAY_HIDDEN
|
||||
cta_label = slide.get("cta")
|
||||
# Always occupy the CTA box (reserve space) so the dialog height stays
|
||||
# constant across slides; only its visibility changes.
|
||||
cta_style = {
|
||||
"display": "inline-flex",
|
||||
"visibility": "visible" if cta_label else "hidden",
|
||||
}
|
||||
dots = [
|
||||
html.Span(
|
||||
className=(
|
||||
"cloud-tour-dot cloud-tour-dot--active"
|
||||
if i == index
|
||||
else "cloud-tour-dot"
|
||||
)
|
||||
)
|
||||
for i in range(len(TOUR_SLIDES))
|
||||
]
|
||||
return (
|
||||
overlay_style,
|
||||
slide["image"],
|
||||
slide["eyebrow"],
|
||||
slide["title"],
|
||||
slide["text"],
|
||||
cta_label or "Start free on Prowler Cloud",
|
||||
cta_style,
|
||||
dots,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the Prowler Cloud promo tour logic and layout."""
|
||||
|
||||
from dashboard.lib import cloud_promo as cp
|
||||
|
||||
|
||||
class TestNextTourState:
|
||||
total = len(cp.TOUR_SLIDES)
|
||||
|
||||
def test_trigger_opens_at_first_slide(self):
|
||||
assert cp.next_tour_state(cp.TRIGGER_ID, False, 3, self.total) == (True, 0)
|
||||
|
||||
def test_close_keeps_index_and_closes(self):
|
||||
assert cp.next_tour_state(cp.CLOSE_ID, True, 2, self.total) == (False, 2)
|
||||
|
||||
def test_backdrop_closes(self):
|
||||
assert cp.next_tour_state(cp.BACKDROP_ID, True, 1, self.total) == (False, 1)
|
||||
|
||||
def test_next_advances_and_wraps(self):
|
||||
assert cp.next_tour_state(cp.NEXT_ID, True, 0, self.total) == (True, 1)
|
||||
assert cp.next_tour_state(cp.NEXT_ID, True, self.total - 1, self.total) == (
|
||||
True,
|
||||
0,
|
||||
)
|
||||
|
||||
def test_prev_goes_back_and_wraps(self):
|
||||
assert cp.next_tour_state(cp.PREV_ID, True, 1, self.total) == (True, 0)
|
||||
assert cp.next_tour_state(cp.PREV_ID, True, 0, self.total) == (
|
||||
True,
|
||||
self.total - 1,
|
||||
)
|
||||
|
||||
def test_unknown_trigger_is_noop(self):
|
||||
assert cp.next_tour_state("whatever", True, 2, self.total) == (True, 2)
|
||||
|
||||
def test_empty_tour_stays_closed(self):
|
||||
assert cp.next_tour_state(cp.NEXT_ID, True, 0, 0) == (False, 0)
|
||||
|
||||
|
||||
class TestSlides:
|
||||
def test_every_slide_has_image_and_copy(self):
|
||||
for slide in cp.TOUR_SLIDES:
|
||||
assert slide["image"].startswith("/assets/images/cloud/")
|
||||
assert slide["title"]
|
||||
assert slide["text"]
|
||||
assert slide["eyebrow"]
|
||||
|
||||
def test_exactly_one_closing_cta(self):
|
||||
with_cta = [s for s in cp.TOUR_SLIDES if s.get("cta")]
|
||||
assert len(with_cta) == 1
|
||||
assert with_cta[0] is cp.TOUR_SLIDES[-1]
|
||||
|
||||
|
||||
class TestLayout:
|
||||
def test_card_uses_trigger_id(self):
|
||||
card = cp.cloud_promo_card()
|
||||
# The clickable element carries the trigger id used by the callback.
|
||||
ids = _collect_ids(card)
|
||||
assert cp.TRIGGER_ID in ids
|
||||
|
||||
def test_modal_exposes_stores_and_controls(self):
|
||||
modal = cp.cloud_tour_modal()
|
||||
ids = _collect_ids(modal)
|
||||
for expected in (
|
||||
cp.OVERLAY_ID,
|
||||
cp.OPEN_STORE_ID,
|
||||
cp.INDEX_STORE_ID,
|
||||
cp.CLOSE_ID,
|
||||
cp.BACKDROP_ID,
|
||||
cp.PREV_ID,
|
||||
cp.NEXT_ID,
|
||||
"cloud-tour-image",
|
||||
):
|
||||
assert expected in ids
|
||||
|
||||
|
||||
def _collect_ids(component):
|
||||
"""Walk a Dash component tree collecting every string id."""
|
||||
ids = []
|
||||
|
||||
def visit(node):
|
||||
comp_id = getattr(node, "id", None)
|
||||
if isinstance(comp_id, str):
|
||||
ids.append(comp_id)
|
||||
children = getattr(node, "children", None)
|
||||
if children is None:
|
||||
return
|
||||
if isinstance(children, (list, tuple)):
|
||||
for child in children:
|
||||
visit(child)
|
||||
else:
|
||||
visit(children)
|
||||
|
||||
visit(component)
|
||||
return ids
|
||||