[Perl] DAQ system for the FAPG.
Introduce dashboard role.
Changed files
- roles/dashboard/dashboard.yml
- roles/dashboard/lib/dashboard.pm
- roles/dashboard/lib/dashboard/Controller/Dashboard.pm
- roles/dashboard/lib/dashboard/Controller/Example.pm
- roles/dashboard/lib/dashboard/Controller/Reading.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/public/index.html
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/script/dashboard
- roles/dashboard/t/basic.t
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/layouts/default.html.ep
roles/dashboard/dashboard.yml
@@ -0,0 +1,3 @@
1
Added:
---
2
Added:
secrets:
3
Added:
- 7nLGfP4ybXpvpcu1CrdetpuAd3cDLkBE9KIN1qpLHkT7As33HHyn8DcrrT1ivaKlqugWv5JtufMJQXs-Bf96fKav9eZPcx0MnGqNXOfzRtbkS-MwrGpYfQzni_wz2rWGcAxK-x9fSkNDVqezW518fA6a1Ju9RBOjTW91_raxMWk
roles/dashboard/lib/dashboard.pm
@@ -0,0 +1,56 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
package dashboard;
4
Added:
use Mojo::Base 'Mojolicious', -signatures;
5
Added:
6
Added:
use Mojo::SQLite;
7
Added:
8
Added:
# This method will run once at server start
9
Added:
sub startup ($self) {
10
Added:
11
Added:
# Load configuration from config file
12
Added:
my $config = $self->plugin('NotYAMLConfig');
13
Added:
14
Added:
# Configure the application
15
Added:
$self->secrets($config->{secrets});
16
Added:
17
Added:
my $db_path = $ENV{FAPG_DAQ_DB}
18
Added:
// $self->home->rel_file('fapg-daq.sqlite3');
19
Added:
20
Added:
my $sqlite = Mojo::SQLite->new("sqlite:$db_path");
21
Added:
22
Added:
$self->helper(sqlite => sub { $sqlite });
23
Added:
24
Added:
$self->helper(probes => sub {
25
Added:
return [
26
Added:
{
27
Added:
key => 'ph',
28
Added:
label => 'pH',
29
Added:
unit => 'pH',
30
Added:
},
31
Added:
{
32
Added:
key => 'do',
33
Added:
label => 'Dissolved Oxygen',
34
Added:
unit => 'mg/L',
35
Added:
},
36
Added:
{
37
Added:
key => 'orp',
38
Added:
label => 'Redox / ORP',
39
Added:
unit => 'mV',
40
Added:
},
41
Added:
{
42
Added:
key => 'ec',
43
Added:
label => 'Electrical Conductivity',
44
Added:
unit => 'µS/cm',
45
Added:
},
46
Added:
];
47
Added:
});
48
Added:
49
Added:
my $r = $self->routes;
50
Added:
51
Added:
$r->get('/')->to('dashboard#index');
52
Added:
53
Added:
$r->get('/api/readings/:probe')->to('Reading#list');
54
Added:
}
55
Added:
56
Added:
1;
roles/dashboard/lib/dashboard/Controller/Dashboard.pm
@@ -0,0 +1,13 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
package dashboard::Controller::Dashboard;
4
Added:
use Mojo::Base 'Mojolicious::Controller', -signatures;
5
Added:
6
Added:
sub index ($self) {
7
Added:
$self->render(
8
Added:
template => 'dashboard/index',
9
Added:
probes => $self->probes,
10
Added:
);
11
Added:
}
12
Added:
13
Added:
1;
roles/dashboard/lib/dashboard/Controller/Example.pm
@@ -0,0 +1,11 @@
1
Added:
package dashboard::Controller::Example;
2
Added:
use Mojo::Base 'Mojolicious::Controller', -signatures;
3
Added:
4
Added:
# This action will render a template
5
Added:
sub welcome ($self) {
6
Added:
7
Added:
# Render template "example/welcome.html.ep" with message
8
Added:
$self->render(msg => 'Welcome to the Mojolicious real-time web framework!');
9
Added:
}
10
Added:
11
Added:
1;
roles/dashboard/lib/dashboard/Controller/Reading.pm
@@ -0,0 +1,45 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
package FAPG::DAQ::Dashboard::Controller::Reading;
4
Added:
use Mojo::Base 'Mojolicious::Controller', -signatures;
5
Added:
6
Added:
sub list ($self) {
7
Added:
my $probe = $self->param('probe') // '';
8
Added:
9
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
10
Added:
11
Added:
return $self->render(
12
Added:
status => 404,
13
Added:
json => {
14
Added:
error => "Unknown probe type: $probe",
15
Added:
},
16
Added:
) unless $known{$probe};
17
Added:
18
Added:
my $limit = $self->param('limit') // 300;
19
Added:
20
Added:
$limit = 300 unless $limit =~ /^\d+$/;
21
Added:
$limit = 2_000 if $limit > 2_000;
22
Added:
23
Added:
my $rows = $self->sqlite->db->query(
24
Added:
q{
25
Added:
SELECT timestamp, node, probe, value, unit
26
Added:
FROM readings
27
Added:
WHERE probe = ?
28
Added:
ORDER BY timestamp DESC
29
Added:
LIMIT ?
30
Added:
},
31
Added:
$probe,
32
Added:
$limit,
33
Added:
)->hashes->to_array;
34
Added:
35
Added:
$rows = [ reverse $rows->@* ];
36
Added:
37
Added:
$self->render(
38
Added:
json => {
39
Added:
probe => $probe,
40
Added:
readings => $rows,
41
Added:
},
42
Added:
);
43
Added:
}
44
Added:
45
Added:
1;
roles/dashboard/public/css/app.css
@@ -0,0 +1,134 @@
1
Added:
/* -*- mode: css; -*- */
2
Added:
3
Added:
:root {
4
Added:
--bg: #f4f7f5;
5
Added:
--card: #ffffff;
6
Added:
--text: #17211b;
7
Added:
--muted: #66736a;
8
Added:
--border: #d8e2dc;
9
Added:
--accent: #276749;
10
Added:
--accent-soft: #e4f3ea;
11
Added:
}
12
Added:
13
Added:
* {
14
Added:
box-sizing: border-box;
15
Added:
}
16
Added:
17
Added:
body {
18
Added:
margin: 0;
19
Added:
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
Added:
background: var(--bg);
21
Added:
color: var(--text);
22
Added:
}
23
Added:
24
Added:
.site-main {
25
Added:
width: min(1100px, calc(100% - 2rem));
26
Added:
margin: 2rem auto;
27
Added:
}
28
Added:
29
Added:
.page-header {
30
Added:
margin-bottom: 1.5rem;
31
Added:
}
32
Added:
33
Added:
.page-header h1 {
34
Added:
margin: 0 0 0.4rem;
35
Added:
font-size: clamp(1.8rem, 3vw, 2.6rem);
36
Added:
}
37
Added:
38
Added:
.page-header p {
39
Added:
margin: 0;
40
Added:
color: var(--muted);
41
Added:
}
42
Added:
43
Added:
.dashboard-card {
44
Added:
background: var(--card);
45
Added:
border: 1px solid var(--border);
46
Added:
border-radius: 1rem;
47
Added:
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
48
Added:
overflow: hidden;
49
Added:
}
50
Added:
51
Added:
.tabs {
52
Added:
display: flex;
53
Added:
flex-wrap: wrap;
54
Added:
gap: 0.5rem;
55
Added:
padding: 1rem;
56
Added:
border-bottom: 1px solid var(--border);
57
Added:
background: #fafcfb;
58
Added:
}
59
Added:
60
Added:
.tab-button {
61
Added:
border: 1px solid var(--border);
62
Added:
border-radius: 999px;
63
Added:
padding: 0.6rem 1rem;
64
Added:
background: white;
65
Added:
color: var(--text);
66
Added:
cursor: pointer;
67
Added:
font: inherit;
68
Added:
}
69
Added:
70
Added:
.tab-button:hover {
71
Added:
border-color: var(--accent);
72
Added:
}
73
Added:
74
Added:
.tab-button.is-active {
75
Added:
background: var(--accent);
76
Added:
border-color: var(--accent);
77
Added:
color: white;
78
Added:
}
79
Added:
80
Added:
.tab-panel {
81
Added:
display: none;
82
Added:
padding: 1.25rem;
83
Added:
}
84
Added:
85
Added:
.tab-panel.is-active {
86
Added:
display: block;
87
Added:
}
88
Added:
89
Added:
.panel-heading {
90
Added:
display: flex;
91
Added:
justify-content: space-between;
92
Added:
gap: 1rem;
93
Added:
align-items: baseline;
94
Added:
margin-bottom: 1rem;
95
Added:
}
96
Added:
97
Added:
.panel-heading h2 {
98
Added:
margin: 0;
99
Added:
}
100
Added:
101
Added:
.panel-heading p {
102
Added:
margin: 0;
103
Added:
color: var(--muted);
104
Added:
}
105
Added:
106
Added:
.chart-wrap {
107
Added:
min-height: 420px;
108
Added:
}
109
Added:
110
Added:
.reading-status {
111
Added:
margin: 1rem 0 0;
112
Added:
color: var(--muted);
113
Added:
font-size: 0.95rem;
114
Added:
}
115
Added:
116
Added:
@media (max-width: 700px) {
117
Added:
.site-main {
118
Added:
width: min(100% - 1rem, 1100px);
119
Added:
margin: 1rem auto;
120
Added:
}
121
Added:
122
Added:
.tabs {
123
Added:
display: grid;
124
Added:
grid-template-columns: 1fr 1fr;
125
Added:
}
126
Added:
127
Added:
.panel-heading {
128
Added:
display: block;
129
Added:
}
130
Added:
131
Added:
.chart-wrap {
132
Added:
min-height: 320px;
133
Added:
}
134
Added:
}
roles/dashboard/public/index.html
@@ -0,0 +1,11 @@
1
Added:
<!DOCTYPE html>
2
Added:
<html>
3
Added:
<head>
4
Added:
<title>Welcome to the Mojolicious real-time web framework!</title>
5
Added:
</head>
6
Added:
<body>
7
Added:
<h2>Welcome to the Mojolicious real-time web framework!</h2>
8
Added:
This is the static document "public/index.html",
9
Added:
<a href="/">click here</a> to get back to the start.
10
Added:
</body>
11
Added:
</html>
roles/dashboard/public/js/dashboard.js
@@ -0,0 +1,129 @@
1
Added:
const charts = new Map();
2
Added:
3
Added:
function activeProbe() {
4
Added:
return document.querySelector(".tab-button.is-active").dataset.probe;
5
Added:
}
6
Added:
7
Added:
function statusElement(probe) {
8
Added:
return document.querySelector(`[data-status="${probe}"]`);
9
Added:
}
10
Added:
11
Added:
function chartElement(probe) {
12
Added:
return document.getElementById(`chart-${probe}`);
13
Added:
}
14
Added:
15
Added:
function labelForRow(row) {
16
Added:
const date = new Date(row.timestamp);
17
Added:
return date.toLocaleTimeString([], {
18
Added:
hour: "2-digit",
19
Added:
minute: "2-digit",
20
Added:
second: "2-digit"
21
Added:
});
22
Added:
}
23
Added:
24
Added:
async function loadProbe(probe) {
25
Added:
const status = statusElement(probe);
26
Added:
27
Added:
try {
28
Added:
const response = await fetch(`/api/readings/${probe}?limit=300`);
29
Added:
30
Added:
if (!response.ok) {
31
Added:
throw new Error(`HTTP ${response.status}`);
32
Added:
}
33
Added:
34
Added:
const payload = await response.json();
35
Added:
const rows = payload.readings || [];
36
Added:
37
Added:
const labels = rows.map(labelForRow);
38
Added:
const values = rows.map(row => Number(row.value));
39
Added:
const unit = rows.length ? rows[rows.length - 1].unit : "";
40
Added:
41
Added:
if (!charts.has(probe)) {
42
Added:
const ctx = chartElement(probe);
43
Added:
44
Added:
const chart = new Chart(ctx, {
45
Added:
type: "line",
46
Added:
data: {
47
Added:
labels,
48
Added:
datasets: [{
49
Added:
label: `${probe} ${unit ? `(${unit})` : ""}`,
50
Added:
data: values,
51
Added:
tension: 0.2,
52
Added:
pointRadius: 0
53
Added:
}]
54
Added:
},
55
Added:
options: {
56
Added:
responsive: true,
57
Added:
maintainAspectRatio: false,
58
Added:
animation: false,
59
Added:
scales: {
60
Added:
x: {
61
Added:
ticks: {
62
Added:
maxTicksLimit: 8
63
Added:
}
64
Added:
},
65
Added:
y: {
66
Added:
beginAtZero: false
67
Added:
}
68
Added:
},
69
Added:
plugins: {
70
Added:
legend: {
71
Added:
display: true
72
Added:
},
73
Added:
tooltip: {
74
Added:
mode: "index",
75
Added:
intersect: false
76
Added:
}
77
Added:
}
78
Added:
}
79
Added:
});
80
Added:
81
Added:
charts.set(probe, chart);
82
Added:
} else {
83
Added:
const chart = charts.get(probe);
84
Added:
85
Added:
chart.data.labels = labels;
86
Added:
chart.data.datasets[0].data = values;
87
Added:
chart.data.datasets[0].label = `${probe} ${unit ? `(${unit})` : ""}`;
88
Added:
chart.update();
89
Added:
}
90
Added:
91
Added:
if (rows.length) {
92
Added:
const latest = rows[rows.length - 1];
93
Added:
94
Added:
status.textContent =
95
Added:
`Latest: ${latest.value} ${latest.unit || ""} from ${latest.node} at ${latest.timestamp}`;
96
Added:
} else {
97
Added:
status.textContent = "No readings found for this probe yet.";
98
Added:
}
99
Added:
} catch (error) {
100
Added:
status.textContent = `Could not load ${probe} readings: ${error.message}`;
101
Added:
}
102
Added:
}
103
Added:
104
Added:
function activateTab(probe) {
105
Added:
document.querySelectorAll(".tab-button").forEach(button => {
106
Added:
const active = button.dataset.probe === probe;
107
Added:
108
Added:
button.classList.toggle("is-active", active);
109
Added:
button.setAttribute("aria-selected", active ? "true" : "false");
110
Added:
});
111
Added:
112
Added:
document.querySelectorAll(".tab-panel").forEach(panel => {
113
Added:
panel.classList.toggle("is-active", panel.dataset.panel === probe);
114
Added:
});
115
Added:
116
Added:
loadProbe(probe);
117
Added:
}
118
Added:
119
Added:
document.querySelectorAll(".tab-button").forEach(button => {
120
Added:
button.addEventListener("click", () => {
121
Added:
activateTab(button.dataset.probe);
122
Added:
});
123
Added:
});
124
Added:
125
Added:
loadProbe(activeProbe());
126
Added:
127
Added:
setInterval(() => {
128
Added:
loadProbe(activeProbe());
129
Added:
}, 5_000);
roles/dashboard/script/dashboard
@@ -0,0 +1,11 @@
1
Added:
#!/usr/bin/env perl
2
Added:
3
Added:
use strict;
4
Added:
use warnings;
5
Added:
6
Added:
use Mojo::File qw(curfile);
7
Added:
use lib curfile->dirname->sibling('lib')->to_string;
8
Added:
use Mojolicious::Commands;
9
Added:
10
Added:
# Start command line interface for application
11
Added:
Mojolicious::Commands->start_app('dashboard');
roles/dashboard/t/basic.t
@@ -0,0 +1,46 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
use Mojo::Base -strict;
4
Added:
5
Added:
use Test2::V0;
6
Added:
use Test::Mojo;
7
Added:
8
Added:
use FindBin;
9
Added:
use lib "${FindBin::Bin}/../lib/";
10
Added:
use dashboard;
11
Added:
12
Added:
$ENV{FAPG_DAQ_DB} = ':memory:';
13
Added:
14
Added:
my $t = Test::Mojo->new('dashboard');
15
Added:
16
Added:
$t->app->sqlite->db->query(q{
17
Added:
CREATE TABLE readings (
18
Added:
id INTEGER PRIMARY KEY AUTOINCREMENT,
19
Added:
timestamp TEXT NOT NULL,
20
Added:
node TEXT NOT NULL,
21
Added:
probe TEXT NOT NULL,
22
Added:
value REAL NOT NULL,
23
Added:
unit TEXT,
24
Added:
raw_json TEXT
25
Added:
)
26
Added:
});
27
Added:
28
Added:
$t->app->sqlite->db->query(q{
29
Added:
INSERT INTO readings (timestamp, node, probe, value, unit)
30
Added:
VALUES (?, ?, ?, ?, ?)
31
Added:
}, '2026-07-05T12:00:00Z', 'fapg-daq-zero-ph-01', 'ph', 7.12, 'pH');
32
Added:
33
Added:
$t->get_ok('/')
34
Added:
->status_is(200)
35
Added:
->content_like(qr/FAPG DAQ Dashboard/)
36
Added:
->content_like(qr/Dissolved Oxygen/);
37
Added:
38
Added:
$t->get_ok('/api/readings/ph')
39
Added:
->status_is(200)
40
Added:
->json_is('/probe' => 'ph')
41
Added:
->json_is('/readings/0/value' => 7.12);
42
Added:
43
Added:
$t->get_ok('/api/readings/nope')
44
Added:
->status_is(404);
45
Added:
46
Added:
done_testing;
roles/dashboard/templates/dashboard/index.html.ep
@@ -0,0 +1,54 @@
1
Added:
<!-- -*- mode: web; -*- -->
2
Added:
3
Added:
% layout 'default';
4
Added:
% title 'FAPG DAQ Dashboard';
5
Added:
6
Added:
<section class="page-header">
7
Added:
<h1>FAPG DAQ Dashboard</h1>
8
Added:
<p>Live aquaponic sensor readings from the DAQ network.</p>
9
Added:
</section>
10
Added:
11
Added:
<section class="dashboard-card">
12
Added:
<div class="tabs" role="tablist" aria-label="DAQ readings">
13
Added:
% for my $i (0 .. $probes->$#*) {
14
Added:
% my $probe = $probes->[$i];
15
Added:
<button
16
Added:
class="tab-button <%= $i == 0 ? 'is-active' : '' %>"
17
Added:
type="button"
18
Added:
role="tab"
19
Added:
data-probe="<%= $probe->{key} %>"
20
Added:
aria-selected="<%= $i == 0 ? 'true' : 'false' %>">
21
Added:
<%= $probe->{label} %>
22
Added:
</button>
23
Added:
% }
24
Added:
</div>
25
Added:
26
Added:
% for my $i (0 .. $probes->$#*) {
27
Added:
% my $probe = $probes->[$i];
28
Added:
29
Added:
<section
30
Added:
class="tab-panel <%= $i == 0 ? 'is-active' : '' %>"
31
Added:
data-panel="<%= $probe->{key} %>"
32
Added:
role="tabpanel">
33
Added:
34
Added:
<div class="panel-heading">
35
Added:
<h2><%= $probe->{label} %></h2>
36
Added:
<p>
37
Added:
Unit:
38
Added:
<strong><%= $probe->{unit} %></strong>
39
Added:
</p>
40
Added:
</div>
41
Added:
42
Added:
<div class="chart-wrap">
43
Added:
<canvas id="chart-<%= $probe->{key} %>"></canvas>
44
Added:
</div>
45
Added:
46
Added:
<p class="reading-status" data-status="<%= $probe->{key} %>">
47
Added:
Waiting for data…
48
Added:
</p>
49
Added:
</section>
50
Added:
% }
51
Added:
</section>
52
Added:
53
Added:
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
54
Added:
<script src="/js/dashboard.js"></script>
roles/dashboard/templates/layouts/default.html.ep
@@ -0,0 +1,16 @@
1
Added:
<!-- -*- mode: web; -*- -->
2
Added:
3
Added:
<!doctype html>
4
Added:
<html lang="en">
5
Added:
<head>
6
Added:
<meta charset="utf-8">
7
Added:
<title><%= title %></title>
8
Added:
<meta name="viewport" content="width=device-width, initial-scale=1">
9
Added:
<link rel="stylesheet" href="/css/app.css">
10
Added:
</head>
11
Added:
<body>
12
Added:
<main class="site-main">
13
Added:
<%= content %>
14
Added:
</main>
15
Added:
</body>
16
Added:
</html>