Skip to main content
Sign In

Tracking Instrument Calibration with a Digital Dashboard

Get overdue and due-soon status from the dates, not from a status column that can go out of date

By Sumit Shinde, Technical Writer || 37 min read
Back to Blog Posts
Image representing null
TL;DR

Build a FlowFuse calibration dashboard that works out overdue, due-soon and compliance figures from the due dates instead of a stored status column, filters by department and time window from the app bar, lists every instrument due in that window with days remaining, and gives operators a camera-based tool check at the station.

Most plants know their calibration status at two moments: when someone updates the spreadsheet, and when an auditor asks. The records already hold enough to do better than that: a last calibration date, an interval, and a next due date for every instrument.

Most registers also have a status field next to those dates, and reading it seems like the obvious move. But whatever writes that field can fall behind. Then the dashboard reports a compliant plant while due dates quietly pass. This build works out every figure from the due date instead, and counts how often the stored status disagrees with it.

For background on calibration records, including what they should contain, how calibration intervals are determined, and why overdue instruments matter, see What Is Instrument Calibration (Equipment Calibration)?.

A screen in the quality office is only half the job, though. The moment that matters is when someone picks up a torque wrench, and that person isn't looking at a dashboard. So the same check also runs behind a camera at the station: scan the label, get a verdict.

Calibration status dashboard showing KPI cards for total equipment, valid, due soon, overdue, upcoming, and compliance percentage The finished dashboard: filters in the app bar, KPI row, and asset detail table on one page.

In this article, we'll build a calibration application in FlowFuse. It will show plant-level compliance figures, let you filter by department and time window, give an asset-level view of what's due and when, and offer a camera-based tool check for the shop floor.

Note: This tutorial reads from a calibration_assets table in FlowFuse Tables, filled with sample instruments. Everything here works the same against a real calibration register, a CMMS, or an ERP endpoint. Only the query nodes change.

You can interact with the live demo here: Try the Calibration Status Dashboard Demo.

What You'll Need

Before you start building, get these ready:

  • A FlowFuse account. Sign up for FlowFuse Cloud, or use a self-hosted instance.
  • A FlowFuse instance up and running. If you don't have one yet, create a new instance from your FlowFuse Platform.
  • FlowFuse Dashboard installed. This tutorial uses @flowfuse/node-red-dashboard nodes (ui-template, ui-event, ui-button, ui-markdown, ui-notification, ui-page, ui-group, ui-theme) to build both pages. Install it from the Palette Manager if it isn't already in your instance.
  • A webcam node and an OCR node. The Tool Check page uses @sumit_shinde_84/node-red-dashboard-2-ui-webcam to capture the label and @sumit_shinde_84/node-red-contrib-simple-ocr to read it. Install both from the Palette Manager.
  • A device with a camera for the Tool Check page, since the scanner works through the browser.

Note: FlowFuse Tables is available to Enterprise tier teams on FlowFuse Cloud, and to Enterprise licensed self-hosted teams running on Kubernetes. If you're on a different tier, use any external database instead, such as Postgres or MySQL, with the standard database nodes from the palette. The SQL here is standard PostgreSQL, and the widgets only care about the shape of the rows, not where they came from.

How the Application Works

Before we build anything, let's walk through what the application does. There are two pages, and one idea holding them together.

  1. Home. A quality engineer picks a department and how far ahead to look. Six figures across the top show how much equipment there is, and how much of it is valid, due soon, overdue, coming up in the chosen window, and compliant overall. Below that is a list of instruments due inside that window, most urgent first, each with its status, due date, and days remaining. The cards describe the whole register for the chosen department. Only the Upcoming figure and the table below respond to the time window.
  2. Tool Check. An operator holds a calibration label up to the camera and presses SCAN. The app reads the tool ID off the label, looks it up, and shows an answer in the middle of the screen: OK to use, Do not use, Not in register, or Label not readable. No numbers to interpret and no dashboard to search through.

The due date is the single source of truth for the whole application. Every KPI figure, every status chip in the table, and the scanner's verdict all come from comparing that date to the current time. That is what stops the office screen and the station gate from ever telling two different stories about the same tool.

Importing the Simulated Flow

Instead of pointing the application at a live calibration register, we'll generate one.

  1. Import the following flow into FlowFuse and click Deploy.
  2. Click the trigger on Create Table, then the trigger on Generate & Insert Automotive Calibration Dataset. Drop Table is there for when you want to start over from scratch.

That gives you 120 instruments with IDs from TL-0001 to TL-0120, spread across eight departments: roughly 70% valid, 20% due soon, and 10% overdue. The eight department names are the same ones the filter dropdown offers later.

Everything we build reads from these columns:

Column What the application does with it
tool_id Primary key, and the ID printed on the calibration label that the scanner matches against
next_due_ts Every status figure, chip, and scanner verdict comes from comparing this against the current time
status The stored label. We deliberately don't trust it, and use it only to spot drift from the dates
department Drives the Department filter on the dashboard
equipment_name Names the instrument in the table and in the scanner verdict
location, calibration_interval_months, last_calibration_ts Table columns
serial_number, calibration_lab, certificate_no, created_at Kept for reference; nothing in this build shows them

Note: Your register will likely name these columns differently. Alias your columns in the SELECT list, as in SELECT gauge_ref AS tool_id, cal_due_date AS next_due_ts, ..., and every widget will still work. Only the due-date column is essential.

Before running the generator, set its reference date near the top of the function, const today = new Date('2026-07-28T10:00:00'), to roughly today's date. Otherwise everything will land relative to a date in the past.

Setting Up the Dashboard Layout

The application has two pages and four groups. Most widgets sit inside a named group. The filter bar is scoped to the page instead, because it appears in the app bar rather than in the grid. See the Dashboard layout docs if you're new to how pages, groups, and bases relate.

  1. Create two ui-page nodes. The Dashboard automatically creates a base dashboard the first time you add a Dashboard node to the canvas.

    • Home (path: /home): the calibration status dashboard. Set the layout to Grid.
    • Tool Check (path: /tool-check): the scanner. Set the layout to Notebook.
  2. On the Home page, create two ui-group nodes, both 12 columns wide with Show title turned off:

    • KPIs (order 1)
    • Asset Detail (order 2)
  3. On the Tool Check page, create two more ui-group nodes, also 12 columns wide with titles off:

    • Scanner (order 1)
    • Verdict (order 2)
  4. Open the ui-base node and set Header Content to page. This shows the app bar, which the filter widget needs.

  5. Set up the ui-theme to suit your needs. The dashboard already comes with a default theme, which you can use for now.

Building the Filter Bar

Two controls drive the page: which department, and how far ahead to look. Both sit in the app bar rather than the grid, so no row of the page is used up by a dropdown.

The department list is read from the register itself, not typed in by hand, so adding a department next year doesn't mean editing a flow. The time windows are fixed, because "next 30 days" is a choice this application makes rather than something the data tells you.

  1. Add a ui-event node named "Page Load / Refresh Event" and select your "My Dashboard" ui-base. It fires whenever a page loads.

  2. Add a tables-query node named "Load Departments":

Tip: You don't have to write the SQL yourself either. Use FlowFuse Expert and describe what you want the query to return in plain English, and it will generate the SQL for you.

SELECT DISTINCT department
FROM calibration_assets
WHERE department IS NOT NULL
AND btrim(department) <> ''
ORDER BY department;
  1. Import the App Header Filters ui-template below, set its type to Widget (Page-Scoped), select the Home page, and turn off Pass through messages from input to output. The complete component is provided below, so there's no need to build it yourself. It shows two dropdowns in the header using Vue's Teleport: Department, filled from the query above, and Time Window. It opens on All departments and Next 90 days, sends { department, timeWindow } on every change, and sends the same on load so the dropdowns and the data always agree.

Tip: Whenever you need a custom Dashboard widget, you don't have to write the Vue code yourself. Use FlowFuse Expert and describe the widget in plain English, and it will generate the ui-template for you.

  1. Add a change node named "Save Filters to Global Context" and set the persistent global filters to msg.payload.

  2. Add a link out node named "Filters Changed". This is what makes the page respond right away. Changing a dropdown writes the new selection to context, but nothing would redraw until the next poll tick, so the operator would be looking at stale numbers for up to fifteen seconds. The link carries the change straight into the next flow, which re-runs its query and refreshes the page.

  3. Wire the nodes: Page Load / Refresh Event → Load Departments → App Header Filters → Save Filters to Global Context → Filters Changed.

Placeholder: the app bar showing the Department and Time Window dropdowns The Department and Time Window dropdowns shown in the app bar, with departments read from the register.

Working Out the KPI Figures from the Due Dates

This is where we stop using the register's status column. Every figure on the page comes from comparing next_due_ts against the current time, and one extra count reports how often the stored status disagrees with that comparison.

  1. Add an inject node named "Poll Data (15 sec)", set it to repeat every 15 seconds and to fire once shortly after deploy, so the page has data before the first tick.

  2. Add a link in node named "Filters Changed" and point it at the link out from the filter bar.

  3. Add a change node named "Set Params". This maps the saved filters onto the $department and $timeWindow parameters the SQL reads. Set three rules, in order:

    • Set queryParameters to {} (JSON): starts clean so old values don't linger.
    • Set queryParameters.department to filters.department (global persistent).
    • Set queryParameters.timeWindow to filters.timeWindow (global persistent).

Set Params change node with its three rules The "Set Params" change node open in the edit panel, all three rules visible.

  1. Add a tables-query node named "KPI Query":
SELECT
-- Sent back so the cards label themselves from the data on screen,
-- not from whatever the dropdowns happen to show. COALESCE covers the
-- first poll after deploy, before any browser has sent a selection.
COALESCE($department::text, 'All') AS filter_department,
COALESCE($timeWindow::text, 'Next 90 days') AS filter_window,

COUNT(*) AS total_assets,

-- Buckets worked out from next_due_ts instead of the stored status column,
-- which drifts whenever whatever writes it falls behind. These three
-- don't overlap, and add up to total_assets. The 30-day threshold is a
-- policy decision, not a view setting: it does not change with the dropdown.
COUNT(*) FILTER (
WHERE next_due_ts > NOW() + INTERVAL '30 days'
) AS valid_assets,

COUNT(*) FILTER (
WHERE next_due_ts >= NOW()
AND next_due_ts <= NOW() + INTERVAL '30 days'
) AS due_soon_assets,

COUNT(*) FILTER (
WHERE next_due_ts < NOW()
) AS overdue_assets,

-- The only figure the Time Window dropdown affects. It sends a label, so
-- it maps to an interval here; ELSE also catches an unrecognised label.
COUNT(*) FILTER (
WHERE next_due_ts >= NOW()
AND next_due_ts <= NOW() + (
CASE COALESCE($timeWindow::text, 'Next 90 days')
WHEN 'Next 7 days' THEN INTERVAL '7 days'
WHEN 'Next 30 days' THEN INTERVAL '30 days'
WHEN 'Next 90 days' THEN INTERVAL '90 days'
WHEN 'All future' THEN INTERVAL '100 years'
ELSE INTERVAL '90 days'
END
)
) AS due_in_window,

COUNT(*) FILTER (
WHERE next_due_ts BETWEEN NOW()
AND NOW() + INTERVAL '7 days'
) AS due_next_7_days,

-- Compliance means "inside its calibration interval", which includes
-- tools due soon, since they have not expired yet.
ROUND(
COUNT(*) FILTER (WHERE next_due_ts >= NOW()) * 100.0
/ NULLIF(COUNT(*), 0),
1
) AS valid_percentage,

-- Counts records where the stored status and the live date disagree, so
-- the drift shows up on the dashboard instead of staying hidden.
COUNT(*) FILTER (
WHERE (status = 'Overdue') <> (next_due_ts < NOW())
) AS stale_status_count

FROM calibration_assets
-- Driven by the Department dropdown; 'All' turns the filter off.
WHERE (COALESCE($department::text, 'All') = 'All'
OR department = $department::text);

stale_status_count is the figure worth watching. It counts the rows where the stored status and the due date disagree, which is the number that justifies leaving the status column out of every other calculation.

  1. Add a link out node named "Refresh Asset Table" and wire it from Set Params, so the table below runs on the same selection instead of working it out again.

  2. Import the KPI Cards ui-template below and assign it to the KPIs group. The complete component is provided below, so there's no need to build it yourself. It shows a header with the active filter and a last-updated time, six cards for total equipment, valid, due soon, overdue, upcoming and compliance, and a three-line summary strip. When stale_status_count is above zero it also shows a warning banner naming the number of drifted records.

  1. Wire the nodes: Poll Data (15 sec) → Set Params → KPI Query → KPI Cards, and Filters Changed → Set Params.

Deploy and open /home. The six cards fill in, and on the seeded data the drift banner should appear right away, because the generator creates records whose stored status no longer matches their due date.

the KPI row showing six cards and the summary strip Six figures worked out from the due dates, with the active filter and refresh time shown in the header.

Listing Every Instrument with Days Remaining

The cards tell a supervisor how much is overdue. This table tells them which tools, so someone can actually go and collect them. It's a worklist rather than a full register dump, which is why the time window scopes it down.

  1. Add a link in node named "Refresh Asset Table" and point it at the link out from the KPI flow. Taking the filters from that message instead of reading context again is what keeps the table and the cards working from the same selection.

  2. Add a tables-query node named "Asset Rows":

SELECT
-- Sent back so the table can state its own scope in the header.
COALESCE($timeWindow::text, 'Next 90 days') AS filter_window,

tool_id,
equipment_name,
department,
location,
serial_number,

-- Same 30-day policy threshold as the KPI buckets, so a tool never reads
-- one status here and another elsewhere. The bare column name still
-- refers to the table column.
CASE
WHEN next_due_ts < NOW() THEN 'Overdue'
WHEN next_due_ts <= NOW() + INTERVAL '30 days' THEN 'DueSoon'
ELSE 'Valid'
END AS status,
status AS stored_status,

last_calibration_ts,
next_due_ts,
calibration_interval_months,
calibration_lab,
certificate_no,

-- Negative for anything already past due, which the widget shows as
-- "12d overdue" rather than "-12d".
(next_due_ts::date - CURRENT_DATE) AS days_remaining

FROM calibration_assets

-- This is a worklist, not the whole register: the window scopes it to what
-- needs attention. Overdue tools have a past due date, so the upper bound
-- already includes them; no separate clause needed.
WHERE (COALESCE($department::text, 'All') = 'All'
OR department = $department::text)
AND next_due_ts <= NOW() + (
CASE COALESCE($timeWindow::text, 'Next 90 days')
WHEN 'Next 7 days' THEN INTERVAL '7 days'
WHEN 'Next 30 days' THEN INTERVAL '30 days'
WHEN 'Next 90 days' THEN INTERVAL '90 days'
WHEN 'All future' THEN INTERVAL '100 years'
ELSE INTERVAL '90 days'
END
)

ORDER BY next_due_ts ASC
LIMIT 500;

Sorting by due date ascending puts the worst offenders at the top, which is the only order that makes a calibration list useful. stored_status is included but not used by the widget, so it's there when you want to see exactly which records have drifted rather than just how many.

  1. Import the Asset Table ui-template below and assign it to the Asset Detail group. The complete component is provided below, so there's no need to build it yourself. It shows a Vuetify data table with a status chip per row, formatted dates, and days remaining shown as "12d overdue" or "due today". Its header names the active window, so it's clear the list is scoped rather than the full register. Above the table sit a search box and a status toggle with live counts for Overdue, Due soon and Valid, filtering the rows already on the page without hitting the database again. The Department column hides itself when every row belongs to the same department, since a single-department view makes it a fixed value.
  1. Wire the nodes: Refresh Asset Table → Asset Rows → Asset Table.

Deploy and open /home. The table fills in below the cards, most urgent first, with its header naming the window it covers. Change a dropdown and both halves of the page update together.

the asset detail table with the status toggle and search box Instruments due inside the selected window, soonest due first, with status chips and days remaining.

Note: The toggle counts describe the table, not the register, so they will read lower than the cards above. That's the point of the split: the cards answer "how are we doing," and the table answers "what needs collecting." Select All future and the two match.

Checking a Tool at the Station

A dashboard in the quality office doesn't reach the person about to pick up a torque wrench. This page does: hold the calibration label to a camera, press one button, get one answer.

  1. Add a ui-button node named "Scan Label Button" in the Verdict group, order 1. Set the label to SCAN and the payload to the string capture, which tells the webcam to take a frame.

  2. Add a ui-webcam node in the Scanner group, with Pass through and QR detection both off. Leave image width, height and quality blank to use the camera's defaults.

  3. Add a change node that sets msg.payload to msg.payload.image. The webcam outputs an object; the OCR node just needs the image.

  4. Add a simple-ocr node, then a link out / link in pair to carry the result down to the next row of the canvas.

Tip: OCR keeps this tutorial hardware-free, but if your labels have a barcode or QR code, scanning one is faster and far more reliable than reading printed text. Swap the camera and OCR nodes for a barcode scanner and feed the decoded ID straight into "Set Tool ID," and everything downstream will keep working. You can also drop "Extract Tool ID" and "Scan Valid?", since a scanner either returns an ID or nothing.

  1. Add a function node named "Extract Tool ID":

Tip: The same goes for function node logic. Describe what you want the function to do in plain English, and FlowFuse Expert will generate the code for you.

// Stickers read TL followed by four digits. OCR loses the hyphen and swaps
// look-alike characters, so normalise before matching.
const text = String(msg.payload?.text || msg.payload || '');
const flat = text.toUpperCase().replace(/[^A-Z0-9]/g, '');

const FIXES = { O: '0', Q: '0', I: '1', L: '1', S: '5', B: '8', Z: '2', G: '6' };

const m = flat.match(/T[L1I]([A-Z0-9]{4})/);
const digits = m ? m[1].replace(/[A-Z]/g, c => FIXES[c] || c) : '';
const toolId = /^\d{4}$/.test(digits) ? 'TL-' + digits : null;

msg.payload = {
success: !!toolId,
toolId,
// Shows what OCR actually read when a scan fails.
rawText: text.replace(/\s+/g, ' ').trim()
};
return msg;

Wire a debug node to this as well and leave it enabled. When a scan fails, rawText shows what the camera actually read, which is the difference between fixing the label and guessing at it.

Note: The pattern above expects TL followed by four digits, because that's what the seeded data uses. Your labels almost certainly look different: a different prefix, more digits, a plant code, or no letters at all. Change the regex and the length check to match your own sticker format before you test this on real tools, or every scan will come back as Label not readable.

  1. Add a switch node named "Scan Valid?" on payload.success, with is true on the first output and otherwise on the second. A failed read skips the database entirely.

  2. Add a change node named "Set Tool ID" on the true branch, with two rules:

    • Set queryParameters to the JSONata { "toolId": $trim(payload.toolId) }
    • Set scannedId to the JSONata $trim(payload.toolId), so the ID survives to the verdict even when no row comes back
  3. Add a tables-query node named "Look Up Tool":

SELECT
tool_id,
equipment_name,

-- The safety answer comes from the date, not the stored status flag,
-- which can go stale if whatever recalculates it falls behind.
(next_due_ts >= NOW()) AS ok_to_use

FROM calibration_assets

-- Deliberately NOT filtered by department or time window: a scan is a
-- lookup of one tool, not a view. Case, padding and hyphens are all
-- normalised away, at the cost of the index, which is fine at register size.
WHERE replace(upper(btrim(tool_id)), '-', '')
= replace(upper(btrim($toolId)), '-', '');
  1. Add a function node named "Build Verdict". Wire both the query output and the switch's second output into it:
// Two paths feed this node: a completed lookup, and a failed scan routed
// straight past the query. Tell them apart by payload shape.
const p = msg.payload;

if (p?.success === false) {
msg.payload = { state: 'scan_failed' };
return msg;
}

const row = Array.isArray(p) ? p[0] : p;

if (!row?.tool_id) {
msg.payload = { state: 'not_found', scannedId: msg.scannedId };
return msg;
}

msg.payload = {
state: row.ok_to_use ? 'ok' : 'blocked',
tool: row
};
return msg;

Four states come out of it, and they're the only four an operator ever sees:

State When Verdict
ok Due date in the future OK to use, green
blocked Due date has passed Do not use, red
not_found ID read, no matching row Not in register, amber
scan_failed No TL-#### pattern found Label not readable, amber
  1. Add a function node named "Format Notification". It shows what was scanned, the verdict, and what to do about it:
const v = msg.payload || {};
const t = v.tool;

// timeout 0 on 'blocked' is deliberate: an expired-tool verdict must be
// dismissed by a person, not fade out while nobody is watching.
const VERDICTS = {
ok: { text: 'OK to use', do: 'Carry on.', color: '#15803d', timeout: 8 },
blocked: { text: 'Do not use', do: 'Tag it and tell your supervisor.', color: '#b3261e', timeout: 0 },
not_found: { text: 'Not in register', do: 'Tell your supervisor.', color: '#b45309', timeout: 12 },
scan_failed: { text: 'Label not readable', do: 'Wipe the label and scan again.', color: '#b45309', timeout: 8 }
};

const cfg = VERDICTS[v.state] || VERDICTS.scan_failed;

// ui-notification renders raw HTML, so escape what came from the register.
const esc = s => String(s ?? '').replace(/[<>&"]/g,
c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));

const scanned = t
? esc(t.tool_id) + ' - ' + esc(t.equipment_name)
: esc(v.scannedId || 'No ID read');

msg.payload = `<div style="font-size:1rem;opacity:.85">${scanned}</div>`
+ `<div style="font-size:2rem;font-weight:700;margin:4px 0">${cfg.text}</div>`
+ `<div style="font-size:1rem">${cfg.do}</div>`;

msg.color = cfg.color;
msg.timeout = cfg.timeout;
return msg;
  1. Add a ui-notification node named "Verdict" and select your ui-base. Set Position to center center, turn off Use default colour, turn on Show countdown, Allow dismiss and Allow raw HTML. The display time and colour on the node are only fallbacks; msg.timeout and msg.color override them for each verdict.

  2. Add a ui-markdown node named "Tool Check Instructions" in the Verdict group, order 2:

<div style="text-align: center">

### Before you use a tool

</div>

**1** Hold the calibration label flat in front of the camera.

**2** Press **SCAN**.

**3** Read the message in the middle of the screen.

---

**"OK to use"**: the tool is inside its calibration interval. Carry on.

**"Do not use"**: set the tool aside, tag it, and tell your supervisor. If you have already used it on parts today, say so; those measurements need checking.

**"Not in register"** or **"Label not readable"**: wipe the label and scan again. If it fails twice, tell your supervisor.

---

Anything other than **"OK to use"** means the tool stays out of production.
  1. Wire the nodes: Scan Label Button → ui-webcam → change → simple-ocr → Extract Tool ID → Scan Valid?, then Scan Valid? (true) → Set Tool ID → Look Up Tool → Build Verdict, and Scan Valid? (otherwise) → Build Verdict → Format Notification → Verdict.

Deploy and open /tool-check on a device with a camera. Hold a label reading TL-0007, or one in whatever format you configured above, in front of it and press SCAN. Whatever the seeded data says about that tool is what comes back. Because the verdict compares the date instead of reading the status column, it will always agree with the Home page.

the Tool Check page with the camera preview and SCAN button The scanner page: camera preview, one button, and the operator instructions below it.

What Next

You've built a calibration application that answers two different questions from the same data. The office screen tells a quality engineer how the plant stands and what needs collecting this week. The station screen tells an operator whether the tool in their hand is fit to use. Both read the due date, so neither can drift from the other.

Right now it runs on generated data. To go live, remove the simulator flow and point the queries at your own register. Just alias your column names in the SELECT lists, and everything downstream keeps working. If your records sit in a CMMS or ERP behind an API, swap the query nodes for http request nodes and nothing else changes. From there, the obvious next steps are ones the register already supports: email the overdue list to the calibration lab each Monday, or notify someone the moment a critical gauge goes overdue instead of waiting for a screen to be looked at. The same approach works for defect tracking and downtime too. See how manufacturers are using it on our automotive solutions page.

See What Your Team Can Build

See it live with our team, and the variety of applications you can build without coding expertise, then scale them across your plant in a single click.

Frequently Asked Questions

About the Author

Sumit Shinde

Technical Writer

Sumit Shinde is a Technical Writer at FlowFuse specializing in industrial automation and manufacturing. In the past three years, he has built industrial applications and authored more than 100 technical articles covering industrial connectivity, unified data architecture, production metrics, and quality management for modern manufacturing.

Related Articles:

Sign up for updates