· 8 min read

Building Real-Time OEE Dashboards with Ignition WebDev

A practical walkthrough of building a production OEE dashboard using HTML, CSS, JavaScript, and Ignition's WebDev module REST endpoints.

OEE Tutorial WebDev Module

OEE dashboards are the single most requested project in manufacturing IT. Every plant wants one. Most plants have something — a spreadsheet, a Perspective screen, a whiteboard with magnets.

Very few have one that people actually trust.

This tutorial walks through building a real-time OEE dashboard using Ignition's WebDev module — from the data model to the REST API to the frontend that runs on a factory floor TV. No frameworks, no build tools, no dependencies. Just HTML, CSS, JavaScript, and Ignition scripting.

By the end, you'll have a dashboard that calculates OEE in real time, updates every 10 seconds, and survives Gateway restarts without anyone touching it.

What We're Building

A single-page dashboard showing real-time OEE for one or more production lines. Each line displays:

  • Overall OEE percentage — the big number everyone looks at
  • Availability, Performance, Quality — the three pillars, broken out individually
  • Current status — running, down, changeover, break
  • Shift production count — parts produced vs. target
  • Top downtime reasons — what's actually killing your numbers

The page runs in kiosk mode on a TV. It fetches fresh data every 10 seconds. No login, no session, no interaction needed.

The Data Foundation

Before writing any code, you need clean data. OEE is only as good as what feeds it. Here's the minimum viable data model:

Required Tags

At a minimum, you need these tags per line (or machine):

[default]Line1/
├── State              # INT — 0=Down, 1=Running, 2=Changeover, 3=Break
├── PartCount          # INT — running count of good parts this shift
├── RejectCount        # INT — running count of rejected parts this shift
├── IdealCycleTime     # FLOAT — seconds per part at rated speed
├── ActualCycleTime    # FLOAT — current average cycle time
├── ShiftStart         # DATETIME — when the current shift began
├── PlannedDowntime    # FLOAT — minutes of planned stops (breaks, PMs)
└── DowntimeReason     # STRING — current reason if State = 0

The magic of OEE is that it distills all of this into three percentages:

  • Availability = (Run Time) / (Planned Production Time)
  • Performance = (Ideal Cycle Time × Part Count) / (Run Time)
  • Quality = (Good Parts) / (Total Parts)
  • OEE = Availability × Performance × Quality

Downtime Tracking Table

For the "top downtime reasons" section, you'll need a database table logging downtime events:

CREATE TABLE downtime_log (
    id          INT IDENTITY PRIMARY KEY,
    line_id     VARCHAR(20) NOT NULL,
    reason      VARCHAR(100) NOT NULL,
    start_time  DATETIME NOT NULL,
    end_time    DATETIME,
    duration_min AS DATEDIFF(MINUTE, start_time, ISNULL(end_time, GETDATE())),
    shift_date  DATE NOT NULL,
    category    VARCHAR(50)  -- mechanical, electrical, material, quality, etc.
);

This is the data that makes OEE dashboards actionable. Showing "85% OEE" is interesting. Showing "85% OEE — 47 minutes lost to material shortages" is something a supervisor can act on.

Building the REST API

The WebDev module is your API layer. We'll create two endpoints:

  1. /system/webdev/oee/api/lines — returns real-time OEE for all lines
  2. /system/webdev/oee/api/downtime — returns top downtime reasons for a given line

Endpoint 1: Line OEE Data

Create a Python resource called api/lines in a WebDev project named oee:

def doGet(request, session):
    import system
    from java.util import Date

    lines = ["Line1", "Line2", "Line3"]
    results = []

    for line in lines:
        prefix = "[default]%s/" % line

        # Read all tags in a single call (batch for performance)
        paths = [
            prefix + "State",
            prefix + "PartCount",
            prefix + "RejectCount",
            prefix + "IdealCycleTime",
            prefix + "ActualCycleTime",
            prefix + "ShiftStart",
            prefix + "PlannedDowntime",
            prefix + "DowntimeReason"
        ]
        values = system.tag.readBlocking(paths)

        state = values[0].value
        part_count = values[1].value or 0
        reject_count = values[2].value or 0
        ideal_cycle = values[3].value or 1.0
        actual_cycle = values[4].value or 1.0
        shift_start = values[5].value
        planned_downtime = values[6].value or 0.0
        downtime_reason = values[7].value or ""

        # Calculate elapsed shift time in minutes
        now = Date()
        shift_minutes = (now.getTime() - shift_start.getTime()) / 60000.0
        planned_production_min = max(shift_minutes - planned_downtime, 1)

        # Get actual run time from downtime log
        unplanned_down = system.db.runScalarPrepQuery(
            """SELECT ISNULL(SUM(duration_min), 0)
               FROM downtime_log
               WHERE line_id = ? AND shift_date = CAST(GETDATE() AS DATE)
                 AND category != 'planned'""",
            [line], "production_db"
        )
        run_time = max(planned_production_min - unplanned_down, 1)

        # OEE components
        availability = min(run_time / planned_production_min, 1.0)
        total_parts = part_count + reject_count
        performance = min((ideal_cycle * total_parts) / (run_time * 60), 1.0) if run_time > 0 else 0
        quality = float(part_count) / max(total_parts, 1)
        oee = availability * performance * quality

        # State label
        state_labels = {0: "Down", 1: "Running", 2: "Changeover", 3: "Break"}

        # Shift target (based on ideal cycle time and planned production time)
        shift_target = int((planned_production_min * 60) / ideal_cycle) if ideal_cycle > 0 else 0

        results.append({
            "line": line,
            "oee": round(oee * 100, 1),
            "availability": round(availability * 100, 1),
            "performance": round(performance * 100, 1),
            "quality": round(quality * 100, 1),
            "state": state_labels.get(state, "Unknown"),
            "stateCode": state,
            "partCount": part_count,
            "rejectCount": reject_count,
            "shiftTarget": shift_target,
            "downtimeReason": downtime_reason
        })

    return {
        "json": {
            "lines": results,
            "timestamp": system.date.format(Date(), "yyyy-MM-dd'T'HH:mm:ss"),
            "shiftMinutesElapsed": round(shift_minutes, 1)
        }
    }

A few things worth noting in this endpoint:

  • Batch tag reads — we read all tags for a line in one readBlocking call instead of eight individual reads. This matters when you have 10+ lines.
  • Capped at 100% — each OEE component is capped at 1.0 with min(). Bad data or timing edge cases can produce values over 100%, which erodes trust in the dashboard instantly.
  • Shift target calculation — showing "452 / 500 parts" is more intuitive than "90.4% Performance." Include both.

Endpoint 2: Downtime Breakdown

Create a second resource at api/downtime:

def doGet(request, session):
    import system

    line = request['params'].get('line', 'Line1')

    data = system.db.runPrepQuery(
        """SELECT TOP 5
               reason,
               category,
               SUM(duration_min) AS total_minutes,
               COUNT(*) AS occurrences
           FROM downtime_log
           WHERE line_id = ? AND shift_date = CAST(GETDATE() AS DATE)
           GROUP BY reason, category
           ORDER BY total_minutes DESC""",
        [line], "production_db"
    )

    reasons = []
    for row in system.dataset.toPyDataSet(data):
        reasons.append({
            "reason": row["reason"],
            "category": row["category"],
            "minutes": int(row["total_minutes"]),
            "count": int(row["occurrences"])
        })

    return {"json": {"line": line, "downtime": reasons}}

Simple. Returns the top 5 downtime reasons for the current shift, sorted by total minutes lost. This is the data that turns a vanity dashboard into an action board.

Building the Frontend

Now for the part people actually see. Create an HTML resource called dashboard in your WebDev project. This is the full page — HTML, CSS, and JavaScript in one file.

The Layout

We're using CSS Grid for the overall layout and Flexbox within each card. No framework needed — this is a display page, not a web app.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OEE Dashboard</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: 'Inter', system-ui, sans-serif;
      background: #0a0a0f;
      color: #e5e5e5;
      min-height: 100vh;
    }

    .header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 1.5rem 2rem;
      border-bottom: 1px solid #1e1e2e;
    }
    .header h1 { font-size: 1.5rem; font-weight: 700; }
    .header .timestamp { color: #888; font-size: 0.875rem; }

    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
      gap: 1.5rem;
      padding: 2rem;
    }

    .line-card {
      background: #12121a;
      border: 1px solid #1e1e2e;
      border-radius: 1rem;
      padding: 1.5rem;
      transition: border-color 0.3s;
    }
    .line-card.running { border-left: 4px solid #22c55e; }
    .line-card.down { border-left: 4px solid #ef4444; }
    .line-card.changeover { border-left: 4px solid #f59e0b; }

    .line-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1.25rem;
    }
    .line-name { font-size: 1.25rem; font-weight: 700; }
    .status-badge {
      font-size: 0.75rem;
      font-weight: 600;
      padding: 0.25rem 0.75rem;
      border-radius: 999px;
      text-transform: uppercase;
      letter-spacing: 0.05em;
    }
    .status-badge.running { background: #052e16; color: #4ade80; }
    .status-badge.down { background: #2a0a0a; color: #f87171; }
    .status-badge.changeover { background: #2a1f00; color: #fbbf24; }
    .status-badge.break { background: #1a1a2e; color: #94a3b8; }

    .oee-big {
      font-size: 4rem;
      font-weight: 800;
      line-height: 1;
      margin-bottom: 0.5rem;
    }
    .oee-label { color: #888; font-size: 0.875rem; margin-bottom: 1.5rem; }

    .pillars {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 1rem;
      margin-bottom: 1.25rem;
    }
    .pillar {
      text-align: center;
      padding: 0.75rem;
      background: #0a0a14;
      border-radius: 0.5rem;
    }
    .pillar-value {
      font-size: 1.5rem;
      font-weight: 700;
    }
    .pillar-label {
      font-size: 0.75rem;
      color: #888;
      margin-top: 0.25rem;
    }

    .progress-bar {
      width: 100%;
      height: 6px;
      background: #1e1e2e;
      border-radius: 3px;
      overflow: hidden;
      margin: 1rem 0 0.5rem;
    }
    .progress-fill {
      height: 100%;
      border-radius: 3px;
      transition: width 0.5s ease;
    }
    .parts-text {
      display: flex;
      justify-content: space-between;
      font-size: 0.8rem;
      color: #888;
    }

    .downtime-reason {
      color: #f87171;
      font-size: 0.85rem;
      margin-top: 0.75rem;
      padding: 0.5rem 0.75rem;
      background: #1a0a0a;
      border-radius: 0.5rem;
    }

    /* Color coding OEE values */
    .oee-green { color: #4ade80; }
    .oee-yellow { color: #fbbf24; }
    .oee-red { color: #f87171; }

    /* Connection status indicator */
    .conn-status {
      width: 8px; height: 8px;
      border-radius: 50%;
      display: inline-block;
      margin-right: 0.5rem;
    }
    .conn-status.ok { background: #22c55e; }
    .conn-status.error { background: #ef4444; }
  </style>
</head>
<body>
  <div class="header">
    <h1>Production OEE</h1>
    <div class="timestamp">
      <span class="conn-status ok" id="connDot"></span>
      <span id="lastUpdate">Loading...</span>
    </div>
  </div>
  <div class="grid" id="lineGrid"></div>

  <script>
    const API_BASE = '/system/webdev/oee/api';
    const REFRESH_MS = 10000;

    function oeeColor(val) {
      if (val >= 85) return 'oee-green';
      if (val >= 60) return 'oee-yellow';
      return 'oee-red';
    }

    function progressColor(val) {
      if (val >= 85) return '#22c55e';
      if (val >= 60) return '#f59e0b';
      return '#ef4444';
    }

    function renderLine(line) {
      const stateClass = line.state.toLowerCase();
      const pct = line.shiftTarget > 0
        ? Math.min((line.partCount / line.shiftTarget) * 100, 100)
        : 0;

      let html = '<div class="line-card ' + stateClass + '">';
      html += '<div class="line-header">';
      html += '  <span class="line-name">' + line.line + '</span>';
      html += '  <span class="status-badge ' + stateClass + '">'
           + line.state + '</span>';
      html += '</div>';
      html += '<div class="oee-big ' + oeeColor(line.oee) + '">'
           + line.oee.toFixed(1) + '%</div>';
      html += '<div class="oee-label">Overall Equipment Effectiveness</div>';
      html += '<div class="pillars">';
      html += pillar('Availability', line.availability);
      html += pillar('Performance', line.performance);
      html += pillar('Quality', line.quality);
      html += '</div>';
      html += '<div class="progress-bar"><div class="progress-fill" style="width:'
           + pct + '%;background:' + progressColor(pct) + '"></div></div>';
      html += '<div class="parts-text"><span>' + line.partCount
           + ' parts</span><span>Target: ' + line.shiftTarget + '</span></div>';

      if (line.stateCode === 0 && line.downtimeReason) {
        html += '<div class="downtime-reason">⚠ ' + line.downtimeReason + '</div>';
      }
      html += '</div>';
      return html;
    }

    function pillar(label, value) {
      return '<div class="pillar"><div class="pillar-value '
           + oeeColor(value) + '">' + value.toFixed(1)
           + '%</div><div class="pillar-label">' + label + '</div></div>';
    }

    async function refresh() {
      try {
        const resp = await fetch(API_BASE + '/lines');
        const data = await resp.json();

        document.getElementById('lineGrid').innerHTML =
          data.lines.map(renderLine).join('');
        document.getElementById('lastUpdate').textContent =
          'Updated ' + new Date().toLocaleTimeString();
        document.getElementById('connDot').className = 'conn-status ok';
      } catch (err) {
        document.getElementById('connDot').className = 'conn-status error';
        document.getElementById('lastUpdate').textContent =
          'Connection lost — retrying...';
      }
    }

    refresh();
    setInterval(refresh, REFRESH_MS);
  </script>
</body>
</html>

What This Gets You

Open http://your-gateway:8088/system/webdev/oee/dashboard in Chrome, press F11, walk away. The dashboard:

  • Auto-refreshes every 10 seconds — no human interaction needed
  • Shows connection status — the green dot turns red if it can't reach the Gateway, so maintenance knows at a glance if the display or the network is the problem
  • Color-codes everything — green (>85%), yellow (60-85%), red (<60%) on all OEE values
  • Shows active downtime reasons — when a line goes down, the reason appears immediately
  • Scales automatically — CSS Grid handles 3 lines, 6 lines, or 12 lines without layout changes
  • Survives Gateway restarts — the catch block keeps the page alive and retrying during outages

Making It Production-Ready

The code above is functional but needs a few additions before it's ready for a 24/7 production environment.

1. Auto-Recover After Network Drops

Factory networks aren't perfect. Switches reboot, cables get bumped, and wireless bridges drop. Add a visibility check so the page doesn't queue up hundreds of stale requests when the tab is backgrounded:

let refreshTimer;

function startRefresh() {
    refresh();
    refreshTimer = setInterval(refresh, REFRESH_MS);
}

function stopRefresh() {
    clearInterval(refreshTimer);
}

document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        stopRefresh();
    } else {
        startRefresh(); // Immediate refresh when tab becomes visible
    }
});

startRefresh();

2. Force Page Reload at Shift Change

OEE resets at each shift boundary. Rather than building shift-change logic into the frontend, just reload the page:

// Reload at shift changes (6:00, 14:00, 22:00)
function checkShiftChange() {
    const hour = new Date().getHours();
    const minute = new Date().getMinutes();
    if ([6, 14, 22].includes(hour) && minute === 0) {
        location.reload();
    }
}
setInterval(checkShiftChange, 30000);

3. Add Authentication (When Needed)

Floor displays usually don't need authentication — the TV is physically inside the plant. But if you're exposing the dashboard on a corporate network, add HTTP Basic auth to the WebDev resource and include credentials in the fetch call:

const resp = await fetch(API_BASE + '/lines', {
    headers: {
        'Authorization': 'Basic ' + btoa('dashuser:securePassword')
    }
});

Or better yet, handle authentication at the network level with IP whitelisting in your Gateway's reverse proxy.

Performance Tips

Once you have the basics working, here's what separates a good OEE dashboard from a great one:

Cache the Calculation, Not the Display

If you have 20 TVs all pointing at the same dashboard URL, that's 20 devices hitting your API endpoint every 10 seconds — 120 requests per minute. The tag reads are fast, but the database query for downtime can add up.

Add a simple cache in your WebDev endpoint using a project library script:

# In a project library script called 'oeeCache'
_cache = {"data": None, "timestamp": 0}

def getOEEData(max_age_seconds=5):
    import time, system
    now = time.time()
    if _cache["data"] and (now - _cache["timestamp"]) < max_age_seconds:
        return _cache["data"]

    # ... run the actual calculation ...
    result = _calculateOEE()
    _cache["data"] = result
    _cache["timestamp"] = now
    return result

Now 20 TVs refreshing every 10 seconds only trigger an actual calculation every 5 seconds. One query serves all displays.

Use Tag Groups for Consistent Reads

If your tags update at different rates (some on change, some polling every second), you can get inconsistent snapshots — part count from this second, cycle time from last second. For OEE, this usually doesn't matter. But if precision matters, read from a snapshot table that your Gateway updates atomically.

Test with Realistic Data

OEE dashboards look great in development when every value is a clean 85.0%. They look terrible when a line has been down for 3 hours and shows 12.4% OEE with a quality of NaN because no parts were produced.

Test with zeros. Test with lines down since shift start. Test with 100% values. Test with one line running and two lines that haven't started yet. These edge cases are where trust breaks down.

Beyond the Basics

Once the real-time view is solid, the natural next steps are:

  • Historical OEE trending — store shift-end OEE snapshots in a database table and add a sparkline or mini-chart showing the last 7 days
  • Pareto charts — visualize downtime reasons as a Pareto chart to drive continuous improvement meetings
  • Drill-down views — click a line card to see detailed machine-level OEE (if your data model goes that deep)
  • Shift comparison — show how Day shift compares to Night shift, driving healthy competition
  • Email/SMS alerts — trigger a notification when OEE drops below a threshold, using the same WebDev endpoint data

Each of these is a new WebDev endpoint and a bit more JavaScript. The architecture doesn't change — just the number of endpoints and the complexity of the frontend.

Why This Matters

The best OEE dashboards aren't the ones with the most features. They're the ones operators believe.

When the floor supervisor glances at the TV and sees "Line 2 — 73.4% — Material Shortage," they should nod and think "yeah, that's about right." If the number doesn't match reality — if it says 95% when the line has been struggling all day — they'll stop looking at the screen. And a dashboard nobody looks at is worse than no dashboard at all.

Start simple. Get the data right. Make it fast. Make it visible. Then iterate.


Want a dashboard like this without the development time? We build custom OEE dashboards for Ignition environments — tailored to your lines, your data model, and your floor layout. Send us a project backup and we'll show you what's possible. Start with a free readiness check.