perf consolidate status requests

- expose the latest configured statuses through one bulk API query - render all dashboard status components from the shared response - retain and test the existing single-status endpoint

Commit
d94e60e5bc6ec3f01646b2ee40ad00c33e9d78b9
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index 2cbaab0c..6124a4c7 100644..100644
@@ -81,6 +81,7 @@
81 81
82 82 $r->get('/api/readings/:probe/series')->to('Reading#series');
83 83 $r->get('/api/readings/:probe')->to('Reading#list');
84 Added: $r->get('/api/status')->to('Reading#status_list');
84 85 $r->get('/api/status/:probe')->to('Reading#status');
85 86 }
86 87
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
index e0b51249..20d7bc8f 100644..100644
@@ -233,6 +233,49 @@
233 233 );
234 234 }
235 235
236 Added: sub status_list ($self) {
237 Added: my @probes = map { $_->{key} } $self->status_items->@*;
238 Added: my $placeholders = join ', ', ('?') x @probes;
239 Added: my %statuses = map { $_ => undef } @probes;
240 Added:
241 Added: my $rows = eval {
242 Added: $self->sqlite->db->query(
243 Added: qq{
244 Added: WITH ranked AS (
245 Added: SELECT
246 Added: timestamp,
247 Added: received_at,
248 Added: node,
249 Added: probe,
250 Added: status,
251 Added: message,
252 Added: error,
253 Added: device,
254 Added: ROW_NUMBER() OVER (
255 Added: PARTITION BY probe
256 Added: ORDER BY COALESCE(timestamp, received_at) DESC, id DESC
257 Added: ) AS status_rank
258 Added: FROM node_status
259 Added: WHERE valid = 1
260 Added: AND probe IN ($placeholders)
261 Added: )
262 Added: SELECT timestamp, received_at, node, probe, status, message, error, device
263 Added: FROM ranked
264 Added: WHERE status_rank = 1
265 Added: },
266 Added: @probes,
267 Added: )->hashes->to_array;
268 Added: };
269 Added:
270 Added: $rows = [] if $@;
271 Added: for my $row (@$rows) {
272 Added: mark_unreachable_if_stale($row);
273 Added: $statuses{ $row->{probe} } = $row;
274 Added: }
275 Added:
276 Added: $self->render( json => { statuses => \%statuses, }, );
277 Added: }
278 Added:
236 279 sub mark_unreachable_if_stale ($row) {
237 280 my $received_at = $row->{received_at} // $row->{timestamp};
238 281 return if !defined $received_at || $received_at ge reachable_since();
roles/dashboard/public/js/dashboard/api.js
index ca5d209e..974b7ffd 100644..100644
@@ -8,9 +8,9 @@
8 8 return response.json();
9 9 }
10 10
11 Removed: export async function fetchLatestStatus(probe) {
12 Removed: const payload = await fetchJson(`/api/status/${probe}`);
13 Removed: return payload.status || null;
11 Added: export async function fetchLatestStatuses() {
12 Added: const payload = await fetchJson("/api/status");
13 Added: return payload.statuses || {};
14 14 }
15 15
16 16 export async function fetchLatestReading(probe) {
roles/dashboard/public/js/dashboard/status.js
index c3212b2a..9f80b5cb 100644..100644
@@ -1,4 +1,4 @@
1 Removed: import { fetchLatestStatus } from "./api.js";
1 Added: import { fetchLatestStatuses } from "./api.js";
2 2 import { OFFLINE_AFTER_MS } from "./constants.js";
3 3 import { ageMs, formatAge, formatDateTime } from "./format.js";
4 4
@@ -47,17 +47,13 @@
47 47 return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
48 48 }
49 49
50 Removed: async function loadOverview() {
50 Added: function loadOverview(statuses) {
51 51 const overview = document.querySelector("[data-overview-status]");
52 52 if (!overview) return;
53 53
54 54 const rows = Array.from(document.querySelectorAll("[data-sensor-status]"));
55 Removed: const results = await Promise.allSettled(
56 Removed: rows.map(row => fetchLatestStatus(row.dataset.sensorStatus))
57 Removed: );
58 Removed: const items = rows.map((row, index) => {
59 Removed: const result = results[index];
60 Removed: const status = result.status === "fulfilled" ? result.value : null;
55 Added: const items = rows.map(row => {
56 Added: const status = statuses[row.dataset.sensorStatus] || null;
61 57
62 58 return { status, state: stateForNodeStatus(status) };
63 59 });
@@ -89,7 +85,7 @@
89 85 element.title = text;
90 86 }
91 87
92 Removed: async function loadHeaderStatus() {
88 Added: function loadHeaderStatus(statuses) {
93 89 if (!document.querySelector("[data-header-status]")) return;
94 90
95 91 const rows = Array.from(document.querySelectorAll("[data-header-status-item]"));
@@ -98,12 +94,9 @@
98 94 return;
99 95 }
100 96
101 Removed: const results = await Promise.allSettled(
102 Removed: rows.map(row => fetchLatestStatus(row.dataset.headerStatusItem))
97 Added: const warnings = rows.filter(row =>
98 Added: stateForNodeStatus(statuses[row.dataset.headerStatusItem] || null) !== "healthy"
103 99 );
104 Removed: const warnings = rows.filter((row, index) =>
105 Removed: stateForNodeStatus(results[index].status === "fulfilled" ? results[index].value : null) !== "healthy"
106 Removed: );
107 100
108 101 if (!warnings.length) {
109 102 setHeaderStatus("healthy", "All DAQ status checks are healthy");
@@ -132,20 +125,31 @@
132 125 : "No recent status received";
133 126 }
134 127
135 Removed: async function loadStatusPage() {
128 Added: function loadStatusPage(statuses) {
136 129 const rows = Array.from(document.querySelectorAll("[data-status-page-item]"));
137 130 if (!rows.length) return;
138 131
139 Removed: const results = await Promise.allSettled(
140 Removed: rows.map(row => fetchLatestStatus(row.dataset.statusPageItem))
141 Removed: );
142 Removed: rows.forEach((row, index) => {
143 Removed: updateStatusPageRow(row, results[index].status === "fulfilled" ? results[index].value : null);
132 Added: rows.forEach(row => {
133 Added: updateStatusPageRow(row, statuses[row.dataset.statusPageItem] || null);
144 134 });
145 135 }
146 136
147 137 export function createStatusLoader() {
148 138 return async function loadStatuses() {
149 Removed: await Promise.all([loadOverview(), loadHeaderStatus(), loadStatusPage()]);
139 Added: const hasStatusElements = document.querySelector(
140 Added: "[data-overview-status], [data-header-status], [data-status-page-item]"
141 Added: );
142 Added: if (!hasStatusElements) return;
143 Added:
144 Added: let statuses = {};
145 Added: try {
146 Added: statuses = await fetchLatestStatuses();
147 Added: } catch (_error) {
148 Added: // Render every configured item as unreachable when the bulk request fails.
149 Added: }
150 Added:
151 Added: loadOverview(statuses);
152 Added: loadHeaderStatus(statuses);
153 Added: loadStatusPage(statuses);
150 154 };
151 155 }
roles/dashboard/t/03-status-api.t
index 7e82544b..a7415998 100644..100644
@@ -11,6 +11,14 @@
11 11
12 12 my $t = test_app();
13 13
14 Added: $t->get_ok('/api/status')
15 Added: ->status_is(200)
16 Added: ->json_is( '/statuses/ph/status' => 'ok' )
17 Added: ->json_is( '/statuses/hub/node' => 'fapg-daq-five-01' )
18 Added: ->json_is( '/statuses/do/status' => 'unreachable' )
19 Added: ->json_is( '/statuses/orp' => undef )
20 Added: ->json_is( '/statuses/ec' => undef );
21 Added:
14 22 $t->get_ok('/api/status/ph')
15 23 ->status_is(200)
16 24 ->json_is( '/probe' => 'ph' )