This commit is contained in:
sebseb7
2025-12-25 00:08:05 +01:00
parent 1f3292bc17
commit 077e76735e
15 changed files with 9311 additions and 0 deletions

109
uiserver/src/components/Chart.js vendored Normal file
View File

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Paper, Typography, Box, CircularProgress } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart';
import { useTheme } from '@mui/material/styles';
export default function Chart({ selectedChannels = [], channelConfig = null }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const theme = useTheme();
// Determine effective channels list
const effectiveChannels = channelConfig
? channelConfig.map(c => c.id)
: selectedChannels;
const fetchData = () => {
// Only fetch if selection exists
if (effectiveChannels.length === 0) {
setData([]);
setLoading(false);
return;
}
const selectionStr = effectiveChannels.join(',');
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
.then(res => res.json())
.then(rows => {
const timeMap = new Map();
rows.forEach(row => {
const id = `${row.device}:${row.channel}`;
if (!effectiveChannels.includes(id)) return;
const time = new Date(row.timestamp).getTime();
if (!timeMap.has(time)) {
timeMap.set(time, { time: new Date(row.timestamp) });
}
const entry = timeMap.get(time);
let val = row.value;
if (row.data_type === 'json' && !val) {
val = null;
}
entry[id] = val;
});
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
setData(sortedData);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch data", err);
setLoading(false);
});
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 60000);
return () => clearInterval(interval);
}, [selectedChannels, channelConfig]);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
const series = effectiveChannels.map(id => {
// Find alias if config exists
let label = id;
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item && item.alias) label = item.alias;
}
return {
dataKey: id,
label: label,
connectNulls: true,
showMark: false,
};
});
return (
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexGrow: 1 }}>
<LineChart
dataset={data}
series={series}
xAxis={[{
dataKey: 'time',
scaleType: 'time',
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]}
slotProps={{
legend: {
direction: 'row',
position: { vertical: 'top', horizontal: 'middle' },
padding: 0,
},
}}
/>
</Box>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import {
Paper, TextField, Button, Typography, Container, Alert
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function Login({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Login failed');
}
onLogin(data); // { token, role, username }
navigate('/');
} catch (err) {
setError(err.message);
}
};
return (
<Container maxWidth="xs" sx={{ mt: 8 }}>
<Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5" align="center">Login</Typography>
{error && <Alert severity="error">{error}</Alert>}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<TextField
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
/>
<Button type="submit" variant="contained" color="primary" fullWidth>
Sign In
</Button>
</form>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import {
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
Switch, Paper, Typography, CircularProgress, Container
} from '@mui/material';
import SensorsIcon from '@mui/icons-material/Sensors';
export default function Settings({ selectedChannels, onToggleChannel }) {
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/devices')
.then(res => {
if (!res.ok) throw new Error('Failed to load devices');
return res.json();
})
.then(data => {
setDevices(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">Error: {error}</Typography></Container>;
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Typography variant="h4" gutterBottom>
Channel Selection
</Typography>
<Paper>
<List>
{devices.map((device, index) => {
const id = `${device.device}:${device.channel}`;
const isSelected = selectedChannels.includes(id);
return (
<ListItem key={id} divider={index !== devices.length - 1}>
<ListItemIcon>
<SensorsIcon />
</ListItemIcon>
<ListItemText
primary={device.channel}
secondary={`Device: ${device.device}`}
/>
<ListItemSecondaryAction>
<Switch
edge="end"
checked={isSelected}
onChange={() => onToggleChannel(id)}
/>
</ListItemSecondaryAction>
</ListItem>
);
})}
{devices.length === 0 && (
<ListItem>
<ListItemText primary="No devices found in database" />
</ListItem>
)}
</List>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Typography, Container, CircularProgress } from '@mui/material';
import Chart from './Chart';
export default function ViewDisplay() {
const { id } = useParams();
const [view, setView] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/views/${id}`)
.then(res => {
if (!res.ok) throw new Error('View not found');
return res.json();
})
.then(data => {
setView(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [id]);
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
// Map view config to Chart format with aliases
const channelConfig = view.config.map(item => ({
id: `${item.device}:${item.channel}`,
alias: item.alias
}));
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2 }}>
<Typography variant="h5">{view.name}</Typography>
</Box>
<Chart channelConfig={channelConfig} />
</Box>
);
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, Chip
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import AddIcon from '@mui/icons-material/Add';
import { useNavigate } from 'react-router-dom';
export default function ViewManager({ user }) {
const [views, setViews] = useState([]);
const [open, setOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [availableDevices, setAvailableDevices] = useState([]);
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias }]
// Selection state for new item
const [selDevice, setSelDevice] = useState('');
const [selChannel, setSelChannel] = useState('');
const [alias, setAlias] = useState('');
const navigate = useNavigate();
const isAdmin = user && user.role === 'admin';
useEffect(() => {
refreshViews();
if (isAdmin) {
fetch('/api/devices')
.then(res => res.json())
.then(setAvailableDevices)
.catch(console.error);
}
}, [isAdmin]);
const refreshViews = () => {
fetch('/api/views')
.then(res => res.json())
.then(setViews)
.catch(console.error);
};
const handleCreate = async () => {
if (!newViewName || viewConfig.length === 0) return;
try {
const res = await fetch('/api/views', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ name: newViewName, config: viewConfig })
});
if (res.ok) {
setOpen(false);
setNewViewName('');
setViewConfig([]);
refreshViews();
} else {
alert('Failed to create view');
}
} catch (err) {
console.error(err);
}
};
const addConfigItem = () => {
if (selDevice && selChannel) {
setViewConfig([...viewConfig, { device: selDevice, channel: selChannel, alias: alias || `${selDevice}:${selChannel}` }]);
setAlias('');
}
};
// Derived state for channels
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4">Views</Typography>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
Create View
</Button>
)}
</Box>
<List>
{views.map(view => (
<ListItem
button
key={view.id}
divider
onClick={() => navigate(`/views/${view.id}`)}
>
<ListItemIcon><DashboardIcon /></ListItemIcon>
<ListItemText
primary={view.name}
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
/>
</ListItem>
))}
{views.length === 0 && <Typography>No views available.</Typography>}
</List>
{/* Create Dialog */}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New View</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="View Name"
fullWidth
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
/>
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
<Typography variant="subtitle2">Add Channels</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<FormControl fullWidth size="small">
<InputLabel>Device</InputLabel>
<Select value={selDevice} label="Device" onChange={(e) => setSelDevice(e.target.value)}>
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Channel</InputLabel>
<Select value={selChannel} label="Channel" onChange={(e) => setSelChannel(e.target.value)}>
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
label="Alias (Optional)"
fullWidth
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
<Button variant="outlined" onClick={addConfigItem}>Add</Button>
</Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{viewConfig.map((item, idx) => (
<Chip
key={idx}
label={`${item.alias} (${item.device}:${item.channel})`}
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} color="primary">Save</Button>
</DialogActions>
</Dialog>
</Container>
);
}