Files
egutierrez 4ec62f5ed6 refactor(frontend): migracion a Mantine y limpieza de widgets
- 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.
2026-04-13 23:33:04 +02:00

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