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