Датчики уже присылают сообщения, а хочется открыть одну страницу и посмотреть, что происходит в теплицах. Сделаем такую страницу своими руками: текущие значения, история и события будут собраны рядом. Заодно разберёмся, как сообщение превращается в точку графика.
Это полный лабораторный проект для одного процесса Node.js: Nuxt, MQTT с TLS, SQLite, Chart.js и Web Push. Он показывает данные и сообщает о переходах состояния. Управление нагрузками оставляем отдельному контроллеру. После запуска проверим страницу на своих сообщениях и телефоне.
Начинаем с карточки одного датчика
- Карточки настроенных устройств, включая точки, от которых ещё нет данных.
- Температуру и влажность воздуха, время измерения и его возраст на момент загрузки страницы. При передаче дополнительных полей — условную шкалу датчика почвы и координаты.
- Часовые средние за выбранный период до семи суток. Пустые часы остаются разрывами; среднее не показывает все кратковременные пики.
- Журнал изменений: выход за пределы, восстановление, ошибка датчика, некорректное сообщение и устаревание.
- Подписку браузера на уведомления, если сервер и браузер поддерживают её и пользователь дал разрешение.
Карточка сравнивает показания с выбранными границами и показывает возраст данных. Отдельно видны потеря MQTT и ошибка загрузки страницы. Рабочие границы подбирайте под задачу: надпись «В заданных пределах» описывает именно эту настройку.
Готовим проект и зависимости
Проверенный набор: Nuxt 4.5.2, MQTT.js 5.16.0, Chart.js 4.5.1, better-sqlite3 13.0.3 и web-push 3.6.7. Испытания выполнены с Node.js 22.22.1 на Linux ARM64. Nuxt этой версии требует Node.js ^22.19.0, ^24.11.0 или более новую поддерживаемую ветку согласно пакету; совместимость своей версии проверьте перед установкой. Актуальные требования приведены в руководстве Nuxt.
Создайте пустую папку farm-dashboard и сохраните файлы по указанным путям. В Nuxt 4 папка app содержит страницу, server — обработчики и хранение. MQTT и секреты остаются на серверной стороне. Ниже есть все части: можно собрать проект и проследить связь между ними.
package.json
{
"name": "fermozavr-diy-dashboard-example",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev --host 127.0.0.1",
"build": "nuxt build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"nuxt": "4.5.2",
"mqtt": "5.16.0",
"chart.js": "4.5.1",
"better-sqlite3": "13.0.3",
"web-push": "3.6.7"
}
}
nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2026-09-27',
devtools: { enabled: false },
app: { head: { link: [{ rel: 'manifest', href: '/manifest.json' }] } },
nitro: { preset: 'node-server' },
routeRules: { '/**': { headers: {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'X-Frame-Options': 'DENY'
} } }
});
.gitignore
node_modules/
.nuxt/
.output/
.env
data/
После сохранения файлов установите зависимости. Непосредственные версии закреплены в package.json; npm также создаст package-lock.json для транзитивных зависимостей. Сохраните его вместе с проектом и используйте npm ci при последующих установках. Для better-sqlite3 может потребоваться подходящий готовый модуль или среда сборки на вашей платформе.
npm install
node create-secrets.mjs
Следующий скрипт создаёт приватный .env и отказывается перезаписывать существующий. Замените адрес сайта, адрес MQTT-брокера, пароль, путь к CA и контакт VAPID своими значениями. Пароль со специальными символами оформляйте с учётом синтаксиса env-файла Node.js. Ключ DASH_TOKEN из этого файла вводится на странице; он даёт доступ владельца ко всем данным примера. Не включайте .env в репозиторий и резервные копии с общим доступом.
create-secrets.mjs
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import webpush from 'web-push';
const keys=webpush.generateVAPIDKeys();
writeFileSync('.env',`NODE_ENV=production
NITRO_HOST=127.0.0.1
NITRO_PORT=3000
DASH_ORIGIN=https://farm.example.com
DASH_TOKEN=${randomBytes(32).toString('hex')}
DEVICES_FILE=./devices.json
DATA_DIR=./data
MQTT_URL=mqtts://broker.example.com:8883
MQTT_USERNAME=dashboard
MQTT_PASSWORD=replace_with_broker_password
MQTT_CA_FILE=/etc/mosquitto/certs/ca.crt
VAPID_SUBJECT=mailto:replace@example.com
VAPID_PUBLIC_KEY=${keys.publicKey}
VAPID_PRIVATE_KEY=${keys.privateKey}
`,{flag:'wx',mode:0o600});
console.log('Created .env without overwriting an existing file. Edit broker, origin, CA, password and contact. Keep this file private.');
Для локальной проверки адресом может быть http://127.0.0.1:3000; в рабочем доступе используйте HTTPS и точный внешний origin без завершающего слеша. Node слушает только loopback. MQTT требует TLS с проверкой сертификата, имя пользователя и пароль. Отдельной учётной записи dashboard разрешите только чтение нужных тем, например:
user dashboard
topic read ferma/teplica1/data
Это фрагмент ACL уже настроенного Mosquitto, а не вся конфигурация брокера. Настройка TLS и отдельных прав устройств разобрана в статье про MQTT и бота. Не выдавайте странице права управления устройствами.
Договариваемся с датчиком о формате
В devices.json задайте свои границы, гистерезис и допустимый возраст. Числа 5, 35 и 90 оставлены для первого знакомства. Идентификаторы должны совпасть с темами MQTT; пример рассчитан на 1–32 устройства.
devices.json
[
{
"id": "teplica1",
"name": "Теплица: учебная точка",
"staleSeconds": 120,
"temperature": { "low": 5, "high": 35, "hysteresis": 1 },
"humidity": { "high": 90, "hysteresis": 3 }
}
]
Для первого источника используем ferma/teplica1/data: boot отмечает запуск, sampled_at — время измерения в секундах Unix UTC, ok — результат чтения. При ошибке остаются boot, sampled_at и ok:false. JSON ниже иллюстративный: замените время на фактическое текущее, затем найдите сообщение на странице. Время приёма хранится отдельно.
{
"boot": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sampled_at": 1800000000,
"ok": true,
"temp": 20,
"hum": 50
}
Дополнительные soilMoisture, lat и lng допускаются только от устройства, которое действительно их измеряет. Координаты передаются парой, почвенная шкала имеет условный диапазон от 0 до 100 и не является VWC без калибровки. DHT не выдаёт эти поля. Данный формат требует температуру и влажность при ok:true; для отдельного GPS-трекера или почвенного датчика нужен явно согласованный адаптер, а не копирование произвольного JSON.
Попробуйте повторить сообщение, затем отправить более старое. Они должны сохранить прежнюю текущую запись. Retained игнорируются; данные старше часа и более чем на пять секунд из будущего отклоняются. Эта схема рассчитана на синхронизированные часы и измерения реже раза в секунду.
server/lib/domain.mjs
const idPattern = /^[a-z][a-z0-9_-]{0,23}$/;
const finite = x => typeof x === 'number' && Number.isFinite(x);
const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
export function validateDevices(input) {
if (!Array.isArray(input) || !input.length || input.length > 32) throw Error('Configure 1..32 devices');
const ids = new Set();
return input.map(x => {
if (!object(x) || typeof x.id !== 'string' || !idPattern.test(x.id) || ids.has(x.id) ||
typeof x.name !== 'string' || !x.name.trim() || x.name.length > 80 ||
!Number.isInteger(x.staleSeconds) || x.staleSeconds < 30 || x.staleSeconds > 3600)
throw Error('Invalid device settings');
ids.add(x.id);
const t = x.temperature;
if (!object(t) || !finite(t.low) || !finite(t.high) || !finite(t.hysteresis) ||
t.low < -40 || t.high > 80 || t.hysteresis <= 0 || t.low + 2 * t.hysteresis >= t.high)
throw Error('Invalid temperature limits');
const h = x.humidity;
if (!object(h) || !finite(h.high) || !finite(h.hysteresis) ||
h.high > 100 || h.high <= 0 || h.hysteresis <= 0 || h.hysteresis >= h.high)
throw Error('Invalid humidity limits');
return { id: x.id, name: x.name.trim(), staleSeconds: x.staleSeconds,
temperature: { low: t.low, high: t.high, hysteresis: t.hysteresis },
humidity: { high: h.high, hysteresis: h.hysteresis } };
});
}
export function parseReading(topic, payload, retained, devices, now = Date.now()) {
const match = /^ferma\/([a-z][a-z0-9_-]{0,23})\/data$/.exec(topic);
if (!match || !devices.some(x => x.id === match[1]) || retained) return null;
if (payload.length > 1024) throw Error('Payload too large');
const d = JSON.parse(payload.toString('utf8'));
if (!object(d) || typeof d.boot !== 'string' || !/^[a-f0-9]{32}$/.test(d.boot) ||
!Number.isSafeInteger(d.sampled_at) || typeof d.ok !== 'boolean' ||
d.sampled_at * 1000 > now + 5000 || d.sampled_at * 1000 < now - 3600000)
throw Error('Invalid measurement identity or timestamp');
const known = new Set(['boot', 'sampled_at', 'ok', 'temp', 'hum', 'soilMoisture', 'lat', 'lng']);
if (Object.keys(d).some(k => !known.has(k))) throw Error('Unknown measurement field');
if (d.ok && (!finite(d.temp) || d.temp < -40 || d.temp > 80 || !finite(d.hum) || d.hum < 0 || d.hum > 100))
throw Error('Invalid temperature or humidity');
if (!d.ok && ['temp', 'hum', 'soilMoisture', 'lat', 'lng'].some(k => k in d))
throw Error('Failed measurement must omit readings');
const optional = (key, min, max) => {
if (!(key in d)) return null;
if (!finite(d[key]) || d[key] < min || d[key] > max) throw Error('Invalid optional measurement');
return d[key];
};
const soilMoisture = optional('soilMoisture', 0, 100);
const lat = optional('lat', -90, 90), lng = optional('lng', -180, 180);
if ((lat === null) !== (lng === null)) throw Error('Coordinates require both latitude and longitude');
return { id: match[1], boot: d.boot, sampledAt: d.sampled_at * 1000, receivedAt: now,
ok: d.ok, temp: d.ok ? d.temp : null, hum: d.ok ? d.hum : null, soilMoisture, lat, lng };
}
export function alarmFlags(reading, config, previous = {}) {
if (!reading.ok) return { cold: false, hot: false, wet: false };
const t = config.temperature, h = config.humidity;
return {
cold: previous.cold ? reading.temp < t.low + t.hysteresis : reading.temp < t.low,
hot: previous.hot ? reading.temp > t.high - t.hysteresis : reading.temp > t.high,
wet: previous.wet ? reading.hum > h.high - h.hysteresis : reading.hum > h.high
};
}
export function deviceView(config, row, now = Date.now()) {
const base = { id: config.id, name: config.name, staleSeconds: config.staleSeconds };
if (!row) return { ...base, state: 'no_data', reading: null, ageSeconds: null, flags: {} };
const ageSeconds = (now - row.sampledAt) / 1000;
let state;
if (ageSeconds < -5 || now < row.receivedAt - 5000) state = 'clock_error';
else if (ageSeconds > config.staleSeconds) state = 'stale';
else if (row.invalidAt && row.invalidAt >= row.receivedAt) state = 'invalid';
else if (!row.ok) state = 'sensor_error';
else state = Object.values(row.flags).some(Boolean) ? 'alert' : 'ok';
return { ...base, state, reading: row, ageSeconds: Math.max(0, Math.floor(ageSeconds)), flags: row.flags };
}
Сохраняем историю между перезапусками
SQLite хранит текущие записи, историю, события и подписки. Используются транзакции, параметризованные запросы, журнал DELETE и synchronous=FULL. Каталог данных должен находиться на постоянном локальном накопителе. Очистка оставляет до семи суток истории, но не более 200000 записей всех устройств вместе; при большой частоте лимит сокращает доступный период. Событий хранится не более 500, страница показывает последние 100. Выберите частоту передачи с учётом этих ограничений.
Перезапустите приложение и посмотрите на возраст текущего показания: он продолжает отсчитываться от sampled_at. При ошибке записи сервис переходит в состояние ошибки. Пороговые переходы используют гистерезис — понаблюдайте, как он удерживает состояние возле границы.
server/lib/store.mjs
import Database from 'better-sqlite3';
import { randomBytes } from 'node:crypto';
import { alarmFlags, deviceView } from './domain.mjs';
export class Store {
constructor(path, devices) {
this.devices = devices;
this.db = new Database(path);
this.db.pragma('journal_mode = DELETE');
this.db.pragma('synchronous = FULL');
this.db.pragma('busy_timeout = 3000');
this.db.exec(`
CREATE TABLE IF NOT EXISTS current (id TEXT PRIMARY KEY, row TEXT, invalid_at INTEGER, signature TEXT);
CREATE TABLE IF NOT EXISTS readings (id TEXT, at INTEGER, row TEXT, PRIMARY KEY(id,at));
CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, device TEXT, at INTEGER, text TEXT, delivery TEXT);
CREATE TABLE IF NOT EXISTS subscriptions (endpoint TEXT PRIMARY KEY, value TEXT);
CREATE INDEX IF NOT EXISTS readings_at ON readings(at);
CREATE INDEX IF NOT EXISTS events_at ON events(at);
`);
for (const d of devices) this.db.prepare('INSERT OR IGNORE INTO current(id) VALUES(?)').run(d.id);
}
view(d, now) {
const c = this.db.prepare('SELECT * FROM current WHERE id=?').get(d.id);
const row = c?.row ? JSON.parse(c.row) : null;
if (row) row.invalidAt = c.invalid_at;
const v = deviceView(d, row, now);
if (!row && c?.invalid_at) v.state = 'invalid';
return v;
}
views(now) { return this.devices.map(d => this.view(d, now)); }
invalid(id, now) {
this.db.prepare('UPDATE current SET invalid_at=? WHERE id=?').run(now,id);
}
ingest(r) {
return this.db.transaction(() => {
const c = this.db.prepare('SELECT row FROM current WHERE id=?').get(r.id);
if (!c) throw Error('Unknown device');
const old = c.row ? JSON.parse(c.row) : null;
// A reboot or repeated delivery must not make an older sample current.
if (old && r.sampledAt <= old.sampledAt) return false;
const config = this.devices.find(d => d.id === r.id);
r.flags = alarmFlags(r, config, old?.flags);
const value = JSON.stringify(r);
this.db.prepare('INSERT INTO readings VALUES(?,?,?)').run(r.id,r.sampledAt,value);
this.db.prepare('UPDATE current SET row=?,invalid_at=NULL WHERE id=?').run(value,r.id);
this.trim(r.receivedAt);
return true;
})();
}
trim(now) {
this.db.prepare('DELETE FROM readings WHERE at<?').run(now-7*86400000);
this.db.exec('DELETE FROM readings WHERE rowid IN (SELECT rowid FROM readings ORDER BY at DESC LIMIT -1 OFFSET 200000)');
this.db.prepare('DELETE FROM events WHERE at<?').run(now-7*86400000);
this.db.exec('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY at DESC LIMIT -1 OFFSET 500)');
}
transitions(now) {
return this.db.transaction(() => {
this.trim(now);
const results=[];
const labels={ok:'показания в заданных пределах',alert:'выход за заданные пределы',stale:'нет свежего измерения',
no_data:'измерений ещё нет',invalid:'некорректное сообщение',sensor_error:'датчик сообщил об ошибке',clock_error:'ошибка времени'};
for (const d of this.devices) {
const v=this.view(d,now), c=this.db.prepare('SELECT signature FROM current WHERE id=?').get(d.id);
const signature=JSON.stringify([v.state,v.state==='alert'?v.flags:null]);
if (c.signature===signature) continue;
this.db.prepare('UPDATE current SET signature=? WHERE id=?').run(signature,d.id);
if (c.signature===null && ['ok','no_data'].includes(v.state)) continue;
const event={id:randomBytes(16).toString('hex'),device:d.id,at:now,text:`${d.name}: ${labels[v.state]}`};
this.db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(event.id,event.device,event.at,event.text,'pending');
results.push(event);
}
this.trim(now);
return results;
})();
}
history(id,hours,now) {
if (!this.devices.some(d=>d.id===id) || !Number.isInteger(hours) || hours<1 || hours>168) throw Error('Invalid history query');
const rows=this.db.prepare('SELECT at,row FROM readings WHERE id=? AND at>=? AND at<=? ORDER BY at').all(id,now-hours*3600000,now);
// Return hourly averages and explicit empty buckets, not a continuous line over gaps.
const buckets=new Map();
const start=Math.floor((now-hours*3600000)/3600000)*3600000;
for(let at=start;at<=now;at+=3600000) buckets.set(at,{at,temp:null,hum:null,soilMoisture:null,count:0});
const sums=new Map();
for(const x of rows){const r=JSON.parse(x.row);if(!r.ok)continue;const at=Math.floor(x.at/3600000)*3600000;
const s=sums.get(at)||{temp:0,hum:0,soil:0,n:0,soilN:0};s.temp+=r.temp;s.hum+=r.hum;s.n++;
if(r.soilMoisture!==null){s.soil+=r.soilMoisture;s.soilN++;}sums.set(at,s);}
for(const [at,s]of sums){const b=buckets.get(at);if(b)Object.assign(b,{temp:s.temp/s.n,hum:s.hum/s.n,soilMoisture:s.soilN?s.soil/s.soilN:null,count:s.n});}
return [...buckets.values()];
}
events(){return this.db.prepare('SELECT * FROM events ORDER BY at DESC LIMIT 100').all();}
pending(now){
this.db.prepare("UPDATE events SET delivery='expired' WHERE delivery='pending' AND at<?").run(now-600000);
return this.db.prepare("SELECT * FROM events WHERE delivery='pending' ORDER BY at LIMIT 1").all();
}
delivered(id,status){this.db.prepare('UPDATE events SET delivery=? WHERE id=?').run(status,id);}
subscribe(s){
if (!this.db.prepare('SELECT endpoint FROM subscriptions WHERE endpoint=?').get(s.endpoint) &&
this.db.prepare('SELECT count(*) AS n FROM subscriptions').get().n>=16) throw Error('Subscription limit');
this.db.prepare('INSERT OR REPLACE INTO subscriptions VALUES(?,?)').run(s.endpoint,JSON.stringify(s));
}
subscriptions(){return this.db.prepare('SELECT value FROM subscriptions').all().map(x=>JSON.parse(x.value));}
unsubscribe(endpoint){this.db.prepare('DELETE FROM subscriptions WHERE endpoint=?').run(endpoint);}
close(){this.db.close();}
}
Просим браузер сообщить об изменении
Нажмите кнопку подписки и разрешите уведомления. Web Push использует Service Worker и push-службу браузера или ОС. Проверьте свой телефон, браузер и установленное веб-приложение: этот опыт покажет, где появляется сообщение и какие условия нужны для его доставки.
В примере разрешены только перечисленные в модуле узлы push-служб. Неизвестный endpoint отклоняется, а не превращает сервер в средство обращения к произвольному адресу. Подписей допускается не более 16. Ответ 404 или 410 удаляет устаревшую подписку. Принятие push-службой не подтверждает показ или прочтение.
server/lib/push.mjs
import webpush from 'web-push';
export function checkSubscription(s) {
if (!s || typeof s.endpoint !== 'string' || s.endpoint.length > 2048) throw Error('Invalid subscription');
const u = new URL(s.endpoint);
const allowed = ['fcm.googleapis.com', 'updates.push.services.mozilla.com', 'web.push.apple.com'];
if (u.protocol !== 'https:' || !allowed.includes(u.hostname) || u.port || u.username || u.password || u.hash)
throw Error('Push host is not allowed');
for (const [key, bytes] of [['p256dh', 65], ['auth', 16]]) {
const value = s.keys?.[key];
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(value) ||
Buffer.from(value, 'base64url').length !== bytes) throw Error('Invalid push key');
}
return { endpoint: u.href, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } };
}
export function pushSender(subscription, vapidDetails, remove, fetcher = fetch) {
const sub = checkSubscription(subscription);
return async event => {
const ttl = Math.max(0, Math.floor((event.expiresAt - Date.now()) / 1000));
if (!ttl) throw Error('Expired event');
const request = webpush.generateRequestDetails(sub, JSON.stringify({ text: event.text, id: event.id }),
{ vapidDetails, TTL: ttl, urgency: 'normal', topic: event.id });
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 10000);
try {
const r = await fetcher(request.endpoint, { method: request.method, headers: request.headers,
body: request.body, signal: controller.signal, redirect: 'error' });
await r.body?.cancel();
if ([404, 410].includes(r.status)) { await remove(); return { accepted: false, expired: true }; }
if (r.status !== 201) throw Error('Push service did not accept message');
return { accepted: true };
} finally { clearTimeout(timer); }
};
}
Состояния проверяются каждые десять секунд. У события десять минут актуальности, один проход по подпискам и десятисекундный тайм-аут отдельного запроса. После неудачи результат остаётся в журнале без автоматического повтора. При сбое между отправкой и сохранением возможен дубликат после запуска. Изучите эти ситуации, используя push как дополнительный канал.
Запускайте ровно один экземпляр процесса с этим каталогом. Файл instance.lock предотвращает случайный второй запуск. После аварии его удаляют только убедившись, что прежний процесс остановлен; затем проверяют состояние базы и запускают приложение снова. Несколько процессов PM2 или кластер с общей SQLite в этом примере не предусмотрены.
server/lib/runtime.mjs
import mqtt from 'mqtt';
import {readFileSync,mkdirSync,openSync,writeFileSync,closeSync,unlinkSync} from 'node:fs';
import {resolve,join} from 'node:path';
import {validateDevices,parseReading} from './domain.mjs';
import {Store} from './store.mjs';
import {checkSubscription,pushSender} from './push.mjs';
let active;
export function start(env=process.env) {
if(active) throw Error('One server instance only');
if(!/^[a-f0-9]{64}$/.test(env.DASH_TOKEN||'')) throw Error('Set DASH_TOKEN to 64 random hex characters');
const origin=new URL(env.DASH_ORIGIN||'');
if(origin.origin!==env.DASH_ORIGIN || (origin.protocol!=='https:' &&
!(origin.protocol==='http:' && ['127.0.0.1','localhost'].includes(origin.hostname)))) throw Error('Invalid DASH_ORIGIN');
const url=new URL(env.MQTT_URL||'');
if(url.protocol!=='mqtts:' || url.username || url.password || !env.MQTT_USERNAME || !env.MQTT_PASSWORD || !env.MQTT_CA_FILE)
throw Error('MQTT requires TLS, CA and credentials');
const devices=validateDevices(JSON.parse(readFileSync(env.DEVICES_FILE||'./devices.json','utf8')));
const ca=readFileSync(env.MQTT_CA_FILE);
const vapid=env.VAPID_PUBLIC_KEY||env.VAPID_PRIVATE_KEY ? {subject:env.VAPID_SUBJECT,publicKey:env.VAPID_PUBLIC_KEY,privateKey:env.VAPID_PRIVATE_KEY}:null;
if(vapid && (!/^mailto:[^\s@]+@[^\s@]+$/.test(vapid.subject||'') || Buffer.from(vapid.publicKey||'','base64url').length!==65 ||
Buffer.from(vapid.privateKey||'','base64url').length!==32)) throw Error('Invalid VAPID configuration');
process.umask(0o077);
const dir=resolve(env.DATA_DIR||'./data');mkdirSync(dir,{recursive:true,mode:0o700});
const lock=join(dir,'instance.lock'),fd=openSync(lock,'wx',0o600);writeFileSync(fd,String(process.pid));closeSync(fd);
let db,client,timer,busy=false,stopping=false;
const state={connected:false,fatal:false};
const fail=()=>{state.fatal=true;state.connected=false;client?.end(true);console.error('Dashboard storage failure; restart after repair');};
async function tick(){
if(busy||stopping||state.fatal)return;busy=true;
try{
db.transitions(Date.now());
for(const event of db.pending(Date.now())){
const subs=db.subscriptions();
if(!vapid||!subs.length){db.delivered(event.id,'not_configured');continue;}
let failures=0;
for(const sub of subs){
try{const result=await pushSender(sub,vapid,()=>db.unsubscribe(sub.endpoint))({id:event.id,text:event.text,expiresAt:event.at+600000});
if(!result.accepted)failures++;}
catch{failures++;}
}
db.delivered(event.id,failures?'failed_or_partial':'accepted_by_service');
}
}catch{fail();}finally{busy=false;}
}
try{
db=new Store(join(dir,'dashboard.sqlite'),devices);
client=mqtt.connect(env.MQTT_URL,{username:env.MQTT_USERNAME,password:env.MQTT_PASSWORD,ca,rejectUnauthorized:true,
protocolVersion:4,clean:true,clientId:'dashboard-'+process.pid,connectTimeout:10000,reconnectPeriod:5000,queueQoSZero:false});
client.on('connect',()=>client.subscribe(devices.map(d=>`ferma/${d.id}/data`),{qos:1},err=>{state.connected=!err;}));
for(const name of ['offline','close','error'])client.on(name,()=>{state.connected=false;});
client.on('message',(topic,payload,packet)=>{
if(state.fatal||stopping||packet.retain)return;
const match=/^ferma\/([a-z][a-z0-9_-]{0,23})\/data$/.exec(topic);
if(!match||!devices.some(d=>d.id===match[1]))return;
let reading;
try{reading=parseReading(topic,payload,packet.retain,devices);}
catch{try{db.invalid(match[1],Date.now());}catch{fail();}return;}
try{if(reading)db.ingest(reading);}catch{fail();}
});
timer=setInterval(tick,10000);timer.unref();
active={db,devices,state,token:env.DASH_TOKEN,origin:origin.origin,publicKey:vapid?.publicKey||null,
subscribe:s=>db.subscribe(checkSubscription(s)),
close:async()=>{stopping=true;clearInterval(timer);await client.endAsync(true);
while(busy)await new Promise(r=>setTimeout(r,25));db.close();unlinkSync(lock);active=undefined;}};
return active;
}catch(error){clearInterval(timer);client?.end(true);db?.close();unlinkSync(lock);throw error;}
}
export function service(){if(!active)throw Error('Dashboard not started');return active;}
server/plugins/dashboard.ts
import { start } from '../lib/runtime.mjs';
export default defineNitroPlugin(nitro => {
const dashboard = start();
nitro.hooks.hook('close', () => dashboard.close());
});
Открываем данные ключом владельца
Все пути /api/ требуют Bearer-ключ. Для изменяющих запросов проверяется точный Origin; CORS для других сайтов не открывается. Подписка ограничена размером тела и проверяется перед сохранением. Разметка страницы доступна без ключа, но данные устройств — нет. Это доступ одного владельца, не система ролей для работников. Ключ хранится только в памяти открытой страницы, повторный вход нужен после перезагрузки.
server/middleware/access.ts
import { timingSafeEqual } from 'node:crypto';
import { service } from '../lib/runtime.mjs';
export default defineEventHandler(event => {
if (!event.path.startsWith('/api/')) return;
const s=service(), header=getHeader(event,'authorization')||'';
const token=header.startsWith('Bearer ')?header.slice(7):'';
if (!/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(token),Buffer.from(s.token)))
throw createError({statusCode:401,statusMessage:'Authentication required'});
setHeader(event,'Cache-Control','no-store');
if (event.method!=='GET' && getHeader(event,'origin')!==s.origin)
throw createError({statusCode:403,statusMessage:'Origin rejected'});
if(s.state.fatal) throw createError({statusCode:503,statusMessage:'Storage unavailable'});
});
server/api/[...path].ts
import { service } from '../lib/runtime.mjs';
export default defineEventHandler(async event => {
const s=service(),path=event.path.split('?')[0];
if(event.method==='GET' && path==='/api/devices')return {now:Date.now(),connected:s.state.connected,devices:s.db.views(Date.now()),publicKey:s.publicKey};
if(event.method==='GET' && path==='/api/alerts')return s.db.events();
if(event.method==='GET' && path==='/api/history'){
const q=getQuery(event);
if(typeof q.device!=='string' || typeof q.hours!=='string' || !/^\d{1,3}$/.test(q.hours))
throw createError({statusCode:400,statusMessage:'Invalid query'});
try{return s.db.history(q.device,Number(q.hours),Date.now());}
catch{throw createError({statusCode:400,statusMessage:'Invalid history query'});}
}
if(event.method==='POST' && ['/api/push/subscribe','/api/push/unsubscribe'].includes(path)){
if(path.endsWith('/subscribe')&&!s.publicKey)throw createError({statusCode:409,statusMessage:'Push not configured'});
if(!getHeader(event,'content-type')?.startsWith('application/json'))throw createError({statusCode:415,statusMessage:'JSON required'});
const length=Number(getHeader(event,'content-length'));
if(!Number.isInteger(length)||length<1||length>16384)throw createError({statusCode:413,statusMessage:'Body limit'});
const chunks=[];let size=0;
for await(const chunk of event.node.req){size+=chunk.length;if(size>16384)throw createError({statusCode:413,statusMessage:'Body limit'});chunks.push(chunk);}
try{const sub=JSON.parse(Buffer.concat(chunks).toString('utf8'));
if(path.endsWith('/unsubscribe')){if(typeof sub?.endpoint!=='string'||sub.endpoint.length>2048)throw Error();s.db.unsubscribe(sub.endpoint);return {removed:true};}
s.subscribe(sub);return {stored:true};}
catch{throw createError({statusCode:400,statusMessage:'Invalid subscription or storage failure'});}
}
throw createError({statusCode:404,statusMessage:'Not found'});
});
Смотрим карточки и листаем историю
После входа страница загружает данные и делает паузу 15 секунд до следующего опроса. Попробуйте отключить API: появится предупреждение, а старые значения сохранят отметку своего состояния. Время показано в часовом поясе браузера; фоновая вкладка может обновляться реже.
На графике у температуры и влажности свои шкалы. Найдите одиночную точку и промежуток без измерений: пропуски остаются видимыми. Chart.js работает на клиенте и освобождает график при удалении компонента. Почвенный индекс и координаты доступны в карточке; карту можно добавить отдельным этапом.
app/app.vue
<template>
<main>
<h1>Наблюдения на ферме</h1>
<p>Только просмотр. Пороговые значения задаются для каждой точки.</p>
<form v-if="!loggedIn" @submit.prevent="login">
<label>Ключ доступа <input v-model="token" type="password" autocomplete="off" required></label>
<button :disabled="loading">Открыть</button>
</form>
<template v-else>
<button @click="logout">Выйти</button>
<button :disabled="pushBusy || !publicKey" @click="enablePush">Включить уведомления</button>
<button :disabled="pushBusy" @click="disablePush">Отключить уведомления</button>
<p v-if="!publicKey">Web Push не настроен на сервере.</p>
<p>{{ pushMessage }}</p>
<p v-if="!connected" role="alert">Нет подтверждённого подключения к MQTT. Показаны последние сохранённые данные.</p>
<p>Последняя успешная загрузка: {{ loadedAt ? new Date(loadedAt).toLocaleString() : 'ещё не было' }}</p>
<section aria-label="Устройства">
<DeviceCard v-for="device in devices" :key="device.id" :device="device" :api-error="!!error" @select="selected=device.id" />
</section>
<label>История устройства <select v-model="selected"><option value="">Выберите точку</option><option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }}</option></select></label>
<div v-if="selected">
<h2>Последние семь суток: средние за час</h2>
<p>Пустые часы показаны разрывами. Время отображается в часовом поясе браузера; ошибки датчика не включаются в среднее.</p>
<p v-if="historyError" role="alert">{{ historyError }}</p>
<ClientOnly><TempChart :rows="history" /></ClientOnly>
</div>
<AlertLog :rows="alerts" />
</template>
<p v-if="error" role="alert">{{ error }} Последние значения не подтверждают текущее состояние.</p>
</main>
</template>
<script setup>
const token=ref(''),loggedIn=ref(false),loading=ref(false),error=ref('');
const devices=ref([]),alerts=ref([]),selected=ref(''),history=ref([]),historyError=ref('');
const connected=ref(false),loadedAt=ref(null),publicKey=ref(null),pushMessage=ref(''),pushBusy=ref(false);
let timer,stopped=false,generation=0,historyVersion=0;
async function api(path,options={}){
const r=await fetch(path,{...options,headers:{Authorization:'Bearer '+token.value,...options.headers},cache:'no-store',signal:AbortSignal.timeout(12000)});
if(!r.ok)throw Error(r.status===401?'Неверный ключ доступа':'Сервер не ответил успешно');
return r.json();
}
async function refresh(){
if(stopped||!loggedIn.value)return;
const current=generation;
try{const [state,events]=await Promise.all([api('/api/devices'),api('/api/alerts')]);
if(current!==generation)return;
devices.value=state.devices;alerts.value=events;connected.value=state.connected;publicKey.value=state.publicKey;
loadedAt.value=Date.now();error.value='';await loadHistory();
}catch(e){if(current===generation)error.value=e.message;}
finally{if(!stopped&&loggedIn.value&¤t===generation)timer=setTimeout(refresh,15000);}
}
async function loadHistory(){
const version=++historyVersion,id=selected.value,current=generation;
if(!id){history.value=[];return;}
try{const rows=await api('/api/history?device='+encodeURIComponent(id)+'&hours=168');
if(version===historyVersion&¤t===generation){history.value=rows;historyError.value='';}}
catch{if(version===historyVersion&¤t===generation){history.value=[];historyError.value='Не удалось загрузить историю';}}
}
watch(selected,()=>{history.value=[];loadHistory();});
async function login(){
if(loading.value)return;loading.value=true;error.value='';
try{await api('/api/devices');loggedIn.value=true;generation++;refresh();}
catch(e){error.value=e.message;}finally{loading.value=false;}
}
function logout(){generation++;historyVersion++;clearTimeout(timer);loggedIn.value=false;token.value='';devices.value=[];alerts.value=[];selected.value='';history.value=[];publicKey.value=null;loadedAt.value=null;pushMessage.value='';error.value='';}
async function registration(){
if(!window.isSecureContext||!('serviceWorker'in navigator)||!('PushManager'in window)||!('Notification'in window))throw Error('Web Push недоступен в этом браузере или контексте');
await navigator.serviceWorker.register('/sw.js',{scope:'/'});
return navigator.serviceWorker.ready;
}
function keyBytes(key){const raw=atob(key.replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(key.length/4)*4,'='));return Uint8Array.from(raw,c=>c.charCodeAt(0));}
async function enablePush(){
pushBusy.value=true;
try{
if(!('Notification'in window))throw Error('Уведомления не поддерживаются');
// Called directly from a button; permission is not requested during page loading.
if(await Notification.requestPermission()!=='granted')throw Error('Разрешение на уведомления не получено');
const reg=await registration(),key=keyBytes(publicKey.value);let sub=await reg.pushManager.getSubscription();
if(sub){const old=new Uint8Array(sub.options.applicationServerKey||[]);
if(old.length!==key.length||old.some((x,i)=>x!==key[i]))throw Error('Ключ сервера изменился: отключите старую подписку и включите заново');}
sub ||= await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:key});
await api('/api/push/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(sub.toJSON())});
pushMessage.value='Подписка сохранена. Доставка зависит от браузера и push-службы.';
}catch(e){pushMessage.value=e.message;}finally{pushBusy.value=false;}
}
async function disablePush(){
pushBusy.value=true;
try{const reg=await registration(),sub=await reg.pushManager.getSubscription();
if(sub){await api('/api/push/unsubscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(sub.toJSON())});
if(!await sub.unsubscribe())throw Error('Серверная подписка удалена; браузер не подтвердил отключение');}
pushMessage.value='Подписка отключена.';
}catch(e){pushMessage.value=e.message;}finally{pushBusy.value=false;}
}
onBeforeUnmount(()=>{stopped=true;generation++;historyVersion++;clearTimeout(timer);});
</script>
<style>
body{font-family:system-ui,sans-serif;max-width:1100px;margin:24px auto;padding:0 16px;color:#172b24}button,input,select{font:inherit;margin:6px;padding:8px}section{display:flex;flex-wrap:wrap;gap:12px}p[role=alert]{color:#8b210d;font-weight:600}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:8px;text-align:left}canvas{max-height:320px}
</style>
app/components/DeviceCard.vue
<template>
<article :class="apiError ? 'invalid' : device.state">
<h2>{{ device.name }}</h2>
<p><strong>{{ apiError ? 'Ошибка обновления страницы' : labels[device.state] || 'Неизвестное состояние' }}</strong></p>
<template v-if="device.reading">
<p>Измерение: {{ new Date(device.reading.sampledAt).toLocaleString() }}</p>
<p>На момент загрузки возраст: {{ device.ageSeconds }} с</p>
<p v-if="device.reading.ok">{{ device.reading.temp }} °C · {{ device.reading.hum }} % RH</p>
<p v-if="device.reading.soilMoisture !== null">Шкала датчика почвы: {{ device.reading.soilMoisture }} % (не VWC без калибровки)</p>
<p v-if="device.reading.lat !== null">Координаты: {{ device.reading.lat }}, {{ device.reading.lng }}</p>
<p v-if="device.state==='alert'">{{ activeFlags }}</p>
</template>
<button @click="$emit('select')">Показать историю</button>
</article>
</template>
<script setup>
const props=defineProps({device:{type:Object,required:true},apiError:Boolean});defineEmits(['select']);
const labels={ok:'В заданных пределах',alert:'Выход за пределы',stale:'Данные устарели',no_data:'Нет измерений',invalid:'Некорректные данные',sensor_error:'Ошибка датчика',clock_error:'Ошибка времени'};
const activeFlags=computed(()=>Object.entries(props.device.flags).filter(([,v])=>v).map(([k])=>({cold:'температура ниже заданной',hot:'температура выше заданной',wet:'влажность выше заданной'}[k])).join('; '));
</script>
<style scoped>
article{border:2px solid #777;padding:12px;border-radius:8px;flex:1 1 280px}.ok{border-color:#237b45}.alert{border-color:#a32b14}.stale,.invalid,.sensor_error,.clock_error{border-color:#9b6811}h2{font-size:1.1rem}
</style>
app/components/TempChart.vue
<template><div><p v-if="!rows.some(r=>r.count)">В выбранном периоде нет пригодных измерений.</p><canvas ref="canvas" role="img" aria-label="Графики температуры и влажности"></canvas></div></template>
<script setup>
import Chart from 'chart.js/auto';
const props=defineProps({rows:{type:Array,default:()=>[]}}),canvas=ref(null);let chart;
function draw(){if(!canvas.value)return;chart?.destroy();chart=new Chart(canvas.value,{type:'line',data:{labels:props.rows.map(r=>new Date(r.at).toLocaleString()),datasets:[
{label:'Температура, °C',data:props.rows.map(r=>r.temp),borderColor:'#a32b14',yAxisID:'t',spanGaps:false,pointRadius:3},
{label:'Влажность воздуха, % RH',data:props.rows.map(r=>r.hum),borderColor:'#205aaa',yAxisID:'h',spanGaps:false,pointRadius:3}
]},options:{animation:false,responsive:true,scales:{t:{type:'linear',position:'left'},h:{type:'linear',position:'right',min:0,max:100,grid:{drawOnChartArea:false}}}}});}
onMounted(draw);watch(()=>props.rows,draw);onBeforeUnmount(()=>chart?.destroy());
</script>
app/components/AlertLog.vue
<template>
<div><h2>Последние события</h2><p v-if="!rows.length">Событий пока нет.</p>
<table v-else><thead><tr><th>Время</th><th>Событие</th><th>Web Push</th></tr></thead><tbody>
<tr v-for="r in rows" :key="r.id"><td>{{ new Date(r.at).toLocaleString() }}</td><td>{{ r.text }}</td><td>{{ labels[r.delivery] || r.delivery }}</td></tr>
</tbody></table><p>Принятие сообщения push-службой не подтверждает показ на телефоне или прочтение.</p></div>
</template>
<script setup>
defineProps({rows:{type:Array,default:()=>[]}});
const labels={pending:'Ожидает попытки',expired:'Срок истёк',not_configured:'Нет настроенного канала',failed_or_partial:'Ошибка или частичная отправка',accepted_by_service:'Принято push-службой'};
</script>
public/sw.js
self.addEventListener('push',event=>{
let data={};try{data=event.data?.json()||{};}catch{}
const body=typeof data.text==='string'?data.text.slice(0,500):'Откройте журнал фермы';
event.waitUntil(self.registration.showNotification('Наблюдения на ферме',{body,tag:typeof data.id==='string'?data.id:'farm'}));
});
self.addEventListener('notificationclick',event=>{
event.notification.close();event.waitUntil(clients.openWindow('/'));
});
public/manifest.json
{"name":"Наблюдения на ферме","short_name":"Ферма","start_url":"/","scope":"/","display":"standalone","lang":"ru"}
Запускаем локально и готовим постоянный адрес
Сначала проверьте устройства и права брокера, затем откройте локальный стенд. Для разработки Nuxt читает .env; для собранного сервера файл передаётся Node.js явно. Запускайте команды из каталога проекта, поскольку пути к настройкам и данным в примере относительные.
# Разработка после заполнения .env
npm run dev
# Сборка и один рабочий процесс
npm run build
node --env-file=.env .output/server/index.mjs
В документации Nuxt описан запуск Node-сервера. Здесь .env задаёт NITRO_HOST=127.0.0.1, поэтому внешний доступ организуется через HTTPS-прокси. Следующий фрагмент nginx предполагает уже выпущенный сертификат для вашего домена; example.com и пути нужно заменить. Проверьте конфигурацию перед её применением. Само добавление фрагмента сертификат не выпускает.
server {
listen 443 ssl;
server_name farm.example.com;
ssl_certificate /etc/letsencrypt/live/farm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/farm.example.com/privkey.pem;
client_max_body_size 16k;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
}
Настройте выпуск и продление сертификата подходящим для вашего домена способом, защиту хоста и резервное копирование. Для простой согласованной копии SQLite остановите единственный процесс и скопируйте каталог данных. .env храните отдельно с ограниченным доступом. После восстановления проверьте возраст измерений и доставку тестового уведомления. Замена VAPID-ключей требует пересоздать браузерные подписки.
Первый день с собственной страницей
Начните с одного датчика. Сравните его сообщение с карточкой и историей, затем проверьте повтор, старое время, ошибку чтения и остановку связи. Перезапустите сервер, вернитесь к графику и испытайте уведомление на телефоне. Запишите, чего не хватает именно для ваших повседневных наблюдений.
- Проверьте соответствие идентификаторов, единиц и времени; неверный ключ не должен открывать данные.
- Отправьте нормальное измерение, отказ датчика, повтор и повреждённый JSON; проверьте различие состояний.
- Отключите MQTT и дождитесь устаревания данных. Перезапустите сервер и убедитесь, что время старой записи не обновилось.
- Проверьте свободное место, права каталога, восстановление копии и реакцию на отказ записи на отдельном стенде.
- На своём телефоне проверьте разрешение, подписку, тестовый переход и отказ push-службы. Запись в журнале и прочтение сообщения — разные события.
Если хочется использовать готовые панели Grafana
Grafana поддерживает разные источники данных, включая SQL-базы. Для неё не обязательно одновременно использовать InfluxDB, Prometheus и PromQL. Существует официальный MQTT-плагин; он показывает поток сообщений и сам не хранит историю.
Grafana удобна для готовых средств построения панелей с подходящим хранилищем. Свой Nuxt-проект даёт возможность развивать нужное вам представление данных, если есть время сопровождать код. Сравните эти варианты по задаче, серверу, накопителю, обслуживанию и разработке.
Когда температура и влажность отображаются понятно, выберите следующий источник: почвенный датчик, весы или GPS. Для него согласуйте поля, единицы, время и обработку ошибок, затем проверьте весь путь до страницы. Так панель будет расти вместе с задачами хозяйства.





