TKK_E32230469/web_ups/src/app/page.tsx

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>
);
}