Operator screen
Maintenance
Every asset as a wall of cards ordered by service due in run hours, the chosen one with its condition, work orders and trend.
index.htm as it is written with the smart-industrial package; industrial-demos.css is the screens' shared layout
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Maintenance - Smart.Industrial</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.default.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-elements.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-dark.css" />
<link rel="stylesheet" type="text/css" href="industrial-demos.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<div class="hmi-screen mt-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">MAINTENANCE</span>
<span class="hmi-area">Line 3 and utilities · 12 assets · <span id="clock"></span></span>
</div>
<div class="hmi-header-actions">
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body mt-body">
<div class="mt-tiles">
<smart-status-tile id="tileOverdue" label="Service overdue" precision-digits="0"></smart-status-tile>
<smart-status-tile id="tileSoon" label="Due within 100 h" precision-digits="0"></smart-status-tile>
<smart-status-tile id="tileOrders" label="Open work orders" precision-digits="0"></smart-status-tile>
<smart-status-tile id="tileCondition" label="Readings out of band" precision-digits="0"></smart-status-tile>
</div>
<div class="mt-row">
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Assets, next service first</span>
<span class="mt-hint">Hours to go come from each machine's run-hour counter</span>
</div>
<div class="hmi-panel-content">
<ul class="mt-wall" id="wall"></ul>
</div>
</div>
<div class="mt-detail">
<div class="hmi-panel">
<div class="hmi-panel-header">
<span id="detailName">Asset</span>
</div>
<div class="hmi-panel-content">
<smart-asset-card id="card"></smart-asset-card>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Condition, last 14 days</span>
</div>
<div class="hmi-panel-content">
<smart-trend id="trend" label="Condition"></smart-trend>
</div>
</div>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Recent requests</span>
</div>
<div class="hmi-panel-content">
<ol class="mt-log" id="log"></ol>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statustile.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.assetcard.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.trend.js"></script>
<script type="module" src="index.js"></script>
<script>
/* Shown once every element has rendered, so nothing moves on the way in (styles/demos.css). */
addEventListener('load', function () {
var all = Array.prototype.slice.call(document.querySelectorAll('*')),
ready = function () { document.body.classList.add('ready'); };
Promise.all(all.map(function (e) { return e.localName.indexOf('smart-') === 0 ? customElements.whenDefined(e.localName) : null; })).then(function () {
return Promise.all(all.filter(function (e) { return typeof e.whenRendered === 'function' && !e.isRendered; }).map(function (e) { return e.whenRendered(); }));
}).then(function () { requestAnimationFrame(function () { requestAnimationFrame(ready); }); }, ready);
});
</script>
<script>
/* The site's light/dark switch is the reader's choice: a screen opened from the light site
in a new tab opens light too. Framed, the site's page sets the theme itself. */
addEventListener('load', function () {
var light = false,
toggle = document.querySelector('.hmi-theme-toggle');
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its button starts listening */
if (light && toggle) { setTimeout(function () { if (document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); } }, 0); }
});
</script>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Maintenance.
*
* The screen a maintenance planner opens at the start of the day: every asset on the line, the
* one due next first, as a wall of compact cards; the chosen one in full, with its condition
* readings, its open work orders and its condition trend over the last two weeks; and what was
* asked of maintenance today. Run hours come from each machine's counter, so "due" is measured
* in the hours the machine has actually run rather than in calendar weeks. The plant here is a
* stand-in; the requests and services raised on it would go to the station's audit trail or to
* the plant's maintenance system. */
window.onload = function () {
const wall = document.getElementById('wall'),
card = document.getElementById('card'),
trend = document.getElementById('trend'),
detailName = document.getElementById('detailName'),
log = document.getElementById('log'),
clock = document.getElementById('clock'),
themeToggle = document.getElementById('themeToggle'),
tiles = {
overdue: document.getElementById('tileOverdue'),
soon: document.getElementById('tileSoon'),
orders: document.getElementById('tileOrders'),
condition: document.getElementById('tileCondition')
},
H = 3600000,
D = 24 * H,
day = (days) => new Date(Date.now() + days * D).toISOString();
const reading = (label, value, unit, status, limit) => ({ label: label, value: value, unit: unit, status: status || 'normal', limit: limit });
const ASSETS = [
{ assetId: 'P-101', name: 'Feed pump P-101', model: 'KSB Etanorm 65-200', location: 'Pump house', state: 'running', runHours: 12640, starts: 3214, serviceInterval: 2000, lastServiceHours: 10800, lastServiceDate: day(-190), lastServiceBy: 'J. Novak',
condition: [reading('Vibration, drive end', 4.6, 'mm/s', 'warning', 4.5), reading('Bearing temperature', 61, '°C'), reading('Motor current', 38.4, 'A')],
workOrders: [{ id: 'WO-1182', title: 'Drive-end bearing noise', status: 'open', priority: 'high', due: day(2) }], trend: { field: 'vib', label: 'Vibration, drive end', unit: 'mm/s', max: 8, start: 2.4, rise: 2.2 } },
{ assetId: 'FL-310', name: 'Filler', model: 'Krones Modulfill', location: 'Line 3', state: 'running', runHours: 8120, starts: 1420, serviceInterval: 1000, lastServiceHours: 7200, lastServiceDate: day(-44), lastServiceBy: 'J. Novak',
condition: [reading('Main drive current', 21.2, 'A'), reading('Valve block temperature', 48, '°C')], workOrders: [{ id: 'WO-1175', title: 'Replace filling valve seals, heads 7-12', status: 'in-progress', priority: 'normal', due: day(1) }], trend: { field: 'cur', label: 'Main drive current', unit: 'A', max: 40, start: 20, rise: 1.2 } },
{ assetId: 'CP-320', name: 'Capper', model: 'Arol Euro PK', location: 'Line 3', state: 'maintenance', runHours: 8120, starts: 1398, serviceInterval: 1500, lastServiceHours: 6500, lastServiceDate: day(-80), lastServiceBy: 'P. Weber',
condition: [reading('Torque head vibration', 7.9, 'mm/s', 'alarm', 7.1)], workOrders: [{ id: 'WO-1190', title: 'Torque head bearing replacement', status: 'in-progress', priority: 'high', due: day(0) }], trend: { field: 'vib', label: 'Torque head vibration', unit: 'mm/s', max: 10, start: 3.1, rise: 4.8 } },
{ assetId: 'LB-330', name: 'Labeller', model: 'Krones Contiroll', location: 'Line 3', state: 'running', runHours: 8120, starts: 1402, serviceInterval: 2500, lastServiceHours: 7000, lastServiceDate: day(-60), lastServiceBy: 'P. Weber',
condition: [reading('Glue unit temperature', 162, '°C')], workOrders: [], trend: { field: 'temp', label: 'Glue unit temperature', unit: '°C', max: 200, start: 160, rise: 1 } },
{ assetId: 'CPK-340', name: 'Case packer', model: 'KHS Innopack', location: 'Line 3', state: 'running', runHours: 7980, starts: 2210, serviceInterval: 2000, lastServiceHours: 6100, lastServiceDate: day(-75), lastServiceBy: 'J. Novak',
condition: [reading('Glue unit temperature', 151, '°C', 'warning', 155)], workOrders: [], trend: { field: 'temp', label: 'Glue unit temperature', unit: '°C', max: 200, start: 163, rise: -12 } },
{ assetId: 'PAL-350', name: 'Palletiser', model: 'Kuka KR 180', location: 'Line 3', state: 'running', runHours: 7400, starts: 980, serviceInterval: 3000, lastServiceHours: 5200, lastServiceDate: day(-120), lastServiceBy: 'P. Weber',
condition: [reading('Axis 2 current', 12.8, 'A')], workOrders: [{ id: 'WO-1178', title: 'Guard door switch - replaced, check again in a week', status: 'open', priority: 'low', due: day(5) }], trend: { field: 'cur', label: 'Axis 2 current', unit: 'A', max: 25, start: 12.5, rise: 0.3 } },
{ assetId: 'C-201', name: 'Air compressor C-201', model: 'Atlas Copco GA 37', location: 'Utilities', state: 'running', runHours: 31840, starts: 8820, serviceInterval: 4000, lastServiceHours: 28000, lastServiceDate: day(-160), lastServiceBy: 'Service partner',
condition: [reading('Discharge temperature', 92, '°C'), reading('Oil pressure', 2.9, 'bar')], workOrders: [], trend: { field: 'temp', label: 'Discharge temperature', unit: '°C', max: 120, start: 86, rise: 6 } },
{ assetId: 'D-202', name: 'Air dryer D-202', model: 'Atlas Copco FD 70', location: 'Utilities', state: 'running', runHours: 31500, starts: 610, serviceInterval: 8000, lastServiceHours: 26000, lastServiceDate: day(-230), lastServiceBy: 'Service partner',
condition: [reading('Dew point', 3.1, '°C')], workOrders: [], trend: { field: 'dew', label: 'Dew point', unit: '°C', max: 10, start: 3, rise: 0.1 } },
{ assetId: 'CH-203', name: 'Chiller CH-203', model: 'Carrier 30RB', location: 'Utilities', state: 'stopped', runHours: 14200, starts: 4420, serviceInterval: 3000, lastServiceHours: 12000, lastServiceDate: day(-140), lastServiceBy: 'Service partner',
condition: [reading('Supply water temperature', 7.2, '°C')], workOrders: [], trend: { field: 'temp', label: 'Supply water temperature', unit: '°C', max: 20, start: 7, rise: 0.2 } },
{ assetId: 'B-204', name: 'Boiler B-204', model: 'Viessmann Vitoplex', location: 'Utilities', state: 'running', runHours: 22300, starts: 12050, serviceInterval: 2000, lastServiceHours: 20250, lastServiceDate: day(-101), lastServiceBy: 'Service partner',
condition: [reading('Flue gas temperature', 188, '°C', 'warning', 180)], workOrders: [{ id: 'WO-1186', title: 'Annual inspection - book with the insurer', status: 'open', priority: 'normal', due: day(9) }], trend: { field: 'temp', label: 'Flue gas temperature', unit: '°C', max: 250, start: 168, rise: 20 } },
{ assetId: 'CV-301', name: 'Infeed conveyor', model: 'Flexlink X85', location: 'Line 3', state: 'running', runHours: 8120, starts: 1450, serviceInterval: 3000, lastServiceHours: 6000, lastServiceDate: day(-95), lastServiceBy: 'J. Novak',
condition: [reading('Motor current', 3.2, 'A')], workOrders: [], trend: { field: 'cur', label: 'Motor current', unit: 'A', max: 8, start: 3.1, rise: 0.1 } },
{ assetId: 'ETP-400', name: 'Effluent pump ETP-400', model: 'Flygt NP 3127', location: 'Effluent plant', state: 'offline', runHours: 9800, starts: 20100, serviceInterval: 2000, lastServiceHours: 8100, lastServiceDate: day(-150), lastServiceBy: 'J. Novak',
condition: [], workOrders: [{ id: 'WO-1191', title: 'Outstation lost - check the modem', status: 'open', priority: 'normal', due: day(1) }], trend: { field: 'cur', label: 'Motor current', unit: 'A', max: 20, start: 9, rise: 0 } }
];
let selected = ASSETS[0],
next = 1192;
const left = (a) => a.lastServiceHours + a.serviceInterval - a.runHours;
function renderWall() {
const sorted = ASSETS.slice().sort((a, b) => left(a) - left(b));
wall.innerHTML = '';
sorted.forEach(function (asset) {
const item = document.createElement('li'),
compact = document.createElement('smart-asset-card'),
open = document.createElement('button');
item.className = 'mt-item' + (asset === selected ? ' selected' : '');
Object.assign(compact, { compact: true, readOnly: true, name: asset.name, assetId: asset.assetId, state: asset.state, runHours: asset.runHours, serviceInterval: asset.serviceInterval, lastServiceHours: asset.lastServiceHours });
open.type = 'button';
open.className = 'mt-open';
open.textContent = asset === selected ? 'Shown' : 'Show';
open.setAttribute('aria-label', 'Show ' + asset.name);
open.setAttribute('aria-pressed', asset === selected ? 'true' : 'false');
open.addEventListener('click', function () {
select(asset);
});
item.appendChild(compact);
item.appendChild(open);
wall.appendChild(item);
});
}
function select(asset) {
selected = asset;
detailName.textContent = asset.name;
Object.assign(card, {
name: asset.name, assetId: asset.assetId, model: asset.model, location: asset.location, state: asset.state,
runHours: asset.runHours, starts: asset.starts, serviceInterval: asset.serviceInterval, lastServiceHours: asset.lastServiceHours,
lastServiceDate: asset.lastServiceDate, lastServiceBy: asset.lastServiceBy, condition: asset.condition.slice(), workOrders: asset.workOrders.slice()
});
const t = asset.trend,
data = [],
now = Date.now();
for (let i = 14 * 24; i >= 0; i--) {
const at = now - i * H,
progress = (14 * 24 - i) / (14 * 24);
data.push({ timestamp: at, [t.field]: asset.state === 'offline' && i < 30 ? null : t.start + t.rise * progress * progress + Math.sin(i / 3) * t.max * 0.01 });
}
trend.clear();
trend.live = false;
trend.scaleMode = 'value';
/* one pen: the footer names it, and the plot gets the width */
trend.showPens = false;
trend.timeSpan = 14 * D;
trend.pens = [{ field: t.field, label: t.label, unit: t.unit, min: 0, max: t.max, precision: 1 }];
trend.pushMany(data);
trend.setWindow(now - 14 * D, now);
renderWall();
}
function counts() {
const open = ASSETS.reduce((sum, a) => sum + a.workOrders.filter((w) => w.status !== 'done').length, 0),
out = ASSETS.reduce((sum, a) => sum + a.condition.filter((c) => c.status !== 'normal').length, 0),
overdue = ASSETS.filter((a) => left(a) < 0).length,
soon = ASSETS.filter((a) => left(a) >= 0 && left(a) <= 100).length;
tiles.overdue.value = overdue;
tiles.overdue.state = overdue ? 'warning' : 'ok';
tiles.soon.value = soon;
tiles.orders.value = open;
tiles.condition.value = out;
tiles.condition.state = out ? 'warning' : 'ok';
}
function note(text) {
const item = document.createElement('li'),
when = document.createElement('time');
when.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
item.appendChild(when);
item.appendChild(document.createTextNode(text));
log.insertBefore(item, log.firstChild);
}
card.addEventListener('workOrderRequest', function (event) {
const detail = event.detail;
card.busy = true;
setTimeout(function () {
card.busy = false;
selected.workOrders.unshift({ id: 'WO-' + next++, title: detail.text, status: 'open', priority: detail.priority });
card.workOrders = selected.workOrders.slice();
counts();
note('WO-' + (next - 1) + ' opened on ' + detail.assetId + ' (' + detail.priority + '): ' + detail.text);
}, 400);
});
card.addEventListener('serviceRecord', function (event) {
selected.lastServiceHours = event.detail.runHours;
selected.lastServiceDate = new Date().toISOString();
selected.lastServiceBy = 'J. Novak';
select(selected);
counts();
note('Service of ' + event.detail.assetId + ' recorded at ' + event.detail.runHours.toLocaleString('en') + ' h');
});
card.addEventListener('workOrderOpen', function (event) {
note('Opened ' + event.detail.id + ' in the maintenance system');
});
themeToggle.addEventListener('click', function () {
const theme = document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark';
document.body.setAttribute('theme', theme);
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
});
const time = () => { clock.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); };
time();
setInterval(time, 15000);
note('WO-1191 raised by the station: outstation ETP-400 lost');
select(ASSETS.slice().sort((a, b) => left(a) - left(b))[0]);
counts();
};
styles.css
/* Maintenance. A wall of the line's assets, the one due next first; the chosen one in full. */
html,
body {
height: 100%;
margin: 0;
}
body.viewport {
background: var(--smart-industrial-canvas, var(--smart-body-background-color));
color: var(--smart-text-color);
font-family: var(--smart-font-family);
font-size: 13px;
}
.mt-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.mt-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.mt-row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(380px, 1fr);
gap: 12px;
align-items: start;
}
@media (max-width: 1100px) {
.mt-row {
grid-template-columns: 1fr;
}
}
.mt-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--smart-text-secondary-color);
}
.mt-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.mt-item {
position: relative;
display: flex;
flex-direction: column;
}
.mt-item smart-asset-card {
width: auto;
}
/* room under the service bar for the Show button */
.mt-item smart-asset-card .smart-asset-card-container {
padding-bottom: 44px;
}
/* the chosen asset carries the accent edge; the button says so in words too */
.mt-item.selected smart-asset-card .smart-asset-card-container {
box-shadow: inset 3px 0 0 var(--smart-primary);
}
.mt-open {
position: absolute;
right: 8px;
bottom: 8px;
min-height: 26px;
padding: 0 10px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mt-open[aria-pressed="true"] {
border-color: var(--smart-primary);
color: var(--smart-primary);
font-weight: 600;
}
.mt-open:focus-visible {
outline: 2px solid var(--smart-primary);
outline-offset: 1px;
}
.mt-detail {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
#card {
width: 100%;
}
#trend {
width: 100%;
height: 200px;
}
.mt-log {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
font-size: 12px;
}
.mt-log time {
display: inline-block;
min-width: 48px;
color: var(--smart-text-secondary-color);
font-variant-numeric: tabular-nums;
}