Compare commits

...

1 Commits

Author SHA1 Message Date
Alan Buscaglia 934c61b24b feat(dashboard): add Prowler Cloud promo tour
- Add sidebar hero card opening an image-only product tour
- Showcase Overview, Attack Paths, Lighthouse AI, scans, compliance,
  integrations and alerts with real new-app screenshots
- Close with importing existing CLI findings and a Start free CTA
2026-06-10 19:01:07 +02:00
13 changed files with 704 additions and 15 deletions
+13 -15
View File
@@ -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)
+269
View File
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+328
View File
@@ -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,
)
View File
+94
View File
@@ -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