Add admin api for deleting invalid metrics (#32666)

This commit is contained in:
andig 2026-08-09 16:30:34 +02:00 • committed by GitHub
parent 5d1f1193bb
commit 5ab5cfc2ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 589 additions and 12 deletions

View file

@ -0,0 +1,104 @@
package metrics
import (
"testing"
"time"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
func TestDeleteEnergy(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
ePv := entity{Id: 2, Name: "pv1", Group: PV}
require.NoError(t, db.Instance.Create(&ePv).Error)
eFc := entity{Id: 3, Name: Forecast, Group: Forecast}
require.NoError(t, db.Instance.Create(&eFc).Error)
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
for i := range 4 {
ts := base.Add(time.Duration(i) * 15 * time.Minute)
require.NoError(t, persist(ePv, ts, 1, 0, nil, false))
require.NoError(t, persist(eFc, ts, 2, 0, nil, false))
}
count := func() int64 {
var n int64
require.NoError(t, db.Instance.Model(new(meter)).Count(&n).Error)
return n
}
require.Equal(t, int64(8), count())
// both bounds are required
_, err := DeleteEnergy(time.Time{}, base, EnergyFilter{})
require.Error(t, err)
// filter narrows to the matching entity, range is half-open
rows, err := DeleteEnergy(base.UTC(), base.Add(30*time.Minute).UTC(), EnergyFilter{Group: Forecast})
require.NoError(t, err)
require.Equal(t, int64(2), rows)
require.Equal(t, int64(6), count())
// pv untouched
res, err := QueryEnergy(base.Add(-time.Hour).UTC(), base.Add(time.Hour).UTC(), "hour", false, EnergyFilter{Group: PV})
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, 4.0, res[0].Data[0].Energy)
// empty filter deletes across entities
rows, err = DeleteEnergy(base.UTC(), base.Add(time.Hour).UTC(), EnergyFilter{})
require.NoError(t, err)
require.Equal(t, int64(6), rows)
require.Zero(t, count())
}
func TestDeleteTariffs(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, db.Instance.AutoMigrate(new(tariffValue)))
base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.UTC)
grid, co2 := 0.3, 250.0
for i := range 4 {
require.NoError(t, PersistTariffs(base.Add(time.Duration(i)*15*time.Minute), &grid, nil, &co2, nil))
}
count := func() int64 {
var n int64
require.NoError(t, db.Instance.Model(new(tariffValue)).Count(&n).Error)
return n
}
_, err := DeleteTariffs(base, time.Time{}, "")
require.Error(t, err)
// unknown usage never reaches the column interpolation
_, err = DeleteTariffs(base, base.Add(time.Hour), "unknown")
require.ErrorIs(t, err, ErrInvalidUsage)
require.Equal(t, int64(4), count())
// clearing one usage keeps the row as long as another value remains
rows, err := DeleteTariffs(base, base.Add(30*time.Minute), "grid")
require.NoError(t, err)
require.Equal(t, int64(2), rows)
require.Equal(t, int64(4), count())
var res tariffValue
require.NoError(t, db.Instance.Where("ts = ?", base.Unix()).First(&res).Error)
require.Nil(t, res.Grid)
require.InDelta(t, 250, *res.Co2, 0.001)
// clearing the last usage drops the now empty rows
rows, err = DeleteTariffs(base, base.Add(30*time.Minute), "co2")
require.NoError(t, err)
require.Equal(t, int64(2), rows)
require.Equal(t, int64(2), count())
// no usage drops the whole row
rows, err = DeleteTariffs(base, base.Add(time.Hour), "")
require.NoError(t, err)
require.Equal(t, int64(2), rows)
require.Zero(t, count())
}

View file

@ -10,6 +10,7 @@ import (
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util/export"
"gorm.io/gorm"
)
// Slot represents an aggregated energy time slot
@ -62,6 +63,47 @@ type EnergyFilter struct {
Title string
}
// entityQuery returns a subquery selecting the ids of the matching entities,
// nil for an empty filter.
func entityQuery(f EnergyFilter) *gorm.DB {
if f.Group == "" && f.Name == "" && f.Title == "" {
return nil
}
tx := db.Instance.Model(new(entity)).Select("id")
if f.Group != "" {
tx = tx.Where(`"group" = ?`, f.Group)
}
if f.Name != "" {
tx = tx.Where("name = ?", f.Name)
}
if f.Title != "" {
tx = tx.Where("title = ?", f.Title)
}
return tx
}
// DeleteEnergy removes the slots in [from,to), narrowed to the matching
// entities. Both bounds are required, a full wipe is /api/db/reset.
func DeleteEnergy(from, to time.Time, filter ...EnergyFilter) (int64, error) {
if from.IsZero() || to.IsZero() {
return 0, errors.New("missing from/to")
}
tx := db.Instance.Where("ts >= ? AND ts < ?", from.Unix(), to.Unix())
if len(filter) > 0 {
if sub := entityQuery(filter[0]); sub != nil {
tx = tx.Where("meter IN (?)", sub)
}
}
res := tx.Delete(new(meter))
return res.RowsAffected, res.Error
}
// QueryEnergy returns aggregated energy data, per title or per group.
func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...EnergyFilter) ([]Series, error) {
addDuration := aggregateDurations[aggregate]
@ -114,15 +156,8 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...E
}
if len(filter) > 0 {
f := filter[0]
if f.Group != "" {
tx = tx.Where(`e."group" = ?`, f.Group)
}
if f.Name != "" {
tx = tx.Where("e.name = ?", f.Name)
}
if f.Title != "" {
tx = tx.Where("e.title = ?", f.Title)
if sub := entityQuery(filter[0]); sub != nil {
tx = tx.Where("m.meter IN (?)", sub)
}
}

View file

@ -1,6 +1,10 @@
package metrics
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/server/db"
@ -26,6 +30,50 @@ func init() {
})
}
// ErrInvalidUsage is returned for an unknown tariff usage
var ErrInvalidUsage = errors.New("invalid usage")
// tariffUsages are the deletable usages, named after their table column
var tariffUsages = []string{"grid", "feedin", "co2", "temperature"}
// DeleteTariffs removes the persisted values in [from,to). An empty usage drops
// the entire row, otherwise only that usage is cleared. Both bounds are
// required, a full wipe is /api/db/reset. The count is the number of affected
// rows; rows dropped by the cleanup are a subset of the cleared ones.
func DeleteTariffs(from, to time.Time, usage string) (int64, error) {
if from.IsZero() || to.IsZero() {
return 0, errors.New("missing from/to")
}
inRange := func() *gorm.DB {
return db.Instance.Where("ts >= ? AND ts < ?", from.Unix(), to.Unix())
}
if usage == "" {
res := inRange().Delete(new(tariffValue))
return res.RowsAffected, res.Error
}
// guards the column interpolated below
if !slices.Contains(tariffUsages, usage) {
return 0, fmt.Errorf("%w: %s (valid: %s)", ErrInvalidUsage, usage, strings.Join(tariffUsages, ", "))
}
res := inRange().Model(new(tariffValue)).
Where(usage+" IS NOT NULL").
Update(usage, gorm.Expr("NULL"))
if res.Error != nil {
return 0, res.Error
}
// drop the rows that no longer hold any value
err := inRange().
Where("grid IS NULL AND feedin IS NULL AND co2 IS NULL AND temperature IS NULL").
Delete(new(tariffValue)).Error
return res.RowsAffected, err
}
// PersistTariffs stores the tariff values at the given 15min boundary, nil values omitted
func PersistTariffs(ts time.Time, grid, feedin, co2, temperature *float64) error {
if grid == nil && feedin == nil && co2 == nil && temperature == nil {

View file

@ -438,9 +438,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
api.Use(ensureDbAuth(auth))
routes := map[string]route{
"backup": {"GET", "/backup", getBackup()},
"restore": {"POST", "/restore", restoreDatabase(shutdown)},
"reset": {"POST", "/reset", resetDatabase(shutdown)},
"backup": {"GET", "/backup", getBackup()},
"restore": {"POST", "/restore", restoreDatabase(shutdown)},
"reset": {"POST", "/reset", resetDatabase(shutdown)},
"deleteenergy": {"DELETE", "/metrics/energy", deleteEnergyHandler},
"deletetariffs": {"DELETE", "/metrics/tariffs", deleteTariffsHandler},
}
for _, r := range routes {

View file

@ -0,0 +1,88 @@
package server
import (
"errors"
"net/http"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
)
// deleteResult reports the number of affected rows
type deleteResult struct {
Deleted int64 `json:"deleted"`
}
// timeRange parses the mandatory from/to query parameters
func timeRange(r *http.Request) (time.Time, time.Time, error) {
q := r.URL.Query()
from, err := time.Parse(time.RFC3339, q.Get("from"))
if err != nil {
return time.Time{}, time.Time{}, errors.New("invalid 'from' parameter")
}
to, err := time.Parse(time.RFC3339, q.Get("to"))
if err != nil {
return time.Time{}, time.Time{}, errors.New("invalid 'to' parameter")
}
return from, to, nil
}
// deleteEnergyHandler removes energy metrics. Parameters match
// /api/history/energy so the same request can be previewed before deleting.
func deleteEnergyHandler(w http.ResponseWriter, r *http.Request) {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
from, to, err := timeRange(r)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
q := r.URL.Query()
filter := metrics.EnergyFilter{
Group: q.Get("group"),
Name: q.Get("name"),
Title: q.Get("title"),
}
rows, err := metrics.DeleteEnergy(from, to, filter)
if err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
jsonWrite(w, deleteResult{rows})
}
// deleteTariffsHandler removes persisted tariff values
func deleteTariffsHandler(w http.ResponseWriter, r *http.Request) {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
from, to, err := timeRange(r)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
rows, err := metrics.DeleteTariffs(from, to, r.URL.Query().Get("usage"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, metrics.ErrInvalidUsage) {
status = http.StatusBadRequest
}
jsonError(w, status, err)
return
}
jsonWrite(w, deleteResult{rows})
}

181
server/mcp/openapi.json generated
View file

@ -2244,6 +2244,171 @@
}
}
},
"/db/metrics/energy": {
"delete": {
"operationId": "deleteEnergyMetrics",
"summary": "Delete energy metrics",
"description": "Deletes recorded energy slots in the given time range, optionally narrowed by entity. Filter parameters match /history/energy, so the same request can be previewed there before deleting. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.",
"tags": [
"db"
],
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/adminPassword"
},
{
"name": "from",
"in": "query",
"required": true,
"description": "Start time, inclusive (RFC3339)",
"schema": {
"type": "string",
"format": "date-time",
"example": "2026-07-01T00:00:00Z"
}
},
{
"name": "to",
"in": "query",
"required": true,
"description": "End time, exclusive (RFC3339)",
"schema": {
"type": "string",
"format": "date-time",
"example": "2026-07-02T00:00:00Z"
}
},
{
"name": "group",
"in": "query",
"description": "Filter by entity group",
"schema": {
"type": "string",
"example": "forecast",
"enum": [
"forecast",
"battery",
"grid",
"pv",
"home",
"loadpoint",
"meter",
"consumer",
"temperature"
]
}
},
{
"name": "name",
"in": "query",
"description": "Filter by entity name",
"schema": {
"type": "string",
"example": "db:8"
}
},
{
"name": "title",
"in": "query",
"description": "Filter by entity title",
"schema": {
"type": "string",
"example": "Battery"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Deleted"
},
"400": {
"description": "Invalid parameters or database offline"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
}
}
}
},
"/db/metrics/tariffs": {
"delete": {
"operationId": "deleteTariffMetrics",
"summary": "Delete tariff metrics",
"description": "Deletes recorded tariff values in the given time range. Without usage the entire record is removed, otherwise only that usage is cleared and records left without any value are removed. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.",
"tags": [
"db"
],
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/adminPassword"
},
{
"name": "from",
"in": "query",
"required": true,
"description": "Start time, inclusive (RFC3339)",
"schema": {
"type": "string",
"format": "date-time",
"example": "2026-07-01T00:00:00Z"
}
},
{
"name": "to",
"in": "query",
"required": true,
"description": "End time, exclusive (RFC3339)",
"schema": {
"type": "string",
"format": "date-time",
"example": "2026-07-02T00:00:00Z"
}
},
{
"name": "usage",
"in": "query",
"description": "Limit deletion to a single tariff usage",
"schema": {
"type": "string",
"example": "co2",
"enum": [
"grid",
"feedin",
"co2",
"temperature"
]
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Deleted"
},
"400": {
"description": "Invalid parameters or database offline"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
}
}
}
},
"/history/energy": {
"get": {
"operationId": "getEnergyHistory",
@ -5296,6 +5461,22 @@
}
},
"responses": {
"Deleted": {
"description": "Success - number of deleted records",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deleted": {
"type": "integer",
"example": 96
}
}
}
}
}
},
"NanoSecondsResult": {
"description": "Success - Number result - Unit is **nanoseconds**",
"content": {

View file

@ -1353,6 +1353,115 @@ paths:
description: Reset successful, instance is restarting
"401":
$ref: "#/components/responses/Unauthorized"
/db/metrics/energy:
delete:
operationId: deleteEnergyMetrics
summary: Delete energy metrics
description: "Deletes recorded energy slots in the given time range, optionally narrowed by entity. Filter parameters match /history/energy, so the same request can be previewed there before deleting. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt."
tags:
- db
security:
- cookieAuth: []
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/adminPassword"
- name: from
in: query
required: true
description: Start time, inclusive (RFC3339)
schema:
type: string
format: date-time
example: "2026-07-01T00:00:00Z"
- name: to
in: query
required: true
description: End time, exclusive (RFC3339)
schema:
type: string
format: date-time
example: "2026-07-02T00:00:00Z"
- name: group
in: query
description: Filter by entity group
schema:
type: string
example: forecast
enum:
- forecast
- battery
- grid
- pv
- home
- loadpoint
- meter
- consumer
- temperature
- name: name
in: query
description: Filter by entity name
schema:
type: string
example: db:8
- name: title
in: query
description: Filter by entity title
schema:
type: string
example: Battery
responses:
"200":
$ref: "#/components/responses/Deleted"
"400":
description: Invalid parameters or database offline
"401":
$ref: "#/components/responses/Unauthorized"
/db/metrics/tariffs:
delete:
operationId: deleteTariffMetrics
summary: Delete tariff metrics
description: "Deletes recorded tariff values in the given time range. Without usage the entire record is removed, otherwise only that usage is cleared and records left without any value are removed. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt."
tags:
- db
security:
- cookieAuth: []
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/adminPassword"
- name: from
in: query
required: true
description: Start time, inclusive (RFC3339)
schema:
type: string
format: date-time
example: "2026-07-01T00:00:00Z"
- name: to
in: query
required: true
description: End time, exclusive (RFC3339)
schema:
type: string
format: date-time
example: "2026-07-02T00:00:00Z"
- name: usage
in: query
description: Limit deletion to a single tariff usage
schema:
type: string
example: co2
enum:
- grid
- feedin
- co2
- temperature
responses:
"200":
$ref: "#/components/responses/Deleted"
"400":
description: Invalid parameters or database offline
"401":
$ref: "#/components/responses/Unauthorized"
/history/energy:
get:
operationId: getEnergyHistory
@ -1816,6 +1925,16 @@ components:
- "1"
- "0"
responses:
Deleted:
description: Success - number of deleted records
content:
application/json:
schema:
type: object
properties:
deleted:
type: integer
example: 96
NanoSecondsResult:
description: Success - Number result - Unit is **nanoseconds**
content: