[Perl] DAQ system for the FAPG.
Differentiate DAQ node reading and DAQ node health.
Changed files
- roles/daq-node/bin/fapg-daq-node
- roles/daq-node/lib/perl5/FAPG/DAQ/EZO/USB.pm
- roles/daq-node/lib/perl5/FAPG/DAQ/MQTT.pm
- roles/daq-node/t/03-mqtt.t
- roles/daq-recorder/bin/fapg-daq-mqtt-sqlite
- roles/dashboard/lib/dashboard.pm
- roles/dashboard/lib/dashboard/Controller/Reading.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/t/basic.t
- roles/dashboard/templates/dashboard/index.html.ep
roles/daq-node/bin/fapg-daq-node
@@ -11,12 +11,14 @@
11
11
12
12
use FAPG::DAQ::EZO::USB qw(
13
13
discover_ezo_usb_device
14
Added:
normalize_probe_type
14
15
open_ezo_usb
15
16
read_probe_info
16
17
read_once
17
18
);
18
19
19
Removed:
use FAPG::DAQ::MQTT qw(publish_mqtt);
20
Added:
use FAPG::DAQ::MQTT qw(publish_mqtt publish_status_mqtt);
21
Added:
use Sys::Hostname qw(hostname);
20
22
21
23
=head1 NAME
22
24
@@ -24,10 +26,11 @@
24
26
25
27
=cut
26
28
27
Removed:
my $read_interval = positive_number_from_env( READ_INTERVAL => 5 );
28
Removed:
my $retry_delay = positive_number_from_env( EZO_RECONNECT_INTERVAL => 5 );
29
Added:
my $read_interval = positive_number_from_env( READ_INTERVAL => 5 );
30
Added:
my $retry_delay = positive_number_from_env( EZO_RECONNECT_INTERVAL => 5 );
31
Added:
my $expected_probe = expected_probe();
29
32
30
Removed:
my ( $serial, $info, $device );
33
Added:
my ( $serial, $info, $device, $probe );
31
34
32
35
while (1) {
33
36
if ( !defined $serial || !defined $info ) {
@@ -37,20 +40,38 @@
37
40
};
38
41
39
42
if ($@) {
40
Removed:
warn "DAQ probe connection error: $@";
43
Added:
my $error = $@;
44
Added:
warn "DAQ probe connection error: $error";
45
Added:
publish_probe_status(
46
Added:
probe => $probe // $expected_probe,
47
Added:
status => 'probe_error',
48
Added:
error => $error,
49
Added:
device => $device,
50
Added:
message => 'Unable to connect to or interrogate EZO probe',
51
Added:
);
41
52
close_serial($serial);
42
Removed:
( $serial, $info, $device ) = ();
53
Added:
( $serial, $info, $device, $probe ) = ();
43
54
sleep $retry_delay;
44
55
next;
45
56
}
57
Added:
58
Added:
$probe = $info->{probe};
46
59
}
47
60
48
61
my $reading = eval { read_once( $serial, probe => $info->{probe} ) };
49
62
50
63
if ($@) {
51
Removed:
warn "DAQ probe read error, reconnecting to $device: $@";
64
Added:
my $error = $@;
65
Added:
warn "DAQ probe read error, reconnecting to $device: $error";
66
Added:
publish_probe_status(
67
Added:
probe => $probe // $expected_probe,
68
Added:
status => 'probe_error',
69
Added:
error => $error,
70
Added:
device => $device,
71
Added:
message => 'EZO probe did not return a valid reading',
72
Added:
);
52
73
close_serial($serial);
53
Removed:
( $serial, $info, $device ) = ();
74
Added:
( $serial, $info, $device, $probe ) = ();
54
75
sleep $retry_delay;
55
76
next;
56
77
}
@@ -58,6 +79,12 @@
58
79
say "$reading->{probe}: $reading->{value} $reading->{unit}";
59
80
60
81
eval { publish_mqtt($reading) } or warn "MQTT publish error: $@";
82
Added:
publish_probe_status(
83
Added:
probe => $reading->{probe},
84
Added:
status => 'ok',
85
Added:
device => $device,
86
Added:
message => 'Probe reading valid',
87
Added:
);
61
88
62
89
sleep $read_interval;
63
90
}
@@ -85,14 +112,41 @@
85
112
}
86
113
87
114
sub serial_device {
88
Removed:
return $ENV{EZO_SERIAL_DEVICE} if defined $ENV{EZO_SERIAL_DEVICE} && $ENV{EZO_SERIAL_DEVICE} ne q{};
115
Added:
return $ENV{EZO_SERIAL_DEVICE}
116
Added:
if defined $ENV{EZO_SERIAL_DEVICE} && $ENV{EZO_SERIAL_DEVICE} ne q{};
89
117
90
118
if ( defined $ENV{SERIAL_DEVICE} && $ENV{SERIAL_DEVICE} ne q{} ) {
91
119
return $ENV{SERIAL_DEVICE} if -e $ENV{SERIAL_DEVICE};
92
Removed:
warn "Configured SERIAL_DEVICE=$ENV{SERIAL_DEVICE} does not exist; attempting USB serial discovery\n";
120
Added:
warn
121
Added:
"Configured SERIAL_DEVICE=$ENV{SERIAL_DEVICE} does not exist; attempting USB serial discovery\n";
93
122
}
94
123
95
124
return discover_ezo_usb_device();
125
Added:
}
126
Added:
127
Added:
sub expected_probe {
128
Added:
for my $name (qw(FAPG_DAQ_PROBE EZO_PROBE PROBE_TYPE)) {
129
Added:
next if !defined $ENV{$name} || $ENV{$name} eq q{};
130
Added:
return normalize_probe_type( $ENV{$name} );
131
Added:
}
132
Added:
133
Added:
my $hostname = hostname();
134
Added:
return $1 if $hostname =~ /(?:\A|-)(ph|do|ec)(?:-|\z)/;
135
Added:
return 'redox' if $hostname =~ /(?:\A|-)(?:orp|redox)(?:-|\z)/;
136
Added:
137
Added:
return undef;
138
Added:
}
139
Added:
140
Added:
sub publish_probe_status {
141
Added:
my (%status) = @_;
142
Added:
143
Added:
if ( !defined $status{probe} || $status{probe} eq q{} ) {
144
Added:
warn "DAQ status not published because probe type is unknown\n";
145
Added:
return;
146
Added:
}
147
Added:
148
Added:
eval { publish_status_mqtt( \%status ); 1 }
149
Added:
or warn "MQTT status publish error: $@";
96
150
}
97
151
98
152
sub close_serial {
roles/daq-node/lib/perl5/FAPG/DAQ/EZO/USB.pm
@@ -30,9 +30,10 @@
30
30
sub discover_ezo_usb_device {
31
31
my %opt = @_;
32
32
33
Removed:
my $by_id_dir = $opt{by_id_dir} // '/dev/serial/by-id';
33
Added:
my $by_id_dir = $opt{by_id_dir} // '/dev/serial/by-id';
34
34
my $preferred_pattern = $opt{preferred_pattern} // qr/UART/i;
35
Removed:
my @fallback_patterns = exists $opt{fallback_patterns}
35
Added:
my @fallback_patterns =
36
Added:
exists $opt{fallback_patterns}
36
37
? $opt{fallback_patterns}->@*
37
38
: qw(/dev/ttyUSB* /dev/ttyACM*);
38
39
roles/daq-node/lib/perl5/FAPG/DAQ/MQTT.pm
@@ -13,15 +13,19 @@
13
13
14
14
our @EXPORT_OK = qw(
15
15
publish_mqtt
16
Added:
publish_status_mqtt
16
17
mqtt_payload_for_reading
18
Added:
mqtt_payload_for_status
17
19
mqtt_topic_for_reading
20
Added:
mqtt_topic_for_status
18
21
mqtt_broker_endpoint
19
22
);
20
23
21
Removed:
my $DEFAULT_HOST = 'fapg-daq-five-01';
22
Removed:
my $DEFAULT_PORT = 1883;
23
Removed:
my $DEFAULT_SCHEMA = 'fapg.daq.reading.v1';
24
Removed:
my $TOPIC_PREFIX = 'fapg/daq';
24
Added:
my $DEFAULT_HOST = 'fapg-daq-five-01';
25
Added:
my $DEFAULT_PORT = 1883;
26
Added:
my $DEFAULT_SCHEMA = 'fapg.daq.reading.v1';
27
Added:
my $DEFAULT_STATUS_SCHEMA = 'fapg.daq.status.v1';
28
Added:
my $TOPIC_PREFIX = 'fapg/daq';
25
29
26
30
sub publish_mqtt {
27
31
my ( $reading, %opt ) = @_;
@@ -37,6 +41,20 @@
37
41
};
38
42
}
39
43
44
Added:
sub publish_status_mqtt {
45
Added:
my ( $status, %opt ) = @_;
46
Added:
my $client = $opt{client} // _mqtt_client(%opt);
47
Added:
my $topic = mqtt_topic_for_status( $status, %opt );
48
Added:
my $payload = mqtt_payload_for_status( $status, %opt );
49
Added:
50
Added:
$client->publish( $topic => $payload );
51
Added:
52
Added:
return {
53
Added:
topic => $topic,
54
Added:
payload => $payload,
55
Added:
};
56
Added:
}
57
Added:
40
58
sub mqtt_payload_for_reading {
41
59
my ( $reading, %opt ) = @_;
42
60
_assert_reading($reading);
@@ -63,6 +81,30 @@
63
81
return JSON::PP->new->canonical(1)->encode( \%payload );
64
82
}
65
83
84
Added:
sub mqtt_payload_for_status {
85
Added:
my ( $status, %opt ) = @_;
86
Added:
_assert_status($status);
87
Added:
88
Added:
my $probe = lc $status->{probe};
89
Added:
my $node = _node_name( $status, %opt );
90
Added:
91
Added:
my %payload = (
92
Added:
schema => $opt{schema} // $DEFAULT_STATUS_SCHEMA,
93
Added:
timestamp => $status->{timestamp} // _utc_timestamp(),
94
Added:
node => $node,
95
Added:
probe => $probe,
96
Added:
status => lc $status->{status},
97
Added:
source => $opt{source} // 'fapg-daq-node',
98
Added:
);
99
Added:
100
Added:
for my $field (qw(message error device)) {
101
Added:
$payload{$field} = $status->{$field}
102
Added:
if exists $status->{$field} && defined $status->{$field};
103
Added:
}
104
Added:
105
Added:
return JSON::PP->new->canonical(1)->encode( \%payload );
106
Added:
}
107
Added:
66
108
sub mqtt_topic_for_reading {
67
109
my ( $reading, %opt ) = @_;
68
110
_assert_reading($reading);
@@ -76,6 +118,19 @@
76
118
return join '/', $prefix, $probe, $node, 'reading';
77
119
}
78
120
121
Added:
sub mqtt_topic_for_status {
122
Added:
my ( $status, %opt ) = @_;
123
Added:
_assert_status($status);
124
Added:
125
Added:
my $prefix = $opt{topic_prefix} // $TOPIC_PREFIX;
126
Added:
my $probe = _topic_level( 'probe', lc $status->{probe} );
127
Added:
my $node = _topic_level( 'node', _node_name( $status, %opt ) );
128
Added:
129
Added:
$prefix =~ s{/+\z}{};
130
Added:
131
Added:
return join '/', $prefix, $probe, $node, 'status';
132
Added:
}
133
Added:
79
134
sub mqtt_broker_endpoint {
80
135
my (%opt) = @_;
81
136
my $host = $opt{host} // $ENV{FAPG_MQTT_HOST} // $ENV{MQTT_HOST} // $DEFAULT_HOST;
@@ -127,6 +182,23 @@
127
182
128
183
croak "reading value is not numeric: $reading->{value}"
129
184
if $reading->{value} !~ /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)\z/;
185
Added:
186
Added:
return;
187
Added:
}
188
Added:
189
Added:
sub _assert_status {
190
Added:
my ($status) = @_;
191
Added:
croak 'status must be a HASH reference'
192
Added:
if ref($status) ne 'HASH';
193
Added:
194
Added:
croak 'status requires a probe field'
195
Added:
if !defined $status->{probe} || $status->{probe} eq q{};
196
Added:
197
Added:
croak 'status requires a status field'
198
Added:
if !defined $status->{status} || $status->{status} eq q{};
199
Added:
200
Added:
croak "unsupported node status '$status->{status}'"
201
Added:
if lc( $status->{status} ) !~ /\A(?:ok|probe_error)\z/;
130
202
131
203
return;
132
204
}
roles/daq-node/t/03-mqtt.t
@@ -12,8 +12,11 @@
12
12
13
13
use FAPG::DAQ::MQTT qw(
14
14
publish_mqtt
15
Added:
publish_status_mqtt
15
16
mqtt_payload_for_reading
17
Added:
mqtt_payload_for_status
16
18
mqtt_topic_for_reading
19
Added:
mqtt_topic_for_status
17
20
mqtt_broker_endpoint
18
21
);
19
22
@@ -46,6 +49,11 @@
46
49
node => 'fapg-daq-zero-01', );
47
50
48
51
is $topic, 'fapg/daq/ph/fapg-daq-zero-01/reading';
52
Added:
53
Added:
my $status_topic =
54
Added:
mqtt_topic_for_status( { probe => 'pH', status => 'ok' }, node => 'fapg-daq-zero-01', );
55
Added:
56
Added:
is $status_topic, 'fapg/daq/ph/fapg-daq-zero-01/status';
49
57
};
50
58
51
59
subtest 'payload shape' => sub {
@@ -72,6 +80,30 @@
72
80
is $decoded->{source}, 'fapg-daq-node';
73
81
};
74
82
83
Added:
subtest 'status payload shape' => sub {
84
Added:
my $payload = mqtt_payload_for_status(
85
Added:
{
86
Added:
probe => 'pH',
87
Added:
status => 'probe_error',
88
Added:
error => 'timeout waiting for EZO response',
89
Added:
device => '/dev/serial/by-id/usb-FTDI_USB_UART',
90
Added:
timestamp => '2026-07-06T10:00:05Z',
91
Added:
},
92
Added:
node => 'fapg-daq-zero-01',
93
Added:
);
94
Added:
95
Added:
my $decoded = decode_json($payload);
96
Added:
97
Added:
is $decoded->{schema}, 'fapg.daq.status.v1';
98
Added:
is $decoded->{timestamp}, '2026-07-06T10:00:05Z';
99
Added:
is $decoded->{node}, 'fapg-daq-zero-01';
100
Added:
is $decoded->{probe}, 'ph';
101
Added:
is $decoded->{status}, 'probe_error';
102
Added:
is $decoded->{error}, 'timeout waiting for EZO response';
103
Added:
is $decoded->{device}, '/dev/serial/by-id/usb-FTDI_USB_UART';
104
Added:
is $decoded->{source}, 'fapg-daq-node';
105
Added:
};
106
Added:
75
107
subtest 'publish uses injected client, so unit tests need no broker' => sub {
76
108
my $fake = Local::FakeMQTT->new;
77
109
@@ -93,6 +125,19 @@
93
125
my $decoded = decode_json( $fake->published->[0][1] );
94
126
is $decoded->{probe}, 'do';
95
127
is $decoded->{value}, 8.34;
128
Added:
129
Added:
my $status = publish_status_mqtt(
130
Added:
{
131
Added:
probe => 'DO',
132
Added:
status => 'ok',
133
Added:
},
134
Added:
node => 'fapg-daq-zero-02',
135
Added:
client => $fake,
136
Added:
);
137
Added:
138
Added:
is $status->{topic}, 'fapg/daq/do/fapg-daq-zero-02/status';
139
Added:
is scalar $fake->published->@*, 2;
140
Added:
is $fake->published->[1][0], 'fapg/daq/do/fapg-daq-zero-02/status';
96
141
};
97
142
98
143
subtest 'broker endpoint defaults to the Pi 5 broker' => sub {
roles/daq-recorder/bin/fapg-daq-mqtt-sqlite
@@ -134,12 +134,49 @@
134
134
ON readings(topic)
135
135
}
136
136
);
137
Added:
138
Added:
$dbh->do(
139
Added:
q{
140
Added:
CREATE TABLE IF NOT EXISTS node_status (
141
Added:
id INTEGER PRIMARY KEY AUTOINCREMENT,
142
Added:
received_at TEXT NOT NULL,
143
Added:
topic TEXT NOT NULL,
144
Added:
schema TEXT,
145
Added:
timestamp TEXT,
146
Added:
probe TEXT,
147
Added:
node TEXT,
148
Added:
status TEXT,
149
Added:
message TEXT,
150
Added:
error TEXT,
151
Added:
device TEXT,
152
Added:
source TEXT,
153
Added:
payload TEXT NOT NULL,
154
Added:
valid INTEGER NOT NULL DEFAULT 1
155
Added:
)
156
Added:
}
157
Added:
);
158
Added:
159
Added:
$dbh->do(
160
Added:
q{
161
Added:
CREATE INDEX IF NOT EXISTS node_status_probe_node_timestamp_idx
162
Added:
ON node_status(probe, node, timestamp)
163
Added:
}
164
Added:
);
165
Added:
166
Added:
$dbh->do(
167
Added:
q{
168
Added:
CREATE INDEX IF NOT EXISTS node_status_topic_idx
169
Added:
ON node_status(topic)
170
Added:
}
171
Added:
);
137
172
}
138
173
139
174
sub store_message {
140
175
my ( $dbh, $topic, $message ) = @_;
141
176
142
177
my ( $topic_probe, $topic_node, $topic_kind ) = $topic =~ m{\Afapg/daq/([^/]+)/([^/]+)/([^/]+)\z};
178
Added:
die "invalid MQTT topic: $topic\n"
179
Added:
if !defined $topic_kind || $topic_kind !~ /\A(?:reading|status)\z/;
143
180
144
181
my $valid = 1;
145
182
my $error;
@@ -156,6 +193,12 @@
156
193
$data = {};
157
194
}
158
195
196
Added:
if ( $topic_kind eq 'status' ) {
197
Added:
store_status_message( $dbh, $topic, $message, $topic_probe, $topic_node, $data, $valid,
198
Added:
$error );
199
Added:
return;
200
Added:
}
201
Added:
159
202
my $value = $data->{value};
160
203
$value = undef if defined $value && !looks_like_number($value);
161
204
@@ -185,6 +228,51 @@
185
228
$data->{node} // $topic_node, $value, $data->{unit},
186
229
$data->{source}, $message, $valid,
187
230
$error,
231
Added:
);
232
Added:
}
233
Added:
234
Added:
sub store_status_message {
235
Added:
my ( $dbh, $topic, $message, $topic_probe, $topic_node, $data, $valid, $error ) = @_;
236
Added:
237
Added:
my $status = $data->{status};
238
Added:
if ( defined $status ) {
239
Added:
$status = lc $status;
240
Added:
if ( $status !~ /\A(?:ok|probe_error)\z/ ) {
241
Added:
$valid = 0;
242
Added:
$error =
243
Added:
defined $error ? "$error; unsupported status: $status" : "unsupported status: $status";
244
Added:
}
245
Added:
} else {
246
Added:
$valid = 0;
247
Added:
$error = defined $error ? "$error; missing status" : 'missing status';
248
Added:
}
249
Added:
250
Added:
my $sth = $dbh->prepare_cached(
251
Added:
q{
252
Added:
INSERT INTO node_status (
253
Added:
received_at,
254
Added:
topic,
255
Added:
schema,
256
Added:
timestamp,
257
Added:
probe,
258
Added:
node,
259
Added:
status,
260
Added:
message,
261
Added:
error,
262
Added:
device,
263
Added:
source,
264
Added:
payload,
265
Added:
valid
266
Added:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
267
Added:
}
268
Added:
);
269
Added:
270
Added:
$sth->execute(
271
Added:
utc_now(), $topic, $data->{schema},
272
Added:
$data->{timestamp}, $data->{probe} // $topic_probe, $data->{node} // $topic_node,
273
Added:
$status, $data->{message}, $data->{error} // $error,
274
Added:
$data->{device}, $data->{source}, $message,
275
Added:
$valid,
188
276
);
189
277
}
190
278
roles/dashboard/lib/dashboard.pm
@@ -65,6 +65,7 @@
65
65
$r->get('/')->to('dashboard#index');
66
66
67
67
$r->get('/api/readings/:probe')->to('Reading#list');
68
Added:
$r->get('/api/status/:probe')->to('Reading#status');
68
69
}
69
70
70
71
1;
roles/dashboard/lib/dashboard/Controller/Reading.pm
@@ -42,4 +42,40 @@
42
42
);
43
43
}
44
44
45
Added:
sub status ($self) {
46
Added:
my $probe = $self->param('probe') // '';
47
Added:
48
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
49
Added:
50
Added:
return $self->render(
51
Added:
status => 404,
52
Added:
json => {
53
Added:
error => "Unknown probe type: $probe",
54
Added:
},
55
Added:
) unless $known{$probe};
56
Added:
57
Added:
my $row = eval {
58
Added:
$self->sqlite->db->query(
59
Added:
q{
60
Added:
SELECT timestamp, received_at, node, probe, status, message, error, device
61
Added:
FROM node_status
62
Added:
WHERE probe = ?
63
Added:
AND valid = 1
64
Added:
ORDER BY COALESCE(timestamp, received_at) DESC, id DESC
65
Added:
LIMIT 1
66
Added:
},
67
Added:
$probe,
68
Added:
)->hash;
69
Added:
};
70
Added:
71
Added:
$row = undef if $@;
72
Added:
73
Added:
$self->render(
74
Added:
json => {
75
Added:
probe => $probe,
76
Added:
status => $row,
77
Added:
},
78
Added:
);
79
Added:
}
80
Added:
45
81
1;
roles/dashboard/public/css/app.css
@@ -125,12 +125,27 @@
125
125
color: var(--accent);
126
126
}
127
127
128
Added:
.status-pill.is-healthy {
129
Added:
background: var(--accent-soft);
130
Added:
color: var(--accent);
131
Added:
}
132
Added:
128
133
.status-pill.is-stale {
129
134
background: var(--warn-soft);
130
135
color: var(--warn);
131
136
}
132
137
138
Added:
.status-pill.is-probe-error {
139
Added:
background: var(--warn-soft);
140
Added:
color: var(--warn);
141
Added:
}
142
Added:
133
143
.status-pill.is-offline {
144
Added:
background: var(--danger-soft);
145
Added:
color: var(--danger);
146
Added:
}
147
Added:
148
Added:
.status-pill.is-unreachable {
134
149
background: var(--danger-soft);
135
150
color: var(--danger);
136
151
}
roles/dashboard/public/js/dashboard.js
@@ -1,5 +1,4 @@
1
1
const charts = new Map();
2
Removed:
const ONLINE_AFTER_MS = 2 * 60 * 1000;
3
2
const OFFLINE_AFTER_MS = 15 * 60 * 1000;
4
3
5
4
function probeRows() {
@@ -66,33 +65,28 @@
66
65
return `${days}d ago`;
67
66
}
68
67
69
Removed:
function stateForAge(ms) {
70
Removed:
if (ms === null) {
71
Removed:
return "unknown";
72
Removed:
}
73
Removed:
74
Removed:
if (ms <= ONLINE_AFTER_MS) {
75
Removed:
return "online";
76
Removed:
}
77
Removed:
78
Removed:
if (ms <= OFFLINE_AFTER_MS) {
79
Removed:
return "stale";
80
Removed:
}
81
Removed:
82
Removed:
return "offline";
83
Removed:
}
84
Removed:
85
68
function statusLabel(state) {
86
69
return {
87
70
online: "Online",
71
Added:
healthy: "Healthy",
88
72
stale: "Stale",
73
Added:
"probe-error": "Probe unavailable",
89
74
offline: "Offline",
75
Added:
unreachable: "Unreachable",
90
76
unknown: "Unknown"
91
77
}[state] || "Unknown";
92
78
}
93
79
94
80
function setStatusPill(element, state) {
95
Removed:
element.classList.remove("is-online", "is-stale", "is-offline", "is-unknown");
81
Added:
element.classList.remove(
82
Added:
"is-online",
83
Added:
"is-healthy",
84
Added:
"is-stale",
85
Added:
"is-probe-error",
86
Added:
"is-offline",
87
Added:
"is-unreachable",
88
Added:
"is-unknown"
89
Added:
);
96
90
element.classList.add(`is-${state}`);
97
91
element.textContent = statusLabel(state);
98
92
}
@@ -108,19 +102,65 @@
108
102
return (payload.readings || [])[0] || null;
109
103
}
110
104
111
Removed:
function updateLatestRow(row, reading) {
105
Added:
async function fetchLatestStatus(probe) {
106
Added:
const response = await fetch(`/api/status/${probe}`);
107
Added:
108
Added:
if (!response.ok) {
109
Added:
throw new Error(`HTTP ${response.status}`);
110
Added:
}
111
Added:
112
Added:
const payload = await response.json();
113
Added:
return payload.status || null;
114
Added:
}
115
Added:
116
Added:
function stateForNodeStatus(nodeStatus) {
117
Added:
if (!nodeStatus) {
118
Added:
return "unreachable";
119
Added:
}
120
Added:
121
Added:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
122
Added:
const age = ageMs(timestamp);
123
Added:
124
Added:
if (age === null || age > OFFLINE_AFTER_MS) {
125
Added:
return "unreachable";
126
Added:
}
127
Added:
128
Added:
if (nodeStatus.status === "ok") {
129
Added:
return "healthy";
130
Added:
}
131
Added:
132
Added:
if (nodeStatus.status === "probe_error") {
133
Added:
return "probe-error";
134
Added:
}
135
Added:
136
Added:
return "unknown";
137
Added:
}
138
Added:
139
Added:
function statusTitle(nodeStatus) {
140
Added:
if (!nodeStatus) {
141
Added:
return "No recent node status message has been stored.";
142
Added:
}
143
Added:
144
Added:
const detail = nodeStatus.error || nodeStatus.message || "";
145
Added:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
146
Added:
const when = timestamp ? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})` : "unknown time";
147
Added:
148
Added:
return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
149
Added:
}
150
Added:
151
Added:
function updateLatestRow(row, reading, nodeStatus) {
112
152
const stateElement = row.querySelector("[data-latest-state]");
113
153
114
154
if (!reading) {
115
155
row.querySelector("[data-latest-value]").textContent = "No reading";
116
156
row.querySelector("[data-latest-time]").textContent = "Never";
117
157
row.querySelector("[data-latest-age]").textContent = "Unknown";
118
Removed:
setStatusPill(stateElement, "unknown");
158
Added:
setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
159
Added:
stateElement.title = statusTitle(nodeStatus);
119
160
return;
120
161
}
121
162
122
163
const age = ageMs(reading.timestamp);
123
Removed:
const state = stateForAge(age);
124
164
const unit = reading.unit || "";
125
165
126
166
row.querySelector("[data-latest-value]").textContent =
@@ -128,25 +168,29 @@
128
168
row.querySelector("[data-latest-time]").textContent =
129
169
formatDateTime(reading.timestamp);
130
170
row.querySelector("[data-latest-age]").textContent = formatAge(age);
131
Removed:
setStatusPill(stateElement, state);
171
Added:
setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
172
Added:
stateElement.title = statusTitle(nodeStatus);
132
173
}
133
174
134
175
async function loadOverview() {
135
176
const rows = probeRows();
136
177
const results = await Promise.allSettled(
137
Removed:
rows.map(row => fetchLatestReading(row.dataset.latestProbe))
178
Added:
rows.map(row => Promise.all([
179
Added:
fetchLatestReading(row.dataset.latestProbe),
180
Added:
fetchLatestStatus(row.dataset.latestProbe)
181
Added:
]))
138
182
);
139
183
140
184
rows.forEach((row, index) => {
141
185
const result = results[index];
142
186
143
187
if (result.status !== "fulfilled") {
144
Removed:
updateLatestRow(row, null);
188
Added:
updateLatestRow(row, null, null);
145
189
return;
146
190
}
147
191
148
Removed:
const reading = result.value;
149
Removed:
updateLatestRow(row, reading);
192
Added:
const [reading, nodeStatus] = result.value;
193
Added:
updateLatestRow(row, reading, nodeStatus);
150
194
});
151
195
}
152
196
roles/dashboard/t/basic.t
@@ -29,11 +29,49 @@
29
29
30
30
$t->app->sqlite->db->query(
31
31
q{
32
Added:
CREATE TABLE node_status (
33
Added:
id INTEGER PRIMARY KEY AUTOINCREMENT,
34
Added:
received_at TEXT NOT NULL,
35
Added:
topic TEXT NOT NULL,
36
Added:
schema TEXT,
37
Added:
timestamp TEXT,
38
Added:
probe TEXT,
39
Added:
node TEXT,
40
Added:
status TEXT,
41
Added:
message TEXT,
42
Added:
error TEXT,
43
Added:
device TEXT,
44
Added:
source TEXT,
45
Added:
payload TEXT NOT NULL,
46
Added:
valid INTEGER NOT NULL DEFAULT 1
47
Added:
)
48
Added:
}
49
Added:
);
50
Added:
51
Added:
$t->app->sqlite->db->query(
52
Added:
q{
32
53
INSERT INTO readings (timestamp, node, probe, value, unit)
33
54
VALUES (?, ?, ?, ?, ?)
34
55
}, '2026-07-05T12:00:00Z', 'fapg-daq-zero-ph-01', 'ph', 7.12, 'pH'
35
56
);
36
57
58
Added:
$t->app->sqlite->db->query(
59
Added:
q{
60
Added:
INSERT INTO node_status (
61
Added:
received_at, topic, schema, timestamp, probe, node, status, message, payload
62
Added:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
63
Added:
},
64
Added:
'2026-07-05T12:00:02Z',
65
Added:
'fapg/daq/ph/fapg-daq-zero-ph-01/status',
66
Added:
'fapg.daq.status.v1',
67
Added:
'2026-07-05T12:00:02Z',
68
Added:
'ph',
69
Added:
'fapg-daq-zero-ph-01',
70
Added:
'ok',
71
Added:
'Probe reading valid',
72
Added:
'{}',
73
Added:
);
74
Added:
37
75
$t->get_ok('/')
38
76
->status_is(200)
39
77
->content_like(qr/FAPG DAQ Dashboard/)
@@ -47,6 +85,12 @@
47
85
->json_is( '/probe' => 'ph' )
48
86
->json_is( '/readings/0/value' => 7.12 );
49
87
88
Added:
$t->get_ok('/api/status/ph')
89
Added:
->status_is(200)
90
Added:
->json_is( '/probe' => 'ph' )
91
Added:
->json_is( '/status/status' => 'ok' );
92
Added:
50
93
$t->get_ok('/api/readings/nope')->status_is(404);
94
Added:
$t->get_ok('/api/status/nope')->status_is(404);
51
95
52
96
done_testing;
roles/dashboard/templates/dashboard/index.html.ep
@@ -11,7 +11,7 @@
11
11
<section class="status-band" aria-label="DAQ status overview">
12
12
<div class="section-heading">
13
13
<h2>Latest Probe Readings</h2>
14
Removed:
<p>Online: under 2 minutes. Stale: 2 to 15 minutes. Offline: over 15 minutes.</p>
14
Added:
<p>Healthy nodes publish valid readings. Probe unavailable means the node is reachable but cannot read its probe. Unreachable means no recent status.</p>
15
15
</div>
16
16
17
17
<div class="table-wrap">