refactor(frontend): migracion a Mantine y limpieza de widgets

- Migra el frontend a Mantine v9 siguiendo la regla de theming del registry (@fn_library, sin Tailwind/cn/CVA).
- Reescribe DashboardShell, FilterBar, Section, WidgetRenderer y todos los widgets (Area/Bar/Line/Pie/KPI/Sparkline/Table) con componentes y props de Mantine.
- Ajusta vite.config, main.tsx, App.tsx, app.css y env.d.ts.
- Añade postcss.config.cjs requerido por Mantine.
- Actualiza package.json y pnpm-lock.
- Ajusta config.go, main.go y los ejemplos (fn_registry_apps/overview) para el nuevo esquema de tipos en frontend/src/types.ts.
This commit is contained in:
2026-04-13 23:33:04 +02:00
parent b7f354e081
commit 4ec62f5ed6
25 changed files with 807 additions and 1096 deletions
+7
View File
@@ -25,6 +25,7 @@ type Settings struct {
Width int `yaml:"width" json:"width"` Width int `yaml:"width" json:"width"`
Height int `yaml:"height" json:"height"` Height int `yaml:"height" json:"height"`
Columns int `yaml:"columns" json:"columns"` Columns int `yaml:"columns" json:"columns"`
Layout string `yaml:"layout" json:"layout"` // "scrollable" (default) or "single_view"
} }
type ConnConfig struct { type ConnConfig struct {
@@ -127,6 +128,12 @@ func (c *DashboardConfig) validate() error {
if c.Theme == "" { if c.Theme == "" {
c.Theme = "dark" c.Theme = "dark"
} }
if c.Settings.Layout == "" {
c.Settings.Layout = "scrollable"
}
if c.Settings.Layout != "scrollable" && c.Settings.Layout != "single_view" {
return fmt.Errorf("settings.layout must be 'scrollable' or 'single_view', got %q", c.Settings.Layout)
}
if len(c.Connections) == 0 { if len(c.Connections) == 0 {
return fmt.Errorf("at least one connection is required") return fmt.Errorf("at least one connection is required")
+2 -2
View File
@@ -5,12 +5,12 @@ settings:
height: 900 height: 900
columns: 12 columns: 12
theme: "emerald" theme: "amber"
connections: connections:
registry: registry:
driver: sqlite driver: sqlite
path: ../../registry.db path: ../../../registry.db
queries: queries:
# --- KPIs --- # --- KPIs ---
+1 -1
View File
@@ -10,7 +10,7 @@ theme: "dark"
connections: connections:
registry: registry:
driver: sqlite driver: sqlite
path: ../../registry.db path: ../../../registry.db
queries: queries:
# --- KPIs --- # --- KPIs ---
+9 -11
View File
@@ -9,27 +9,25 @@
"preview": "vite preview --host" "preview": "vite preview --host"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
"@mantine/notifications": "^9.0.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@tabler/icons-react": "^3.41.1",
"@radix-ui/react-slider": "^1.3.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.8.0", "recharts": "^3.8.0"
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.2.2", "postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
+1 -1
View File
@@ -1 +1 @@
052e44bcd719cd7db765d6c7fb248074 1677d31df4e76e35008620851dba3211
+381 -609
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
+17 -16
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { Center, Box, Stack, Text, Code, Alert, Loader } from '@mantine/core'
import { DashboardShell } from './components/DashboardShell' import { DashboardShell } from './components/DashboardShell'
import { useFilterState } from './hooks/useFilterState' import { useFilterState } from './hooks/useFilterState'
import type { DashboardConfig } from './types' import type { DashboardConfig } from './types'
@@ -21,7 +22,7 @@ interface DashboardInfo {
current: boolean current: boolean
} }
const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12 }, theme: 'dark', queries: {}, filters: {}, sections: [] } const dummyConfig: DashboardConfig = { settings: { title: 'Dev Mode', refresh: '30s', width: 1280, height: 800, columns: 12, layout: 'scrollable' }, theme: 'dark', queries: {}, filters: {}, sections: [] }
// Import bindings — they call window.go which only exists inside Wails WebView // Import bindings — they call window.go which only exists inside Wails WebView
const bindings = await import('./wailsjs/go/main/App').catch(() => null) const bindings = await import('./wailsjs/go/main/App').catch(() => null)
@@ -64,22 +65,26 @@ export default function App() {
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center p-8"> <Center mih="100vh" p="xl">
<div className="max-w-lg space-y-4"> <Stack maw={480} gap="md">
<h1 className="text-xl font-semibold text-[var(--destructive)]">Dashboard Error</h1> <Alert color="red" title="Dashboard Error" variant="light">
<pre className="text-sm bg-[var(--card)] rounded-lg p-4 overflow-auto whitespace-pre-wrap border border-[var(--border)]"> <Code block style={{ whiteSpace: 'pre-wrap' }}>
{error} {error}
</pre> </Code>
</div> </Alert>
</div> </Stack>
</Center>
) )
} }
if (!config || switching) { if (!config || switching) {
return ( return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] flex items-center justify-center"> <Center mih="100vh">
<p className="text-[var(--muted-foreground)]">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</p> <Stack align="center" gap="sm">
</div> <Loader size="sm" />
<Text c="dimmed">{switching ? 'Switching dashboard...' : 'Loading dashboard...'}</Text>
</Stack>
</Center>
) )
} }
@@ -93,10 +98,6 @@ function DashboardInner({ config, dashboards, onSwitch }: {
}) { }) {
const { values, setValue, reset } = useFilterState(config.filters ?? {}) 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) => { const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
return GetWidgetData(widgetID, filters) return GetWidgetData(widgetID, filters)
}, []) }, [])
-173
View File
@@ -1,176 +1,3 @@
@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 { body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased; -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);
}
+53 -35
View File
@@ -1,6 +1,7 @@
import { Box, Group, Text, Stack } from '@mantine/core'
import { FilterBar } from './FilterBar' import { FilterBar } from './FilterBar'
import { Section } from './Section' import { Section } from './Section'
import { SimpleSelect } from '@fn_library' import { Select } from '@fn_library'
import type { DashboardConfig } from '../types' import type { DashboardConfig } from '../types'
import type { FilterValues } from '../hooks/useFilterState' import type { FilterValues } from '../hooks/useFilterState'
@@ -23,40 +24,57 @@ interface DashboardShellProps {
} }
export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) { export function DashboardShell({ config, filters, onFilterChange, onFilterReset, getData, dashboards, onSwitchDashboard }: DashboardShellProps) {
return ( const isSingleView = config.settings.layout === 'single_view'
<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 */} return (
{(config.sections ?? []).map(section => ( <Box
<Section px="md"
key={section.id} py="sm"
section={section} mih={isSingleView ? undefined : '100vh'}
queries={config.queries} style={isSingleView ? {
filters={filters} height: '100vh',
globalColumns={config.settings.columns} overflow: 'hidden',
getData={getData} display: 'flex',
/> flexDirection: 'column',
))} } : undefined}
</div> >
<Stack gap="sm" style={isSingleView ? { height: '100%', display: 'flex', flexDirection: 'column' } : undefined}>
{/* Header */}
<Group justify="space-between" style={{ flexShrink: 0 }}>
<Group gap="sm">
<Text size="xl" fw={600} style={{ letterSpacing: '-0.025em' }}>{config.settings.title}</Text>
{dashboards && dashboards.length > 1 && onSwitchDashboard && (
<Select
size="sm"
value={dashboards.find(d => d.current)?.file ?? null}
onChange={(v) => v && onSwitchDashboard(v)}
data={dashboards.map(d => ({ value: d.file, label: `${d.title} (${d.theme})` }))}
/>
)}
</Group>
<FilterBar
filters={config.filters ?? {}}
values={filters}
onChange={onFilterChange}
onReset={onFilterReset}
/>
</Group>
{/* Sections */}
<Box style={isSingleView ? { flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-sm)' } : undefined}>
{(config.sections ?? []).map(section => (
<Section
key={section.id}
section={section}
queries={config.queries}
filters={filters}
globalColumns={config.settings.columns}
getData={getData}
fillHeight={isSingleView}
/>
))}
</Box>
</Stack>
</Box>
) )
} }
+29 -33
View File
@@ -1,5 +1,6 @@
import { useCallback, useRef, useEffect, useState } from 'react' import { useCallback, useRef, useEffect, useState } from 'react'
import { SimpleSelect } from '@fn_library' import { Button, TextInput, Group, Text } from '@mantine/core'
import { Select } from '@fn_library'
import type { FilterDef, FilterPreset } from '../types' import type { FilterDef, FilterPreset } from '../types'
import type { FilterValues } from '../hooks/useFilterState' import type { FilterValues } from '../hooks/useFilterState'
@@ -15,17 +16,14 @@ export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps
if (entries.length === 0) return null if (entries.length === 0) return null
return ( return (
<div className="flex items-center gap-3 flex-wrap"> <Group gap="sm" wrap="wrap">
{entries.map(([key, def]) => ( {entries.map(([key, def]) => (
<FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} /> <FilterControl key={key} id={key} def={def} value={values[key]} onChange={onChange} />
))} ))}
<button <Button variant="subtle" size="xs" onClick={onReset}>
onClick={onReset}
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors px-2 py-1 rounded"
>
Reset Reset
</button> </Button>
</div> </Group>
) )
} }
@@ -51,14 +49,15 @@ function FilterControl({ id, def, value, onChange }: FilterControlProps) {
function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) { function SelectFilter({ id, def, value, onChange }: { id: string; def: FilterDef; value: string; onChange: (k: string, v: unknown) => void }) {
return ( return (
<div className="flex items-center gap-2"> <Group gap="xs" align="center">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label> <Text size="xs" c="dimmed">{def.label}</Text>
<SimpleSelect <Select
value={value ?? ''} value={value ?? null}
onValueChange={(v) => onChange(id, v)} onChange={(v) => onChange(id, v ?? '')}
options={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []} data={def.options?.map(opt => ({ value: opt.value, label: opt.label })) ?? []}
size="sm"
/> />
</div> </Group>
) )
} }
@@ -67,24 +66,21 @@ function DateRangeFilter({ id, def, value, onChange }: { id: string; def: Filter
const currentFrom = value?.from ?? '' const currentFrom = value?.from ?? ''
return ( return (
<div className="flex items-center gap-2"> <Group gap="xs" align="center">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label> <Text size="xs" c="dimmed">{def.label}</Text>
<div className="flex gap-1"> <Group gap={4}>
{presets.map((preset: FilterPreset) => ( {presets.map((preset: FilterPreset) => (
<button <Button
key={preset.label} key={preset.label}
size="xs"
variant={currentFrom === preset.from ? 'filled' : 'light'}
onClick={() => onChange(id, { from: preset.from, to: preset.to })} 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} {preset.label}
</button> </Button>
))} ))}
</div> </Group>
</div> </Group>
) )
} }
@@ -105,15 +101,15 @@ function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef;
}, [id, onChange, debounce]) }, [id, onChange, debounce])
return ( return (
<div className="flex items-center gap-2"> <Group gap="xs" align="center">
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label> <Text size="xs" c="dimmed">{def.label}</Text>
<input <TextInput
type="text" size="sm"
value={localValue} value={localValue}
onChange={handleChange} onChange={handleChange}
placeholder={def.placeholder} 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" style={{ width: 192 }}
/> />
</div> </Group>
) )
} }
+27 -21
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react' import { Box, UnstyledButton, Group, Text } from '@mantine/core'
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'
import { WidgetRenderer } from './WidgetRenderer' import { WidgetRenderer } from './WidgetRenderer'
import type { SectionDef, QueryDef } from '../types' import type { SectionDef, QueryDef } from '../types'
import type { FilterValues } from '../hooks/useFilterState' import type { FilterValues } from '../hooks/useFilterState'
@@ -10,42 +11,47 @@ interface SectionProps {
filters: FilterValues filters: FilterValues
globalColumns: number globalColumns: number
getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]> getData: (widgetID: string, filters: FilterValues) => Promise<Record<string, unknown>[]>
fillHeight?: boolean
} }
export function Section({ section, queries, filters, globalColumns, getData }: SectionProps) { export function Section({ section, queries, filters, globalColumns, getData, fillHeight }: SectionProps) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const columns = section.columns || globalColumns const columns = section.columns || globalColumns
return ( return (
<div className="space-y-2"> <Box style={fillHeight ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-xs)' } : { display: 'flex', flexDirection: 'column', gap: 'var(--mantine-spacing-xs)' }}>
<button <UnstyledButton
className="flex items-center gap-2 cursor-pointer select-none bg-transparent border-none p-0"
onClick={() => section.collapsible && setCollapsed(c => !c)} onClick={() => section.collapsible && setCollapsed(c => !c)}
type="button" style={{ flexShrink: 0, cursor: section.collapsible ? 'pointer' : 'default', userSelect: 'none' }}
> >
{section.collapsible && ( <Group gap="xs">
collapsed {section.collapsible && (
? <ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" /> collapsed
: <ChevronDown className="w-4 h-4 text-[var(--muted-foreground)]" /> ? <IconChevronRight size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
)} : <IconChevronDown size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
<h2 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider"> )}
{section.title} <Text size="xs" fw={500} c="dimmed" tt="uppercase" style={{ letterSpacing: '0.05em' }}>
</h2> {section.title}
</button> </Text>
</Group>
</UnstyledButton>
{!collapsed && ( {!collapsed && (
<div <Box
className="grid gap-2"
style={{ style={{
display: 'grid',
gap: 'var(--mantine-spacing-xs)',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
...(fillHeight ? { flex: 1, minHeight: 0 } : {}),
}} }}
> >
{section.widgets.map(widget => ( {section.widgets.map(widget => (
<div <Box
key={widget.id} key={widget.id}
style={{ style={{
gridColumn: `span ${Math.min(widget.span || 1, columns)}`, gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined, gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
...(fillHeight ? { minHeight: 0, overflow: 'hidden' } : {}),
}} }}
> >
<WidgetRenderer <WidgetRenderer
@@ -54,10 +60,10 @@ export function Section({ section, queries, filters, globalColumns, getData }: S
filters={filters} filters={filters}
getData={getData} getData={getData}
/> />
</div> </Box>
))} ))}
</div> </Box>
)} )}
</div> </Box>
) )
} }
+19 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { Box, Text } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { getWidget } from './widget-registry' import { getWidget } from './widget-registry'
import type { WidgetDef, QueryDef } from '../types' import type { WidgetDef, QueryDef } from '../types'
@@ -71,23 +72,34 @@ export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRen
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">{widget.title}</CardTitle> <CardTitle>{widget.title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-[var(--muted-foreground)]"> <Text size="sm" c="dimmed">
Unknown widget type: <code>{widget.type}</code> Unknown widget type: <code>{widget.type}</code>
</p> </Text>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
return ( return (
<div className="relative h-full"> <Box style={{ position: 'relative', height: '100%' }}>
<Component widget={widget} data={data} loading={loading} error={error} /> <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"> <Text
size="xs"
c="dimmed"
style={{
position: 'absolute',
top: 8,
right: 8,
fontSize: 10,
fontVariantNumeric: 'tabular-nums',
opacity: 0.6,
}}
>
{countdown}s {countdown}s
</span> </Text>
</div> </Box>
) )
} }
@@ -1,13 +1,14 @@
import { Center, Text, Loader } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer, Legend,
} from 'recharts' } from 'recharts'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', '#3b82f6', '#10b981', '#f59e0b',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)', '#ef4444', '#8b5cf6', '#ec4899',
] ]
export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) { export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
@@ -25,47 +26,46 @@ export function AreaChartWidget({ widget, data, loading, error }: WidgetProps) {
: yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : [] : yKey ? [{ dataKey: yKey, name: yKey, color: COLORS[0] }] : []
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]"> <CardTitle>
{widget.title} <Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading && !data ? ( {loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div> <Center h={300}><Loader size="sm" /></Center>
) : error ? ( ) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div> <Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
) : ( ) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}> <AreaChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }} data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<AreaChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}> {options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />} <XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} /> <YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} /> <Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} /> {!!options.show_legend && <Legend />}
{!!options.show_legend && <Legend />} <defs>
<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 => ( {areas.map(area => (
<Area <linearGradient key={`grad-${area.dataKey}`} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
key={area.dataKey} <stop offset="0%" stopColor={area.color} stopOpacity={0.3} />
type="monotone" <stop offset="100%" stopColor={area.color} stopOpacity={0.05} />
dataKey={area.dataKey} </linearGradient>
name={area.name}
stroke={area.color}
strokeWidth={2}
fill={`url(#gradient-${area.dataKey})`}
stackId={options.stacked ? 'stack' : undefined}
/>
))} ))}
</AreaChart> </defs>
</ResponsiveContainer> {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}
isAnimationActive={false}
/>
))}
</AreaChart>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,13 +1,14 @@
import { Center, Text, Loader } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer, Legend,
} from 'recharts' } from 'recharts'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', '#3b82f6', '#10b981', '#f59e0b',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)', '#ef4444', '#8b5cf6', '#ec4899',
] ]
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) { export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
@@ -27,51 +28,52 @@ export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
const isHorizontal = !!options.horizontal const isHorizontal = !!options.horizontal
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardHeader className="pb-1"> <CardHeader style={{ paddingBottom: 4 }}>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]"> <CardTitle>
{widget.title} <Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading && !data ? ( {loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div> <Center h={300}><Loader size="sm" /></Center>
) : error ? ( ) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div> <Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
) : ( ) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}> <BarChart
<BarChart width={options.width as number ?? undefined}
data={data ?? []} height={options.height as number ?? 300}
layout={isHorizontal ? 'vertical' : 'horizontal'} style={{ width: '100%' }}
margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }} data={data ?? []}
> layout={isHorizontal ? 'vertical' : 'horizontal'}
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />} margin={{ top: 5, right: 10, left: isHorizontal ? 10 : 0, bottom: 5 }}
{isHorizontal ? ( >
<> {options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
<YAxis {isHorizontal ? (
dataKey={xKey} <>
type="category" <YAxis
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} dataKey={xKey}
width={140} type="category"
/> tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
<XAxis width={140}
type="number" />
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} <XAxis
/> type="number"
</> tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
) : ( />
<> </>
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} /> ) : (
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }} /> <>
</> <XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 11 }} />
)} <YAxis tick={{ fill: 'var(--mantine-color-dimmed)', 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 => ( <Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8, fontSize: 12 }} />
<Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} /> {!!options.show_legend && <Legend wrapperStyle={{ fontSize: 11 }} />}
))} {bars.map(bar => (
</BarChart> <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} isAnimationActive={false} />
</ResponsiveContainer> ))}
</BarChart>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -55,7 +55,7 @@ export function KPIWidget({ widget, data, loading }: WidgetProps) {
/> />
) : undefined} ) : undefined}
size="default" size="default"
className="border-0 shadow-none" style={{ border: 'none', boxShadow: 'none' }}
/> />
) )
} }
@@ -1,13 +1,14 @@
import { Center, Text, Loader } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, Brush, ResponsiveContainer, Legend, Brush,
} from 'recharts' } from 'recharts'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', '#3b82f6', '#10b981', '#f59e0b',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)', '#ef4444', '#8b5cf6', '#ec4899',
] ]
export function LineChartWidget({ widget, data, loading, error }: WidgetProps) { export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
@@ -25,40 +26,39 @@ export function LineChartWidget({ widget, data, loading, error }: WidgetProps) {
: yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : [] : yKey ? [{ dataKey: yKey, name: yKey, stroke: COLORS[0] }] : []
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]"> <CardTitle>
{widget.title} <Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading && !data ? ( {loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div> <Center h={300}><Loader size="sm" /></Center>
) : error ? ( ) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div> <Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
) : ( ) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}> <LineChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }} data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<LineChart data={data ?? []} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}> {options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />} <XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
<XAxis dataKey={xKey} tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} /> <YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} /> <Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8 }} /> {!!options.show_legend && <Legend />}
{!!options.show_legend && <Legend />} {lines.map(line => (
{lines.map(line => ( <Line
<Line key={line.dataKey}
key={line.dataKey} type={(options.curve as any) ?? 'monotone'}
type={(options.curve as any) ?? 'monotone'} dataKey={line.dataKey}
dataKey={line.dataKey} name={line.name}
name={line.name} stroke={line.stroke}
stroke={line.stroke} strokeWidth={2}
strokeWidth={2} dot={false}
dot={false} activeDot={{ r: 4 }}
activeDot={{ r: 4 }} isAnimationActive={false}
/> />
))} ))}
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--primary)" />} {!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--mantine-primary-color-filled)" />}
</LineChart> </LineChart>
</ResponsiveContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,14 +1,15 @@
import { Center, Text, Loader } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import { import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend,
} from 'recharts' } from 'recharts'
import type { PieLabelRenderProps } from 'recharts' import type { PieLabelRenderProps } from 'recharts'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', '#3b82f6', '#10b981', '#f59e0b',
'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6, #ec4899)', '#ef4444', '#8b5cf6', '#ec4899',
'var(--chart-7, #06b6d4)', 'var(--chart-8, #f97316)', '#06b6d4', '#f97316',
] ]
function renderLabel(props: PieLabelRenderProps): string { function renderLabel(props: PieLabelRenderProps): string {
@@ -30,47 +31,46 @@ export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
})) }))
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardHeader className="pb-1"> <CardHeader style={{ paddingBottom: 4 }}>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]"> <CardTitle>
{widget.title} <Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading && !data ? ( {loading && !data ? (
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div> <Center h={300}><Loader size="sm" /></Center>
) : error ? ( ) : error ? (
<div className="h-[300px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div> <Center h={300}><Text size="sm" c="red">{error.message}</Text></Center>
) : ( ) : (
<ResponsiveContainer width="100%" height={options.height as number ?? 300}> <PieChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }}>
<PieChart> <Pie
<Pie data={pieData}
data={pieData} dataKey={valueKey}
dataKey={valueKey} nameKey={nameKey}
nameKey={nameKey} cx="50%"
cx="50%" cy="50%"
cy="50%" outerRadius={100}
outerRadius={100} innerRadius={options.donut ? 50 : 0}
innerRadius={options.donut ? 50 : 0} strokeWidth={0}
strokeWidth={0} fontSize={11}
fontSize={11} fill="var(--mantine-color-dimmed)"
fill="var(--muted-foreground)" isAnimationActive={false}
isAnimationActive={false} label={renderLabel}
label={renderLabel} labelLine={false}
labelLine={false} >
> {pieData.map((_, i) => (
{pieData.map((_, i) => ( <Cell key={i} fill={COLORS[i % COLORS.length]} />
<Cell key={i} fill={COLORS[i % COLORS.length]} /> ))}
))} </Pie>
</Pie> <Tooltip
<Tooltip isAnimationActive={false}
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8, fontSize: 12 }}
/> />
{options.show_legend !== false && ( {options.show_legend !== false && (
<Legend wrapperStyle={{ fontSize: 11 }} /> <Legend wrapperStyle={{ fontSize: 11 }} />
)} )}
</PieChart> </PieChart>
</ResponsiveContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,3 +1,4 @@
import { Center, Text, Loader } from '@mantine/core'
import { Card, CardContent } from '@fn_library' import { Card, CardContent } from '@fn_library'
import { Sparkline } from '@fn_library' import { Sparkline } from '@fn_library'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
@@ -10,11 +11,11 @@ export function SparklineWidget({ widget, data, loading }: WidgetProps) {
const values = (data ?? []).map(row => Number(row[valueKey]) || 0) const values = (data ?? []).map(row => Number(row[valueKey]) || 0)
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardContent className="pt-4"> <CardContent style={{ paddingTop: 'var(--mantine-spacing-sm)' }}>
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p> <Text size="xs" c="dimmed" mb="xs">{widget.title}</Text>
{loading && values.length === 0 ? ( {loading && values.length === 0 ? (
<div className="h-[40px] flex items-center text-[var(--muted-foreground)] text-xs">Loading...</div> <Center h={40}><Loader size="xs" /></Center>
) : ( ) : (
<Sparkline <Sparkline
data={values} data={values}
+42 -27
View File
@@ -1,3 +1,4 @@
import { Center, Text, Loader, Table, Box } from '@mantine/core'
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library' import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
import type { WidgetProps } from '../../types' import type { WidgetProps } from '../../types'
@@ -38,53 +39,67 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
if (isNaN(num)) return undefined if (isNaN(num)) return undefined
const t = (num - range.min) / (range.max - range.min) const t = (num - range.min) / (range.max - range.min)
const alpha = 0.1 + t * 0.55 const alpha = 0.1 + t * 0.55
return { backgroundColor: `color-mix(in srgb, var(--chart-1) ${Math.round(alpha * 100)}%, transparent)` } return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
} }
return ( return (
<Card className="h-full"> <Card style={{ height: '100%' }}>
<CardHeader className="pb-1"> <CardHeader style={{ paddingBottom: 4 }}>
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]"> <CardTitle>
{widget.title} <Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading && !data ? ( {loading && !data ? (
<div className="h-[200px] flex items-center justify-center text-[var(--muted-foreground)] text-sm">Loading...</div> <Center h={200}><Loader size="sm" /></Center>
) : error ? ( ) : error ? (
<div className="h-[200px] flex items-center justify-center text-[var(--destructive)] text-sm">{error.message}</div> <Center h={200}><Text size="sm" c="red">{error.message}</Text></Center>
) : ( ) : (
<div className="overflow-auto max-h-[500px]"> <Table.ScrollContainer minWidth={0} style={{ maxHeight: 500 }}>
<table className="w-full text-sm"> <Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false} fz="sm">
<thead className="sticky top-0 bg-[var(--card)]"> <Table.Thead style={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'var(--mantine-color-body)' }}>
<tr className="border-b border-[var(--border)]"> <Table.Tr>
{effectiveColumns.map(col => ( {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"> <Table.Th
key={col.key}
style={{
fontSize: 'var(--mantine-font-size-xs)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--mantine-color-dimmed)',
padding: '6px 12px',
}}
>
{col.label} {col.label}
</th> </Table.Th>
))} ))}
</tr> </Table.Tr>
</thead> </Table.Thead>
<tbody> <Table.Tbody>
{(data ?? []).map((row, i) => ( {(data ?? []).map((row, i) => (
<tr key={i} className="border-b border-[var(--border)] hover:bg-[var(--accent)]/50 transition-colors"> <Table.Tr key={i}>
{effectiveColumns.map(col => ( {effectiveColumns.map(col => (
<td <Table.Td
key={col.key} key={col.key}
className="py-1.5 px-3 font-mono text-xs" style={{
style={heatmapStyle(col.key, row[col.key])} padding: '6px 12px',
fontFamily: 'var(--mantine-font-family-monospace)',
fontSize: 'var(--mantine-font-size-xs)',
...heatmapStyle(col.key, row[col.key]),
}}
> >
{formatCell(row[col.key], col.format)} {formatCell(row[col.key], col.format)}
</td> </Table.Td>
))} ))}
</tr> </Table.Tr>
))} ))}
</tbody> </Table.Tbody>
</table> </Table>
{(!data || data.length === 0) && ( {(!data || data.length === 0) && (
<p className="text-center text-[var(--muted-foreground)] text-sm py-8">No data</p> <Text ta="center" c="dimmed" size="sm" py="xl">No data</Text>
)} )}
</div> </Table.ScrollContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -92,7 +107,7 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
} }
function formatCell(value: unknown, format?: string): string { function formatCell(value: unknown, format?: string): string {
if (value == null) return '' if (value == null) return '\u2014'
if (!format) return String(value) if (!format) return String(value)
const num = Number(value) const num = Number(value)
+3 -2
View File
@@ -3,6 +3,7 @@
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/ // Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
declare module '@fn_library' { declare module '@fn_library' {
import type { FC } from 'react' import type { FC } from 'react'
import type { SelectProps as MantineSelectProps } from '@mantine/core'
export const Card: FC<any> export const Card: FC<any>
export const CardContent: FC<any> export const CardContent: FC<any>
export const CardHeader: FC<any> export const CardHeader: FC<any>
@@ -13,6 +14,6 @@ declare module '@fn_library' {
export const Button: FC<any> export const Button: FC<any>
export const Input: FC<any> export const Input: FC<any>
export const Skeleton: FC<any> export const Skeleton: FC<any>
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean } export interface SelectProps extends MantineSelectProps {}
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement export function Select(props: SelectProps): React.ReactElement
} }
+30 -1
View File
@@ -1,10 +1,39 @@
import '@mantine/core/styles.css'
import '@mantine/charts/styles.css'
import '@mantine/notifications/styles.css'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import './app.css' import './app.css'
import App from './App' import App from './App'
const brand: MantineColorsTuple = [
'#e5f0ff',
'#cddeff',
'#9abbff',
'#6495ff',
'#3874fe',
'#1d60fe',
'#0953ff',
'#0046e4',
'#003dcd',
'#0034b5',
]
const theme = createTheme({
colors: { brand },
primaryColor: 'brand',
defaultRadius: 'md',
fontFamily: "'Geist Variable', system-ui, -apple-system, sans-serif",
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<App />
</MantineProvider>
</StrictMode>, </StrictMode>,
) )
+1
View File
@@ -14,6 +14,7 @@ export interface Settings {
width: number width: number
height: number height: number
columns: number columns: number
layout: 'scrollable' | 'single_view'
} }
export interface QueryDef { export interface QueryDef {
+4 -3
View File
@@ -1,11 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
@@ -14,6 +12,9 @@ export default defineConfig({
}, },
dedupe: ['react', 'react-dom'], dedupe: ['react', 'react-dom'],
}, },
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
server: { server: {
port: 5173, port: 5173,
}, },
+13 -3
View File
@@ -122,9 +122,9 @@ func main() {
engine := NewQueryEngine(cfg, pool) engine := NewQueryEngine(cfg, pool)
app := NewApp(cfg, engine, pool, dashDir, *dashboardPath) 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) log.Printf("starting wails — title=%q width=%d height=%d layout=%q dashDir=%q", cfg.Settings.Title, cfg.Settings.Width, cfg.Settings.Height, cfg.Settings.Layout, dashDir)
runErr := wails.Run(&options.App{ wailsOpts := &options.App{
Title: cfg.Settings.Title, Title: cfg.Settings.Title,
Width: cfg.Settings.Width, Width: cfg.Settings.Width,
Height: cfg.Settings.Height, Height: cfg.Settings.Height,
@@ -136,7 +136,17 @@ func main() {
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },
}) }
if cfg.Settings.Layout == "single_view" {
wailsOpts.MaxWidth = cfg.Settings.Width
wailsOpts.MaxHeight = cfg.Settings.Height
wailsOpts.MinWidth = cfg.Settings.Width
wailsOpts.MinHeight = cfg.Settings.Height
wailsOpts.DisableResize = true
}
runErr := wails.Run(wailsOpts)
if runErr != nil { if runErr != nil {
log.Printf("ERROR wails.Run: %v", runErr) log.Printf("ERROR wails.Run: %v", runErr)