Своими рукамипрактическая инструкция

Своя страница теплицы: собираем дашборд на Nuxt

Редакция Фермозавра·12 апреля 2026 г.·20 мин чтения

Откроем в браузере показания датчиков, историю и журнал событий. Пройдём путь от сообщения MQTT до графика и попробуем уведомление на своём телефоне.

Своя страница теплицы: собираем дашборд на Nuxt

Датчики уже присылают сообщения, а хочется открыть одну страницу и посмотреть, что происходит в теплицах. Сделаем такую страницу своими руками: текущие значения, история и события будут собраны рядом. Заодно разберёмся, как сообщение превращается в точку графика.

Это полный лабораторный проект для одного процесса 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&&current===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&&current===generation){history.value=rows;historyError.value='';}}
  catch{if(version===historyVersion&&current===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-ключей требует пересоздать браузерные подписки.

Первый день с собственной страницей

Начните с одного датчика. Сравните его сообщение с карточкой и историей, затем проверьте повтор, старое время, ошибку чтения и остановку связи. Перезапустите сервер, вернитесь к графику и испытайте уведомление на телефоне. Запишите, чего не хватает именно для ваших повседневных наблюдений.

  1. Проверьте соответствие идентификаторов, единиц и времени; неверный ключ не должен открывать данные.
  2. Отправьте нормальное измерение, отказ датчика, повтор и повреждённый JSON; проверьте различие состояний.
  3. Отключите MQTT и дождитесь устаревания данных. Перезапустите сервер и убедитесь, что время старой записи не обновилось.
  4. Проверьте свободное место, права каталога, восстановление копии и реакцию на отказ записи на отдельном стенде.
  5. На своём телефоне проверьте разрешение, подписку, тестовый переход и отказ push-службы. Запись в журнале и прочтение сообщения — разные события.

Если хочется использовать готовые панели Grafana

Grafana поддерживает разные источники данных, включая SQL-базы. Для неё не обязательно одновременно использовать InfluxDB, Prometheus и PromQL. Существует официальный MQTT-плагин; он показывает поток сообщений и сам не хранит историю.

Grafana удобна для готовых средств построения панелей с подходящим хранилищем. Свой Nuxt-проект даёт возможность развивать нужное вам представление данных, если есть время сопровождать код. Сравните эти варианты по задаче, серверу, накопителю, обслуживанию и разработке.

Когда температура и влажность отображаются понятно, выберите следующий источник: почвенный датчик, весы или GPS. Для него согласуйте поля, единицы, время и обработку ошибок, затем проверьте весь путь до страницы. Так панель будет расти вместе с задачами хозяйства.

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи