Introduce dashboard role.

Commit
a935a703748bf36dd002fc7f244e8d572276dfd6
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/dashboard.yml
index 00000000..57346306 000000..100644
@@ -0,0 +1,3 @@
1 Added: ---
2 Added: secrets:
3 Added: - 7nLGfP4ybXpvpcu1CrdetpuAd3cDLkBE9KIN1qpLHkT7As33HHyn8DcrrT1ivaKlqugWv5JtufMJQXs-Bf96fKav9eZPcx0MnGqNXOfzRtbkS-MwrGpYfQzni_wz2rWGcAxK-x9fSkNDVqezW518fA6a1Ju9RBOjTW91_raxMWk
roles/dashboard/lib/dashboard.pm
index 00000000..9a803ff2 000000..100644
@@ -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
index 00000000..f74f9238 000000..100644
@@ -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
index 00000000..033afbf2 000000..100644
@@ -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
index 00000000..77d6319e 000000..100644
@@ -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
index 00000000..9c27aac7 000000..100644
@@ -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
index 00000000..e74bb5f0 000000..100644
@@ -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
index 00000000..44e2f107 000000..100644
@@ -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
index 00000000..73179739 000000..100755
@@ -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
index 00000000..e86af4c6 000000..100644
@@ -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
index 00000000..db635b25 000000..100644
@@ -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
index 00000000..bfbb5bf0 000000..100644
@@ -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>