diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/app.yaml b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/app.yaml index 69a6c46..e3d4fa0 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/app.yaml +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/app.yaml @@ -13,3 +13,5 @@ env: value: databricks_postgres - name: LAKEBASE_INSTANCE value: campaign-pacing + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: campaign-pacing-warehouse diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/.gitignore b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/.gitignore new file mode 100644 index 0000000..4609e81 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.local +.vite/ diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/index.html b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/index.html index 1e9a012..2c6c3c2 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/index.html +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/index.html @@ -3,7 +3,9 @@ - Campaign Pacing + + + Databricks: Campaign Pacing
diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/package-lock.json b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/package-lock.json index 8f29029..5846394 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/package-lock.json +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/package-lock.json @@ -68,6 +68,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1246,6 +1247,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1406,6 +1408,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1820,6 +1823,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2064,6 +2068,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2233,6 +2238,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2554,6 +2560,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2639,6 +2646,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/MegaCorp_Logo_-_Transparent.png b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/MegaCorp_Logo_-_Transparent.png new file mode 100644 index 0000000..0aac6f7 Binary files /dev/null and b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/MegaCorp_Logo_-_Transparent.png differ diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/apple-touch-icon.png b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..6c5533a Binary files /dev/null and b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/apple-touch-icon.png differ diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/favicon-32x32.png b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..2cf6739 Binary files /dev/null and b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/favicon-32x32.png differ diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apache-spark.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apache-spark.svg new file mode 100644 index 0000000..0c12361 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apache-spark.svg @@ -0,0 +1,13 @@ + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apps.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apps.svg new file mode 100644 index 0000000..cb30c1e --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/data-streaming.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/data-streaming.svg new file mode 100644 index 0000000..62e0b96 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/data-streaming.svg @@ -0,0 +1,4 @@ + + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/databricks-symbol.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/databricks-symbol.svg new file mode 100644 index 0000000..ab7e64b --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/databricks-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/delta-lake.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/delta-lake.svg new file mode 100644 index 0000000..3dfb0da --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/delta-lake.svg @@ -0,0 +1,3 @@ + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/kafka.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/kafka.svg new file mode 100644 index 0000000..dc2b7b8 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/kafka.svg @@ -0,0 +1 @@ +Apache Kafka \ No newline at end of file diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/lakebase.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/lakebase.svg new file mode 100644 index 0000000..07dff8f --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/lakebase.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/unity-catalog.svg b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/unity-catalog.svg new file mode 100644 index 0000000..694b1ae --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/public/icons/unity-catalog.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/App.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/App.tsx index b24c324..bd78c6a 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/App.tsx +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/App.tsx @@ -1,28 +1,5 @@ -import CampaignDashboard from "./components/CampaignDashboard"; +import { AppLayout } from "./components/AppLayout"; export default function App() { - return ( -
-
-
-
-

- Campaign Pacing -

-

- Real-time impression tracking · powered by Spark RTM + Lakebase -

-
- - - Live - -
-
- -
- -
-
- ); + return ; } diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/AppLayout.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..bf8453e --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/AppLayout.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { LeftNav, type NavPage } from "./LeftNav"; +import CampaignDashboard from "./CampaignDashboard"; +import { SettingsPage } from "./SettingsPage"; +import { ArchitecturePage } from "./ArchitecturePage"; +import { PageHeader } from "./PageHeader"; +import { useTheme } from "../lib/theme"; + +export const AppLayout: React.FC = () => { + const [navExpanded, setNavExpanded] = useState(false); + const [page, setPage] = useState("monitoring"); + const { theme, toggleTheme } = useTheme(); + + return ( +
+ +
+
+ {page === "monitoring" && ( + <> + + + + Live + + +
+
+ setPage("architecture")} + /> +
+
+ + )} + {page === "architecture" && } + {page === "settings" && ( + + )} +
+
+ Powered by Databricks + Databricks +
+
+
+ ); +}; + +export default AppLayout; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/ArchitecturePage.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/ArchitecturePage.tsx new file mode 100644 index 0000000..fe0fc89 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/ArchitecturePage.tsx @@ -0,0 +1,357 @@ +import React from "react"; +import { PageHeader } from "./PageHeader"; + +const WORKSPACE_HOST = "https://e2-demo-field-eng.cloud.databricks.com"; + +interface Node { + id: string; + label: string; + sub: string; + description?: string; + tone: "navy" | "blue" | "lava"; + icon?: string; + href?: string; +} + +const TONE_CLASSES: Record = { + navy: "border-databricks-navy-300 bg-databricks-navy-300/20 dark:bg-databricks-navy-800/30 dark:border-databricks-navy-700", + blue: "border-blue-300 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800", + lava: "border-databricks-lava-300 bg-databricks-lava-300/20 dark:bg-databricks-lava-800/30 dark:border-databricks-lava-700", +}; + +const LinkOutIcon: React.FC = () => ( + + + +); + +const NodeBox: React.FC<{ n: Node }> = ({ n }) => { + const inner = ( +
+ {n.icon && ( + + )} +
+

+ {n.label} + {n.href && ( + + + + )} +

+

+ {n.sub} +

+ {n.description && ( +

+ {n.description} +

+ )} +
+
+ ); + return n.href ? ( + + {inner} + + ) : ( + inner + ); +}; + +const ArrowDown: React.FC<{ label?: string }> = ({ label }) => ( +
+ {label && ( + + {label} + + )} + + + +
+); + +const ArrowRight: React.FC<{ label?: string }> = ({ label }) => ( +
+ {label && ( + + {label} + + )} + + + +
+); + +interface KeyTech { + label: string; + desc: string; + href?: string; + icon?: string; +} + +const KEY_TECH: KeyTech[] = [ + { + label: "Real-Time Mode (RTM)", + desc: "Sub-second Structured Streaming trigger for low-latency ingest.", + href: "https://docs.databricks.com/aws/en/structured-streaming/real-time", + icon: "/icons/apache-spark.svg", + }, + { + label: "jdbcStreaming Sink (Private Preview)", + desc: "Streaming connector — upserts each microbatch into Lakebase via Postgres ON CONFLICT.", + icon: "/icons/databricks-symbol.svg", + }, + { + label: "Lakebase Provisioned", + desc: "Managed Postgres for OLTP. OAuth tokens refreshed automatically each hour.", + href: "https://docs.databricks.com/aws/en/oltp/instances/about", + icon: "/icons/lakebase.svg", + }, + { + label: "Databricks Apps", + desc: "Serverless host for the FastAPI + React app. Resources declared in the bundle.", + href: "https://docs.databricks.com/aws/en/dev-tools/databricks-apps/", + icon: "/icons/apps.svg", + }, + { + label: "Unity Catalog", + desc: "Governs the Delta table that supplies segment definitions.", + href: "https://docs.databricks.com/aws/en/data-governance/unity-catalog/", + icon: "/icons/unity-catalog.svg", + }, + { + label: "Delta Lake", + desc: "Source of segment definitions joined to each campaign at read time.", + href: "https://docs.databricks.com/aws/en/delta/", + icon: "/icons/delta-lake.svg", + }, + { + label: "Kafka source", + desc: "Built-in Spark Kafka connector for ingestion in batch or streaming.", + href: "https://docs.databricks.com/aws/en/structured-streaming/kafka", + icon: "/icons/kafka.svg", + }, +]; + +export const ArchitecturePage: React.FC = () => { + return ( +
+ +
+
+ {/* Main vertical flow */} +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ {/* Databricks App container — wraps backend + frontend */} +
+
+ + + Databricks App + +
+ + + + +
+ + {/* joins arrow — vertically aligned with FastAPI row */} +
+ +
+ + {/* Delta table — external (UC-governed) */} +
+ +
+
+

+ The FastAPI backend reads pacing state from Lakebase and joins + each campaign with its segment_definition from the + Unity Catalog–governed Delta table via a SQL warehouse. +

+
+
+ + {/* Legend */} +
+

+ Key technologies +

+
+ {KEY_TECH.map((t) => ( +
+ {t.icon && ( + + )} +
+
+ {t.href ? ( + + {t.label} + + ) : ( + t.label + )} +
+
+ {t.desc} +
+
+
+ ))} +
+
+
+
+
+ ); +}; + +export default ArchitecturePage; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/CampaignDashboard.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/CampaignDashboard.tsx index a22b772..4b1bb9b 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/CampaignDashboard.tsx +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/CampaignDashboard.tsx @@ -11,6 +11,7 @@ interface Campaign { pacing_pct: number; status: "ACTIVE" | "PACING_FAST" | "STOPPED"; last_updated: string | null; + segment_definition: string | null; } async function fetchCampaigns(): Promise { @@ -19,24 +20,39 @@ async function fetchCampaigns(): Promise { return res.json(); } -// --------------------------------------------------------------------------- -// Color helpers -// --------------------------------------------------------------------------- - function barColor(pacing_pct: number, status: Campaign["status"]): string { - if (status === "STOPPED") return "bg-gray-600"; + if (status === "STOPPED") return "bg-gray-400 dark:bg-gray-600"; if (pacing_pct >= 80) return "bg-red-500"; if (pacing_pct >= 50) return "bg-amber-400"; return "bg-emerald-500"; } function statusBadge(status: Campaign["status"]) { - const base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"; + const base = + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"; if (status === "STOPPED") - return STOPPED; + return ( + + STOPPED + + ); if (status === "PACING_FAST") - return PACING FAST; - return ACTIVE; + return ( + + PACING FAST + + ); + return ( + + ACTIVE + + ); } function fmt(n: number) { @@ -47,64 +63,75 @@ function fmtDollars(n: number) { return `$${n.toFixed(2)}`; } -// --------------------------------------------------------------------------- -// Card -// --------------------------------------------------------------------------- - function CampaignCard({ c }: { c: Campaign }) { const pct = Math.min(c.pacing_pct, 100); - const updatedAgo = - c.last_updated - ? formatDistanceToNow(new Date(c.last_updated), { addSuffix: true }) - : "—"; + const updatedAgo = c.last_updated + ? formatDistanceToNow(new Date(c.last_updated), { addSuffix: true }) + : "—"; const stopped = c.status === "STOPPED"; return (
- {/* Header */}
-
-

{c.campaign_name}

-

Updated {updatedAgo}

+
+

+ {c.campaign_name} +

+

+ Updated {updatedAgo} +

{statusBadge(c.status)}
- {/* Progress bar */} + {c.segment_definition && ( +

+ {c.segment_definition} +

+ )} +
-
+
Pacing - {c.pacing_pct.toFixed(1)}% + + {c.pacing_pct.toFixed(1)}% +
-
+
- {/* Stats row */}
-
+
Impressions - + {fmt(c.impression_count)} - / {fmt(c.budget_imps)} + + {" "}/ {fmt(c.budget_imps)} +
-
+
Spend - + {fmtDollars(c.spend_dollars)} - / {fmtDollars(c.budget_dollars)} + + {" "}/ {fmtDollars(c.budget_dollars)} +
@@ -112,11 +139,13 @@ function CampaignCard({ c }: { c: Campaign }) { ); } -// --------------------------------------------------------------------------- -// Dashboard -// --------------------------------------------------------------------------- +interface CampaignDashboardProps { + onNavigateToArchitecture?: () => void; +} -export default function CampaignDashboard() { +export default function CampaignDashboard({ + onNavigateToArchitecture, +}: CampaignDashboardProps = {}) { const queryClient = useQueryClient(); const [resetting, setResetting] = useState(false); @@ -140,7 +169,10 @@ export default function CampaignDashboard() { return (
{[...Array(5)].map((_, i) => ( -
+
))}
); @@ -148,7 +180,7 @@ export default function CampaignDashboard() { if (isError || !campaigns) { return ( -

+

Failed to load campaign data — is the API reachable?

); @@ -156,30 +188,64 @@ export default function CampaignDashboard() { const active = campaigns.filter((c) => c.status !== "STOPPED").length; const stopped = campaigns.filter((c) => c.status === "STOPPED").length; - - // Already sorted by pacing_pct desc from API, but sort here too for safety const sorted = [...campaigns].sort((a, b) => b.pacing_pct - a.pacing_pct); return (
- {/* Summary bar */} +
+

+ + Imagine you are a campaign manager + {" "} + who cares about whether your live campaigns are burning budget at + the right rate and staying within their contractual frequency caps. + This app leverages{" "} + + Lakebase + Spark RTM + {" "} + to surface pacing decisions within ~2 seconds of an impression + being served — fast enough to pause an over-pacing campaign before + the money is wasted, instead of finding out hours later. +

+

+ To learn more about what's going on under the hood, visit the{" "} + {onNavigateToArchitecture ? ( + + ) : ( + Architecture page + )} + . +

+
+
- {active} Active - · - {stopped} Stopped + + {active} Active + + · + + {stopped} Stopped + - Refreshes every 2s + + Refreshes every 2s +
- {/* Campaign cards */}
{sorted.map((c) => ( diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/LeftNav.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/LeftNav.tsx new file mode 100644 index 0000000..1aef811 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/LeftNav.tsx @@ -0,0 +1,142 @@ +import React from "react"; + +export type NavPage = "monitoring" | "architecture" | "settings"; + +export interface LeftNavProps { + currentPage: NavPage; + onNavigate: (page: NavPage) => void; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; +} + +const ChevronLeft = () => ( + + + +); + +const ChevronRight = () => ( + + + +); + +const PulseIcon = () => ( + + + +); + +const ArchitectureIcon = () => ( + + + + + + + +); + +const GearIcon = () => ( + + + +); + +const NAV_ITEMS: { id: NavPage; label: string; icon: React.FC }[] = [ + { id: "monitoring", label: "Realtime Monitoring", icon: PulseIcon }, + { id: "architecture", label: "Architecture", icon: ArchitectureIcon }, +]; + +export const LeftNav: React.FC = ({ + currentPage, + onNavigate, + expanded, + onExpandedChange, +}) => { + return ( + + ); +}; + +export default LeftNav; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/PageHeader.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..9f9bc22 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/PageHeader.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +const LOGO_SRC = "/MegaCorp_Logo_-_Transparent.png"; + +export interface PageHeaderProps { + title: string; + description?: string; + children?: React.ReactNode; +} + +export const PageHeader: React.FC = ({ + title, + description, + children, +}) => { + return ( +
+
+
+ MegaCorp +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {children != null &&
{children}
} +
+ ); +}; + +export default PageHeader; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/SettingsPage.tsx b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/SettingsPage.tsx new file mode 100644 index 0000000..12c36f3 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/components/SettingsPage.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { PageHeader } from "./PageHeader"; +import type { Theme } from "../lib/theme"; + +const WORKSPACE_HOST = "https://e2-demo-field-eng.cloud.databricks.com"; +const LAKEBASE_INSTANCE_NAME = "campaign-pacing"; + +interface ResourceLink { + label: string; + description: string; + href: string; + detail?: string; +} + +const LAKEBASE_RESOURCES: ResourceLink[] = [ + { + label: "Lakebase instance", + description: `PostgreSQL state store — ${LAKEBASE_INSTANCE_NAME}`, + href: `${WORKSPACE_HOST}/compute/database-instances/${LAKEBASE_INSTANCE_NAME}`, + detail: "databricks_postgres", + }, + { + label: "campaigns table", + description: "Per-campaign pacing state (impressions, budget, status)", + href: `${WORKSPACE_HOST}/compute/database-instances/${LAKEBASE_INSTANCE_NAME}/databases/databricks_postgres/schemas/public/tables/campaigns`, + detail: "databricks_postgres.public.campaigns", + }, + { + label: "segment_definitions (Delta / UC)", + description: "Source of segment_definition text shown on each card", + href: `${WORKSPACE_HOST}/explore/data/media_advertising/segments/megacorp_segment_definitions`, + detail: "media_advertising.segments.megacorp_segment_definitions", + }, +]; + +const PIPELINE_RESOURCES: ResourceLink[] = [ + { + label: "kafka_producer_job", + description: "Generates synthetic impression events into Kafka", + href: `${WORKSPACE_HOST}/jobs?searchTerm=kafka_producer_job`, + detail: "Topic: tanner_wendland_adtech_impressions_realtime_v3", + }, + { + label: "campaign_pacing_job", + description: "Real-Time Mode stream — Kafka → Lakebase via jdbcStreaming", + href: `${WORKSPACE_HOST}/jobs?searchTerm=campaign_pacing_job`, + detail: "Sink: campaign-pacing (Lakebase)", + }, +]; + +const ExternalLinkIcon = () => ( + + + +); + +const SunIcon = () => ( + + + +); + +const MoonIcon = () => ( + + + +); + +const ResourceRow: React.FC<{ r: ResourceLink }> = ({ r }) => ( + +
+

+ {r.label} + + + +

+

+ {r.description} +

+ {r.detail && ( +

+ {r.detail} +

+ )} +
+
+); + +interface SettingsPageProps { + theme: Theme; + onToggleTheme: () => void; +} + +export const SettingsPage: React.FC = ({ + theme, + onToggleTheme, +}) => { + const isDark = theme === "dark"; + + return ( +
+ +
+
+
+

+ Appearance +

+

+ Switch between light and dark themes. Preference is saved locally. +

+
+
+ + {isDark ? : } + + + {isDark ? "Dark mode" : "Light mode"} + +
+ +
+
+ +
+

+ Lakebase & Unity Catalog +

+

+ Tables read by this app. OAuth tokens to Lakebase are refreshed + automatically each hour. +

+
+ {LAKEBASE_RESOURCES.map((r) => ( + + ))} +
+
+ +
+

+ Pipelines +

+

+ Jobs that feed the pacing dashboard. Deploy via{" "} + + bash scripts/deploy.sh deploy + {" "} + from the bundle root. +

+
+ {PIPELINE_RESOURCES.map((r) => ( + + ))} +
+
+ +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/index.css b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/index.css index b5c61c9..3168740 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/index.css +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/index.css @@ -1,3 +1,89 @@ @tailwind base; @tailwind components; @tailwind utilities; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +#root { + isolation: isolate; + height: 100vh; +} + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background-color: #f5f5f5; +} + +html.dark body { + background-color: #030712; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/lib/theme.ts b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/lib/theme.ts new file mode 100644 index 0000000..b1e7cd3 --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/src/lib/theme.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from "react"; + +export type Theme = "light" | "dark"; + +const STORAGE_KEY = "campaign-pacing-theme"; + +function readInitialTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function applyTheme(theme: Theme) { + const root = document.documentElement; + if (theme === "dark") root.classList.add("dark"); + else root.classList.remove("dark"); +} + +export function useTheme() { + const [theme, setThemeState] = useState(readInitialTheme); + + useEffect(() => { + applyTheme(theme); + window.localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const setTheme = useCallback((next: Theme) => setThemeState(next), []); + const toggleTheme = useCallback( + () => setThemeState((prev) => (prev === "dark" ? "light" : "dark")), + [], + ); + + return { theme, setTheme, toggleTheme }; +} diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/tailwind.config.ts b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/tailwind.config.ts index 74eab37..0cc87d8 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/tailwind.config.ts +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/frontend/tailwind.config.ts @@ -1,9 +1,45 @@ import type { Config } from "tailwindcss"; +// Palette sourced from adtech-measurement/docs/styleguide/colors.json +// (Databricks Extended Brand Guidelines). export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", theme: { - extend: {}, + extend: { + colors: { + databricks: { + orange: "#FF3621", + dark: "#1B3139", + lava: { + 300: "#FABFBA", + 400: "#FF9E94", + 500: "#FF5F46", + 600: "#FF3621", + 700: "#BD2B26", + 800: "#801C17", + }, + navy: { + 300: "#C4CCD6", + 400: "#90A5B1", + 500: "#618794", + 600: "#1B5162", + 700: "#143D4A", + 800: "#1B3139", + 900: "#0B2026", + }, + oat: { + light: "#F9F7F4", + medium: "#EEEDE9", + }, + gray: { + navigation: "#303F47", + text: "#5A6F77", + lines: "#DCE0E2", + }, + }, + }, + }, }, plugins: [], } satisfies Config; diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/api/campaigns.py b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/api/campaigns.py index fcda4c7..2077e41 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/api/campaigns.py +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/api/campaigns.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from ..core.db import get_db +from ..core.definitions import get_definitions from ..models import CampaignPacing from ..seed import DEMO_CAMPAIGNS @@ -21,6 +22,7 @@ class CampaignResponse(BaseModel): pacing_pct: float status: Literal["ACTIVE", "PACING_FAST", "STOPPED"] last_updated: datetime | None + segment_definition: str | None = None def _compute_status(pacing_pct: float) -> Literal["ACTIVE", "PACING_FAST", "STOPPED"]: @@ -38,6 +40,7 @@ def get_campaigns(db: Session = Depends(get_db)) -> list[CampaignResponse]: Sorted by pacing_pct descending (most at-risk on top). """ rows = db.query(CampaignPacing).all() + definitions = get_definitions() results: list[CampaignResponse] = [] for row in rows: @@ -64,6 +67,7 @@ def get_campaigns(db: Session = Depends(get_db)) -> list[CampaignResponse]: pacing_pct=pacing_pct, status=_compute_status(pacing_pct), last_updated=last_updated, + segment_definition=definitions.get(row.campaign_name), ) ) diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/config.py b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/config.py index 7b7a3f1..8e7abc0 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/config.py +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/config.py @@ -8,5 +8,10 @@ class Settings(BaseSettings): PGDATABASE: str = "databricks_postgres" LAKEBASE_INSTANCE: str = "campaign-pacing" + DATABRICKS_WAREHOUSE_ID: str = "" + SEGMENT_DEFINITIONS_TABLE: str = ( + "media_advertising.segments.megacorp_segment_definitions" + ) + settings = Settings() diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/definitions.py b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/definitions.py new file mode 100644 index 0000000..fbb4a3f --- /dev/null +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/core/definitions.py @@ -0,0 +1,65 @@ +""" +Fetches campaign segment definitions from the Delta table via SQL warehouse. + +The result is cached in-process for SEGMENT_DEFINITIONS_CACHE_SECONDS to avoid +hitting the warehouse on every dashboard refresh (2s poll cadence). +""" +import logging +import time +from typing import Dict + +from databricks.sdk import WorkspaceClient +from databricks.sdk.service.sql import StatementState + +from .config import settings + +logger = logging.getLogger(__name__) + +_CACHE: Dict[str, str] = {} +_CACHE_LOADED_AT: float = 0.0 +_CACHE_TTL_SECONDS = 300 + +_client = WorkspaceClient() + + +def _fetch_definitions() -> Dict[str, str]: + if not settings.DATABRICKS_WAREHOUSE_ID: + logger.info("DATABRICKS_WAREHOUSE_ID not set; segment definitions disabled.") + return {} + + sql = ( + f"SELECT segment_name, segment_definition " + f"FROM {settings.SEGMENT_DEFINITIONS_TABLE} " + f"WHERE segment_definition IS NOT NULL" + ) + + resp = _client.statement_execution.execute_statement( + warehouse_id=settings.DATABRICKS_WAREHOUSE_ID, + statement=sql, + wait_timeout="30s", + ) + + if resp.status and resp.status.state != StatementState.SUCCEEDED: + logger.warning("Definitions query failed: %s", resp.status) + return {} + + out: Dict[str, str] = {} + if resp.result and resp.result.data_array: + for row in resp.result.data_array: + if row and len(row) >= 2 and row[0] and row[1]: + out[row[0]] = row[1] + return out + + +def get_definitions() -> Dict[str, str]: + """Return {segment_name: segment_definition}, cached for 5 minutes.""" + global _CACHE, _CACHE_LOADED_AT + now = time.time() + if not _CACHE or (now - _CACHE_LOADED_AT) > _CACHE_TTL_SECONDS: + try: + _CACHE = _fetch_definitions() + _CACHE_LOADED_AT = now + logger.info("Loaded %d segment definitions.", len(_CACHE)) + except Exception: + logger.exception("Failed to load segment definitions; keeping stale cache.") + return _CACHE diff --git a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/main.py b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/main.py index ab483bd..90380cc 100644 --- a/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/main.py +++ b/adtech_series_sp26/impression_logs_realtime/campaign_pacing_app/src/main.py @@ -47,7 +47,11 @@ async def lifespan(app: FastAPI): @app.get("/{full_path:path}", include_in_schema=False, response_model=None) async def serve_spa(full_path: str): - """SPA fallback — serve index.html for all non-API routes.""" + """Serve static files from dist if they exist (favicon, logos, etc.), else SPA fallback.""" + if full_path: + candidate = os.path.normpath(os.path.join(_FRONTEND_DIST, full_path)) + if candidate.startswith(_FRONTEND_DIST) and os.path.isfile(candidate): + return FileResponse(candidate) index = os.path.join(_FRONTEND_DIST, "index.html") if os.path.isfile(index): return FileResponse(index) diff --git a/adtech_series_sp26/impression_logs_realtime/resources/campaign_pacing_app.yml b/adtech_series_sp26/impression_logs_realtime/resources/campaign_pacing_app.yml index 02e0179..3c2c7cd 100644 --- a/adtech_series_sp26/impression_logs_realtime/resources/campaign_pacing_app.yml +++ b/adtech_series_sp26/impression_logs_realtime/resources/campaign_pacing_app.yml @@ -12,6 +12,11 @@ resources: database_name: "databricks_postgres" instance_name: ${resources.database_instances.campaign_pacing_instance.name} permission: "CAN_CONNECT_AND_CREATE" + - name: "campaign-pacing-warehouse" + description: "SQL warehouse for joining segment definitions from Delta" + sql_warehouse: + id: e9b34f7a2e4b0561 + permission: "CAN_USE" permissions: - level: CAN_USE