Skip to content

Commit 6e2446d

Browse files
committed
solve problems with visualizations and dashboards
1 parent e165653 commit 6e2446d

34 files changed

Lines changed: 1480 additions & 841 deletions

backend/modules/dashboards/repository/visualization_pg.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,11 @@ func (r *pgVisualizationRepository) List(ctx context.Context, f dto.Visualizatio
4949
}
5050

5151
func (r *pgVisualizationRepository) Delete(ctx context.Context, id uint64) error {
52-
return r.db.WithContext(ctx).Delete(&domain.Visualization{}, id).Error
52+
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
53+
if err := tx.Where("id_visualization = ?", id).
54+
Delete(&domain.DashboardVisualization{}).Error; err != nil {
55+
return err
56+
}
57+
return tx.Delete(&domain.Visualization{}, id).Error
58+
})
5359
}
Lines changed: 47 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,45 @@
11
import { useMemo } from 'react'
22
import { useTranslation } from 'react-i18next'
3-
import { Responsive, WidthProvider, type Layout } from 'react-grid-layout/legacy'
4-
import 'react-grid-layout/css/styles.css'
5-
import 'react-resizable/css/styles.css'
3+
import { cn } from '@/shared/lib/utils'
64
import { WidgetCard } from '@/features/dashboard/components/WidgetCard'
75
import { WidgetRenderer } from '@/features/dashboard/components/WidgetRenderer'
8-
import { GRID_COLS, GRID_MARGIN, GRID_ROW_HEIGHT } from '@/features/dashboard/constants'
96
import type {
107
DashboardVisualization,
118
GridLayoutItem,
129
Visualization,
1310
} from '@/features/dashboard/types'
1411
import type { TimeRange } from '@/shared/components/ui/time-range-picker'
1512

16-
const ResponsiveGridLayout = WidthProvider(Responsive)
17-
18-
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }
19-
const COLS = { lg: GRID_COLS, md: GRID_COLS, sm: 6, xs: 4, xxs: 2 }
13+
// Column span per width preset. Grid is responsive (1 col mobile, 2 tablet, 3
14+
// desktop), so spans are clamped at each breakpoint. Literal classes so Tailwind
15+
// keeps them.
16+
const WIDTH_CLASS: Record<number, string> = {
17+
1: '',
18+
2: 'md:col-span-2 xl:col-span-2',
19+
3: 'md:col-span-2 xl:col-span-3',
20+
}
21+
const HEIGHT_CLASS: Record<number, string> = {
22+
1: 'h-[340px]',
23+
2: 'h-[560px]',
24+
}
2025

2126
export function DashboardGrid({
2227
items,
2328
layouts,
2429
visualizationsById,
2530
time,
2631
editing,
27-
onLayoutChange,
32+
onMove,
33+
onResize,
2834
onRemoveItem,
2935
}: {
3036
items: GridLayoutItem[]
3137
layouts: DashboardVisualization[]
3238
visualizationsById: Map<number, Visualization>
3339
time: TimeRange
3440
editing: boolean
35-
onLayoutChange?: (next: GridLayoutItem[]) => void
41+
onMove?: (id: string, dir: -1 | 1) => void
42+
onResize?: (id: string, w: number, h: number) => void
3643
onRemoveItem?: (id: number) => void
3744
}) {
3845
const { t } = useTranslation()
@@ -52,52 +59,42 @@ export function DashboardGrid({
5259
}
5360

5461
return (
55-
<ResponsiveGridLayout
56-
className="layout"
57-
layouts={{ lg: items, md: items, sm: items, xs: items, xxs: items }}
58-
breakpoints={BREAKPOINTS}
59-
cols={COLS}
60-
rowHeight={GRID_ROW_HEIGHT}
61-
margin={GRID_MARGIN}
62-
compactType="vertical"
63-
isDraggable={editing}
64-
isResizable={editing}
65-
draggableHandle=".widget-drag-handle"
66-
onLayoutChange={(next: Layout) => {
67-
if (!editing || !onLayoutChange) return
68-
const mapped: GridLayoutItem[] = next.map((n) => ({
69-
i: String(n.i),
70-
x: n.x,
71-
y: n.y,
72-
w: n.w,
73-
h: n.h,
74-
}))
75-
onLayoutChange(mapped)
76-
}}
77-
>
78-
{items.map((item) => {
62+
// Responsive columns — adapts widgets-per-row to the screen. Each widget
63+
// spans 1–3 columns (its width preset) and is short or tall (height preset).
64+
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
65+
{items.map((item, idx) => {
7966
const dv = layoutMap.get(item.i)
8067
const viz = dv ? visualizationsById.get(dv.idVisualization) : undefined
8168
const title = viz?.name ?? t('dashboards.grid.unknownVisualization')
82-
const layoutId = dv?.id
69+
const w = item.w || 1
70+
const h = item.h || 1
8371
return (
84-
<div key={item.i} data-grid={item}>
85-
<WidgetCard
86-
title={title}
87-
editing={editing}
88-
onRemove={layoutId ? () => onRemoveItem?.(layoutId) : undefined}
89-
>
90-
{viz ? (
91-
<WidgetRenderer visualization={viz} time={time} />
92-
) : (
93-
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
94-
{t('dashboards.grid.missingVisualization')}
95-
</div>
96-
)}
97-
</WidgetCard>
72+
<div key={item.i} className={cn('min-w-0', WIDTH_CLASS[w] ?? '')}>
73+
<div className={cn('w-full', HEIGHT_CLASS[h] ?? HEIGHT_CLASS[1])}>
74+
<WidgetCard
75+
title={title}
76+
editing={editing}
77+
width={w}
78+
height={h}
79+
canMoveBack={idx > 0}
80+
canMoveForward={idx < items.length - 1}
81+
onMoveBack={() => onMove?.(item.i, -1)}
82+
onMoveForward={() => onMove?.(item.i, 1)}
83+
onResize={(nw, nh) => onResize?.(item.i, nw, nh)}
84+
onRemove={dv ? () => onRemoveItem?.(dv.id) : undefined}
85+
>
86+
{viz ? (
87+
<WidgetRenderer visualization={viz} time={time} />
88+
) : (
89+
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
90+
{t('dashboards.grid.missingVisualization')}
91+
</div>
92+
)}
93+
</WidgetCard>
94+
</div>
9895
</div>
9996
)
10097
})}
101-
</ResponsiveGridLayout>
98+
</div>
10299
)
103100
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { useState, type ReactNode } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { useTranslation } from 'react-i18next'
4+
import { BarChart3, MoreVertical, Pencil, Plus, Star, Trash2 } from 'lucide-react'
5+
import { cn } from '@/shared/lib/utils'
6+
import type { Dashboard } from '@/features/dashboard/types'
7+
8+
/**
9+
* Dashboards-as-tabs: one tab per dashboard (consumption-first, like the Log
10+
* Explorer tabs). The view stays clean — tabs + time range + a single options
11+
* (⋯) menu that holds every management action for the current dashboard plus the
12+
* global ones (new dashboard, visualizations library).
13+
*/
14+
export function DashboardTabsBar({
15+
dashboards,
16+
selectedId,
17+
defaultId,
18+
onSelect,
19+
onSetDefault,
20+
onCreate,
21+
onRename,
22+
onDelete,
23+
onEdit,
24+
right,
25+
}: {
26+
dashboards: Dashboard[]
27+
selectedId: number | null
28+
defaultId: number | null
29+
onSelect: (id: number) => void
30+
onSetDefault: (id: number | null) => void
31+
onCreate: () => void
32+
onRename: (d: Dashboard) => void
33+
onDelete: (d: Dashboard) => void
34+
/** Enter edit mode for the current dashboard. Omit when it can't be edited. */
35+
onEdit?: () => void
36+
/** Consumption controls (time range, or the editor bar while editing). */
37+
right?: ReactNode
38+
}) {
39+
const { t } = useTranslation()
40+
const navigate = useNavigate()
41+
const [menuOpen, setMenuOpen] = useState(false)
42+
const current = dashboards.find((d) => d.id === selectedId) ?? null
43+
44+
const close = () => setMenuOpen(false)
45+
46+
return (
47+
<div className="flex items-center gap-2">
48+
{/* Tab strip — scrolls horizontally when dashboards overflow. */}
49+
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
50+
{dashboards.map((d) => {
51+
const active = d.id === selectedId
52+
return (
53+
<button
54+
key={d.id}
55+
onClick={() => onSelect(d.id)}
56+
className={cn(
57+
'flex max-w-[200px] shrink-0 items-center gap-1.5 rounded-t-md border-b-2 px-3 py-2 text-sm transition-colors',
58+
active
59+
? 'border-primary font-medium text-foreground'
60+
: 'border-transparent text-muted-foreground hover:text-foreground',
61+
)}
62+
>
63+
{defaultId === d.id && <Star size={11} className="shrink-0 fill-amber-400 text-amber-400" />}
64+
<span className="truncate">{d.name}</span>
65+
</button>
66+
)
67+
})}
68+
</div>
69+
70+
{/* Right: consumption controls + a single options menu. */}
71+
<div className="flex shrink-0 items-center gap-2">
72+
{right}
73+
<div className="relative">
74+
<button
75+
onClick={() => setMenuOpen((o) => !o)}
76+
title={t('dashboards.tabs.options')}
77+
aria-label={t('dashboards.tabs.options')}
78+
className="flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
79+
>
80+
<MoreVertical size={16} />
81+
</button>
82+
83+
{menuOpen && (
84+
<>
85+
<div className="fixed inset-0 z-10" onClick={close} />
86+
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-border bg-card py-1 shadow-lg">
87+
{current && onEdit && (
88+
<MenuItem icon={Pencil} onClick={() => { onEdit(); close() }}>
89+
{t('dashboards.actions.edit')}
90+
</MenuItem>
91+
)}
92+
{current && (
93+
<MenuItem
94+
icon={Star}
95+
onClick={() => { onSetDefault(defaultId === current.id ? null : current.id); close() }}
96+
>
97+
{defaultId === current.id ? t('dashboards.tabs.unsetDefault') : t('dashboards.picker.setDefault')}
98+
</MenuItem>
99+
)}
100+
{current && !current.systemOwner && (
101+
<>
102+
<MenuItem icon={Pencil} onClick={() => { onRename(current); close() }}>
103+
{t('dashboards.list.rename')}
104+
</MenuItem>
105+
<MenuItem icon={Trash2} danger onClick={() => { onDelete(current); close() }}>
106+
{t('dashboards.list.delete')}
107+
</MenuItem>
108+
</>
109+
)}
110+
<div className="my-1 border-t border-border" />
111+
<MenuItem icon={Plus} onClick={() => { onCreate(); close() }}>
112+
{t('dashboards.tabs.newDashboard')}
113+
</MenuItem>
114+
<MenuItem icon={BarChart3} onClick={() => { navigate('/dashboards/visualizations'); close() }}>
115+
{t('dashboards.tabs.visualizations')}
116+
</MenuItem>
117+
</div>
118+
</>
119+
)}
120+
</div>
121+
</div>
122+
</div>
123+
)
124+
}
125+
126+
function MenuItem({
127+
icon: Icon,
128+
children,
129+
onClick,
130+
danger,
131+
}: {
132+
icon: typeof Star
133+
children: ReactNode
134+
onClick: () => void
135+
danger?: boolean
136+
}) {
137+
return (
138+
<button
139+
onClick={onClick}
140+
className={cn(
141+
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted',
142+
danger ? 'text-destructive' : 'text-foreground',
143+
)}
144+
>
145+
<Icon size={13} /> {children}
146+
</button>
147+
)
148+
}

frontend/src/features/dashboard/components/EChartsRenderer.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import ReactECharts from 'echarts-for-react'
2+
import { useThemeContext } from '@/app/providers/ThemeProvider'
3+
import {
4+
UTM_THEME_DARK,
5+
UTM_THEME_LIGHT,
6+
} from '@/features/dashboard/utils/echarts-theme'
27

38
export function EChartsRenderer({ option }: { option: Record<string, unknown> }) {
9+
const { theme } = useThemeContext()
410
return (
511
<ReactECharts
612
option={option}
13+
theme={theme === 'dark' ? UTM_THEME_DARK : UTM_THEME_LIGHT}
714
notMerge
815
lazyUpdate
916
style={{ height: '100%', width: '100%' }}

0 commit comments

Comments
 (0)