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"`
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")
+2 -2
View File
@@ -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 ---
+1 -1
View File
@@ -10,7 +10,7 @@ theme: "dark"
connections:
registry:
driver: sqlite
path: ../../registry.db
path: ../../../registry.db
queries:
# --- KPIs ---
+9 -11
View File
@@ -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
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 { 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)
}, [])
-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 {
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);
}
+53 -35
View File
@@ -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>
)
}
+29 -33
View File
@@ -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>
)
}
+27 -21
View File
@@ -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>
)
}
+19 -7
View File
@@ -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}
+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 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)
+3 -2
View File
@@ -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
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 { 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>,
)
+1
View File
@@ -14,6 +14,7 @@ export interface Settings {
width: number
height: number
columns: number
layout: 'scrollable' | 'single_view'
}
export interface QueryDef {
+4 -3
View File
@@ -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,
},
+13 -3
View File
@@ -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)