This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package widgetpreview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxSeries = 20
|
||||
MaxPoints = 2000
|
||||
MaxRows = 1000
|
||||
)
|
||||
|
||||
var validTypes = map[string]bool{
|
||||
"stat": true, "timeseries": true, "gauge": true, "ranked-list": true,
|
||||
"status-grid": true, "table": true, "heatmap": true, "event-timeline": true,
|
||||
"storage-map": true, "topology": true, "service-matrix": true, "alert-summary": true,
|
||||
"text": true, "query-inspector": true,
|
||||
}
|
||||
|
||||
var validSources = map[string]bool{
|
||||
"semantic-metric": true, "inventory": true, "events": true,
|
||||
"alerts": true, "incidents": true, "text": true,
|
||||
}
|
||||
|
||||
var validStates = map[string]string{
|
||||
"loading": "Voorbeeld wordt geladen.",
|
||||
"empty": "Geen gegevens binnen de ingestelde scope.",
|
||||
"error": "De bron gaf een fout terug.",
|
||||
"stale": "De gegevens zijn verouderd en moeten opnieuw worden gecontroleerd.",
|
||||
}
|
||||
|
||||
var validRanges = map[string]bool{"live": true, "15m": true, "1h": true, "6h": true, "24h": true, "7d": true}
|
||||
var validAggregations = map[string]bool{"avg": true, "min": true, "max": true, "sum": true, "last": true}
|
||||
|
||||
type InvalidConfig struct{ Fields map[string]string }
|
||||
|
||||
func (e InvalidConfig) Error() string { return "widget configuration is invalid" }
|
||||
|
||||
type Limits struct {
|
||||
MaxSeries int `json:"maxSeries"`
|
||||
MaxPoints int `json:"maxPoints"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
RequestedRows int `json:"requestedRows"`
|
||||
AppliedRows int `json:"appliedRows"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
Sample map[string]any `json:"sample"`
|
||||
Limits Limits `json:"limits"`
|
||||
}
|
||||
|
||||
func Preview(widget map[string]any, state string) (Response, error) {
|
||||
fields := Validate(widget)
|
||||
if len(fields) > 0 {
|
||||
return Response{}, InvalidConfig{Fields: fields}
|
||||
}
|
||||
if state == "" {
|
||||
state = "loading"
|
||||
}
|
||||
message, ok := validStates[state]
|
||||
if !ok {
|
||||
return Response{}, InvalidConfig{Fields: map[string]string{"state": "Kies loading, empty, error of stale."}}
|
||||
}
|
||||
data, _ := object(widget["data"])
|
||||
requested := intValue(data["limit"], 100)
|
||||
applied := requested
|
||||
if applied > MaxRows {
|
||||
applied = MaxRows
|
||||
}
|
||||
return Response{
|
||||
Type: stringValue(widget["type"]),
|
||||
Title: stringValue(widget["title"]),
|
||||
State: state,
|
||||
Message: message,
|
||||
Sample: map[string]any{"status": state, "text": message},
|
||||
Limits: Limits{MaxSeries: MaxSeries, MaxPoints: MaxPoints, MaxRows: MaxRows, RequestedRows: requested, AppliedRows: applied},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Validate(widget map[string]any) map[string]string {
|
||||
fields := make(map[string]string)
|
||||
if id := stringValue(widget["id"]); id == "" {
|
||||
fields["id"] = "Een widget-id is verplicht."
|
||||
}
|
||||
typ := stringValue(widget["type"])
|
||||
if !validTypes[typ] {
|
||||
fields["type"] = "Dit widgettype wordt niet ondersteund."
|
||||
}
|
||||
title := stringValue(widget["title"])
|
||||
if title == "" {
|
||||
fields["title"] = "Een titel is verplicht."
|
||||
} else if len([]rune(title)) > 120 {
|
||||
fields["title"] = "De titel mag maximaal 120 tekens bevatten."
|
||||
}
|
||||
data, ok := object(widget["data"])
|
||||
if !ok {
|
||||
fields["data"] = "Dataconfiguratie is verplicht."
|
||||
} else {
|
||||
source := stringValue(data["sourceType"])
|
||||
if !validSources[source] {
|
||||
fields["data.sourceType"] = "Kies een ondersteund brontype."
|
||||
}
|
||||
if source == "semantic-metric" {
|
||||
metric := stringValue(data["metric"])
|
||||
if metric == "" {
|
||||
fields["data.metric"] = "Een semantische metric is verplicht."
|
||||
}
|
||||
if strings.Contains(strings.ToLower(metric), "promql") || strings.Contains(strings.ToLower(metric), "query=") {
|
||||
fields["data.metric"] = "Gebruik een semantische metric; vrije PromQL is niet toegestaan."
|
||||
}
|
||||
}
|
||||
if value := stringValue(data["range"]); value != "" && !validRanges[value] && !strings.HasPrefix(value, "$") {
|
||||
fields["data.range"] = "Kies een begrensde periode."
|
||||
}
|
||||
if value := stringValue(data["aggregation"]); value != "" && !validAggregations[value] {
|
||||
fields["data.aggregation"] = "Kies een ondersteunde aggregatie."
|
||||
}
|
||||
if number, ok := numberValue(data["limit"]); ok && (number < 1 || number > MaxRows || math.Trunc(number) != number) {
|
||||
fields["data.limit"] = fmt.Sprintf("De limiet moet een geheel getal tussen 1 en %d zijn.", MaxRows)
|
||||
}
|
||||
}
|
||||
if visualization, ok := object(widget["visualization"]); ok {
|
||||
if number, ok := numberValue(visualization["decimals"]); ok && (number < 0 || number > 6 || math.Trunc(number) != number) {
|
||||
fields["visualization.decimals"] = "Decimalen moeten tussen 0 en 6 liggen."
|
||||
}
|
||||
minimum, hasMin := numberValue(visualization["min"])
|
||||
maximum, hasMax := numberValue(visualization["max"])
|
||||
if hasMin && hasMax && minimum > maximum {
|
||||
fields["visualization.max"] = "Maximum moet groter of gelijk aan minimum zijn."
|
||||
}
|
||||
if thresholds, ok := visualization["thresholds"].([]any); ok && len(thresholds) > 10 {
|
||||
fields["visualization.thresholds"] = "Er zijn maximaal 10 drempels toegestaan."
|
||||
}
|
||||
}
|
||||
if behavior, ok := object(widget["behavior"]); ok {
|
||||
if number, ok := numberValue(behavior["liveIntervalSeconds"]); ok && (number < 1 || number > 300 || math.Trunc(number) != number) {
|
||||
fields["behavior.liveIntervalSeconds"] = "Verversing moet tussen 1 en 300 seconden liggen."
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func object(value any) (map[string]any, bool) {
|
||||
result, ok := value.(map[string]any)
|
||||
return result, ok
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func numberValue(value any) (float64, bool) {
|
||||
switch result := value.(type) {
|
||||
case float64:
|
||||
return result, true
|
||||
case float32:
|
||||
return float64(result), true
|
||||
case int:
|
||||
return float64(result), true
|
||||
case int64:
|
||||
return float64(result), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value any, fallback int) int {
|
||||
number, ok := numberValue(value)
|
||||
if !ok || number < 1 || math.Trunc(number) != number {
|
||||
return fallback
|
||||
}
|
||||
return int(number)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package widgetpreview
|
||||
|
||||
import "testing"
|
||||
|
||||
func validWidget() map[string]any {
|
||||
return map[string]any{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"type": "timeseries",
|
||||
"title": "CPU",
|
||||
"data": map[string]any{"sourceType": "semantic-metric", "metric": "host.cpu.utilization", "range": "1h", "aggregation": "avg", "limit": float64(100)},
|
||||
"visualization": map[string]any{"decimals": float64(1), "min": float64(0), "max": float64(100), "thresholds": []any{}},
|
||||
"behavior": map[string]any{"liveIntervalSeconds": float64(5)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnboundedOrRawConfiguration(t *testing.T) {
|
||||
widget := validWidget()
|
||||
widget["data"].(map[string]any)["metric"] = "promql:up"
|
||||
if fields := Validate(widget); fields["data.metric"] == "" {
|
||||
t.Fatal("expected raw query rejection")
|
||||
}
|
||||
widget["data"].(map[string]any)["limit"] = float64(1001)
|
||||
if fields := Validate(widget); fields["data.limit"] == "" {
|
||||
t.Fatal("expected limit rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvertedThresholdRange(t *testing.T) {
|
||||
widget := validWidget()
|
||||
widget["visualization"].(map[string]any)["min"] = float64(90)
|
||||
widget["visualization"].(map[string]any)["max"] = float64(10)
|
||||
if fields := Validate(widget); fields["visualization.max"] == "" {
|
||||
t.Fatal("expected min/max rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewReturnsExplicitStatesAndLimits(t *testing.T) {
|
||||
widget := validWidget()
|
||||
widget["data"].(map[string]any)["limit"] = float64(1000)
|
||||
for _, state := range []string{"loading", "empty", "error", "stale"} {
|
||||
result, err := Preview(widget, state)
|
||||
if err != nil {
|
||||
t.Fatalf("state %s: %v", state, err)
|
||||
}
|
||||
if result.State != state || result.Limits.AppliedRows != 1000 || result.Limits.MaxPoints != MaxPoints {
|
||||
t.Fatalf("unexpected %s result: %+v", state, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user