This commit is contained in:
jhartworks
2026-06-22 15:52:25 +02:00
commit 62a697ce45
47 changed files with 26655 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
import { useEffect, useMemo, useState } from 'react'
import ViewerShortcutButton from '../components/ViewerShortcutButton'
import { api, apiPost } from '../lib/visuApi'
const RANGE_OPTIONS = [
{ value: '15m', label: '15 Min' },
{ value: '1h', label: '1 h' },
{ value: '6h', label: '6 h' },
{ value: '12h', label: '12 h' },
{ value: '24h', label: '24 h' },
{ value: '7d', label: '7 Tage' },
{ value: '30d', label: '30 Tage' },
{ value: '90d', label: '90 Tage' },
{ value: '180d', label: '6 Monate' },
]
const INTERVAL_OPTIONS = ['auto', '10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d']
const AGGREGATES = [
{ value: 'mean', label: 'Mittelwert' },
{ value: 'last', label: 'Letzter Wert' },
{ value: 'min', label: 'Minimum' },
{ value: 'max', label: 'Maximum' },
]
function pointKey(point) {
return ['influx', point.database || '', point.measurement || '', point.tag_key || '', point.tag_value || ''].join('|')
}
function pointMeasurement(point) {
return String(point?.measurement || '').trim()
}
function csvEscape(value, delimiter = ',') {
const text = String(value ?? '')
return /["\r\n]/.test(text) || text.includes(delimiter)
? `"${text.replace(/"/g, '""')}"`
: text
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function chunks(items, size = 8) {
const out = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
function lastNonNull(series) {
const row = [...(series?.values || [])].reverse().find((item) => item.value !== null && item.value !== undefined && item.value !== '')
return row ? { value: row.value, time: row.time } : { value: '', time: '' }
}
export default function MonitoringData() {
const [databases, setDatabases] = useState([])
const [database, setDatabase] = useState('')
const [points, setPoints] = useState([])
const [selection, setSelection] = useState([])
const [savedSelections, setSavedSelections] = useState([])
const [activeSelectionId, setActiveSelectionId] = useState(0)
const [selectionName, setSelectionName] = useState('Neue Selection')
const [q, setQ] = useState('')
const [measurementFilter, setMeasurementFilter] = useState('')
const [range, setRange] = useState('24h')
const [interval, setIntervalValue] = useState('auto')
const [aggregate, setAggregate] = useState('last')
const [delimiter, setDelimiter] = useState(';')
const [lastValues, setLastValues] = useState({})
const [loading, setLoading] = useState(false)
const [savingSelection, setSavingSelection] = useState(false)
const [error, setError] = useState('')
async function loadSavedSelections() {
const data = await api('monitoring_selections')
setSavedSelections(data.selections || [])
}
useEffect(() => {
loadSavedSelections().catch((err) => setError(err.message))
}, [])
useEffect(() => {
let alive = true
setLoading(true)
setError('')
api('trend_sources', { database })
.then((data) => {
if (!alive) return
setDatabases(data.databases || [])
setDatabase((current) => current || data.selected_database || data.databases?.[0] || '')
setPoints(data.points || [])
})
.catch((err) => {
if (alive) setError(err.message)
})
.finally(() => {
if (alive) setLoading(false)
})
return () => {
alive = false
}
}, [database])
useEffect(() => {
if (!measurementFilter) return
if (!points.some((point) => pointMeasurement(point) === measurementFilter)) {
setMeasurementFilter('')
}
}, [measurementFilter, points])
useEffect(() => {
let alive = true
async function loadLastValues() {
if (!selection.length) {
setLastValues({})
return
}
const next = {}
for (const group of chunks(selection, 8)) {
const res = await apiPost('trend_data', {
series_json: JSON.stringify(group.map((point, index) => ({
...point,
id: pointKey(point) || `selection-${index}`,
label: point.tag_value || point.label,
}))),
range,
interval: 'auto',
aggregate: 'last',
})
;(res.data?.series || []).forEach((serie) => {
next[pointKey(serie)] = lastNonNull(serie)
})
}
if (alive) setLastValues(next)
}
loadLastValues().catch((err) => {
if (alive) setError(err.message)
})
return () => {
alive = false
}
}, [selection, range])
const measurements = useMemo(() => (
Array.from(new Set(points.map(pointMeasurement).filter(Boolean))).sort((a, b) => (
a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' })
))
), [points])
const filteredPoints = useMemo(() => {
const needle = q.trim().toLowerCase()
return points.filter((point) => {
const hay = `${point.measurement} ${point.tag_key} ${point.tag_value} ${point.label}`.toLowerCase()
const matchesSearch = !needle || hay.includes(needle)
const matchesMeasurement = !measurementFilter || pointMeasurement(point) === measurementFilter
return matchesSearch && matchesMeasurement
})
}, [points, q, measurementFilter])
const selectionKeys = new Set(selection.map(pointKey))
function addPoint(point) {
if (selectionKeys.has(pointKey(point))) return
setSelection((old) => [...old, point])
}
function removePoint(key) {
setSelection((old) => old.filter((point) => pointKey(point) !== key))
}
async function saveCurrentSelection() {
const name = selectionName.trim() || `Selection ${savedSelections.length + 1}`
setError('')
setSavingSelection(true)
try {
const data = await apiPost('monitoring_selection_save', {
selection_id: activeSelectionId || '',
name,
database_name: database,
range_value: range,
interval_value: interval,
aggregate_value: aggregate,
csv_delimiter: delimiter,
points_json: JSON.stringify(selection),
})
setActiveSelectionId(data.selection_id || activeSelectionId)
setSelectionName(name)
await loadSavedSelections()
} catch (err) {
setError(err.message)
} finally {
setSavingSelection(false)
}
}
function loadSelection(item) {
setSelection(item.points || [])
setSelectionName(item.name || 'Selection')
setActiveSelectionId(Number(item.id || 0))
setDatabase(item.database_name || database)
setRange(item.range_value || '24h')
setIntervalValue(item.interval_value || 'auto')
setAggregate(item.aggregate_value || 'last')
setDelimiter(item.csv_delimiter || item.delimiter_value || ';')
}
async function deleteSelection(item) {
if (!item?.id) return
setError('')
try {
await apiPost('monitoring_selection_delete', { selection_id: item.id })
if (Number(item.id) === Number(activeSelectionId)) setActiveSelectionId(0)
await loadSavedSelections()
} catch (err) {
setError(err.message)
}
}
function exportLastValuesCsv() {
const header = ['Datenpunkt', 'Measurement', 'Tag', 'Wert', 'Zeit', 'Datenbank']
const lines = [
header.map((cell) => csvEscape(cell, delimiter)).join(delimiter),
...selection.map((point) => {
const last = lastValues[pointKey(point)] || {}
return [point.tag_value, point.measurement, point.tag_key, last.value ?? '', last.time ?? '', point.database]
.map((cell) => csvEscape(cell, delimiter)).join(delimiter)
}),
]
downloadBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_lastvalues.csv`)
}
async function exportTrendData(kind = 'csv') {
const rows = new Map()
const headers = ['Zeit', ...selection.map((point) => point.tag_value || point.label)]
for (const group of chunks(selection, 8)) {
const res = await apiPost('trend_data', {
series_json: JSON.stringify(group.map((point) => ({
...point,
id: pointKey(point),
label: point.tag_value || point.label,
}))),
range,
interval,
aggregate,
})
;(res.data?.series || []).forEach((serie) => {
;(serie.values || []).forEach((row) => {
if (!rows.has(row.time)) rows.set(row.time, { Zeit: row.time })
rows.get(row.time)[serie.label] = row.value ?? ''
})
})
}
const sortedRows = Array.from(rows.values()).sort((a, b) => String(a.Zeit).localeCompare(String(b.Zeit)))
if (kind === 'xls') {
const html = [
'<table>',
`<tr>${headers.map((cell) => `<th>${String(cell).replace(/[<>&]/g, '')}</th>`).join('')}</tr>`,
...sortedRows.map((row) => `<tr>${headers.map((cell) => `<td>${String(row[cell] ?? '').replace(/[<>&]/g, '')}</td>`).join('')}</tr>`),
'</table>',
].join('')
downloadBlob(new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_trend.xls`)
return
}
const lines = [
headers.map((cell) => csvEscape(cell, delimiter)).join(delimiter),
...sortedRows.map((row) => headers.map((cell) => csvEscape(row[cell] ?? '', delimiter)).join(delimiter)),
]
downloadBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_trend.csv`)
}
return (
<div className="page-shell full viewer-full">
<section className="glass-card visu-workspace monitoring-page">
<div className="section-head visu-topbar">
<div>
<div className="eyebrow">Monitoring</div>
<h1>Datenpunkt-Uebersicht</h1>
<p className="subline">Datenpunkte auswaehlen, Selections speichern und manuelle CSV- oder Excel-Exporte ziehen.</p>
</div>
<div className="designer-actions">
<ViewerShortcutButton />
<a href="/visu/?mode=dashboard" className="btn">Dashboard</a>
<a href="/visu/?mode=manage" className="btn">Verwaltung</a>
<a href="/visu/?mode=templates" className="btn">Templates</a>
<a href="/visu/?mode=monitoring" className="btn primary">Datenpunkte</a>
<a href="/visu/?mode=virtual_points" className="btn">Virtuelle Datenpunkte</a>
<a href="/visu/?mode=csv_exports" className="btn">CSV Exporte</a>
</div>
</div>
{error && <div className="error-box small">{error}</div>}
<div className="monitoring-layout">
<div className="monitoring-panel">
<div className="prop-grid-2">
<div className="field">
<label>Influx DB</label>
<select value={database} onChange={(e) => setDatabase(e.target.value)}>
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<div className="field">
<label>Measurement / FROM</label>
<select value={measurementFilter} onChange={(e) => setMeasurementFilter(e.target.value)}>
<option value="">Alle Measurements</option>
{measurements.map((measurement) => <option key={measurement} value={measurement}>{measurement}</option>)}
</select>
</div>
</div>
<div className="field">
<label>Suchen</label>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Measurement, Tag, Datenpunkt..." />
</div>
{loading && <div className="small-muted">Datenpunkte werden geladen...</div>}
<div className="monitoring-point-list">
{filteredPoints.map((point) => (
<button key={pointKey(point)} type="button" className={`monitoring-point-row ${selectionKeys.has(pointKey(point)) ? 'is-selected' : ''}`} onClick={() => addPoint(point)}>
<strong>{point.tag_value}</strong>
<span>{point.measurement} - {point.tag_key}</span>
</button>
))}
</div>
</div>
<div className="monitoring-panel">
<div className="monitoring-selection-head">
<div className="field">
<label>Selection Name</label>
<input value={selectionName} onChange={(e) => setSelectionName(e.target.value)} />
</div>
<button type="button" className="btn primary" disabled={savingSelection} onClick={saveCurrentSelection}>
{savingSelection ? 'Speichert...' : 'Speichern'}
</button>
</div>
<div className="prop-grid-2">
<div className="field">
<label>Zeitbereich</label>
<select value={range} onChange={(e) => setRange(e.target.value)}>
{RANGE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="field">
<label>Intervall</label>
<select value={interval} onChange={(e) => setIntervalValue(e.target.value)}>
{INTERVAL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div className="field">
<label>Aggregation</label>
<select value={aggregate} onChange={(e) => setAggregate(e.target.value)}>
{AGGREGATES.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="field">
<label>CSV Trennzeichen</label>
<select value={delimiter} onChange={(e) => setDelimiter(e.target.value)}>
<option value=",">Komma (,)</option>
<option value=";">Semikolon (;)</option>
<option value="\t">Tab</option>
</select>
</div>
</div>
<div className="designer-actions">
<button type="button" className="btn small" disabled={!selection.length} onClick={exportLastValuesCsv}>LastValues CSV</button>
<button type="button" className="btn small" disabled={!selection.length} onClick={() => exportTrendData('csv')}>Trend CSV</button>
<button type="button" className="btn small" disabled={!selection.length} onClick={() => exportTrendData('xls')}>Excel</button>
<a href="/visu/?mode=csv_exports" className="btn small">Zyklische Exporte</a>
</div>
<div className="monitoring-selection-list">
{selection.map((point) => {
const last = lastValues[pointKey(point)] || {}
return (
<div key={pointKey(point)} className="monitoring-selection-row">
<div>
<strong>{point.tag_value}</strong>
<span>{last.value !== '' && last.value !== undefined ? last.value : '...'} {last.time ? `- ${new Date(last.time).toLocaleString('de-DE')}` : ''}</span>
</div>
<button type="button" className="mini-btn danger" onClick={() => removePoint(pointKey(point))}>Entfernen</button>
</div>
)
})}
</div>
<div className="monitoring-saved-list">
<div className="small-title">Gespeicherte Selections</div>
{savedSelections.map((item) => (
<div key={item.id || item.name} className="monitoring-saved-row">
<button type="button" className="mini-btn" onClick={() => loadSelection(item)}>{item.name}</button>
<button type="button" className="mini-btn danger" onClick={() => deleteSelection(item)}>Loeschen</button>
</div>
))}
</div>
</div>
</div>
</section>
</div>
)
}