init: rapid_dashboards app from fn_registry

This commit is contained in:
dataforge
2026-04-06 00:57:13 +02:00
commit b7f354e081
46 changed files with 6139 additions and 0 deletions
+19
View File
@@ -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/
+140
View File
@@ -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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+15
View File
@@ -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}}"
}
}
}
+224
View File
@@ -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()
}
+62
View File
@@ -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)
}
}
+525
View File
@@ -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
View File
@@ -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
View File
@@ -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/`.
+144
View File
@@ -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
+202
View File
@@ -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
+285
View File
@@ -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
+40
View File
@@ -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
}
+12
View File
@@ -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>
+36
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
052e44bcd719cd7db765d6c7fb248074
+1628
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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}
/>
)
}
+176
View File
@@ -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>
)
}
+119
View File
@@ -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>
)
}
+63
View File
@@ -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)
+18
View File
@@ -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
}
+27
View File
@@ -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 }
}
+10
View File
@@ -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>,
)
+74
View File
@@ -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
}
+27
View File
@@ -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"]
}
+20
View File
@@ -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,
},
})
+74
View File
@@ -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
+231
View File
@@ -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=
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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"
}
}