В прошлых статьях мы собрали метеостанции, автополив, весы, GPS-трекеры — и объединили всё через MQTT-брокер с VK-ботом. Текстовые команды в мессенджере работают, но когда устройств двадцать — хочется видеть картину целиком: графики за неделю, карту датчиков, историю алертов. Нужен дашборд.
Обычный совет в таких случаях — Grafana. Мощный инструмент, но для фермера избыточный: нужен InfluxDB, нужна настройка data sources, нужно разбираться в PromQL. А ещё Grafana не умеет push-уведомления в браузер и не работает с вашим MQTT напрямую.
Мы сделаем свой дашборд на Nuxt 3 — лёгкий, быстрый, с push-уведомлениями. Разворачивается на том же сервере, где MQTT-брокер. Один npm install — и у вас полноценный мониторинг фермы в браузере.
Что получится
Веб-страница, которая открывается на любом устройстве — телефоне, планшете, компьютере. На ней:
- Карточки устройств — текущие показания каждого датчика (температура, влажность, влажность почвы, координаты). Цвет карточки меняется: зелёный — норма, жёлтый — предупреждение, красный — алерт
- Графики за неделю — линейные графики температуры и влажности для каждого устройства. Видно тренды, аномалии, суточные колебания
- Лог алертов — все события с временными метками. Когда было холодно, когда жарко, когда влажность зашкаливала
- Push-уведомления — браузер покажет нативное уведомление, даже если вкладка в фоне. Не нужен Telegram, не нужен VK — уведомление придёт прямо на телефон
Технологический стек
- Nuxt 3 — фреймворк на Vue.js. SSR, автоматический роутинг, встроенный сервер. Один пакет — и frontend, и backend
- MQTT.js — подключение к MQTT-брокеру из Node.js (серверная часть) и из браузера через WebSocket
- Chart.js — графики. Лёгкая библиотека, рисует линейные графики без лишних зависимостей
- SQLite — хранение истории данных. Файловая база, не нужен отдельный сервер. Для фермерского дашборда — идеально
- Web Push API — нативные push-уведомления в браузере
Бюджет: 0 ₽. Работает на том же сервере, где Mosquitto (из предыдущей статьи).
Шаг 1: создаём проект Nuxt
npx nuxi@latest init farm-dashboard
cd farm-dashboard
npm install mqtt chart.js vue-chartjs better-sqlite3 web-push
Структура проекта:
farm-dashboard/
pages/
index.vue — главная страница с карточками
components/
DeviceCard.vue — карточка устройства
TempChart.vue — график температуры
AlertLog.vue — лог алертов
server/
api/
devices.get.ts — API: список устройств и текущие данные
history.get.ts — API: исторические данные для графиков
alerts.get.ts — API: лог алертов
plugins/
mqtt.ts — MQTT-подписка и запись в SQLite
utils/
db.ts — инициализация SQLite
public/
sw.js — Service Worker для push-уведомлений
Шаг 2: серверная часть — MQTT → SQLite
Серверный плагин подключается к Mosquitto и записывает каждое сообщение в SQLite. Файл server/plugins/mqtt.ts:
import mqtt from 'mqtt'
import { getDb } from '../utils/db'
export default defineNitroPlugin(() => {
const db = getDb()
// Создаём таблицы
db.exec(`
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
temp REAL,
hum REAL,
soil_moisture REAL,
lat REAL,
lng REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
message TEXT NOT NULL,
level TEXT DEFAULT 'warning',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_readings_device
ON readings(device_id, created_at);
`)
const client = mqtt.connect(process.env.MQTT_URL || 'mqtt://localhost:1883')
client.on('connect', () => {
console.log('[MQTT] Подключён к брокеру')
client.subscribe('ferma/#')
})
const insertReading = db.prepare(`
INSERT INTO readings (device_id, temp, hum, soil_moisture, lat, lng)
VALUES (?, ?, ?, ?, ?, ?)
`)
const insertAlert = db.prepare(`
INSERT INTO alerts (device_id, message, level) VALUES (?, ?, ?)
`)
// Храним последние данные в памяти для быстрого доступа
const devices: Record<string, any> = {}
;(globalThis as any).__farmDevices = devices
client.on('message', (topic: string, payload: Buffer) => {
const parts = topic.split('/')
if (parts.length < 3 || parts[0] !== 'ferma') return
const deviceId = parts[1]
const type = parts[2]
if (type === 'data') {
try {
const data = JSON.parse(payload.toString())
devices[deviceId] = { ...data, updatedAt: new Date().toISOString() }
insertReading.run(
deviceId,
data.temp ?? null,
data.hum ?? null,
data.soilMoisture ?? null,
data.lat ?? null,
data.lng ?? null
)
// Проверка порогов → алерт
if (data.temp < 5) {
insertAlert.run(deviceId, `Температура ${data.temp}°C — ниже минимума`, 'critical')
}
if (data.temp > 35) {
insertAlert.run(deviceId, `Температура ${data.temp}°C — перегрев`, 'critical')
}
if (data.hum > 90) {
insertAlert.run(deviceId, `Влажность ${data.hum}% — выше нормы`, 'warning')
}
} catch (e) {
console.error('[MQTT] Parse error:', e)
}
}
})
})
Файл server/utils/db.ts:
import Database from 'better-sqlite3'
import { join } from 'path'
let db: Database.Database | null = null
export function getDb() {
if (!db) {
db = new Database(join(process.cwd(), 'farm-data.sqlite'))
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
}
return db
}
Шаг 3: API-эндпоинты
Файл server/api/devices.get.ts — текущие данные всех устройств:
export default defineEventHandler(() => {
const devices = (globalThis as any).__farmDevices || {}
return Object.entries(devices).map(([id, data]: [string, any]) => ({
id,
...data,
status: getStatus(data),
}))
})
function getStatus(data: any): 'ok' | 'warning' | 'critical' {
if (!data.updatedAt) return 'critical'
const age = Date.now() - new Date(data.updatedAt).getTime()
if (age > 600000) return 'critical' // нет данных 10+ минут
if (data.temp < 5 || data.temp > 35) return 'critical'
if (data.hum > 90) return 'warning'
return 'ok'
}
Файл server/api/history.get.ts — данные за период:
import { getDb } from '../utils/db'
export default defineEventHandler((event) => {
const query = getQuery(event)
const deviceId = query.device as string
const hours = parseInt(query.hours as string) || 168 // неделя
const db = getDb()
const rows = db.prepare(`
SELECT temp, hum, soil_moisture, created_at
FROM readings
WHERE device_id = ? AND created_at > datetime('now', ?)
ORDER BY created_at ASC
`).all(deviceId, `-${hours} hours`)
return rows
})
Шаг 4: фронтенд — карточки и графики
Файл pages/index.vue:
<template>
<div class="dashboard">
<h1>🌾 Дашборд фермы</h1>
<div class="devices-grid">
<DeviceCard
v-for="device in devices"
:key="device.id"
:device="device"
@click="selectedDevice = device.id"
/>
</div>
<div v-if="selectedDevice" class="chart-section">
<h2>📈 {{ selectedDevice }} — неделя</h2>
<TempChart :device-id="selectedDevice" />
</div>
<AlertLog />
</div>
</template>
<script setup>
const selectedDevice = ref(null)
const { data: devices } = useFetch('/api/devices', {
server: false,
lazy: true,
})
// Автообновление каждые 30 секунд
const refreshInterval = setInterval(async () => {
devices.value = await $fetch('/api/devices')
}, 30000)
onBeforeUnmount(() => clearInterval(refreshInterval))
</script>
Компонент components/DeviceCard.vue:
<template>
<div class="device-card" :class="device.status">
<div class="device-name">📍 {{ device.id }}</div>
<div class="device-data">
<span v-if="device.temp != null">🌡 {{ device.temp }}°C</span>
<span v-if="device.hum != null">💧 {{ device.hum }}%</span>
<span v-if="device.soilMoisture != null">🌱 {{ device.soilMoisture }}%</span>
</div>
<div class="device-age">{{ formatAge(device.updatedAt) }}</div>
</div>
</template>
<script setup>
defineProps({ device: Object })
function formatAge(date) {
if (!date) return 'нет данных'
const mins = Math.round((Date.now() - new Date(date).getTime()) / 60000)
if (mins < 1) return 'только что'
if (mins < 60) return mins + ' мин назад'
return Math.round(mins / 60) + ' ч назад'
}
</script>
<style scoped>
.device-card {
padding: 16px;
border-radius: 12px;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.2s;
}
.device-card.ok { border-color: #4CAF50; background: #f1f8e9; }
.device-card.warning { border-color: #FF9800; background: #fff3e0; }
.device-card.critical { border-color: #f44336; background: #ffebee; }
.device-name { font-weight: 700; font-size: 18px; margin-bottom: 8px; }
.device-data { display: flex; gap: 16px; font-size: 20px; }
.device-age { margin-top: 8px; font-size: 13px; color: #888; }
</style>
Шаг 5: Push-уведомления — алерты без мессенджера
Web Push позволяет отправлять уведомления в браузер — даже когда вкладка закрыта. На телефоне выглядят как обычные уведомления приложения.
Генерируем VAPID-ключи:
npx web-push generate-vapid-keys
Сохраняем в .env:
VAPID_PUBLIC_KEY=BLxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_EMAIL=mailto:you@example.com
На фронтенде подписываемся на push (в app.vue или плагине):
if ('serviceWorker' in navigator && 'PushManager' in window) {
const reg = await navigator.serviceWorker.register('/sw.js')
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'ваш_VAPID_PUBLIC_KEY'
})
// Отправляем подписку на сервер
await $fetch('/api/push/subscribe', {
method: 'POST',
body: sub.toJSON()
})
}
На сервере при алерте отправляем push:
import webpush from 'web-push'
webpush.setVapidDetails(
process.env.VAPID_EMAIL,
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
)
// При алерте:
for (const subscription of savedSubscriptions) {
await webpush.sendNotification(subscription, JSON.stringify({
title: '⚠️ Алерт: теплица1',
body: 'Температура 3°C — ниже минимума!',
}))
}
Файл public/sw.js — Service Worker:
self.addEventListener('push', (event) => {
const data = event.data?.json() || {}
event.waitUntil(
self.registration.showNotification(data.title || 'Ферма', {
body: data.body || '',
icon: '/icon-192.png',
badge: '/badge-72.png',
vibrate: [200, 100, 200],
})
)
})
Теперь алерты приходят прямо в браузер — на десктопе и телефоне. Не нужен ни Telegram, ни VK — работает через стандартный Web Push.
Запуск и деплой
# Разработка
npm run dev
# Сборка для продакшна
npm run build
node .output/server/index.mjs
# Или через PM2
pm2 start .output/server/index.mjs --name farm-dashboard
Дашборд запускается на порту 3000. Если на этом же сервере уже работает farm-hub.js из предыдущей статьи — можно поменять порт через PORT=3001.
Для доступа снаружи (с телефона) — настройте nginx как реверс-прокси:
server {
listen 80;
server_name farm.ваш-домен.ru;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Let's Encrypt даст бесплатный SSL-сертификат:
sudo certbot --nginx -d farm.ваш-домен.ru
Сравнение с Grafana: когда что выбирать
Grafana — мощная система для мониторинга серверов, баз данных, Kubernetes. Для фермы — избыточна:
- Grafana: нужен InfluxDB/Prometheus + настройка data sources + PromQL-запросы. Нет push-уведомлений (нужен Alertmanager). Нет возможности отправить команду на устройство
- Свой дашборд: SQLite (0 настройки) + MQTT напрямую + push-уведомления + двусторонняя связь + полный контроль UI. Кода немного, всё понятно, легко дорабатывать
Если у вас 100 серверов и петабайт метрик — Grafana. Если 20 датчиков на ферме и хочется понятный интерфейс — свой дашборд проще и функциональнее.
Итог серии «Клуб самоделкиных»
За серию статей мы прошли путь от одного датчика в теплице до полноценной IoT-инфраструктуры:
- Метеостанция — ESP32 + DHT22, алерты в Telegram
- Автополив — ёмкостной датчик + реле + помпа
- Весы для приёмки — HX711 + тензодатчик + Google Таблица
- GPS-трекер — NEO-6M + SIM800L, маршруты техники
- OTA-обновление — прошивка по Wi-Fi без поездки в поле
- MQTT + VK бот — центральный хаб управления
- Дашборд — веб-интерфейс с графиками и push-уведомлениями
Общий бюджет на всю систему: 15 000–20 000 ₽ на комплектующие + старый ноутбук или Raspberry Pi в качестве сервера. Коммерческий аналог с теми же возможностями стоит от 200 000 ₽.
Весь код из статьи — рабочий. Nuxt 3 проект разворачивается за 10 минут, после этого у вас полноценный дашборд. Дорабатывайте под своё хозяйство — добавляйте новые типы датчиков, графики, кнопки управления. Вопросы — в комментарии.


