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:
@@ -25,6 +25,7 @@ type Settings struct {
|
||||
Width int `yaml:"width" json:"width"`
|
||||
Height int `yaml:"height" json:"height"`
|
||||
Columns int `yaml:"columns" json:"columns"`
|
||||
Layout string `yaml:"layout" json:"layout"` // "scrollable" (default) or "single_view"
|
||||
}
|
||||
|
||||
type ConnConfig struct {
|
||||
@@ -127,6 +128,12 @@ func (c *DashboardConfig) validate() error {
|
||||
if c.Theme == "" {
|
||||
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 {
|
||||
return fmt.Errorf("at least one connection is required")
|
||||
|
||||
@@ -5,12 +5,12 @@ settings:
|
||||
height: 900
|
||||
columns: 12
|
||||
|
||||
theme: "emerald"
|
||||
theme: "amber"
|
||||
|
||||
connections:
|
||||
registry:
|
||||
driver: sqlite
|
||||
path: ../../registry.db
|
||||
path: ../../../registry.db
|
||||
|
||||
queries:
|
||||
# --- KPIs ---
|
||||
|
||||
@@ -10,7 +10,7 @@ theme: "dark"
|
||||
connections:
|
||||
registry:
|
||||
driver: sqlite
|
||||
path: ../../registry.db
|
||||
path: ../../../registry.db
|
||||
|
||||
queries:
|
||||
# --- KPIs ---
|
||||
|
||||
+9
-11
@@ -9,27 +9,25 @@
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"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",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@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"
|
||||
"recharts": "^3.8.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",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
052e44bcd719cd7db765d6c7fb248074
|
||||
1677d31df4e76e35008620851dba3211
|
||||
Generated
+381
-609
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Center, Box, Stack, Text, Code, Alert, Loader } from '@mantine/core'
|
||||
import { DashboardShell } from './components/DashboardShell'
|
||||
import { useFilterState } from './hooks/useFilterState'
|
||||
import type { DashboardConfig } from './types'
|
||||
@@ -21,7 +22,7 @@ interface DashboardInfo {
|
||||
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
|
||||
const bindings = await import('./wailsjs/go/main/App').catch(() => null)
|
||||
@@ -64,22 +65,26 @@ export default function App() {
|
||||
|
||||
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>
|
||||
<Center mih="100vh" p="xl">
|
||||
<Stack maw={480} gap="md">
|
||||
<Alert color="red" title="Dashboard Error" variant="light">
|
||||
<Code block style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{error}
|
||||
</Code>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<Center mih="100vh">
|
||||
<Stack align="center" gap="sm">
|
||||
<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 ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', config.theme || 'dark')
|
||||
}, [config.theme])
|
||||
|
||||
const getData = useCallback(async (widgetID: string, filters: FilterValues) => {
|
||||
return GetWidgetData(widgetID, filters)
|
||||
}, [])
|
||||
|
||||
@@ -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 {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Group, Text, Stack } from '@mantine/core'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { Section } from './Section'
|
||||
import { SimpleSelect } from '@fn_library'
|
||||
import { Select } from '@fn_library'
|
||||
import type { DashboardConfig } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
@@ -23,40 +24,57 @@ interface DashboardShellProps {
|
||||
}
|
||||
|
||||
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>
|
||||
const isSingleView = config.settings.layout === 'single_view'
|
||||
|
||||
{/* Sections */}
|
||||
{(config.sections ?? []).map(section => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
queries={config.queries}
|
||||
filters={filters}
|
||||
globalColumns={config.settings.columns}
|
||||
getData={getData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
mih={isSingleView ? undefined : '100vh'}
|
||||
style={isSingleView ? {
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} : undefined}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { FilterValues } from '../hooks/useFilterState'
|
||||
|
||||
@@ -15,17 +16,14 @@ export function FilterBar({ filters, values, onChange, onReset }: FilterBarProps
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Group gap="sm" wrap="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"
|
||||
>
|
||||
<Button variant="subtle" size="xs" onClick={onReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</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 }) {
|
||||
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 })) ?? []}
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||
<Select
|
||||
value={value ?? null}
|
||||
onChange={(v) => onChange(id, v ?? '')}
|
||||
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 ?? ''
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--muted-foreground)]">{def.label}</label>
|
||||
<div className="flex gap-1">
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||
<Group gap={4}>
|
||||
{presets.map((preset: FilterPreset) => (
|
||||
<button
|
||||
<Button
|
||||
key={preset.label}
|
||||
size="xs"
|
||||
variant={currentFrom === preset.from ? 'filled' : 'light'}
|
||||
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>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,15 +101,15 @@ function TextFilter({ id, def, value, onChange }: { id: string; def: FilterDef;
|
||||
}, [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"
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">{def.label}</Text>
|
||||
<TextInput
|
||||
size="sm"
|
||||
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"
|
||||
style={{ width: 192 }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 type { SectionDef, QueryDef } from '../types'
|
||||
import type { FilterValues } from '../hooks/useFilterState'
|
||||
@@ -10,42 +11,47 @@ interface SectionProps {
|
||||
filters: FilterValues
|
||||
globalColumns: number
|
||||
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 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"
|
||||
<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)' }}>
|
||||
<UnstyledButton
|
||||
onClick={() => section.collapsible && setCollapsed(c => !c)}
|
||||
type="button"
|
||||
style={{ flexShrink: 0, cursor: section.collapsible ? 'pointer' : 'default', userSelect: 'none' }}
|
||||
>
|
||||
{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>
|
||||
<Group gap="xs">
|
||||
{section.collapsible && (
|
||||
collapsed
|
||||
? <IconChevronRight size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||
: <IconChevronDown size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||
)}
|
||||
<Text size="xs" fw={500} c="dimmed" tt="uppercase" style={{ letterSpacing: '0.05em' }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
<Box
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 'var(--mantine-spacing-xs)',
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
...(fillHeight ? { flex: 1, minHeight: 0 } : {}),
|
||||
}}
|
||||
>
|
||||
{section.widgets.map(widget => (
|
||||
<div
|
||||
<Box
|
||||
key={widget.id}
|
||||
style={{
|
||||
gridColumn: `span ${Math.min(widget.span || 1, columns)}`,
|
||||
gridRow: widget.rowSpan ? `span ${widget.rowSpan}` : undefined,
|
||||
...(fillHeight ? { minHeight: 0, overflow: 'hidden' } : {}),
|
||||
}}
|
||||
>
|
||||
<WidgetRenderer
|
||||
@@ -54,10 +60,10 @@ export function Section({ section, queries, filters, globalColumns, getData }: S
|
||||
filters={filters}
|
||||
getData={getData}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { Box, Text } from '@mantine/core'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import { getWidget } from './widget-registry'
|
||||
import type { WidgetDef, QueryDef } from '../types'
|
||||
@@ -71,23 +72,34 @@ export function WidgetRenderer({ widget, queryDef, filters, getData }: WidgetRen
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{widget.title}</CardTitle>
|
||||
<CardTitle>{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
<Text size="sm" c="dimmed">
|
||||
Unknown widget type: <code>{widget.type}</code>
|
||||
</p>
|
||||
</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Box style={{ position: 'relative', height: '100%' }}>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Center, Text, Loader } from '@mantine/core'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
Legend,
|
||||
} 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)',
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899',
|
||||
]
|
||||
|
||||
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] }] : []
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
<CardTitle>
|
||||
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<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 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>
|
||||
<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 }}>
|
||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }} />
|
||||
{!!options.show_legend && <Legend />}
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</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}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Center, Text, Loader } from '@mantine/core'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
Legend,
|
||||
} 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)',
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899',
|
||||
]
|
||||
|
||||
export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
@@ -27,51 +28,52 @@ export function BarChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
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}
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardHeader style={{ paddingBottom: 4 }}>
|
||||
<CardTitle>
|
||||
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<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
|
||||
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>
|
||||
<BarChart
|
||||
width={options.width as number ?? undefined}
|
||||
height={options.height as number ?? 300}
|
||||
style={{ width: '100%' }}
|
||||
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(--mantine-color-default-border)" />}
|
||||
{isHorizontal ? (
|
||||
<>
|
||||
<YAxis
|
||||
dataKey={xKey}
|
||||
type="category"
|
||||
tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
|
||||
width={140}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 10 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 11 }} />
|
||||
</>
|
||||
)}
|
||||
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-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]} isAnimationActive={false} />
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function KPIWidget({ widget, data, loading }: WidgetProps) {
|
||||
/>
|
||||
) : undefined}
|
||||
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 {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, Brush, ResponsiveContainer,
|
||||
Legend, Brush,
|
||||
} 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)',
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899',
|
||||
]
|
||||
|
||||
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] }] : []
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
<CardTitle>
|
||||
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<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 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>
|
||||
<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 }}>
|
||||
{options.show_grid !== false && <CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-default-border)" />}
|
||||
<XAxis dataKey={xKey} tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'var(--mantine-color-dimmed)', fontSize: 12 }} />
|
||||
<Tooltip isAnimationActive={false} contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-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 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
{!!options.zoomable && <Brush dataKey={xKey} height={20} stroke="var(--mantine-primary-color-filled)" />}
|
||||
</LineChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Center, Text, Loader } from '@mantine/core'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import {
|
||||
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, Tooltip, Legend,
|
||||
} 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)',
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899',
|
||||
'#06b6d4', '#f97316',
|
||||
]
|
||||
|
||||
function renderLabel(props: PieLabelRenderProps): string {
|
||||
@@ -30,47 +31,46 @@ export function PieChartWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-1">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardHeader style={{ paddingBottom: 4 }}>
|
||||
<CardTitle>
|
||||
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<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>
|
||||
<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>
|
||||
<PieChart width={options.width as number ?? undefined} height={options.height as number ?? 300} style={{ width: '100%' }}>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
innerRadius={options.donut ? 50 : 0}
|
||||
strokeWidth={0}
|
||||
fontSize={11}
|
||||
fill="var(--mantine-color-dimmed)"
|
||||
isAnimationActive={false}
|
||||
label={renderLabel}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
isAnimationActive={false}
|
||||
contentStyle={{ backgroundColor: 'var(--mantine-color-body)', border: '1px solid var(--mantine-color-default-border)', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
{options.show_legend !== false && (
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
)}
|
||||
</PieChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Center, Text, Loader } from '@mantine/core'
|
||||
import { Card, CardContent } from '@fn_library'
|
||||
import { Sparkline } from '@fn_library'
|
||||
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)
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2">{widget.title}</p>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardContent style={{ paddingTop: 'var(--mantine-spacing-sm)' }}>
|
||||
<Text size="xs" c="dimmed" mb="xs">{widget.title}</Text>
|
||||
{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
|
||||
data={values}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Center, Text, Loader, Table, Box } from '@mantine/core'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@fn_library'
|
||||
import type { WidgetProps } from '../../types'
|
||||
|
||||
@@ -38,53 +39,67 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
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 { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-1">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{widget.title}
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardHeader style={{ paddingBottom: 4 }}>
|
||||
<CardTitle>
|
||||
<Text size="sm" fw={500} c="dimmed">{widget.title}</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 ? (
|
||||
<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 className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--card)]">
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<Table.ScrollContainer minWidth={0} style={{ maxHeight: 500 }}>
|
||||
<Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false} fz="sm">
|
||||
<Table.Thead style={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'var(--mantine-color-body)' }}>
|
||||
<Table.Tr>
|
||||
{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}
|
||||
</th>
|
||||
</Table.Th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(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 => (
|
||||
<td
|
||||
<Table.Td
|
||||
key={col.key}
|
||||
className="py-1.5 px-3 font-mono text-xs"
|
||||
style={heatmapStyle(col.key, row[col.key])}
|
||||
style={{
|
||||
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)}
|
||||
</td>
|
||||
</Table.Td>
|
||||
))}
|
||||
</tr>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{(!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>
|
||||
</Card>
|
||||
@@ -92,7 +107,7 @@ export function TableWidget({ widget, data, loading, error }: WidgetProps) {
|
||||
}
|
||||
|
||||
function formatCell(value: unknown, format?: string): string {
|
||||
if (value == null) return '—'
|
||||
if (value == null) return '\u2014'
|
||||
if (!format) return String(value)
|
||||
|
||||
const num = Number(value)
|
||||
|
||||
Vendored
+3
-2
@@ -3,6 +3,7 @@
|
||||
// Types for @fn_library — resolved at build time via Vite alias to frontend/functions/ui/
|
||||
declare module '@fn_library' {
|
||||
import type { FC } from 'react'
|
||||
import type { SelectProps as MantineSelectProps } from '@mantine/core'
|
||||
export const Card: FC<any>
|
||||
export const CardContent: FC<any>
|
||||
export const CardHeader: FC<any>
|
||||
@@ -13,6 +14,6 @@ declare module '@fn_library' {
|
||||
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
|
||||
export interface SelectProps extends MantineSelectProps {}
|
||||
export function Select(props: SelectProps): React.ReactElement
|
||||
}
|
||||
|
||||
+30
-1
@@ -1,10 +1,39 @@
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/charts/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import './app.css'
|
||||
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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Settings {
|
||||
width: number
|
||||
height: number
|
||||
columns: number
|
||||
layout: 'scrollable' | 'single_view'
|
||||
}
|
||||
|
||||
export interface QueryDef {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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()],
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
@@ -14,6 +12,9 @@ export default defineConfig({
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
css: {
|
||||
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
@@ -122,9 +122,9 @@ func main() {
|
||||
engine := NewQueryEngine(cfg, pool)
|
||||
app := NewApp(cfg, engine, pool, dashDir, *dashboardPath)
|
||||
|
||||
log.Printf("starting wails — title=%q width=%d height=%d dashDir=%q", cfg.Settings.Title, cfg.Settings.Width, cfg.Settings.Height, dashDir)
|
||||
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,
|
||||
Width: cfg.Settings.Width,
|
||||
Height: cfg.Settings.Height,
|
||||
@@ -136,7 +136,17 @@ func main() {
|
||||
Bind: []interface{}{
|
||||
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 {
|
||||
log.Printf("ERROR wails.Run: %v", runErr)
|
||||
|
||||
Reference in New Issue
Block a user