feat: Enhance water valve control with Telegram bot integration, allowing remote commands and user authorization. Add channel history querying and improve server functionality for rule execution and static file serving.
This commit is contained in:
403
status_server.js
403
status_server.js
@@ -62,6 +62,52 @@ function getStatusData() {
|
||||
});
|
||||
}
|
||||
|
||||
// Query channel history with time range filter
|
||||
function getChannelHistory(channelId, range = '24h') {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Calculate the start timestamp based on range
|
||||
const now = new Date();
|
||||
let startTime;
|
||||
if (range === '1week') {
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
// Default to 24h
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT c.component, c.field, c.type, c.mac,
|
||||
e.event, e.timestamp
|
||||
FROM channels c
|
||||
LEFT JOIN events e ON e.channel_id = c.id
|
||||
WHERE c.id = ? AND (e.timestamp IS NULL OR e.timestamp >= ?)
|
||||
ORDER BY e.timestamp ASC
|
||||
`;
|
||||
|
||||
db.all(sql, [channelId, startTime.toISOString()], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else if (rows.length === 0) {
|
||||
reject(new Error('Channel not found'));
|
||||
} else {
|
||||
const channel = {
|
||||
id: channelId,
|
||||
component: rows[0].component,
|
||||
field: rows[0].field,
|
||||
type: rows[0].type,
|
||||
mac: rows[0].mac
|
||||
};
|
||||
const events = rows
|
||||
.filter(r => r.timestamp !== null)
|
||||
.map(r => ({
|
||||
event: r.event,
|
||||
timestamp: r.timestamp
|
||||
}));
|
||||
resolve({ channel, events });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast event to all connected WebSocket clients
|
||||
export function broadcastEvent(mac, component, field, type, event) {
|
||||
const message = JSON.stringify({
|
||||
@@ -103,6 +149,8 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IoT Status</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f0f14;
|
||||
@@ -481,6 +529,144 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Clickable channel rows */
|
||||
.channels-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.channels-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(10px);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.range-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.range-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.range-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.range-btn.active {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -510,6 +696,31 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
<div id="rules-container" class="rules-grid"></div>
|
||||
|
||||
<!-- Chart Modal -->
|
||||
<div id="chart-modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title" id="modal-title">Channel History</div>
|
||||
<div class="modal-subtitle" id="modal-subtitle"></div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeChartModal()">
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="range-toggle">
|
||||
<button class="range-btn active" data-range="24h" onclick="setChartRange('24h')">24 Hours</button>
|
||||
<button class="range-btn" data-range="1week" onclick="setChartRange('1week')">1 Week</button>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="history-chart"></canvas>
|
||||
<div class="chart-loading" id="chart-loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<script>
|
||||
@@ -779,7 +990,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
</thead>
|
||||
<tbody>
|
||||
\${device.channels.map(ch => \`
|
||||
<tr>
|
||||
<tr data-channel-id="\${ch.id}" onclick="openChartModal(\${ch.id}, '\${device.mac}', '\${ch.component}', '\${ch.field}')">
|
||||
<td class="channel-component">\${ch.component}</td>
|
||||
<td class="channel-field">\${ch.field}</td>
|
||||
<td class="channel-event" data-channel="\${ch.component}_\${ch.field}">\${ch.event ?? '-'}</td>
|
||||
@@ -892,6 +1103,175 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Chart modal functions
|
||||
let historyChart = null;
|
||||
let currentChannelId = null;
|
||||
let currentRange = '24h';
|
||||
|
||||
function openChartModal(channelId, mac, component, field) {
|
||||
currentChannelId = channelId;
|
||||
currentRange = '24h';
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('modal-title').textContent = \`\${component}.\${field}\`;
|
||||
document.getElementById('modal-subtitle').textContent = mac;
|
||||
|
||||
// Reset range buttons
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.range === '24h');
|
||||
});
|
||||
|
||||
// Show modal
|
||||
document.getElementById('chart-modal').classList.add('active');
|
||||
|
||||
// Load chart data
|
||||
loadChartData(channelId, '24h');
|
||||
}
|
||||
|
||||
function closeChartModal() {
|
||||
document.getElementById('chart-modal').classList.remove('active');
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
historyChart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setChartRange(range) {
|
||||
if (range === currentRange) return;
|
||||
currentRange = range;
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.range === range);
|
||||
});
|
||||
|
||||
// Reload data
|
||||
loadChartData(currentChannelId, range);
|
||||
}
|
||||
|
||||
async function loadChartData(channelId, range) {
|
||||
const loadingEl = document.getElementById('chart-loading');
|
||||
loadingEl.style.display = 'flex';
|
||||
loadingEl.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const res = await fetch(\`/api/channel-history/\${channelId}?range=\${range}\`);
|
||||
if (!res.ok) throw new Error('Failed to load data');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.events.length === 0) {
|
||||
loadingEl.textContent = 'No data for this time range';
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
historyChart = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
renderChart(data.events, data.channel.type);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading chart data:', err);
|
||||
loadingEl.textContent = 'Error loading data';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(events, channelType) {
|
||||
const ctx = document.getElementById('history-chart').getContext('2d');
|
||||
|
||||
// Destroy existing chart
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
// Process data based on type
|
||||
const isBoolean = channelType === 'boolean';
|
||||
const data = events.map(e => ({
|
||||
x: new Date(e.timestamp),
|
||||
y: isBoolean ? (e.event === 'true' || e.event === true ? 1 : 0) : parseFloat(e.event)
|
||||
}));
|
||||
|
||||
// Chart config
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: isBoolean ? 0 : 0.3,
|
||||
stepped: isBoolean ? 'before' : false,
|
||||
pointRadius: data.length > 100 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
if (isBoolean) {
|
||||
return context.raw.y === 1 ? 'true' : 'false';
|
||||
}
|
||||
return context.raw.y.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: currentRange === '24h' ? 'hour' : 'day',
|
||||
displayFormats: {
|
||||
hour: 'HH:mm',
|
||||
day: 'MMM dd'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0b0'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: isBoolean,
|
||||
max: isBoolean ? 1.1 : undefined,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0b0',
|
||||
callback: isBoolean ? (val) => val === 1 ? 'true' : val === 0 ? 'false' : '' : undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
historyChart = new Chart(ctx, config);
|
||||
}
|
||||
|
||||
// Close modal on overlay click or Escape key
|
||||
document.getElementById('chart-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'chart-modal') closeChartModal();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeChartModal();
|
||||
});
|
||||
|
||||
connectWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
@@ -916,6 +1296,27 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API endpoint for channel history
|
||||
const historyMatch = url.pathname.match(/^\/api\/channel-history\/(\d+)$/);
|
||||
if (historyMatch) {
|
||||
const channelId = parseInt(historyMatch[1], 10);
|
||||
const range = url.searchParams.get('range') || '24h';
|
||||
|
||||
try {
|
||||
const data = await getChannelHistory(channelId, range);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching channel history:', err);
|
||||
res.writeHead(err.message === 'Channel not found' ? 404 : 500);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard
|
||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||
res.writeHead(200, {
|
||||
|
||||
Reference in New Issue
Block a user