VictoriaMetrics als zusätzliche Datenbank-Option für Messwerte ergänzt (STORE/DBASE=victoria)

Dritte, zu mongo/influx exklusive Auswahl für die laufenden Messwerte. Schreibpfad
nutzt das bestehende Influx-Line-Protocol unverändert (common/victoria_post.js);
Lesepfad (sensorapi/databases/victoria.js + victoria2json.js) holt Rohdaten per
VictoriaMetrics' /api/v1/export und bucketet/aggregiert stundenweise clientseitig,
nach Mongo-Konvention (Stunden-Start als Label, kein Zeit-Shift nötig wie bei Influx).
Scope bewusst auf die schon heute per DBASE umschaltbaren Funktionen begrenzt
(getActData/getNoiseAVGData) - getAvgData/getLongAvg/getGeigerData bleiben wie bisher.

Docker-Compose um victoriametrics-Service ergänzt (Retention explizit auf 100y
gesetzt, da VictoriaMetrics sonst nach 1 Monat Daten löscht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:31:06 +02:00
parent b1b8f39ff4
commit 23fe7d2ed2
12 changed files with 302 additions and 14 deletions
+4 -1
View File
@@ -4,6 +4,7 @@ const DBASE = process.env.DBASE || 'mongo'
import {DateTime} from "luxon"
import * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import {returnOnError} from "../utilities/reporterror.js"
import {csv2Json} from "../utilities/csv2json.js"
import checkParams from "../utilities/checkparams.js"
@@ -155,8 +156,10 @@ export async function getActData(opts) {
return await mongo.fetchActData(opts)
} else if (DBASE === 'influx') {
return await influx.fetchActData(opts)
} else if (DBASE === 'victoria') {
return await victoria.fetchActData(opts)
}
return {err: 'DBASEUNKNOWN', values: []}
return {err: 'DBASEUNKNOWN', values: []}
}
+67
View File
@@ -0,0 +1,67 @@
// Access to VictoriaMetrics via HTTP (Prometheus-compatible /api/v1/export,
// no Flux support - see common/victoria_post.js for the write side, which
// reuses the InfluxDB line-protocol format on VictoriaMetrics' /write endpoint).
import axios from 'axios'
import { logit, logerror } from '../utilities/logit.js'
import { returnOnError } from "../utilities/reporterror.js"
import { exportToRows, bucketNoiseAVG } from "../utilities/victoria2json.js"
let VICTORIAHOST = process.env.VICTORIAHOST || "localhost"
let VICTORIAPORT = process.env.VICTORIAPORT || 8428
const VICTORIAURL_EXPORT = `http://${VICTORIAHOST}:${VICTORIAPORT}/api/v1/export`
// opts.start/opts.stop arrive as Flux range() fragments ("start: <iso>" /
// "stop: <iso>") built by calcRange() in getsensorData.js - strip the Flux
// keyword the same way sensorapi/databases/mongo.js already does.
const isoStart = (opts) => opts.start.slice(7)
const isoStop = (opts) => opts.stop.slice(6)
const victoriaExport = async (matchSelector, start, end) => {
let erg = { values: '', err: null }
try {
let ret = await axios({
method: 'get',
url: VICTORIAURL_EXPORT,
params: { 'match[]': matchSelector, start, end },
timeout: 10000,
transformResponse: [(data) => data], // response body is ndjson, not a single JSON document - keep it raw
})
if (ret.status !== 200) {
return returnOnError(erg, 'RESPSTATUS', victoriaExport.name, ret.status)
}
erg.values = ret.data
} catch (e) {
return returnOnError(erg, e, victoriaExport.name)
}
return erg
}
export const fetchActData = async (opts) => {
let ret = { err: null, values: [] }
const match = `{__name__=~"noise_(LAeq|LA_min|LA_max|E10tel_eq)", sid="${opts.sensorid}"}`
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
if (err) {
return returnOnError(ret, err, fetchActData.name)
}
ret.values = exportToRows(values, opts.sort)
if (ret.values.length === 0) {
return returnOnError(ret, 'NODATA', fetchActData.name)
}
return ret
}
export const fetchNoiseAVGData = async (opts) => {
let ret = { err: null, values: [] }
const match = `{__name__=~"noise_(E10tel_eq|LA_max)", sid="${opts.sensorid}"}`
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
if (err) {
return returnOnError(ret, err, fetchNoiseAVGData.name)
}
ret.values = bucketNoiseAVG(values, opts.peak, opts.long)
if (ret.values.length === 0) {
return returnOnError(ret, 'NODATA', fetchNoiseAVGData.name)
}
return ret
}
+4
View File
@@ -9,6 +9,7 @@ import {DateTime} from 'luxon'
import { translate as trans } from '../routes/api.js'
import * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import { setoptionfromtable } from "../utilities/chartoptions.js"
export const getNoiseData = async (params, possibles, props) => {
@@ -451,6 +452,9 @@ const getNoiseAVGData = async (opts) => {
for (let x=0; x < ret.values.length; x++) {
ret.values[x].datetime = DateTime.fromISO(ret.values[x].datetime).toUTC().minus({hours:1}).toFormat("yyyy-LL-dd'T'HH:mm:ss'Z'")
}
} else if (DBASE === 'victoria') {
// victoria.js buckets by hour-start itself (like mongo), so no shift is needed here
ret = await victoria.fetchNoiseAVGData(opts)
} else {
ret.err = 'DBASEUNKNOWN'
}
+97
View File
@@ -0,0 +1,97 @@
// Parse VictoriaMetrics' /api/v1/export ndjson responses into the same row
// shapes influx.js builds via csv2Json() / pivot(), for the noise-specific
// fields (measurement "noise", separator "_", i.e. metric names noise_LAeq,
// noise_LA_min, noise_LA_max, noise_E10tel_eq).
import { DateTime } from 'luxon'
const parseExportLines = (ndjsonBody) => {
return String(ndjsonBody)
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
try {
return JSON.parse(line)
} catch (e) {
return null
}
})
.filter((series) => series !== null)
}
const fieldName = (metricName) => metricName.replace(/^noise_/, '')
// Merges the per-field series (one ndjson line per field) back into rows of
// {datetime, LAeq, LA_min, LA_max, E10tel_eq}, matched by raw timestamp -
// mirrors influx's pivot(rowKey:["_time"], columnKey:["_field"]).
export const exportToRows = (ndjsonBody, sort) => {
const rowsByTime = new Map()
for (const series of parseExportLines(ndjsonBody)) {
const field = fieldName(series.metric.__name__)
const { values, timestamps } = series
for (let i = 0; i < timestamps.length; i++) {
const ts = timestamps[i]
let row = rowsByTime.get(ts)
if (!row) {
row = { datetime: DateTime.fromMillis(ts).toUTC().toISO() }
rowsByTime.set(ts, row)
}
row[field] = values[i]
}
}
const rows = Array.from(rowsByTime.values())
rows.sort((a, b) => (a.datetime < b.datetime ? -1 : a.datetime > b.datetime ? 1 : 0))
if (sort === -1) {
rows.reverse()
}
return rows
}
// Buckets raw E10tel_eq/LA_max samples by hour (truncated to the start of the
// hour of each sample, same convention as sensorapi/databases/mongo.js's
// $dateToString hour-grouping) and computes n_AVG/n_sum/count/peakcount -
// equivalent to influx.js's aggregateWindow()/reduce() Flux pipeline.
// Bucketing by hour-start (not hour-end, as Influx does) means no extra
// 1-hour shift is needed downstream, unlike the DBASE === 'influx' branch.
export const bucketNoiseAVG = (ndjsonBody, peak, long) => {
const buckets = new Map()
for (const series of parseExportLines(ndjsonBody)) {
const field = fieldName(series.metric.__name__)
if (field !== 'E10tel_eq' && field !== 'LA_max') {
continue
}
const { values, timestamps } = series
for (let i = 0; i < timestamps.length; i++) {
const key = DateTime.fromMillis(timestamps[i]).toUTC().startOf('hour').toISO()
let b = buckets.get(key)
if (!b) {
b = { sum: 0, count: 0, peakcount: 0 }
buckets.set(key, b)
}
if (field === 'E10tel_eq') {
b.sum += values[i]
b.count += 1
} else {
if (values[i] >= peak) {
b.peakcount += 1
}
}
}
}
const rows = Array.from(buckets.entries())
.filter(([, b]) => b.count > 0) // matches Influx's inner join: hours without E10tel_eq samples are dropped
.map(([datetime, b]) => {
let row = {
datetime,
n_AVG: 10 * Math.log10(b.sum / b.count),
peakcount: b.peakcount,
}
if (long) {
row.count = b.count
row.n_sum = b.sum
}
return row
})
rows.sort((a, b) => (a.datetime < b.datetime ? -1 : a.datetime > b.datetime ? 1 : 0))
return rows
}