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() }