Compare commits

...

2 Commits

Author SHA1 Message Date
sebseb7
a1793d0998 u 2025-12-25 02:25:25 +01:00
sebseb7
db4f27302b u 2025-12-25 02:09:06 +01:00
4 changed files with 136 additions and 34 deletions

View File

@@ -12,7 +12,7 @@ console.log(`[Migrate] Connected to ${dbPath}`);
console.log('[Migrate] Applying schema migrations...');
try {
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
@@ -30,9 +30,20 @@ try {
FOREIGN KEY(created_by) REFERENCES users(id)
);
`);
console.log('[Migrate] Schema applied successfully.');
// Add position column if not exists
try {
db.exec("ALTER TABLE views ADD COLUMN position INTEGER DEFAULT 0");
console.log('[Migrate] Added position column to views table.');
} catch (e) {
if (!e.message.includes("duplicate column name")) {
console.log('[Migrate] NOTE: ' + e.message);
}
}
console.log('[Migrate] Schema applied successfully.');
} catch (err) {
console.error('[Migrate] Error applying schema:', err.message);
console.error('[Migrate] Error applying schema:', err.message);
} finally {
db.close();
db.close();
}

View File

@@ -99,7 +99,9 @@ export default class Chart extends Component {
for (let i = 0; i < points.length; i++) {
const [tsStr, rawVal, untilStr] = points[i];
const val = Number(rawVal);
const numVal = Number(rawVal);
// MUI-X charts only accepts numbers and null - NaN causes errors
const val = Number.isNaN(numVal) ? null : numVal;
let start = new Date(tsStr).getTime();
let explicitEnd = untilStr ? new Date(untilStr).getTime() : null;
@@ -230,12 +232,14 @@ export default class Chart extends Component {
let label = id;
let yAxisKey = 'left';
let color = undefined;
let fillColor = undefined;
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item) {
if (item.alias) label = item.alias;
if (item.yAxis) yAxisKey = item.yAxis;
if (item.color) color = item.color;
if (item.fillColor) fillColor = item.fillColor;
}
}
@@ -247,6 +251,16 @@ export default class Chart extends Component {
yAxisKey: yAxisKey,
};
if (color) sObj.color = color;
// Enable area fill if fillColor is set (with 50% transparency)
if (fillColor) {
sObj.area = true;
// Convert hex to rgba with 50% opacity
const hex = fillColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
sObj.areaColor = `rgba(${r}, ${g}, ${b}, 0.5)`;
}
return sObj;
});
@@ -289,6 +303,15 @@ export default class Chart extends Component {
position: { vertical: 'top', horizontal: 'middle' },
padding: 0,
},
lineHighlight: { strokeWidth: 3 },
}}
sx={{
'& .MuiLineElement-root': {
strokeWidth: 3,
},
'& .MuiAreaElement-root': {
fillOpacity: 0.5,
},
}}
/>
</Box>

View File

@@ -44,6 +44,7 @@ class ViewManager extends Component {
views: [],
open: false,
colorPickerOpen: false,
colorPickerMode: 'line',
editingId: null,
viewName: '',
availableDevices: [],
@@ -101,7 +102,6 @@ class ViewManager extends Component {
};
parseViewData(view) {
// Flatten config for display/editing
let channels = [];
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
@@ -109,28 +109,20 @@ class ViewManager extends Component {
if (!config) return { channels, axes };
if (Array.isArray(config)) {
// Very old legacy (array of channels)
channels = config;
} else if (config.groups) {
// Group format (Recent) - Flatten it!
config.groups.forEach(g => {
if (g.channels) {
channels = [...channels, ...g.channels];
}
// Merge axes? Just take first group's axes or default?
// Let's defer to default or first group if present
if (g.channels) channels = [...channels, ...g.channels];
if (g.axes) {
if (g.axes.left) axes.left = { ...axes.left, ...g.axes.left };
if (g.axes.right) axes.right = { ...axes.right, ...g.axes.right };
}
});
} else if (config.channels) {
// Standard Legacy
channels = config.channels;
if (config.axes) axes = config.axes;
}
// Normalize channels
channels = channels.map((c, i) => ({
...c,
color: c.color || this.getNextColor(i)
@@ -180,6 +172,35 @@ class ViewManager extends Component {
this.refreshViews();
};
moveView = async (idx, dir) => {
const newViews = [...this.state.views];
const target = idx + dir;
if (target < 0 || target >= newViews.length) return;
[newViews[idx], newViews[target]] = [newViews[target], newViews[idx]];
this.setState({ views: newViews });
const order = newViews.map((v, i) => ({ id: v.id, position: i }));
const { user } = this.props;
try {
const res = await fetch('/api/views/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ order })
});
if (!res.ok) {
const err = await res.json();
console.error("Failed to save order:", err);
}
} catch (err) {
console.error("Failed to save order", err);
}
};
handleSave = async () => {
const { viewName, viewConfig, axisConfig, editingId } = this.state;
const { user } = this.props;
@@ -189,7 +210,6 @@ class ViewManager extends Component {
const url = editingId ? `/api/views/${editingId}` : '/api/views';
const method = editingId ? 'PUT' : 'POST';
// Save as flat format again
const finalConfig = {
channels: viewConfig,
axes: axisConfig
@@ -261,20 +281,33 @@ class ViewManager extends Component {
}));
};
openColorPicker = (idx) => {
this.setState({ colorPickerOpen: true, pickerTargetIndex: idx });
openColorPicker = (idx, mode = 'line') => {
this.setState({ colorPickerOpen: true, pickerTargetIndex: idx, colorPickerMode: mode });
};
selectColor = (color) => {
const { pickerTargetIndex, viewConfig } = this.state;
const { pickerTargetIndex, viewConfig, colorPickerMode } = this.state;
if (pickerTargetIndex !== null) {
const newConfig = [...viewConfig];
newConfig[pickerTargetIndex].color = color;
const newConfig = viewConfig.map((ch, i) => {
if (i === pickerTargetIndex) {
if (colorPickerMode === 'fill') {
return { ...ch, fillColor: color };
} else {
return { ...ch, color: color };
}
}
return ch;
});
this.setState({ viewConfig: newConfig, colorPickerOpen: false, pickerTargetIndex: null });
}
};
// --- Navigation ---
clearFillColor = (idx) => {
const newConfig = [...this.state.viewConfig];
delete newConfig[idx].fillColor;
this.setState({ viewConfig: newConfig });
};
handleRangeChange = (e, newVal) => {
if (newVal) this.setState({ rangeLabel: newVal });
};
@@ -315,7 +348,6 @@ class ViewManager extends Component {
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
{/* Global Time Controls */}
<Paper sx={{ position: 'sticky', top: 10, zIndex: 1000, p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', border: '1px solid #504945' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ToggleButtonGroup value={rangeLabel} exclusive onChange={this.handleRangeChange} size="small">
@@ -330,14 +362,21 @@ class ViewManager extends Component {
{isAdmin && <Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>Create View</Button>}
</Paper>
{/* View List */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{views.map(view => {
{views.map((view, vIdx) => {
const { channels, axes } = this.parseViewData(view);
return (
<Paper key={view.id} sx={{ p: 2, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h4">{view.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h4">{view.name}</Typography>
{isAdmin && (
<>
<IconButton size="small" onClick={() => this.moveView(vIdx, -1)} disabled={vIdx === 0}><ArrowUpwardIcon /></IconButton>
<IconButton size="small" onClick={() => this.moveView(vIdx, 1)} disabled={vIdx === views.length - 1}><ArrowDownwardIcon /></IconButton>
</>
)}
</Box>
{isAdmin && (
<Box>
<IconButton onClick={(e) => this.handleOpenEdit(view, e)}><EditIcon /></IconButton>
@@ -351,7 +390,8 @@ class ViewManager extends Component {
id: `${c.device}:${c.channel}`,
alias: c.alias,
yAxis: c.yAxis || 'left',
color: c.color
color: c.color,
fillColor: c.fillColor
}))}
axisConfig={axes}
windowEnd={windowEnd}
@@ -364,7 +404,6 @@ class ViewManager extends Component {
{views.length === 0 && <Typography>No views available.</Typography>}
</Box>
{/* Edit Dialog */}
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent>
@@ -373,7 +412,6 @@ class ViewManager extends Component {
onChange={(e) => this.setState({ viewName: e.target.value })} sx={{ mb: 2 }}
/>
{/* Axis Config */}
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Axis Configuration</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
@@ -395,9 +433,17 @@ class ViewManager extends Component {
<List dense>
{viewConfig.map((ch, idx) => (
<ListItem key={idx} sx={{ pl: 0 }}>
<IconButton size="small" onClick={() => this.openColorPicker(idx)}>
<Box sx={{ width: 20, height: 20, bgcolor: ch.color || '#fff', borderRadius: '50%' }} />
<IconButton size="small" onClick={() => this.openColorPicker(idx, 'line')} title="Line color">
<Box sx={{ width: 20, height: 20, bgcolor: ch.color || '#fff', borderRadius: '50%', border: '2px solid #fff' }} />
</IconButton>
<IconButton size="small" onClick={() => this.openColorPicker(idx, 'fill')} title="Fill color (area)">
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
</IconButton>
{ch.fillColor && (
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
<DeleteIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
<ListItemText
primary={ch.alias}
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
@@ -410,7 +456,6 @@ class ViewManager extends Component {
))}
</List>
{/* Add Channel */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1, bgcolor: '#32302f', p: 1, borderRadius: 1 }}>
<Select size="small" value={paramSelDevice} displayEmpty onChange={e => this.setState({ paramSelDevice: e.target.value })} sx={{ minWidth: 120 }}>
<MenuItem value=""><em>Device</em></MenuItem>
@@ -435,7 +480,6 @@ class ViewManager extends Component {
</DialogActions>
</Dialog>
{/* Color Picker Dialog */}
<Dialog open={colorPickerOpen} onClose={() => this.setState({ colorPickerOpen: false })}>
<DialogTitle>Select Color</DialogTitle>
<DialogContent>

View File

@@ -131,7 +131,7 @@ module.exports = {
// Publicly list views
app.get('/api/views', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views ORDER BY name');
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
const rows = stmt.all();
const views = rows.map(row => {
try {
@@ -192,6 +192,30 @@ module.exports = {
}
});
// Reorder Views
app.post('/api/views/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
console.log('[API] Reorder request:', order);
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
console.log('[API] Updating view', item.id, 'to position', item.position);
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
console.log('[API] Reorder successful');
res.json({ success: true });
} catch (err) {
console.error('[API] Reorder error:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/devices
// Returns list of unique device/channel pairs
app.get('/api/devices', (req, res) => {