4ec62f5ed6
- Migra el frontend a Mantine v9 siguiendo la regla de theming del registry (@fn_library, sin Tailwind/cn/CVA). - Reescribe DashboardShell, FilterBar, Section, WidgetRenderer y todos los widgets (Area/Bar/Line/Pie/KPI/Sparkline/Table) con componentes y props de Mantine. - Ajusta vite.config, main.tsx, App.tsx, app.css y env.d.ts. - Añade postcss.config.cjs requerido por Mantine. - Actualiza package.json y pnpm-lock. - Ajusta config.go, main.go y los ejemplos (fn_registry_apps/overview) para el nuevo esquema de tipos en frontend/src/types.ts.
232 lines
6.9 KiB
Go
232 lines
6.9 KiB
Go
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"`
|
|
Layout string `yaml:"layout" json:"layout"` // "scrollable" (default) or "single_view"
|
|
}
|
|
|
|
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 c.Settings.Layout == "" {
|
|
c.Settings.Layout = "scrollable"
|
|
}
|
|
if c.Settings.Layout != "scrollable" && c.Settings.Layout != "single_view" {
|
|
return fmt.Errorf("settings.layout must be 'scrollable' or 'single_view', got %q", c.Settings.Layout)
|
|
}
|
|
|
|
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()
|
|
}
|