Operator screen
Alarms
ISA-18.2 alarm management with an alarm summary, banner, shelving and alarm states.
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>Alarm Management - 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">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">ALARMS</span>
<span class="hmi-area">Area 2 · Utilities & Process</span>
</div>
<div class="hmi-header-actions">
<button class="hmi-button" id="ackSelected" type="button" disabled>Acknowledge selected</button>
<button class="hmi-button" id="ackAll" type="button">Acknowledge all</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<!-- The highest-priority unacknowledged alarm, always in view. -->
<smart-alarm-banner id="alarmBanner" empty-message="All monitored points are within limits"></smart-alarm-banner>
<section class="alarm-summary" aria-label="Alarm counts by priority">
<div class="summary-tile severity-critical" data-priority="1">
<span class="summary-count" id="countCritical">0</span>
<span class="summary-label">Priority 1 · Critical</span>
</div>
<div class="summary-tile severity-warning" data-priority="2">
<span class="summary-count" id="countWarning">0</span>
<span class="summary-label">Priority 2 · Warning</span>
</div>
<div class="summary-tile severity-advisory" data-priority="3">
<span class="summary-count" id="countAdvisory">0</span>
<span class="summary-label">Priority 3 · Advisory</span>
</div>
<div class="summary-tile summary-tile-muted">
<span class="summary-count" id="countUnack">0</span>
<span class="summary-label">Unacknowledged</span>
</div>
<div class="summary-tile summary-tile-muted">
<span class="summary-count" id="countRate">0</span>
<span class="summary-label">Alarms / 10 min</span>
</div>
</section>
<section class="hmi-panel hmi-panel-grid">
<div class="hmi-panel-header">
<span>Alarm list</span>
<span class="hmi-panel-note" id="gridNote">—</span>
</div>
<div class="hmi-panel-content">
<smart-grid id="alarmGrid"></smart-grid>
</div>
</section>
</div>
<smart-toast id="alarmToast" position="bottom-right" auto-close auto-close-delay="4000" show-close-button></smart-toast>
<!-- scripts -->
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.grid.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.toast.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.alarmbanner.js"></script>
<script type="text/javascript" src="data.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" />
/* Alarm management screen.
*
* The grid is the working surface: sortable, filterable, and colour-coded by
* ISA-18.2 priority. Everything above it - banner, counts - is derived from the
* same array, so there is one source of truth for what is active. */
let alarms = AlarmData.initial();
function severityName(priority) {
return priority === 1 ? 'critical' : priority === 2 ? 'warning' : 'advisory';
}
function timeText(date) {
return date.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
/* The grid takes flat, pre-formatted rows - keeping the display strings out of
the render path makes sorting and filtering behave predictably. */
function toRow(alarm) {
return {
id: alarm.id,
time: timeText(alarm.timestamp),
priority: alarm.priority,
severity: AlarmData.priorityLabel(alarm.priority),
tag: alarm.tag,
description: alarm.description,
value: alarm.value,
limit: alarm.limit,
state: alarm.state
};
}
function chip(text, className) {
return '<span class="grid-chip ' + className + '">' + text + '</span>';
}
window.Smart('#alarmGrid', class {
get properties() {
return {
appearance: { alternationCount: 0, showRowHeader: false },
behavior: { columnResizeMode: 'growAndShrink' },
selection: { enabled: true, allowRowSelection: true, mode: 'many' },
sorting: { enabled: true },
filtering: { enabled: true },
dataSource: new window.Smart.DataAdapter({
dataSource: alarms.map(toRow),
dataFields: [
'id: number',
'time: string',
'priority: number',
'severity: string',
'tag: string',
'description: string',
'value: string',
'limit: string',
'state: string'
]
}),
columns: [
{ label: 'Time', dataField: 'time', width: 100, cellsAlign: 'center', align: 'center' },
{
label: 'Priority', dataField: 'severity', width: 110, cellsAlign: 'center', align: 'center',
formatFunction(settings) {
const priority = settings.row.data.priority;
settings.template = chip(settings.value, 'chip-' + severityName(priority));
}
},
{ label: 'Tag', dataField: 'tag', width: 110 },
{ label: 'Description', dataField: 'description' },
{ label: 'Value', dataField: 'value', width: 120, cellsAlign: 'right', align: 'right' },
{ label: 'Limit', dataField: 'limit', width: 120, cellsAlign: 'right', align: 'right' },
{
label: 'State', dataField: 'state', width: 130, cellsAlign: 'center', align: 'center',
formatFunction(settings) {
const state = settings.value,
label = state === 'unack' ? 'UNACK' : state === 'ack' ? 'ACK' : 'RETURNED';
settings.template = chip(label, 'chip-state-' + state);
}
}
]
};
}
});
window.onload = function () {
const grid = document.getElementById('alarmGrid'),
toast = document.getElementById('alarmToast'),
banner = document.getElementById('alarmBanner'),
countCritical = document.getElementById('countCritical'),
countWarning = document.getElementById('countWarning'),
countAdvisory = document.getElementById('countAdvisory'),
countUnack = document.getElementById('countUnack'),
countRate = document.getElementById('countRate'),
gridNote = document.getElementById('gridNote'),
ackSelected = document.getElementById('ackSelected'),
ackAll = document.getElementById('ackAll'),
themeToggle = document.getElementById('themeToggle');
/* ── Theme ───────────────────────────────────────────────────────── */
function applyTheme(theme) {
document.body.setAttribute('theme', theme);
/* a toast already open was made in the theme of its moment, and kept it */
const elements = document.querySelectorAll('smart-grid, smart-toast, smart-toast-item, smart-alarm-banner');
for (let i = 0; i < elements.length; i++) {
elements[i].theme = theme;
}
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
}
themeToggle.addEventListener('click', function () {
applyTheme(document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark');
});
/* ── Derived views ───────────────────────────────────────────────── */
function refreshGrid() {
grid.dataSource = new window.Smart.DataAdapter({
dataSource: alarms.map(toRow),
dataFields: [
'id: number',
'time: string',
'priority: number',
'severity: string',
'tag: string',
'description: string',
'value: string',
'limit: string',
'state: string'
]
});
}
function refreshSummary() {
const unacknowledged = alarms.filter((alarm) => alarm.state === 'unack');
countCritical.textContent = unacknowledged.filter((a) => a.priority === 1).length;
countWarning.textContent = unacknowledged.filter((a) => a.priority === 2).length;
countAdvisory.textContent = unacknowledged.filter((a) => a.priority === 3).length;
countUnack.textContent = unacknowledged.length;
/* ISA-18.2 uses alarm rate as the headline health metric for an alarm
system: more than 10 per 10 minutes is "overloaded". Counted from the
list itself - the standing alarms already carry timestamps, and a
separate tally that started at zero on load reported "0 / 10 min"
beside two alarms stamped inside the last ten. */
const cutoff = Date.now() - 600000;
countRate.textContent = alarms.filter((alarm) => alarm.timestamp > cutoff).length;
gridNote.textContent = alarms.length + ' in list · ' + unacknowledged.length + ' unacknowledged';
/* The banner picks what to show itself: unacknowledged first, then
priority, then oldest. Always hand it a fresh array - assigning the
same one it already holds looks unchanged and skips the redraw. */
banner.alarms = alarms.map(function (alarm) {
return {
id: alarm.id,
tag: alarm.tag,
message: alarm.description + ' (' + alarm.value + ', limit ' + alarm.limit + ')',
priority: alarm.priority,
timestamp: alarm.timestamp,
acknowledged: alarm.state !== 'unack'
};
});
}
/* ── Operator actions ────────────────────────────────────────────── */
function acknowledgeIds(ids) {
if (!ids.length) {
return;
}
const wanted = {};
ids.forEach((id) => { wanted[Number(id)] = true; });
alarms.forEach((alarm) => {
if (wanted[alarm.id]) {
AlarmData.acknowledge(alarm);
}
});
refreshGrid();
refreshSummary();
}
/* getSelection() reports row *indexes*, which stop matching alarm ids as
soon as the list is sorted, filtered, or a new alarm arrives at the top.
getSelectedRowsData() hands back the bound row objects instead. */
function selectedIds() {
if (typeof grid.getSelectedRowsData === 'function') {
return grid.getSelectedRowsData()
.map((data) => data && data.id)
.filter((id) => id !== undefined && id !== null);
}
const selection = grid.getSelection ? grid.getSelection() : null,
rows = (selection && selection.rows) || [];
return rows
.map((entry) => (entry.row && entry.row.data ? entry.row.data.id : undefined))
.filter((id) => id !== undefined && id !== null);
}
grid.addEventListener('change', function () {
ackSelected.disabled = selectedIds().length === 0;
});
/* Acknowledging from the banner goes through the same path as the grid. */
banner.addEventListener('acknowledge', function (event) {
acknowledgeIds([event.detail.alarm.id]);
});
ackSelected.addEventListener('click', function () {
acknowledgeIds(selectedIds());
ackSelected.disabled = true;
});
ackAll.addEventListener('click', function () {
acknowledgeIds(alarms.map((alarm) => alarm.id));
});
/* ── Live feed ───────────────────────────────────────────────────── */
applyTheme('industrial-dark');
refreshSummary();
AlarmData.start(3000, function (alarm) {
alarms.unshift(alarm);
/* Keep the list at a size an operator screen would actually hold. */
if (alarms.length > 60) {
alarms.length = 60;
}
refreshGrid();
refreshSummary();
toast.type = alarm.priority === 1 ? 'error' : alarm.priority === 2 ? 'warning' : 'info';
toast.open('<strong>' + alarm.tag + '</strong> ' + alarm.description);
});
};
styles.css
/* Alarm management screen. Colours come from the industrial theme tokens - the
only saturated colour on the screen belongs to an actual alarm priority. */
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;
overflow: hidden;
}
.hmi-screen {
display: grid;
grid-template-rows: auto auto auto 1fr;
gap: 10px;
padding: 10px;
height: 100%;
box-sizing: border-box;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.hmi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.hmi-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.hmi-plant {
font-size: 16px;
font-weight: 600;
letter-spacing: .08em;
}
.hmi-area {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .04em;
}
.hmi-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.hmi-button,
.hmi-theme-toggle {
background: transparent;
border: 1px solid var(--smart-border);
border-radius: var(--smart-border-radius);
color: var(--smart-text-secondary-color);
cursor: pointer;
font-family: inherit;
font-size: 11px;
height: 30px;
letter-spacing: .06em;
padding: 0 14px;
text-transform: uppercase;
}
.hmi-button:hover:not(:disabled),
.hmi-theme-toggle:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.hmi-button:disabled {
color: var(--smart-text-disabled-color);
cursor: default;
opacity: .6;
}
/* ── Summary tiles ───────────────────────────────────────────────────── */
.alarm-summary {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
.summary-tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-left: 3px solid var(--smart-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.summary-tile.severity-critical {
border-left-color: var(--smart-industrial-alarm-critical);
}
.summary-tile.severity-warning {
border-left-color: var(--smart-industrial-alarm-warning);
}
.summary-tile.severity-advisory {
border-left-color: var(--smart-industrial-alarm-advisory);
}
.summary-count {
font-size: 28px;
font-weight: 300;
line-height: 1;
}
.summary-label {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .04em;
text-transform: uppercase;
}
/* ── Grid panel ──────────────────────────────────────────────────────── */
.hmi-panel {
display: flex;
flex-direction: column;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
overflow: hidden;
min-height: 0;
}
.hmi-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--smart-industrial-panel-header-background);
border-bottom: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-secondary-color);
font-size: 11.5px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
}
.hmi-panel-note {
color: var(--smart-text-tertiary-color);
letter-spacing: 0;
text-transform: none;
}
.hmi-panel-content {
flex: 1;
min-height: 0;
padding: 6px;
}
.hmi-panel-grid smart-grid {
width: 100%;
height: 100%;
}
/* Toasts sit bottom-right: the grid fills from the top, so this is the one
area that stays empty, and it keeps the alarm banner - the most important
thing on the screen - uncovered. */
.smart-toast-container.smart-toast-container-bottom-right {
bottom: 84px;
right: 14px;
}
/* ── Grid chips ──────────────────────────────────────────────────────── */
.grid-chip {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: .07em;
line-height: 1.6;
padding: 1px 9px;
border-radius: var(--smart-border-radius);
white-space: nowrap;
}
.chip-critical {
background: var(--smart-industrial-alarm-critical);
color: var(--smart-industrial-alarm-critical-color);
}
.chip-warning {
background: var(--smart-industrial-alarm-warning);
color: var(--smart-industrial-alarm-warning-color);
}
.chip-advisory {
background: var(--smart-industrial-alarm-advisory);
color: var(--smart-industrial-alarm-advisory-color);
}
.chip-state-unack {
border: 1px solid var(--smart-industrial-alarm-critical);
color: var(--smart-industrial-alarm-critical-text, var(--smart-industrial-alarm-critical));
}
.chip-state-ack {
border: 1px solid var(--smart-border);
color: var(--smart-text-tertiary-color);
}
.chip-state-returned {
border: 1px solid var(--smart-industrial-ok);
color: var(--smart-industrial-ok-text, var(--smart-industrial-ok));
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 1100px) {
body.viewport {
overflow: auto;
}
.alarm-summary {
grid-template-columns: repeat(2, 1fr);
}
.hmi-panel-grid smart-grid {
min-height: 420px;
}
}