Differentiate DAQ node reading and DAQ node health.

Commit
23f1a6bb336c8e7e4cd6ea372d1e922ba4b125cd
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/daq-node/bin/fapg-daq-node
index f114ad0f..3bb37ca7 100755..100755
@@ -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
index 9d0200a9..6fb68330 100644..100644
@@ -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
index 1ef0f075..f4d2fc74 100644..100644
@@ -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
index 85dfcc01..00d4415a 100644..100644
@@ -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
index 10824c6d..d61d68b4 100755..100755
@@ -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
index 6b80512b..e4e4b620 100644..100644
@@ -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
index d0091bb9..ed8e87ae 100644..100644
@@ -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
index 0ef9f8a2..3ab3156d 100644..100644
@@ -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
index 3be958d8..a2ec901c 100644..100644
@@ -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
index 3a797902..056ac253 100644..100644
@@ -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
index 972f1877..c71ee5e6 100644..100644
@@ -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">