315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
import mqtt from 'mqtt';
|
|
import { Menu, Zap } from 'lucide-react';
|
|
import Sidebar from '@/components/Sidebar';
|
|
import Header from '@/components/Header';
|
|
import StatCards from '@/components/StatCards';
|
|
import dynamic from 'next/dynamic';
|
|
const VoltageChart = dynamic(() => import('@/components/VoltageChart'), { ssr: false });
|
|
const HistoryPage = dynamic(() => import('@/components/HistoryPage'), { ssr: false });
|
|
import EventLog from '@/components/EventLog';
|
|
import EventLogsPage from '@/components/EventLogsPage';
|
|
import SettingsPanel from '@/components/SettingsPanel';
|
|
import Login from '@/components/Login';
|
|
import styles from './page.module.css';
|
|
import { insertHistoryPoint, insertEvent, fetchHistory, fetchEvents } from '@/lib/db';
|
|
import type { HistoryRow, EventRow } from '@/lib/db';
|
|
|
|
export interface UpsData {
|
|
success: number;
|
|
on: number;
|
|
state: number;
|
|
v: string;
|
|
batt: number;
|
|
battP: number;
|
|
line: number;
|
|
charging: number;
|
|
chgError: number;
|
|
uptime?: number;
|
|
auto?: number;
|
|
battBlink?: number;
|
|
battCritical?: number;
|
|
chargeLv?: number;
|
|
battMax?: number;
|
|
timedShutdown?: number;
|
|
shutdownRemain?: number;
|
|
shutdownSuggestMode?: number;
|
|
shutdownSuggest?: number;
|
|
shutLastTimer?: number;
|
|
chgMode?: number;
|
|
chgOVP?: number;
|
|
chgReducer?: number;
|
|
chgFullTrig?: number;
|
|
chgOFC?: number;
|
|
}
|
|
|
|
export interface UpsEvent {
|
|
id: string;
|
|
message: string;
|
|
time: string;
|
|
severity?: 'info' | 'success' | 'warning' | 'error';
|
|
}
|
|
|
|
// Classify event severity based on message content
|
|
function classifySeverity(msg: string): 'info' | 'success' | 'warning' | 'error' {
|
|
const lower = msg.toLowerCase();
|
|
if (lower.includes('error') || lower.includes('fail') || lower.includes('disconnect') || lower.includes('offline')) return 'error';
|
|
if (lower.includes('warn') || lower.includes('critical') || lower.includes('low')) return 'warning';
|
|
if (lower.includes('connect') || lower.includes('online') || lower.includes('subscrib')) return 'success';
|
|
return 'info';
|
|
}
|
|
|
|
// Convert DB history row to chart point
|
|
function rowToChartPoint(row: HistoryRow) {
|
|
return {
|
|
time: row.created_at
|
|
? new Date(row.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
|
: '--:--:--',
|
|
batteryPct: row.battery_pct,
|
|
vBatt: row.v_batt,
|
|
};
|
|
}
|
|
|
|
// Convert DB event row to UpsEvent
|
|
function rowToEvent(row: EventRow): UpsEvent {
|
|
return {
|
|
id: String(row.id ?? crypto.randomUUID()),
|
|
message: row.message,
|
|
time: row.created_at
|
|
? new Date(row.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
|
: '--:--:--',
|
|
severity: row.severity,
|
|
};
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const [activeTab, setActiveTab] = useState<'dashboard' | 'history' | 'logs' | 'settings'>('dashboard');
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
const [upsData, setUpsData] = useState<UpsData | null>(null);
|
|
const [mqttStatus, setMqttStatus] = useState<'Connecting...' | 'Online' | 'Offline'>('Connecting...');
|
|
const [events, setEvents] = useState<UpsEvent[]>([]);
|
|
const [chartData, setChartData] = useState<{ time: string; batteryPct: number; vBatt: number }[]>([]);
|
|
const [liveChartData, setLiveChartData] = useState<{ time: string; batteryPct: number; vBatt: number }[]>([]);
|
|
const [dbLoaded, setDbLoaded] = useState(false);
|
|
const mqttClientRef = useRef<mqtt.MqttClient | null>(null);
|
|
const prevUpsDataRef = useRef<UpsData | null>(null);
|
|
const lastMqttMessageRef = useRef<number>(0);
|
|
|
|
// Throttle: only save to Supabase every 30 seconds
|
|
const lastSaveRef = useRef<number>(0);
|
|
|
|
const addEvent = useCallback((msg: string, persist = true) => {
|
|
const severity = classifySeverity(msg);
|
|
const ev: UpsEvent = {
|
|
id: crypto.randomUUID(),
|
|
message: msg,
|
|
time: new Date().toLocaleTimeString(),
|
|
severity,
|
|
};
|
|
setEvents(prev => [ev, ...prev].slice(0, 200));
|
|
|
|
// Save to Supabase (for non-trivial messages)
|
|
if (persist) {
|
|
insertEvent({ message: msg, severity } as Partial<EventRow> & Pick<EventRow, 'message' | 'severity'>);
|
|
}
|
|
}, []);
|
|
|
|
const clearEvents = async () => {
|
|
const { clearAllEvents } = await import('@/lib/db');
|
|
await clearAllEvents();
|
|
setEvents([]);
|
|
};
|
|
|
|
// Auto-clear data if no MQTT message received for 10 seconds (device offline)
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
if (lastMqttMessageRef.current > 0) {
|
|
const elapsed = (Date.now() - lastMqttMessageRef.current) / 1000;
|
|
if (elapsed >= 10 && prevUpsDataRef.current !== null) {
|
|
setUpsData(null);
|
|
setLiveChartData([]);
|
|
prevUpsDataRef.current = null;
|
|
addEvent('IoT Device went Offline (No telemetry signal for >10s)', true);
|
|
}
|
|
}
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [addEvent]);
|
|
|
|
// Initial data load on mount
|
|
useEffect(() => {
|
|
const loadInitialData = async () => {
|
|
try {
|
|
const [histRows, evRows] = await Promise.all([
|
|
fetchHistory(100),
|
|
fetchEvents(200)
|
|
]);
|
|
|
|
if (histRows.length > 0) {
|
|
const points = histRows.map(rowToChartPoint);
|
|
setChartData(points);
|
|
}
|
|
if (evRows.length > 0) {
|
|
const evs = evRows
|
|
.map(rowToEvent)
|
|
.filter(ev =>
|
|
!ev.message.startsWith('Connected to') &&
|
|
!ev.message.startsWith('Subscribed to') &&
|
|
!ev.message.startsWith('Connecting to') &&
|
|
!ev.message.startsWith('Subscription error') &&
|
|
!ev.message.startsWith('MQTT Connection Offline')
|
|
);
|
|
setEvents(evs);
|
|
}
|
|
setDbLoaded(true);
|
|
} catch (err) {
|
|
console.warn('[DB] initial load warning:', err);
|
|
}
|
|
};
|
|
|
|
loadInitialData();
|
|
}, []);
|
|
|
|
// MQTT Connection setup
|
|
useEffect(() => {
|
|
setMqttStatus('Connecting...');
|
|
console.log('Connecting to MQTT Broker (wss://broker.emqx.io:8084/mqtt)...');
|
|
|
|
const client = mqtt.connect('wss://broker.emqx.io:8084/mqtt');
|
|
mqttClientRef.current = client;
|
|
|
|
client.on('connect', () => {
|
|
setMqttStatus('Online');
|
|
console.log('Connected to MQTT Broker');
|
|
client.subscribe('ups/dev15/state', (err) => {
|
|
if (err && err.message !== 'client disconnecting') {
|
|
console.warn(`Subscription warning: ${err.message}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
client.on('message', (topic, message) => {
|
|
if (topic === 'ups/dev15/state') {
|
|
try {
|
|
// Sanitize the string: remove control characters like \n, \t, etc. that might break JSON parse
|
|
const rawString = message.toString().replace(/[\x00-\x1F\x7F-\x9F]/g, "");
|
|
const data: UpsData = JSON.parse(rawString);
|
|
lastMqttMessageRef.current = Date.now();
|
|
setUpsData(data);
|
|
|
|
// Track state changes to generate event logs
|
|
const prevData = prevUpsDataRef.current;
|
|
if (prevData) {
|
|
// Power Source change
|
|
if (prevData.line !== data.line) {
|
|
if (data.line > 0) addEvent('AC Power Restored (Line Active)', true);
|
|
else addEvent('AC Power Lost (Running on Battery)', true);
|
|
}
|
|
// Output State change
|
|
if (prevData.on !== data.on) {
|
|
if (data.on === 1) addEvent('UPS Output turned ON', true);
|
|
else addEvent('UPS Output turned OFF', true);
|
|
}
|
|
// Battery Critical
|
|
if (prevData.battCritical !== data.battCritical && data.battCritical === 1) {
|
|
addEvent('WARNING: Battery level is CRITICAL', true);
|
|
}
|
|
// Charging state
|
|
if (prevData.charging !== data.charging) {
|
|
if (data.charging === 0 && data.battP >= 95) addEvent('Battery Fully Charged', true);
|
|
else if (data.charging === 1) addEvent('Battery is now charging', false);
|
|
}
|
|
// Shutdown Timer
|
|
if ((prevData.shutdownRemain ?? 0) === 0 && (data.shutdownRemain ?? 0) > 0) {
|
|
addEvent(`Shutdown timer started (${data.shutdownRemain}s)`, true);
|
|
} else if ((prevData.shutdownRemain ?? 0) > 0 && (data.shutdownRemain ?? 0) === 0 && data.on === 1) {
|
|
addEvent('Shutdown timer cancelled', true);
|
|
}
|
|
}
|
|
prevUpsDataRef.current = data;
|
|
|
|
const newPoint = {
|
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }),
|
|
batteryPct: data.battP,
|
|
vBatt: parseFloat(data.v),
|
|
};
|
|
|
|
setChartData(prev => [...prev, newPoint].slice(-100));
|
|
setLiveChartData(prev => [...prev, newPoint].slice(-15));
|
|
|
|
// Throttled save to Supabase (every 30 seconds)
|
|
const now = Date.now();
|
|
if (now - lastSaveRef.current >= 30000) {
|
|
lastSaveRef.current = now;
|
|
insertHistoryPoint({
|
|
v_batt: parseFloat(data.v),
|
|
battery_pct: data.battP,
|
|
line_state: data.line,
|
|
power_on: data.on === 1,
|
|
});
|
|
}
|
|
|
|
} catch (e) {
|
|
// Changed to console.warn to prevent Next.js from throwing a red error overlay in dev mode
|
|
console.warn('JSON parse error (likely malformed MQTT data):', e);
|
|
}
|
|
}
|
|
});
|
|
|
|
client.on('offline', () => {
|
|
setMqttStatus('Offline');
|
|
console.log('MQTT Connection Offline');
|
|
});
|
|
|
|
return () => {
|
|
client.end();
|
|
};
|
|
}, [dbLoaded, addEvent]);
|
|
|
|
return (
|
|
<div className={styles.layout}>
|
|
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
|
|
<main className={styles.mainContent}>
|
|
<div className={styles.mobileHeader}>
|
|
<div className={styles.mobileTitle}>
|
|
<Zap color="#2563eb" size={24} strokeWidth={2.5} />
|
|
<span>UPS Monitor</span>
|
|
</div>
|
|
<button className={styles.menuBtn} onClick={() => setIsSidebarOpen(true)} aria-label="Open Menu">
|
|
<Menu size={22} />
|
|
</button>
|
|
</div>
|
|
{activeTab === 'dashboard' && <Header mqttStatus={upsData ? 'Online' : (mqttStatus === 'Connecting...' ? 'Connecting...' : 'Offline')} uptimeSeconds={upsData?.uptime} />}
|
|
|
|
{activeTab === 'dashboard' && (
|
|
<>
|
|
<StatCards data={upsData} />
|
|
<div className={styles.gridContent}>
|
|
<VoltageChart data={upsData ? liveChartData : []} />
|
|
<EventLog events={upsData ? events.filter(ev => !ev.message.startsWith('Connected to') && !ev.message.startsWith('Subscribed to') && !ev.message.startsWith('Connecting to')) : []} />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'history' && (
|
|
<HistoryPage data={chartData} />
|
|
)}
|
|
|
|
{activeTab === 'logs' && <EventLogsPage events={events} onClear={clearEvents} />}
|
|
|
|
{activeTab === 'settings' && <SettingsPanel data={upsData} onSendCommand={(cmd, val) => {
|
|
if (mqttClientRef.current && mqttClientRef.current.connected) {
|
|
const payload = JSON.stringify({ cmd, val: String(val) });
|
|
mqttClientRef.current.publish('ups/dev15/cmd', payload);
|
|
addEvent(`Sent command: ${cmd}=${val}`, false);
|
|
} else {
|
|
alert("MQTT is not connected. Cannot send command.");
|
|
}
|
|
}} />}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|