Build Jozi Transit Live
Build a live transit dashboard with AWS IoT, DynamoDB, Lambda, and an API.
Introduction
30 Second Summary
When bus updates live in separate places, operators struggle to spot delays quickly. A single live view turns those updates into decisions.
In this project, you will build Jozi Transit Live, a fictional Johannesburg dashboard for three simulated buses. Vehicle updates flow through AWS IoT Core into the browser as cloud-backed positions.
What You'll Build
Your final demo shows three simulated buses moving along a visual route using live cloud data.
By the end of this project, you'll have:
- A polished offline transit dashboard that you can open locally to watch three fictional buses move.
- A live telemetry flow that you can run from Windows PowerShell to send vehicle updates through the cloud into Amazon DynamoDB.
- A cloud-backed read API that returns each vehicle's latest update through AWS Lambda plus Amazon API Gateway.
- Secret Mission: Add a delayed-vehicle filter that helps operators focus on buses with service problems.
Are there any prerequisites?
You need a Windows computer plus an AWS account that permits cloud resource creation. Local setup includes credential verification.
Before We Start
This is your moment to commit to the prototype before any hands-on work begins. The fictional Johannesburg fleet dashboard will help transit operators see fleet conditions in one place.
Set Up the Windows Cloud Toolchain
Jozi Transit Live eventually sends deployment commands from your computer into AWS. Unverified tools or credentials could send those commands to the wrong account.
This step prepares a repeatable Windows toolchain. You will confirm every installed tool before you build the dashboard.
In this step, get ready to:
- Prepare AWS credentials with the required deployment permissions.
- Install the pinned Node.js, AWS CLI, and AWS SAM CLI releases.
- Configure the named profile and verify the complete toolchain.
Prepare your AWS access
Cloud deployment commands run under an AWS identity with specific permissions. Your named profile keeps this project's credentials separate from other AWS command contexts.
Credentials deserve extra care. You will store them outside the project files and enter them only during profile configuration.
- Create an AWS account in your web browser if you need one.
- Sign in to the AWS account you plan to use.
- Obtain CLI credentials for an identity that can deploy this project.
- Confirm that the identity has the permissions listed below.
- Store the credential values in a password manager until profile configuration.
Which Permissions Are Required?
Your deployment identity needs access to every service created by the backend. These permissions allow the later AWS SAM deployment to create the complete stack.
- Permission to create and manage the project stack with AWS CloudFormation.
- Permission to create project roles with IAM.
- Permission to create the telemetry rule with AWS IoT Core.
- Permission to create the telemetry table with Amazon DynamoDB.
- Permission to create the read function with AWS Lambda.
- Permission to create the HTTP endpoint with Amazon API Gateway.
- Permission to create runtime logs with CloudWatch Logs.
- Permission to store deployment artifacts with Amazon S3.
Install the pinned cloud tools
Windows PowerShell gives you one command environment for the local toolchain. Pinned releases keep this walkthrough aligned with the commands on your computer.
- Press the Windows key to open the Windows search bar.
- Type PowerShell.
- Select Windows PowerShell from the search results.
Node.js runs the backend code packaged later by AWS SAM. Its installer also provides the npm package manager.
- Check the installed Node.js release by running this command:
node --version
What Does This Check Do?
This command asks Node.js to report its installed release. This project uses v24.22.0 LTS.
✔️ I see the required Node.js version
Node.js is ready when the output shows v24.22.0. The same installation also makes npm available.
ⓧ I see an older Node.js version
The installed runtime is older than the release used by this project. Update it with the official Windows installer.
- Visit the official Node.js download page.
- Download the Windows installer for Node.js 24.22.0 LTS.
- Run the downloaded installer.
- Keep the installer defaults.
- Close the current PowerShell window.
- Use Windows search to open a new Windows PowerShell window.
- Confirm the updated release by running this command:
node --version
What Does This Check Do?
A new PowerShell window reloads the commands registered by the installer. You should now see v24.22.0.
ⓧ Node.js command not found
PowerShell cannot find Node.js because it has not been installed for your Windows account. The official installer adds the runtime and npm.
- Visit the official Node.js download page.
- Download the Windows installer for Node.js 24.22.0 LTS.
- Run the downloaded installer.
- Keep the installer defaults.
- Close the current PowerShell window.
- Use Windows search to open a new Windows PowerShell window.
- Confirm the installation by running this command:
node --version
What Does This Check Do?
This check confirms that PowerShell can find the newly installed runtime. You should see v24.22.0.
AWS CLI sends authenticated commands from PowerShell to your AWS account. The walkthrough uses release 2.38.1.
- Check the installed AWS CLI release by running this command:
aws --version
What Does This Check Do?
This command reports the AWS CLI release available in the current PowerShell window. Look for version 2.38.1 in the output.
✔️ I see the required AWS CLI version
The AWS CLI is ready when its output includes 2.38.1.
ⓧ I see an older AWS CLI version
The current-user installation script updates the AWS CLI available to your Windows account.
- Install AWS CLI 2.38.1 by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex
What Does This Command Do?
PowerShell downloads the official installation script from AWS. The script installs AWS CLI for your current Windows user.
- Close the current PowerShell window when the installation completes.
- Use Windows search to open a new Windows PowerShell window.
- Confirm the updated release by running this command:
aws --version
What Does This Check Do?
The new PowerShell window reloads the installed command. You should see AWS CLI version 2.38.1 in the output.
ⓧ AWS CLI command not found
AWS CLI is not available to PowerShell yet. The official script installs it for your current Windows user.
- Install AWS CLI 2.38.1 by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex
What Does This Command Do?
PowerShell downloads the official installation script from AWS. The script adds AWS CLI to your current Windows user.
- Close the current PowerShell window when the installation completes.
- Use Windows search to open a new Windows PowerShell window.
- Confirm that PowerShell can find AWS CLI by running this command:
aws --version
What Does This Check Do?
A successful check reports the installed AWS CLI release. Look for version 2.38.1 in the output.
AWS SAM CLI builds and packages the serverless backend before deployment. This project uses release 1.169.0.
- Check the installed AWS SAM CLI release by running this command:
sam --version
What Does This Check Do?
This command reports the AWS SAM CLI release available in PowerShell. Look for version 1.169.0.
✔️ I see the required AWS SAM CLI version
AWS SAM CLI is ready when its output includes 1.169.0.
ⓧ I see an older AWS SAM CLI version
The pinned Windows installer updates AWS SAM CLI to the release used by this walkthrough.
- Download the official AWS SAM CLI installer.
- Confirm that the downloaded file is named AWS_SAM_CLI_64_PY3.msi.
- Run AWS_SAM_CLI_64_PY3.msi from your Downloads folder.
- Keep the installer defaults.
- Close the current PowerShell window.
- Use Windows search to open a new Windows PowerShell window.
- Confirm the updated release by running this command:
sam --version
What Does This Check Do?
The new PowerShell window reloads the updated command. You should see version 1.169.0.
ⓧ AWS SAM CLI command not found
AWS SAM CLI has not been installed yet. The official Windows installer adds the sam command to new PowerShell windows.
- Download the official AWS SAM CLI installer.
- Confirm that the downloaded file is named AWS_SAM_CLI_64_PY3.msi.
- Run AWS_SAM_CLI_64_PY3.msi from your Downloads folder.
- Keep the installer defaults.
- Close the current PowerShell window.
- Use Windows search to open a new Windows PowerShell window.
- Confirm the installation by running this command:
sam --version
What Does This Check Do?
This check confirms that PowerShell can find AWS SAM CLI. You should see version 1.169.0.
Still Having Installation Trouble?
Close every PowerShell window after an installer finishes. A window left open during installation may not know about the new command.
Check that you downloaded the required release if a version is still wrong. Run the matching installer again after confirming the version.
Help me diagnose my Windows cloud tool installation. You can also search the AWS re:Post community for Windows installation guidance.
Notepad is the plain-text editor used later in the project. Your web browser is already confirmed because you used it to reach the installer pages.
- Press the Windows key to open the Windows search bar.
- Type Notepad.
- Select Notepad from the search results.
You should see a blank Notepad editing window. This confirms that the editor is available for the project files.
- Close Notepad.
Configure the profile and verify the toolchain
A named AWS CLI profile connects commands to one set of credentials. The profile name jozi-transit keeps this project tied to the intended AWS identity.
The configuration command pauses for your credential values and command defaults. Keep the credentials private while you answer each prompt.
- Close the current PowerShell window.
- Press the Windows key to open the Windows search bar.
- Type PowerShell.
- Select Windows PowerShell from the search results.
- Have your CLI credential values ready in your password manager.
- Use eu-west-1 when the command asks for a default Region.
- Leave the default output format blank.
- Create the named AWS CLI profile by running this command:
aws configure --profile jozi-transit
What Does This Command Do?
AWS CLI creates a named profile called jozi-transit. Commands that include this profile name use the credentials you enter here.
The Region default keeps future commands aligned with the AWS Region used by this project. The profile configuration stays outside your project files.
- Enter your CLI credential values when prompted.
PowerShell returns to its prompt when the profile configuration finishes. Your credentials should never appear in project files or screenshots.
Profile Configuration Did Not Finish?
Confirm that AWS CLI reports version 2.38.1 in this PowerShell window. Restart the configuration command after checking that your credential values are complete.
Help me configure the named profile without sharing credentials. You can also ask the AWS re:Post community for AWS CLI configuration help.
Before you run the final check, which outputs should prove the tools are available? Which output should prove that AWS recognizes the named profile?
- Verify all five toolchain checks by running these commands:
aws --version
sam --version
node --version
npm --version
aws sts get-caller-identity --profile jozi-transit
What Do These Checks Prove?
- The first command confirms that AWS CLI 2.38.1 is available in the new PowerShell window.
- The second command confirms that AWS SAM CLI 1.169.0 is available.
- The third command confirms that Node.js v24.22.0 is available.
- The fourth command confirms that npm is available.
- The final command asks AWS to identify the caller associated with the jozi-transit profile.
You should see AWS CLI 2.38.1, AWS SAM CLI 1.169.0, Node.js v24.22.0, an npm version, and your AWS caller identity.
That is the toolchain ready. Your computer can now authenticate to the intended AWS account with every required local command available.
One of the Final Checks Failed?
Close PowerShell before opening another window if an installed command is missing. New windows reload commands added by the installers.
Configure the jozi-transit profile again if the caller identity check fails. Keep every credential value out of screenshots and help requests.
Help me diagnose the failed toolchain check.
Toolchain Checkpoint
- Configured AWS CLI named profile with verified caller identity and deployment permissions.
- Node.js 24.22.0 LTS, npm, AWS CLI 2.38.1, AWS SAM CLI 1.169.0, Windows PowerShell, Notepad, and a web browser are installed or available.
Your Windows cloud toolchain is ready for repeatable AWS deployments. Next up, you will build the offline dashboard that makes Jozi Transit Live visible.
Build the Offline Transit Dashboard
Your AWS toolchain is ready. You can now use the verified Windows PowerShell environment to start building Jozi Transit Live.
The dashboard gives you the product experience before any cloud infrastructure exists. You will combine HTML, CSS, and JavaScript to render three fictional buses and test the connection control.
In this step, get ready to:
- Create the local project structure and dashboard file.
- Render three demo buses with route markers and status cards.
- Test the dashboard without a cloud API connected.
Create the dashboard files
The project uses one named folder for its local files. The dashboard lives inside a dedicated dashboard folder so the backend files can sit beside it in the next step.
- Switch back to the new PowerShell window from the previous step.
- Create the jozi-transit-live project structure on your Desktop by running these commands:
Set-Location -Path $HOME\Desktop
New-Item -ItemType Directory -Path .\jozi-transit-live\dashboard -Force
Set-Location -Path .\jozi-transit-live
New-Item -ItemType File -Path .\dashboard\index.html -Force
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates jozi-transit-live with its nested dashboard folder.
- The final commands move into the project and create an empty dashboard/index.html file.
PowerShell lists the new dashboard directory and index.html file. Your local project structure now exists.
Folder command failed?
Confirm that your PowerShell prompt can access the Desktop. A redirected Desktop may live inside your organisation's managed folder.
Delete any regular file named jozi-transit-live before running the commands again. A file with that name prevents PowerShell from creating the project directory.
Help me fix the PowerShell folder creation command.
- Open dashboard/index.html in Notepad by running this command:
C:\Windows\System32\notepad.exe .\dashboard\index.html
What does this command do?
This launches Windows Notepad with the empty dashboard/index.html file. PowerShell remains in the jozi-transit-live project folder.
- Paste the document scaffold below into the empty Notepad file:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jozi Transit Live</title>
</head>
<body>
<main>
</main>
</body>
</html>
What does this scaffold do?
- The document metadata gives the page its browser tab title and responsive viewport.
- The main element provides the container for the dashboard interface.
- The closing tags keep the document valid while you add each visible section.
- Save dashboard/index.html by pressing Ctrl+S.
- Switch back to PowerShell.
- Open the saved page in your default browser by running this command:
Invoke-Item .\dashboard\index.html
What does this command do?
PowerShell asks Windows to open dashboard/index.html with its default application. Your web browser handles the .html file.
You will see a blank page with Jozi Transit Live in the browser tab. This confirms that the file path and document scaffold work.
Browser page did not open?
Confirm that the PowerShell prompt ends with jozi-transit-live. The relative path starts from that folder.
Save the file in Notepad before running the browser command again.
Help me open the local dashboard file.
- Switch back to dashboard/index.html in Notepad.
- Place the cursor on the line before </head>.
- Paste the dashboard styles below:
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #edf6ff;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #15314f, #07111f 55%); }
main { width: min(1000px, 92vw); margin: 0 auto; padding: 40px 0 64px; }
header { display: flex; justify-content: space-between; gap: 24px; align-items: end; flex-wrap: wrap; }
h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); }
.subtitle { color: #a7bdd2; max-width: 620px; }
.panel { margin-top: 24px; padding: 20px; border: 1px solid #284963; border-radius: 18px; background: rgba(7, 17, 31, 0.82); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); }
.controls { display: grid; grid-template-columns: 1fr auto auto; gap: 10px; }
input, button { border-radius: 10px; border: 1px solid #365d79; padding: 12px 14px; font: inherit; }
input { background: #0c1b2b; color: #edf6ff; }
button { background: #35d399; color: #03130d; font-weight: 750; cursor: pointer; }
button.secondary { background: #17324a; color: #edf6ff; }
#status { margin: 12px 0 0; color: #f6c76a; }
.route { position: relative; height: 110px; margin: 36px 12px 12px; }
.route::before { content: ""; position: absolute; left: 0; right: 0; top: 48px; height: 8px; border-radius: 99px; background: linear-gradient(90deg, #35d399, #4ba3ff); }
.stop { position: absolute; top: 37px; width: 28px; height: 28px; border: 7px solid #d9ecff; border-radius: 50%; background: #0c1b2b; transform: translateX(-50%); }
.stop.start { left: 0; }
.stop.middle { left: 50%; }
.stop.end { left: 100%; }
.bus { position: absolute; top: 6px; transform: translateX(-50%); padding: 7px 9px; border-radius: 9px; background: #edf6ff; color: #07111f; font-size: 0.76rem; font-weight: 800; transition: left 0.6s ease; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-top: 24px; }
.card { border: 1px solid #284963; border-radius: 14px; padding: 16px; background: #0c1b2b; }
.card.delayed { border-color: #ff786e; box-shadow: inset 4px 0 #ff786e; }
.card h2 { margin: 0 0 8px; font-size: 1.1rem; }
.metric { display: flex; justify-content: space-between; color: #a7bdd2; margin-top: 6px; gap: 12px; }
.metric strong { color: #edf6ff; text-align: right; }
@media (max-width: 700px) { .controls { grid-template-columns: 1fr; } }
</style>
How do these styles shape the dashboard?
- The page rules create the dark blue background and responsive content width.
- The route selectors draw a horizontal line with three circular stops.
- The bus and card selectors create the moving markers and vehicle summaries.
- The delayed card selector adds a red operational warning.
- Save dashboard/index.html.
- Switch back to the browser from earlier.
- Refresh the page.
You will see a dark blue gradient fill the page. The page is now styled for the dashboard interface.
Background still white?
Confirm that the entire style block sits before </head>.
Check that Notepad saved the file with the .html extension.
Help me find why the dashboard styles are missing.
- Switch back to dashboard/index.html in Notepad.
- Place the cursor between <main> and </main>.
- Paste the dashboard interface below:
<header>
<div>
<h1>Jozi Transit Live</h1>
<p class="subtitle">A fictional fleet telemetry dashboard built to demonstrate a startup-friendly AWS IoT architecture.</p>
</div>
</header>
<section class="panel">
<div class="controls">
<input id="apiUrl" type="url" placeholder="Paste TelemetryApiUrl here">
<button id="connectButton">Connect Live API</button>
<button id="refreshButton" class="secondary">Refresh</button>
</div>
<p id="status">Demo mode: cloud API not connected.</p>
<div id="route" class="route">
<span class="stop start"></span>
<span class="stop middle"></span>
<span class="stop end"></span>
</div>
<div id="cards" class="cards"></div>
</section>
What does this interface contain?
- The header names the fictional transit dashboard and describes its purpose.
- The controls collect a future API URL and expose connection actions.
- The route provides fixed stops where vehicle markers can move.
- The empty cards container gives JavaScript a place to render vehicle details.
- Save dashboard/index.html.
- Switch back to the browser.
- Refresh the page.
You will see the Jozi Transit Live heading above a dark panel. The panel contains an API URL input, two buttons, a status message, and three route stops.
Dashboard panel missing?
Confirm that the interface sits inside the main element. Content outside that element will not receive the intended width and spacing.
Check the opening and closing tags around header and section.
Help me fix the dashboard interface markup.
Render the demo vehicles
The interface now needs data and rendering logic. Three fictional vehicle records provide an immediate product demo without relying on any external transit feed.
- Switch back to dashboard/index.html in Notepad.
- Place the cursor on the line before </body>.
- Paste the script foundation below:
<script>
const demoVehicles = [
{ vehicleId: "JHB-101", route: "North-South", progress: 18, delayMinutes: 0, status: "Demo", eventTime: Date.now() },
{ vehicleId: "JHB-202", route: "Soweto-Central", progress: 48, delayMinutes: 8, status: "Demo delayed", eventTime: Date.now() },
{ vehicleId: "JHB-303", route: "East-West", progress: 78, delayMinutes: 0, status: "Demo", eventTime: Date.now() },
];
const apiInput = document.querySelector("#apiUrl");
const statusElement = document.querySelector("#status");
const routeElement = document.querySelector("#route");
const cardsElement = document.querySelector("#cards");
let apiUrl = "";
let refreshTimer;
</script>
What does this script foundation do?
- The demoVehicles array holds three fictional bus records with route, progress, delay, status, and timestamp values.
- The element references connect the script to the controls, route, cards, and status message.
- The apiUrl and refreshTimer variables prepare the page for live polling.
- Save dashboard/index.html.
- Refresh the browser.
The heading and panel remain visible after the script loads. This confirms that the new script has not broken the document.
Page disappeared after adding the script?
Confirm that the script sits before </body>. Check that every vehicle record keeps its closing brace and comma.
Help me find the script syntax problem.
- Place the cursor after the refreshTimer declaration.
- Paste the status helper below:
function setStatus(message, isError = false) {
statusElement.textContent = message;
statusElement.style.color = isError ? "#ff9b93" : "#7de5bd";
}
What does the status helper do?
The setStatus() function updates the message beneath the controls. It uses red for errors and green for successful connections.
- Save dashboard/index.html.
- Refresh the browser.
The dashboard remains visible with its demo-mode status. The helper is ready for the connection controls.
Seeing a script error?
Confirm that setStatus() sits inside the script element. Check both closing braces at the end of the function.
Help me fix the setStatus function.
- Place the cursor after the closing brace of setStatus().
- Paste the first version of the vehicle renderer below:
function render(vehicles) {
routeElement.querySelectorAll(".bus").forEach((marker) => marker.remove());
cardsElement.replaceChildren();
if (vehicles.length === 0) {
cardsElement.textContent = "No telemetry has arrived yet.";
return;
}
for (const vehicle of vehicles) {
const progress = Math.min(100, Math.max(0, Number(vehicle.progress)));
const delay = Number(vehicle.delayMinutes ?? 0);
const marker = document.createElement("span");
marker.className = "bus";
marker.style.left = `${progress}%`;
marker.textContent = vehicle.vehicleId;
routeElement.append(marker);
const card = document.createElement("article");
card.className = `card${delay > 0 ? " delayed" : ""}`;
cardsElement.append(card);
}
}
render(demoVehicles);
How does the renderer work?
- The renderer clears old markers and cards before drawing the current vehicle array.
- Each progress value is kept between 0 and 100 so its marker stays on the route.
- Each delayed vehicle receives the delayed card class.
- The final call renders the three records in demoVehicles.
- Save dashboard/index.html.
- Refresh the browser.
You will see three labelled bus markers positioned along the route. You will also see three empty card shapes beneath it.
Bus markers missing?
Confirm that the render(demoVehicles) call sits before </script>. Check that routeElement matches the earlier element reference.
Help me render the demo bus markers.
- Find the line that assigns card.className inside render().
- Place the cursor on the next line.
- Paste the card details below:
card.innerHTML = `
<h2>${vehicle.vehicleId}</h2>
<div class="metric"><span>Route</span><strong>${vehicle.route}</strong></div>
<div class="metric"><span>Progress</span><strong>${progress}%</strong></div>
<div class="metric"><span>Status</span><strong>${vehicle.status}</strong></div>
<div class="metric"><span>Delay</span><strong>${delay} min</strong></div>
<div class="metric"><span>Updated</span><strong>${new Date(vehicle.eventTime).toLocaleTimeString()}</strong></div>
`;
What do the cards show?
Each card displays the vehicle identifier, route, progress, status, delay, and update time. Template expressions insert the current vehicle values into the card markup.
- Save dashboard/index.html.
- Refresh the browser.
You will see details for JHB-101, JHB-202, and JHB-303. The JHB-202 card has a red delayed indicator.
Cards still empty?
Confirm that the card.innerHTML assignment appears before cardsElement.append(card). Keep the opening and closing backticks around the card markup.
Help me populate the vehicle cards.
- Place the cursor after the closing brace of render().
- Paste the live refresh function before render(demoVehicles).
async function refreshLiveData() {
if (!apiUrl) {
setStatus("Cloud API not connected. Paste the deployed URL first.", true);
return;
}
try {
const response = await fetch(apiUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
const vehicles = data.vehicles ?? [];
render(vehicles);
setStatus(`Live cloud data: ${vehicles.length} vehicles received.`);
} catch (error) {
setStatus(`Live connection failed: ${error.message}`, true);
}
}
How will live refresh work?
- The function requires a saved API URL before making a request.
- The browser requests the URL without using a cached response.
- A successful response replaces the demo records with its vehicles array.
- A failed response becomes a visible connection message.
- Save dashboard/index.html.
- Refresh the browser.
The three demo vehicles remain visible after the refresh. The page is ready to connect its buttons to the new function.
Demo vehicles disappeared?
Confirm that refreshLiveData() ends before the existing render(demoVehicles) call. Check the closing braces for the try and catch blocks.
Help me fix the live refresh function.
- Place the cursor after the closing brace of refreshLiveData().
- Paste the control handlers before render(demoVehicles).
document.querySelector("#connectButton").addEventListener("click", () => {
apiUrl = apiInput.value.trim();
if (!apiUrl) {
setStatus("Cloud API not connected. Paste the deployed URL first.", true);
return;
}
localStorage.setItem("joziTelemetryApiUrl", apiUrl);
clearInterval(refreshTimer);
refreshLiveData();
refreshTimer = setInterval(refreshLiveData, 3000);
});
document.querySelector("#refreshButton").addEventListener("click", refreshLiveData);
apiInput.value = localStorage.getItem("joziTelemetryApiUrl") ?? "";
How do the controls behave?
- The connection handler reads and trims the value from apiInput.
- A valid URL is saved under the joziTelemetryApiUrl local storage key.
- The first refresh runs immediately after connection.
- The polling timer requests fresh data every 3000 milliseconds.
- Save dashboard/index.html.
- Refresh the browser.
Great progress. Jozi Transit Live now renders all three demo vehicles and has the complete browser-side connection flow.
Buttons do not respond?
Confirm that both event listeners appear before render(demoVehicles). Check that connectButton and refreshButton match the IDs in the interface.
Help me fix the dashboard button handlers.
Use these tabs to confirm that your cumulative file matches the finished offline dashboard.
✔️ Awesome, I've got everything!
Your file is saved with the interface, demo records, renderer, refresh function, and control handlers.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jozi Transit Live</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #edf6ff;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #15314f, #07111f 55%); }
main { width: min(1000px, 92vw); margin: 0 auto; padding: 40px 0 64px; }
header { display: flex; justify-content: space-between; gap: 24px; align-items: end; flex-wrap: wrap; }
h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); }
.subtitle { color: #a7bdd2; max-width: 620px; }
.panel { margin-top: 24px; padding: 20px; border: 1px solid #284963; border-radius: 18px; background: rgba(7, 17, 31, 0.82); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); }
.controls { display: grid; grid-template-columns: 1fr auto auto; gap: 10px; }
input, button { border-radius: 10px; border: 1px solid #365d79; padding: 12px 14px; font: inherit; }
input { background: #0c1b2b; color: #edf6ff; }
button { background: #35d399; color: #03130d; font-weight: 750; cursor: pointer; }
button.secondary { background: #17324a; color: #edf6ff; }
#status { margin: 12px 0 0; color: #f6c76a; }
.route { position: relative; height: 110px; margin: 36px 12px 12px; }
.route::before { content: ""; position: absolute; left: 0; right: 0; top: 48px; height: 8px; border-radius: 99px; background: linear-gradient(90deg, #35d399, #4ba3ff); }
.stop { position: absolute; top: 37px; width: 28px; height: 28px; border: 7px solid #d9ecff; border-radius: 50%; background: #0c1b2b; transform: translateX(-50%); }
.stop.start { left: 0; }
.stop.middle { left: 50%; }
.stop.end { left: 100%; }
.bus { position: absolute; top: 6px; transform: translateX(-50%); padding: 7px 9px; border-radius: 9px; background: #edf6ff; color: #07111f; font-size: 0.76rem; font-weight: 800; transition: left 0.6s ease; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-top: 24px; }
.card { border: 1px solid #284963; border-radius: 14px; padding: 16px; background: #0c1b2b; }
.card.delayed { border-color: #ff786e; box-shadow: inset 4px 0 #ff786e; }
.card h2 { margin: 0 0 8px; font-size: 1.1rem; }
.metric { display: flex; justify-content: space-between; color: #a7bdd2; margin-top: 6px; gap: 12px; }
.metric strong { color: #edf6ff; text-align: right; }
@media (max-width: 700px) { .controls { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Jozi Transit Live</h1>
<p class="subtitle">A fictional fleet telemetry dashboard built to demonstrate a startup-friendly AWS IoT architecture.</p>
</div>
</header>
<section class="panel">
<div class="controls">
<input id="apiUrl" type="url" placeholder="Paste TelemetryApiUrl here">
<button id="connectButton">Connect Live API</button>
<button id="refreshButton" class="secondary">Refresh</button>
</div>
<p id="status">Demo mode: cloud API not connected.</p>
<div id="route" class="route">
<span class="stop start"></span>
<span class="stop middle"></span>
<span class="stop end"></span>
</div>
<div id="cards" class="cards"></div>
</section>
</main>
<script>
const demoVehicles = [
{ vehicleId: "JHB-101", route: "North-South", progress: 18, delayMinutes: 0, status: "Demo", eventTime: Date.now() },
{ vehicleId: "JHB-202", route: "Soweto-Central", progress: 48, delayMinutes: 8, status: "Demo delayed", eventTime: Date.now() },
{ vehicleId: "JHB-303", route: "East-West", progress: 78, delayMinutes: 0, status: "Demo", eventTime: Date.now() },
];
const apiInput = document.querySelector("#apiUrl");
const statusElement = document.querySelector("#status");
const routeElement = document.querySelector("#route");
const cardsElement = document.querySelector("#cards");
let apiUrl = "";
let refreshTimer;
function setStatus(message, isError = false) {
statusElement.textContent = message;
statusElement.style.color = isError ? "#ff9b93" : "#7de5bd";
}
function render(vehicles) {
routeElement.querySelectorAll(".bus").forEach((marker) => marker.remove());
cardsElement.replaceChildren();
if (vehicles.length === 0) {
cardsElement.textContent = "No telemetry has arrived yet.";
return;
}
for (const vehicle of vehicles) {
const progress = Math.min(100, Math.max(0, Number(vehicle.progress)));
const delay = Number(vehicle.delayMinutes ?? 0);
const marker = document.createElement("span");
marker.className = "bus";
marker.style.left = `${progress}%`;
marker.textContent = vehicle.vehicleId;
routeElement.append(marker);
const card = document.createElement("article");
card.className = `card${delay > 0 ? " delayed" : ""}`;
card.innerHTML = `
<h2>${vehicle.vehicleId}</h2>
<div class="metric"><span>Route</span><strong>${vehicle.route}</strong></div>
<div class="metric"><span>Progress</span><strong>${progress}%</strong></div>
<div class="metric"><span>Status</span><strong>${vehicle.status}</strong></div>
<div class="metric"><span>Delay</span><strong>${delay} min</strong></div>
<div class="metric"><span>Updated</span><strong>${new Date(vehicle.eventTime).toLocaleTimeString()}</strong></div>
`;
cardsElement.append(card);
}
}
async function refreshLiveData() {
if (!apiUrl) {
setStatus("Cloud API not connected. Paste the deployed URL first.", true);
return;
}
try {
const response = await fetch(apiUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
const vehicles = data.vehicles ?? [];
render(vehicles);
setStatus(`Live cloud data: ${vehicles.length} vehicles received.`);
} catch (error) {
setStatus(`Live connection failed: ${error.message}`, true);
}
}
document.querySelector("#connectButton").addEventListener("click", () => {
apiUrl = apiInput.value.trim();
if (!apiUrl) {
setStatus("Cloud API not connected. Paste the deployed URL first.", true);
return;
}
localStorage.setItem("joziTelemetryApiUrl", apiUrl);
clearInterval(refreshTimer);
refreshLiveData();
refreshTimer = setInterval(refreshLiveData, 3000);
});
document.querySelector("#refreshButton").addEventListener("click", refreshLiveData);
apiInput.value = localStorage.getItem("joziTelemetryApiUrl") ?? "";
render(demoVehicles);
</script>
</body>
</html>
How to use this reference
Compare this reference with your saved dashboard/index.html file. The order matters because each function uses elements and variables declared earlier in the document.
Test the missing cloud connection
The offline dashboard now looks like a working transit product. Its connection control gives you a direct test of what the local demo can do before a backend exists.
Before you select the connection button, do you think an empty API URL can provide cloud telemetry?
- Leave the API URL input empty.
- Select Connect Live API.
You will see Cloud API not connected. Paste the deployed URL first. in red. The three demo vehicles remain visible.
Why is this result useful?
This is the intended shortfall. The dashboard proves the product interface works while making the missing cloud connection visible.
The next step resolves that gap with a deployed telemetry backend and a real API URL.
For the final check, predict what will appear when you open the saved file from PowerShell again.
- Switch back to the PowerShell window in jozi-transit-live.
- Open the completed dashboard by running this command:
Invoke-Item .\dashboard\index.html
What does this final check prove?
Opening the file directly proves that the dashboard runs as a local browser application. It does not require a development server or cloud deployment.
You will see three demo buses, three status cards, the route, the API URL input, the Connect Live API button, and the Refresh button. The initial status reads Demo mode: cloud API not connected.
Final dashboard does not match?
Confirm that Notepad saved dashboard/index.html before the browser command ran. Refresh any older browser tab after saving.
Use the full-code reference to check the placement of render(demoVehicles) and the closing script tag.
Help me compare my offline dashboard with the expected result.
Your offline transit dashboard is working. Next, you will deploy the serverless backend that turns its connection control into a live telemetry feed.
Deploy the Serverless Telemetry Backend
Your offline dashboard proves the product experience. However, its connection control has nowhere to fetch real telemetry from.
This step uses AWS SAM to describe the backend as infrastructure as code. The same template can rebuild the architecture without an always-on custom server.
An AWS IoT Core rule routes vehicle events into Amazon DynamoDB. AWS Lambda exposes the latest records through an Amazon API Gateway HTTP API.
In this step, get ready to:
- Create the SAM template and Lambda API files.
- Build the serverless application locally.
- Deploy the CloudFormation stack and verify its empty API response.
Create the backend files
The YAML template defines each cloud resource plus the permissions connecting them. The Lambda files contain the read API plus its pinned dependencies.
- Press the Windows key to open the search bar.
- Type Notepad into the search bar.
- Press Enter to open Notepad.
- Start template.yaml by copying this table definition into the blank document:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Jozi Transit Live serverless telemetry backend
Resources:
TelemetryTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: vehicleId
AttributeType: S
- AttributeName: eventTime
AttributeType: N
KeySchema:
- AttributeName: vehicleId
KeyType: HASH
- AttributeName: eventTime
KeyType: RANGE
What does this template section do?
- The SAM transform expands the serverless resource definitions during deployment.
- The TelemetryTable resource stores telemetry without preconfigured capacity.
- The vehicleId key groups events by bus.
- The eventTime key orders each bus's events by timestamp.
- Save the document as template.yaml inside the existing jozi-transit-live folder.
- Confirm the saved document ends with KeyType: RANGE.
Did Notepad add a text extension?
Check the saved name in Notepad. Remove any extra text extension so the file is named exactly template.yaml.
Use Help me save a YAML file without an extra text extension in Windows Notepad..
- Append the API and Lambda resource definitions to template.yaml by copying this code below KeyType: RANGE:
TelemetryApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
TelemetryReadFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: api/
Handler: index.handler
Runtime: nodejs24.x
MemorySize: 128
Timeout: 10
Environment:
Variables:
TABLE_NAME: !Ref TelemetryTable
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TelemetryTable
Events:
ReadTelemetry:
Type: HttpApi
Properties:
ApiId: !Ref TelemetryApi
Path: /telemetry
Method: GET
How does the read API work?
- The TelemetryApi resource creates the HTTP API with a prod stage.
- The TelemetryReadFunction resource runs index.handler on the nodejs24.x runtime.
- The TABLE_NAME environment variable passes the generated table name into Lambda.
- The DynamoDBReadPolicy grants read-only table access.
- The HTTP event connects GET requests on /telemetry to the function.
- Save template.yaml.
- Confirm the document now ends with Method: GET.
Does the API section look misaligned?
Keep TelemetryApi aligned with TelemetryTable. Keep Properties indented beneath each resource.
Use Help me check the indentation in my SAM API and Lambda resource definitions..
- Append the IoT rule role to template.yaml by copying this code below Method: GET:
IoTRuleRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: iot.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: WriteTransitTelemetry
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt TelemetryTable.Arn
Why does the IoT rule need a role?
The AWS Identity and Access Management role allows the IoT service to assume a project-specific identity. Its policy permits only dynamodb:PutItem against the telemetry table.
This least-privilege boundary gives the ingestion path the single write action it needs.
- Save template.yaml.
- Confirm the document now ends with Resource: !GetAtt TelemetryTable.Arn.
Is the role outside Resources?
Check that IoTRuleRole uses the same indentation as the other resource names. An extra indentation level places the role inside the preceding function.
Use Help me align IoTRuleRole with the other resources in template.yaml..
- Finish template.yaml by copying the IoT rule and API output below the role policy:
TelemetryTopicRule:
Type: AWS::IoT::TopicRule
Properties:
TopicRulePayload:
RuleDisabled: false
Sql: "SELECT * FROM 'transit/telemetry'"
Actions:
- DynamoDBv2:
RoleArn: !GetAtt IoTRuleRole.Arn
PutItem:
TableName: !Ref TelemetryTable
Outputs:
TelemetryApiUrl:
Description: Public read-only telemetry endpoint for the tutorial dashboard
Value: !Sub 'https://${TelemetryApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/prod/telemetry'
How does telemetry reach the table?
- The TelemetryTopicRule resource listens to every message published on transit/telemetry.
- The DynamoDBv2 action writes each payload field into the table.
- The TelemetryApiUrl output constructs the public read endpoint from the deployed API.
- Save template.yaml.
- Confirm the final line ends with /prod/telemetry inside the URL expression.
Does the topic rule look nested incorrectly?
Keep TelemetryTopicRule beneath Resources. Keep Outputs aligned with Resources.
Use Help me check the IoT rule and Outputs indentation in template.yaml..
✔️ Awesome, I've got everything!
Your SAM template now defines the complete telemetry backend. Keep template.yaml saved.
ⓧ I'd like to double check the full code
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Jozi Transit Live serverless telemetry backend
Resources:
TelemetryTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: vehicleId
AttributeType: S
- AttributeName: eventTime
AttributeType: N
KeySchema:
- AttributeName: vehicleId
KeyType: HASH
- AttributeName: eventTime
KeyType: RANGE
TelemetryApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
TelemetryReadFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: api/
Handler: index.handler
Runtime: nodejs24.x
MemorySize: 128
Timeout: 10
Environment:
Variables:
TABLE_NAME: !Ref TelemetryTable
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TelemetryTable
Events:
ReadTelemetry:
Type: HttpApi
Properties:
ApiId: !Ref TelemetryApi
Path: /telemetry
Method: GET
IoTRuleRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: iot.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: WriteTransitTelemetry
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt TelemetryTable.Arn
TelemetryTopicRule:
Type: AWS::IoT::TopicRule
Properties:
TopicRulePayload:
RuleDisabled: false
Sql: "SELECT * FROM 'transit/telemetry'"
Actions:
- DynamoDBv2:
RoleArn: !GetAtt IoTRuleRole.Arn
PutItem:
TableName: !Ref TelemetryTable
Outputs:
TelemetryApiUrl:
Description: Public read-only telemetry endpoint for the tutorial dashboard
Value: !Sub 'https://${TelemetryApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/prod/telemetry'
- Use Notepad's new-document control to start a blank document.
- Create an api folder inside jozi-transit-live using the folder creation control in the save window.
- Create the dependency manifest by copying this code into the blank document:
{
"name": "jozi-transit-api",
"version": "1.0.0",
"private": true,
"dependencies": {
"@aws-sdk/client-dynamodb": "3.1152.0",
"@aws-sdk/lib-dynamodb": "3.1152.0"
}
}
What does this manifest control?
The JSON manifest names a private package called jozi-transit-api. It pins both AWS SDK dependencies to 3.1152.0 for repeatable builds.
- Save the document as package.json inside the new jozi-transit-live\api folder.
- Confirm the final saved line contains the closing brace for the manifest.
Did the manifest save in the wrong folder?
Check that package.json sits inside api. The folder must sit beside template.yaml inside jozi-transit-live.
Use Help me verify the folder layout for api/package.json in jozi-transit-live..
✔️ Awesome, I've got everything!
Your Lambda package manifest has both pinned DynamoDB dependencies. Keep api/package.json saved.
ⓧ I'd like to double check the full code
{
"name": "jozi-transit-api",
"version": "1.0.0",
"private": true,
"dependencies": {
"@aws-sdk/client-dynamodb": "3.1152.0",
"@aws-sdk/lib-dynamodb": "3.1152.0"
}
}
- Use Notepad's new-document control to start another blank document.
- Add the DynamoDB clients plus the response headers by copying this code:
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const {
DynamoDBDocumentClient,
ScanCommand,
} = require("@aws-sdk/lib-dynamodb");
const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);
const tableName = process.env.TABLE_NAME;
const headers = {
"content-type": "application/json",
"access-control-allow-origin": "*",
};
What does this setup do?
- The base client connects the function to DynamoDB.
- The document client converts DynamoDB records into regular JavaScript values.
- The tableName constant reads the table name supplied by the SAM template.
- The headers object returns JSON plus browser access for the local dashboard.
- Save the document as index.js inside the existing jozi-transit-live\api folder.
- Confirm the saved document ends with the closing brace for headers.
Are the imports incomplete?
Check that DynamoDBClient, DynamoDBDocumentClient, and ScanCommand use the exact capitalization shown above.
Use Help me compare the DynamoDB imports in api/index.js..
- Append the handler shell below the headers object by copying this code:
exports.handler = async () => {
try {
} catch (error) {
console.error(error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ message: "Unable to read telemetry" }),
};
}
};
Why start with a handler shell?
The exported function gives Lambda its entry point. The error path logs unexpected failures plus returns a JSON response with status 500.
The empty try block keeps the surrounding function structure balanced while you add the read logic.
- Save api/index.js.
- Confirm the document ends with the closing brace for exports.handler.
Is the error response outside the handler?
Keep the catch block inside exports.handler. Its closing brace appears immediately before the function's final closing characters.
Use Help me check the braces around the Lambda handler and catch block..
- Place the scan and latest-record logic inside the empty try block by copying this code between its braces:
const response = await documentClient.send(
new ScanCommand({
TableName: tableName,
Limit: 100,
}),
);
const latestByVehicle = new Map();
for (const item of response.Items ?? []) {
const current = latestByVehicle.get(item.vehicleId);
if (!current || item.eventTime > current.eventTime) {
latestByVehicle.set(item.vehicleId, item);
}
}
How are the latest records selected?
- The scan reads up to 100 telemetry records from the table.
- The latestByVehicle map stores one record for each vehicle.
- The timestamp comparison replaces an older record when a newer event arrives for the same vehicle.
- Save api/index.js.
- Confirm the new code sits between try { and } catch (error) {.
Did the scan land outside try?
Move the scan logic so its first line sits directly beneath try {. Keep the final loop brace directly above } catch (error) {.
Use Help me place the scan and Map logic inside the try block..
- Finish the successful response by copying this code below the completed for loop inside try:
const vehicles = Array.from(latestByVehicle.values()).sort((a, b) =>
a.vehicleId.localeCompare(b.vehicleId),
);
return {
statusCode: 200,
headers,
body: JSON.stringify({ vehicles }),
};
What does the successful response contain?
The function converts the map into an array sorted by vehicleId. It returns that array in a JSON body with status 200.
Before telemetry arrives, the same path produces an empty vehicles array.
- Save api/index.js.
- Confirm the success return sits above } catch (error) {.
Are the success and error responses reversed?
Keep the response with status 200 inside try. Keep the response with status 500 inside catch.
Use Help me compare the successful and error return positions in api/index.js..
✔️ Awesome, I've got everything!
Your Lambda handler can now return the latest record for each vehicle. Keep api/index.js saved.
ⓧ I'd like to double check the full code
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const {
DynamoDBDocumentClient,
ScanCommand,
} = require("@aws-sdk/lib-dynamodb");
const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);
const tableName = process.env.TABLE_NAME;
const headers = {
"content-type": "application/json",
"access-control-allow-origin": "*",
};
exports.handler = async () => {
try {
const response = await documentClient.send(
new ScanCommand({
TableName: tableName,
Limit: 100,
}),
);
const latestByVehicle = new Map();
for (const item of response.Items ?? []) {
const current = latestByVehicle.get(item.vehicleId);
if (!current || item.eventTime > current.eventTime) {
latestByVehicle.set(item.vehicleId, item);
}
}
const vehicles = Array.from(latestByVehicle.values()).sort((a, b) =>
a.vehicleId.localeCompare(b.vehicleId),
);
return {
statusCode: 200,
headers,
body: JSON.stringify({ vehicles }),
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ message: "Unable to read telemetry" }),
};
}
};
Build and deploy the stack
SAM now has the infrastructure template plus the Lambda source it needs. The build stage prepares those local files for deployment through AWS CloudFormation.
- Return to the Windows PowerShell window from earlier.
- Confirm its prompt is inside the existing jozi-transit-live folder.
- Build the serverless application by running this command:
sam build
What does this command do?
SAM reads template.yaml plus the local api source. It prepares the Lambda dependencies and deployment artifacts referenced by the template.
- Wait for PowerShell to return to its prompt.
- Confirm the build completes without an error.
Did the build fail?
Confirm template.yaml sits directly inside jozi-transit-live. Confirm index.js plus package.json sit inside its api folder.
Use Help me diagnose my SAM build failure from the PowerShell output..
This deployment creates persistent AWS resources, so pausing before it is reasonable. The cleanup section removes the project stack when you finish.
- Deploy the built application by running this command:
sam deploy --stack-name jozi-transit-live --resolve-s3 --capabilities CAPABILITY_IAM --region eu-west-1 --profile jozi-transit --no-confirm-changeset
What does this deployment command control?
- The stack name keeps all project resources grouped under jozi-transit-live.
- The deployment targets eu-west-1 with the verified jozi-transit profile.
- The managed artifact option creates or reuses storage for the packaged Lambda code.
- The IAM capability permits CloudFormation to create the Lambda and IoT rule roles defined in the template.
Expect this deployment to take a few minutes while CloudFormation creates each resource. Quiet periods in PowerShell are normal during provisioning.
- Wait for PowerShell to report that the deployment completed.
- Confirm the deployed stack name is jozi-transit-live.
Did the deployment stop?
Check that the jozi-transit profile still has permission to create CloudFormation, IAM, IoT Core, DynamoDB, Lambda, API Gateway, CloudWatch Logs, and S3 resources.
Use Help me diagnose my SAM deployment failure without exposing credentials..
Retrieve and test the API URL
The stack output provides the deployed read endpoint. Calling it before telemetry arrives proves the backend is reachable while preserving the next step's ingestion milestone.
- Retrieve the API URL from the stack output by running this command:
aws cloudformation describe-stacks --stack-name jozi-transit-live --region eu-west-1 --profile jozi-transit --query "Stacks[0].Outputs[?OutputKey=='TelemetryApiUrl'].OutputValue" --output text
What does this lookup return?
The command reads the outputs from the deployed stack. Its query selects the value named TelemetryApiUrl so PowerShell prints only the endpoint.
- Record the returned URL here: your TelemetryApiUrl.
Did the command return a blank line?
Confirm the deployment completed before repeating the lookup. A blank result means the named stack output is not available yet.
Use Help me find why my TelemetryApiUrl stack output is blank..
Before you open the endpoint, what do you expect the vehicle list to contain before any telemetry has been published?
- Return to the web browser from earlier.
- Paste your TelemetryApiUrl into the browser address bar.
- Press Enter to request the telemetry endpoint.
You should see {"vehicles":[]}. This proves the API is deployed plus reachable before the simulator publishes its first vehicle event.
That is the backend milestone complete. Your serverless API is live and ready to receive fleet telemetry.
Your cloud connection now exists. Next, you will publish simulated bus events through IoT Core and watch the dashboard switch from demo data to live positions.
Stream Vehicle Telemetry into the Live Dashboard
Your backend now returns an empty vehicle list through the deployed API. That response proves the serverless read path works.
Vehicle events are the missing ingredient. In this step, a simulator publishes telemetry through AWS IoT Core for storage in Amazon DynamoDB. Your dashboard then turns the stored events into a live fleet view.
In this step, get ready to:
- Create a PowerShell simulator with the vehicle telemetry contract.
- Publish six rounds of telemetry for three fictional buses.
- Connect the dashboard to the deployed API.
Create the telemetry simulator
The simulator represents the vehicle devices that a future fleet product could connect. It uses Windows PowerShell to build each JSON event.
How does the telemetry path work?
The script discovers your account-specific IoT data endpoint. Each event then enters the fixed transit/telemetry topic.
The deployed topic rule copies every payload into DynamoDB. This gives the API stored vehicle records to read.
- Press the Windows key to open the Start menu.
- Type Notepad.
- Select Notepad from the results.
- Paste the endpoint discovery and vehicle definitions below into the blank document:
param(
[string]$Region = "eu-west-1",
[string]$Profile = "jozi-transit"
)
$ErrorActionPreference = "Stop"
$endpoint = aws iot describe-endpoint `
--endpoint-type iot:Data-ATS `
--region $Region `
--profile $Profile `
--query endpointAddress `
--output text
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($endpoint)) {
throw "Unable to discover the AWS IoT data endpoint."
}
$vehicles = @(
[ordered]@{ vehicleId = "JHB-101"; route = "North-South"; progress = 12; delayMinutes = 0 },
[ordered]@{ vehicleId = "JHB-202"; route = "Soweto-Central"; progress = 42; delayMinutes = 8 },
[ordered]@{ vehicleId = "JHB-303"; route = "East-West"; progress = 72; delayMinutes = 0 }
)
What does this code do?
- The parameter block defaults the region to eu-west-1.
- The profile parameter defaults to jozi-transit.
- The endpoint lookup requests the recommended iot:Data-ATS endpoint for your AWS account.
- The endpoint check stops the script when discovery fails.
- The vehicle array defines three fictional buses with distinct routes.
- The JHB-202 record carries an eight-minute delay.
- Select File in Notepad.
- Select Save As.
- Choose the existing jozi-transit-live folder as the destination.
- Enter simulate.ps1 in the File name field.
- Select Save.
- Switch back to the PowerShell window from the previous step.
- Test the endpoint discovery by running the partial simulator:
powershell.exe -ExecutionPolicy Bypass -File .\simulate.ps1
PowerShell returns to its prompt without an error. That confirms the script discovered your account-specific IoT endpoint.
Endpoint discovery failed?
Confirm that PowerShell is inside the existing jozi-transit-live folder. The command needs to find simulate.ps1 there.
Confirm that the jozi-transit profile still has permission to discover the IoT endpoint in eu-west-1.
Help me diagnose IoT endpoint discovery for this simulator.
The publishing loop is one continuous construct. You will add its two parts back to back before running the completed simulator.
- Switch back to simulate.ps1 in Notepad.
- Place the cursor on a new line after the closing parenthesis for $vehicles.
- Paste the first part of the publishing loop:
for ($tick = 0; $tick -lt 6; $tick++) {
foreach ($vehicle in $vehicles) {
$vehicle.progress = ($vehicle.progress + 7 + $tick) % 101
$status = if ($vehicle.delayMinutes -gt 0) { "Delayed" } else { "On time" }
$payload = [ordered]@{
vehicleId = $vehicle.vehicleId
route = $vehicle.route
progress = $vehicle.progress
delayMinutes = $vehicle.delayMinutes
status = $status
eventTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
} | ConvertTo-Json -Compress
$tempFile = [System.IO.Path]::GetTempFileName()
What does this part do?
- The outer loop creates six telemetry rounds.
- The inner loop updates each of the three vehicles once per round.
- The payload records the vehicle identifier.
- The payload records the route and progress.
- The payload records the delay and status.
- The eventTime value records the current time in milliseconds.
- The temporary file gives the AWS CLI a payload to publish.
- Place the cursor on a new line after $tempFile = [System.IO.Path]::GetTempFileName().
- Paste the rest of the publishing loop:
try {
[System.IO.File]::WriteAllText(
$tempFile,
$payload,
[System.Text.UTF8Encoding]::new($false)
)
aws iot-data publish `
--endpoint-url "https://$endpoint" `
--topic "transit/telemetry" `
--payload "fileb://$tempFile" `
--region $Region `
--profile $Profile | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Publish failed for $($vehicle.vehicleId)."
}
}
finally {
Remove-Item $tempFile -ErrorAction SilentlyContinue
}
Write-Host "Published $($vehicle.vehicleId) at $($vehicle.progress)%"
}
Start-Sleep -Seconds 2
}
Write-Host "Telemetry simulation complete."
What does this part do?
- The script writes each payload as UTF-8 without a byte-order mark.
- The publish command sends the file to transit/telemetry through the discovered endpoint.
- The publish check stops the run when a vehicle update fails.
- The finally block removes the temporary file after every attempt.
- The terminal message exposes the vehicle identifier and its new route progress.
- The two-second pause spaces the telemetry rounds apart.
- Save simulate.ps1.
- Compare your completed simulator with the reference below.
✔️ Awesome, I've got everything!
Your simulator is saved with endpoint discovery, three vehicle definitions, and six publishing rounds.
ⓧ I'd like to double check the full code
param(
[string]$Region = "eu-west-1",
[string]$Profile = "jozi-transit"
)
$ErrorActionPreference = "Stop"
$endpoint = aws iot describe-endpoint `
--endpoint-type iot:Data-ATS `
--region $Region `
--profile $Profile `
--query endpointAddress `
--output text
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($endpoint)) {
throw "Unable to discover the AWS IoT data endpoint."
}
$vehicles = @(
[ordered]@{ vehicleId = "JHB-101"; route = "North-South"; progress = 12; delayMinutes = 0 },
[ordered]@{ vehicleId = "JHB-202"; route = "Soweto-Central"; progress = 42; delayMinutes = 8 },
[ordered]@{ vehicleId = "JHB-303"; route = "East-West"; progress = 72; delayMinutes = 0 }
)
for ($tick = 0; $tick -lt 6; $tick++) {
foreach ($vehicle in $vehicles) {
$vehicle.progress = ($vehicle.progress + 7 + $tick) % 101
$status = if ($vehicle.delayMinutes -gt 0) { "Delayed" } else { "On time" }
$payload = [ordered]@{
vehicleId = $vehicle.vehicleId
route = $vehicle.route
progress = $vehicle.progress
delayMinutes = $vehicle.delayMinutes
status = $status
eventTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
} | ConvertTo-Json -Compress
$tempFile = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText(
$tempFile,
$payload,
[System.Text.UTF8Encoding]::new($false)
)
aws iot-data publish `
--endpoint-url "https://$endpoint" `
--topic "transit/telemetry" `
--payload "fileb://$tempFile" `
--region $Region `
--profile $Profile | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Publish failed for $($vehicle.vehicleId)."
}
}
finally {
Remove-Item $tempFile -ErrorAction SilentlyContinue
}
Write-Host "Published $($vehicle.vehicleId) at $($vehicle.progress)%"
}
Start-Sleep -Seconds 2
}
Write-Host "Telemetry simulation complete."
Publish six telemetry rounds
Each publish becomes a DynamoDB record through the existing IoT rule. Six rounds provide several timestamps for every vehicle, which lets the API select the latest record.
The run takes about 12 seconds because the simulator pauses between rounds. The stream of publish messages shows that it is still working.
Before you run the simulator, what sequence do you expect from the three vehicle identifiers?
- Publish the six telemetry rounds by running:
powershell.exe -ExecutionPolicy Bypass -File .\simulate.ps1
You will see one Published line for each vehicle in every round. The run ends with Telemetry simulation complete.
The output cycles through JHB-101, JHB-202, and JHB-303 six times. Your cloud table now has 18 telemetry records.
Telemetry publish failed?
Confirm that the jozi-transit profile has permission to publish through AWS IoT Core in eu-west-1.
Check that simulate.ps1 contains the exact transit/telemetry topic used by TelemetryTopicRule.
Help me diagnose a failed vehicle telemetry publish.
Connect the live dashboard
The read API now has stored events to return. The dashboard requests the latest vehicle array every three seconds after you connect it.
- Return to the PowerShell window in the jozi-transit-live folder.
- Open the local dashboard in your default browser by running:
Invoke-Item .\dashboard\index.html
The browser shows the three demo vehicles with the status Demo mode: cloud API not connected.
Before you connect, do you expect the cloud response to remain empty after the simulator finished?
- Paste the deployed TelemetryApiUrl from the previous step into the Paste TelemetryApiUrl here field.
- Select Connect Live API.
- Watch the route markers during the remaining simulator rounds.
- Confirm that the vehicle cards show fresh cloud timestamps.
You will see Live cloud data: 3 vehicles received. The route now shows the latest cloud-backed position for each vehicle.
The JHB-202 card is highlighted as delayed. The JHB-101 and JHB-303 cards show on-time service.
Dashboard still shows demo data?
Confirm that you pasted the complete TelemetryApiUrl output. The URL must end with /prod/telemetry.
Select Connect Live API again after correcting the URL. A successful request replaces the demo timestamps with stored event times.
Help me debug the dashboard connection to TelemetryApiUrl.
That closes the cloud loop. Your simulated buses now travel from PowerShell through AWS IoT Core into DynamoDB before appearing on the live dashboard.
Secret mission
Filter Delayed Vehicles
Turn Jozi Transit Live into a focused operations view. Add a client-side checkbox that hides on-time vehicles so an operator can spot delayed services immediately.
Clean Up Your Resources
Clean Up Your Resources
Your deployed AWS CloudFormation stack can continue serving dashboard requests after you finish, so leaving it online may incur pay-as-you-go charges. Choose whether to keep the live demo, pause new telemetry, or delete the cloud stack and local files.
Cost warning
Applicable credits or Free Tier allowances may cover tutorial-scale use. AWS can still charge for Amazon DynamoDB, AWS IoT Core, AWS Lambda, Amazon API Gateway, or CloudWatch Logs while the stack remains deployed.
Deleting jozi-transit-live removes the project stack resources. A shared AWS SAM managed Amazon S3 artifact bucket may remain for future deployments.
Resources you used:
- The jozi-transit-live CloudFormation stack in eu-west-1, including its DynamoDB table, IoT topic rule, Lambda function, API Gateway HTTP API, IAM roles, CloudWatch logs, and stored vehicle telemetry.
- The local jozi-transit-live folder containing template.yaml, api/package.json, api/index.js, simulate.ps1, and dashboard/index.html.
Keep everything running
No action needed. Choose this if you want to keep demonstrating the live dashboard or continue building the fleet prototype.
- Leave the jozi-transit-live stack deployed in eu-west-1.
- Keep the local jozi-transit-live folder for future changes.
- Run simulate.ps1 only when you want to publish fresh vehicle telemetry.
- Monitor your AWS usage because pay-as-you-go charges can apply while the stack remains deployed.
Pause - I'll come back to this later
Stop local activity to pause new telemetry and dashboard polling. The cloud stack stays deployed, so stored vehicle data remains available and AWS charges can still apply.
- Stop any active simulator by pressing Ctrl+C in its Windows PowerShell window.
- Close the dashboard browser tab to stop its three-second polling.
- Keep the jozi-transit-live folder on your computer.
- Keep the jozi-transit-live stack deployed until you are ready to resume or delete it.
Delete - I don't want to use this again
Deleting the stack permanently removes its API, function, rule, table, logs, roles, and stored telemetry. Your local project files remain until you remove the jozi-transit-live folder separately.
- Start deleting the deployed stack from PowerShell by running this command:
aws cloudformation delete-stack --stack-name jozi-transit-live --region eu-west-1 --profile jozi-transit
What does this command delete?
This command asks CloudFormation to delete every resource managed by the jozi-transit-live stack. That includes the stored telemetry for JHB-101, JHB-202, and JHB-303.
The shared SAM-managed S3 artifact bucket may remain outside the project stack. AWS can reuse that bucket for later SAM deployments.
Stack deletion can take several minutes while AWS removes connected resources.
- Wait until the jozi-transit-live stack has finished deleting.
- Return to the connected dashboard after deletion finishes.
You'll see the live connection fail because the deployed telemetry endpoint no longer exists.
Stack deletion not starting?
Confirm that the command uses the verified jozi-transit profile and the eu-west-1 region.
Help me diagnose why my CloudFormation stack deletion did not start.
Local deletion is permanent
Removing the local folder deletes your dashboard, simulator, API code, and infrastructure template. Keep a copy elsewhere if you want to revisit the project.
- Close Notepad if a project file is still open.
- Close any PowerShell window whose current location is inside jozi-transit-live.
- Press the Windows key to open Windows search.
- Type File Explorer and press Enter.
- Locate the parent folder that contains jozi-transit-live in File Explorer.
- Select the jozi-transit-live folder.
- Press Shift+Delete to remove the folder permanently.
- Confirm the deletion when Windows asks.
You should no longer see the jozi-transit-live folder in its previous location.
Nice Work!
Nice Work!
Mission accomplished. You built Jozi Transit Live into a cloud-backed fleet telemetry prototype that tracks three simulated Johannesburg buses and highlights service delays.
You've learned how to:
- Build a live transit dashboard that displays changing vehicle positions. It also shows fresh cloud timestamps and delayed vehicle states.
- Route fictional vehicle telemetry through AWS IoT Core into Amazon DynamoDB for durable storage.
- Expose the latest vehicle state through a serverless API. AWS SAM defines the application. AWS CloudFormation deploys it. AWS Lambda reads the telemetry. Amazon API Gateway serves it to the dashboard.
- Secret Mission: Added an operator filter that instantly shows only vehicles with delayMinutes greater than zero.
Ready to quiz yourself?