commit b7f354e0812bd14e2d1d14655e97c2fec259c1d4 Author: dataforge Date: Mon Apr 6 00:57:13 2026 +0200 init: rapid_dashboards app from fn_registry diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1b9a2c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/app.go b/app.go new file mode 100644 index 0000000..f95b089 --- /dev/null +++ b/app.go @@ -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 +} diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..63617fe Binary files /dev/null and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..bfa0690 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/build/windows/info.json @@ -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}}" + } + } +} \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..d7492f3 --- /dev/null +++ b/config.go @@ -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() +} diff --git a/connections.go b/connections.go new file mode 100644 index 0000000..acc9689 --- /dev/null +++ b/connections.go @@ -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) + } +} diff --git a/docs/ejemplos.md b/docs/ejemplos.md new file mode 100644 index 0000000..263995e --- /dev/null +++ b/docs/ejemplos.md @@ -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 } +``` diff --git a/docs/guia.md b/docs/guia.md new file mode 100644 index 0000000..508f564 --- /dev/null +++ b/docs/guia.md @@ -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 ` (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 `` no acepta callbacks con tipos genericos como `Record`. 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` diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..40a7098 --- /dev/null +++ b/docs/schema.md @@ -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 # symlink + wails dev +./scripts/prod.sh # build + ejecutar +``` + +Sin argumento lista los dashboards disponibles en `examples/`. diff --git a/examples/demo_dashboard.yaml b/examples/demo_dashboard.yaml new file mode 100644 index 0000000..4624f36 --- /dev/null +++ b/examples/demo_dashboard.yaml @@ -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 diff --git a/examples/fn_registry_apps.yaml b/examples/fn_registry_apps.yaml new file mode 100644 index 0000000..fa518c7 --- /dev/null +++ b/examples/fn_registry_apps.yaml @@ -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 diff --git a/examples/fn_registry_overview.yaml b/examples/fn_registry_overview.yaml new file mode 100644 index 0000000..6e43157 --- /dev/null +++ b/examples/fn_registry_overview.yaml @@ -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 diff --git a/filter_engine.go b/filter_engine.go new file mode 100644 index 0000000..fc654c2 --- /dev/null +++ b/filter_engine.go @@ -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 +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c98eaae --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Rapid Dashboards + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..09d1801 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100755 index 0000000..4592c0a --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +052e44bcd719cd7db765d6c7fb248074 \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..6f603fa --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1628 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@base-ui/react': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@19.2.4) + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.8.0 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1)) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.0 + version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1)) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.0 + version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1) + +packages: + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@base-ui/react@1.3.0': + resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.6': + resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@babel/runtime@7.29.2': {} + + '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@date-fns/tz@1.4.1': {} + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tabby_ai/hijri-converter@1.0.5': {} + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1) + + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/table-core@8.21.3': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1) + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + + decimal.js-light@2.5.1: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-toolkit@1.45.1: {} + + eventemitter3@5.0.4: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + immer@10.2.0: {} + + immer@11.1.4: {} + + internmap@2.0.3: {} + + jiti@2.6.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lucide-react@0.577.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-day-picker@9.14.0(react@19.2.4): + dependencies: + '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.4 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@19.2.4: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + + react@19.2.4: {} + + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reselect@5.1.1: {} + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tabbable@6.4.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(jiti@2.6.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..69a156e --- /dev/null +++ b/frontend/src/App.tsx @@ -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 +let GetWidgetData: (widgetID: string, filters: FilterValues) => Promise[]> +let ListDashboards: () => Promise +let SwitchDashboard: (fileName: string) => Promise + +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(null) + const [dashboards, setDashboards] = useState([]) + const [error, setError] = useState(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 ( +
+
+

Dashboard Error

+
+            {error}
+          
+
+
+ ) + } + + if (!config || switching) { + return ( +
+

{switching ? 'Switching dashboard...' : 'Loading dashboard...'}

+
+ ) + } + + return +} + +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 ( + + ) +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..a506abc --- /dev/null +++ b/frontend/src/app.css @@ -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); +} + diff --git a/frontend/src/components/DashboardShell.tsx b/frontend/src/components/DashboardShell.tsx new file mode 100644 index 0000000..423a61f --- /dev/null +++ b/frontend/src/components/DashboardShell.tsx @@ -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[]> + dashboards?: DashboardInfo[] + onSwitchDashboard?: (fileName: string) => void +} + +export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) { + return ( +
+ {/* Header */} +
+
+

{config.settings.title}

+ {dashboards && dashboards.length > 1 && onSwitchDashboard && ( + d.current)?.file ?? ''} + onValueChange={(v) => onSwitchDashboard(v)} + options={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))} + /> + )} +
+ +
+ + {/* Sections */} + {(config.sections ?? []).map(section => ( +
+ ))} +
+ ) +} diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 0000000..ed0532d --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -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 + 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 ( +
+ {entries.map(([key, def]) => ( + + ))} + +
+ ) +} + +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 + case 'date_range': + return } onChange={onChange} /> + case 'text': + return + default: + return null + } +} + +function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) { + return ( +
+ + onChange(id, v)} + options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []} + /> +
+ ) +} + +function DateRangeFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: Record; onChange: (k: string, v: unknown) => void }) { + const presets = def.presets ?? [] + const currentFrom = value?.from ?? '' + + return ( +
+ +
+ {presets.map((preset: FilterPreset) => ( + + ))} +
+
+ ) +} + +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>(undefined) + const debounce = def.debounce ?? 300 + + useEffect(() => { + setLocalValue(value ?? '') + }, [value]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + const v = e.target.value + setLocalValue(v) + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => onChange(id, v), debounce) + }, [id, onChange, debounce]) + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/Section.tsx b/frontend/src/components/Section.tsx new file mode 100644 index 0000000..dba559a --- /dev/null +++ b/frontend/src/components/Section.tsx @@ -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 + filters: FilterValues + globalColumns: number + getData: (widgetID: string, filters: FilterValues) => Promise[]> +} + +export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) { + const [collapsed, setCollapsed] = useState(false) + const columns = section.columns || globalColumns + + return ( +
+ + + {!collapsed && ( +
+ {section.widgets.map(widget => ( +
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/WidgetRenderer.tsx b/frontend/src/components/WidgetRenderer.tsx new file mode 100644 index 0000000..40b4315 --- /dev/null +++ b/frontend/src/components/WidgetRenderer.tsx @@ -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[]> +} + +export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRendererProps) { + const [data, setData] = useState[] | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [countdown, setCountdown] = useState(0) + const mountedRef = useRef(true) + const lastDataRef = useRef('') + + 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 ( + + + {widget.title} + + +

+ Unknown widget type: {widget.type} +

+
+
+ ) + } + + return ( +
+ + + {countdown}s + +
+ ) +} diff --git a/frontend/src/components/widget-registry.ts b/frontend/src/components/widget-registry.ts new file mode 100644 index 0000000..05f23f3 --- /dev/null +++ b/frontend/src/components/widget-registry.ts @@ -0,0 +1,18 @@ +import type { ComponentType } from 'react' +import type { WidgetProps } from '../types' + +const WIDGET_REGISTRY: Record> = {} + +export function registerWidget(type: string, component: ComponentType) { + WIDGET_REGISTRY[type] = component +} + +export function getWidget(type: string): ComponentType | undefined { + return WIDGET_REGISTRY[type] +} + +export function getRegisteredTypes(): string[] { + return Object.keys(WIDGET_REGISTRY) +} + +export { WIDGET_REGISTRY } diff --git a/frontend/src/components/widgets/AreaChartWidget.tsx b/frontend/src/components/widgets/AreaChartWidget.tsx new file mode 100644 index 0000000..06be241 --- /dev/null +++ b/frontend/src/components/widgets/AreaChartWidget.tsx @@ -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 ( + + + + {widget.title} + + + + {loading && !data ? ( +
Loading...
+ ) : error ? ( +
{error.message}
+ ) : ( + + + {options.show_grid !== false && } + + + + {!!options.show_legend && } + + {areas.map(area => ( + + + + + ))} + + {areas.map(area => ( + + ))} + + + )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/BarChartWidget.tsx b/frontend/src/components/widgets/BarChartWidget.tsx new file mode 100644 index 0000000..85cb5a0 --- /dev/null +++ b/frontend/src/components/widgets/BarChartWidget.tsx @@ -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 ( + + + + {widget.title} + + + + {loading && !data ? ( +
Loading...
+ ) : error ? ( +
{error.message}
+ ) : ( + + + {options.show_grid !== false && } + {isHorizontal ? ( + <> + + + + ) : ( + <> + + + + )} + + {!!options.show_legend && } + {bars.map(bar => ( + + ))} + + + )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/KPIWidget.tsx b/frontend/src/components/widgets/KPIWidget.tsx new file mode 100644 index 0000000..0d66157 --- /dev/null +++ b/frontend/src/components/widgets/KPIWidget.tsx @@ -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 ( + 0 ? ( + + ) : 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 +} diff --git a/frontend/src/components/widgets/LineChartWidget.tsx b/frontend/src/components/widgets/LineChartWidget.tsx new file mode 100644 index 0000000..10d18a7 --- /dev/null +++ b/frontend/src/components/widgets/LineChartWidget.tsx @@ -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 ( + + + + {widget.title} + + + + {loading && !data ? ( +
Loading...
+ ) : error ? ( +
{error.message}
+ ) : ( + + + {options.show_grid !== false && } + + + + {!!options.show_legend && } + {lines.map(line => ( + + ))} + {!!options.zoomable && } + + + )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/PieChartWidget.tsx b/frontend/src/components/widgets/PieChartWidget.tsx new file mode 100644 index 0000000..d000dc3 --- /dev/null +++ b/frontend/src/components/widgets/PieChartWidget.tsx @@ -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 ( + + + + {widget.title} + + + + {loading && !data ? ( +
Loading...
+ ) : error ? ( +
{error.message}
+ ) : ( + + + + {pieData.map((_, i) => ( + + ))} + + + {options.show_legend !== false && ( + + )} + + + )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/SparklineWidget.tsx b/frontend/src/components/widgets/SparklineWidget.tsx new file mode 100644 index 0000000..ea1bf7b --- /dev/null +++ b/frontend/src/components/widgets/SparklineWidget.tsx @@ -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 ( + + +

{widget.title}

+ {loading && values.length === 0 ? ( +
Loading...
+ ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/TableWidget.tsx b/frontend/src/components/widgets/TableWidget.tsx new file mode 100644 index 0000000..ac0bcd4 --- /dev/null +++ b/frontend/src/components/widgets/TableWidget.tsx @@ -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 = {} + 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 ( + + + + {widget.title} + + + + {loading && !data ? ( +
Loading...
+ ) : error ? ( +
{error.message}
+ ) : ( +
+ + + + {effectiveColumns.map(col => ( + + ))} + + + + {(data ?? []).map((row, i) => ( + + {effectiveColumns.map(col => ( + + ))} + + ))} + +
+ {col.label} +
+ {formatCell(row[col.key], col.format)} +
+ {(!data || data.length === 0) && ( +

No data

+ )} +
+ )} +
+
+ ) +} + +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) +} diff --git a/frontend/src/components/widgets/register.ts b/frontend/src/components/widgets/register.ts new file mode 100644 index 0000000..8a4c47b --- /dev/null +++ b/frontend/src/components/widgets/register.ts @@ -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) diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..1b81eff --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,18 @@ +/// + +// 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 + export const CardContent: FC + export const CardHeader: FC + export const CardTitle: FC + export const KPICard: FC + export const Sparkline: FC + export const Badge: FC + export const Button: FC + export const Input: FC + export const Skeleton: FC + 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 +} diff --git a/frontend/src/hooks/useFilterState.ts b/frontend/src/hooks/useFilterState.ts new file mode 100644 index 0000000..fc6764c --- /dev/null +++ b/frontend/src/hooks/useFilterState.ts @@ -0,0 +1,27 @@ +import { useState, useCallback, useMemo } from 'react' +import type { FilterDef } from '../types' + +export type FilterValues = Record + +function initializeDefaults(filters: Record): 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) { + const defaults = useMemo(() => initializeDefaults(filters), [filters]) + const [values, setValues] = useState(defaults) + + const setValue = useCallback((key: string, value: unknown) => { + setValues(prev => ({ ...prev, [key]: value })) + }, []) + + const reset = useCallback(() => { + setValues(defaults) + }, [defaults]) + + return { values, setValue, reset } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..7d02351 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..10154f1 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,74 @@ +// Types mirroring Go config — sent via GetDashboardConfig() IPC. + +export interface DashboardConfig { + settings: Settings + theme: string + queries: Record + filters: Record + 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 | 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 + options?: Record + span: number + rowSpan?: number +} + +// Widget component props — all widget components receive this. +export interface WidgetProps { + widget: WidgetDef + data: Record[] | null + loading: boolean + error: Error | null +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d610747 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..afa62ca --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2923ac5 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eb7a3b2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c7a3b90 --- /dev/null +++ b/main.go @@ -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 ===") +} diff --git a/query_engine.go b/query_engine.go new file mode 100644 index 0000000..103953e --- /dev/null +++ b/query_engine.go @@ -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 "?" + } +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..35bce54 --- /dev/null +++ b/scripts/dev.sh @@ -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 diff --git a/scripts/prod.sh b/scripts/prod.sh new file mode 100755 index 0000000..c86c08b --- /dev/null +++ b/scripts/prod.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Compila y lanza rapid_dashboards en modo produccion. +# Uso: ./scripts/prod.sh [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 (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" diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..45fb36f --- /dev/null +++ b/wails.json @@ -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" + } +}