Build a Vue Kafka Lag Dashboard
Build a Vue dashboard to visualize GitHub events, Kafka lag, and recovery.
Introduction
30 Second Summary
Live dashboards feel immediate even when work is piling up behind the screen. Without a clear signal, you cannot tell whether the system is keeping pace or falling behind.
In this project, you will build GitHub Event Pulse, a local Vue dashboard connected to an Apache Kafka event stream. The dashboard exposes consumer lag as a Node.js consumer processes public GitHub activity.
What You'll Build
After you click Load GitHub batch, repository events fill the live feed while the lag counter climbs above zero.
By the end of this project, you'll have:
- A live event feed that displays public GitHub activity as the consumer processes each Kafka message.
- A lag-and-recovery demonstration that pushes the lag counter above zero. Recovery mode drains it back to zero.
- A restart test that shows the Node.js consumer continuing from its committed group offsets after a graceful restart.
- Secret Mission: Add an idempotent display guard that rejects repeated GitHub event IDs.
Do I need Vue or Kafka experience?
No prior experience is required. The guide checks Node.js plus Docker before walking you through the local broker setup.
Before We Start
Before the hands-on work begins, lock in what you are building and why each part needs to stay visible.
Set Up the Local Lab
Your local observability lab depends on Node.js to run the pinned Vue tooling. Docker keeps the local Apache Kafka broker isolated from the rest of your Mac.
A version mismatch can block dependency installation before a single event reaches the broker. This step proves both foundations work before you build the dashboard.
In this step, get ready to:
- Check that Node.js meets the project requirements.
- Create the pinned project structure.
- Start the local Kafka broker and verify the cluster.
Check Node.js and Docker
The project combines the compatibility requirements of Vue and Platformatic Kafka. Node.js 22 must be at least 22.22.0. Node.js 24 or newer must be at least 24.12.0.
- Press Cmd+Space to open macOS search.
- Enter Terminal in the search bar.
- Press Enter to open Terminal.
- Check the installed Node.js and Docker versions by running these commands:
node --version
docker --version
What do these checks prove?
- The first command shows whether Node.js can run the pinned project dependencies.
- The second command confirms that the Docker command-line tool is available.
- Choose the Node.js result that matches your terminal.
✔️ I see a compatible version
Your Node.js version is compatible if it is 22.22.0 or later within the 22.x release line. A version of 24.12.0 or later is also compatible.
Your runtime is ready for the pinned Vue and Kafka packages.
ⓧ I see an older version
The installed runtime is too old for at least one pinned dependency. Install Node.js v24.20.0 LTS to give every package a compatible foundation.
- Visit the official Node.js download page.
- Download Node.js v24.20.0 LTS for macOS.
- Run the downloaded installer.
- Complete the installation prompts.
- Close the current Terminal window.
- Reopen Terminal through macOS search.
- Check the new Node.js version by running:
node --version
What should I see?
The command should now print v24.20.0 or another compatible version.
Still seeing the old version?
Close every Terminal window before opening a new one. This refreshes the command path after installation.
Still stuck? Help me diagnose why Terminal still shows my older Node.js version.
ⓧ Command not found
Terminal cannot find a Node.js installation. Install the pinned LTS release before creating the project.
- Visit the official Node.js download page.
- Download Node.js v24.20.0 LTS for macOS.
- Run the downloaded installer.
- Complete the installation prompts.
- Close the current Terminal window.
- Reopen Terminal through macOS search.
- Verify the installation by running:
node --version
What should I see?
The command should print v24.20.0 or another compatible version.
Node.js still unavailable?
Make sure the installer completed before reopening Terminal. A new Terminal window loads the updated command path.
Need another pair of eyes? Help me find why Node.js is unavailable after installation.
- Choose the Docker result that matches your terminal.
✔️ I see a Docker version
The Docker command-line tool is available. The broker startup later in this step checks that Docker Desktop can run containers.
ⓧ Command not found
Docker Desktop supplies the container runtime that hosts Kafka. Install it before continuing to the broker setup.
- Follow Docker's official installation guide for Mac.
- Complete the Docker Desktop installation.
- Open Docker Desktop through macOS search.
- Return to Terminal.
- Verify the Docker command-line tool by running:
docker --version
What should I see?
The command should print a Docker version instead of reporting a missing command.
Docker still unavailable?
Close Terminal after Docker Desktop finishes installing. Reopen Terminal so the updated command path loads.
Need help? Help me troubleshoot why Docker is unavailable on my Mac.
Create the pinned project
A pinned package.json records the exact dependency versions used by the lab. This keeps your local setup aligned with the code you build in later steps.
- Move to your Desktop and create the project structure by running these commands:
cd ~/Desktop
mkdir github-event-pulse
cd github-event-pulse
mkdir src
What do these commands create?
- The github-event-pulse directory keeps every project artifact in one place on your Desktop.
- The empty src directory is ready for the Vue application files you create in the next step.
- Confirm that the empty source directory exists by running:
ls
What should I see?
The terminal should list src inside the new github-event-pulse directory.
- Press Cmd+Space to open macOS search.
- Enter the name of your code editor in the search bar.
- Press Enter to open your editor.
- Open the github-event-pulse folder from your Desktop.
- Create package.json inside github-event-pulse by copying the following code:
{
"name": "github-event-pulse",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"server": "node server.mjs",
"build": "vite build"
},
"dependencies": {
"@platformatic/kafka": "2.11.0",
"vue": "3.5.42"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"vite": "8.2.2"
}
}
What does this file define?
- The scripts provide separate commands for the dashboard server, the Kafka pipeline, and the production build.
- The dependencies pin Vue 3.5.42 with Platformatic Kafka 2.11.0.
- The development dependencies pin Vite 8.2.2 with its Vue plugin at 6.0.8.
- Save package.json.
- Switch back to Terminal.
The first dependency installation can take a minute while npm downloads each pinned package. Progress output means the installation is still working.
- Install the pinned project dependencies by running:
npm install
What does this command do?
npm reads package.json and downloads the exact application dependencies. It also records the resolved package tree for repeatable installations.
- Confirm that npm returns to the command prompt without an installation error.
- Inspect the installed project contents by running:
ls
What should I see?
The output should include node_modules, package-lock.json, package.json, and src.
Dependency installation failed?
Check that Terminal is inside github-event-pulse before installing. Confirm that the version check from the previous substep shows a compatible Node.js release.
Still seeing an error? Help me troubleshoot my pinned npm installation.
✔️ Awesome, I've got everything!
Your pinned dependencies are installed. Double-check that package.json is saved before continuing.
ⓧ I'd like to double check the full code
Compare your saved file with this complete version.
{
"name": "github-event-pulse",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"server": "node server.mjs",
"build": "vite build"
},
"dependencies": {
"@platformatic/kafka": "2.11.0",
"vue": "3.5.42"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"vite": "8.2.2"
}
}
What should match?
Every script name, package name, and version should match this reference exactly.
Start Kafka and verify the cluster
Kafka acts as the broker between the producer and consumer you build later. A named local container gives that broker a stable identity across the lag and restart experiments.
- Start the named Kafka broker on port 9092 by running:
docker run -d --name=kafka -p 9092:9092 apache/kafka:4.1.2
What does this command do?
- The -d option keeps the container running in the background.
- The --name=kafka option gives the container a reusable local name.
- The -p 9092:9092 option exposes Kafka at localhost:9092.
- The apache/kafka:4.1.2 image pins the broker to the supported release used by this lab.
- Confirm that Docker prints a container ID and returns to the command prompt.
Kafka container did not start?
Make sure Docker Desktop is running before you retry the command. Another local service using port 9092 can also block the container.
Need help reading the failure? Help me troubleshoot why my local Kafka container did not start.
Before you check, do you think a running container is enough to prove the Kafka broker is responding? Hold that prediction before you run the cluster command.
- Verify that the broker responds by running:
docker exec -ti kafka /opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server :9092
What does this check prove?
The command executes Kafka's cluster utility inside the named container. A printed cluster ID proves that the broker is running and reachable through its bootstrap server.
You should see a Kafka cluster ID in the terminal. That result confirms that your local broker is ready to accept the topic created in the pipeline step.
Cluster ID missing?
The broker may still be starting after its first container launch. Wait briefly before running the verification command again.
If the command continues to fail, confirm that Docker Desktop is running and that the container startup command returned a container ID.
Still blocked? Help me diagnose why the Kafka cluster ID command is failing.
Your local lab now has a compatible runtime, pinned dependencies, and a responsive Kafka broker. That is the complete foundation for the event pipeline.
Your local infrastructure is ready. Next, you will turn the empty project into a visible Vue dashboard.
Build the Vue Dashboard Shell
Your local Apache Kafka broker is running. The broker has no screen that shows events moving through it.
A visible Vue dashboard solves that problem. Vite gives you a development server that displays each interface change in the browser.
In this step, get ready to:
- Connect the HTML entry point to a Vue single-file component.
- Build static metric cards with the first piece of reactive state.
- Style the dashboard shell and verify it in the browser.
Wire the Vue entry point
The browser needs an HTML page with a mounting point. Vue takes control of the element named #app and renders your component inside it.
- Return to the github-event-pulse project in your editor from earlier.
- Create index.html inside the github-event-pulse directory using your editor's file sidebar.
- Add the page structure to index.html by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A Vue dashboard for observing Kafka event flow and consumer lag." />
<title>GitHub Event Pulse</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this page do?
- The #app element gives Vue a specific place to render the dashboard.
- The module script loads src/main.js as the application's entry point.
- The description records the dashboard's purpose for browsers and search tools.
- Save index.html.
- Confirm that index.html appears beside package.json in your editor's file sidebar.
Cannot find the mounting point?
Check that the element ID is exactly app. Confirm that the script path begins with /src/.
Still stuck? Help me check the Vue mounting point in my index.html file.
✔️ Awesome, I've got everything!
Great. Your HTML entry point is saved with the Vue mounting element.
ⓧ 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.0" />
<meta name="description" content="A Vue dashboard for observing Kafka event flow and consumer lag." />
<title>GitHub Event Pulse</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Vite needs its Vue plugin to understand .vue files. The plugin transforms your component into code the browser can run.
- Create vite.config.js inside the github-event-pulse directory using your editor's file sidebar.
- Configure the Vue plugin in vite.config.js by pasting this code:
import vue from '@vitejs/plugin-vue'
export default {
plugins: [vue()]
}
What does this configuration do?
- The import loads the Vue plugin that is already installed in your project.
- The exported configuration activates that plugin whenever Vite starts.
- Save vite.config.js.
- Confirm that vite.config.js appears beside index.html in the file sidebar.
Plugin configuration looks different?
Check the import spelling against the saved dependency in package.json. Keep the parentheses after vue inside the plugin list.
Need a second pair of eyes? Help me compare my Vite Vue plugin configuration.
✔️ Awesome, I've got everything!
Your Vite configuration now activates the Vue plugin.
ⓧ I'd like to double check the full code
import vue from '@vitejs/plugin-vue'
export default {
plugins: [vue()]
}
The JavaScript entry point creates the Vue application. It also loads the component and the shared stylesheet.
- Create main.js inside the existing src directory using your editor's file sidebar.
- Set up the Vue application in src/main.js by pasting this code:
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
How does the app start?
- The imports bring in Vue's application creator and your local component.
- The stylesheet import applies one shared visual system to the page.
- The final line mounts App inside the matching #app element.
- Save src/main.js.
- Confirm that main.js appears inside the src directory.
Entry files in the wrong place?
Keep index.html and vite.config.js beside package.json. Keep main.js inside src.
Need help checking the layout? Help me verify my Vue project file locations.
✔️ Awesome, I've got everything!
Your JavaScript entry point now connects the component to the page.
ⓧ I'd like to double check the full code
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
Build the static dashboard component
A Vue ref stores reactive state. When a referenced value changes, Vue updates every matching value in the rendered component.
- Create App.vue inside the existing src directory using your editor's file sidebar.
- Add the reactive state and dashboard header to src/App.vue by pasting this code:
<script setup>
import { ref } from 'vue'
const connectionStatus = ref('ready')
const producedCount = ref(0)
const processedCount = ref(0)
const consumerLag = ref(0)
const events = ref([])
</script>
<template>
<main class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Vue + Node.js + Apache Kafka</p>
<h1>GitHub Event Pulse</h1>
<p class="subtitle">A local dashboard for observing Kafka event flow, consumer lag, and recovery.</p>
</div>
<span class="connection">{{ connectionStatus }}</span>
</header>
</main>
</template>
What does this component do?
- The script creates reactive values for the connection state and future pipeline metrics.
- The template displays connectionStatus inside the header.
- The remaining refs give the next interface sections stable values to render.
- Save src/App.vue.
- Confirm that the file contains one script setup block followed by one template block.
Component structure not lining up?
Check that both top-level blocks have matching closing tags. Confirm that each reactive value uses ref with parentheses.
Still unsure? Help me check the structure of my App.vue component.
Metric cards turn internal counts into values you can see. All three begin at zero because the event pipeline arrives in the next step.
- Find the closing </main> tag in src/App.vue.
- Insert the metric grid directly above that tag by pasting this code:
<section class="metric-grid" aria-label="Pipeline metrics">
<article class="metric-card"><span>Produced</span><strong>{{ producedCount }}</strong></article>
<article class="metric-card"><span>Processed</span><strong>{{ processedCount }}</strong></article>
<article class="metric-card lag"><span>Consumer lag</span><strong>{{ consumerLag }}</strong></article>
</section>
What do the metrics represent?
- Produced tracks how many records enter Kafka.
- Processed tracks how many records the consumer finishes.
- Consumer lag represents the gap between produced records and committed progress.
- Save src/App.vue.
- Confirm that the metric grid contains the labels Produced, Processed, and Consumer lag.
Metric values missing?
Confirm that each template expression matches the corresponding ref name exactly. Vue identifiers are case-sensitive.
Need help matching them? Help me compare my metric bindings with my Vue refs.
The controls establish the three actions your final dashboard exposes. Their behavior arrives after the server pipeline exists.
- Find the closing </main> tag again.
- Insert the controls directly above that tag by pasting this code:
<section class="controls">
<button>Load GitHub batch</button>
<button class="secondary active">Slow consumer</button>
<button class="secondary">Recover now</button>
</section>
Why add the controls now?
The buttons reserve a stable place for loading events and switching processing modes. You can shape the dashboard before asynchronous data adds more moving parts.
- Save src/App.vue.
- Confirm that the controls contain three button elements.
Controls outside the page shell?
Keep the controls above the closing </main> tag. Check that the section has the class controls.
Need help placing the section? Help me position the controls inside my App.vue page shell.
The feed panel gives consumed records a visible destination. Its empty state also confirms that the component can react to the current length of events.
- Find the closing </main> tag once more.
- Insert the empty event feed directly above that tag by pasting this code:
<section class="feed-panel">
<div class="feed-heading">
<div><p class="eyebrow">Consumed records</p><h2>Public GitHub activity</h2></div>
<span>waiting for pipeline</span>
</div>
<div v-if="events.length === 0" class="empty-state">Load a batch to send the default 15 GitHub events through Kafka.</div>
</section>
How does the empty state work?
- The feed heading names the records that eventually arrive from the consumer.
- The v-if directive displays the empty-state message while the events array has no entries.
- Save src/App.vue.
- Confirm that the feed panel appears after the controls inside main.
Empty state condition missing?
Check that the directive is exactly v-if="events.length === 0". Confirm that events is declared as a ref in the script.
Still stuck? Help me troubleshoot my Vue empty-state condition.
✔️ Awesome, I've got everything!
Your component now contains the header, metrics, controls, and empty event feed.
ⓧ I'd like to double check the full code
<script setup>
import { ref } from 'vue'
const connectionStatus = ref('ready')
const producedCount = ref(0)
const processedCount = ref(0)
const consumerLag = ref(0)
const events = ref([])
</script>
<template>
<main class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Vue + Node.js + Apache Kafka</p>
<h1>GitHub Event Pulse</h1>
<p class="subtitle">A local dashboard for observing Kafka event flow, consumer lag, and recovery.</p>
</div>
<span class="connection">{{ connectionStatus }}</span>
</header>
<section class="metric-grid" aria-label="Pipeline metrics">
<article class="metric-card"><span>Produced</span><strong>{{ producedCount }}</strong></article>
<article class="metric-card"><span>Processed</span><strong>{{ processedCount }}</strong></article>
<article class="metric-card lag"><span>Consumer lag</span><strong>{{ consumerLag }}</strong></article>
</section>
<section class="controls">
<button>Load GitHub batch</button>
<button class="secondary active">Slow consumer</button>
<button class="secondary">Recover now</button>
</section>
<section class="feed-panel">
<div class="feed-heading">
<div><p class="eyebrow">Consumed records</p><h2>Public GitHub activity</h2></div>
<span>waiting for pipeline</span>
</div>
<div v-if="events.length === 0" class="empty-state">Load a batch to send the default 15 GitHub events through Kafka.</div>
</section>
</main>
</template>
Style and run the dashboard
The component now has its full structure. CSS turns that structure into a dashboard where the metrics and event feed are easy to scan.
- Create style.css inside the existing src directory using your editor's file sidebar.
- Set the global type and page background in src/style.css by pasting this code:
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eefc;
background: #08111f;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(91, 124, 250, 0.2), transparent 34rem),
#08111f;
}
What do the global styles control?
- The root rules establish the page font and shared colors.
- The universal selector keeps element dimensions predictable.
- The body rules create the dark background that frames the dashboard.
- Save src/style.css.
- Confirm that style.css appears beside App.vue in the src directory.
Stylesheet not loading?
Check that the file is named exactly style.css. Confirm that main.js imports ./style.css.
Need help finding the mismatch? Help me troubleshoot why my Vue stylesheet is not loading.
Before you start the development server, what parts of the component do you expect the browser to render?
- Start the Vite development server from the github-event-pulse directory by running:
npm run dev
The terminal shows that the development server is available at http://localhost:5173. Leave this terminal running while you style the page.
- Open http://localhost:5173 in your browser.
You should see the GitHub Event Pulse heading with three zero-valued metrics. You should also see three controls and the empty event feed.
Development server did not start?
Confirm that your terminal is inside the github-event-pulse directory. Check that the earlier dependency installation completed successfully.
Need help with the server? Help me troubleshoot why my Vite development server will not start.
- Add the button and page-shell styles below the existing CSS by pasting this code:
button {
border: 0;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: white;
background: #5b7cfa;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.secondary {
color: #c9d6f3;
background: #15233b;
}
button.secondary.active {
outline: 2px solid #7f9bff;
}
.page-shell {
width: min(1050px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
What changes on the page?
The button rules distinguish the main action from the processing-mode controls. The page shell centers the dashboard while preserving space around its content.
- Save src/style.css.
- Return to the browser to see a centered page with blue and dark control buttons.
Buttons still look plain?
Check that the secondary buttons still use the secondary class in App.vue. Confirm that the active button also uses active.
Need help matching the classes? Help me troubleshoot the dashboard button styles.
- Add the shared layout and heading styles below the existing CSS by pasting this code:
.hero,
.feed-heading,
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hero {
align-items: flex-start;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.5rem;
font-size: clamp(2.2rem, 7vw, 4.7rem);
line-height: 0.95;
}
h2 {
margin-bottom: 0;
}
How does the layout change?
Flex layouts separate headings from their status values. The heading rules give the dashboard title visual priority without changing the component structure.
- Save src/style.css.
- Return to the browser to see a larger GitHub Event Pulse heading with the connection status aligned beside it.
Header items stacked unexpectedly?
Confirm that the header uses the hero class. Check that the selector list includes .hero before the shared flex rules.
Need layout help? Help me troubleshoot the hero flex layout in my dashboard.
- Add the label and connection styles below the existing CSS by pasting this code:
.eyebrow {
margin-bottom: 0.45rem;
color: #8fa8ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.subtitle {
max-width: 700px;
color: #9cafcf;
}
.connection {
border: 1px solid #34496d;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: #9cafcf;
background: #101d31;
}
What do these details add?
The eyebrow labels separate context from primary headings. The connection value becomes a compact status pill that remains easy to spot.
- Save src/style.css.
- Return to the browser to see uppercase context labels and a rounded ready status pill.
Status pill missing its shape?
Check that the status span uses the connection class. Confirm that the class name has no leading period inside App.vue.
Need help comparing the selector? Help me fix the connection status styling.
- Add the metric grid and card styles below the existing CSS by pasting this code:
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin: 2rem 0 1rem;
}
.metric-card,
.feed-panel {
border: 1px solid #203251;
border-radius: 1rem;
background: rgba(13, 25, 44, 0.9);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.2);
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
margin-bottom: 0.4rem;
color: #9cafcf;
}
.metric-card strong {
font-size: 2rem;
}
How do the metrics become scannable?
The responsive grid gives each metric its own card. Shared borders and backgrounds visually connect the cards with the event feed.
- Save src/style.css.
- Return to the browser to see three dark metric cards arranged in a responsive row.
Metric cards still run together?
Confirm that the wrapper uses the metric-grid class. Check that each article uses metric-card.
Need help with the grid? Help me troubleshoot my metric-card layout.
- Finish the controls and empty-feed styles below the existing CSS by pasting this code:
.controls {
justify-content: flex-start;
flex-wrap: wrap;
margin: 1rem 0 2rem;
}
.feed-panel {
padding: 1.25rem;
}
.feed-heading > span {
color: #8fa8ff;
}
.empty-state {
border: 1px dashed #34496d;
border-radius: 0.8rem;
padding: 1rem;
color: #9cafcf;
text-align: center;
}
What completes the shell?
The controls stay grouped while wrapping on narrow screens. The dashed empty state makes the feed's current lack of records visible.
- Save src/style.css.
- Return to the browser to see the controls above a bordered event-feed panel with a centered empty-state message.
Feed panel still looks unstyled?
Confirm that the feed section uses feed-panel. Check that the message element uses empty-state.
Need help finding a mismatch? Help me troubleshoot the feed-panel and empty-state styles.
✔️ Awesome, I've got everything!
Your dashboard now has its complete initial visual system.
ⓧ I'd like to double check the full code
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eefc;
background: #08111f;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(91, 124, 250, 0.2), transparent 34rem),
#08111f;
}
button {
border: 0;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: white;
background: #5b7cfa;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.secondary {
color: #c9d6f3;
background: #15233b;
}
button.secondary.active {
outline: 2px solid #7f9bff;
}
.page-shell {
width: min(1050px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero,
.feed-heading,
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hero {
align-items: flex-start;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.5rem;
font-size: clamp(2.2rem, 7vw, 4.7rem);
line-height: 0.95;
}
h2 {
margin-bottom: 0;
}
.eyebrow {
margin-bottom: 0.45rem;
color: #8fa8ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.subtitle {
max-width: 700px;
color: #9cafcf;
}
.connection {
border: 1px solid #34496d;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: #9cafcf;
background: #101d31;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin: 2rem 0 1rem;
}
.metric-card,
.feed-panel {
border: 1px solid #203251;
border-radius: 1rem;
background: rgba(13, 25, 44, 0.9);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.2);
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
margin-bottom: 0.4rem;
color: #9cafcf;
}
.metric-card strong {
font-size: 2rem;
}
.controls {
justify-content: flex-start;
flex-wrap: wrap;
margin: 1rem 0 2rem;
}
.feed-panel {
padding: 1.25rem;
}
.feed-heading > span {
color: #8fa8ff;
}
.empty-state {
border: 1px dashed #34496d;
border-radius: 0.8rem;
padding: 1rem;
color: #9cafcf;
text-align: center;
}
The connection pill reads directly from reactive state. A small source change lets you watch Vue replace that value without any manual DOM editing.
- Find const connectionStatus = ref('ready') in src/App.vue.
- Replace ready with connecting.
- Save src/App.vue.
The status pill changes to connecting in the browser. Vue rendered the new ref value into the existing component.
- Restore the status by replacing connecting with ready.
- Save src/App.vue.
The connection pill returns to ready. Your target component state now matches the completed dashboard shell.
Before you check the finished page, which interface elements should prove that every application file is connected correctly?
- Return to http://localhost:5173 in your browser.
- Reload the dashboard page.
You should see the GitHub Event Pulse heading and three metric cards. You should also see three controls above an empty Public GitHub activity feed.
Dashboard missing a section?
Compare src/App.vue with the full-code tab. Check that every section remains inside the page-shell element.
Still seeing a blank page? Help me troubleshoot my Vue dashboard shell.
Your Vue dashboard shell is live and ready to display real event flow. Next, you will build the Node.js pipeline that connects GitHub activity to Kafka.
Build the Kafka Event Pipeline
Your Vue dashboard now gives the project a visible shell. Its counters still have no event pipeline behind them.
A local Node.js server will connect the public GitHub activity feed to Apache Kafka. It will also expose the consumed records through server-sent events for the dashboard to use in the next step.
In this step, get ready to:
- Create the Kafka topic plus the producer and consumer clients.
- Publish public GitHub activity and process it with manual commits.
- Expose pipeline controls through a local HTTP server with lag monitoring.
Create the topic and Kafka clients
A Kafka topic stores the ordered event records. A producer writes those records while a consumer group tracks processing progress through committed offsets.
- In the editor from earlier, create server.mjs inside the github-event-pulse folder.
- Add the pipeline imports plus its shared configuration by pasting this code into server.mjs:
import * as http from 'node:http'
import {
Admin,
Consumer,
MessagesStreamFallbackModes,
MessagesStreamModes,
Producer,
stringDeserializers,
stringSerializers
} from '@platformatic/kafka'
const TOPIC = 'github-public-events-v1'
const GROUP_ID = 'github-event-pulse-dashboard-v1'
const API_PORT = 3000
const BROKERS = ['localhost:9092']
const clients = new Set()
let stream
let shuttingDown = false
let mode = 'slow'
let processingDelayMs = 650
let lag = 0
let producedCount = 0
let processedCount = 0
let pipelineStatus = 'starting'
let lastError = ''
What does this foundation define?
- The imports provide the HTTP server plus the Kafka administration, production, consumption, serialization, and stream-mode tools.
- The constants give the topic, consumer group, API port, and local broker one shared source of truth.
- The mutable values hold the pipeline mode, processing delay, lag, counters, connection clients, and latest error.
- Save server.mjs.
- Check the file syntax by running this command in the terminal from earlier:
node --check server.mjs
The command returns to the prompt without a syntax error.
Seeing a syntax error?
Check the braces around the import list. Confirm that every string uses matching quotes.
Still stuck? Help me compare the imports and shared configuration in server.mjs with the provided code.
- Add the producer and consumer clients below let lastError = '' by pasting this code:
const producer = new Producer({
clientId: 'github-event-pulse-producer',
bootstrapBrokers: BROKERS,
serializers: stringSerializers,
retries: true,
retryDelay: 1000
})
const consumer = new Consumer({
groupId: GROUP_ID,
clientId: 'github-event-pulse-consumer',
bootstrapBrokers: BROKERS,
deserializers: stringDeserializers,
autocommit: false,
retries: true,
retryDelay: 1000
})
How do the clients divide the work?
- The producer serializes event keys plus event values before sending them to the broker.
- The consumer joins github-event-pulse-dashboard-v1 with automatic commits disabled.
- The retry settings keep both clients attempting to reconnect if the local broker becomes temporarily unavailable.
- Save server.mjs.
- Check the expanded file syntax by running:
node --check server.mjs
The command returns silently when both client configurations are valid JavaScript.
Client configuration not passing?
Confirm that both constructor calls end with }). Check that BROKERS matches the constant declared above.
Need another set of eyes? Help me find the syntax problem in my Platformatic producer and consumer configuration.
- Add the timing, status, and broadcast helpers below the consumer configuration by pasting this code:
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
function snapshot() {
return {
pipelineStatus,
mode,
lag,
producedCount,
processedCount,
error: lastError
}
}
function sendEvent(client, eventName, payload) {
client.write(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`)
}
function broadcast(eventName, payload) {
for (const client of clients) sendEvent(client, eventName, payload)
}
function broadcastStatus() {
broadcast('status', snapshot())
}
What do these helpers manage?
- The delay helper makes the consumer pause during slow mode.
- The snapshot helper packages the latest pipeline state for browser clients.
- The event helpers format named SSE messages and send them to every connected browser.
- Save server.mjs.
- Confirm the helper syntax by running:
node --check server.mjs
The command returns to the prompt without reporting an unterminated template string.
Template string not closing?
Check the backticks around the event payload in sendEvent(). Preserve every \n sequence because SSE messages depend on those line breaks.
Need help with the event format? Help me inspect the sendEvent helper in server.mjs.
- Add the JSON response helper plus the topic setup function below broadcastStatus() by pasting this code:
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
})
response.end(JSON.stringify(payload))
}
async function ensureTopic() {
const admin = new Admin({
clientId: 'github-event-pulse-admin',
bootstrapBrokers: BROKERS
})
const topics = await admin.listTopics()
if (!topics.includes(TOPIC)) {
await admin.createTopics({
topics: [TOPIC],
partitions: 1,
replicas: 1
})
}
await admin.close()
}
Why check the topic first?
The admin client lists the existing topics before creating github-public-events-v1. Repeated server starts therefore reuse the same one-partition topic.
One partition keeps every record in a single ordered offset sequence. That makes committed progress easier to observe during this lab.
- Save server.mjs.
- Check the topic setup syntax by running:
node --check server.mjs
The terminal returns to the prompt without an error.
Topic setup not passing?
Confirm that createTopics() receives topics, partitions, and replicas inside one object.
Still seeing a syntax error? Help me compare my ensureTopic function with the target code.
Publish and consume GitHub events
The public GitHub REST API supplies the latest activity batch. Each response becomes a string-serialized Kafka message with the event ID, type, actor, repository, timestamp, and batch ID.
The consumer commits each message after the artificial processing delay. The committed position therefore represents completed work instead of prefetched work.
- Add the first half of fetchAndPublish() below ensureTopic() by pasting this code:
async function fetchAndPublish() {
pipelineStatus = 'publishing'
lastError = ''
broadcastStatus()
const response = await fetch('https://api.github.com/events', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10'
}
})
if (!response.ok) {
throw new Error(`GitHub request failed with status ${response.status}`)
}
const events = await response.json()
const batchId = Date.now().toString()
const messages = events.map(event => ({
topic: TOPIC,
key: `${event.id}:${batchId}`,
value: JSON.stringify({
id: event.id,
type: event.type,
actor: event.actor.login,
repo: event.repo.name,
createdAt: event.created_at,
batchId
})
}))
How does the batch become messages?
- The request uses GitHub's public events endpoint without a credential.
- The response check turns a failed request into an error for the API route to report.
- The mapping step creates one Kafka message for every event in the default batch.
- Save the first half of the publishing function in server.mjs.
- Confirm that the file grew by running this line-count command:
wc -l server.mjs
The terminal prints a line count followed by server.mjs. This confirms that the first publishing section is saved.
- Leave one blank line after })).
- Complete fetchAndPublish() by adding this code after that blank line:
await producer.send({ messages })
producedCount += messages.length
pipelineStatus = 'ready'
broadcastStatus()
return messages.length
}
When is a batch counted?
The producer sends the complete message array before the produced counter changes. The status then returns to ready and the function reports the batch size.
- Save server.mjs.
- Check the completed publishing function by running:
node --check server.mjs
The command returns without an error once the publishing function is complete.
Publishing function not closing?
Confirm that the event mapping ends with })). Confirm that the final brace closes fetchAndPublish().
Need help tracing the brackets? Help me locate the unmatched bracket in fetchAndPublish.
- Add the consumer loop plus its lag listeners below fetchAndPublish() by pasting this code:
async function consumeEvents() {
for await (const message of stream) {
if (processingDelayMs > 0) await sleep(processingDelayMs)
const event = JSON.parse(message.value)
await message.commit()
processedCount += 1
broadcast('message', {
...event,
partition: message.partition,
offset: message.offset.toString()
})
broadcastStatus()
}
}
consumer.on('consumer:lag', lagMap => {
const partitions = lagMap.get(TOPIC) ?? []
lag = partitions.reduce((total, value) => (value >= 0n ? total + Number(value) : total), 0)
broadcastStatus()
})
consumer.on('consumer:lag:error', error => {
lastError = error.message
broadcastStatus()
})
How does committed progress become visible?
- The async loop waits for each record from the consumer stream.
- Slow mode pauses before the record is committed.
- A successful commit advances the consumer group's durable progress.
- The lag listener adds the non-negative partition lag values and broadcasts the total.
- Save server.mjs.
- Check the consumer and lag-listener syntax by running:
node --check server.mjs
The command returns silently when the async loop plus both listeners are valid.
Consumer loop not passing?
Check that for await stays inside the async function. Confirm that both consumer.on() calls close with }).
Still stuck? Help me inspect the consumer loop and lag listeners in server.mjs.
Expose and start the pipeline API
The HTTP server gives the browser four entry points. It streams events, reports status, triggers publishing, and changes the processing mode.
The route handler is one continuous callback split into three code pieces for readability. The completed server will listen at http://localhost:3000.
- Add the first HTTP handler section below the lag listeners by pasting this code:
const server = http.createServer(async (request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
})
response.end()
return
}
if (request.method === 'GET' && request.url === '/events') {
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream'
})
response.write('retry: 1000\n\n')
clients.add(response)
sendEvent(response, 'status', snapshot())
request.on('close', () => clients.delete(response))
return
}
How does the event stream stay open?
The /events route responds with text/event-stream and keeps the connection alive. It immediately sends the current status to each new client.
The close listener removes disconnected clients from the set. Future broadcasts therefore target active connections.
- Save the first route section in server.mjs.
- Confirm that the route section is saved by running:
wc -l server.mjs
The terminal prints the updated line count plus the file name.
- Add the status and fetch routes directly below the first route section by pasting this code:
if (request.method === 'GET' && request.url === '/api/status') {
sendJson(response, 200, snapshot())
return
}
if (request.method === 'POST' && request.url === '/api/fetch') {
try {
const count = await fetchAndPublish()
sendJson(response, 200, { count })
} catch (error) {
pipelineStatus = 'ready'
lastError = error.message
broadcastStatus()
sendJson(response, 500, { error: error.message })
}
return
}
What do these API routes return?
- The status route returns the current pipeline snapshot as JSON.
- The fetch route publishes a GitHub batch and returns its message count.
- A failed GitHub request becomes a JSON error plus an updated status broadcast.
- Save the second route section in server.mjs.
- Confirm that both route sections are present by running:
wc -l server.mjs
The new line count is larger than the previous result.
- Complete the HTTP handler with the mode route and fallback response by pasting this code directly below the fetch route:
if (request.method === 'POST' && request.url?.startsWith('/api/mode')) {
const url = new URL(request.url, 'http://localhost')
const nextMode = url.searchParams.get('value')
if (!['slow', 'fast'].includes(nextMode)) {
sendJson(response, 400, { error: 'Mode must be slow or fast' })
return
}
mode = nextMode
processingDelayMs = mode === 'slow' ? 650 : 0
broadcastStatus()
sendJson(response, 200, snapshot())
return
}
sendJson(response, 404, { error: 'Not found' })
})
How does recovery mode work?
The mode route accepts slow or fast. Slow mode uses a 650 millisecond delay while fast mode sets the delay to zero.
Every unrecognized route receives a JSON response with status code 404.
- Save server.mjs.
- Check the completed HTTP handler by running:
node --check server.mjs
The command now returns without an error because the HTTP callback is complete.
HTTP handler not closing?
Confirm that each route returns after sending its response. Check that the final }) closes the createServer() callback.
Need help with the route structure? Help me trace the HTTP handler braces in server.mjs.
- Add the startup function below the HTTP handler by pasting this code:
async function start() {
await ensureTopic()
stream = await consumer.consume({
topics: [TOPIC],
mode: MessagesStreamModes.COMMITTED,
fallbackMode: MessagesStreamFallbackModes.EARLIEST,
autocommit: false
})
consumer.startLagMonitoring({ topics: [TOPIC] }, 500)
pipelineStatus = 'ready'
consumeEvents().catch(error => {
if (!shuttingDown) {
pipelineStatus = 'error'
lastError = error.message
broadcastStatus()
}
})
server.listen(API_PORT, () => {
console.log(`Pipeline API listening at http://localhost:${API_PORT}`)
})
}
What happens during startup?
- The topic check runs before consumption starts.
- Committed mode resumes from the consumer group's stored offsets.
- The earliest fallback starts at the beginning when the group has no committed position.
- Lag monitoring samples the topic while the HTTP server listens on port 3000.
- Save server.mjs.
- Check the startup function by running:
node --check server.mjs
The command returns without a syntax error.
Startup function not passing?
Check the spelling of MessagesStreamModes.COMMITTED and MessagesStreamFallbackModes.EARLIEST. Confirm that server.listen() remains inside start().
Still seeing an error? Help me inspect the startup function in server.mjs.
- Add graceful shutdown plus the process signal handlers below start() by pasting this code:
async function shutdown() {
if (shuttingDown) return
shuttingDown = true
for (const client of clients) client.end()
consumer.stopLagMonitoring()
try {
if (stream) await stream.close()
await consumer.close()
await producer.close()
} finally {
server.close()
process.exit(0)
}
}
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
start().catch(error => {
console.error(error)
process.exit(1)
})
Why close the pipeline gracefully?
The shutdown function closes browser streams plus the lag monitor before disconnecting the Kafka clients. It also prevents the cleanup sequence from running twice.
The signal handlers connect terminal shutdown requests to that cleanup. A startup failure prints the error before the process exits.
- Save server.mjs.
- Run one final syntax check:
node --check server.mjs
The command returns to the prompt without a syntax error. Your complete pipeline server is ready to contact Kafka.
Shutdown code not passing?
Confirm that the try block has a matching finally block. Check that both process signal handlers appear after shutdown() closes.
Need help finding the mismatch? Help me compare the shutdown and startup call at the bottom of server.mjs.
Use the tabs below to compare your completed file before starting the server.
✔️ Awesome, I've got everything!
Great. Confirm that server.mjs is saved before you start the pipeline.
ⓧ I'd like to double check the full code
import * as http from 'node:http'
import {
Admin,
Consumer,
MessagesStreamFallbackModes,
MessagesStreamModes,
Producer,
stringDeserializers,
stringSerializers
} from '@platformatic/kafka'
const TOPIC = 'github-public-events-v1'
const GROUP_ID = 'github-event-pulse-dashboard-v1'
const API_PORT = 3000
const BROKERS = ['localhost:9092']
const clients = new Set()
let stream
let shuttingDown = false
let mode = 'slow'
let processingDelayMs = 650
let lag = 0
let producedCount = 0
let processedCount = 0
let pipelineStatus = 'starting'
let lastError = ''
const producer = new Producer({
clientId: 'github-event-pulse-producer',
bootstrapBrokers: BROKERS,
serializers: stringSerializers,
retries: true,
retryDelay: 1000
})
const consumer = new Consumer({
groupId: GROUP_ID,
clientId: 'github-event-pulse-consumer',
bootstrapBrokers: BROKERS,
deserializers: stringDeserializers,
autocommit: false,
retries: true,
retryDelay: 1000
})
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
function snapshot() {
return {
pipelineStatus,
mode,
lag,
producedCount,
processedCount,
error: lastError
}
}
function sendEvent(client, eventName, payload) {
client.write(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`)
}
function broadcast(eventName, payload) {
for (const client of clients) sendEvent(client, eventName, payload)
}
function broadcastStatus() {
broadcast('status', snapshot())
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
})
response.end(JSON.stringify(payload))
}
async function ensureTopic() {
const admin = new Admin({
clientId: 'github-event-pulse-admin',
bootstrapBrokers: BROKERS
})
const topics = await admin.listTopics()
if (!topics.includes(TOPIC)) {
await admin.createTopics({
topics: [TOPIC],
partitions: 1,
replicas: 1
})
}
await admin.close()
}
async function fetchAndPublish() {
pipelineStatus = 'publishing'
lastError = ''
broadcastStatus()
const response = await fetch('https://api.github.com/events', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10'
}
})
if (!response.ok) {
throw new Error(`GitHub request failed with status ${response.status}`)
}
const events = await response.json()
const batchId = Date.now().toString()
const messages = events.map(event => ({
topic: TOPIC,
key: `${event.id}:${batchId}`,
value: JSON.stringify({
id: event.id,
type: event.type,
actor: event.actor.login,
repo: event.repo.name,
createdAt: event.created_at,
batchId
})
}))
await producer.send({ messages })
producedCount += messages.length
pipelineStatus = 'ready'
broadcastStatus()
return messages.length
}
async function consumeEvents() {
for await (const message of stream) {
if (processingDelayMs > 0) await sleep(processingDelayMs)
const event = JSON.parse(message.value)
await message.commit()
processedCount += 1
broadcast('message', {
...event,
partition: message.partition,
offset: message.offset.toString()
})
broadcastStatus()
}
}
consumer.on('consumer:lag', lagMap => {
const partitions = lagMap.get(TOPIC) ?? []
lag = partitions.reduce((total, value) => (value >= 0n ? total + Number(value) : total), 0)
broadcastStatus()
})
consumer.on('consumer:lag:error', error => {
lastError = error.message
broadcastStatus()
})
const server = http.createServer(async (request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
})
response.end()
return
}
if (request.method === 'GET' && request.url === '/events') {
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream'
})
response.write('retry: 1000\n\n')
clients.add(response)
sendEvent(response, 'status', snapshot())
request.on('close', () => clients.delete(response))
return
}
if (request.method === 'GET' && request.url === '/api/status') {
sendJson(response, 200, snapshot())
return
}
if (request.method === 'POST' && request.url === '/api/fetch') {
try {
const count = await fetchAndPublish()
sendJson(response, 200, { count })
} catch (error) {
pipelineStatus = 'ready'
lastError = error.message
broadcastStatus()
sendJson(response, 500, { error: error.message })
}
return
}
if (request.method === 'POST' && request.url?.startsWith('/api/mode')) {
const url = new URL(request.url, 'http://localhost')
const nextMode = url.searchParams.get('value')
if (!['slow', 'fast'].includes(nextMode)) {
sendJson(response, 400, { error: 'Mode must be slow or fast' })
return
}
mode = nextMode
processingDelayMs = mode === 'slow' ? 650 : 0
broadcastStatus()
sendJson(response, 200, snapshot())
return
}
sendJson(response, 404, { error: 'Not found' })
})
async function start() {
await ensureTopic()
stream = await consumer.consume({
topics: [TOPIC],
mode: MessagesStreamModes.COMMITTED,
fallbackMode: MessagesStreamFallbackModes.EARLIEST,
autocommit: false
})
consumer.startLagMonitoring({ topics: [TOPIC] }, 500)
pipelineStatus = 'ready'
consumeEvents().catch(error => {
if (!shuttingDown) {
pipelineStatus = 'error'
lastError = error.message
broadcastStatus()
}
})
server.listen(API_PORT, () => {
console.log(`Pipeline API listening at http://localhost:${API_PORT}`)
})
}
async function shutdown() {
if (shuttingDown) return
shuttingDown = true
for (const client of clients) client.end()
consumer.stopLagMonitoring()
try {
if (stream) await stream.close()
await consumer.close()
await producer.close()
} finally {
server.close()
process.exit(0)
}
}
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
start().catch(error => {
console.error(error)
process.exit(1)
})
Before you run the server, predict whether it can create or reuse the topic and connect to Kafka without an error.
- Start the pipeline server by running:
npm run server
You should see Pipeline API listening at http://localhost:3000 without a Kafka connection error.
That is the pipeline backbone running. Your local server can now create the topic, consume with committed offsets, monitor lag, and accept browser connections.
Pipeline server not starting?
Confirm that the kafka container from earlier is still running. A stopped broker prevents the clients from reaching localhost:9092.
If the terminal reports a JavaScript syntax problem, run the syntax check again and compare server.mjs with the full-code tab.
Need help diagnosing the startup output? Help me troubleshoot why my Kafka pipeline server will not start.
Your Kafka pipeline is live beside the static dashboard. Next, you will connect the Vue interface to these endpoints and make consumer lag visible.
Connect the Dashboard and Create Lag
Your Apache Kafka pipeline can already publish public GitHub activity. Your Node.js consumer can process each record.
The Vue shell still cannot observe that pipeline. This step connects the dashboard to live status updates before exposing the slow consumer as visible lag.
In this step, get ready to:
- Connect the dashboard to the pipeline with server-sent events.
- Merge live status updates into the metric cards.
- Load a public GitHub batch while the slow consumer creates visible lag.
Connect Vue to the event stream
Server-sent events give the browser a one-way stream from the pipeline API. An EventSource connection receives status snapshots plus consumed records without repeatedly polling the server.
- Return to the terminal for the github-event-pulse project.
- Start the pipeline API by running:
npm run server
You should see Pipeline API listening at http://localhost:3000 in the terminal.
- Leave the pipeline API terminal running.
- Use a second terminal for the Vue development server.
- Start the dashboard by running:
npm run dev
- Open http://localhost:5173 in your browser.
You should still see the static dashboard from the previous step. The three metric cards should each show zero.
Can't start both processes?
Keep npm run server in one terminal. Keep npm run dev in a separate terminal.
If the dashboard cannot reach the API, confirm that the server terminal still shows the listening message. Help me check why my local Vue dashboard cannot reach the pipeline API.
The static component stores each metric in a separate ref. The connected version keeps the pipeline snapshot together so every status event can update the cards consistently.
- In src/App.vue, replace the current import and state declarations inside <script setup> with this code:
import { computed, onMounted, onUnmounted, onUpdated, ref } from 'vue'
const API_BASE = 'http://localhost:3000'
const connectionStatus = ref('connecting')
const requestPending = ref(false)
const errorMessage = ref('')
const events = ref([])
const status = ref({
pipelineStatus: 'starting',
mode: 'slow',
lag: 0,
producedCount: 0,
processedCount: 0
})
const lagTone = computed(() => {
if (status.value.lag === 0) return 'clear'
if (status.value.lag < 8) return 'warning'
return 'danger'
})
let eventSource
What does this state model do?
- The status ref mirrors the pipeline snapshot sent by the server.
- The lagTone computed value turns the current lag into a visual state.
- The eventSource variable holds the browser connection so the component can close it later.
- Replace the existing connection badge and metric grid with this code:
<span class="connection" :class="connectionStatus">{{ connectionStatus }}</span>
</header>
<section class="metric-grid" aria-label="Pipeline metrics">
<article class="metric-card">
<span>Produced</span>
<strong>{{ status.producedCount }}</strong>
</article>
<article class="metric-card">
<span>Processed</span>
<strong>{{ status.processedCount }}</strong>
</article>
<article class="metric-card lag" :class="lagTone">
<span>Consumer lag</span>
<strong>{{ status.lag }}</strong>
</article>
</section>
How do the cards stay reactive?
Each card reads a property from status. Vue updates the rendered number whenever a new snapshot replaces the ref value.
The lag card also receives the class returned by lagTone. That class makes the size of the backlog visible at a glance.
- Save src/App.vue.
- Return to the browser to confirm that the connection badge now says connecting.
The component now has the right state shape. It still needs the stream connection that fills that state.
- Add these status and request helpers below let eventSource in src/App.vue by copying this code:
function mergeStatus(payload) {
status.value = { ...status.value, ...payload }
if (payload.error) errorMessage.value = payload.error
}
function formatTime(value) {
return new Date(value).toLocaleTimeString()
}
async function request(path) {
errorMessage.value = ''
const response = await fetch(`${API_BASE}${path}`, { method: 'POST' })
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || 'Request failed')
}
return payload
}
What do these helpers handle?
- The mergeStatus() helper updates only the fields included in each status event.
- The formatTime() helper converts each GitHub timestamp into a local display time.
- The request() helper sends control requests to the pipeline API.
- Add the mounted hook below request() by copying this code:
onMounted(() => {
console.log('GitHub Event Pulse mounted')
eventSource = new EventSource(`${API_BASE}/events`)
eventSource.onopen = () => {
connectionStatus.value = 'connected'
}
eventSource.onerror = () => {
connectionStatus.value = 'reconnecting'
}
eventSource.addEventListener('status', event => {
mergeStatus(JSON.parse(event.data))
})
eventSource.addEventListener('message', event => {
const incoming = JSON.parse(event.data)
events.value = [incoming, ...events.value].slice(0, 12)
})
})
How does the event stream work?
- The mounted hook opens the stream only after Vue attaches the component to the page.
- The named status listener merges pipeline snapshots into the metric state.
- The named message listener prepends each consumed record and keeps the newest 12 records.
- Save src/App.vue.
- Refresh the dashboard to reconnect the component.
You should see the connection badge change from connecting to connected. The metric cards now reflect the latest pipeline status.
Connection stuck on connecting?
Check that the API terminal is still running on http://localhost:3000. A stopped server leaves the browser waiting for the stream.
Check that API_BASE matches the local API address exactly. Help me debug an EventSource connection that stays on connecting.
Add controls and the live feed
The stream now carries data into Vue. The dashboard needs controls that request a batch plus a feed that renders each record as the consumer commits it.
- Insert these control handlers between request() and onMounted() in src/App.vue by copying this code:
async function loadEvents() {
requestPending.value = true
try {
await request('/api/fetch')
} catch (error) {
errorMessage.value = error.message
} finally {
requestPending.value = false
}
}
async function setMode(mode) {
try {
await request(`/api/mode?value=${mode}`)
} catch (error) {
errorMessage.value = error.message
}
}
What do the controls request?
- The loadEvents() handler asks the server to fetch and publish a GitHub batch.
- The requestPending ref prevents another batch request while the current one is running.
- The setMode() handler switches the server between slow processing and recovery mode.
- Replace the current controls section with this code:
<section class="controls">
<button :disabled="requestPending" @click="loadEvents">
{{ requestPending ? 'Publishing...' : 'Load GitHub batch' }}
</button>
<button class="secondary" :class="{ active: status.mode === 'slow' }" @click="setMode('slow')">
Slow consumer
</button>
<button class="secondary" :class="{ active: status.mode === 'fast' }" @click="setMode('fast')">
Recover now
</button>
</section>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
How does the template use those handlers?
The first button calls loadEvents() and shows a pending label during the request. The mode buttons call setMode() with the selected processing speed.
The error paragraph only renders when the API returns a problem. That keeps normal pipeline status separate from actionable errors.
- Save src/App.vue.
- Click Recover now to test the mode control.
You should see Recover now gain the active outline. The status snapshot should report fast mode.
- Click Slow consumer to restore the teaching delay.
You should see the active outline return to Slow consumer.
- Add the update and cleanup hooks directly below onMounted() by copying this code:
onUpdated(() => {
console.count('Vue dashboard DOM updated')
})
onUnmounted(() => {
eventSource?.close()
console.log('GitHub Event Pulse unmounted')
})
Why add update and cleanup hooks?
The update hook counts each rendered dashboard change in the browser console. The unmount hook closes the open event stream when Vue removes the component.
Closing the stream prevents an unused browser connection from remaining attached to the server.
- Replace the current subtitle with this code:
<p class="subtitle">
Publish the latest public GitHub activity into Kafka, then watch consumer lag and recovery.
</p>
What does the new subtitle clarify?
The subtitle describes the complete interaction shown on the page. A batch enters Kafka before the dashboard exposes processing progress.
- Save src/App.vue.
- Return to the browser to confirm that the new subtitle mentions publishing and recovery.
- Replace the current feed panel with this code:
<section class="feed-panel">
<div class="feed-heading">
<div>
<p class="eyebrow">Consumed records</p>
<h2>Public GitHub activity</h2>
</div>
<span>{{ status.pipelineStatus }}</span>
</div>
<p class="api-note">
GitHub's Events API is a recent activity feed, not a guaranteed real-time source.
</p>
<div v-if="events.length === 0" class="empty-state">
Load a batch to send the default 15 GitHub events through Kafka.
</div>
<ul v-else class="event-list" aria-live="polite">
<li v-for="item in events" :key="`${item.batchId}-${item.id}-${item.offset}`">
<div>
<strong>{{ item.type }}</strong>
<span>{{ item.actor }} in {{ item.repo }}</span>
</div>
<small>partition {{ item.partition }} · offset {{ item.offset }} · {{ formatTime(item.createdAt) }}</small>
</li>
</ul>
</section>
How does the feed render consumed records?
- The pipeline status beside the heading shows the latest server state.
- The empty state remains visible until the first consumed record reaches Vue.
- The v-for list shows each event type plus its repository details and committed offset.
- Save src/App.vue.
- Return to the browser to confirm that the feed shows the recent-activity note and the empty state.
Dashboard shows a blank section?
Check that the closing tags in the feed panel match the reference code. A missing template tag can stop Vue from rendering the component.
Check the browser console for the first component error. Help me find a Vue template error in my live event feed.
Use this reference to compare the complete component before styling the live states.
✔️ Awesome, I've got everything!
Great. Save src/App.vue before you continue.
ⓧ I'd like to double check the full code
<script setup>
import { computed, onMounted, onUnmounted, onUpdated, ref } from 'vue'
const API_BASE = 'http://localhost:3000'
const connectionStatus = ref('connecting')
const requestPending = ref(false)
const errorMessage = ref('')
const events = ref([])
const status = ref({
pipelineStatus: 'starting',
mode: 'slow',
lag: 0,
producedCount: 0,
processedCount: 0
})
const lagTone = computed(() => {
if (status.value.lag === 0) return 'clear'
if (status.value.lag < 8) return 'warning'
return 'danger'
})
let eventSource
function mergeStatus(payload) {
status.value = { ...status.value, ...payload }
if (payload.error) errorMessage.value = payload.error
}
function formatTime(value) {
return new Date(value).toLocaleTimeString()
}
async function request(path) {
errorMessage.value = ''
const response = await fetch(`${API_BASE}${path}`, { method: 'POST' })
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || 'Request failed')
}
return payload
}
async function loadEvents() {
requestPending.value = true
try {
await request('/api/fetch')
} catch (error) {
errorMessage.value = error.message
} finally {
requestPending.value = false
}
}
async function setMode(mode) {
try {
await request(`/api/mode?value=${mode}`)
} catch (error) {
errorMessage.value = error.message
}
}
onMounted(() => {
console.log('GitHub Event Pulse mounted')
eventSource = new EventSource(`${API_BASE}/events`)
eventSource.onopen = () => {
connectionStatus.value = 'connected'
}
eventSource.onerror = () => {
connectionStatus.value = 'reconnecting'
}
eventSource.addEventListener('status', event => {
mergeStatus(JSON.parse(event.data))
})
eventSource.addEventListener('message', event => {
const incoming = JSON.parse(event.data)
events.value = [incoming, ...events.value].slice(0, 12)
})
})
onUpdated(() => {
console.count('Vue dashboard DOM updated')
})
onUnmounted(() => {
eventSource?.close()
console.log('GitHub Event Pulse unmounted')
})
</script>
<template>
<main class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Vue + Node.js + Apache Kafka</p>
<h1>GitHub Event Pulse</h1>
<p class="subtitle">
Publish the latest public GitHub activity into Kafka, then watch consumer lag and recovery.
</p>
</div>
<span class="connection" :class="connectionStatus">{{ connectionStatus }}</span>
</header>
<section class="metric-grid" aria-label="Pipeline metrics">
<article class="metric-card">
<span>Produced</span>
<strong>{{ status.producedCount }}</strong>
</article>
<article class="metric-card">
<span>Processed</span>
<strong>{{ status.processedCount }}</strong>
</article>
<article class="metric-card lag" :class="lagTone">
<span>Consumer lag</span>
<strong>{{ status.lag }}</strong>
</article>
</section>
<section class="controls">
<button :disabled="requestPending" @click="loadEvents">
{{ requestPending ? 'Publishing...' : 'Load GitHub batch' }}
</button>
<button class="secondary" :class="{ active: status.mode === 'slow' }" @click="setMode('slow')">
Slow consumer
</button>
<button class="secondary" :class="{ active: status.mode === 'fast' }" @click="setMode('fast')">
Recover now
</button>
</section>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
<section class="feed-panel">
<div class="feed-heading">
<div>
<p class="eyebrow">Consumed records</p>
<h2>Public GitHub activity</h2>
</div>
<span>{{ status.pipelineStatus }}</span>
</div>
<p class="api-note">
GitHub's Events API is a recent activity feed, not a guaranteed real-time source.
</p>
<div v-if="events.length === 0" class="empty-state">
Load a batch to send the default 15 GitHub events through Kafka.
</div>
<ul v-else class="event-list" aria-live="polite">
<li v-for="item in events" :key="`${item.batchId}-${item.id}-${item.offset}`">
<div>
<strong>{{ item.type }}</strong>
<span>{{ item.actor }} in {{ item.repo }}</span>
</div>
<small>partition {{ item.partition }} · offset {{ item.offset }} · {{ formatTime(item.createdAt) }}</small>
</li>
</ul>
</section>
</main>
</template>
The new states need visual cues so connection health and lag remain readable while records arrive.
- In src/style.css, replace the existing button rules with this code:
button {
border: 0;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: white;
background: #5b7cfa;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
button.secondary {
color: #c9d6f3;
background: #15233b;
}
button.secondary.active {
outline: 2px solid #7f9bff;
}
What changes while publishing?
The disabled rule lowers the button opacity while a batch request is pending. The wait cursor shows that the dashboard is still handling the request.
- Replace the subtitle and connection rules with this code:
.subtitle,
.api-note {
max-width: 700px;
color: #9cafcf;
}
.connection {
border: 1px solid #34496d;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: #9cafcf;
background: #101d31;
}
.connection.connected {
color: #76e6a5;
border-color: #2f7750;
}
.connection.reconnecting {
color: #ffc86e;
border-color: #8f6122;
}
How does connection health appear?
A connected stream uses green text and a green border. A reconnecting stream uses amber so a dropped API connection is easy to spot.
- Save src/style.css.
- Return to the dashboard to confirm that the connected badge is green.
Connection badge has no colour?
Check that the template binds :class="connectionStatus" to the badge. The dynamic class must match the CSS selector.
Confirm that the badge currently reads connected. Help me match my Vue connection class to the CSS selector.
- Replace the current metric value rule with this code:
.metric-card strong {
font-size: 2rem;
}
.lag.clear strong {
color: #76e6a5;
}
.lag.warning strong {
color: #ffc86e;
}
.lag.danger strong {
color: #ff7b91;
}
How does lag change colour?
Zero lag appears green. A small backlog appears amber.
A larger backlog appears red. The computed class lets the same card reflect all three states.
- Replace the existing feed panel rules with this code:
.feed-panel {
padding: 1.25rem;
}
.feed-heading > span {
color: #8fa8ff;
}
.api-note {
margin: 1rem 0;
font-size: 0.9rem;
}
.empty-state,
.event-list li {
border: 1px dashed #34496d;
border-radius: 0.8rem;
padding: 1rem;
}
.empty-state {
color: #9cafcf;
text-align: center;
}
What does the feed shell style?
The API note uses the same muted colour as the subtitle. The empty state and future event rows share the same spacing and rounded border.
- Save src/style.css.
- Return to the dashboard to confirm that zero lag is green and the activity note is muted.
- Add these event list rules below the empty-state rules:
.event-list {
display: grid;
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.event-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-style: solid;
background: #101d31;
}
.event-list span,
.event-list small {
display: block;
margin-top: 0.25rem;
color: #9cafcf;
}
How are consumed events arranged?
The list uses a compact grid with one row per consumed record. Each row separates the event identity from its partition and offset details.
- Add the error and mobile rules below the event list rules:
.error {
border: 1px solid #8d3345;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: #ffc4cf;
background: #361522;
}
@media (max-width: 680px) {
.hero,
.event-list li {
align-items: flex-start;
flex-direction: column;
}
}
What do the final styles cover?
API errors receive a red panel that separates them from ordinary status updates. Narrow screens stack the hero and event details vertically.
- Save src/style.css.
- Narrow the browser window to confirm that the hero content stacks vertically.
Styles missing after saving?
Confirm that src/main.js still imports ./style.css. The development server needs that import to include the stylesheet.
Check the terminal for a CSS syntax error near the last selector you changed. Help me find why the final dashboard styles are not loading.
Use this reference to confirm that the stylesheet matches the completed dashboard.
✔️ Awesome, I've got everything!
Your component and stylesheet are ready for the lag experiment.
ⓧ I'd like to double check the full code
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eefc;
background: #08111f;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(91, 124, 250, 0.2), transparent 34rem),
#08111f;
}
button {
border: 0;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: white;
background: #5b7cfa;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
button.secondary {
color: #c9d6f3;
background: #15233b;
}
button.secondary.active {
outline: 2px solid #7f9bff;
}
.page-shell {
width: min(1050px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero,
.feed-heading,
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hero {
align-items: flex-start;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.5rem;
font-size: clamp(2.2rem, 7vw, 4.7rem);
line-height: 0.95;
}
h2 {
margin-bottom: 0;
}
.eyebrow {
margin-bottom: 0.45rem;
color: #8fa8ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.subtitle,
.api-note {
max-width: 700px;
color: #9cafcf;
}
.connection {
border: 1px solid #34496d;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: #9cafcf;
background: #101d31;
}
.connection.connected {
color: #76e6a5;
border-color: #2f7750;
}
.connection.reconnecting {
color: #ffc86e;
border-color: #8f6122;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin: 2rem 0 1rem;
}
.metric-card,
.feed-panel {
border: 1px solid #203251;
border-radius: 1rem;
background: rgba(13, 25, 44, 0.9);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.2);
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
margin-bottom: 0.4rem;
color: #9cafcf;
}
.metric-card strong {
font-size: 2rem;
}
.lag.clear strong {
color: #76e6a5;
}
.lag.warning strong {
color: #ffc86e;
}
.lag.danger strong {
color: #ff7b91;
}
.controls {
justify-content: flex-start;
flex-wrap: wrap;
margin: 1rem 0 2rem;
}
.feed-panel {
padding: 1.25rem;
}
.feed-heading > span {
color: #8fa8ff;
}
.api-note {
margin: 1rem 0;
font-size: 0.9rem;
}
.empty-state,
.event-list li {
border: 1px dashed #34496d;
border-radius: 0.8rem;
padding: 1rem;
}
.empty-state {
color: #9cafcf;
text-align: center;
}
.event-list {
display: grid;
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.event-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-style: solid;
background: #101d31;
}
.event-list span,
.event-list small {
display: block;
margin-top: 0.25rem;
color: #9cafcf;
}
.error {
border: 1px solid #8d3345;
border-radius: 0.8rem;
padding: 0.8rem 1rem;
color: #ffc4cf;
background: #361522;
}
@media (max-width: 680px) {
.hero,
.event-list li {
align-items: flex-start;
flex-direction: column;
}
}
Load a batch and watch lag rise
Slow mode keeps a 650 millisecond delay before each manual commit. The producer publishes the batch quickly while the consumer records progress one event at a time.
- Restore the browser to a comfortable width.
- Select Slow consumer.
Before you load the batch, do you expect the produced count or processed count to move first?
- Click Load GitHub batch.
- Watch the three metric cards while records enter the feed.
You should see the produced count jump by the default 15 events. The processed count should rise gradually while the consumer lag card moves above zero before draining.
You should also see consumed GitHub events appear one at a time. Each row includes its partition plus its offset.
Why does lag become visible?
The producer places the batch onto the topic faster than the delayed consumer commits records. Lag measures the distance between the latest topic offset and the consumer group's committed progress.
This shortfall is intentional. The backlog gives the next step something concrete to recover from.
Lag stays at zero?
Confirm that Slow consumer has the active outline before loading the batch. Recovery mode removes the processing delay.
Confirm that both terminals remain active while you watch the cards. Help me diagnose why my Kafka consumer lag never rises.
You have turned a static shell into a live event-streaming dashboard. The page now makes a slow consumer and its committed progress visible.
Next, you'll remove the artificial delay and prove that the consumer resumes from its committed offsets after a restart.
Recover and Prove Offset Persistence
Your live Vue dashboard now makes Kafka traffic visible. The Consumer lag card proves that the delayed consumer falls behind.
Removing the artificial delay shows catch-up behavior. Restarting the Node.js consumer tests whether its consumer group keeps progress through committed offsets.
In this step, get ready to:
- Switch to recovery mode until consumer lag reaches zero.
- Restart the pipeline while lag is positive to test committed offsets.
- Build the production assets into dist.
Drain consumer lag
Recovery mode sets processingDelayMs to 0. The consumer can commit available records without the teaching delay.
Before you switch modes, do you expect the Consumer lag value to fall gradually or stay fixed?
- Return to the dashboard at http://localhost:5173 from earlier.
- Click Recover now in the controls.
- Watch the Consumer lag value until it reaches 0.
That is the catch-up moment. The consumer has committed every available record.
Why Does Lag Reach Zero?
Slow mode waits 650 milliseconds before each message commit. Fast mode sets that wait to 0.
The lag monitor compares the latest topic offset with the committed offset. Matching offsets produce a lag value of 0.
Restart from committed offsets
Each processed record calls message.commit() after the artificial delay. That committed offset gives the consumer group a durable checkpoint.
- Click Slow consumer in the dashboard controls.
- Click Load GitHub batch.
- Watch the Consumer lag value until it rises above 0.
Stopping the local server is safe because your Kafka data remains inside the running broker. The shutdown handler closes the browser stream before the Node.js process exits.
- Switch back to the pipeline terminal from earlier.
- Press Ctrl-C while the Consumer lag value is above 0.
The pipeline API stops. In the dashboard, the connection badge changes to reconnecting.
Before you restart the server, do you expect the consumer to begin at the first topic offset or continue from its last committed position?
- Restart the pipeline API by running this command:
npm run server
What Does This Restart Prove?
- The server script starts server.mjs with GROUP_ID set to github-event-pulse-dashboard-v1.
- MessagesStreamModes.COMMITTED asks Kafka to continue from the group's committed position.
- MessagesStreamFallbackModes.EARLIEST applies when the group has no committed position.
- Wait for the terminal to print Pipeline API listening at http://localhost:3000.
- Return to the dashboard from earlier.
- Watch the connection badge change to connected.
- Watch the remaining records continue into the event feed.
Previously committed records stay behind the durable checkpoint. Only the remaining uncommitted records continue into the feed.
Server Not Reconnecting?
- Confirm that the terminal is still in the github-event-pulse folder.
- Confirm that the kafka container from earlier is still running.
- Confirm that the previous Node.js server process fully stopped before the restart.
Still stuck? Help me diagnose why the pipeline server does not reconnect.
Create the production build
A production build asks Vite to transform the Vue application into static assets. This confirms that the completed dashboard can be packaged successfully.
Before you run the final check, do you expect the build to finish without changing the running Kafka state?
- Create a new terminal for the github-event-pulse folder in your editor.
- Build the Vue application by running this command:
npm run build
What Does This Build Do?
The package's build script asks Vite to bundle the Vue application for production. Vite writes the generated assets to dist.
- Wait for the command to return to the terminal prompt.
- Confirm that dist appears in the editor's file explorer.
You should see a successful Vite build summary in the terminal. You should also see the dist directory inside github-event-pulse.
Build Not Completing?
- Confirm that the terminal is in the folder containing package.json.
- Compare any source file named in the build output with the Complete Code.
Need a hand? Help me troubleshoot the failed Vite build.
That completes the recovery proof. Your consumer catches up from durable offsets after a restart.
Secret mission
Add Duplicate Protection
Your pipeline can deliver overlapping public activity batches. Add a component-scoped ID guard that keeps each GitHub event visible once. A fourth metric will count every rejected duplicate.
Clean Up Your Resources
Clean Up Your Resources
This local lab has no ongoing cloud costs. Choose whether to keep it active, pause it for later, or remove it.
Resources you used:
- The github-event-pulse folder containing your Vue source files.
- The Node.js server files plus the dist build artifact.
- The local kafka Docker container running Apache Kafka version 4.1.2 at localhost:9092.
- The github-public-events-v1 topic stored inside the kafka container.
- The github-event-pulse-dashboard-v1 consumer group with its committed offsets stored inside the kafka container.
- The server process plus the Vite development process running in two terminals.
Keep everything running
No action is needed. Choose this option if you want to keep experimenting with lag recovery or duplicate protection.
- Leave the kafka container running so the broker remains available.
- Leave the server process running so it can continue producing and consuming events.
- Leave the Vite process running so the dashboard remains available at http://localhost:5173.
- Keep the github-event-pulse folder so your source files plus the dist build remain available.
- Keep the container to preserve the github-public-events-v1 topic.
- Keep the container to preserve the committed offsets for github-event-pulse-dashboard-v1.
Pause - I'll come back to this later
Stopping the processes frees local memory. The stopped Kafka container keeps your topic plus the consumer group's committed offsets.
- Return to the Node.js server terminal from earlier.
- Press Ctrl-C to stop the server process.
- Return to the Vite terminal from earlier.
- Press Ctrl-C to stop the development process.
Your two application processes are now stopped. The broker continues using memory until you pause its container.
- Pause the Kafka broker by running this command:
docker stop kafka
What Gets Paused?
This command stops the named container without deleting it. The topic plus committed consumer-group offsets remain available for your next session.
- Confirm that the command finishes without an error.
Kafka Container Won't Stop?
Check that Docker Desktop is running. A container that was already stopped can remain paused until you return.
Ask for help with stopping the local Kafka container.
Your lab is safely paused with its files intact. The same container can restore the broker state when you return.
- Restart the Kafka broker by running this command when you return:
docker start kafka
What Gets Restored?
This command starts the existing container. Its topic plus committed offsets remain available to the returning consumer.
- Confirm that the command finishes without an error.
- Return to the Node.js server terminal from earlier.
- Restart the pipeline API by running this command:
npm run server
What Restarts Here?
This script starts the pipeline API. The consumer reconnects to Kafka using the same consumer group.
- Confirm that the terminal prints Pipeline API listening at http://localhost:3000.
- Return to the Vite terminal from earlier.
- Restart the dashboard by running this command:
npm run dev
What Does Vite Restore?
This script starts the local development server. It makes your Vue dashboard available in the browser again.
- Return to the dashboard at http://localhost:5173.
- Confirm that the connection status returns to connected.
Lab Won't Resume?
Start the Kafka container before restarting the server process. Keep both application commands inside the github-event-pulse folder.
Ask for help with restoring the local lab.
Delete - I don't want to use this again
This cleanup is intentionally final. The Keep and Pause options preserve the lab if you want more time with it.
- Return to the Node.js server terminal from earlier.
- Press Ctrl-C to stop the server process.
- Return to the Vite terminal from earlier.
- Press Ctrl-C to stop the development process.
- Remove the Kafka container by running this command in either terminal:
docker rm -f kafka
What Gets Deleted?
This command removes the kafka container plus its local broker data. It also removes the github-public-events-v1 topic.
The committed offsets for github-event-pulse-dashboard-v1 are removed with the container.
- Confirm that the command finishes without an error.
Kafka Container Won't Delete?
Check that Docker Desktop is running. Confirm that the container name is kafka.
Ask for help with removing the local Kafka container.
- Move the github-event-pulse folder from earlier to Trash.
- Confirm that github-event-pulse no longer appears in its previous location.
Cleanup complete. The folder removal includes your source files plus the dist build artifact.
Nice Work!
Nice Work!
You did it! Your event pulse lab now streams public activity from GitHub through Apache Kafka into a live Vue dashboard.
You've learned how to:
- Build a live dashboard with reactive state. Use computed state plus lifecycle hooks to keep its status current.
- Publish public GitHub activity to a Kafka topic with a Node.js producer. Process each record through a committed consumer group. Deliver consumed events to the browser with server-sent events.
- Expose consumer lag while processing is slow. Drain the backlog in recovery mode. Resume from durable committed offsets after a graceful restart.
- Secret Mission: Added an idempotent display guard that keeps each GitHub event ID visible once. Tracked rejected repeats with the Duplicate events metric.
Ready to quiz yourself?