init: rapid_dashboards app from fn_registry
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Binarios compilados
|
||||||
|
rapid-dashboards
|
||||||
|
rapid-dashboards.exe
|
||||||
|
build/bin/
|
||||||
|
build/windows/wails.exe.manifest
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Base de datos
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Wails
|
||||||
|
wailsjs/
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App struct — each public method is an IPC binding to the frontend.
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
config *DashboardConfig
|
||||||
|
engine *QueryEngine
|
||||||
|
pool map[string]*sql.DB
|
||||||
|
dashboardDir string // directory to scan for YAML dashboards
|
||||||
|
currentFile string // currently loaded YAML file
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(cfg *DashboardConfig, engine *QueryEngine, pool map[string]*sql.DB, dashDir, currentFile string) *App {
|
||||||
|
return &App{
|
||||||
|
config: cfg,
|
||||||
|
engine: engine,
|
||||||
|
pool: pool,
|
||||||
|
dashboardDir: dashDir,
|
||||||
|
currentFile: currentFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardConfig returns the full config for the frontend to render.
|
||||||
|
func (a *App) GetDashboardConfig() *DashboardConfig {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
log.Printf("[IPC] GetDashboardConfig — title=%q sections=%d", a.config.Settings.Title, len(a.config.Sections))
|
||||||
|
return a.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWidgetData executes the query for a widget with current filter values.
|
||||||
|
func (a *App) GetWidgetData(widgetID string, filters map[string]any) ([]map[string]any, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := a.engine.Execute(widgetID, filters)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[IPC] GetWidgetData ERROR — widget=%q err=%v", widgetID, err)
|
||||||
|
return nil, fmt.Errorf("widget %q: %w", widgetID, err)
|
||||||
|
}
|
||||||
|
log.Printf("[IPC] GetWidgetData OK — widget=%q rows=%d", widgetID, len(rows))
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardInfo is a summary of an available dashboard file.
|
||||||
|
type DashboardInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDashboards returns all .yaml files in the dashboards directory.
|
||||||
|
func (a *App) ListDashboards() []DashboardInfo {
|
||||||
|
a.mu.RLock()
|
||||||
|
currentFile := a.currentFile
|
||||||
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
var dashboards []DashboardInfo
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(a.dashboardDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[IPC] ListDashboards ERROR — %v", err)
|
||||||
|
return dashboards
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || (!strings.HasSuffix(e.Name(), ".yaml") && !strings.HasSuffix(e.Name(), ".yml")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(a.dashboardDir, e.Name())
|
||||||
|
cfg, err := LoadDashboard(filePath)
|
||||||
|
title := e.Name()
|
||||||
|
theme := ""
|
||||||
|
if err == nil {
|
||||||
|
title = cfg.Settings.Title
|
||||||
|
theme = cfg.Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboards = append(dashboards, DashboardInfo{
|
||||||
|
Name: strings.TrimSuffix(strings.TrimSuffix(e.Name(), ".yaml"), ".yml"),
|
||||||
|
File: e.Name(),
|
||||||
|
Title: title,
|
||||||
|
Theme: theme,
|
||||||
|
Current: filePath == currentFile || e.Name() == filepath.Base(currentFile),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[IPC] ListDashboards — found %d", len(dashboards))
|
||||||
|
return dashboards
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchDashboard loads a different dashboard YAML by filename.
|
||||||
|
func (a *App) SwitchDashboard(fileName string) (*DashboardConfig, error) {
|
||||||
|
filePath := filepath.Join(a.dashboardDir, fileName)
|
||||||
|
|
||||||
|
log.Printf("[IPC] SwitchDashboard — loading %q", filePath)
|
||||||
|
|
||||||
|
cfg, err := LoadDashboard(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading %q: %w", fileName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPool, err := OpenConnections(cfg.Connections, filepath.Dir(filePath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connections for %q: %w", fileName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
// Close old connections
|
||||||
|
if a.pool != nil {
|
||||||
|
CloseConnections(a.pool)
|
||||||
|
}
|
||||||
|
a.config = cfg
|
||||||
|
a.pool = newPool
|
||||||
|
a.engine = NewQueryEngine(cfg, newPool)
|
||||||
|
a.currentFile = filePath
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[IPC] SwitchDashboard OK — title=%q sections=%d", cfg.Settings.Title, len(cfg.Sections))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "{{.Info.ProductVersion}}"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||||
|
"CompanyName": "{{.Info.CompanyName}}",
|
||||||
|
"FileDescription": "{{.Info.ProductName}}",
|
||||||
|
"LegalCopyright": "{{.Info.Copyright}}",
|
||||||
|
"ProductName": "{{.Info.ProductName}}",
|
||||||
|
"Comments": "{{.Info.Comments}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenConnections opens all database connections defined in the config.
|
||||||
|
// basePath is the directory of the YAML file — file-based drivers (sqlite, duckdb)
|
||||||
|
// resolve relative paths from there.
|
||||||
|
func OpenConnections(conns map[string]ConnConfig, basePath string) (map[string]*sql.DB, error) {
|
||||||
|
pool := make(map[string]*sql.DB, len(conns))
|
||||||
|
|
||||||
|
for name, cfg := range conns {
|
||||||
|
db, err := openOne(name, cfg, basePath)
|
||||||
|
if err != nil {
|
||||||
|
// Close already opened connections on failure.
|
||||||
|
CloseConnections(pool)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool[name] = db
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openOne(name string, cfg ConnConfig, basePath string) (*sql.DB, error) {
|
||||||
|
switch cfg.Driver {
|
||||||
|
case "sqlite":
|
||||||
|
return infra.SQLiteOpen(cfg.Path, basePath)
|
||||||
|
case "postgres":
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 5432
|
||||||
|
}
|
||||||
|
return infra.PostgresOpen(cfg.Host, port, cfg.User, cfg.Password, cfg.Database, cfg.SSLMode)
|
||||||
|
case "duckdb":
|
||||||
|
p := cfg.Path
|
||||||
|
if basePath != "" && p != "" && p != ":memory:" && !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(basePath, p)
|
||||||
|
}
|
||||||
|
return infra.DuckDBOpen(p)
|
||||||
|
case "clickhouse":
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 9000
|
||||||
|
}
|
||||||
|
return infra.ClickHouseOpen(cfg.Host, port, cfg.User, cfg.Password, cfg.Database)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("connection %q: unsupported driver %q", name, cfg.Driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseConnections closes all database connections.
|
||||||
|
func CloseConnections(pool map[string]*sql.DB) {
|
||||||
|
for _, db := range pool {
|
||||||
|
infra.DBClose(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
# Ejemplos de dashboards
|
||||||
|
|
||||||
|
## 1. Dashboard minimo — una BD, un KPI
|
||||||
|
|
||||||
|
El dashboard mas simple posible: una conexion SQLite y un numero.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Contador"
|
||||||
|
refresh: 5s
|
||||||
|
columns: 4
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
db:
|
||||||
|
driver: sqlite
|
||||||
|
path: ./data.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
total:
|
||||||
|
sql: "SELECT COUNT(*) as value FROM items"
|
||||||
|
connection: db
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: main
|
||||||
|
title: "Total"
|
||||||
|
widgets:
|
||||||
|
- id: count
|
||||||
|
type: kpi
|
||||||
|
title: "Items"
|
||||||
|
query: total
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard con filtros — Postgres + select + date range
|
||||||
|
|
||||||
|
Dashboard de ventas con filtros interactivos que re-ejecutan las queries.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Sales Dashboard"
|
||||||
|
refresh: 30s
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
sales:
|
||||||
|
driver: postgres
|
||||||
|
host: localhost
|
||||||
|
port: 5432
|
||||||
|
user: analytics
|
||||||
|
password: "${SALES_PW}"
|
||||||
|
database: sales
|
||||||
|
|
||||||
|
queries:
|
||||||
|
revenue_kpi:
|
||||||
|
connection: sales
|
||||||
|
sql: |
|
||||||
|
SELECT SUM(amount) as value, COUNT(*) as orders
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at >= :from AND created_at <= :to
|
||||||
|
AND (:cat = 'all' OR category = :cat)
|
||||||
|
refresh: 15s
|
||||||
|
params:
|
||||||
|
from: "$filter.periodo.from"
|
||||||
|
to: "$filter.periodo.to"
|
||||||
|
cat: "$filter.categoria"
|
||||||
|
|
||||||
|
revenue_daily:
|
||||||
|
connection: sales
|
||||||
|
sql: |
|
||||||
|
SELECT date_trunc('day', created_at)::date as day,
|
||||||
|
SUM(amount) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at >= :from
|
||||||
|
AND (:cat = 'all' OR category = :cat)
|
||||||
|
GROUP BY day ORDER BY day
|
||||||
|
refresh: 30s
|
||||||
|
params:
|
||||||
|
from: "$filter.periodo.from"
|
||||||
|
cat: "$filter.categoria"
|
||||||
|
|
||||||
|
top_categories:
|
||||||
|
connection: sales
|
||||||
|
sql: |
|
||||||
|
SELECT category, SUM(amount) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at >= :from
|
||||||
|
GROUP BY category ORDER BY revenue DESC LIMIT 10
|
||||||
|
refresh: 30s
|
||||||
|
params:
|
||||||
|
from: "$filter.periodo.from"
|
||||||
|
|
||||||
|
recent_orders:
|
||||||
|
connection: sales
|
||||||
|
sql: |
|
||||||
|
SELECT id, customer, amount, category, status, created_at
|
||||||
|
FROM orders ORDER BY created_at DESC LIMIT 25
|
||||||
|
refresh: 10s
|
||||||
|
|
||||||
|
filters:
|
||||||
|
periodo:
|
||||||
|
type: date_range
|
||||||
|
label: "Periodo"
|
||||||
|
default: { from: "now-7d", to: "now" }
|
||||||
|
presets:
|
||||||
|
- { label: "Hoy", from: "now-0d", to: "now" }
|
||||||
|
- { label: "7d", from: "now-7d", to: "now" }
|
||||||
|
- { label: "30d", from: "now-30d", to: "now" }
|
||||||
|
- { label: "90d", from: "now-90d", to: "now" }
|
||||||
|
|
||||||
|
categoria:
|
||||||
|
type: select
|
||||||
|
label: "Categoria"
|
||||||
|
default: "all"
|
||||||
|
options:
|
||||||
|
- { label: "Todas", value: "all" }
|
||||||
|
- { label: "Electronics", value: "electronics" }
|
||||||
|
- { label: "Clothing", value: "clothing" }
|
||||||
|
- { label: "Food", value: "food" }
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "Resumen"
|
||||||
|
widgets:
|
||||||
|
- id: revenue
|
||||||
|
type: kpi
|
||||||
|
title: "Revenue"
|
||||||
|
query: revenue_kpi
|
||||||
|
mapping: { value: "value", format: "$,.2f" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: orders
|
||||||
|
type: kpi
|
||||||
|
title: "Ordenes"
|
||||||
|
query: revenue_kpi
|
||||||
|
mapping: { value: "orders", format: "," }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: charts
|
||||||
|
title: "Tendencias"
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: revenue_line
|
||||||
|
type: line_chart
|
||||||
|
title: "Revenue diario"
|
||||||
|
query: revenue_daily
|
||||||
|
mapping:
|
||||||
|
x: "day"
|
||||||
|
series: [{ key: "revenue", name: "Revenue" }]
|
||||||
|
options: { curve: monotone, zoomable: true }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: categories_bar
|
||||||
|
type: bar_chart
|
||||||
|
title: "Top categorias"
|
||||||
|
query: top_categories
|
||||||
|
mapping: { x: "category", y: "revenue" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: detail
|
||||||
|
title: "Ordenes recientes"
|
||||||
|
collapsible: true
|
||||||
|
widgets:
|
||||||
|
- id: orders_table
|
||||||
|
type: table
|
||||||
|
title: "Ultimas 25"
|
||||||
|
query: recent_orders
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "id", label: "ID" }
|
||||||
|
- { key: "customer", label: "Cliente" }
|
||||||
|
- { key: "amount", label: "Monto", format: "$,.2f" }
|
||||||
|
- { key: "category", label: "Categoria" }
|
||||||
|
- { key: "status", label: "Estado" }
|
||||||
|
- { key: "created_at", label: "Fecha", format: "datetime" }
|
||||||
|
span: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dashboard multi-BD — SQLite + DuckDB
|
||||||
|
|
||||||
|
Combina datos de distintas bases en un solo dashboard.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Multi-DB Analytics"
|
||||||
|
refresh: 10s
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
ops:
|
||||||
|
driver: sqlite
|
||||||
|
path: ./operations.db
|
||||||
|
warehouse:
|
||||||
|
driver: duckdb
|
||||||
|
path: ./analytics.duckdb
|
||||||
|
|
||||||
|
queries:
|
||||||
|
entities_count:
|
||||||
|
connection: ops
|
||||||
|
sql: "SELECT COUNT(*) as value FROM entities"
|
||||||
|
refresh: 5s
|
||||||
|
|
||||||
|
executions_count:
|
||||||
|
connection: ops
|
||||||
|
sql: "SELECT COUNT(*) as value FROM executions"
|
||||||
|
refresh: 5s
|
||||||
|
|
||||||
|
assertions_pass_rate:
|
||||||
|
connection: ops
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) / COUNT(*), 1) as value
|
||||||
|
FROM assertion_results
|
||||||
|
refresh: 10s
|
||||||
|
|
||||||
|
daily_events:
|
||||||
|
connection: warehouse
|
||||||
|
sql: |
|
||||||
|
SELECT date_trunc('day', ts) as day, COUNT(*) as events
|
||||||
|
FROM events GROUP BY day ORDER BY day DESC LIMIT 30
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
status_distribution:
|
||||||
|
connection: ops
|
||||||
|
sql: "SELECT status, COUNT(*) as count FROM entities GROUP BY status"
|
||||||
|
refresh: 10s
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "Operations"
|
||||||
|
widgets:
|
||||||
|
- id: entities
|
||||||
|
type: kpi
|
||||||
|
title: "Entities"
|
||||||
|
query: entities_count
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: executions
|
||||||
|
type: kpi
|
||||||
|
title: "Executions"
|
||||||
|
query: executions_count
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: pass_rate
|
||||||
|
type: kpi
|
||||||
|
title: "Assertion Pass %"
|
||||||
|
query: assertions_pass_rate
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: charts
|
||||||
|
title: "Visualizacion"
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: events_area
|
||||||
|
type: area_chart
|
||||||
|
title: "Eventos diarios (DuckDB)"
|
||||||
|
query: daily_events
|
||||||
|
mapping:
|
||||||
|
x: "day"
|
||||||
|
series: [{ key: "events", name: "Eventos" }]
|
||||||
|
options: { show_grid: true }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: status_bar
|
||||||
|
type: bar_chart
|
||||||
|
title: "Distribucion por status (SQLite)"
|
||||||
|
query: status_distribution
|
||||||
|
mapping: { x: "status", y: "count" }
|
||||||
|
span: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dashboard tiempo real — refresh sub-segundo
|
||||||
|
|
||||||
|
Para metricas que cambian rapido (CPU, colas, precios).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Realtime Monitor"
|
||||||
|
refresh: 1s
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
metrics:
|
||||||
|
driver: sqlite
|
||||||
|
path: ./metrics.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
cpu_current:
|
||||||
|
connection: metrics
|
||||||
|
sql: "SELECT value FROM metrics WHERE key = 'cpu' ORDER BY ts DESC LIMIT 1"
|
||||||
|
refresh: 200ms
|
||||||
|
stale_time: 100ms
|
||||||
|
|
||||||
|
memory_current:
|
||||||
|
connection: metrics
|
||||||
|
sql: "SELECT value FROM metrics WHERE key = 'memory' ORDER BY ts DESC LIMIT 1"
|
||||||
|
refresh: 500ms
|
||||||
|
stale_time: 250ms
|
||||||
|
|
||||||
|
cpu_history:
|
||||||
|
connection: metrics
|
||||||
|
sql: |
|
||||||
|
SELECT ts, value FROM metrics
|
||||||
|
WHERE key = 'cpu' ORDER BY ts DESC LIMIT 60
|
||||||
|
refresh: 1s
|
||||||
|
|
||||||
|
queue_depth:
|
||||||
|
connection: metrics
|
||||||
|
sql: "SELECT value FROM metrics WHERE key = 'queue' ORDER BY ts DESC LIMIT 1"
|
||||||
|
refresh: 300ms
|
||||||
|
stale_time: 150ms
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: live
|
||||||
|
title: "Live"
|
||||||
|
widgets:
|
||||||
|
- id: cpu
|
||||||
|
type: kpi
|
||||||
|
title: "CPU %"
|
||||||
|
query: cpu_current
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: mem
|
||||||
|
type: kpi
|
||||||
|
title: "Memory %"
|
||||||
|
query: memory_current
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: queue
|
||||||
|
type: kpi
|
||||||
|
title: "Queue"
|
||||||
|
query: queue_depth
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 4
|
||||||
|
|
||||||
|
- id: history
|
||||||
|
title: "CPU History (60s)"
|
||||||
|
widgets:
|
||||||
|
- id: cpu_line
|
||||||
|
type: line_chart
|
||||||
|
title: "CPU % (ultimo minuto)"
|
||||||
|
query: cpu_history
|
||||||
|
mapping:
|
||||||
|
x: "ts"
|
||||||
|
y: "value"
|
||||||
|
options: { curve: linear, show_grid: true, height: 250 }
|
||||||
|
span: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dashboard con busqueda — filtro texto
|
||||||
|
|
||||||
|
Dashboard que permite buscar texto con debounce.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Buscador de funciones"
|
||||||
|
refresh: 10s
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
registry:
|
||||||
|
driver: sqlite
|
||||||
|
path: ../../../registry.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
search_results:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT id, kind, lang, domain, description
|
||||||
|
FROM functions
|
||||||
|
WHERE id LIKE '%' || :q || '%'
|
||||||
|
OR description LIKE '%' || :q || '%'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
refresh: 10s
|
||||||
|
params:
|
||||||
|
q: "$filter.busqueda"
|
||||||
|
|
||||||
|
result_count:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT COUNT(*) as value
|
||||||
|
FROM functions
|
||||||
|
WHERE id LIKE '%' || :q || '%'
|
||||||
|
OR description LIKE '%' || :q || '%'
|
||||||
|
refresh: 10s
|
||||||
|
params:
|
||||||
|
q: "$filter.busqueda"
|
||||||
|
|
||||||
|
filters:
|
||||||
|
busqueda:
|
||||||
|
type: text
|
||||||
|
label: "Buscar"
|
||||||
|
default: ""
|
||||||
|
placeholder: "nombre o descripcion..."
|
||||||
|
debounce: 300
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: results
|
||||||
|
title: "Resultados"
|
||||||
|
widgets:
|
||||||
|
- id: count
|
||||||
|
type: kpi
|
||||||
|
title: "Coincidencias"
|
||||||
|
query: result_count
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: results_table
|
||||||
|
type: table
|
||||||
|
title: "Funciones encontradas"
|
||||||
|
query: search_results
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "id", label: "ID" }
|
||||||
|
- { key: "kind", label: "Kind" }
|
||||||
|
- { key: "lang", label: "Lang" }
|
||||||
|
- { key: "domain", label: "Domain" }
|
||||||
|
- { key: "description", label: "Descripcion" }
|
||||||
|
span: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patrones comunes
|
||||||
|
|
||||||
|
### Una query alimenta varios widgets
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
queries:
|
||||||
|
summary:
|
||||||
|
sql: "SELECT SUM(amount) as revenue, COUNT(*) as orders, AVG(amount) as avg FROM orders"
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "KPIs"
|
||||||
|
widgets:
|
||||||
|
- id: rev
|
||||||
|
type: kpi
|
||||||
|
query: summary
|
||||||
|
mapping: { value: "revenue", format: "$,.2f" }
|
||||||
|
span: 4
|
||||||
|
- id: ord
|
||||||
|
type: kpi
|
||||||
|
query: summary
|
||||||
|
mapping: { value: "orders", format: "," }
|
||||||
|
span: 4
|
||||||
|
- id: avg
|
||||||
|
type: kpi
|
||||||
|
query: summary
|
||||||
|
mapping: { value: "avg", format: "$,.2f" }
|
||||||
|
span: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
La query se ejecuta una sola vez y los 3 KPIs leen del mismo resultado.
|
||||||
|
|
||||||
|
### Seccion colapsable para detalle
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- id: detail
|
||||||
|
title: "Detalle (click para expandir)"
|
||||||
|
collapsible: true
|
||||||
|
widgets:
|
||||||
|
- id: log_table
|
||||||
|
type: table
|
||||||
|
query: logs
|
||||||
|
span: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid flexible por seccion
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "KPIs"
|
||||||
|
# hereda columns: 12 del global → 4 KPIs de span 3
|
||||||
|
widgets:
|
||||||
|
- { id: a, type: kpi, query: q, mapping: { value: v }, span: 3 }
|
||||||
|
- { id: b, type: kpi, query: q, mapping: { value: v }, span: 3 }
|
||||||
|
- { id: c, type: kpi, query: q, mapping: { value: v }, span: 3 }
|
||||||
|
- { id: d, type: kpi, query: q, mapping: { value: v }, span: 3 }
|
||||||
|
|
||||||
|
- id: charts
|
||||||
|
title: "Graficos"
|
||||||
|
columns: 2 # override: solo 2 columnas
|
||||||
|
widgets:
|
||||||
|
- { id: chart1, type: line_chart, query: q1, mapping: { x: a, y: b }, span: 1 }
|
||||||
|
- { id: chart2, type: bar_chart, query: q2, mapping: { x: a, y: b }, span: 1 }
|
||||||
|
|
||||||
|
- id: full
|
||||||
|
title: "Tabla"
|
||||||
|
columns: 1 # 1 columna = ancho completo
|
||||||
|
widgets:
|
||||||
|
- { id: tbl, type: table, query: q3, span: 1 }
|
||||||
|
```
|
||||||
+569
@@ -0,0 +1,569 @@
|
|||||||
|
# Rapid Dashboards — Guia de uso
|
||||||
|
|
||||||
|
Dashboard builder declarativo: defines un YAML, obtienes un dashboard desktop con datos en vivo.
|
||||||
|
|
||||||
|
## Inicio rapido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/rapid_dashboards
|
||||||
|
|
||||||
|
# Desarrollo — usa symlink dashboard.yaml (ver "Cambiar entre dashboards")
|
||||||
|
wails dev
|
||||||
|
|
||||||
|
# Desarrollo — build produccion
|
||||||
|
CGO_ENABLED=1 wails build -tags fts5
|
||||||
|
./build/bin/rapid-dashboards --dashboard examples/fn_registry_overview.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compilar un dashboard especifico
|
||||||
|
|
||||||
|
### Desarrollo (wails dev)
|
||||||
|
|
||||||
|
`wails dev` no pasa argumentos CLI al binario. El binario busca el dashboard en este orden:
|
||||||
|
|
||||||
|
1. Flag `--dashboard <path>` (solo funciona con `wails build`, no con `wails dev`)
|
||||||
|
2. Variable de entorno `DASHBOARD` (no se propaga al binario en `wails dev`)
|
||||||
|
3. Archivo `dashboard.yaml` en el cwd (metodo recomendado para desarrollo)
|
||||||
|
|
||||||
|
**Para desarrollo, usar un symlink:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apuntar al dashboard deseado
|
||||||
|
ln -sf examples/fn_registry_overview.yaml dashboard.yaml
|
||||||
|
|
||||||
|
# Lanzar — el binario encuentra dashboard.yaml automaticamente
|
||||||
|
wails dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Produccion (wails build)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compilar
|
||||||
|
CGO_ENABLED=1 wails build -tags fts5
|
||||||
|
|
||||||
|
# Ejecutar con un YAML especifico
|
||||||
|
./build/bin/rapid-dashboards --dashboard examples/fn_registry_overview.yaml
|
||||||
|
./build/bin/rapid-dashboards --dashboard /ruta/absoluta/mi_dashboard.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
El binario compilado acepta el flag `--dashboard` directamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cambiar entre dashboards
|
||||||
|
|
||||||
|
### En vivo (dropdown en la app)
|
||||||
|
|
||||||
|
Si hay mas de un archivo `.yaml` en el directorio del dashboard actual, aparece un dropdown junto al titulo. Al seleccionar otro dashboard:
|
||||||
|
|
||||||
|
- Se cargan las nuevas conexiones, queries y widgets
|
||||||
|
- Se cierran las conexiones anteriores
|
||||||
|
- El tema se aplica automaticamente
|
||||||
|
|
||||||
|
Los dashboards disponibles se escanean del directorio donde esta el YAML actual (por defecto `examples/`).
|
||||||
|
|
||||||
|
### Cambiando el symlink (para desarrollo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver dashboards disponibles
|
||||||
|
ls examples/*.yaml
|
||||||
|
|
||||||
|
# Cambiar al dashboard de apps
|
||||||
|
ln -sf examples/fn_registry_apps.yaml dashboard.yaml
|
||||||
|
|
||||||
|
# Cambiar al overview de funciones
|
||||||
|
ln -sf examples/fn_registry_overview.yaml dashboard.yaml
|
||||||
|
|
||||||
|
# Reiniciar wails dev para que tome el cambio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Por CLI (produccion)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dashboard de funciones
|
||||||
|
./build/bin/rapid-dashboards --dashboard examples/fn_registry_overview.yaml
|
||||||
|
|
||||||
|
# Dashboard de apps
|
||||||
|
./build/bin/rapid-dashboards --dashboard examples/fn_registry_apps.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Temas
|
||||||
|
|
||||||
|
El campo `theme` del YAML controla toda la estetica del dashboard. Cada tema cambia colores de fondo, texto, cards, bordes, acentos y la paleta de colores de los graficos.
|
||||||
|
|
||||||
|
### Temas disponibles
|
||||||
|
|
||||||
|
| Tema | Descripcion | Uso recomendado |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `dark` | Azul-gris profundo, acentos azules | Default, dashboards tecnicos |
|
||||||
|
| `emerald` | Verde esmeralda oscuro, acentos dorados | Dashboards financieros, naturaleza |
|
||||||
|
| `amber` | Calido ambar/naranja sobre fondo oscuro | Dashboards de alertas, operaciones |
|
||||||
|
| `rose` | Rosa/magenta sobre fondo oscuro | Dashboards de marketing, analytics |
|
||||||
|
| `light` | Fondo claro con sombras sutiles | Presentaciones, pantallas con luz |
|
||||||
|
|
||||||
|
### Ejemplo de uso
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Dashboard con tema esmeralda
|
||||||
|
theme: "emerald"
|
||||||
|
|
||||||
|
# Dashboard con tema claro
|
||||||
|
theme: "light"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Que cambia cada tema
|
||||||
|
|
||||||
|
- **Background y foreground**: tonos base del fondo y texto
|
||||||
|
- **Card**: color de fondo de widgets, sin bordes en temas oscuros, con sombra en light
|
||||||
|
- **Primary/accent**: color de acento para interacciones y highlights
|
||||||
|
- **Border**: tonos de separadores y bordes
|
||||||
|
- **Chart palette**: cada tema tiene su propia secuencia de colores para graficos (`--chart-1` a `--chart-5`)
|
||||||
|
|
||||||
|
### Anadir un tema nuevo
|
||||||
|
|
||||||
|
Anadir un bloque `[data-theme="nombre"]` en `frontend/src/app.css` con todas las variables CSS. El tema se activa automaticamente al usar `theme: "nombre"` en el YAML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura del YAML
|
||||||
|
|
||||||
|
Un dashboard se define con 5 bloques:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings: # titulo, dimensiones, refresh global, columnas
|
||||||
|
theme: # tema visual
|
||||||
|
connections: # bases de datos
|
||||||
|
queries: # SQL + refresh + params
|
||||||
|
filters: # controles de filtro
|
||||||
|
sections: # secciones con widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. settings
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
title: "Mi Dashboard" # titulo de la ventana
|
||||||
|
refresh: 30s # refresh global por defecto
|
||||||
|
width: 1280 # ancho ventana (px)
|
||||||
|
height: 800 # alto ventana (px)
|
||||||
|
columns: 12 # columnas del grid CSS
|
||||||
|
```
|
||||||
|
|
||||||
|
- `refresh` acepta duraciones Go: `200ms`, `1s`, `5s`, `1m`, `30m`
|
||||||
|
- `columns` define el grid base. Cada widget usa `span` para ocupar N columnas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. connections
|
||||||
|
|
||||||
|
Cada conexion tiene un nombre y un driver. Secrets via variables de entorno `${VAR}`.
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
```yaml
|
||||||
|
connections:
|
||||||
|
local:
|
||||||
|
driver: sqlite
|
||||||
|
path: ./data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
```yaml
|
||||||
|
connections:
|
||||||
|
main_db:
|
||||||
|
driver: postgres
|
||||||
|
host: localhost
|
||||||
|
port: 5432
|
||||||
|
user: analytics
|
||||||
|
password: "${PG_PASSWORD}"
|
||||||
|
database: myapp
|
||||||
|
sslmode: disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### DuckDB
|
||||||
|
```yaml
|
||||||
|
connections:
|
||||||
|
warehouse:
|
||||||
|
driver: duckdb
|
||||||
|
path: ./warehouse.duckdb
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClickHouse
|
||||||
|
```yaml
|
||||||
|
connections:
|
||||||
|
events:
|
||||||
|
driver: clickhouse
|
||||||
|
host: localhost
|
||||||
|
port: 9000
|
||||||
|
user: default
|
||||||
|
password: ""
|
||||||
|
database: events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paths relativos**: se resuelven desde el cwd del binario, NO desde la ubicacion del YAML. En desarrollo el cwd es `apps/rapid_dashboards/`, asi que `../../registry.db` apunta a la raiz del repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. queries
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
queries:
|
||||||
|
revenue_total:
|
||||||
|
connection: main_db
|
||||||
|
sql: "SELECT SUM(amount) as value FROM orders"
|
||||||
|
refresh: 10s
|
||||||
|
stale_time: 5s
|
||||||
|
|
||||||
|
revenue_by_date:
|
||||||
|
connection: main_db
|
||||||
|
sql: |
|
||||||
|
SELECT date, SUM(amount) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE date >= :date_from AND date <= :date_to
|
||||||
|
ORDER BY date
|
||||||
|
params:
|
||||||
|
date_from: "$filter.date_range.from"
|
||||||
|
date_to: "$filter.date_range.to"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parametros
|
||||||
|
|
||||||
|
- `:nombre` en el SQL se reemplaza por el valor del param.
|
||||||
|
- `$filter.xxx` referencia el valor actual de un filtro.
|
||||||
|
- `$filter.date_range.from` accede al subcampo `from`.
|
||||||
|
- Placeholders se convierten al formato del driver (`$1` para Postgres, `?` para SQLite/DuckDB/ClickHouse).
|
||||||
|
|
||||||
|
### Fechas relativas
|
||||||
|
|
||||||
|
| Valor | Resultado |
|
||||||
|
|-------|-----------|
|
||||||
|
| `now` | timestamp actual |
|
||||||
|
| `now-7d` | hace 7 dias |
|
||||||
|
| `now-24h` | hace 24 horas |
|
||||||
|
| `now-30m` | hace 30 minutos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. filters
|
||||||
|
|
||||||
|
### Select
|
||||||
|
```yaml
|
||||||
|
filters:
|
||||||
|
category:
|
||||||
|
type: select
|
||||||
|
label: "Categoria"
|
||||||
|
default: "all"
|
||||||
|
options:
|
||||||
|
- { label: "Todas", value: "all" }
|
||||||
|
- { label: "Electronics", value: "electronics" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Range
|
||||||
|
```yaml
|
||||||
|
filters:
|
||||||
|
date_range:
|
||||||
|
type: date_range
|
||||||
|
label: "Periodo"
|
||||||
|
default: { from: "now-7d", to: "now" }
|
||||||
|
presets:
|
||||||
|
- { label: "7 dias", from: "now-7d", to: "now" }
|
||||||
|
- { label: "30 dias", from: "now-30d", to: "now" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text
|
||||||
|
```yaml
|
||||||
|
filters:
|
||||||
|
search:
|
||||||
|
type: text
|
||||||
|
label: "Buscar"
|
||||||
|
placeholder: "Buscar por nombre..."
|
||||||
|
debounce: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. sections y widgets
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "Metricas"
|
||||||
|
columns: 4 # override de columnas
|
||||||
|
widgets:
|
||||||
|
- id: total_users
|
||||||
|
type: kpi
|
||||||
|
title: "Usuarios"
|
||||||
|
query: count_users
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: charts
|
||||||
|
title: "Graficos"
|
||||||
|
collapsible: true
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: revenue_line
|
||||||
|
type: line_chart
|
||||||
|
title: "Revenue"
|
||||||
|
query: revenue_by_date
|
||||||
|
mapping:
|
||||||
|
x: "date"
|
||||||
|
series:
|
||||||
|
- { key: "revenue", name: "Revenue", color: "#3b82f6" }
|
||||||
|
options:
|
||||||
|
zoomable: true
|
||||||
|
span: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipos de widget
|
||||||
|
|
||||||
|
### kpi
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: kpi
|
||||||
|
mapping:
|
||||||
|
value: "campo_sql"
|
||||||
|
format: "$,.2f" # opcional
|
||||||
|
```
|
||||||
|
|
||||||
|
### line_chart
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: line_chart
|
||||||
|
mapping:
|
||||||
|
x: "date"
|
||||||
|
series:
|
||||||
|
- { key: "revenue", name: "Revenue", color: "#3b82f6" }
|
||||||
|
options:
|
||||||
|
curve: monotone # linear | monotone | step
|
||||||
|
show_grid: true
|
||||||
|
show_legend: true
|
||||||
|
zoomable: true
|
||||||
|
height: 400
|
||||||
|
```
|
||||||
|
|
||||||
|
### bar_chart
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: bar_chart
|
||||||
|
mapping:
|
||||||
|
x: "category"
|
||||||
|
y: "count"
|
||||||
|
# O multi-series con series: [...]
|
||||||
|
options:
|
||||||
|
horizontal: true # barras horizontales
|
||||||
|
show_grid: true
|
||||||
|
show_legend: true
|
||||||
|
height: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### pie_chart
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: pie_chart
|
||||||
|
mapping:
|
||||||
|
name: "domain" # campo para nombres de segmentos
|
||||||
|
value: "cantidad" # campo numerico
|
||||||
|
options:
|
||||||
|
donut: true # hueco central
|
||||||
|
show_legend: true # default: true
|
||||||
|
height: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### area_chart
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: area_chart
|
||||||
|
mapping:
|
||||||
|
x: "date"
|
||||||
|
series:
|
||||||
|
- { key: "users", name: "Usuarios" }
|
||||||
|
options:
|
||||||
|
stacked: true
|
||||||
|
height: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### sparkline
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: sparkline
|
||||||
|
mapping:
|
||||||
|
value: "metric"
|
||||||
|
options:
|
||||||
|
variant: area # line | area | bar
|
||||||
|
width: 200
|
||||||
|
height: 40
|
||||||
|
```
|
||||||
|
|
||||||
|
### table
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: table
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "name", label: "Nombre" }
|
||||||
|
- { key: "amount", label: "Monto", format: "$,.2f" }
|
||||||
|
- { key: "created_at", label: "Fecha", format: "datetime" }
|
||||||
|
options:
|
||||||
|
heatmap_columns: ["go", "python", "bash"] # colorea celdas por intensidad
|
||||||
|
```
|
||||||
|
|
||||||
|
Si no defines `columns`, se auto-detectan del resultado SQL.
|
||||||
|
|
||||||
|
**Heatmap**: `options.heatmap_columns` acepta un array de nombres de columna. Las celdas se colorean de azul oscuro (valor minimo) a azul brillante (valor maximo), calculado por columna.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formatos de valor
|
||||||
|
|
||||||
|
| Formato | Ejemplo | Resultado |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| `$,.2f` | 1234.5 | $1,234.50 |
|
||||||
|
| `,.2f` | 1234.5 | 1,234.50 |
|
||||||
|
| `.2f` | 1234.5 | 1234.50 |
|
||||||
|
| `,` | 1234 | 1,234 |
|
||||||
|
| `datetime` | ISO string | locale datetime |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
Cualquier campo del YAML soporta `${VARIABLE}`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=prod.example.com DB_PASSWORD=secret \
|
||||||
|
./rapid-dashboards --dashboard prod.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Puntos clave (aprendizajes)
|
||||||
|
|
||||||
|
### Wails dev no pasa CLI args al binario
|
||||||
|
|
||||||
|
`wails dev -- --flag value` no funciona: Wails compila y ejecuta el binario como proceso hijo sin pasar los args despues de `--`. Tampoco propaga variables de entorno al binario. La solucion es usar un archivo `dashboard.yaml` en el cwd que el binario detecta automaticamente.
|
||||||
|
|
||||||
|
### Wails genera bindings ejecutando el binario sin args
|
||||||
|
|
||||||
|
Durante `wails dev`, el primer paso es generar bindings TypeScript. Para esto Wails ejecuta el binario sin argumentos. Si el binario requiere flags obligatorios y hace `os.Exit(1)`, la generacion falla. Solucion: detectar cuando no hay args y usar un config dummy con slices vacios (no nil) para que los tipos se generen correctamente.
|
||||||
|
|
||||||
|
### Slices nil de Go se serializan como null en JSON
|
||||||
|
|
||||||
|
`[]SectionDef(nil)` se serializa como `null`, no como `[]`. Si el frontend hace `.map()` sobre null, crash. Inicializar siempre con `[]SectionDef{}` para el config dummy, y usar `(config.sections ?? [])` en el frontend.
|
||||||
|
|
||||||
|
### Tipos de Wails vs tipos manuales de TypeScript
|
||||||
|
|
||||||
|
Wails genera `models.ts` con tipos `string` genericos. Si el frontend tiene tipos mas estrictos (union types como `"select" | "date_range"`), hay incompatibilidad. Solucion: castear los imports con `as unknown as typeof X`.
|
||||||
|
|
||||||
|
### Paths relativos se resuelven desde el cwd, no desde el YAML
|
||||||
|
|
||||||
|
`SQLiteOpen(path)` resuelve paths relativos al cwd del proceso. Si el YAML esta en `examples/` y el binario corre desde `apps/rapid_dashboards/`, un path `../../registry.db` es correcto pero `../../../registry.db` (relativo al YAML) no. Siempre pensar los paths desde el cwd del binario.
|
||||||
|
|
||||||
|
### Recharts Pie necesita valores numericos
|
||||||
|
|
||||||
|
SQLite puede devolver numeros como strings en algunos contextos. Recharts Pie no renderiza nada si `dataKey` apunta a un string. Solucion: coercer a `Number()` antes de pasar a Pie.
|
||||||
|
|
||||||
|
### Recharts PieLabelRenderProps es estricto
|
||||||
|
|
||||||
|
El tipo `label` de `<Pie>` no acepta callbacks con tipos genericos como `Record<string, unknown>`. Hay que importar `PieLabelRenderProps` de `recharts` y usar una funcion con nombre tipada, no un lambda inline.
|
||||||
|
|
||||||
|
### Animaciones de Recharts
|
||||||
|
|
||||||
|
Las animaciones por defecto de Recharts Pie son lentas en dashboards que refrescan frecuentemente. Usar `isAnimationActive={false}` para render instantaneo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts de lanzamiento
|
||||||
|
|
||||||
|
Cada dashboard YAML debe tener forma de lanzarse rapido desde `scripts/`. La carpeta `scripts/` contiene dos scripts genericos que reciben el nombre del dashboard como argumento:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Listar dashboards disponibles
|
||||||
|
./scripts/dev.sh
|
||||||
|
|
||||||
|
# Desarrollo — crea symlink + wails dev
|
||||||
|
./scripts/dev.sh fn_registry_overview
|
||||||
|
|
||||||
|
# Produccion — compila si es necesario + ejecuta
|
||||||
|
./scripts/prod.sh fn_registry_apps
|
||||||
|
|
||||||
|
# Produccion con variables de entorno
|
||||||
|
DB_PASSWORD=secret ./scripts/prod.sh mi_dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convencion
|
||||||
|
|
||||||
|
Al crear un nuevo YAML en `examples/`, no hace falta crear scripts adicionales — `dev.sh` y `prod.sh` descubren automaticamente los `.yaml` de `examples/`. Solo ejecutar con el nombre (sin extension):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nuevo dashboard
|
||||||
|
vim examples/mi_nuevo_dashboard.yaml
|
||||||
|
|
||||||
|
# Lanzar inmediatamente
|
||||||
|
./scripts/dev.sh mi_nuevo_dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
`dev.sh` crea el symlink `dashboard.yaml` y lanza `wails dev`. `prod.sh` compila con `CGO_ENABLED=1 wails build -tags fts5` si el binario no existe o el YAML es mas reciente, y ejecuta con `--dashboard`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilacion multiplataforma
|
||||||
|
|
||||||
|
### Linux (default)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 wails build -tags fts5
|
||||||
|
```
|
||||||
|
|
||||||
|
Genera `build/bin/rapid-dashboards` con soporte para SQLite, PostgreSQL, DuckDB y ClickHouse.
|
||||||
|
|
||||||
|
### Windows (cross-compile desde Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 \
|
||||||
|
wails build -tags "fts5 noduckdb noclickhouse" -platform windows/amd64 -skipbindings
|
||||||
|
```
|
||||||
|
|
||||||
|
Genera `build/bin/rapid-dashboards.exe`. Requiere `mingw-w64` instalado (`apt install gcc-mingw-w64-x86-64`).
|
||||||
|
|
||||||
|
**Limitacion:** DuckDB y ClickHouse se excluyen con los tags `noduckdb` y `noclickhouse` porque `go-duckdb` depende de libstdc++ y no cross-compila bien desde Linux. El `.exe` solo soporta SQLite y PostgreSQL. Si un YAML usa esos drivers, dara error: `"duckdb support not compiled"`.
|
||||||
|
|
||||||
|
### Windows nativo (con DuckDB y ClickHouse)
|
||||||
|
|
||||||
|
Para compilar con todos los drivers, compilar directamente en Windows con Go y MinGW/MSYS2 instalados:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# En Windows con Go + MinGW (MSYS2)
|
||||||
|
$env:CGO_ENABLED=1
|
||||||
|
wails build -tags fts5
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto genera un `.exe` con soporte completo: SQLite, PostgreSQL, DuckDB y ClickHouse.
|
||||||
|
|
||||||
|
### Resumen de soporte por plataforma
|
||||||
|
|
||||||
|
| Driver | Linux | Windows (cross-compile) | Windows (nativo) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SQLite | si | si | si |
|
||||||
|
| PostgreSQL | si | si | si |
|
||||||
|
| DuckDB | si | no | si |
|
||||||
|
| ClickHouse | si | no | si |
|
||||||
|
|
||||||
|
### Rutas en Windows
|
||||||
|
|
||||||
|
Las rutas no se rompen entre plataformas. El codigo usa `filepath.Join` y `filepath.IsAbs` de Go, que adaptan los separadores automaticamente (`/` en Linux, `\` en Windows). Los paths relativos en el YAML (ej: `../../registry.db`) funcionan igual en ambos OS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensibilidad
|
||||||
|
|
||||||
|
Para anadir un tipo de widget nuevo:
|
||||||
|
|
||||||
|
1. Crear `frontend/src/components/widgets/MiWidget.tsx` implementando `WidgetProps`
|
||||||
|
2. Registrar en `frontend/src/components/widgets/register.ts`: `registerWidget('mi_tipo', MiWidget)`
|
||||||
|
3. Usar en YAML: `type: mi_tipo`
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
# Schema YAML — Referencia completa
|
||||||
|
|
||||||
|
Referencia rapida de todos los campos del YAML de dashboard.
|
||||||
|
|
||||||
|
## Top level
|
||||||
|
|
||||||
|
| Campo | Tipo | Requerido | Default | Descripcion |
|
||||||
|
|-------|------|-----------|---------|-------------|
|
||||||
|
| `settings` | object | si | — | Configuracion global |
|
||||||
|
| `theme` | string | no | `"dark"` | Nombre del tema |
|
||||||
|
| `connections` | map | si | — | Conexiones a bases de datos |
|
||||||
|
| `queries` | map | si | — | Definiciones de queries SQL |
|
||||||
|
| `filters` | map | no | `{}` | Filtros interactivos |
|
||||||
|
| `sections` | array | si | — | Secciones con widgets |
|
||||||
|
|
||||||
|
## settings
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Descripcion |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `title` | string | — | Titulo de la ventana (requerido) |
|
||||||
|
| `refresh` | duration | `"30s"` | Refresh global por defecto |
|
||||||
|
| `width` | int | `1280` | Ancho de ventana en px |
|
||||||
|
| `height` | int | `800` | Alto de ventana en px |
|
||||||
|
| `columns` | int | `12` | Columnas del grid CSS |
|
||||||
|
|
||||||
|
## connections[nombre]
|
||||||
|
|
||||||
|
| Campo | Tipo | Drivers | Descripcion |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `driver` | string | todos | `sqlite` \| `postgres` \| `duckdb` \| `clickhouse` |
|
||||||
|
| `path` | string | sqlite, duckdb | Ruta al archivo de BD |
|
||||||
|
| `host` | string | postgres, clickhouse | Hostname |
|
||||||
|
| `port` | int | postgres, clickhouse | Puerto (default: 5432/9000) |
|
||||||
|
| `user` | string | postgres, clickhouse | Usuario |
|
||||||
|
| `password` | string | postgres, clickhouse | Password (soporta `${ENV}`) |
|
||||||
|
| `database` | string | postgres, clickhouse | Nombre de la base de datos |
|
||||||
|
| `sslmode` | string | postgres | Modo SSL (default: `"disable"`) |
|
||||||
|
|
||||||
|
## queries[nombre]
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Descripcion |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `connection` | string | — | Nombre de la conexion (requerido) |
|
||||||
|
| `sql` | string | — | Query SQL (requerido) |
|
||||||
|
| `refresh` | duration | global | Intervalo de re-ejecucion |
|
||||||
|
| `stale_time` | duration | refresh/2 | Tiempo antes de considerar dato viejo |
|
||||||
|
| `params` | map | `{}` | Parametros: `nombre: valor_o_$filter.ref` |
|
||||||
|
|
||||||
|
## filters[nombre]
|
||||||
|
|
||||||
|
| Campo | Tipo | Tipos de filtro | Descripcion |
|
||||||
|
|-------|------|-----------------|-------------|
|
||||||
|
| `type` | string | todos | `select` \| `date_range` \| `text` |
|
||||||
|
| `label` | string | todos | Etiqueta visible |
|
||||||
|
| `default` | any | todos | Valor por defecto |
|
||||||
|
| `options` | array | select | `[{ label, value }]` |
|
||||||
|
| `presets` | array | date_range | `[{ label, from, to }]` |
|
||||||
|
| `placeholder` | string | text | Placeholder del input |
|
||||||
|
| `debounce` | int | text | Delay en ms (default: 300) |
|
||||||
|
|
||||||
|
## sections[]
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Descripcion |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `id` | string | — | Identificador unico (requerido) |
|
||||||
|
| `title` | string | — | Titulo visible |
|
||||||
|
| `collapsible` | bool | `false` | Permite colapsar |
|
||||||
|
| `columns` | int | global | Override de columnas del grid |
|
||||||
|
| `widgets` | array | — | Widgets de esta seccion |
|
||||||
|
|
||||||
|
## widgets[]
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Descripcion |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `id` | string | — | Identificador unico (requerido) |
|
||||||
|
| `type` | string | — | Tipo de componente (requerido) |
|
||||||
|
| `title` | string | — | Titulo del widget |
|
||||||
|
| `query` | string | — | Nombre de la query (requerido) |
|
||||||
|
| `mapping` | object | — | Mapeo de campos SQL a props |
|
||||||
|
| `options` | object | `{}` | Opciones del componente |
|
||||||
|
| `span` | int | `1` | Columnas que ocupa |
|
||||||
|
| `row_span` | int | `1` | Filas que ocupa |
|
||||||
|
|
||||||
|
## Tipos de widget y sus mappings
|
||||||
|
|
||||||
|
### kpi
|
||||||
|
```
|
||||||
|
mapping.value → campo del resultado (string)
|
||||||
|
mapping.format → formato de visualizacion (string, opcional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### line_chart / bar_chart / area_chart
|
||||||
|
```
|
||||||
|
mapping.x → campo del eje X (string)
|
||||||
|
mapping.y → campo del eje Y — single series (string)
|
||||||
|
mapping.series → multi-series: [{ key, name, color? }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### sparkline
|
||||||
|
```
|
||||||
|
mapping.value → campo numerico (string)
|
||||||
|
```
|
||||||
|
|
||||||
|
### table
|
||||||
|
```
|
||||||
|
mapping.columns → [{ key, label, format? }]
|
||||||
|
Si no se define, auto-detecta del resultado.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options por tipo
|
||||||
|
|
||||||
|
### line_chart
|
||||||
|
| Option | Tipo | Default | Descripcion |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `curve` | string | `"monotone"` | linear, monotone, step, stepBefore, stepAfter |
|
||||||
|
| `show_grid` | bool | `true` | Mostrar cuadricula |
|
||||||
|
| `show_legend` | bool | `false` | Mostrar leyenda |
|
||||||
|
| `zoomable` | bool | `false` | Habilitar brush zoom |
|
||||||
|
| `height` | int | `300` | Altura en px |
|
||||||
|
|
||||||
|
### bar_chart
|
||||||
|
| Option | Tipo | Default | Descripcion |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `horizontal` | bool | `false` | Barras horizontales |
|
||||||
|
| `show_grid` | bool | `true` | Mostrar cuadricula |
|
||||||
|
| `show_legend` | bool | `false` | Mostrar leyenda |
|
||||||
|
| `height` | int | `300` | Altura en px |
|
||||||
|
|
||||||
|
### area_chart
|
||||||
|
| Option | Tipo | Default | Descripcion |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `stacked` | bool | `false` | Apilar areas |
|
||||||
|
| `show_grid` | bool | `true` | Mostrar cuadricula |
|
||||||
|
| `show_legend` | bool | `false` | Mostrar leyenda |
|
||||||
|
| `height` | int | `300` | Altura en px |
|
||||||
|
|
||||||
|
### sparkline
|
||||||
|
| Option | Tipo | Default | Descripcion |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `variant` | string | `"area"` | line, area, bar |
|
||||||
|
| `width` | int | `200` | Ancho en px |
|
||||||
|
| `height` | int | `40` | Altura en px |
|
||||||
|
|
||||||
|
## Duraciones validas
|
||||||
|
|
||||||
|
| Formato | Ejemplo |
|
||||||
|
|---------|---------|
|
||||||
|
| milisegundos | `100ms`, `200ms`, `500ms` |
|
||||||
|
| segundos | `1s`, `5s`, `30s` |
|
||||||
|
| minutos | `1m`, `5m`, `30m` |
|
||||||
|
| horas | `1h` |
|
||||||
|
| combinado | `1m30s` |
|
||||||
|
|
||||||
|
## Fechas relativas
|
||||||
|
|
||||||
|
| Formato | Resultado |
|
||||||
|
|---------|-----------|
|
||||||
|
| `now` | momento actual |
|
||||||
|
| `now-Ns` | hace N segundos |
|
||||||
|
| `now-Nm` | hace N minutos |
|
||||||
|
| `now-Nh` | hace N horas |
|
||||||
|
| `now-Nd` | hace N dias |
|
||||||
|
|
||||||
|
## KPI mappings extendidos (v2)
|
||||||
|
|
||||||
|
Ademas de `value` y `format`, el widget KPI soporta:
|
||||||
|
|
||||||
|
| Mapping | Tipo | Descripcion |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `value` | string | Campo SQL del valor principal |
|
||||||
|
| `format` | string | Formato: `$,.2f`, `,`, `datetime` |
|
||||||
|
| `unit` | string | Campo SQL para la unidad (ej: "k", "ms") |
|
||||||
|
| `unitLabel` | string | Unidad fija (alternativa a `unit` dinamico) |
|
||||||
|
| `delta` | string | Campo SQL para el cambio porcentual |
|
||||||
|
| `deltaLabel` | string | Texto antes del delta: "Increased by" |
|
||||||
|
| `deltaSuffix` | string | Texto despues del delta: "vs yesterday" |
|
||||||
|
| `sparkline` | string | Campo SQL con valores para mini barras |
|
||||||
|
| `sparklineColors` | string[] | Colores por barra |
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: kpi
|
||||||
|
mapping:
|
||||||
|
value: "total"
|
||||||
|
format: ","
|
||||||
|
unitLabel: "k"
|
||||||
|
delta: "pct_change"
|
||||||
|
deltaLabel: "Increased by"
|
||||||
|
deltaSuffix: "vs yesterday"
|
||||||
|
sparkline: "daily_value"
|
||||||
|
sparklineColors: ["#3b82f6", "#8b5cf6", "#f59e0b", "#10b981"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts de lanzamiento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev.sh <nombre> # symlink + wails dev
|
||||||
|
./scripts/prod.sh <nombre> # build + ejecutar
|
||||||
|
```
|
||||||
|
|
||||||
|
Sin argumento lista los dashboards disponibles en `examples/`.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
settings:
|
||||||
|
title: "fn_registry — Dashboard Demo"
|
||||||
|
refresh: 5s
|
||||||
|
width: 1280
|
||||||
|
height: 800
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
registry:
|
||||||
|
driver: sqlite
|
||||||
|
path: ../../../registry.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
total_functions:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) as value FROM functions"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
total_types:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) as value FROM types"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
total_proposals:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) as value FROM proposals"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
functions_by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT domain, COUNT(*) as count FROM functions GROUP BY domain ORDER BY count DESC"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
functions_by_kind:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT kind, COUNT(*) as count FROM functions GROUP BY kind ORDER BY count DESC"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
functions_by_purity:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT purity, COUNT(*) as count FROM functions GROUP BY purity ORDER BY count DESC"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
functions_by_lang:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT lang, COUNT(*) as count FROM functions GROUP BY lang ORDER BY count DESC"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
recent_functions:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT id, kind, lang, domain, purity, description FROM functions ORDER BY updated_at DESC LIMIT 20"
|
||||||
|
refresh: 10s
|
||||||
|
|
||||||
|
tested_ratio:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT tested, COUNT(*) as count FROM functions GROUP BY tested"
|
||||||
|
refresh: 30s
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
- id: kpis
|
||||||
|
title: "Overview"
|
||||||
|
widgets:
|
||||||
|
- id: total_fn
|
||||||
|
type: kpi
|
||||||
|
title: "Functions"
|
||||||
|
query: total_functions
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: total_tp
|
||||||
|
type: kpi
|
||||||
|
title: "Types"
|
||||||
|
query: total_types
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: total_pr
|
||||||
|
type: kpi
|
||||||
|
title: "Proposals"
|
||||||
|
query: total_proposals
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: tested_kpi
|
||||||
|
type: kpi
|
||||||
|
title: "Tested Ratio"
|
||||||
|
query: tested_ratio
|
||||||
|
mapping: { value: "count" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: distribution
|
||||||
|
title: "Distribution"
|
||||||
|
columns: 4
|
||||||
|
widgets:
|
||||||
|
- id: by_domain
|
||||||
|
type: bar_chart
|
||||||
|
title: "By Domain"
|
||||||
|
query: functions_by_domain
|
||||||
|
mapping: { x: "domain", y: "count" }
|
||||||
|
options: { show_grid: true }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_lang
|
||||||
|
type: bar_chart
|
||||||
|
title: "By Language"
|
||||||
|
query: functions_by_lang
|
||||||
|
mapping: { x: "lang", y: "count" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_kind
|
||||||
|
type: bar_chart
|
||||||
|
title: "By Kind"
|
||||||
|
query: functions_by_kind
|
||||||
|
mapping: { x: "kind", y: "count" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_purity
|
||||||
|
type: bar_chart
|
||||||
|
title: "By Purity"
|
||||||
|
query: functions_by_purity
|
||||||
|
mapping: { x: "purity", y: "count" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: detail
|
||||||
|
title: "Recent Functions"
|
||||||
|
collapsible: true
|
||||||
|
widgets:
|
||||||
|
- id: recent_table
|
||||||
|
type: table
|
||||||
|
title: "Last 20 Updated"
|
||||||
|
query: recent_functions
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "id", label: "ID" }
|
||||||
|
- { key: "kind", label: "Kind" }
|
||||||
|
- { key: "lang", label: "Lang" }
|
||||||
|
- { key: "domain", label: "Domain" }
|
||||||
|
- { key: "purity", label: "Purity" }
|
||||||
|
- { key: "description", label: "Description" }
|
||||||
|
span: 12
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
settings:
|
||||||
|
title: "fn-registry Apps"
|
||||||
|
refresh: 30s
|
||||||
|
width: 1440
|
||||||
|
height: 900
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "emerald"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
registry:
|
||||||
|
driver: sqlite
|
||||||
|
path: ../../registry.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
# --- KPIs ---
|
||||||
|
total_apps:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM apps"
|
||||||
|
|
||||||
|
apps_go:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM apps WHERE lang = 'go'"
|
||||||
|
|
||||||
|
apps_python:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM apps WHERE lang = 'py'"
|
||||||
|
|
||||||
|
domains_with_apps:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(DISTINCT domain) AS value FROM apps"
|
||||||
|
|
||||||
|
# --- Distribucion ---
|
||||||
|
apps_by_lang:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT lang, COUNT(*) AS cantidad FROM apps GROUP BY lang ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
apps_by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT domain, COUNT(*) AS cantidad FROM apps GROUP BY domain ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
apps_by_framework:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
CASE WHEN framework = '' OR framework IS NULL THEN '(sin framework)' ELSE framework END AS framework,
|
||||||
|
COUNT(*) AS cantidad
|
||||||
|
FROM apps
|
||||||
|
GROUP BY framework
|
||||||
|
ORDER BY cantidad DESC
|
||||||
|
|
||||||
|
# --- Dependencias ---
|
||||||
|
apps_most_deps:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
name || ' (' || lang || ')' AS app,
|
||||||
|
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
|
||||||
|
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS dependencias
|
||||||
|
FROM apps
|
||||||
|
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||||
|
ORDER BY dependencias DESC
|
||||||
|
LIMIT 15
|
||||||
|
|
||||||
|
functions_most_used_in_apps:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
WITH RECURSIVE split_uses(app_id, rest, val) AS (
|
||||||
|
SELECT id, uses_functions || ',', NULL
|
||||||
|
FROM apps
|
||||||
|
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT app_id,
|
||||||
|
SUBSTR(rest, INSTR(rest, ',') + 1),
|
||||||
|
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
|
||||||
|
FROM split_uses WHERE rest != ''
|
||||||
|
)
|
||||||
|
SELECT val AS funcion, COUNT(*) AS veces
|
||||||
|
FROM split_uses
|
||||||
|
WHERE val IS NOT NULL AND val != '' AND val != ']'
|
||||||
|
GROUP BY val
|
||||||
|
ORDER BY veces DESC
|
||||||
|
LIMIT 15
|
||||||
|
|
||||||
|
# --- Catalogo ---
|
||||||
|
apps_catalog:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
lang,
|
||||||
|
domain,
|
||||||
|
CASE WHEN framework = '' THEN '-' ELSE framework END AS framework,
|
||||||
|
description,
|
||||||
|
entry_point,
|
||||||
|
updated_at
|
||||||
|
FROM apps
|
||||||
|
ORDER BY domain, name
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
# ---- KPIs ----
|
||||||
|
- id: kpis
|
||||||
|
title: "Overview"
|
||||||
|
columns: 4
|
||||||
|
widgets:
|
||||||
|
- id: total_apps
|
||||||
|
type: kpi
|
||||||
|
title: "Total Apps"
|
||||||
|
query: total_apps
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: apps_go
|
||||||
|
type: kpi
|
||||||
|
title: "Apps Go"
|
||||||
|
query: apps_go
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: apps_py
|
||||||
|
type: kpi
|
||||||
|
title: "Apps Python"
|
||||||
|
query: apps_python
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: domains
|
||||||
|
type: kpi
|
||||||
|
title: "Dominios"
|
||||||
|
query: domains_with_apps
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Distribucion ----
|
||||||
|
- id: distribution
|
||||||
|
title: "Distribucion"
|
||||||
|
columns: 3
|
||||||
|
widgets:
|
||||||
|
- id: by_lang
|
||||||
|
type: pie_chart
|
||||||
|
title: "Apps por Lenguaje"
|
||||||
|
query: apps_by_lang
|
||||||
|
mapping: { name: "lang", value: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_domain
|
||||||
|
type: pie_chart
|
||||||
|
title: "Apps por Dominio"
|
||||||
|
query: apps_by_domain
|
||||||
|
mapping: { name: "domain", value: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_framework
|
||||||
|
type: bar_chart
|
||||||
|
title: "Apps por Framework"
|
||||||
|
query: apps_by_framework
|
||||||
|
mapping: { x: "framework", y: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Dependencias ----
|
||||||
|
- id: dependencies
|
||||||
|
title: "Dependencias"
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: most_deps
|
||||||
|
type: bar_chart
|
||||||
|
title: "Apps con Mas Dependencias"
|
||||||
|
query: apps_most_deps
|
||||||
|
mapping: { x: "app", y: "dependencias" }
|
||||||
|
options: { horizontal: true, height: 350 }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: most_used_fns
|
||||||
|
type: bar_chart
|
||||||
|
title: "Funciones Mas Usadas en Apps"
|
||||||
|
query: functions_most_used_in_apps
|
||||||
|
mapping: { x: "funcion", y: "veces" }
|
||||||
|
options: { horizontal: true, height: 350 }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Catalogo ----
|
||||||
|
- id: catalog
|
||||||
|
title: "Catalogo de Apps"
|
||||||
|
widgets:
|
||||||
|
- id: catalog_table
|
||||||
|
type: table
|
||||||
|
title: "Todas las Apps"
|
||||||
|
query: apps_catalog
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "id", label: "ID" }
|
||||||
|
- { key: "name", label: "Nombre" }
|
||||||
|
- { key: "lang", label: "Lang" }
|
||||||
|
- { key: "domain", label: "Dominio" }
|
||||||
|
- { key: "framework", label: "Framework" }
|
||||||
|
- { key: "description", label: "Descripcion" }
|
||||||
|
- { key: "entry_point", label: "Entry Point" }
|
||||||
|
- { key: "updated_at", label: "Actualizado" }
|
||||||
|
span: 12
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
settings:
|
||||||
|
title: "fn-registry Overview"
|
||||||
|
refresh: 30s
|
||||||
|
width: 1440
|
||||||
|
height: 900
|
||||||
|
columns: 12
|
||||||
|
|
||||||
|
theme: "dark"
|
||||||
|
|
||||||
|
connections:
|
||||||
|
registry:
|
||||||
|
driver: sqlite
|
||||||
|
path: ../../registry.db
|
||||||
|
|
||||||
|
queries:
|
||||||
|
# --- KPIs ---
|
||||||
|
total_functions:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM functions"
|
||||||
|
|
||||||
|
functions_with_tests:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM functions WHERE tested = 1"
|
||||||
|
|
||||||
|
functions_without_tests:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM functions WHERE tested = 0"
|
||||||
|
|
||||||
|
total_types:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM types"
|
||||||
|
|
||||||
|
pending_proposals:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT COUNT(*) AS value FROM proposals WHERE status = 'pending'"
|
||||||
|
|
||||||
|
# --- Distribucion general ---
|
||||||
|
by_lang:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT lang, COUNT(*) AS cantidad FROM functions GROUP BY lang ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT domain, COUNT(*) AS cantidad FROM functions GROUP BY domain ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
by_kind:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT kind, COUNT(*) AS cantidad FROM functions GROUP BY kind ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
# --- Analisis profundo ---
|
||||||
|
purity:
|
||||||
|
connection: registry
|
||||||
|
sql: "SELECT purity, COUNT(*) AS cantidad FROM functions GROUP BY purity ORDER BY cantidad DESC"
|
||||||
|
|
||||||
|
most_used:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
WITH RECURSIVE split_uses(fn_id, rest, val) AS (
|
||||||
|
SELECT id, uses_functions || ',', NULL FROM functions WHERE uses_functions != '[]' AND uses_functions != ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT fn_id,
|
||||||
|
SUBSTR(rest, INSTR(rest, ',') + 1),
|
||||||
|
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
|
||||||
|
FROM split_uses WHERE rest != ''
|
||||||
|
)
|
||||||
|
SELECT val AS funcion, COUNT(*) AS veces
|
||||||
|
FROM split_uses
|
||||||
|
WHERE val IS NOT NULL AND val != '' AND val != ']'
|
||||||
|
GROUP BY val
|
||||||
|
ORDER BY veces DESC
|
||||||
|
LIMIT 15
|
||||||
|
|
||||||
|
most_complex:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
name || ' (' || lang || ')' AS funcion,
|
||||||
|
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
|
||||||
|
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS dependencias
|
||||||
|
FROM functions
|
||||||
|
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||||
|
ORDER BY dependencias DESC
|
||||||
|
LIMIT 15
|
||||||
|
|
||||||
|
# --- Cobertura y cross-table ---
|
||||||
|
test_coverage_by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
domain,
|
||||||
|
SUM(CASE WHEN tested = 1 THEN 1 ELSE 0 END) AS con_tests,
|
||||||
|
SUM(CASE WHEN tested = 0 THEN 1 ELSE 0 END) AS sin_tests
|
||||||
|
FROM functions
|
||||||
|
GROUP BY domain
|
||||||
|
ORDER BY domain
|
||||||
|
|
||||||
|
lang_by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT
|
||||||
|
domain,
|
||||||
|
SUM(CASE WHEN lang = 'go' THEN 1 ELSE 0 END) AS go,
|
||||||
|
SUM(CASE WHEN lang = 'py' THEN 1 ELSE 0 END) AS python,
|
||||||
|
SUM(CASE WHEN lang = 'bash' THEN 1 ELSE 0 END) AS bash,
|
||||||
|
SUM(CASE WHEN lang = 'ts' THEN 1 ELSE 0 END) AS typescript,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM functions
|
||||||
|
GROUP BY domain
|
||||||
|
ORDER BY total DESC
|
||||||
|
|
||||||
|
# --- Tipos y recientes ---
|
||||||
|
types_by_domain:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT domain, algebraic, COUNT(*) AS cantidad
|
||||||
|
FROM types
|
||||||
|
GROUP BY domain, algebraic
|
||||||
|
ORDER BY domain, cantidad DESC
|
||||||
|
|
||||||
|
recent_functions:
|
||||||
|
connection: registry
|
||||||
|
sql: |
|
||||||
|
SELECT name, lang, domain, kind, purity, created_at
|
||||||
|
FROM functions
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
filters: {}
|
||||||
|
|
||||||
|
sections:
|
||||||
|
# ---- Fila 0: KPIs ----
|
||||||
|
- id: kpis
|
||||||
|
title: "Overview"
|
||||||
|
widgets:
|
||||||
|
- id: total_fn
|
||||||
|
type: kpi
|
||||||
|
title: "Total Funciones"
|
||||||
|
query: total_functions
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 2
|
||||||
|
|
||||||
|
- id: fn_tested
|
||||||
|
type: kpi
|
||||||
|
title: "Con Tests"
|
||||||
|
query: functions_with_tests
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: fn_untested
|
||||||
|
type: kpi
|
||||||
|
title: "Sin Tests"
|
||||||
|
query: functions_without_tests
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 2
|
||||||
|
|
||||||
|
- id: total_tp
|
||||||
|
type: kpi
|
||||||
|
title: "Total Tipos"
|
||||||
|
query: total_types
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 3
|
||||||
|
|
||||||
|
- id: pending_pr
|
||||||
|
type: kpi
|
||||||
|
title: "Proposals Pendientes"
|
||||||
|
query: pending_proposals
|
||||||
|
mapping: { value: "value" }
|
||||||
|
span: 2
|
||||||
|
|
||||||
|
# ---- Fila 1: Distribucion general ----
|
||||||
|
- id: distribution
|
||||||
|
title: "Distribucion"
|
||||||
|
columns: 3
|
||||||
|
widgets:
|
||||||
|
- id: by_lang
|
||||||
|
type: pie_chart
|
||||||
|
title: "Funciones por Lenguaje"
|
||||||
|
query: by_lang
|
||||||
|
mapping: { name: "lang", value: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_domain
|
||||||
|
type: pie_chart
|
||||||
|
title: "Funciones por Dominio"
|
||||||
|
query: by_domain
|
||||||
|
mapping: { name: "domain", value: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: by_kind
|
||||||
|
type: bar_chart
|
||||||
|
title: "Funciones por Kind"
|
||||||
|
query: by_kind
|
||||||
|
mapping: { x: "kind", y: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Fila 2: Analisis profundo ----
|
||||||
|
- id: analysis
|
||||||
|
title: "Analisis"
|
||||||
|
columns: 3
|
||||||
|
widgets:
|
||||||
|
- id: purity_chart
|
||||||
|
type: bar_chart
|
||||||
|
title: "Puras vs Impuras"
|
||||||
|
query: purity
|
||||||
|
mapping: { x: "purity", y: "cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: most_used
|
||||||
|
type: bar_chart
|
||||||
|
title: "Funciones Mas Usadas por Otras"
|
||||||
|
query: most_used
|
||||||
|
mapping: { x: "funcion", y: "veces" }
|
||||||
|
options: { horizontal: true, height: 400 }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: most_complex
|
||||||
|
type: bar_chart
|
||||||
|
title: "Mas Complejas (mas dependencias)"
|
||||||
|
query: most_complex
|
||||||
|
mapping: { x: "funcion", y: "dependencias" }
|
||||||
|
options: { horizontal: true, height: 400 }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Fila 3: Cobertura + cross-table ----
|
||||||
|
- id: coverage
|
||||||
|
title: "Cobertura y Lenguajes"
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: test_coverage
|
||||||
|
type: bar_chart
|
||||||
|
title: "Cobertura de Tests por Dominio"
|
||||||
|
query: test_coverage_by_domain
|
||||||
|
mapping:
|
||||||
|
x: "domain"
|
||||||
|
series:
|
||||||
|
- { key: "con_tests", name: "Con Tests", color: "#10b981" }
|
||||||
|
- { key: "sin_tests", name: "Sin Tests", color: "#ef4444" }
|
||||||
|
options: { show_legend: true }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: lang_domain_table
|
||||||
|
type: table
|
||||||
|
title: "Funciones por Lenguaje y Dominio"
|
||||||
|
query: lang_by_domain
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "domain", label: "Dominio" }
|
||||||
|
- { key: "go", label: "Go" }
|
||||||
|
- { key: "python", label: "Python" }
|
||||||
|
- { key: "bash", label: "Bash" }
|
||||||
|
- { key: "typescript", label: "TypeScript" }
|
||||||
|
- { key: "total", label: "Total" }
|
||||||
|
options:
|
||||||
|
heatmap_columns: ["go", "python", "bash", "typescript", "total"]
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
# ---- Fila 4: Tipos + recientes ----
|
||||||
|
- id: bottom
|
||||||
|
title: "Tipos y Funciones Recientes"
|
||||||
|
columns: 2
|
||||||
|
widgets:
|
||||||
|
- id: types_table
|
||||||
|
type: table
|
||||||
|
title: "Tipos por Dominio y Algebraic"
|
||||||
|
query: types_by_domain
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "domain", label: "Dominio" }
|
||||||
|
- { key: "algebraic", label: "Algebraic" }
|
||||||
|
- { key: "cantidad", label: "Cantidad" }
|
||||||
|
span: 1
|
||||||
|
|
||||||
|
- id: recent_table
|
||||||
|
type: table
|
||||||
|
title: "Funciones Recientes (ultimas 20)"
|
||||||
|
query: recent_functions
|
||||||
|
mapping:
|
||||||
|
columns:
|
||||||
|
- { key: "name", label: "Nombre" }
|
||||||
|
- { key: "lang", label: "Lang" }
|
||||||
|
- { key: "domain", label: "Dominio" }
|
||||||
|
- { key: "kind", label: "Kind" }
|
||||||
|
- { key: "purity", label: "Purity" }
|
||||||
|
- { key: "created_at", label: "Creado" }
|
||||||
|
span: 1
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var relDateRe = regexp.MustCompile(`^now(?:-(\d+)([smhd]))?$`)
|
||||||
|
|
||||||
|
// ResolveRelativeDate converts relative date strings like "now", "now-7d", "now-30d"
|
||||||
|
// to RFC3339 timestamps.
|
||||||
|
func ResolveRelativeDate(s string) (string, bool) {
|
||||||
|
m := relDateRe.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if m[1] == "" {
|
||||||
|
return now.Format(time.RFC3339), true
|
||||||
|
}
|
||||||
|
|
||||||
|
n, _ := strconv.Atoi(m[1])
|
||||||
|
var d time.Duration
|
||||||
|
|
||||||
|
switch m[2] {
|
||||||
|
case "s":
|
||||||
|
d = time.Duration(n) * time.Second
|
||||||
|
case "m":
|
||||||
|
d = time.Duration(n) * time.Minute
|
||||||
|
case "h":
|
||||||
|
d = time.Duration(n) * time.Hour
|
||||||
|
case "d":
|
||||||
|
d = time.Duration(n) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Add(-d).Format(time.RFC3339), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rapid Dashboards</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "rapid-dashboards",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
052e44bcd719cd7db765d6c7fb248074
|
||||||
Generated
+1628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { DashboardShell } from './components/DashboardShell'
|
||||||
|
import { useFilterState } from './hooks/useFilterState'
|
||||||
|
import type { DashboardConfig } from './types'
|
||||||
|
import type { FilterValues } from './hooks/useFilterState'
|
||||||
|
|
||||||
|
// Register all built-in widgets.
|
||||||
|
import './components/widgets/register'
|
||||||
|
|
||||||
|
// Wails-generated bindings.
|
||||||
|
let GetDashboardConfig: () => Promise<DashboardConfig>
|
||||||
|
let GetWidgetData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||||
|
let ListDashboards: () => Promise<DashboardInfo[]>
|
||||||
|
let SwitchDashboard: (fileName: string) => Promise<DashboardConfig>
|
||||||
|
|
||||||
|
interface DashboardInfo {
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
title: string
|
||||||
|
theme: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12 }, theme: 'dark', queries: {}, filters: {}, sections: [] }
|
||||||
|
|
||||||
|
// Import bindings — they call window.go which only exists inside Wails WebView
|
||||||
|
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
|
||||||
|
GetDashboardConfig = bindings?.GetDashboardConfig as typeof GetDashboardConfig ?? (async () => dummyConfig)
|
||||||
|
GetWidgetData = bindings?.GetWidgetData as typeof GetWidgetData ?? (async () => [])
|
||||||
|
ListDashboards = bindings?.ListDashboards as typeof ListDashboards ?? (async () => [])
|
||||||
|
SwitchDashboard = bindings?.SwitchDashboard as typeof SwitchDashboard ?? (async () => dummyConfig)
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [config, setConfig] = useState<DashboardConfig | null>(null)
|
||||||
|
const [dashboards, setDashboards] = useState<DashboardInfo[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [switching, setSwitching] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
GetDashboardConfig()
|
||||||
|
.then(cfg => {
|
||||||
|
setConfig(cfg)
|
||||||
|
// ListDashboards may fail if runtime not ready yet — retry once
|
||||||
|
ListDashboards()
|
||||||
|
.then(setDashboards)
|
||||||
|
.catch(() => setTimeout(() => ListDashboards().then(setDashboards).catch(() => {}), 1000))
|
||||||
|
})
|
||||||
|
.catch(err => setError(err.message ?? String(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSwitch = useCallback(async (fileName: string) => {
|
||||||
|
setSwitching(true)
|
||||||
|
try {
|
||||||
|
const newCfg = await SwitchDashboard(fileName)
|
||||||
|
setConfig(newCfg)
|
||||||
|
const list = await ListDashboards()
|
||||||
|
setDashboards(list)
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message ?? String(err))
|
||||||
|
} finally {
|
||||||
|
setSwitching(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center p-8">
|
||||||
|
<div className="max-w-lg space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold text-[var(--destructive)]">Dashboard Error</h1>
|
||||||
|
<pre className="text-sm bg-[var(--card)] rounded-lg p-4 overflow-auto whitespace-pre-wrap border border-[var(--border)]">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config || switching) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center">
|
||||||
|
<p className="text-[var(--muted-foreground)]">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DashboardInner config={config} dashboards={dashboards} onSwitch={handleSwitch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardInner({ config, dashboards, onSwitch }: {
|
||||||
|
config: DashboardConfig
|
||||||
|
dashboards: DashboardInfo[]
|
||||||
|
onSwitch: (fileName: string) => void
|
||||||
|
}) {
|
||||||
|
const { values, setValue, reset } = useFilterState(config.filters ?? {})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', config.theme || 'dark')
|
||||||
|
}, [config.theme])
|
||||||
|
|
||||||
|
const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
|
||||||
|
return GetWidgetData(widgetID, filters)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
config={config}
|
||||||
|
filters={values}
|
||||||
|
onFilterChange={setValue}
|
||||||
|
onFilterReset={reset}
|
||||||
|
getData={getData}
|
||||||
|
dashboards={dashboards}
|
||||||
|
onSwitchDashboard={onSwitch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ========== DARK (default) — deep blue-grey ========== */
|
||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--background: oklch(8% 0.015 260);
|
||||||
|
--foreground: oklch(95% 0.01 260);
|
||||||
|
--muted: oklch(18% 0.02 260);
|
||||||
|
--muted-foreground: oklch(60% 0.02 260);
|
||||||
|
--border: oklch(15% 0.01 260);
|
||||||
|
--primary: oklch(65% 0.22 260);
|
||||||
|
--primary-foreground: oklch(98% 0.01 260);
|
||||||
|
--secondary: oklch(20% 0.02 260);
|
||||||
|
--secondary-foreground: oklch(95% 0.01 260);
|
||||||
|
--accent: oklch(18% 0.03 260);
|
||||||
|
--accent-foreground: oklch(95% 0.01 260);
|
||||||
|
--destructive: oklch(55% 0.22 25);
|
||||||
|
--destructive-foreground: oklch(98% 0.01 260);
|
||||||
|
--card: oklch(11% 0.015 260);
|
||||||
|
--card-foreground: oklch(95% 0.01 260);
|
||||||
|
--popover: oklch(12% 0.015 260);
|
||||||
|
--popover-foreground: oklch(95% 0.01 260);
|
||||||
|
--ring: oklch(65% 0.22 260);
|
||||||
|
--input: oklch(22% 0.02 260);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--success: oklch(65% 0.2 145);
|
||||||
|
--success-foreground: oklch(98% 0.01 145);
|
||||||
|
--chart-1: #3b82f6;
|
||||||
|
--chart-2: #10b981;
|
||||||
|
--chart-3: #f59e0b;
|
||||||
|
--chart-4: #ef4444;
|
||||||
|
--chart-5: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== EMERALD — rich green, gold accents ========== */
|
||||||
|
[data-theme="emerald"] {
|
||||||
|
--background: oklch(6% 0.03 155);
|
||||||
|
--foreground: oklch(92% 0.03 155);
|
||||||
|
--muted: oklch(15% 0.04 155);
|
||||||
|
--muted-foreground: oklch(58% 0.04 155);
|
||||||
|
--border: oklch(13% 0.03 155);
|
||||||
|
--primary: oklch(70% 0.22 155);
|
||||||
|
--primary-foreground: oklch(10% 0.03 155);
|
||||||
|
--secondary: oklch(18% 0.04 155);
|
||||||
|
--secondary-foreground: oklch(90% 0.03 155);
|
||||||
|
--accent: oklch(16% 0.05 155);
|
||||||
|
--accent-foreground: oklch(90% 0.03 155);
|
||||||
|
--destructive: oklch(55% 0.22 25);
|
||||||
|
--destructive-foreground: oklch(98% 0.01 260);
|
||||||
|
--card: oklch(9% 0.03 155);
|
||||||
|
--card-foreground: oklch(92% 0.03 155);
|
||||||
|
--popover: oklch(10% 0.04 155);
|
||||||
|
--popover-foreground: oklch(92% 0.03 155);
|
||||||
|
--ring: oklch(70% 0.22 155);
|
||||||
|
--input: oklch(20% 0.04 155);
|
||||||
|
--success: oklch(70% 0.22 155);
|
||||||
|
--success-foreground: oklch(10% 0.03 155);
|
||||||
|
--chart-1: #10b981;
|
||||||
|
--chart-2: #f59e0b;
|
||||||
|
--chart-3: #06b6d4;
|
||||||
|
--chart-4: #8b5cf6;
|
||||||
|
--chart-5: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== AMBER — warm dark, orange/amber accent ========== */
|
||||||
|
[data-theme="amber"] {
|
||||||
|
--background: oklch(7% 0.02 55);
|
||||||
|
--foreground: oklch(93% 0.02 55);
|
||||||
|
--muted: oklch(16% 0.03 55);
|
||||||
|
--muted-foreground: oklch(58% 0.03 55);
|
||||||
|
--border: oklch(14% 0.025 55);
|
||||||
|
--primary: oklch(72% 0.2 65);
|
||||||
|
--primary-foreground: oklch(10% 0.02 55);
|
||||||
|
--secondary: oklch(18% 0.03 55);
|
||||||
|
--secondary-foreground: oklch(90% 0.02 55);
|
||||||
|
--accent: oklch(16% 0.04 55);
|
||||||
|
--accent-foreground: oklch(90% 0.02 55);
|
||||||
|
--destructive: oklch(55% 0.22 25);
|
||||||
|
--destructive-foreground: oklch(98% 0.01 260);
|
||||||
|
--card: oklch(10% 0.025 55);
|
||||||
|
--card-foreground: oklch(93% 0.02 55);
|
||||||
|
--popover: oklch(11% 0.03 55);
|
||||||
|
--popover-foreground: oklch(93% 0.02 55);
|
||||||
|
--ring: oklch(72% 0.2 65);
|
||||||
|
--input: oklch(20% 0.03 55);
|
||||||
|
--success: oklch(65% 0.2 145);
|
||||||
|
--success-foreground: oklch(98% 0.01 145);
|
||||||
|
--chart-1: #f59e0b;
|
||||||
|
--chart-2: #ef4444;
|
||||||
|
--chart-3: #3b82f6;
|
||||||
|
--chart-4: #10b981;
|
||||||
|
--chart-5: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== ROSE — dark with pink/rose accent ========== */
|
||||||
|
[data-theme="rose"] {
|
||||||
|
--background: oklch(7% 0.02 350);
|
||||||
|
--foreground: oklch(93% 0.015 350);
|
||||||
|
--muted: oklch(16% 0.03 350);
|
||||||
|
--muted-foreground: oklch(58% 0.025 350);
|
||||||
|
--border: oklch(14% 0.025 350);
|
||||||
|
--primary: oklch(65% 0.22 350);
|
||||||
|
--primary-foreground: oklch(98% 0.01 350);
|
||||||
|
--secondary: oklch(18% 0.03 350);
|
||||||
|
--secondary-foreground: oklch(90% 0.015 350);
|
||||||
|
--accent: oklch(16% 0.04 350);
|
||||||
|
--accent-foreground: oklch(90% 0.015 350);
|
||||||
|
--destructive: oklch(55% 0.22 25);
|
||||||
|
--destructive-foreground: oklch(98% 0.01 260);
|
||||||
|
--card: oklch(10% 0.025 350);
|
||||||
|
--card-foreground: oklch(93% 0.015 350);
|
||||||
|
--popover: oklch(11% 0.03 350);
|
||||||
|
--popover-foreground: oklch(93% 0.015 350);
|
||||||
|
--ring: oklch(65% 0.22 350);
|
||||||
|
--input: oklch(20% 0.03 350);
|
||||||
|
--success: oklch(65% 0.2 145);
|
||||||
|
--success-foreground: oklch(98% 0.01 145);
|
||||||
|
--chart-1: #ec4899;
|
||||||
|
--chart-2: #8b5cf6;
|
||||||
|
--chart-3: #3b82f6;
|
||||||
|
--chart-4: #f59e0b;
|
||||||
|
--chart-5: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== LIGHT — clean light theme ========== */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--background: oklch(98% 0.005 260);
|
||||||
|
--foreground: oklch(15% 0.01 260);
|
||||||
|
--muted: oklch(93% 0.01 260);
|
||||||
|
--muted-foreground: oklch(45% 0.01 260);
|
||||||
|
--border: oklch(88% 0.01 260);
|
||||||
|
--primary: oklch(50% 0.22 260);
|
||||||
|
--primary-foreground: oklch(98% 0.01 260);
|
||||||
|
--secondary: oklch(94% 0.01 260);
|
||||||
|
--secondary-foreground: oklch(20% 0.01 260);
|
||||||
|
--accent: oklch(95% 0.01 260);
|
||||||
|
--accent-foreground: oklch(20% 0.01 260);
|
||||||
|
--destructive: oklch(50% 0.22 25);
|
||||||
|
--destructive-foreground: oklch(98% 0.01 260);
|
||||||
|
--card: oklch(100% 0 0);
|
||||||
|
--card-foreground: oklch(15% 0.01 260);
|
||||||
|
--popover: oklch(100% 0 0);
|
||||||
|
--popover-foreground: oklch(15% 0.01 260);
|
||||||
|
--ring: oklch(50% 0.22 260);
|
||||||
|
--input: oklch(88% 0.01 260);
|
||||||
|
--success: oklch(50% 0.2 145);
|
||||||
|
--success-foreground: oklch(98% 0.01 145);
|
||||||
|
--chart-1: #2563eb;
|
||||||
|
--chart-2: #059669;
|
||||||
|
--chart-3: #d97706;
|
||||||
|
--chart-4: #dc2626;
|
||||||
|
--chart-5: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove card borders for seamless look on dark themes */
|
||||||
|
[data-theme="dark"] [data-slot="card"],
|
||||||
|
[data-theme="emerald"] [data-slot="card"],
|
||||||
|
[data-theme="amber"] [data-slot="card"],
|
||||||
|
[data-theme="rose"] [data-slot="card"] {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme keeps subtle card shadows */
|
||||||
|
[data-theme="light"] [data-slot="card"] {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { FilterBar } from './FilterBar'
|
||||||
|
import { Section } from './Section'
|
||||||
|
import { SimpleSelect } from '@fn_library'
|
||||||
|
import type { DashboardConfig } from '../types'
|
||||||
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
|
interface DashboardInfo {
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
title: string
|
||||||
|
theme: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardShellProps {
|
||||||
|
config: DashboardConfig
|
||||||
|
filters: FilterValues
|
||||||
|
onFilterChange: (key: string, value: unknown) => void
|
||||||
|
onFilterReset: () => void
|
||||||
|
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||||
|
dashboards?: DashboardInfo[]
|
||||||
|
onSwitchDashboard?: (fileName: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] px-4 py-3 space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">{config.settings.title}</h1>
|
||||||
|
{dashboards && dashboards.length > 1 && onSwitchDashboard && (
|
||||||
|
<SimpleSelect
|
||||||
|
size="sm"
|
||||||
|
value={dashboards.find(d => d.current)?.file ?? ''}
|
||||||
|
onValueChange={(v) => onSwitchDashboard(v)}
|
||||||
|
options={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FilterBar
|
||||||
|
filters={config.filters ?? {}}
|
||||||
|
values={filters}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
onReset={onFilterReset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{(config.sections ?? []).map(section => (
|
||||||
|
<Section
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
queries={config.queries}
|
||||||
|
filters={filters}
|
||||||
|
globalColumns={config.settings.columns}
|
||||||
|
getData={getData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useRef, useEffect, useState } from 'react'
|
||||||
|
import { SimpleSelect } from '@fn_library'
|
||||||
|
import type { FilterDef, FilterPreset } from '../types'
|
||||||
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
filters: Record<string, FilterDef>
|
||||||
|
values: FilterValues
|
||||||
|
onChange: (key: string, value: unknown) => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps) {
|
||||||
|
const entries = Object.entries(filters)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{entries.map(([key, def]) => (
|
||||||
|
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterControlProps {
|
||||||
|
id: string
|
||||||
|
def: FilterDef
|
||||||
|
value: unknown
|
||||||
|
onChange: (key: string, value: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterControl({ id, def, value, onChange }: FilterControlProps) {
|
||||||
|
switch (def.type) {
|
||||||
|
case 'select':
|
||||||
|
return <SelectFilter id={id} def={def} value={value as string} onChange={onChange} />
|
||||||
|
case 'date_range':
|
||||||
|
return <DateRangeFilter id={id} def={def} value={value as Record<string, string>} onChange={onChange} />
|
||||||
|
case 'text':
|
||||||
|
return <TextFilter id={id} def={def} value={value as string} onChange={onChange} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||||
|
<SimpleSelect
|
||||||
|
value={value ?? ''}
|
||||||
|
onValueChange={(v) => onChange(id, v)}
|
||||||
|
options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateRangeFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: Record<string, string>; onChange: (k: string, v: unknown) => void }) {
|
||||||
|
const presets = def.presets ?? []
|
||||||
|
const currentFrom = value?.from ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map((preset: FilterPreset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={() => onChange(id, { from: preset.from, to: preset.to })}
|
||||||
|
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||||
|
currentFrom === preset.from
|
||||||
|
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||||
|
: 'bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--accent)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
|
||||||
|
const [localValue, setLocalValue] = useState(value ?? '')
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const debounce = def.debounce ?? 300
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value ?? '')
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setLocalValue(v)
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => onChange(id, v), debounce)
|
||||||
|
}, [id, onChange, debounce])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={def.placeholder}
|
||||||
|
className="text-sm bg-[var(--secondary)] text-[var(--foreground)] border border-[var(--border)] rounded px-2 py-1 outline-none focus:ring-1 focus:ring-[var(--ring)] w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
import { WidgetRenderer } from './WidgetRenderer'
|
||||||
|
import type { SectionDef, QueryDef } from '../types'
|
||||||
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
section: SectionDef
|
||||||
|
queries: Record<string, QueryDef>
|
||||||
|
filters: FilterValues
|
||||||
|
globalColumns: number
|
||||||
|
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const columns = section.columns || globalColumns
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 cursor-pointer select-none bg-transparent border-none p-0"
|
||||||
|
onClick={() => section.collapsible && setCollapsed(c => !c)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{section.collapsible && (
|
||||||
|
collapsed
|
||||||
|
? <ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
|
||||||
|
: <ChevronDown className="w-4 h-4 text-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
|
<h2 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.widgets.map(widget => (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
style={{
|
||||||
|
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
|
||||||
|
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WidgetRenderer
|
||||||
|
widget={widget}
|
||||||
|
queryDef={queries[widget.query]}
|
||||||
|
filters={filters}
|
||||||
|
getData={getData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import { getWidget } from './widget-registry'
|
||||||
|
import type { WidgetDef, QueryDef } from '../types'
|
||||||
|
import type { FilterValues } from '../hooks/useFilterState'
|
||||||
|
|
||||||
|
interface WidgetRendererProps {
|
||||||
|
widget: WidgetDef
|
||||||
|
queryDef: QueryDef
|
||||||
|
filters: FilterValues
|
||||||
|
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRendererProps) {
|
||||||
|
const [data, setData] = useState<Record<string, unknown>[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
const [countdown, setCountdown] = useState(0)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const lastDataRef = useRef<string>('')
|
||||||
|
|
||||||
|
const refreshSec = Math.max(Math.round((queryDef.refreshMs ?? 30000) / 1000), 1)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getData(widget.id, filters)
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(result)
|
||||||
|
if (serialized !== lastDataRef.current) {
|
||||||
|
lastDataRef.current = serialized
|
||||||
|
setData(result)
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setError(err as Error)
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
setCountdown(refreshSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [widget.id, filters, getData, refreshSec])
|
||||||
|
|
||||||
|
// Initial fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
fetch()
|
||||||
|
return () => { mountedRef.current = false }
|
||||||
|
}, [fetch])
|
||||||
|
|
||||||
|
// Refetch on interval.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryDef.refreshMs || queryDef.refreshMs <= 0) return
|
||||||
|
const interval = setInterval(fetch, queryDef.refreshMs)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetch, queryDef.refreshMs])
|
||||||
|
|
||||||
|
// Countdown tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
setCountdown(c => (c > 0 ? c - 1 : 0))
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(tick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const Component = getWidget(widget.type)
|
||||||
|
if (!Component) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">{widget.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Unknown widget type: <code>{widget.type}</code>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<Component widget={widget} data={data} loading={loading} error={error} />
|
||||||
|
<span className="absolute top-2 right-2 text-[10px] tabular-nums text-[var(--muted-foreground)] opacity-60">
|
||||||
|
{countdown}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { WidgetProps } from '../types'
|
||||||
|
|
||||||
|
const WIDGET_REGISTRY: Record<string, ComponentType<WidgetProps>> = {}
|
||||||
|
|
||||||
|
export function registerWidget(type: string, component: ComponentType<WidgetProps>) {
|
||||||
|
WIDGET_REGISTRY[type] = component
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWidget(type: string): ComponentType<WidgetProps> | undefined {
|
||||||
|
return WIDGET_REGISTRY[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredTypes(): string[] {
|
||||||
|
return Object.keys(WIDGET_REGISTRY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WIDGET_REGISTRY }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||||
|
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const xKey = mapping.x as string ?? 'x'
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
|
||||||
|
const series = Array.isArray(mapping.series)
|
||||||
|
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const yKey = !series ? (mapping.y as string) : undefined
|
||||||
|
const areas = series
|
||||||
|
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: s.color ?? COLORS[i % COLORS.length] }))
|
||||||
|
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||||
|
<AreaChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||||
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
||||||
|
{!!options.show_legend && <Legend />}
|
||||||
|
<defs>
|
||||||
|
{areas.map(area => (
|
||||||
|
<linearGradient key={`grad-${area.dataKey}`} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={area.color} stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor={area.color} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
{areas.map(area => (
|
||||||
|
<Area
|
||||||
|
key={area.dataKey}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={area.dataKey}
|
||||||
|
name={area.name}
|
||||||
|
stroke={area.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#gradient-${area.dataKey})`}
|
||||||
|
stackId={options.stacked ? 'stack' : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||||
|
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const xKey = mapping.x as string ?? 'x'
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
|
||||||
|
const series = Array.isArray(mapping.series)
|
||||||
|
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const yKey = !series ? (mapping.y as string) : undefined
|
||||||
|
const bars = series
|
||||||
|
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: s.color ?? COLORS[i % COLORS.length] }))
|
||||||
|
: yKey ? [{ dataKey: yKey, name: yKey, fill: COLORS[0] }] : []
|
||||||
|
|
||||||
|
const isHorizontal = !!options.horizontal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-1">
|
||||||
|
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||||
|
<BarChart
|
||||||
|
data={data ?? []}
|
||||||
|
layout={isHorizontal ? 'vertical' : 'horizontal'}
|
||||||
|
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
|
||||||
|
>
|
||||||
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||||
|
{isHorizontal ? (
|
||||||
|
<>
|
||||||
|
<YAxis
|
||||||
|
dataKey={xKey}
|
||||||
|
type="category"
|
||||||
|
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||||
|
width={140}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} />
|
||||||
|
{!!options.show_legend && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||||
|
{bars.map(bar => (
|
||||||
|
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { KPICard, Sparkline } from '@fn_library'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
export function KPIWidget({ widget, data, loading }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const valueKey = mapping.value as string ?? 'value'
|
||||||
|
const format = mapping.format as string
|
||||||
|
const unitKey = mapping.unit as string | undefined
|
||||||
|
const deltaKey = mapping.delta as string | undefined
|
||||||
|
const deltaLabel = mapping.deltaLabel as string | undefined
|
||||||
|
const deltaSuffix = mapping.deltaSuffix as string | undefined
|
||||||
|
const sparklineKey = mapping.sparkline as string | undefined
|
||||||
|
const sparklineColors = mapping.sparklineColors as string[] | undefined
|
||||||
|
|
||||||
|
let displayValue: string | number = '—'
|
||||||
|
let displayUnit: string | undefined
|
||||||
|
let delta: { value: number; isPositive: boolean; label?: string; suffix?: string } | undefined
|
||||||
|
let sparklineData: number[] | undefined
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const row = data[0]
|
||||||
|
displayValue = formatValue(row[valueKey], format)
|
||||||
|
|
||||||
|
if (unitKey && row[unitKey] != null) {
|
||||||
|
displayUnit = String(row[unitKey])
|
||||||
|
} else if (mapping.unitLabel) {
|
||||||
|
displayUnit = mapping.unitLabel as string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaKey && row[deltaKey] != null) {
|
||||||
|
const dv = Number(row[deltaKey])
|
||||||
|
if (!isNaN(dv)) {
|
||||||
|
delta = { value: dv, isPositive: dv >= 0, label: deltaLabel, suffix: deltaSuffix }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sparklineKey) {
|
||||||
|
sparklineData = data.map(r => Number(r[sparklineKey]) || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KPICard
|
||||||
|
label={widget.title}
|
||||||
|
value={loading ? '...' : displayValue}
|
||||||
|
unit={displayUnit}
|
||||||
|
delta={delta}
|
||||||
|
chart={sparklineData && sparklineData.length > 0 ? (
|
||||||
|
<Sparkline
|
||||||
|
data={sparklineData}
|
||||||
|
variant="bar"
|
||||||
|
colors={sparklineColors}
|
||||||
|
width={80}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
size="default"
|
||||||
|
className="border-0 shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(raw: unknown, format?: string): string | number {
|
||||||
|
if (raw == null) return '—'
|
||||||
|
const num = Number(raw)
|
||||||
|
if (isNaN(num)) return String(raw)
|
||||||
|
if (!format) return num
|
||||||
|
|
||||||
|
if (format.includes('f')) {
|
||||||
|
const match = format.match(/\.(\d+)f/)
|
||||||
|
const decimals = match ? parseInt(match[1]) : 0
|
||||||
|
let str = num.toFixed(decimals)
|
||||||
|
if (format.includes(',')) {
|
||||||
|
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
}
|
||||||
|
if (format.startsWith('$')) str = '$' + str
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
if (format === ',') return num.toLocaleString()
|
||||||
|
return num
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
Legend, Brush, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||||
|
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const xKey = mapping.x as string ?? 'x'
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
|
||||||
|
const series = Array.isArray(mapping.series)
|
||||||
|
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const yKey = !series ? (mapping.y as string) : undefined
|
||||||
|
const lines = series
|
||||||
|
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: s.color ?? COLORS[i % COLORS.length] }))
|
||||||
|
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||||
|
<LineChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
|
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
|
||||||
|
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
|
||||||
|
{!!options.show_legend && <Legend />}
|
||||||
|
{lines.map(line => (
|
||||||
|
<Line
|
||||||
|
key={line.dataKey}
|
||||||
|
type={(options.curve as any) ?? 'monotone'}
|
||||||
|
dataKey={line.dataKey}
|
||||||
|
name={line.name}
|
||||||
|
stroke={line.stroke}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--primary)" />}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import {
|
||||||
|
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { PieLabelRenderProps } from 'recharts'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
|
||||||
|
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
|
||||||
|
'var(--chart-7, #06b6d4)', 'var(--chart-8, #f97316)',
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderLabel(props: PieLabelRenderProps): string {
|
||||||
|
const name = props.name ?? ''
|
||||||
|
const pct = typeof props.percent === 'number' ? (props.percent * 100).toFixed(0) : '0'
|
||||||
|
return `${name} ${pct}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const nameKey = mapping.name as string ?? mapping.x as string ?? 'name'
|
||||||
|
const valueKey = mapping.value as string ?? mapping.y as string ?? 'value'
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
|
||||||
|
// Ensure numeric values for Recharts Pie
|
||||||
|
const pieData = (data ?? []).map(row => ({
|
||||||
|
...row,
|
||||||
|
[valueKey]: Number(row[valueKey]) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-1">
|
||||||
|
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
dataKey={valueKey}
|
||||||
|
nameKey={nameKey}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
innerRadius={options.donut ? 50 : 0}
|
||||||
|
strokeWidth={0}
|
||||||
|
fontSize={11}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
label={renderLabel}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{pieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
{options.show_legend !== false && (
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
)}
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Card, CardContent } from '@fn_library'
|
||||||
|
import { Sparkline } from '@fn_library'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
export function SparklineWidget({ widget, data, loading }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const valueKey = mapping.value as string ?? 'value'
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
|
||||||
|
const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p>
|
||||||
|
{loading && values.length === 0 ? (
|
||||||
|
<div className="h-[40px] flex items-center text-[var(--muted-foreground)] text-xs">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<Sparkline
|
||||||
|
data={values}
|
||||||
|
variant={(options.variant as 'line' | 'area' | 'bar') ?? 'area'}
|
||||||
|
width={options.width as number ?? 200}
|
||||||
|
height={options.height as number ?? 40}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||||
|
import type { WidgetProps } from '../../types'
|
||||||
|
|
||||||
|
interface ColumnDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
format?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
||||||
|
const mapping = widget.mapping ?? {}
|
||||||
|
const columns = (mapping.columns as ColumnDef[]) ?? []
|
||||||
|
const options = widget.options ?? {}
|
||||||
|
const heatmapKeys = (options.heatmap_columns as string[]) ?? []
|
||||||
|
|
||||||
|
// Auto-detect columns from first row if not specified.
|
||||||
|
const effectiveColumns: ColumnDef[] = columns.length > 0
|
||||||
|
? columns
|
||||||
|
: data && data.length > 0
|
||||||
|
? Object.keys(data[0]).map(k => ({ key: k, label: k }))
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Compute heatmap ranges per column
|
||||||
|
const heatmapRanges: Record<string, { min: number; max: number }> = {}
|
||||||
|
if (heatmapKeys.length > 0 && data && data.length > 0) {
|
||||||
|
for (const key of heatmapKeys) {
|
||||||
|
const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
|
||||||
|
if (values.length > 0) {
|
||||||
|
heatmapRanges[key] = { min: Math.min(...values), max: Math.max(...values) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
|
||||||
|
const range = heatmapRanges[key]
|
||||||
|
if (!range || range.max === range.min) return undefined
|
||||||
|
const num = Number(value)
|
||||||
|
if (isNaN(num)) return undefined
|
||||||
|
const t = (num - range.min) / (range.max - range.min)
|
||||||
|
const alpha = 0.1 + t * 0.55
|
||||||
|
return { backgroundColor: `color-mix(in srgb, var(--chart-1) ${Math.round(alpha * 100)}%, transparent)` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-1">
|
||||||
|
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto max-h-[500px]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-[var(--card)]">
|
||||||
|
<tr className="border-b border-[var(--border)]">
|
||||||
|
{effectiveColumns.map(col => (
|
||||||
|
<th key={col.key} className="text-left py-1.5 px-3 text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data ?? []).map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-[var(--border)] hover:bg-[var(--accent)]/50 transition-colors">
|
||||||
|
{effectiveColumns.map(col => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className="py-1.5 px-3 font-mono text-xs"
|
||||||
|
style={heatmapStyle(col.key, row[col.key])}
|
||||||
|
>
|
||||||
|
{formatCell(row[col.key], col.format)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{(!data || data.length === 0) && (
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] text-sm py-8">No data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value: unknown, format?: string): string {
|
||||||
|
if (value == null) return '—'
|
||||||
|
if (!format) return String(value)
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
|
||||||
|
return new Date(String(value)).toLocaleString()
|
||||||
|
}
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
if (format.includes('f')) {
|
||||||
|
const match = format.match(/\.(\d+)f/)
|
||||||
|
const d = match ? parseInt(match[1]) : 0
|
||||||
|
let str = num.toFixed(d)
|
||||||
|
if (format.includes(',')) str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||||
|
if (format.startsWith('$')) str = '$' + str
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
if (format === ',') return num.toLocaleString()
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Registers all built-in widget types.
|
||||||
|
// To add a new widget: create the component, then add registerWidget() here.
|
||||||
|
|
||||||
|
import { registerWidget } from '../widget-registry'
|
||||||
|
import { KPIWidget } from './KPIWidget'
|
||||||
|
import { LineChartWidget } from './LineChartWidget'
|
||||||
|
import { BarChartWidget } from './BarChartWidget'
|
||||||
|
import { AreaChartWidget } from './AreaChartWidget'
|
||||||
|
import { PieChartWidget } from './PieChartWidget'
|
||||||
|
import { SparklineWidget } from './SparklineWidget'
|
||||||
|
import { TableWidget } from './TableWidget'
|
||||||
|
|
||||||
|
registerWidget('kpi', KPIWidget)
|
||||||
|
registerWidget('line_chart', LineChartWidget)
|
||||||
|
registerWidget('bar_chart', BarChartWidget)
|
||||||
|
registerWidget('area_chart', AreaChartWidget)
|
||||||
|
registerWidget('pie_chart', PieChartWidget)
|
||||||
|
registerWidget('sparkline', SparklineWidget)
|
||||||
|
registerWidget('table', TableWidget)
|
||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
|
||||||
|
declare module '@fn_library' {
|
||||||
|
import type { FC } from 'react'
|
||||||
|
export const Card: FC<any>
|
||||||
|
export const CardContent: FC<any>
|
||||||
|
export const CardHeader: FC<any>
|
||||||
|
export const CardTitle: FC<any>
|
||||||
|
export const KPICard: FC<any>
|
||||||
|
export const Sparkline: FC<any>
|
||||||
|
export const Badge: FC<any>
|
||||||
|
export const Button: FC<any>
|
||||||
|
export const Input: FC<any>
|
||||||
|
export const Skeleton: FC<any>
|
||||||
|
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
|
||||||
|
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
|
import type { FilterDef } from '../types'
|
||||||
|
|
||||||
|
export type FilterValues = Record<string, unknown>
|
||||||
|
|
||||||
|
function initializeDefaults(filters: Record<string, FilterDef>): FilterValues {
|
||||||
|
const values: FilterValues = {}
|
||||||
|
for (const [key, def] of Object.entries(filters)) {
|
||||||
|
values[key] = def.default ?? (def.type === 'text' ? '' : null)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFilterState(filters: Record<string, FilterDef>) {
|
||||||
|
const defaults = useMemo(() => initializeDefaults(filters), [filters])
|
||||||
|
const [values, setValues] = useState<FilterValues>(defaults)
|
||||||
|
|
||||||
|
const setValue = useCallback((key: string, value: unknown) => {
|
||||||
|
setValues(prev => ({ ...prev, [key]: value }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setValues(defaults)
|
||||||
|
}, [defaults])
|
||||||
|
|
||||||
|
return { values, setValue, reset }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Types mirroring Go config — sent via GetDashboardConfig() IPC.
|
||||||
|
|
||||||
|
export interface DashboardConfig {
|
||||||
|
settings: Settings
|
||||||
|
theme: string
|
||||||
|
queries: Record<string, QueryDef>
|
||||||
|
filters: Record<string, FilterDef>
|
||||||
|
sections: SectionDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
title: string
|
||||||
|
refresh: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
columns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryDef {
|
||||||
|
connection: string
|
||||||
|
refresh: string
|
||||||
|
staleTime: string
|
||||||
|
params: Record<string, string> | null
|
||||||
|
refreshMs: number
|
||||||
|
staleMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterDef {
|
||||||
|
type: 'select' | 'date_range' | 'text'
|
||||||
|
label: string
|
||||||
|
default: unknown
|
||||||
|
options?: FilterOption[]
|
||||||
|
presets?: FilterPreset[]
|
||||||
|
placeholder?: string
|
||||||
|
debounce?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterPreset {
|
||||||
|
label: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionDef {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
collapsible: boolean
|
||||||
|
columns?: number
|
||||||
|
widgets: WidgetDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDef {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
query: string
|
||||||
|
mapping: Record<string, unknown>
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
span: number
|
||||||
|
rowSpan?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget component props — all widget components receive this.
|
||||||
|
export interface WidgetProps {
|
||||||
|
widget: WidgetDef
|
||||||
|
data: Record<string, unknown>[] | null
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@wails/*": ["./src/wailsjs/*"],
|
||||||
|
"@fn_library": ["./src/env.d.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
'@wails': resolve(__dirname, './wailsjs'),
|
||||||
|
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
|
||||||
|
},
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
module rapid-dashboards
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace fn-registry => /home/lucas/fn_registry
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||||
|
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||||
|
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||||
|
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// examplesDir returns the path to the examples/ directory.
|
||||||
|
// Checks cwd first (wails dev), then next to the executable (production).
|
||||||
|
func examplesDir() string {
|
||||||
|
// 1. examples/ in cwd (wails dev runs from project root)
|
||||||
|
if info, err := os.Stat("examples"); err == nil && info.IsDir() {
|
||||||
|
return "examples"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. examples/ next to the executable (production binary)
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Join(filepath.Dir(exePath), "examples")
|
||||||
|
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstYAML(dir string) string {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && (filepath.Ext(e.Name()) == ".yaml" || filepath.Ext(e.Name()) == ".yml") {
|
||||||
|
return filepath.Join(dir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// File logger for debugging
|
||||||
|
logFile, err := os.OpenFile("rapid_dashboards.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
if err == nil {
|
||||||
|
log.SetOutput(logFile)
|
||||||
|
defer logFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("=== rapid_dashboards starting ===")
|
||||||
|
log.Printf("os.Args: %v", os.Args)
|
||||||
|
log.Printf("DASHBOARD env: %q", os.Getenv("DASHBOARD"))
|
||||||
|
log.Printf("cwd: %s", func() string { d, _ := os.Getwd(); return d }())
|
||||||
|
|
||||||
|
dashboardPath := flag.String("dashboard", "", "path to dashboard YAML file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.Printf("flag --dashboard: %q", *dashboardPath)
|
||||||
|
|
||||||
|
// Resolve examples directory — always used for listing/switching dashboards.
|
||||||
|
dashDir := examplesDir()
|
||||||
|
log.Printf("examples dir: %q", dashDir)
|
||||||
|
|
||||||
|
// Fallback: CLI flag → env var → first YAML in examples/
|
||||||
|
if *dashboardPath == "" {
|
||||||
|
if envPath := os.Getenv("DASHBOARD"); envPath != "" {
|
||||||
|
*dashboardPath = envPath
|
||||||
|
log.Printf("using DASHBOARD env var: %q", envPath)
|
||||||
|
} else if dashDir != "" {
|
||||||
|
if first := firstYAML(dashDir); first != "" {
|
||||||
|
*dashboardPath = first
|
||||||
|
log.Printf("auto-discovered dashboard: %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *DashboardConfig
|
||||||
|
var pool map[string]*sql.DB
|
||||||
|
|
||||||
|
if *dashboardPath == "" {
|
||||||
|
log.Printf("NO dashboard path — using dummy config (bindings mode)")
|
||||||
|
cfg = &DashboardConfig{
|
||||||
|
Settings: Settings{Title: "rapid-dashboards", Width: 1280, Height: 800, Columns: 12},
|
||||||
|
Queries: map[string]QueryDef{},
|
||||||
|
Filters: map[string]FilterDef{},
|
||||||
|
Sections: []SectionDef{},
|
||||||
|
Connections: map[string]ConnConfig{},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("loading dashboard from: %q", *dashboardPath)
|
||||||
|
var loadErr error
|
||||||
|
cfg, loadErr = LoadDashboard(*dashboardPath)
|
||||||
|
if loadErr != nil {
|
||||||
|
log.Printf("ERROR loading dashboard: %v", loadErr)
|
||||||
|
fmt.Fprintf(os.Stderr, "error loading dashboard: %v\n", loadErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Printf("dashboard loaded OK — title=%q sections=%d queries=%d",
|
||||||
|
cfg.Settings.Title, len(cfg.Sections), len(cfg.Queries))
|
||||||
|
|
||||||
|
yamlDir := filepath.Dir(*dashboardPath)
|
||||||
|
pool, loadErr = OpenConnections(cfg.Connections, yamlDir)
|
||||||
|
if loadErr != nil {
|
||||||
|
log.Printf("ERROR opening connections: %v", loadErr)
|
||||||
|
fmt.Fprintf(os.Stderr, "error opening connections: %v\n", loadErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer CloseConnections(pool)
|
||||||
|
log.Printf("connections opened: %d", len(pool))
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewQueryEngine(cfg, pool)
|
||||||
|
app := NewApp(cfg, engine, pool, dashDir, *dashboardPath)
|
||||||
|
|
||||||
|
log.Printf("starting wails — title=%q width=%d height=%d dashDir=%q", cfg.Settings.Title, cfg.Settings.Width, cfg.Settings.Height, dashDir)
|
||||||
|
|
||||||
|
runErr := wails.Run(&options.App{
|
||||||
|
Title: cfg.Settings.Title,
|
||||||
|
Width: cfg.Settings.Width,
|
||||||
|
Height: cfg.Settings.Height,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: assets,
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 10, G: 10, B: 15, A: 1},
|
||||||
|
OnStartup: app.startup,
|
||||||
|
Bind: []interface{}{
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
log.Printf("ERROR wails.Run: %v", runErr)
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", runErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Printf("=== rapid_dashboards exited cleanly ===")
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var namedParamRe = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||||
|
|
||||||
|
// QueryEngine executes queries with parameter resolution.
|
||||||
|
type QueryEngine struct {
|
||||||
|
config *DashboardConfig
|
||||||
|
pool map[string]*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueryEngine(cfg *DashboardConfig, pool map[string]*sql.DB) *QueryEngine {
|
||||||
|
return &QueryEngine{config: cfg, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the query for a widget, resolving filter references and named params.
|
||||||
|
func (qe *QueryEngine) Execute(widgetID string, filters map[string]any) ([]map[string]any, error) {
|
||||||
|
// Find the widget and its query.
|
||||||
|
widget, err := qe.findWidget(widgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
qdef, ok := qe.config.Queries[widget.Query]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("query %q not found", widget.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, ok := qe.pool[qdef.Connection]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("connection %q not found", qdef.Connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parameter values: static or $filter.xxx references.
|
||||||
|
resolvedParams := qe.resolveParams(qdef.Params, filters)
|
||||||
|
|
||||||
|
// Convert :name placeholders to driver-appropriate positional params.
|
||||||
|
driver := qe.config.Connections[qdef.Connection].Driver
|
||||||
|
query, args := convertNamedParams(qdef.SQL, resolvedParams, driver)
|
||||||
|
|
||||||
|
return infra.DBQuery(db, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qe *QueryEngine) findWidget(id string) (*WidgetDef, error) {
|
||||||
|
for _, sec := range qe.config.Sections {
|
||||||
|
for i := range sec.Widgets {
|
||||||
|
if sec.Widgets[i].ID == id {
|
||||||
|
return &sec.Widgets[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("widget %q not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveParams replaces $filter.xxx references with actual filter values
|
||||||
|
// and resolves relative dates.
|
||||||
|
func (qe *QueryEngine) resolveParams(params map[string]string, filters map[string]any) map[string]string {
|
||||||
|
resolved := make(map[string]string, len(params))
|
||||||
|
|
||||||
|
for name, ref := range params {
|
||||||
|
val := ref
|
||||||
|
|
||||||
|
// $filter.date_range.from → filters["date_range"]["from"]
|
||||||
|
if strings.HasPrefix(ref, "$filter.") {
|
||||||
|
val = resolveFilterRef(ref, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative dates (now-7d, etc.)
|
||||||
|
if resolved, ok := ResolveRelativeDate(val); ok {
|
||||||
|
val = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved[name] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFilterRef extracts a value from the filters map using dot notation.
|
||||||
|
// "$filter.date_range.from" → filters["date_range"] → map["from"]
|
||||||
|
func resolveFilterRef(ref string, filters map[string]any) string {
|
||||||
|
// Strip "$filter." prefix.
|
||||||
|
path := strings.TrimPrefix(ref, "$filter.")
|
||||||
|
parts := strings.SplitN(path, ".", 2)
|
||||||
|
|
||||||
|
v, ok := filters[parts[0]]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple value (e.g. $filter.category).
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested value (e.g. $filter.date_range.from).
|
||||||
|
if m, ok := v.(map[string]any); ok {
|
||||||
|
if val, ok := m[parts[1]]; ok {
|
||||||
|
return fmt.Sprint(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertNamedParams replaces :name placeholders with positional params ($1, ?, etc.)
|
||||||
|
// and builds the args slice in the correct order.
|
||||||
|
func convertNamedParams(query string, params map[string]string, driver string) (string, []any) {
|
||||||
|
matches := namedParamRe.FindAllStringSubmatch(query, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return query, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate and order params as they appear.
|
||||||
|
seen := make(map[string]int)
|
||||||
|
var ordered []string
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if _, ok := seen[name]; !ok {
|
||||||
|
seen[name] = len(ordered)
|
||||||
|
ordered = append(ordered, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build args slice.
|
||||||
|
args := make([]any, len(ordered))
|
||||||
|
for i, name := range ordered {
|
||||||
|
args[i] = params[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace :name with positional placeholder.
|
||||||
|
result := namedParamRe.ReplaceAllStringFunc(query, func(match string) string {
|
||||||
|
name := match[1:] // strip leading ':'
|
||||||
|
idx := seen[name]
|
||||||
|
return placeholder(driver, idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholder(driver string, idx int) string {
|
||||||
|
switch driver {
|
||||||
|
case "postgres":
|
||||||
|
return fmt.Sprintf("$%d", idx+1)
|
||||||
|
default:
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Lanza rapid_dashboards en modo desarrollo (wails dev).
|
||||||
|
# El dashboard inicial se auto-descubre desde examples/.
|
||||||
|
# El usuario puede cambiar de dashboard desde la UI.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
echo "Dashboards disponibles en examples/:"
|
||||||
|
for f in examples/*.yaml; do
|
||||||
|
echo " $(basename "$f" .yaml)"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "-> wails dev (selecciona dashboard desde la UI)"
|
||||||
|
wails dev
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compila y lanza rapid_dashboards en modo produccion.
|
||||||
|
# Uso: ./scripts/prod.sh <nombre_dashboard> [env vars...]
|
||||||
|
# Ejemplo: DB_PASSWORD=secret ./scripts/prod.sh fn_registry_overview
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
EXAMPLES_DIR="$APP_DIR/examples"
|
||||||
|
BIN="$APP_DIR/build/bin/rapid-dashboards"
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Dashboards disponibles:"
|
||||||
|
for f in "$EXAMPLES_DIR"/*.yaml; do
|
||||||
|
echo " $(basename "$f" .yaml)"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Uso: $0 <nombre> (sin .yaml)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
YAML="$EXAMPLES_DIR/$1.yaml"
|
||||||
|
if [ ! -f "$YAML" ]; then
|
||||||
|
echo "Error: no existe $YAML"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
# Compilar si el binario no existe o el YAML es mas reciente
|
||||||
|
if [ ! -f "$BIN" ] || [ "$YAML" -nt "$BIN" ]; then
|
||||||
|
echo "-> compilando..."
|
||||||
|
CGO_ENABLED=1 wails build -tags fts5
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-> $BIN --dashboard $YAML"
|
||||||
|
exec "$BIN" --dashboard "$YAML"
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "rapid-dashboards",
|
||||||
|
"outputfilename": "rapid-dashboards",
|
||||||
|
"frontend:dir": "./frontend",
|
||||||
|
"frontend:install": "pnpm install",
|
||||||
|
"frontend:build": "pnpm run build",
|
||||||
|
"frontend:dev:watcher": "pnpm run dev",
|
||||||
|
"frontend:dev:serverUrl": "auto",
|
||||||
|
"wailsjsdir": "./frontend/src",
|
||||||
|
"author": {
|
||||||
|
"name": "Egutierrez"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user