init: rapid_dashboards app from fn_registry
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DashboardConfig is the top-level YAML structure.
|
||||
type DashboardConfig struct {
|
||||
Settings Settings `yaml:"settings" json:"settings"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
Connections map[string]ConnConfig `yaml:"connections" json:"-"`
|
||||
Queries map[string]QueryDef `yaml:"queries" json:"queries"`
|
||||
Filters map[string]FilterDef `yaml:"filters" json:"filters"`
|
||||
Sections []SectionDef `yaml:"sections" json:"sections"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Refresh string `yaml:"refresh" json:"refresh"` // duration: "30s", "200ms"
|
||||
Width int `yaml:"width" json:"width"`
|
||||
Height int `yaml:"height" json:"height"`
|
||||
Columns int `yaml:"columns" json:"columns"`
|
||||
}
|
||||
|
||||
type ConnConfig struct {
|
||||
Driver string `yaml:"driver"`
|
||||
Path string `yaml:"path"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Database string `yaml:"database"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
type QueryDef struct {
|
||||
Connection string `yaml:"connection" json:"connection"`
|
||||
SQL string `yaml:"sql" json:"-"`
|
||||
Refresh string `yaml:"refresh" json:"refresh"`
|
||||
StaleTime string `yaml:"stale_time" json:"staleTime"`
|
||||
Params map[string]string `yaml:"params" json:"params"`
|
||||
RefreshMs int64 `json:"refreshMs"`
|
||||
StaleMs int64 `json:"staleMs"`
|
||||
}
|
||||
|
||||
type FilterDef struct {
|
||||
Type string `yaml:"type" json:"type"` // select, date_range, text
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Default any `yaml:"default" json:"default"`
|
||||
Options []FilterOption `yaml:"options" json:"options,omitempty"`
|
||||
Presets []FilterPreset `yaml:"presets" json:"presets,omitempty"`
|
||||
Placeholder string `yaml:"placeholder" json:"placeholder,omitempty"`
|
||||
Debounce int `yaml:"debounce" json:"debounce,omitempty"`
|
||||
}
|
||||
|
||||
type FilterOption struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Value string `yaml:"value" json:"value"`
|
||||
}
|
||||
|
||||
type FilterPreset struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
From string `yaml:"from" json:"from"`
|
||||
To string `yaml:"to" json:"to"`
|
||||
}
|
||||
|
||||
type SectionDef struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Collapsible bool `yaml:"collapsible" json:"collapsible"`
|
||||
Columns int `yaml:"columns" json:"columns,omitempty"`
|
||||
Widgets []WidgetDef `yaml:"widgets" json:"widgets"`
|
||||
}
|
||||
|
||||
type WidgetDef struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Type string `yaml:"type" json:"type"` // kpi, line_chart, bar_chart, area_chart, sparkline, table
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Query string `yaml:"query" json:"query"`
|
||||
Mapping map[string]any `yaml:"mapping" json:"mapping"`
|
||||
Options map[string]any `yaml:"options" json:"options,omitempty"`
|
||||
Span int `yaml:"span" json:"span"`
|
||||
RowSpan int `yaml:"row_span" json:"rowSpan,omitempty"`
|
||||
}
|
||||
|
||||
// LoadDashboard reads and parses a YAML dashboard file.
|
||||
func LoadDashboard(path string) (*DashboardConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading dashboard file: %w", err)
|
||||
}
|
||||
|
||||
// Expand env vars in the raw YAML (for ${VAR} in passwords etc.)
|
||||
expanded := os.ExpandEnv(string(data))
|
||||
|
||||
var cfg DashboardConfig
|
||||
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.computeRefreshMs()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *DashboardConfig) validate() error {
|
||||
if c.Settings.Title == "" {
|
||||
return fmt.Errorf("settings.title is required")
|
||||
}
|
||||
if c.Settings.Columns == 0 {
|
||||
c.Settings.Columns = 12
|
||||
}
|
||||
if c.Settings.Width == 0 {
|
||||
c.Settings.Width = 1280
|
||||
}
|
||||
if c.Settings.Height == 0 {
|
||||
c.Settings.Height = 800
|
||||
}
|
||||
if c.Theme == "" {
|
||||
c.Theme = "dark"
|
||||
}
|
||||
|
||||
if len(c.Connections) == 0 {
|
||||
return fmt.Errorf("at least one connection is required")
|
||||
}
|
||||
for name, conn := range c.Connections {
|
||||
switch conn.Driver {
|
||||
case "sqlite", "duckdb":
|
||||
if conn.Path == "" {
|
||||
return fmt.Errorf("connection %q: path is required for %s", name, conn.Driver)
|
||||
}
|
||||
case "postgres":
|
||||
if conn.Host == "" || conn.Database == "" {
|
||||
return fmt.Errorf("connection %q: host and database are required for postgres", name)
|
||||
}
|
||||
case "clickhouse":
|
||||
if conn.Host == "" || conn.Database == "" {
|
||||
return fmt.Errorf("connection %q: host and database are required for clickhouse", name)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("connection %q: unsupported driver %q (use sqlite, postgres, duckdb, clickhouse)", name, conn.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
for name, q := range c.Queries {
|
||||
if q.Connection == "" {
|
||||
return fmt.Errorf("query %q: connection is required", name)
|
||||
}
|
||||
if _, ok := c.Connections[q.Connection]; !ok {
|
||||
return fmt.Errorf("query %q: connection %q not found", name, q.Connection)
|
||||
}
|
||||
if strings.TrimSpace(q.SQL) == "" {
|
||||
return fmt.Errorf("query %q: sql is required", name)
|
||||
}
|
||||
}
|
||||
|
||||
for name, f := range c.Filters {
|
||||
switch f.Type {
|
||||
case "select", "date_range", "text":
|
||||
default:
|
||||
return fmt.Errorf("filter %q: unsupported type %q (use select, date_range, text)", name, f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Sections) == 0 {
|
||||
return fmt.Errorf("at least one section with widgets is required")
|
||||
}
|
||||
widgetIDs := make(map[string]bool)
|
||||
for _, sec := range c.Sections {
|
||||
if sec.ID == "" {
|
||||
return fmt.Errorf("section id is required")
|
||||
}
|
||||
for _, w := range sec.Widgets {
|
||||
if w.ID == "" {
|
||||
return fmt.Errorf("section %q: widget id is required", sec.ID)
|
||||
}
|
||||
if widgetIDs[w.ID] {
|
||||
return fmt.Errorf("duplicate widget id %q", w.ID)
|
||||
}
|
||||
widgetIDs[w.ID] = true
|
||||
if w.Query == "" {
|
||||
return fmt.Errorf("widget %q: query is required", w.ID)
|
||||
}
|
||||
if _, ok := c.Queries[w.Query]; !ok {
|
||||
return fmt.Errorf("widget %q: query %q not found", w.ID, w.Query)
|
||||
}
|
||||
if w.Span == 0 {
|
||||
w.Span = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeRefreshMs converts duration strings to milliseconds for the frontend.
|
||||
func (c *DashboardConfig) computeRefreshMs() {
|
||||
globalRefresh := parseDurationMs(c.Settings.Refresh, 30000)
|
||||
|
||||
for name, q := range c.Queries {
|
||||
q.RefreshMs = parseDurationMs(q.Refresh, globalRefresh)
|
||||
q.StaleMs = parseDurationMs(q.StaleTime, q.RefreshMs/2)
|
||||
c.Queries[name] = q
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationMs(s string, fallback int64) int64 {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d.Milliseconds()
|
||||
}
|
||||
Reference in New Issue
Block a user