GroundOps Console: Dashboard Frontend

Build a real-time satellite ops dashboard with maps, telemetry, and timelines.

Introduction

30 Second Summary

Think about mission control rooms you see in movies. Those real-time displays showing satellite positions, signal windows, and system health are what actual operators rely on to make split-second decisions.

In this project, you will build a live satellite operations dashboard that visualizes orbital positions, contact windows, telemetry data, and procedure logs from your existing GroundOps Console backend.

What You'll Build

A single-page mission operations dashboard served by your existing FastAPI backend, featuring a Leaflet.js map showing satellite positions updating in real time, a Gantt-style contact timeline, a live telemetry panel with color-coded alarm states, and a scrollable procedure log.

By the end of this project, you'll have:

  • A live satellite tracking map with real-time position markers and ground track polylines served from FastAPI static files.
  • A data-dense ops-console dashboard with a contact timeline, live telemetry panel, and procedure log viewer all consuming your existing backend endpoints.
  • A professional dark-theme UI that demonstrates frontend integration skills relevant to satellite operations software engineering roles.
  • Secret Mission: Add ground station visibility footprints as dynamic circle overlays on the map.

Are there any prerequisites?

This project builds directly on GroundOps Console Projects 1 and 2 (the automation engine and orbital operations layer). You need a working backend with REST and WebSocket endpoints before starting.

You should also have working knowledge of HTML, CSS, and JavaScript.

Mount Static Files and Scaffold the Dashboard

Your GroundOps Console backend already handles satellite telemetry, contact scheduling, and task automation. Now you need a way for operators to actually see all that data in real time.

In this step, you'll create the frontend file structure and configure FastAPI to serve it. By the end, your backend will host a skeleton dashboard page that's ready for live data visualization.

In this step, get ready to:
  • Create the static file directory with HTML, CSS, and JS files.
  • Update main.py to mount and serve static files.
  • Verify the empty dashboard loads in your browser.
Create the static directory structure

Your dashboard needs three files: an HTML page for structure, a CSS file for styling, and a JavaScript file for all the interactive logic. These live in a static/ folder at the root of your existing project.

  • Switch to the terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Create the static/ directory and its files by running these commands:
mkdir static
touch static/index.html static/style.css static/app.js
  • Confirm the files exist by running:
ls static

You should see app.js, index.html, and style.css in the output.

Don't see the files?

Make sure your terminal is in the project root directory (the folder containing main.py). Run ls to check you can see main.py in the current folder before creating static/.

Help me figure out why my static directory wasn't created

Add the StaticFiles mount to your FastAPI app

FastAPI doesn't serve static files by default. You need to explicitly mount a StaticFiles instance that maps a URL path to your static/ directory on disk.

  • Open main.py in the VS Code editor.
  • Add these two imports near the top of the file, alongside your existing imports:
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

What do these imports do?

  • StaticFiles is a mini-application that serves files (HTML, CSS, JS, images) from a directory you specify.
  • FileResponse lets you return a file directly from a route handler, which you'll use to serve index.html at the root URL.
  • Below your existing app = FastAPI() line (and after any middleware or CORS configuration), add a root route that serves your dashboard:
@app.get("/")
async def serve_dashboard():
    # Serve the main dashboard HTML file at the root URL
    return FileResponse("static/index.html")
  • Below the root route, add the static files mount:
# Serve static frontend files from the /static path
app.mount("/static", StaticFiles(directory="static"), name="static")

Why put the route before the mount?

FastAPI processes routes in declaration order. The app.mount call catches all requests starting with /static. Placing your root route before the mount ensures / is handled by your explicit route, not swallowed by the static files handler.

  • Save main.py.

Seeing a red underline on the import?

If your editor shows an error on the StaticFiles or FileResponse import, make sure your Python environment has FastAPI installed. Run pip install fastapi in your terminal if needed.

Help me fix my FastAPI import errors

Scaffold the dashboard HTML and verify it loads

The HTML file defines the four-panel layout your dashboard will use: a satellite map, a contact timeline, a telemetry panel, and a procedure log. It also pulls in the Leaflet.js mapping library from a CDN.

  • Open static/index.html in VS Code and paste the following scaffold:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GroundOps Console</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
        integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
        crossorigin="" />
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <header id="header">
        <h1>GroundOps Console</h1>
        <span id="connection-status" class="status-disconnected">DISCONNECTED</span>
        <span id="clock"></span>
    </header>
    <main id="dashboard">
        <section id="map-panel" class="panel">
            <div class="panel-header">Satellite Map</div>
            <div id="map"></div>
        </section>
        <section id="timeline-panel" class="panel">
            <div class="panel-header">Contact Timeline (24h)</div>
            <div id="timeline"></div>
        </section>
        <section id="telemetry-panel" class="panel">
            <div class="panel-header">Live Telemetry</div>
            <div id="telemetry"></div>
        </section>
        <section id="log-panel" class="panel">
            <div class="panel-header">Procedure Log</div>
            <div id="log-viewer"></div>
        </section>
    </main>
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
        crossorigin=""></script>
    <script src="/static/app.js"></script>
</body>
</html>

What does this HTML do?

  • The <head> loads Leaflet 1.9.4 CSS from a CDN with an integrity hash for security, plus your own stylesheet.
  • The <header> contains a connection status indicator and a clock placeholder for UTC time.
  • The <main> holds four <section> panels that will each display a different data view.
  • Scripts load at the bottom so the DOM is ready before JavaScript runs.
  • Save static/index.html.
  • Start the backend by running this command in your terminal:
uvicorn main:app --reload
  • Open your browser and navigate to http://localhost:8000/static/index.html.

You should see a mostly blank page with "GroundOps Console" as the title, a red "DISCONNECTED" badge in the header, and four panel headers (Satellite Map, Contact Timeline, Live Telemetry, Procedure Log) visible on the page.

Seeing a 404 or blank screen?

  • Check that uvicorn started without errors in the terminal. Look for the line saying "Application startup complete."
  • Verify the URL is exactly http://localhost:8000/static/index.html with no typos.
  • Make sure the static/ folder is in the same directory as main.py. The StaticFiles(directory="static") mount looks for a folder named static relative to where you run uvicorn.

Help me troubleshoot why my dashboard page isn't loading

✔️ Awesome, I've got everything!

Great. Double check you've saved both main.py and static/index.html before moving on.

ⓧ I'd like to double check the full code

Here is the full static/index.html file at this point:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GroundOps Console</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
        integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
        crossorigin="" />
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <header id="header">
        <h1>GroundOps Console</h1>
        <span id="connection-status" class="status-disconnected">DISCONNECTED</span>
        <span id="clock"></span>
    </header>
    <main id="dashboard">
        <section id="map-panel" class="panel">
            <div class="panel-header">Satellite Map</div>
            <div id="map"></div>
        </section>
        <section id="timeline-panel" class="panel">
            <div class="panel-header">Contact Timeline (24h)</div>
            <div id="timeline"></div>
        </section>
        <section id="telemetry-panel" class="panel">
            <div class="panel-header">Live Telemetry</div>
            <div id="telemetry"></div>
        </section>
        <section id="log-panel" class="panel">
            <div class="panel-header">Procedure Log</div>
            <div id="log-viewer"></div>
        </section>
    </main>
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
        crossorigin=""></script>
    <script src="/static/app.js"></script>
</body>
</html>

Your main.py should now include these additions (alongside your existing code from Projects 1 and 2):

from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

# ... your existing FastAPI app setup ...

@app.get("/")
async def serve_dashboard():
    # Serve the main dashboard HTML file at the root URL
    return FileResponse("static/index.html")

# Serve static frontend files from the /static path
app.mount("/static", StaticFiles(directory="static"), name="static")

The static/style.css and static/app.js files should exist but remain empty for now.

Your backend is now serving a skeleton dashboard page. Next up, you'll bring the satellite map to life with real-time position markers using Leaflet.js.

Build the Live Satellite Map with Leaflet

Your dashboard HTML is loading, but right now it's just an empty shell with no visual styling or live data.

In this step, you'll transform that empty page into a dark-themed operations console and wire up a live satellite tracking map. The map will poll your backend every 5 seconds and plot each satellite's real-time position on a Leaflet world map.

In this step, get ready to:
  • Style all four dashboard panels with a professional dark-theme ops-console CSS.
  • Initialize a Leaflet map with CartoDB Dark Matter tiles.
  • Build a polling function that fetches satellite positions and renders them as markers with ground track polylines.
Style the dashboard with a dark ops-console theme

Mission control interfaces use dark backgrounds to reduce eye strain during long shifts and make color-coded alerts stand out. Your stylesheet will establish CSS custom properties for a consistent color palette and lay out the four panels in a 2x2 grid.

  • In VS Code, open static/style.css and paste the full dark-theme stylesheet:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --bg-primary: #0a0e14;
    --bg-secondary: #1a1f2e;
    --bg-panel: #141924;
    --border-color: #2a3040;
    --text-primary: #e0e6ed;
    --text-secondary: #8899aa;
    --accent-blue: #4fc3f7;
    --accent-green: #66bb6a;
    --accent-amber: #ffa726;
    --accent-red: #ef5350;
    --accent-purple: #ab47bc;
}

body {
    font-family: 'Courier New', monospace;
    background: var(--bg-primary);
    color: var(--text-primary);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

#header {
    display: flex;
    align-items: center;
    gap: 20px;
    padding: 8px 16px;
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
}

#header h1 {
    font-size: 14px;
    text-transform: uppercase;
    letter-spacing: 2px;
    color: var(--accent-blue);
}

#connection-status {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 3px;
    font-weight: bold;
}

.status-connected {
    background: var(--accent-green);
    color: #000;
}

.status-disconnected {
    background: var(--accent-red);
    color: #fff;
}

#clock {
    margin-left: auto;
    font-size: 12px;
    color: var(--text-secondary);
}

#dashboard {
    flex: 1;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
    gap: 2px;
    padding: 2px;
    overflow: hidden;
}

.panel {
    background: var(--bg-panel);
    border: 1px solid var(--border-color);
    border-radius: 4px;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.panel-header {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 1px;
    padding: 6px 12px;
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
    color: var(--text-secondary);
}

#map {
    flex: 1;
    z-index: 1;
}

#timeline {
    flex: 1;
    overflow-x: auto;
    overflow-y: auto;
    padding: 8px;
}

.timeline-grid {
    display: grid;
    grid-template-columns: 100px repeat(24, 1fr);
    gap: 1px;
    min-width: 800px;
}

.timeline-header {
    display: contents;
}

.timeline-header span {
    font-size: 10px;
    text-align: center;
    padding: 4px 0;
    color: var(--text-secondary);
    border-bottom: 1px solid var(--border-color);
}

.timeline-row {
    display: contents;
}

.timeline-row-label {
    font-size: 11px;
    padding: 8px 4px;
    color: var(--text-primary);
    display: flex;
    align-items: center;
}

.timeline-row-bars {
    grid-column: 2 / -1;
    position: relative;
    height: 32px;
    border-bottom: 1px solid var(--border-color);
}

.pass-bar {
    position: absolute;
    top: 4px;
    height: 24px;
    border-radius: 3px;
    display: flex;
    align-items: center;
    padding: 0 4px;
    font-size: 9px;
    color: #fff;
    overflow: hidden;
    white-space: nowrap;
}

.pass-bar.high-elevation {
    background: var(--accent-green);
}

.pass-bar.mid-elevation {
    background: var(--accent-amber);
}

.pass-bar.low-elevation {
    background: var(--accent-red);
}

.task-marker {
    position: absolute;
    top: 2px;
    height: 8px;
    background: var(--accent-blue);
    border-radius: 2px;
    font-size: 7px;
    color: #fff;
    padding: 0 2px;
}

#telemetry {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-content: flex-start;
}

.telemetry-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: 4px;
    padding: 8px 12px;
    min-width: 200px;
    flex: 1;
}

.telemetry-card.nominal {
    border-left: 3px solid var(--accent-green);
}

.telemetry-card.warning {
    border-left: 3px solid var(--accent-amber);
}

.telemetry-card.critical {
    border-left: 3px solid var(--accent-red);
    animation: pulse-border 1s infinite;
}

@keyframes pulse-border {
    0%, 100% { border-left-color: var(--accent-red); }
    50% { border-left-color: transparent; }
}

.telemetry-card h3 {
    font-size: 11px;
    color: var(--accent-blue);
    margin-bottom: 6px;
    text-transform: uppercase;
}

.telemetry-value {
    font-size: 12px;
    margin: 2px 0;
    color: var(--text-primary);
}

.telemetry-value .label {
    color: var(--text-secondary);
    margin-right: 8px;
}

#log-viewer {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
}

.log-entry {
    padding: 6px 8px;
    margin-bottom: 4px;
    border-radius: 3px;
    font-size: 11px;
    background: var(--bg-secondary);
    border-left: 3px solid var(--border-color);
}

.log-entry.procedure {
    border-left-color: var(--accent-blue);
}

.log-entry.resolver {
    border-left-color: var(--accent-amber);
}

.log-entry.autopilot {
    border-left-color: var(--accent-purple);
}

.log-entry .timestamp {
    color: var(--text-secondary);
    margin-right: 8px;
}

.log-entry .action {
    color: var(--text-primary);
}

.log-entry .outcome {
    color: var(--accent-green);
    margin-left: 8px;
}

.log-entry.new-entry {
    animation: highlight-flash 1s ease-out;
}

@keyframes highlight-flash {
    0% { background: rgba(79, 195, 247, 0.2); }
    100% { background: var(--bg-secondary); }
}

What does this stylesheet do?

The :root block defines CSS custom properties (color variables) so every panel uses the same dark palette.

The #dashboard rule uses CSS Grid with grid-template-columns: 1fr 1fr and two rows to arrange the four panels in a 2x2 layout. The remaining rules pre-style timeline bars, telemetry cards, and log entries that you'll populate in later steps.

  • Save static/style.css and refresh your browser at http://localhost:8000/static/index.html.
  • You should see a dark background, a blue "GROUNDOPS CONSOLE" header with a red DISCONNECTED badge, and four bordered panels arranged in a 2x2 grid.

Still seeing an unstyled white page?

Confirm the stylesheet link in your HTML points to /static/style.css (with the leading slash). Also check that your FastAPI StaticFiles mount uses the directory name static matching the actual folder on disk.

Help me debug why my CSS isn't loading on the dashboard.

✔️ Awesome, I've got everything!

Great. Make sure you've saved style.css before moving on.

ⓧ I'd like to double check the full code

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --bg-primary: #0a0e14;
    --bg-secondary: #1a1f2e;
    --bg-panel: #141924;
    --border-color: #2a3040;
    --text-primary: #e0e6ed;
    --text-secondary: #8899aa;
    --accent-blue: #4fc3f7;
    --accent-green: #66bb6a;
    --accent-amber: #ffa726;
    --accent-red: #ef5350;
    --accent-purple: #ab47bc;
}

body {
    font-family: 'Courier New', monospace;
    background: var(--bg-primary);
    color: var(--text-primary);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

#header {
    display: flex;
    align-items: center;
    gap: 20px;
    padding: 8px 16px;
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
}

#header h1 {
    font-size: 14px;
    text-transform: uppercase;
    letter-spacing: 2px;
    color: var(--accent-blue);
}

#connection-status {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 3px;
    font-weight: bold;
}

.status-connected {
    background: var(--accent-green);
    color: #000;
}

.status-disconnected {
    background: var(--accent-red);
    color: #fff;
}

#clock {
    margin-left: auto;
    font-size: 12px;
    color: var(--text-secondary);
}

#dashboard {
    flex: 1;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
    gap: 2px;
    padding: 2px;
    overflow: hidden;
}

.panel {
    background: var(--bg-panel);
    border: 1px solid var(--border-color);
    border-radius: 4px;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.panel-header {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 1px;
    padding: 6px 12px;
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
    color: var(--text-secondary);
}

#map {
    flex: 1;
    z-index: 1;
}

#timeline {
    flex: 1;
    overflow-x: auto;
    overflow-y: auto;
    padding: 8px;
}

.timeline-grid {
    display: grid;
    grid-template-columns: 100px repeat(24, 1fr);
    gap: 1px;
    min-width: 800px;
}

.timeline-header {
    display: contents;
}

.timeline-header span {
    font-size: 10px;
    text-align: center;
    padding: 4px 0;
    color: var(--text-secondary);
    border-bottom: 1px solid var(--border-color);
}

.timeline-row {
    display: contents;
}

.timeline-row-label {
    font-size: 11px;
    padding: 8px 4px;
    color: var(--text-primary);
    display: flex;
    align-items: center;
}

.timeline-row-bars {
    grid-column: 2 / -1;
    position: relative;
    height: 32px;
    border-bottom: 1px solid var(--border-color);
}

.pass-bar {
    position: absolute;
    top: 4px;
    height: 24px;
    border-radius: 3px;
    display: flex;
    align-items: center;
    padding: 0 4px;
    font-size: 9px;
    color: #fff;
    overflow: hidden;
    white-space: nowrap;
}

.pass-bar.high-elevation {
    background: var(--accent-green);
}

.pass-bar.mid-elevation {
    background: var(--accent-amber);
}

.pass-bar.low-elevation {
    background: var(--accent-red);
}

.task-marker {
    position: absolute;
    top: 2px;
    height: 8px;
    background: var(--accent-blue);
    border-radius: 2px;
    font-size: 7px;
    color: #fff;
    padding: 0 2px;
}

#telemetry {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-content: flex-start;
}

.telemetry-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: 4px;
    padding: 8px 12px;
    min-width: 200px;
    flex: 1;
}

.telemetry-card.nominal {
    border-left: 3px solid var(--accent-green);
}

.telemetry-card.warning {
    border-left: 3px solid var(--accent-amber);
}

.telemetry-card.critical {
    border-left: 3px solid var(--accent-red);
    animation: pulse-border 1s infinite;
}

@keyframes pulse-border {
    0%, 100% { border-left-color: var(--accent-red); }
    50% { border-left-color: transparent; }
}

.telemetry-card h3 {
    font-size: 11px;
    color: var(--accent-blue);
    margin-bottom: 6px;
    text-transform: uppercase;
}

.telemetry-value {
    font-size: 12px;
    margin: 2px 0;
    color: var(--text-primary);
}

.telemetry-value .label {
    color: var(--text-secondary);
    margin-right: 8px;
}

#log-viewer {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
}

.log-entry {
    padding: 6px 8px;
    margin-bottom: 4px;
    border-radius: 3px;
    font-size: 11px;
    background: var(--bg-secondary);
    border-left: 3px solid var(--border-color);
}

.log-entry.procedure {
    border-left-color: var(--accent-blue);
}

.log-entry.resolver {
    border-left-color: var(--accent-amber);
}

.log-entry.autopilot {
    border-left-color: var(--accent-purple);
}

.log-entry .timestamp {
    color: var(--text-secondary);
    margin-right: 8px;
}

.log-entry .action {
    color: var(--text-primary);
}

.log-entry .outcome {
    color: var(--accent-green);
    margin-left: 8px;
}

.log-entry.new-entry {
    animation: highlight-flash 1s ease-out;
}

@keyframes highlight-flash {
    0% { background: rgba(79, 195, 247, 0.2); }
    100% { background: var(--bg-secondary); }
}
Initialize the Leaflet map with dark tiles

Leaflet needs two things to render: a map instance bound to a DOM element and a tile layer providing the background imagery. You'll use CartoDB Dark Matter tiles to match the ops-console aesthetic.

  • Open static/app.js and add the base constants, a UTC clock function, and the map initialization:
const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace(/^http/, 'ws');

// --- Clock ---
function updateClock() {
    const now = new Date();
    document.getElementById('clock').textContent =
        now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();

// --- Leaflet Map ---
const map = L.map('map').setView([20, 0], 2);

L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

What does this code do?

  • API_BASE captures the page's origin so fetch calls work regardless of hostname. WS_BASE swaps http for ws for WebSocket connections later.
  • updateClock() writes the current UTC time into the header's #clock element every second, giving operators a consistent time reference.
  • L.map('map').setView([20, 0], 2) attaches a Leaflet map to the #map div, centered to show the whole world at zoom level 2.
  • The CartoDB Dark Matter tile layer fills in the basemap imagery with a dark color scheme and the abcd subdomains spread tile requests across multiple CDN servers.
  • Save static/app.js and refresh your browser.
  • You should see a dark world map filling the top-left panel, a ticking UTC clock in the header, and continent outlines rendered in dark gray on the nearly-black basemap.

Map panel is blank or gray?

Open DevTools (F12) and check the Console tab for errors. If you see L is not defined, confirm the Leaflet JS script tag in your HTML appears BEFORE the app.js script tag.

If the map renders but tiles are missing (gray squares), your browser may be blocking the CartoDB CDN. Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

Help me debug my Leaflet map not rendering.

Create the satellite position polling function

Your backend already exposes position endpoints for each satellite. You need a function that fetches the satellite list, loops through each one to get its current position, and places (or updates) a marker on the map.

  • In static/app.js, add the following code below the .addTo(map) line:
const satelliteMarkers = {};
const groundTrackLines = {};

async function fetchJSON(path) {
    const response = await fetch(`${API_BASE}${path}`);
    if (!response.ok) return null;
    return response.json();
}

async function updateSatellitePositions() {
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;

    for (const sat of satellites) {
        const position = await fetchJSON(`/satellites/${sat.id}/position`);
        if (!position) continue;

        const latlng = [position.latitude, position.longitude];

        // Create or update the satellite marker
        if (satelliteMarkers[sat.id]) {
            satelliteMarkers[sat.id].setLatLng(latlng);
            satelliteMarkers[sat.id].setPopupContent(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        } else {
            satelliteMarkers[sat.id] = L.circleMarker(latlng, {
                radius: 6,
                fillColor: '#4fc3f7',
                fillOpacity: 1,
                color: '#fff',
                weight: 1
            }).addTo(map).bindPopup(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        }
    }
}

setInterval(updateSatellitePositions, 5000);
updateSatellitePositions();

What does this code do?

  • fetchJSON(path) is a reusable helper that fetches a backend endpoint and returns parsed JSON, or null on failure.
  • updateSatellitePositions() first fetches the full satellite list from /satellites, then loops through each one to get its current lat/lon/alt from /satellites/{id}/position.
  • The if/else block either moves an existing marker to the new position or creates a fresh L.circleMarker with a popup showing satellite name, coordinates, and altitude.
  • setInterval(updateSatellitePositions, 5000) polls the backend every 5 seconds to keep positions current.

Why polling instead of WebSocket here?

Position data changes relatively slowly (orbital updates every few seconds), so a simple 5-second poll is efficient and keeps the code straightforward. You'll use a WebSocket for telemetry in a later step where sub-second latency matters for alarm states.

  • Save static/app.js and refresh your browser.
  • Open DevTools (F12) and switch to the Network tab. You should see fetch requests to /satellites and /satellites/{id}/position repeating every 5 seconds.
  • If your backend has satellites registered, you'll see blue circle markers appearing on the map. Clicking a marker opens a popup with the satellite's name, coordinates, and altitude.

No markers showing up?

Check the Network tab responses. If /satellites returns an empty array, you need to register satellites in your backend first (from Projects 1/2). If requests return 404, double-check that your backend endpoint paths match the ones used in fetchJSON calls.

Help me figure out why my satellite markers aren't appearing on the Leaflet map.

Add ground track polylines

Ground tracks show the path a satellite traces over Earth's surface. Rendering them as dashed polylines gives operators an at-a-glance sense of where each satellite is heading.

  • In static/app.js, find the closing brace of the if (satelliteMarkers[sat.id]) block (the if/else that creates or updates markers).
  • Add the following ground track fetch directly below that closing brace, still inside the for (const sat of satellites) loop:
        // Fetch and render the satellite's ground track
        const track = await fetchJSON(`/satellites/${sat.id}/ground-track`);
        if (track && track.coordinates) {
            if (groundTrackLines[sat.id]) {
                groundTrackLines[sat.id].setLatLngs(track.coordinates);
            } else {
                groundTrackLines[sat.id] = L.polyline(track.coordinates, {
                    color: '#4fc3f7',
                    weight: 1,
                    opacity: 0.5,
                    dashArray: '5, 5'
                }).addTo(map);
            }
        }

What does this code do?

  • For each satellite, it fetches coordinate arrays from /satellites/{id}/ground-track.
  • L.polyline draws a line through those coordinates. The dashArray: '5, 5' option makes the line dashed so it reads as a projected path rather than a solid boundary.
  • Like the markers, existing polylines update their coordinates with setLatLngs() instead of being recreated each cycle.
  • Save static/app.js and refresh your browser.
  • You should see faint blue dashed lines tracing each satellite's orbital path across the dark map. The lines update alongside the markers every 5 seconds.

Markers move but no polylines appear?

Check the Network tab for /satellites/{id}/ground-track responses. If the response JSON uses a different key than coordinates, update the track.coordinates reference to match your backend's field name.

Also make sure the ground track code is inside the for loop, not after it.

Help me debug why my ground track polylines aren't rendering on the Leaflet map.

✔️ Awesome, I've got everything!

Great. Double check you've saved static/app.js before continuing.

ⓧ I'd like to double check the full code

const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace(/^http/, 'ws');

// --- Clock ---
function updateClock() {
    const now = new Date();
    document.getElementById('clock').textContent =
        now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();

// --- Leaflet Map ---
const map = L.map('map').setView([20, 0], 2);

L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

const satelliteMarkers = {};
const groundTrackLines = {};

async function fetchJSON(path) {
    const response = await fetch(`${API_BASE}${path}`);
    if (!response.ok) return null;
    return response.json();
}

async function updateSatellitePositions() {
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;

    for (const sat of satellites) {
        const position = await fetchJSON(`/satellites/${sat.id}/position`);
        if (!position) continue;

        const latlng = [position.latitude, position.longitude];

        if (satelliteMarkers[sat.id]) {
            satelliteMarkers[sat.id].setLatLng(latlng);
            satelliteMarkers[sat.id].setPopupContent(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        } else {
            satelliteMarkers[sat.id] = L.circleMarker(latlng, {
                radius: 6,
                fillColor: '#4fc3f7',
                fillOpacity: 1,
                color: '#fff',
                weight: 1
            }).addTo(map).bindPopup(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        }

        const track = await fetchJSON(`/satellites/${sat.id}/ground-track`);
        if (track && track.coordinates) {
            if (groundTrackLines[sat.id]) {
                groundTrackLines[sat.id].setLatLngs(track.coordinates);
            } else {
                groundTrackLines[sat.id] = L.polyline(track.coordinates, {
                    color: '#4fc3f7',
                    weight: 1,
                    opacity: 0.5,
                    dashArray: '5, 5'
                }).addTo(map);
            }
        }
    }
}

setInterval(updateSatellitePositions, 5000);
updateSatellitePositions();

Your satellite map is live and tracking. Next up, you'll build a contact timeline that visualizes upcoming pass windows as a 24-hour Gantt chart.

Build the Contact Window Timeline

Your satellite map is now tracking orbital positions in real time. The next piece of the dashboard answers a critical question for ground station operators: when can we talk to each satellite?

Contact windows are the brief periods when a satellite passes overhead and a ground station can establish a radio link. In this step, you will build a Gantt-style timeline that visualizes these windows across the next 24 hours, color-coded by pass quality.

In this step, get ready to:
  • Build a CSS Grid timeline header showing the next 24 hours.
  • Render color-coded pass bars positioned by AOS/LOS timestamps.
  • Set up automatic timeline refresh on a 30-second interval.
Build the timeline grid header

A Gantt-style timeline needs a time axis. Your CSS already defines the .timeline-grid class with a 100px label column and 24 equal columns. The JavaScript needs to generate the HTML that uses this structure.

  • In app.js, scroll to the bottom of the file (below the updateSatellitePositions(); call).
  • Add the start of the updateTimeline() function by pasting this code:
// --- Contact Timeline ---
async function updateTimeline() {
    const container = document.getElementById('timeline');
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;

    // Define the 24-hour window starting from now
    const now = new Date();
    const endWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000);

    // Build the header row with hour labels
    let html = '<div class="timeline-grid">';
    html += '<div class="timeline-header"><span></span>';
    for (let h = 0; h < 24; h++) {
        const hour = new Date(now.getTime() + h * 60 * 60 * 1000);
        html += `<span>${String(hour.getUTCHours()).padStart(2, '0')}:00</span>`;
    }
    html += '</div>';

What does this code do?

This first section of updateTimeline() sets up the time axis for the Gantt chart.

  • endWindow defines the 24-hour range from the current moment.
  • The header loop generates 24 column labels showing UTC hours (e.g. 00:00, 01:00) starting from the current hour.
  • The first <span></span> is the empty label column cell that sits above the satellite names.

Do not save the file yet. You will add the satellite row logic next, then save both parts together.

Render color-coded pass bars

Each satellite row needs a label and a bar container. Pass bars are positioned using percentage-based left and width values calculated from each pass's AOS (Acquisition of Signal) and LOS (Loss of Signal) timestamps.

  • Directly below the header code you just added (still inside the updateTimeline() function), add the satellite row loop and close out the function:
    // Build a row for each satellite with its pass bars
    for (const sat of satellites) {
        const passes = await fetchJSON(`/satellites/${sat.id}/passes`);
        html += '<div class="timeline-row">';
        html += `<div class="timeline-row-label">${sat.name}</div>`;
        html += '<div class="timeline-row-bars">';

        if (passes) {
            for (const pass of passes) {
                const aos = new Date(pass.aos);
                const los = new Date(pass.los);
                // Skip passes outside the 24-hour window
                if (los < now || aos > endWindow) continue;

                // Calculate bar position as percentage of the 24h window
                const startPct = Math.max(0, (aos - now) / (24 * 60 * 60 * 1000) * 100);
                const endPct = Math.min(100, (los - now) / (24 * 60 * 60 * 1000) * 100);
                const widthPct = endPct - startPct;

                // Color-code by max elevation quality
                let elevClass = 'low-elevation';
                if (pass.max_elevation > 45) elevClass = 'high-elevation';
                else if (pass.max_elevation > 20) elevClass = 'mid-elevation';

                html += `<div class="pass-bar ${elevClass}" style="left:${startPct}%;width:${widthPct}%">` +
                    `${pass.max_elevation.toFixed(0)}&deg;</div>`;
            }
        }

        html += '</div></div>';
    }

    html += '</div>';
    container.innerHTML = html;
}

// Refresh timeline every 30 seconds
setInterval(updateTimeline, 30000);
updateTimeline();

What does this code do?

This section fetches pass predictions for each satellite and renders them as positioned bars.

  • startPct and widthPct convert AOS/LOS times into percentages of the 24-hour window. A pass starting 6 hours from now gets left: 25%.
  • Elevation color coding tells operators which passes are highest quality. Green (high-elevation) means above 45 degrees. Amber (mid-elevation) means above 20 degrees. Red (low-elevation) is everything else.
  • The 30-second interval keeps the timeline current as time passes and new windows come into range.

Why color-code by elevation?

A satellite directly overhead (90 degrees elevation) has the strongest signal and longest contact time. Low passes near the horizon have weaker signals, more atmospheric interference, and shorter windows. Operators prioritize high-elevation passes for critical commands.

  • Save app.js.
  • Refresh your browser at http://localhost:8000/static/index.html. You should see the Contact Timeline panel populated with satellite names on the left and hour labels across the top. If your backend has pass data, colored bars appear showing upcoming contact windows.

Timeline panel is empty?

  • Open DevTools (F12) and check the Console tab. A 404 error on /satellites means your backend endpoint path may differ from the one in the code. Update the path in fetchJSON() to match your actual API route.
  • If you see the header row but no bars, your passes endpoint may be returning an empty array. Verify your backend is returning pass objects with aos, los, and max_elevation fields.
  • Check that you pasted both code chunks inside the same function. The second chunk continues directly after the header loop closes.

Help me debug my contact timeline

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.js file up to this point. Compare your file against this:

const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace(/^http/, 'ws');

// --- Clock ---
function updateClock() {
    const now = new Date();
    document.getElementById('clock').textContent =
        now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();

// --- Leaflet Map ---
const map = L.map('map').setView([20, 0], 2);

L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

const satelliteMarkers = {};
const groundTrackLines = {};

async function fetchJSON(path) {
    const response = await fetch(`${API_BASE}${path}`);
    if (!response.ok) return null;
    return response.json();
}

async function updateSatellitePositions() {
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;

    for (const sat of satellites) {
        const position = await fetchJSON(`/satellites/${sat.id}/position`);
        if (!position) continue;

        const latlng = [position.latitude, position.longitude];

        if (satelliteMarkers[sat.id]) {
            satelliteMarkers[sat.id].setLatLng(latlng);
            satelliteMarkers[sat.id].setPopupContent(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        } else {
            satelliteMarkers[sat.id] = L.circleMarker(latlng, {
                radius: 6,
                fillColor: '#4fc3f7',
                fillOpacity: 1,
                color: '#fff',
                weight: 1
            }).addTo(map).bindPopup(
                `<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>` +
                `Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`
            );
        }

        const track = await fetchJSON(`/satellites/${sat.id}/ground-track`);
        if (track && track.coordinates) {
            if (groundTrackLines[sat.id]) {
                groundTrackLines[sat.id].setLatLngs(track.coordinates);
            } else {
                groundTrackLines[sat.id] = L.polyline(track.coordinates, {
                    color: '#4fc3f7',
                    weight: 1,
                    opacity: 0.5,
                    dashArray: '5, 5'
                }).addTo(map);
            }
        }
    }
}

setInterval(updateSatellitePositions, 5000);
updateSatellitePositions();

// --- Contact Timeline ---
async function updateTimeline() {
    const container = document.getElementById('timeline');
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;

    const now = new Date();
    const endWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000);

    let html = '<div class="timeline-grid">';
    html += '<div class="timeline-header"><span></span>';
    for (let h = 0; h < 24; h++) {
        const hour = new Date(now.getTime() + h * 60 * 60 * 1000);
        html += `<span>${String(hour.getUTCHours()).padStart(2, '0')}:00</span>`;
    }
    html += '</div>';

    for (const sat of satellites) {
        const passes = await fetchJSON(`/satellites/${sat.id}/passes`);
        html += '<div class="timeline-row">';
        html += `<div class="timeline-row-label">${sat.name}</div>`;
        html += '<div class="timeline-row-bars">';

        if (passes) {
            for (const pass of passes) {
                const aos = new Date(pass.aos);
                const los = new Date(pass.los);
                if (los < now || aos > endWindow) continue;

                const startPct = Math.max(0, (aos - now) / (24 * 60 * 60 * 1000) * 100);
                const endPct = Math.min(100, (los - now) / (24 * 60 * 60 * 1000) * 100);
                const widthPct = endPct - startPct;

                let elevClass = 'low-elevation';
                if (pass.max_elevation > 45) elevClass = 'high-elevation';
                else if (pass.max_elevation > 20) elevClass = 'mid-elevation';

                html += `<div class="pass-bar ${elevClass}" style="left:${startPct}%;width:${widthPct}%">` +
                    `${pass.max_elevation.toFixed(0)}&deg;</div>`;
            }
        }

        html += '</div></div>';
    }

    html += '</div>';
    container.innerHTML = html;
}

setInterval(updateTimeline, 30000);
updateTimeline();

Your timeline now gives operators a clear 24-hour view of upcoming contact opportunities. Next, you will wire up the live telemetry panel to stream real-time subsystem readings over WebSocket.

Stream Live Telemetry via WebSocket

Your map and timeline panels use polling to fetch data at regular intervals. Telemetry is different. Alarm states can change in an instant, and operators need to see those changes the moment they happen.

In this step, you'll open a WebSocket connection to your backend's telemetry stream. Instead of asking for data on a timer, the server pushes new readings to the dashboard the moment they arrive.

In this step, get ready to:
  • Open a WebSocket connection to the backend telemetry stream.
  • Render satellite telemetry cards with color-coded alarm states.
  • Implement automatic reconnection with exponential backoff.
Open the WebSocket connection

Your backend from Project 2 already exposes a WebSocket endpoint at /ws/telemetry. The browser's WebSocket API lets you open a persistent connection to that endpoint and react to every message the server sends.

  • In static/app.js, scroll to the bottom of the file (below the updateTimeline(); call).
  • Add the WebSocket connection function by pasting the following code:
// --- Live Telemetry via WebSocket ---
let ws = null;
let reconnectDelay = 3000;

function connectTelemetry() {
    ws = new WebSocket(`${WS_BASE}/ws/telemetry`);
    ws.addEventListener('open', () => {
        document.getElementById('connection-status').textContent = 'CONNECTED';
        document.getElementById('connection-status').className = 'status-connected';
        reconnectDelay = 3000;
    });
    ws.addEventListener('message', (event) => {
        const data = JSON.parse(event.data);
        updateTelemetryCard(data);
    });
    ws.addEventListener('close', () => {
        document.getElementById('connection-status').textContent = 'DISCONNECTED';
        document.getElementById('connection-status').className = 'status-disconnected';
        setTimeout(connectTelemetry, reconnectDelay);
        reconnectDelay = Math.min(reconnectDelay * 2, 30000);
    });
    ws.addEventListener('error', () => { ws.close(); });
}

What does this code do?

  • ws holds the active WebSocket instance. reconnectDelay starts at 3 seconds and doubles on each failed reconnection attempt (up to 30 seconds max).
  • The open handler flips the header status badge to CONNECTED and resets the delay back to 3 seconds on a successful connection.
  • The message handler parses each incoming JSON payload and passes it to updateTelemetryCard() (which you'll write next).
  • The close handler marks the status as DISCONNECTED and schedules a reconnection. Each retry waits longer (exponential backoff), preventing the browser from hammering the server during an outage.
  • The error handler triggers a clean close, which in turn fires the close handler and its reconnection logic.

Why WebSocket instead of polling?

Polling (like your map and timeline use) works well for data that changes on a known schedule. Telemetry is unpredictable. A subsystem might stay nominal for hours, then fire five alarm updates in one second.

A WebSocket keeps a persistent channel open so the server can push each update the instant it happens. No wasted requests when nothing changes, and zero delay when something does.

  • Save app.js.
  • You won't see a change yet. The function is defined but not called, and it depends on updateTelemetryCard() which doesn't exist yet.
Render telemetry cards with alarm states

Each WebSocket message contains a satellite's current readings: power, thermal, attitude, and alarm state. You need a function that turns each message into a visible card inside the #telemetry panel.

  • Directly below the connectTelemetry() function you just added, paste the card renderer:
function updateTelemetryCard(data) {
    const container = document.getElementById('telemetry');
    let card = document.getElementById(`telem-${data.satellite_id}`);
    const alarmClass = data.alarm_state ? data.alarm_state.toLowerCase() : 'nominal';
    if (!card) {
        // First time seeing this satellite - create a new card
        card = document.createElement('div');
        card.id = `telem-${data.satellite_id}`;
        card.className = `telemetry-card ${alarmClass}`;
        container.appendChild(card);
    } else {
        // Update existing card's alarm class
        card.className = `telemetry-card ${alarmClass}`;
    }
    card.innerHTML = `<h3>${data.satellite_name || data.satellite_id}</h3>` +
        `<div class="telemetry-value"><span class="label">Power:</span>${data.power_w || '--'} W</div>` +
        `<div class="telemetry-value"><span class="label">Temp:</span>${data.thermal_c || '--'} &deg;C</div>` +
        `<div class="telemetry-value"><span class="label">Attitude:</span>${data.attitude_deg || '--'}&deg;</div>` +
        `<div class="telemetry-value"><span class="label">State:</span>${data.alarm_state || 'NOMINAL'}</div>`;
}

What does this code do?

  • The function checks if a card already exists for this satellite (using the id telem-${data.satellite_id}). If not, it creates one. If yes, it updates the existing card in place.
  • alarmClass maps the server's alarm state to a CSS class: .nominal (green border), .warning (amber border), or .critical (red pulsing border from the CSS animation you already have in style.css).
  • The innerHTML template renders subsystem values (power, thermal, attitude) plus the overall alarm state. Missing values fall back to '--' so the card never shows blank fields.
  • Save app.js.
  • The card renderer is ready, but you still need to call connectTelemetry() to kick off the connection.
Activate the connection and verify

With both the connection function and card renderer in place, a single call starts everything. The connection opens, messages flow in, and cards appear automatically.

  • At the very bottom of app.js (below the updateTelemetryCard function), add the startup call:
connectTelemetry();
  • Save app.js.
  • Refresh your browser at http://localhost:8000/static/index.html.
  • Look at the header bar. If your backend's WebSocket endpoint is running, the status badge should switch from DISCONNECTED (red) to CONNECTED (green).
  • In the Live Telemetry panel, you should see cards appearing for each satellite with power, temperature, attitude, and alarm state values.

How does the reconnection protect you?

If the backend restarts or the network drops, the close handler fires. It waits 3 seconds, then calls connectTelemetry() again. If that attempt also fails, the next retry waits 6 seconds, then 12, then 24, capping at 30 seconds.

This exponential backoff pattern prevents the browser from flooding the server with connection attempts during an outage. The moment the server comes back, the next retry succeeds and reconnectDelay resets to 3 seconds.

Status stays DISCONNECTED?

  • Make sure your backend is still running (uvicorn main:app --reload in your terminal). If the server isn't up, the WebSocket has nothing to connect to.
  • Verify your backend exposes a WebSocket route at /ws/telemetry. Check your main.py from Project 2 to confirm the endpoint path matches.
  • Open the browser DevTools console (F12) and look for WebSocket connection errors. A 404 means the endpoint path doesn't match.

Help me debug my WebSocket connection issue in the GroundOps Console dashboard.

Test the reconnection yourself

Stop your backend (Ctrl+C in the terminal), watch the badge flip to DISCONNECTED, then restart it. Within a few seconds the dashboard reconnects automatically and cards reappear.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and your telemetry cards are updating in real time.

ⓧ I'd like to double check the full code

Here is the complete app.js up to this point. Compare yours against it:

const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace(/^http/, 'ws');

// --- Clock ---
function updateClock() {
    const now = new Date();
    document.getElementById('clock').textContent =
        now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();

// --- Leaflet Map ---
const map = L.map('map').setView([20, 0], 2);

L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

const satelliteMarkers = {};
const groundTrackLines = {};

async function fetchJSON(path) {
    const response = await fetch(`${API_BASE}${path}`);
    if (!response.ok) return null;
    return response.json();
}

async function updateSatellitePositions() {
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;
    for (const sat of satellites) {
        const position = await fetchJSON(`/satellites/${sat.id}/position`);
        if (!position) continue;
        const latlng = [position.latitude, position.longitude];
        if (satelliteMarkers[sat.id]) {
            satelliteMarkers[sat.id].setLatLng(latlng);
            satelliteMarkers[sat.id].setPopupContent(`<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`);
        } else {
            satelliteMarkers[sat.id] = L.circleMarker(latlng, { radius: 6, fillColor: '#4fc3f7', fillOpacity: 1, color: '#fff', weight: 1 }).addTo(map).bindPopup(`<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`);
        }
        const track = await fetchJSON(`/satellites/${sat.id}/ground-track`);
        if (track && track.coordinates) {
            if (groundTrackLines[sat.id]) { groundTrackLines[sat.id].setLatLngs(track.coordinates); }
            else { groundTrackLines[sat.id] = L.polyline(track.coordinates, { color: '#4fc3f7', weight: 1, opacity: 0.5, dashArray: '5, 5' }).addTo(map); }
        }
    }
}
setInterval(updateSatellitePositions, 5000);
updateSatellitePositions();

// --- Contact Timeline ---
async function updateTimeline() {
    const container = document.getElementById('timeline');
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;
    const now = new Date();
    const endWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
    let html = '<div class="timeline-grid">';
    html += '<div class="timeline-header"><span></span>';
    for (let h = 0; h < 24; h++) {
        const hour = new Date(now.getTime() + h * 60 * 60 * 1000);
        html += `<span>${String(hour.getUTCHours()).padStart(2, '0')}:00</span>`;
    }
    html += '</div>';
    for (const sat of satellites) {
        const passes = await fetchJSON(`/satellites/${sat.id}/passes`);
        html += '<div class="timeline-row">';
        html += `<div class="timeline-row-label">${sat.name}</div>`;
        html += '<div class="timeline-row-bars">';
        if (passes) {
            for (const pass of passes) {
                const aos = new Date(pass.aos);
                const los = new Date(pass.los);
                if (los < now || aos > endWindow) continue;
                const startPct = Math.max(0, (aos - now) / (24 * 60 * 60 * 1000) * 100);
                const endPct = Math.min(100, (los - now) / (24 * 60 * 60 * 1000) * 100);
                const widthPct = endPct - startPct;
                let elevClass = 'low-elevation';
                if (pass.max_elevation > 45) elevClass = 'high-elevation';
                else if (pass.max_elevation > 20) elevClass = 'mid-elevation';
                html += `<div class="pass-bar ${elevClass}" style="left:${startPct}%;width:${widthPct}%">${pass.max_elevation.toFixed(0)}&deg;</div>`;
            }
        }
        html += '</div></div>';
    }
    html += '</div>';
    container.innerHTML = html;
}
setInterval(updateTimeline, 30000);
updateTimeline();

// --- Live Telemetry via WebSocket ---
let ws = null;
let reconnectDelay = 3000;

function connectTelemetry() {
    ws = new WebSocket(`${WS_BASE}/ws/telemetry`);
    ws.addEventListener('open', () => {
        document.getElementById('connection-status').textContent = 'CONNECTED';
        document.getElementById('connection-status').className = 'status-connected';
        reconnectDelay = 3000;
    });
    ws.addEventListener('message', (event) => {
        const data = JSON.parse(event.data);
        updateTelemetryCard(data);
    });
    ws.addEventListener('close', () => {
        document.getElementById('connection-status').textContent = 'DISCONNECTED';
        document.getElementById('connection-status').className = 'status-disconnected';
        setTimeout(connectTelemetry, reconnectDelay);
        reconnectDelay = Math.min(reconnectDelay * 2, 30000);
    });
    ws.addEventListener('error', () => { ws.close(); });
}

function updateTelemetryCard(data) {
    const container = document.getElementById('telemetry');
    let card = document.getElementById(`telem-${data.satellite_id}`);
    const alarmClass = data.alarm_state ? data.alarm_state.toLowerCase() : 'nominal';
    if (!card) {
        card = document.createElement('div');
        card.id = `telem-${data.satellite_id}`;
        card.className = `telemetry-card ${alarmClass}`;
        container.appendChild(card);
    } else {
        card.className = `telemetry-card ${alarmClass}`;
    }
    card.innerHTML = `<h3>${data.satellite_name || data.satellite_id}</h3><div class="telemetry-value"><span class="label">Power:</span>${data.power_w || '--'} W</div><div class="telemetry-value"><span class="label">Temp:</span>${data.thermal_c || '--'} &deg;C</div><div class="telemetry-value"><span class="label">Attitude:</span>${data.attitude_deg || '--'}&deg;</div><div class="telemetry-value"><span class="label">State:</span>${data.alarm_state || 'NOMINAL'}</div>`;
}

connectTelemetry();

Your dashboard now has live, push-based telemetry with automatic failover. Next up, you'll add the final panel: a scrollable procedure and resolver log that keeps operators informed about every automated action your backend takes.

Build the Procedure and Resolver Log Viewer

Your dashboard now shows satellite positions, contact windows, and live telemetry. The final panel fills the last gap in operational awareness: a scrollable log of every procedure, resolver action, and autopilot sequence the backend has executed.

In this step, you will fetch the mission log archive from your backend's REST endpoint, render entries with color-coded type indicators, and set up smart polling that appends only new entries instead of re-rendering the entire list.

In this step, get ready to:
  • Fetch and parse the mission log archive from the backend.
  • Render log entries with type-based color coding and formatted timestamps.
  • Set up incremental polling with highlight animations for new entries.
Fetch and parse the mission log archive

The log viewer needs to handle two distinct scenarios: loading the full log history on first page load, and efficiently appending only new entries on subsequent polls. A lastLogTimestamp tracker determines which mode the function is in.

  • In app.js, scroll to the bottom of the file (after the connectTelemetry(); call).
  • Add the log viewer section header comment and the updateLogViewer() function by pasting the following code at the end of the file:
// --- Procedure Log Viewer ---
let lastLogTimestamp = null;

async function updateLogViewer() {
    const logs = await fetchJSON('/logs/mission');
    if (!logs) return;
    const container = document.getElementById('log-viewer');
    const entries = Array.isArray(logs) ? logs : logs.entries || [];
    const sorted = entries.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
    const newEntries = lastLogTimestamp
        ? sorted.filter(e => new Date(e.timestamp) > new Date(lastLogTimestamp))
        : sorted;
    if (!lastLogTimestamp) {
        // First load: render up to 100 entries
        container.innerHTML = '';
        for (const entry of sorted.slice(0, 100)) {
            container.appendChild(createLogEntry(entry, false));
        }
    } else {
        // Subsequent polls: prepend only new entries
        for (const entry of newEntries.reverse()) {
            const el = createLogEntry(entry, true);
            container.insertBefore(el, container.firstChild);
        }
    }
    if (sorted.length > 0) { lastLogTimestamp = sorted[0].timestamp; }
}

What does this code do?

This function uses two modes controlled by lastLogTimestamp:

  • On the first call, lastLogTimestamp is null, so it clears the container and renders up to 100 entries sorted newest-first.
  • On subsequent calls, it filters for only entries newer than lastLogTimestamp and prepends them to the top using insertBefore. This avoids re-rendering the entire list on every poll.
  • The Array.isArray(logs) ? logs : logs.entries || [] check handles both response formats your backend might return (a flat array or an object with an entries property).
  • Save app.js.

You won't see a change in the browser yet. The function references createLogEntry() which you will add in the next chunk.

Seeing a console error about createLogEntry?

This is expected if your browser auto-refreshes before you add the next function. The error disappears once you paste createLogEntry() in the next chunk.

If you see a different error, check that the function is pasted after the connectTelemetry(); call, not inside it.

Help me debug my updateLogViewer function.

Render styled log entries with type indicators

Each log entry needs a visual indicator showing whether it came from a procedure execution, a resolver action, or an autopilot sequence. The CSS you already have in style.css applies colored left borders based on the entry's class name.

  • Add the createLogEntry() function directly below updateLogViewer() by pasting this code:
function createLogEntry(entry, isNew) {
    const div = document.createElement('div');
    const entryType = entry.type || 'procedure';
    // Apply type class for color-coded left border, add new-entry for flash animation
    div.className = `log-entry ${entryType}${isNew ? ' new-entry' : ''}`;
    const ts = new Date(entry.timestamp);
    // Format as HH:MM:SS for compact display
    const timeStr = ts.toISOString().replace('T', ' ').substring(11, 19);
    div.innerHTML = `<span class="timestamp">${timeStr}</span>` +
        `<span class="action">${entry.action || entry.description || ''}</span>` +
        `<span class="outcome">${entry.outcome || ''}</span>`;
    return div;
}

What does this code do?

  • entryType reads the type field from each log entry (procedure, resolver, or autopilot). Your CSS uses these as class names to apply the blue, orange, or purple left border.
  • When isNew is true, the .new-entry class triggers the highlight-flash keyframe animation, giving new entries a brief blue glow as they appear.
  • The timestamp is extracted as just HH:MM:SS to keep the log compact. Full dates would waste horizontal space in an ops console.
  • Save app.js.

You won't see log entries populate yet because the function has not been called. The next chunk activates it.

Activate polling and run the initial fetch

The log viewer polls every 10 seconds. Unlike the telemetry panel which uses a persistent WebSocket stream, the log endpoint is a standard REST call. Polling at 10 seconds balances freshness with network efficiency for data that changes less frequently.

Why polling instead of WebSocket here?

Telemetry data arrives at sub-second intervals, making a persistent WebSocket connection worthwhile. Log entries are created only when procedures run or resolvers fire. Polling every 10 seconds avoids holding a connection open for data that might not arrive for minutes at a time.

  • Add the polling interval and immediate call directly below createLogEntry() by pasting this code at the very end of app.js:
setInterval(updateLogViewer, 10000);
updateLogViewer();
  • Save app.js and refresh your browser at http://localhost:8000/static/index.html.
  • Look at the bottom-right panel (Procedure Log). You should see log entries appearing with colored left borders: blue for procedures, orange for resolvers, and purple for autopilot sequences.
  • Wait 10 seconds. If new entries arrive, they appear at the top of the list with a brief blue highlight flash.

Log panel stays empty?

  • Open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Check for errors. A 404 on /logs/mission means your backend does not have this endpoint. Confirm it exists from Project 2.
  • If you see a JavaScript error mentioning createLogEntry is not defined, make sure the function is declared BEFORE the setInterval line. JavaScript hoists function declarations, but check for typos in the function name.
  • If the endpoint returns data but entries do not render, check that your backend returns objects with timestamp and type fields. The code falls back to procedure if type is missing, but timestamp is required for sorting.

Help me debug my log viewer panel.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js and all four panels are rendering data in your browser.

ⓧ I'd like to double check the full code

Here is the complete app.js file as it should look at the end of this step:

const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace(/^http/, 'ws');

// --- Clock ---
function updateClock() {
    const now = new Date();
    document.getElementById('clock').textContent =
        now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
setInterval(updateClock, 1000);
updateClock();

// --- Leaflet Map ---
const map = L.map('map').setView([20, 0], 2);

L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

const satelliteMarkers = {};
const groundTrackLines = {};

async function fetchJSON(path) {
    const response = await fetch(`${API_BASE}${path}`);
    if (!response.ok) return null;
    return response.json();
}

async function updateSatellitePositions() {
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;
    for (const sat of satellites) {
        const position = await fetchJSON(`/satellites/${sat.id}/position`);
        if (!position) continue;
        const latlng = [position.latitude, position.longitude];
        if (satelliteMarkers[sat.id]) {
            satelliteMarkers[sat.id].setLatLng(latlng);
            satelliteMarkers[sat.id].setPopupContent(`<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`);
        } else {
            satelliteMarkers[sat.id] = L.circleMarker(latlng, { radius: 6, fillColor: '#4fc3f7', fillOpacity: 1, color: '#fff', weight: 1 }).addTo(map).bindPopup(`<b>${sat.name}</b><br>Lat: ${position.latitude.toFixed(2)}<br>Lon: ${position.longitude.toFixed(2)}<br>Alt: ${position.altitude_km.toFixed(1)} km`);
        }
        const track = await fetchJSON(`/satellites/${sat.id}/ground-track`);
        if (track && track.coordinates) {
            if (groundTrackLines[sat.id]) { groundTrackLines[sat.id].setLatLngs(track.coordinates); }
            else { groundTrackLines[sat.id] = L.polyline(track.coordinates, { color: '#4fc3f7', weight: 1, opacity: 0.5, dashArray: '5, 5' }).addTo(map); }
        }
    }
}
setInterval(updateSatellitePositions, 5000);
updateSatellitePositions();

// --- Contact Timeline ---
async function updateTimeline() {
    const container = document.getElementById('timeline');
    const satellites = await fetchJSON('/satellites');
    if (!satellites) return;
    const now = new Date();
    const endWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
    let html = '<div class="timeline-grid">';
    html += '<div class="timeline-header"><span></span>';
    for (let h = 0; h < 24; h++) {
        const hour = new Date(now.getTime() + h * 60 * 60 * 1000);
        html += `<span>${String(hour.getUTCHours()).padStart(2, '0')}:00</span>`;
    }
    html += '</div>';
    for (const sat of satellites) {
        const passes = await fetchJSON(`/satellites/${sat.id}/passes`);
        html += '<div class="timeline-row">';
        html += `<div class="timeline-row-label">${sat.name}</div>`;
        html += '<div class="timeline-row-bars">';
        if (passes) {
            for (const pass of passes) {
                const aos = new Date(pass.aos);
                const los = new Date(pass.los);
                if (los < now || aos > endWindow) continue;
                const startPct = Math.max(0, (aos - now) / (24 * 60 * 60 * 1000) * 100);
                const endPct = Math.min(100, (los - now) / (24 * 60 * 60 * 1000) * 100);
                const widthPct = endPct - startPct;
                let elevClass = 'low-elevation';
                if (pass.max_elevation > 45) elevClass = 'high-elevation';
                else if (pass.max_elevation > 20) elevClass = 'mid-elevation';
                html += `<div class="pass-bar ${elevClass}" style="left:${startPct}%;width:${widthPct}%">${pass.max_elevation.toFixed(0)}&deg;</div>`;
            }
        }
        html += '</div></div>';
    }
    html += '</div>';
    container.innerHTML = html;
}
setInterval(updateTimeline, 30000);
updateTimeline();

// --- Live Telemetry via WebSocket ---
let ws = null;
let reconnectDelay = 3000;

function connectTelemetry() {
    ws = new WebSocket(`${WS_BASE}/ws/telemetry`);
    ws.addEventListener('open', () => {
        document.getElementById('connection-status').textContent = 'CONNECTED';
        document.getElementById('connection-status').className = 'status-connected';
        reconnectDelay = 3000;
    });
    ws.addEventListener('message', (event) => {
        const data = JSON.parse(event.data);
        updateTelemetryCard(data);
    });
    ws.addEventListener('close', () => {
        document.getElementById('connection-status').textContent = 'DISCONNECTED';
        document.getElementById('connection-status').className = 'status-disconnected';
        setTimeout(connectTelemetry, reconnectDelay);
        reconnectDelay = Math.min(reconnectDelay * 2, 30000);
    });
    ws.addEventListener('error', () => { ws.close(); });
}

function updateTelemetryCard(data) {
    const container = document.getElementById('telemetry');
    let card = document.getElementById(`telem-${data.satellite_id}`);
    const alarmClass = data.alarm_state ? data.alarm_state.toLowerCase() : 'nominal';
    if (!card) {
        card = document.createElement('div');
        card.id = `telem-${data.satellite_id}`;
        card.className = `telemetry-card ${alarmClass}`;
        container.appendChild(card);
    } else {
        card.className = `telemetry-card ${alarmClass}`;
    }
    card.innerHTML = `<h3>${data.satellite_name || data.satellite_id}</h3><div class="telemetry-value"><span class="label">Power:</span>${data.power_w || '--'} W</div><div class="telemetry-value"><span class="label">Temp:</span>${data.thermal_c || '--'} &deg;C</div><div class="telemetry-value"><span class="label">Attitude:</span>${data.attitude_deg || '--'}&deg;</div><div class="telemetry-value"><span class="label">State:</span>${data.alarm_state || 'NOMINAL'}</div>`;
}

connectTelemetry();

// --- Procedure Log Viewer ---
let lastLogTimestamp = null;

async function updateLogViewer() {
    const logs = await fetchJSON('/logs/mission');
    if (!logs) return;
    const container = document.getElementById('log-viewer');
    const entries = Array.isArray(logs) ? logs : logs.entries || [];
    const sorted = entries.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
    const newEntries = lastLogTimestamp
        ? sorted.filter(e => new Date(e.timestamp) > new Date(lastLogTimestamp))
        : sorted;
    if (!lastLogTimestamp) {
        container.innerHTML = '';
        for (const entry of sorted.slice(0, 100)) {
            container.appendChild(createLogEntry(entry, false));
        }
    } else {
        for (const entry of newEntries.reverse()) {
            const el = createLogEntry(entry, true);
            container.insertBefore(el, container.firstChild);
        }
    }
    if (sorted.length > 0) { lastLogTimestamp = sorted[0].timestamp; }
}

function createLogEntry(entry, isNew) {
    const div = document.createElement('div');
    const entryType = entry.type || 'procedure';
    div.className = `log-entry ${entryType}${isNew ? ' new-entry' : ''}`;
    const ts = new Date(entry.timestamp);
    const timeStr = ts.toISOString().replace('T', ' ').substring(11, 19);
    div.innerHTML = `<span class="timestamp">${timeStr}</span>` +
        `<span class="action">${entry.action || entry.description || ''}</span>` +
        `<span class="outcome">${entry.outcome || ''}</span>`;
    return div;
}

setInterval(updateLogViewer, 10000);
updateLogViewer();

All four panels of your GroundOps Console are now fully operational. You have a satellite tracking map, a contact timeline, live telemetry cards, and a scrollable procedure log. Your dashboard is a complete mission operations interface consuming both REST and WebSocket APIs in real time.

Secret mission

Add Ground Station Visibility Footprints

Your dashboard shows where satellites are, but operators also need to know when a ground station can actually talk to a satellite. Add dynamic circle overlays to the map that visualize each ground station's radio horizon based on its elevation mask angle and altitude.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources, so there are no ongoing costs.

Resources you used:

  • Running uvicorn local development server.
  • The static/ directory containing index.html, style.css, and app.js.
  • Updated main.py with the StaticFiles mount and root route.

Keep everything running

No action needed. Choose this if you are still actively building on your GroundOps Console or plan to extend the dashboard with additional panels.

  • Your uvicorn server continues running at http://localhost:8000.
  • All static/ files remain in your project directory, ready for further edits.
  • When you are ready to deploy, push the static/ directory alongside your backend code to Render. The dashboard will be served automatically.

Pause - I'll come back to this later

Shut down the running server process to free up the terminal. Your files stay on disk for when you return.

  • Switch to the terminal where uvicorn is running.
  • Press Ctrl+C to stop the server.
  • Your project files remain untouched. To resume later, navigate back to your project folder and run uvicorn main:app --reload again.

Delete - I don't want to use this again

Remove the dashboard files you created in this project. This leaves your backend code from Projects 1 and 2 intact.

  • Switch to the terminal where uvicorn is running and press Ctrl+C to stop the server.
  • Delete the static/ directory by running this command from your project root:

macOS

rm -rf static/

Windows

rmdir /s /q static
  • Open main.py and remove the StaticFiles import, the app.mount("/static", ...) line, and the root route that serves index.html.
  • Save main.py. Your backend reverts to its state at the end of Project 2.

That's a Wrap!

That's a Wrap!

Nice work! You've just built a professional satellite operations dashboard that consumes your backend's REST and WebSocket APIs in real time.

You've learned how to:

  • Build a four-panel ops-console dashboard with a Leaflet.js map showing real-time satellite positions and ground track polylines.
  • Create a CSS Grid Gantt-style contact timeline displaying 24-hour pass windows color-coded by elevation quality.
  • Consume live WebSocket telemetry streams with color-coded alarm states and exponential backoff reconnection logic.
  • Secret Mission: Added ground station visibility footprint circles using geometric horizon calculations to show station coverage on the map.

Ready to quiz yourself?