init: rapid_dashboards app from fn_registry

This commit is contained in:
dataforge
2026-04-06 00:57:13 +02:00
commit b7f354e081
46 changed files with 6139 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rapid Dashboards</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "rapid-dashboards",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slider": "^1.3.6",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"recharts": "^3.8.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
052e44bcd719cd7db765d6c7fb248074
+1628
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState, useCallback } from 'react'
import { DashboardShell } from './components/DashboardShell'
import { useFilterState } from './hooks/useFilterState'
import type { DashboardConfig } from './types'
import type { FilterValues } from './hooks/useFilterState'
// Register all built-in widgets.
import './components/widgets/register'
// Wails-generated bindings.
let GetDashboardConfig: () => Promise<DashboardConfig>
let GetWidgetData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
let ListDashboards: () => Promise<DashboardInfo[]>
let SwitchDashboard: (fileName: string) => Promise<DashboardConfig>
interface DashboardInfo {
name: string
file: string
title: string
theme: string
current: boolean
}
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12 }, theme: 'dark', queries: {}, filters: {}, sections: [] }
// Import bindings — they call window.go which only exists inside Wails WebView
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
GetDashboardConfig = bindings?.GetDashboardConfig as typeof GetDashboardConfig ?? (async () => dummyConfig)
GetWidgetData = bindings?.GetWidgetData as typeof GetWidgetData ?? (async () => [])
ListDashboards = bindings?.ListDashboards as typeof ListDashboards ?? (async () => [])
SwitchDashboard = bindings?.SwitchDashboard as typeof SwitchDashboard ?? (async () => dummyConfig)
export default function App() {
const [config, setConfig] = useState<DashboardConfig | null>(null)
const [dashboards, setDashboards] = useState<DashboardInfo[]>([])
const [error, setError] = useState<string | null>(null)
const [switching, setSwitching] = useState(false)
useEffect(() => {
GetDashboardConfig()
.then(cfg => {
setConfig(cfg)
// ListDashboards may fail if runtime not ready yet — retry once
ListDashboards()
.then(setDashboards)
.catch(() => setTimeout(() => ListDashboards().then(setDashboards).catch(() => {}), 1000))
})
.catch(err => setError(err.message ?? String(err)))
}, [])
const handleSwitch = useCallback(async (fileName: string) => {
setSwitching(true)
try {
const newCfg = await SwitchDashboard(fileName)
setConfig(newCfg)
const list = await ListDashboards()
setDashboards(list)
} catch (err) {
setError((err as Error).message ?? String(err))
} finally {
setSwitching(false)
}
}, [])
if (error) {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center p-8">
<div className="max-w-lg space-y-4">
<h1 className="text-xl font-semibold text-[var(--destructive)]">Dashboard Error</h1>
<pre className="text-sm bg-[var(--card)] rounded-lg p-4 overflow-auto whitespace-pre-wrap border border-[var(--border)]">
{error}
</pre>
</div>
</div>
)
}
if (!config || switching) {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center">
<p className="text-[var(--muted-foreground)]">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</p>
</div>
)
}
return <DashboardInner config={config} dashboards={dashboards} onSwitch={handleSwitch} />
}
function DashboardInner({ config, dashboards, onSwitch }: {
config: DashboardConfig
dashboards: DashboardInfo[]
onSwitch: (fileName: string) => void
}) {
const { values, setValue, reset } = useFilterState(config.filters ?? {})
useEffect(() => {
document.documentElement.setAttribute('data-theme', config.theme || 'dark')
}, [config.theme])
const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
return GetWidgetData(widgetID, filters)
}, [])
return (
<DashboardShell
config={config}
filters={values}
onFilterChange={setValue}
onFilterReset={reset}
getData={getData}
dashboards={dashboards}
onSwitchDashboard={onSwitch}
/>
)
}
+176
View File
@@ -0,0 +1,176 @@
@import "tailwindcss";
/* ========== DARK (default) — deep blue-grey ========== */
:root,
[data-theme="dark"] {
--background: oklch(8% 0.015 260);
--foreground: oklch(95% 0.01 260);
--muted: oklch(18% 0.02 260);
--muted-foreground: oklch(60% 0.02 260);
--border: oklch(15% 0.01 260);
--primary: oklch(65% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(20% 0.02 260);
--secondary-foreground: oklch(95% 0.01 260);
--accent: oklch(18% 0.03 260);
--accent-foreground: oklch(95% 0.01 260);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(11% 0.015 260);
--card-foreground: oklch(95% 0.01 260);
--popover: oklch(12% 0.015 260);
--popover-foreground: oklch(95% 0.01 260);
--ring: oklch(65% 0.22 260);
--input: oklch(22% 0.02 260);
--radius: 0.5rem;
--success: oklch(65% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
--chart-1: #3b82f6;
--chart-2: #10b981;
--chart-3: #f59e0b;
--chart-4: #ef4444;
--chart-5: #8b5cf6;
}
/* ========== EMERALD — rich green, gold accents ========== */
[data-theme="emerald"] {
--background: oklch(6% 0.03 155);
--foreground: oklch(92% 0.03 155);
--muted: oklch(15% 0.04 155);
--muted-foreground: oklch(58% 0.04 155);
--border: oklch(13% 0.03 155);
--primary: oklch(70% 0.22 155);
--primary-foreground: oklch(10% 0.03 155);
--secondary: oklch(18% 0.04 155);
--secondary-foreground: oklch(90% 0.03 155);
--accent: oklch(16% 0.05 155);
--accent-foreground: oklch(90% 0.03 155);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(9% 0.03 155);
--card-foreground: oklch(92% 0.03 155);
--popover: oklch(10% 0.04 155);
--popover-foreground: oklch(92% 0.03 155);
--ring: oklch(70% 0.22 155);
--input: oklch(20% 0.04 155);
--success: oklch(70% 0.22 155);
--success-foreground: oklch(10% 0.03 155);
--chart-1: #10b981;
--chart-2: #f59e0b;
--chart-3: #06b6d4;
--chart-4: #8b5cf6;
--chart-5: #ec4899;
}
/* ========== AMBER — warm dark, orange/amber accent ========== */
[data-theme="amber"] {
--background: oklch(7% 0.02 55);
--foreground: oklch(93% 0.02 55);
--muted: oklch(16% 0.03 55);
--muted-foreground: oklch(58% 0.03 55);
--border: oklch(14% 0.025 55);
--primary: oklch(72% 0.2 65);
--primary-foreground: oklch(10% 0.02 55);
--secondary: oklch(18% 0.03 55);
--secondary-foreground: oklch(90% 0.02 55);
--accent: oklch(16% 0.04 55);
--accent-foreground: oklch(90% 0.02 55);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(10% 0.025 55);
--card-foreground: oklch(93% 0.02 55);
--popover: oklch(11% 0.03 55);
--popover-foreground: oklch(93% 0.02 55);
--ring: oklch(72% 0.2 65);
--input: oklch(20% 0.03 55);
--success: oklch(65% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
--chart-1: #f59e0b;
--chart-2: #ef4444;
--chart-3: #3b82f6;
--chart-4: #10b981;
--chart-5: #ec4899;
}
/* ========== ROSE — dark with pink/rose accent ========== */
[data-theme="rose"] {
--background: oklch(7% 0.02 350);
--foreground: oklch(93% 0.015 350);
--muted: oklch(16% 0.03 350);
--muted-foreground: oklch(58% 0.025 350);
--border: oklch(14% 0.025 350);
--primary: oklch(65% 0.22 350);
--primary-foreground: oklch(98% 0.01 350);
--secondary: oklch(18% 0.03 350);
--secondary-foreground: oklch(90% 0.015 350);
--accent: oklch(16% 0.04 350);
--accent-foreground: oklch(90% 0.015 350);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(10% 0.025 350);
--card-foreground: oklch(93% 0.015 350);
--popover: oklch(11% 0.03 350);
--popover-foreground: oklch(93% 0.015 350);
--ring: oklch(65% 0.22 350);
--input: oklch(20% 0.03 350);
--success: oklch(65% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
--chart-1: #ec4899;
--chart-2: #8b5cf6;
--chart-3: #3b82f6;
--chart-4: #f59e0b;
--chart-5: #10b981;
}
/* ========== LIGHT — clean light theme ========== */
[data-theme="light"] {
--background: oklch(98% 0.005 260);
--foreground: oklch(15% 0.01 260);
--muted: oklch(93% 0.01 260);
--muted-foreground: oklch(45% 0.01 260);
--border: oklch(88% 0.01 260);
--primary: oklch(50% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(94% 0.01 260);
--secondary-foreground: oklch(20% 0.01 260);
--accent: oklch(95% 0.01 260);
--accent-foreground: oklch(20% 0.01 260);
--destructive: oklch(50% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(100% 0 0);
--card-foreground: oklch(15% 0.01 260);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(15% 0.01 260);
--ring: oklch(50% 0.22 260);
--input: oklch(88% 0.01 260);
--success: oklch(50% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
--chart-1: #2563eb;
--chart-2: #059669;
--chart-3: #d97706;
--chart-4: #dc2626;
--chart-5: #7c3aed;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Remove card borders for seamless look on dark themes */
[data-theme="dark"] [data-slot="card"],
[data-theme="emerald"] [data-slot="card"],
[data-theme="amber"] [data-slot="card"],
[data-theme="rose"] [data-slot="card"] {
border: none;
box-shadow: none;
}
/* Light theme keeps subtle card shadows */
[data-theme="light"] [data-slot="card"] {
border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
@@ -0,0 +1,62 @@
import { FilterBar } from './FilterBar'
import { Section } from './Section'
import { SimpleSelect } from '@fn_library'
import type { DashboardConfig } from '../types'
import type { FilterValues } from '../hooks/useFilterState'
interface DashboardInfo {
name: string
file: string
title: string
theme: string
current: boolean
}
interface DashboardShellProps {
config: DashboardConfig
filters: FilterValues
onFilterChange: (key: string, value: unknown) => void
onFilterReset: () => void
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
dashboards?: DashboardInfo[]
onSwitchDashboard?: (fileName: string) => void
}
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] px-4 py-3 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold tracking-tight">{config.settings.title}</h1>
{dashboards && dashboards.length > 1 && onSwitchDashboard && (
<SimpleSelect
size="sm"
value={dashboards.find(d => d.current)?.file ?? ''}
onValueChange={(v) => onSwitchDashboard(v)}
options={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))}
/>
)}
</div>
<FilterBar
filters={config.filters ?? {}}
values={filters}
onChange={onFilterChange}
onReset={onFilterReset}
/>
</div>
{/* Sections */}
{(config.sections ?? []).map(section => (
<Section
key={section.id}
section={section}
queries={config.queries}
filters={filters}
globalColumns={config.settings.columns}
getData={getData}
/>
))}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useRef, useEffect, useState } from 'react'
import { SimpleSelect } from '@fn_library'
import type { FilterDef, FilterPreset } from '../types'
import type { FilterValues } from '../hooks/useFilterState'
interface FilterBarProps {
filters: Record<string, FilterDef>
values: FilterValues
onChange: (key: string, value: unknown) => void
onReset: () => void
}
export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps) {
const entries = Object.entries(filters)
if (entries.length === 0) return null
return (
<div className="flex items-center gap-3 flex-wrap">
{entries.map(([key, def]) => (
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
))}
<button
onClick={onReset}
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors px-2 py-1 rounded"
>
Reset
</button>
</div>
)
}
interface FilterControlProps {
id: string
def: FilterDef
value: unknown
onChange: (key: string, value: unknown) => void
}
function FilterControl({ id, def, value, onChange }: FilterControlProps) {
switch (def.type) {
case 'select':
return <SelectFilter id={id} def={def} value={value as string} onChange={onChange} />
case 'date_range':
return <DateRangeFilter id={id} def={def} value={value as Record<string, string>} onChange={onChange} />
case 'text':
return <TextFilter id={id} def={def} value={value as string} onChange={onChange} />
default:
return null
}
}
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
return (
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
<SimpleSelect
value={value ?? ''}
onValueChange={(v) => onChange(id, v)}
options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
/>
</div>
)
}
function DateRangeFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: Record<string, string>; onChange: (k: string, v: unknown) => void }) {
const presets = def.presets ?? []
const currentFrom = value?.from ?? ''
return (
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
<div className="flex gap-1">
{presets.map((preset: FilterPreset) => (
<button
key={preset.label}
onClick={() => onChange(id, { from: preset.from, to: preset.to })}
className={`text-xs px-2 py-1 rounded transition-colors ${
currentFrom === preset.from
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--accent)]'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
)
}
function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
const [localValue, setLocalValue] = useState(value ?? '')
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const debounce = def.debounce ?? 300
useEffect(() => {
setLocalValue(value ?? '')
}, [value])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value
setLocalValue(v)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => onChange(id, v), debounce)
}, [id, onChange, debounce])
return (
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
<input
type="text"
value={localValue}
onChange={handleChange}
placeholder={def.placeholder}
className="text-sm bg-[var(--secondary)] text-[var(--foreground)] border border-[var(--border)] rounded px-2 py-1 outline-none focus:ring-1 focus:ring-[var(--ring)] w-48"
/>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { WidgetRenderer } from './WidgetRenderer'
import type { SectionDef, QueryDef } from '../types'
import type { FilterValues } from '../hooks/useFilterState'
interface SectionProps {
section: SectionDef
queries: Record<string, QueryDef>
filters: FilterValues
globalColumns: number
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
}
export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) {
const [collapsed, setCollapsed] = useState(false)
const columns = section.columns || globalColumns
return (
<div className="space-y-2">
<button
className="flex items-center gap-2 cursor-pointer select-none bg-transparent border-none p-0"
onClick={() => section.collapsible && setCollapsed(c => !c)}
type="button"
>
{section.collapsible && (
collapsed
? <ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
: <ChevronDown className="w-4 h-4 text-[var(--muted-foreground)]" />
)}
<h2 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
{section.title}
</h2>
</button>
{!collapsed && (
<div
className="grid gap-2"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{section.widgets.map(widget => (
<div
key={widget.id}
style={{
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
}}
>
<WidgetRenderer
widget={widget}
queryDef={queries[widget.query]}
filters={filters}
getData={getData}
/>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,93 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { getWidget } from './widget-registry'
import type { WidgetDef, QueryDef } from '../types'
import type { FilterValues } from '../hooks/useFilterState'
interface WidgetRendererProps {
widget: WidgetDef
queryDef: QueryDef
filters: FilterValues
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
}
export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRendererProps) {
const [data, setData] = useState<Record<string, unknown>[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [countdown, setCountdown] = useState(0)
const mountedRef = useRef(true)
const lastDataRef = useRef<string>('')
const refreshSec = Math.max(Math.round((queryDef.refreshMs ?? 30000) / 1000), 1)
const fetch = useCallback(async () => {
try {
const result = await getData(widget.id, filters)
if (!mountedRef.current) return
const serialized = JSON.stringify(result)
if (serialized !== lastDataRef.current) {
lastDataRef.current = serialized
setData(result)
}
setError(null)
} catch (err) {
if (!mountedRef.current) return
setError(err as Error)
} finally {
if (mountedRef.current) {
setLoading(false)
setCountdown(refreshSec)
}
}
}, [widget.id, filters, getData, refreshSec])
// Initial fetch.
useEffect(() => {
mountedRef.current = true
setLoading(true)
fetch()
return () => { mountedRef.current = false }
}, [fetch])
// Refetch on interval.
useEffect(() => {
if (!queryDef.refreshMs || queryDef.refreshMs <= 0) return
const interval = setInterval(fetch, queryDef.refreshMs)
return () => clearInterval(interval)
}, [fetch, queryDef.refreshMs])
// Countdown tick.
useEffect(() => {
const tick = setInterval(() => {
setCountdown(c => (c > 0 ? c - 1 : 0))
}, 1000)
return () => clearInterval(tick)
}, [])
const Component = getWidget(widget.type)
if (!Component) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">{widget.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
Unknown widget type: <code>{widget.type}</code>
</p>
</CardContent>
</Card>
)
}
return (
<div className="relative h-full">
<Component widget={widget} data={data} loading={loading} error={error} />
<span className="absolute top-2 right-2 text-[10px] tabular-nums text-[var(--muted-foreground)] opacity-60">
{countdown}s
</span>
</div>
)
}
@@ -0,0 +1,18 @@
import type { ComponentType } from 'react'
import type { WidgetProps } from '../types'
const WIDGET_REGISTRY: Record<string, ComponentType<WidgetProps>> = {}
export function registerWidget(type: string, component: ComponentType<WidgetProps>) {
WIDGET_REGISTRY[type] = component
}
export function getWidget(type: string): ComponentType<WidgetProps> | undefined {
return WIDGET_REGISTRY[type]
}
export function getRegisteredTypes(): string[] {
return Object.keys(WIDGET_REGISTRY)
}
export { WIDGET_REGISTRY }
@@ -0,0 +1,73 @@
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts'
import type { WidgetProps } from '../../types'
const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
]
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
const mapping = widget.mapping ?? {}
const xKey = mapping.x as string ?? 'x'
const options = widget.options ?? {}
const series = Array.isArray(mapping.series)
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
: undefined
const yKey = !series ? (mapping.y as string) : undefined
const areas = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: s.color ?? COLORS[i % COLORS.length] }))
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{widget.title}
</CardTitle>
</CardHeader>
<CardContent>
{loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
<AreaChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
{!!options.show_legend && <Legend />}
<defs>
{areas.map(area => (
<linearGradient key={`grad-${area.dataKey}`} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={area.color} stopOpacity={0.3} />
<stop offset="100%" stopColor={area.color} stopOpacity={0.05} />
</linearGradient>
))}
</defs>
{areas.map(area => (
<Area
key={area.dataKey}
type="monotone"
dataKey={area.dataKey}
name={area.name}
stroke={area.color}
strokeWidth={2}
fill={`url(#gradient-${area.dataKey})`}
stackId={options.stacked ? 'stack' : undefined}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,79 @@
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts'
import type { WidgetProps } from '../../types'
const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
]
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
const mapping = widget.mapping ?? {}
const xKey = mapping.x as string ?? 'x'
const options = widget.options ?? {}
const series = Array.isArray(mapping.series)
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
: undefined
const yKey = !series ? (mapping.y as string) : undefined
const bars = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: s.color ?? COLORS[i % COLORS.length] }))
: yKey ? [{ dataKey: yKey, name: yKey, fill: COLORS[0] }] : []
const isHorizontal = !!options.horizontal
return (
<Card className="h-full">
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{widget.title}
</CardTitle>
</CardHeader>
<CardContent>
{loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
<BarChart
data={data ?? []}
layout={isHorizontal ? 'vertical' : 'horizontal'}
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
>
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
{isHorizontal ? (
<>
<YAxis
dataKey={xKey}
type="category"
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
width={140}
/>
<XAxis
type="number"
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
/>
</>
) : (
<>
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} />
</>
)}
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} />
{!!options.show_legend && <Legend wrapperStyle={{ fontSize: 11 }} />}
{bars.map(bar => (
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,81 @@
import { KPICard, Sparkline } from '@fn_library'
import type { WidgetProps } from '../../types'
export function KPIWidget({ widget, data, loading }: WidgetProps) {
const mapping = widget.mapping ?? {}
const valueKey = mapping.value as string ?? 'value'
const format = mapping.format as string
const unitKey = mapping.unit as string | undefined
const deltaKey = mapping.delta as string | undefined
const deltaLabel = mapping.deltaLabel as string | undefined
const deltaSuffix = mapping.deltaSuffix as string | undefined
const sparklineKey = mapping.sparkline as string | undefined
const sparklineColors = mapping.sparklineColors as string[] | undefined
let displayValue: string | number = '—'
let displayUnit: string | undefined
let delta: { value: number; isPositive: boolean; label?: string; suffix?: string } | undefined
let sparklineData: number[] | undefined
if (data && data.length > 0) {
const row = data[0]
displayValue = formatValue(row[valueKey], format)
if (unitKey && row[unitKey] != null) {
displayUnit = String(row[unitKey])
} else if (mapping.unitLabel) {
displayUnit = mapping.unitLabel as string
}
if (deltaKey && row[deltaKey] != null) {
const dv = Number(row[deltaKey])
if (!isNaN(dv)) {
delta = { value: dv, isPositive: dv >= 0, label: deltaLabel, suffix: deltaSuffix }
}
}
if (sparklineKey) {
sparklineData = data.map(r => Number(r[sparklineKey]) || 0)
}
}
return (
<KPICard
label={widget.title}
value={loading ? '...' : displayValue}
unit={displayUnit}
delta={delta}
chart={sparklineData && sparklineData.length > 0 ? (
<Sparkline
data={sparklineData}
variant="bar"
colors={sparklineColors}
width={80}
height={32}
/>
) : undefined}
size="default"
className="border-0 shadow-none"
/>
)
}
function formatValue(raw: unknown, format?: string): string | number {
if (raw == null) return '—'
const num = Number(raw)
if (isNaN(num)) return String(raw)
if (!format) return num
if (format.includes('f')) {
const match = format.match(/\.(\d+)f/)
const decimals = match ? parseInt(match[1]) : 0
let str = num.toFixed(decimals)
if (format.includes(',')) {
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
}
if (format.startsWith('$')) str = '$' + str
return str
}
if (format === ',') return num.toLocaleString()
return num
}
@@ -0,0 +1,66 @@
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, Brush, ResponsiveContainer,
} from 'recharts'
import type { WidgetProps } from '../../types'
const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
]
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
const mapping = widget.mapping ?? {}
const xKey = mapping.x as string ?? 'x'
const options = widget.options ?? {}
const series = Array.isArray(mapping.series)
? (mapping.series as Array<{ key: string; name: string; color?: string }>)
: undefined
const yKey = !series ? (mapping.y as string) : undefined
const lines = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: s.color ?? COLORS[i % COLORS.length] }))
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{widget.title}
</CardTitle>
</CardHeader>
<CardContent>
{loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
<LineChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />}
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} />
{!!options.show_legend && <Legend />}
{lines.map(line => (
<Line
key={line.dataKey}
type={(options.curve as any) ?? 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.stroke}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--primary)" />}
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,78 @@
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
} from 'recharts'
import type { PieLabelRenderProps } from 'recharts'
import type { WidgetProps } from '../../types'
const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)',
'var(--chart-7, #06b6d4)', 'var(--chart-8, #f97316)',
]
function renderLabel(props: PieLabelRenderProps): string {
const name = props.name ?? ''
const pct = typeof props.percent === 'number' ? (props.percent * 100).toFixed(0) : '0'
return `${name} ${pct}%`
}
export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
const mapping = widget.mapping ?? {}
const nameKey = mapping.name as string ?? mapping.x as string ?? 'name'
const valueKey = mapping.value as string ?? mapping.y as string ?? 'value'
const options = widget.options ?? {}
// Ensure numeric values for Recharts Pie
const pieData = (data ?? []).map(row => ({
...row,
[valueKey]: Number(row[valueKey]) || 0,
}))
return (
<Card className="h-full">
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{widget.title}
</CardTitle>
</CardHeader>
<CardContent>
{loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}>
<PieChart>
<Pie
data={pieData}
dataKey={valueKey}
nameKey={nameKey}
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={options.donut ? 50 : 0}
strokeWidth={0}
fontSize={11}
fill="var(--muted-foreground)"
isAnimationActive={false}
label={renderLabel}
labelLine={false}
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
/>
{options.show_legend !== false && (
<Legend wrapperStyle={{ fontSize: 11 }} />
)}
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,29 @@
import { Card, CardContent } from '@fn_library'
import { Sparkline } from '@fn_library'
import type { WidgetProps } from '../../types'
export function SparklineWidget({ widget, data, loading }: WidgetProps) {
const mapping = widget.mapping ?? {}
const valueKey = mapping.value as string ?? 'value'
const options = widget.options ?? {}
const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
return (
<Card className="h-full">
<CardContent className="pt-4">
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p>
{loading && values.length === 0 ? (
<div className="h-[40px] flex items-center text-[var(--muted-foreground)] text-xs">Loading...</div>
) : (
<Sparkline
data={values}
variant={(options.variant as 'line' | 'area' | 'bar') ?? 'area'}
width={options.width as number ?? 200}
height={options.height as number ?? 40}
/>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,114 @@
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import type { WidgetProps } from '../../types'
interface ColumnDef {
key: string
label: string
format?: string
}
export function TableWidget({ widget, data, loading, error }: WidgetProps) {
const mapping = widget.mapping ?? {}
const columns = (mapping.columns as ColumnDef[]) ?? []
const options = widget.options ?? {}
const heatmapKeys = (options.heatmap_columns as string[]) ?? []
// Auto-detect columns from first row if not specified.
const effectiveColumns: ColumnDef[] = columns.length > 0
? columns
: data && data.length > 0
? Object.keys(data[0]).map(k => ({ key: k, label: k }))
: []
// Compute heatmap ranges per column
const heatmapRanges: Record<string, { min: number; max: number }> = {}
if (heatmapKeys.length > 0 && data && data.length > 0) {
for (const key of heatmapKeys) {
const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
if (values.length > 0) {
heatmapRanges[key] = { min: Math.min(...values), max: Math.max(...values) }
}
}
}
function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
const range = heatmapRanges[key]
if (!range || range.max === range.min) return undefined
const num = Number(value)
if (isNaN(num)) return undefined
const t = (num - range.min) / (range.max - range.min)
const alpha = 0.1 + t * 0.55
return { backgroundColor: `color-mix(in srgb, var(--chart-1) ${Math.round(alpha * 100)}%, transparent)` }
}
return (
<Card className="h-full">
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{widget.title}
</CardTitle>
</CardHeader>
<CardContent>
{loading && !data ? (
<div className="h-[200px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div>
) : error ? (
<div className="h-[200px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div>
) : (
<div className="overflow-auto max-h-[500px]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
{effectiveColumns.map(col => (
<th key={col.key} className="text-left py-1.5 px-3 text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{(data ?? []).map((row, i) => (
<tr key={i} className="border-b border-[var(--border)] hover:bg-[var(--accent)]/50 transition-colors">
{effectiveColumns.map(col => (
<td
key={col.key}
className="py-1.5 px-3 font-mono text-xs"
style={heatmapStyle(col.key, row[col.key])}
>
{formatCell(row[col.key], col.format)}
</td>
))}
</tr>
))}
</tbody>
</table>
{(!data || data.length === 0) && (
<p className="text-center text-[var(--muted-foreground)] text-sm py-8">No data</p>
)}
</div>
)}
</CardContent>
</Card>
)
}
function formatCell(value: unknown, format?: string): string {
if (value == null) return '—'
if (!format) return String(value)
const num = Number(value)
if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
return new Date(String(value)).toLocaleString()
}
if (!isNaN(num)) {
if (format.includes('f')) {
const match = format.match(/\.(\d+)f/)
const d = match ? parseInt(match[1]) : 0
let str = num.toFixed(d)
if (format.includes(',')) str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
if (format.startsWith('$')) str = '$' + str
return str
}
if (format === ',') return num.toLocaleString()
}
return String(value)
}
@@ -0,0 +1,19 @@
// Registers all built-in widget types.
// To add a new widget: create the component, then add registerWidget() here.
import { registerWidget } from '../widget-registry'
import { KPIWidget } from './KPIWidget'
import { LineChartWidget } from './LineChartWidget'
import { BarChartWidget } from './BarChartWidget'
import { AreaChartWidget } from './AreaChartWidget'
import { PieChartWidget } from './PieChartWidget'
import { SparklineWidget } from './SparklineWidget'
import { TableWidget } from './TableWidget'
registerWidget('kpi', KPIWidget)
registerWidget('line_chart', LineChartWidget)
registerWidget('bar_chart', BarChartWidget)
registerWidget('area_chart', AreaChartWidget)
registerWidget('pie_chart', PieChartWidget)
registerWidget('sparkline', SparklineWidget)
registerWidget('table', TableWidget)
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
declare module '@fn_library' {
import type { FC } from 'react'
export const Card: FC<any>
export const CardContent: FC<any>
export const CardHeader: FC<any>
export const CardTitle: FC<any>
export const KPICard: FC<any>
export const Sparkline: FC<any>
export const Badge: FC<any>
export const Button: FC<any>
export const Input: FC<any>
export const Skeleton: FC<any>
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useCallback, useMemo } from 'react'
import type { FilterDef } from '../types'
export type FilterValues = Record<string, unknown>
function initializeDefaults(filters: Record<string, FilterDef>): FilterValues {
const values: FilterValues = {}
for (const [key, def] of Object.entries(filters)) {
values[key] = def.default ?? (def.type === 'text' ? '' : null)
}
return values
}
export function useFilterState(filters: Record<string, FilterDef>) {
const defaults = useMemo(() => initializeDefaults(filters), [filters])
const [values, setValues] = useState<FilterValues>(defaults)
const setValue = useCallback((key: string, value: unknown) => {
setValues(prev => ({ ...prev, [key]: value }))
}, [])
const reset = useCallback(() => {
setValues(defaults)
}, [defaults])
return { values, setValue, reset }
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+74
View File
@@ -0,0 +1,74 @@
// Types mirroring Go config — sent via GetDashboardConfig() IPC.
export interface DashboardConfig {
settings: Settings
theme: string
queries: Record<string, QueryDef>
filters: Record<string, FilterDef>
sections: SectionDef[]
}
export interface Settings {
title: string
refresh: string
width: number
height: number
columns: number
}
export interface QueryDef {
connection: string
refresh: string
staleTime: string
params: Record<string, string> | null
refreshMs: number
staleMs: number
}
export interface FilterDef {
type: 'select' | 'date_range' | 'text'
label: string
default: unknown
options?: FilterOption[]
presets?: FilterPreset[]
placeholder?: string
debounce?: number
}
export interface FilterOption {
label: string
value: string
}
export interface FilterPreset {
label: string
from: string
to: string
}
export interface SectionDef {
id: string
title: string
collapsible: boolean
columns?: number
widgets: WidgetDef[]
}
export interface WidgetDef {
id: string
type: string
title: string
query: string
mapping: Record<string, unknown>
options?: Record<string, unknown>
span: number
rowSpan?: number
}
// Widget component props — all widget components receive this.
export interface WidgetProps {
widget: WidgetDef
data: Record<string, unknown>[] | null
loading: boolean
error: Error | null
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@wails/*": ["./src/wailsjs/*"],
"@fn_library": ["./src/env.d.ts"]
}
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@wails': resolve(__dirname, './wailsjs'),
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
},
dedupe: ['react', 'react-dom'],
},
server: {
port: 5173,
},
})