diff --git a/backend/modules/dashboards/domain/dashboard.go b/backend/modules/dashboards/domain/dashboard.go index 1fdc3c81e..fead0c198 100644 --- a/backend/modules/dashboards/domain/dashboard.go +++ b/backend/modules/dashboards/domain/dashboard.go @@ -7,6 +7,8 @@ type Dashboard struct { Name string `gorm:"column:name;size:100;not null;uniqueIndex" json:"name"` Description string `gorm:"column:description;size:255" json:"description"` Config string `gorm:"column:config" json:"config"` + // RefreshTime is the auto-refresh interval in seconds. 0 disables auto-refresh. + RefreshTime int64 `gorm:"column:refresh_time;not null;default:0" json:"refreshTime"` SystemOwner bool `gorm:"column:system_owner" json:"systemOwner"` CreatedDate time.Time `gorm:"column:created_date" json:"createdDate"` ModifiedDate time.Time `gorm:"column:modified_date" json:"modifiedDate"` diff --git a/backend/modules/dashboards/domain/errors.go b/backend/modules/dashboards/domain/errors.go index 7836e2546..85aa2a3fb 100644 --- a/backend/modules/dashboards/domain/errors.go +++ b/backend/modules/dashboards/domain/errors.go @@ -8,4 +8,5 @@ var ( ErrIDRequired = errors.New("id is required for update") ErrNameRequired = errors.New("name is required") ErrSQLQueryRequired = errors.New("sqlQuery is required") + ErrInvalidSQL = errors.New("invalid sqlQuery") ) diff --git a/backend/modules/dashboards/handler/helpers.go b/backend/modules/dashboards/handler/helpers.go index e88cb3aec..9c72ef3fb 100644 --- a/backend/modules/dashboards/handler/helpers.go +++ b/backend/modules/dashboards/handler/helpers.go @@ -13,7 +13,7 @@ func writeError(c *gin.Context, err error) { switch { case errors.Is(err, domain.ErrNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, domain.ErrIDForbidden), errors.Is(err, domain.ErrIDRequired), errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrSQLQueryRequired): + case errors.Is(err, domain.ErrIDForbidden), errors.Is(err, domain.ErrIDRequired), errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrSQLQueryRequired), errors.Is(err, domain.ErrInvalidSQL): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/modules/dashboards/usecase/visualization.go b/backend/modules/dashboards/usecase/visualization.go index fd30ed4cf..5c316ccf0 100644 --- a/backend/modules/dashboards/usecase/visualization.go +++ b/backend/modules/dashboards/usecase/visualization.go @@ -2,12 +2,14 @@ package usecase import ( "context" + "fmt" "strings" "time" "github.com/utmstack/utmstack/backend/modules/dashboards/connectors" "github.com/utmstack/utmstack/backend/modules/dashboards/domain" "github.com/utmstack/utmstack/backend/modules/dashboards/dto" + os_usecase "github.com/utmstack/utmstack/backend/modules/opensearch/usecase" ) type visualizationUsecase struct { @@ -25,8 +27,8 @@ func (u *visualizationUsecase) Create(ctx context.Context, v *domain.Visualizati if strings.TrimSpace(v.Name) == "" { return nil, domain.ErrNameRequired } - if strings.TrimSpace(v.SQLQuery) == "" { - return nil, domain.ErrSQLQueryRequired + if err := sanitizeVisualizationSQL(v); err != nil { + return nil, err } now := time.Now().UTC() v.CreatedDate = now @@ -48,8 +50,8 @@ func (u *visualizationUsecase) Update(ctx context.Context, v *domain.Visualizati if existing == nil { return nil, domain.ErrNotFound } - if strings.TrimSpace(v.SQLQuery) == "" { - return nil, domain.ErrSQLQueryRequired + if err := sanitizeVisualizationSQL(v); err != nil { + return nil, err } v.CreatedDate = existing.CreatedDate v.SystemOwner = existing.SystemOwner @@ -71,3 +73,20 @@ func (u *visualizationUsecase) List(ctx context.Context, f dto.VisualizationFilt func (u *visualizationUsecase) Delete(ctx context.Context, id uint64) error { return u.repo.Delete(ctx, id) } + +// sanitizeVisualizationSQL trims and validates a visualization's SQL query +// through the same guard used at query-execution time (opensearch.ValidateSQL: +// SELECT-only, no comments, no DML/DDL keywords, balanced quotes/parens, only +// whitelisted aggregate functions). Placeholders like {{timeFilter}} and +// {{dashboardFilters}} pass through unchanged. Enforcing here on save prevents +// dangerous queries from ever reaching the database. +func sanitizeVisualizationSQL(v *domain.Visualization) error { + v.SQLQuery = strings.TrimSpace(v.SQLQuery) + if v.SQLQuery == "" { + return domain.ErrSQLQueryRequired + } + if err := os_usecase.ValidateSQL(v.SQLQuery); err != nil { + return fmt.Errorf("%w: %s", domain.ErrInvalidSQL, err.Error()) + } + return nil +} diff --git a/backend/modules/dashboards/usecase/visualization_sanitize_test.go b/backend/modules/dashboards/usecase/visualization_sanitize_test.go new file mode 100644 index 000000000..fd3dc41d3 --- /dev/null +++ b/backend/modules/dashboards/usecase/visualization_sanitize_test.go @@ -0,0 +1,70 @@ +package usecase + +import ( + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/modules/dashboards/domain" +) + +func TestSanitizeVisualizationSQL(t *testing.T) { + cases := []struct { + name string + sql string + wantErr error // sentinel expected via errors.Is (nil means pass) + }{ + { + name: "valid templated SELECT", + sql: `SELECT bucket, count(*) AS y FROM logs +WHERE {{dashboardFilters}}{{timeFilter}} +GROUP BY bucket +ORDER BY y DESC +LIMIT 100`, + }, + { + name: "empty is rejected as required", + sql: " ", + wantErr: domain.ErrSQLQueryRequired, + }, + { + name: "non-SELECT rejected", + sql: "UPDATE logs SET user='x' WHERE 1=1", + wantErr: domain.ErrInvalidSQL, + }, + { + name: "forbidden keyword rejected", + sql: "SELECT * FROM logs; DROP TABLE users", + wantErr: domain.ErrInvalidSQL, + }, + { + name: "line comment rejected", + sql: "SELECT * FROM logs -- inject", + wantErr: domain.ErrInvalidSQL, + }, + { + name: "block comment rejected", + sql: "SELECT /* trick */ * FROM logs", + wantErr: domain.ErrInvalidSQL, + }, + { + name: "disallowed function rejected", + sql: "SELECT LOAD_FILE('/etc/passwd') FROM logs", + wantErr: domain.ErrInvalidSQL, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v := &domain.Visualization{SQLQuery: tc.sql} + err := sanitizeVisualizationSQL(v) + if tc.wantErr == nil { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("expected errors.Is(%v), got %v", tc.wantErr, err) + } + }) + } +} diff --git a/frontend/src/features/dashboard/components/DashboardFilterBar.tsx b/frontend/src/features/dashboard/components/DashboardFilterBar.tsx new file mode 100644 index 000000000..5da7a9ab7 --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardFilterBar.tsx @@ -0,0 +1,394 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { ChevronDown, Filter, Loader2, Pencil, Trash2, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog' +import { usePropertyValues } from '@/features/dashboard/hooks/usePropertyValues' +import { DashboardFilterChipEditor } from '@/features/dashboard/components/DashboardFilterChipEditor' +import type { DashboardFilterChip } from '@/features/dashboard/types' + +export type ChipValueMap = Record + +const POPOVER_WIDTH = 720 +const POPOVER_GAP = 8 +const VIEWPORT_MARGIN = 16 + +/** + * Horizontal chip bar above the dashboard grid. In edit mode each chip exposes + * pencil + trash icons (open the config popover / open a confirm dialog), plus + * an "Add filter" trigger that spawns an empty chip form. Outside edit mode the + * chips work as value pickers (unchanged). + */ +export function DashboardFilterBar({ + chips, + values, + onChange, + editable = false, + onSaveChips, + savingChips = false, +}: { + chips: DashboardFilterChip[] + values: ChipValueMap + onChange: (next: ChipValueMap) => void + editable?: boolean + onSaveChips?: (next: DashboardFilterChip[]) => void + savingChips?: boolean +}) { + const { t } = useTranslation() + // Anchor + mode for the single-chip editor popover. `chip=null` → add mode, + // `chip=` → edit mode (form pre-filled). + const [popover, setPopover] = useState< + | { anchor: HTMLElement; chip: DashboardFilterChip | null } + | null + >(null) + const [pendingDelete, setPendingDelete] = useState(null) + + if (chips.length === 0 && !editable) return null + + const setValue = (id: string, next: string | string[] | null) => { + if (next === null || (Array.isArray(next) && next.length === 0) || next === '') { + const { [id]: _, ...rest } = values + onChange(rest) + return + } + onChange({ ...values, [id]: next }) + } + + const submitChip = (chip: DashboardFilterChip) => { + if (!popover) return + const next = popover.chip + ? chips.map((c) => (c.id === chip.id ? chip : c)) + : [...chips, chip] + onSaveChips?.(next) + setPopover(null) + } + + const confirmRemove = () => { + if (!pendingDelete) return + onSaveChips?.(chips.filter((c) => c.id !== pendingDelete.id)) + setPendingDelete(null) + } + + return ( +
+ {chips.map((chip) => + editable ? ( + setPopover({ anchor, chip })} + onRemove={() => setPendingDelete(chip)} + /> + ) : ( + setValue(chip.id, next)} + clearLabel={t('dashboards.filters.clear')} + /> + ) + )} + {editable && ( + setPopover({ anchor, chip: null })} /> + )} + {savingChips && ( + + + {t('dashboards.filters.saving')} + + )} + + {popover && ( + setPopover(null)} + onSubmit={submitChip} + /> + )} + + setPendingDelete(null)} + onConfirm={confirmRemove} + /> +
+ ) +} + +function AddFilterButton({ onOpen }: { onOpen: (anchor: HTMLElement) => void }) { + const { t } = useTranslation() + return ( + + ) +} + +function EditableChip({ + chip, + onEdit, + onRemove, +}: { + chip: DashboardFilterChip + onEdit: (anchor: HTMLElement) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const editRef = useRef(null) + return ( +
+ {chip.label} + + +
+ ) +} + +function ChipEditorPopover({ + anchor, + initial, + busy, + onCancel, + onSubmit, +}: { + anchor: HTMLElement + initial: DashboardFilterChip | null + busy: boolean + onCancel: () => void + onSubmit: (chip: DashboardFilterChip) => void +}) { + const popoverRef = useRef(null) + const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>( + null + ) + + // Anchor bottom-right of the trigger. Clamp so the popover never spills off + // the right edge. Portalled to to escape the surrounding
's + // overflow, which would otherwise clip a popover positioned outside it. + useLayoutEffect(() => { + const compute = () => { + const rect = anchor.getBoundingClientRect() + const availableWidth = window.innerWidth - VIEWPORT_MARGIN * 2 + const width = Math.min(POPOVER_WIDTH, availableWidth) + let left = rect.right + POPOVER_GAP + const top = rect.bottom + POPOVER_GAP + if (left + width > window.innerWidth - VIEWPORT_MARGIN) { + left = Math.max(VIEWPORT_MARGIN, window.innerWidth - VIEWPORT_MARGIN - width) + } + setCoords({ top, left, width }) + } + compute() + window.addEventListener('resize', compute) + window.addEventListener('scroll', compute, true) + return () => { + window.removeEventListener('resize', compute) + window.removeEventListener('scroll', compute, true) + } + }, [anchor]) + + useEffect(() => { + const onDoc = (e: MouseEvent) => { + const target = e.target as Node + if (anchor.contains(target) || popoverRef.current?.contains(target)) return + onCancel() + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [anchor, onCancel]) + + if (!coords) return null + + return createPortal( +
+ +
, + document.body + ) +} + +function FilterChip({ + chip, + value, + onChange, + clearLabel, +}: { + chip: DashboardFilterChip + value: string | string[] | undefined + onChange: (next: string | string[] | null) => void + clearLabel: string +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const rootRef = useRef(null) + + const query = usePropertyValues(chip.indexPattern, chip.field, open) + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [open]) + + const hasValue = Array.isArray(value) ? value.length > 0 : !!value + const displayValue = Array.isArray(value) + ? value.length <= 2 + ? value.join(', ') + : `${value.length} ${t('dashboards.filters.selected')}` + : (value ?? '') + + const items = (query.data ?? []).filter((v) => + chip.searchable && search ? v.toLowerCase().includes(search.toLowerCase()) : true + ) + + const toggle = (v: string) => { + if (chip.multiple) { + const cur = Array.isArray(value) ? value : [] + const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v] + onChange(next) + return + } + onChange(v) + setOpen(false) + } + + return ( +
+
+ + {hasValue && ( + + )} +
+ + {open && ( +
+ {chip.searchable && ( + setSearch(e.target.value)} + placeholder={t('dashboards.filters.searchPlaceholder')} + className="mb-2 h-7 w-full rounded border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> + )} +
+ {query.isLoading && ( +
+ + {t('dashboards.filters.loading')} +
+ )} + {query.isError && ( +
+ {t('dashboards.filters.loadError')} +
+ )} + {!query.isLoading && items.length === 0 && ( +
+ {t('dashboards.filters.noValues')} +
+ )} + {items.map((v) => { + const selected = Array.isArray(value) ? value.includes(v) : value === v + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/dashboard/components/DashboardFilterChipEditor.tsx b/frontend/src/features/dashboard/components/DashboardFilterChipEditor.tsx new file mode 100644 index 000000000..fd5841c7c --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardFilterChipEditor.tsx @@ -0,0 +1,130 @@ +import { useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { IndexPatternSelect } from '@/features/dashboard/components/editor/IndexPatternSelect' +import { FieldSelect } from '@/features/dashboard/components/editor/FieldSelect' +import { useAggregatableFields } from '@/features/dashboard/hooks/useAggregatableFields' +import type { DashboardFilterChip } from '@/features/dashboard/types' + +/** + * Editor for a single dashboard filter chip. Add-mode when `initial` is null, + * edit-mode otherwise. The parent (typically {@link DashboardFilterBar}) hosts + * this in a popover and receives the completed chip through {@link onSubmit}. + */ +export function DashboardFilterChipEditor({ + initial, + busy, + onCancel, + onSubmit, +}: { + initial: DashboardFilterChip | null + busy: boolean + onCancel: () => void + onSubmit: (chip: DashboardFilterChip) => void +}) { + const { t } = useTranslation() + const [chip, setChip] = useState(() => + initial ?? { + id: crypto.randomUUID(), + field: '', + label: '', + indexPattern: '', + multiple: false, + searchable: true, + } + ) + + const { fields, isLoading } = useAggregatableFields(chip.indexPattern || null) + + const update = (patch: Partial) => + setChip((c) => ({ ...c, ...patch })) + + const valid = + chip.field.trim() !== '' && chip.label.trim() !== '' && chip.indexPattern.trim() !== '' + + return ( +
+
+

+ {initial ? t('dashboards.filters.editTitle') : t('dashboards.filters.addTitle')} +

+
+ +
+
+ + update({ indexPattern: pattern, field: '' })} + /> + + + update({ field: next })} + fields={fields} + loading={isLoading} + disabled={!chip.indexPattern} + placeholder={t('dashboards.filters.field.chooseField') ?? ''} + /> + + + update({ label: e.target.value })} + placeholder={t('dashboards.filters.field.labelPlaceholder') ?? ''} + /> + + + update({ placeholder: e.target.value || undefined })} + placeholder={t('dashboards.filters.field.placeholderPlaceholder') ?? ''} + /> + +
+ + +
+
+
+ +
+ + +
+
+ ) +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/frontend/src/features/dashboard/components/DashboardGrid.tsx b/frontend/src/features/dashboard/components/DashboardGrid.tsx index 39a671a70..73922b382 100644 --- a/frontend/src/features/dashboard/components/DashboardGrid.tsx +++ b/frontend/src/features/dashboard/components/DashboardGrid.tsx @@ -7,6 +7,7 @@ import { WidgetRenderer } from '@/features/dashboard/components/WidgetRenderer' import { GRID_COLS, GRID_MARGIN, GRID_ROW_HEIGHT } from '@/features/dashboard/constants' import type { DashboardVisualization, + FilterType, GridLayoutItem, Visualization, } from '@/features/dashboard/types' @@ -17,6 +18,8 @@ export function DashboardGrid({ layouts, visualizationsById, time, + filters, + refreshSeconds, editing, onLayoutChange, onRemoveItem, @@ -25,6 +28,8 @@ export function DashboardGrid({ layouts: DashboardVisualization[] visualizationsById: Map time: TimeRange + filters?: FilterType[] + refreshSeconds?: number editing: boolean onLayoutChange?: (items: GridLayoutItem[]) => void onRemoveItem?: (id: number) => void @@ -82,7 +87,12 @@ export function DashboardGrid({ onRemove={dv ? () => onRemoveItem?.(dv.id) : undefined} > {viz ? ( - + ) : (
{t('dashboards.grid.missingVisualization')} diff --git a/frontend/src/features/dashboard/components/DashboardList.tsx b/frontend/src/features/dashboard/components/DashboardList.tsx deleted file mode 100644 index 3922227c8..000000000 --- a/frontend/src/features/dashboard/components/DashboardList.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import type { Dashboard } from '@/features/dashboard/types' - -export function DashboardList({ - dashboards, - selectedId, - search, - loading, - onSearchChange, - onSelect, - onCreate, - onRename, - onDelete, -}: { - dashboards: Dashboard[] - selectedId: number | null - search: string - loading: boolean - onSearchChange: (value: string) => void - onSelect: (id: number) => void - onCreate: () => void - onRename: (d: Dashboard) => void - onDelete: (d: Dashboard) => void -}) { - const { t } = useTranslation() - - return ( - - ) -} diff --git a/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx b/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx new file mode 100644 index 000000000..53a12e564 --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardPreviewHeader.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { ArrowLeft, Pencil, Trash2 } from 'lucide-react' +import type { Dashboard } from '@/features/dashboard/types' + +export function DashboardPreviewHeader({ + dashboard, + onBack, + onEdit, + onDelete, + right, +}: { + dashboard: Dashboard + onBack: () => void + /** Enter widget-layout edit mode. Omit while already editing or for systemOwner dashboards. */ + onEdit?: () => void + onDelete: (d: Dashboard) => void + /** Consumption controls (time range) or the editor bar while editing. */ + right?: ReactNode +}) { + const { t } = useTranslation() + const canModify = !dashboard.systemOwner + + return ( +
+ + / +
+

{dashboard.name}

+ {dashboard.description && ( +

{dashboard.description}

+ )} +
+ +
+ {right} + {canModify && onEdit && ( + + )} + {canModify && ( + + )} +
+
+ ) +} diff --git a/frontend/src/features/dashboard/components/DashboardRefreshSelect.tsx b/frontend/src/features/dashboard/components/DashboardRefreshSelect.tsx new file mode 100644 index 000000000..006bbf2aa --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardRefreshSelect.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' +import { RefreshCw } from 'lucide-react' + +// Options in seconds. 0 = auto-refresh disabled. +export const REFRESH_OPTIONS: { value: number; labelKey: string }[] = [ + { value: 0, labelKey: 'dashboards.refresh.off' }, + { value: 10, labelKey: 'dashboards.refresh.10s' }, + { value: 30, labelKey: 'dashboards.refresh.30s' }, + { value: 60, labelKey: 'dashboards.refresh.1m' }, + { value: 300, labelKey: 'dashboards.refresh.5m' }, + { value: 900, labelKey: 'dashboards.refresh.15m' }, +] + +export function DashboardRefreshSelect({ + value, + onChange, + disabled, +}: { + value: number + onChange: (next: number) => void + disabled?: boolean +}) { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/features/dashboard/components/DashboardTable.tsx b/frontend/src/features/dashboard/components/DashboardTable.tsx new file mode 100644 index 000000000..0b8404a5c --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardTable.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from 'react-i18next' +import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import type { Dashboard } from '@/features/dashboard/types' + +export function DashboardTable({ + dashboards, + loading, + search, + onSearchChange, + onSelect, + onCreate, + onEdit, + onDelete, +}: { + dashboards: Dashboard[] + loading: boolean + search: string + onSearchChange: (value: string) => void + onSelect: (id: number) => void + onCreate: () => void + onEdit: (d: Dashboard) => void + onDelete: (d: Dashboard) => void +}) { + const { t } = useTranslation() + + return ( +
+
+

{t('dashboards.list.title')}

+
+ onSearchChange(e.target.value)} + placeholder={t('dashboards.list.searchPlaceholder') ?? ''} + className="h-9 w-64" + /> + +
+
+ +
+ {loading && ( +
+ + {t('dashboards.list.loading')} +
+ )} + {!loading && dashboards.length === 0 && ( +
+ {t('dashboards.list.empty')} +
+ )} + {!loading && dashboards.length > 0 && ( + + + + + + + + + + {dashboards.map((d) => ( + + ))} + +
{t('dashboards.table.col.name')}{t('dashboards.table.col.description')}{t('dashboards.table.col.modified')} +
+ )} +
+
+ ) +} + +function DashboardRow({ + dashboard: d, + onSelect, + onEdit, + onDelete, +}: { + dashboard: Dashboard + onSelect: (id: number) => void + onEdit: (d: Dashboard) => void + onDelete: (d: Dashboard) => void +}) { + const { t } = useTranslation() + const modified = d.modifiedDate ? new Date(d.modifiedDate).toLocaleString() : '—' + return ( + onSelect(d.id)} + className="cursor-pointer border-b border-border/60 last:border-0 hover:bg-muted/40" + > + {d.name} + {d.description || '—'} + {modified} + e.stopPropagation()}> + {!d.systemOwner && ( +
+ + +
+ )} + + + ) +} diff --git a/frontend/src/features/dashboard/components/DashboardTabsBar.tsx b/frontend/src/features/dashboard/components/DashboardTabsBar.tsx deleted file mode 100644 index 6880a4e4c..000000000 --- a/frontend/src/features/dashboard/components/DashboardTabsBar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState, type ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { BarChart3, MoreVertical, Pencil, Plus, Star, Trash2 } from 'lucide-react' -import { cn } from '@/shared/lib/utils' -import type { Dashboard } from '@/features/dashboard/types' - -/** - * Dashboards-as-tabs: one tab per dashboard (consumption-first, like the Log - * Explorer tabs). The view stays clean — tabs + time range + a single options - * (⋯) menu that holds every management action for the current dashboard plus the - * global ones (new dashboard, visualizations library). - */ -export function DashboardTabsBar({ - dashboards, - selectedId, - defaultId, - onSelect, - onSetDefault, - onCreate, - onRename, - onDelete, - onEdit, - right, -}: { - dashboards: Dashboard[] - selectedId: number | null - defaultId: number | null - onSelect: (id: number) => void - onSetDefault: (id: number | null) => void - onCreate: () => void - onRename: (d: Dashboard) => void - onDelete: (d: Dashboard) => void - /** Enter edit mode for the current dashboard. Omit when it can't be edited. */ - onEdit?: () => void - /** Consumption controls (time range, or the editor bar while editing). */ - right?: ReactNode -}) { - const { t } = useTranslation() - const navigate = useNavigate() - const [menuOpen, setMenuOpen] = useState(false) - const current = dashboards.find((d) => d.id === selectedId) ?? null - - const close = () => setMenuOpen(false) - - return ( -
- {/* Tab strip — scrolls horizontally when dashboards overflow. */} -
- {dashboards.map((d) => { - const active = d.id === selectedId - return ( - - ) - })} -
- - {/* Right: consumption controls + a single options menu. */} -
- {right} -
- - - {menuOpen && ( - <> -
-
- {current && onEdit && ( - { onEdit(); close() }}> - {t('dashboards.actions.edit')} - - )} - {current && ( - { onSetDefault(defaultId === current.id ? null : current.id); close() }} - > - {defaultId === current.id ? t('dashboards.tabs.unsetDefault') : t('dashboards.picker.setDefault')} - - )} - {current && !current.systemOwner && ( - <> - { onRename(current); close() }}> - {t('dashboards.list.rename')} - - { onDelete(current); close() }}> - {t('dashboards.list.delete')} - - - )} -
- { onCreate(); close() }}> - {t('dashboards.tabs.newDashboard')} - - { navigate('/dashboards/visualizations'); close() }}> - {t('dashboards.tabs.visualizations')} - -
- - )} -
-
-
- ) -} - -function MenuItem({ - icon: Icon, - children, - onClick, - danger, -}: { - icon: typeof Star - children: ReactNode - onClick: () => void - danger?: boolean -}) { - return ( - - ) -} diff --git a/frontend/src/features/dashboard/components/VisualizationEditor.tsx b/frontend/src/features/dashboard/components/VisualizationEditor.tsx index 0f85cab76..6915360c4 100644 --- a/frontend/src/features/dashboard/components/VisualizationEditor.tsx +++ b/frontend/src/features/dashboard/components/VisualizationEditor.tsx @@ -13,9 +13,11 @@ import { ColumnsPicker } from '@/features/dashboard/components/editor/ColumnsPic import { ChartPreviewPanel } from '@/features/dashboard/components/editor/ChartPreviewPanel' import { ChartTypeModal } from '@/features/dashboard/components/editor/ChartTypeModal' import { useAggregatableFields } from '@/features/dashboard/hooks/useAggregatableFields' +import { useIndexPatterns } from '@/features/dashboard/hooks/useIndexPatterns' import { useVisualizationMutations } from '@/features/dashboard/hooks/useVisualizations' +import { SqlQueryEditor } from '@/shared/components/sql-editor' import { getChartTypeMeta } from '@/features/dashboard/constants' -import { composeSql } from '@/features/dashboard/utils/sql-builder' +import { composeSql, parseSqlToBuilder } from '@/features/dashboard/utils/sql-builder' import { makeInitialBuilder, parseBuilderConfig, @@ -55,21 +57,32 @@ export function VisualizationEditor({ initial, initialChartType }: Visualization }) const [builder, setBuilder] = useState(() => { - if (initialParsed.builder) return initialParsed.builder + // Seed rawSql so the SQL tab and the visual tab start out synchronised — + // legacy widgets stored just `sqlQuery`, newer ones round-trip via composeSql. + const seedRawSql = (b: BuilderState) => ({ + ...b, + rawSql: b.rawSql && b.rawSql.trim() ? b.rawSql : composeSql({ ...b, rawMode: false }), + }) + if (initialParsed.builder) return seedRawSql(initialParsed.builder) if (initial) { - return { + return seedRawSql({ ...makeInitialBuilder(), chartType: initialChartType ?? 'bar', - rawMode: true, rawSql: initial.sqlQuery ?? null, - } + }) } - return { ...makeInitialBuilder(), chartType: initialChartType ?? 'bar' } + return seedRawSql({ ...makeInitialBuilder(), chartType: initialChartType ?? 'bar' }) }) const [name, setName] = useState(initial?.name ?? '') const [description, setDescription] = useState(initial?.description ?? '') - const [tab, setTab] = useState(() => (builder.rawMode ? 'sql' : 'visual')) + // Open on whichever tab the widget was last saved from: legacy widgets + // (no builder config) land on SQL because we only have raw SQL for them. + const [tab, setTab] = useState(() => { + if (initialParsed.builder?.rawMode) return 'sql' + if (initial && !initialParsed.builder) return 'sql' + return 'visual' + }) const [chartTypeOpen, setChartTypeOpen] = useState(false) const { @@ -77,24 +90,36 @@ export function VisualizationEditor({ initial, initialChartType }: Visualization groupableFields: groupable, isLoading: fieldsLoading, } = useAggregatableFields(builder.indexPattern) + const indexPatterns = useIndexPatterns() + const patternList = useMemo( + () => indexPatterns.data?.data ?? [], + [indexPatterns.data?.data] + ) - const composedSql = useMemo(() => composeSql(builder), [builder]) + // Visual edits regenerate rawSql so the SQL tab reflects the change on switch, + // and SQL edits reverse-parse into the builder so the visual tab reflects it + // on switch. See parseSqlToBuilder for the exact fields that round-trip + // (filters and chartType are intentionally NOT parsed back from SQL). + const applyVisualEdit = (updater: (b: BuilderState) => BuilderState) => { + setBuilder((b) => { + const next = updater(b) + return { + ...next, + rawMode: false, + rawSql: composeSql({ ...next, rawMode: false }), + } + }) + } - const switchTab = (next: EditorTab) => { - setTab(next) - if (next === 'sql') { - setBuilder((b) => ({ - ...b, - rawMode: true, - rawSql: b.rawSql && b.rawSql.trim() ? b.rawSql : composedSql, - })) - } else { - setBuilder((b) => ({ ...b, rawMode: false })) - } + const applySqlEdit = (nextSql: string) => { + setBuilder((b) => { + const patch = parseSqlToBuilder(nextSql) ?? {} + return { ...b, ...patch, rawSql: nextSql, rawMode: true } + }) } const setChartType = (chartType: ChartTypeId) => { - setBuilder((b) => ({ ...b, chartType })) + applyVisualEdit((b) => ({ ...b, chartType })) if (!builder.configTouched) { setOption(getChartTypeMeta(chartType).defaultConfig) } @@ -107,7 +132,7 @@ export function VisualizationEditor({ initial, initialChartType }: Visualization const { createVisualization, updateVisualization } = useVisualizationMutations() const busy = createVisualization.isPending || updateVisualization.isPending - const sqlForSave = builder.rawMode ? (builder.rawSql ?? '').trim() : composedSql.trim() + const sqlForSave = (builder.rawSql ?? '').trim() const ready = name.trim().length > 0 && @@ -218,7 +243,7 @@ export function VisualizationEditor({ initial, initialChartType }: Visualization
- +
@@ -230,12 +255,14 @@ export function VisualizationEditor({ initial, initialChartType }: Visualization fieldsLoading={fieldsLoading} showMetric={showMetric} showDimension={showDimension} - onBuilderChange={setBuilder} + onBuilderChange={applyVisualEdit} /> ) : ( setBuilder((b) => ({ ...b, rawSql: next }))} + onChange={applySqlEdit} + fields={fields} + patterns={patternList} /> )}
@@ -352,7 +379,7 @@ function VisualTab({ fieldsLoading: boolean showMetric: boolean showDimension: boolean - onBuilderChange: React.Dispatch> + onBuilderChange: (updater: (b: BuilderState) => BuilderState) => void }) { const { t } = useTranslation() return ( @@ -433,22 +460,29 @@ function VisualTab({ function SqlTab({ rawSql, onChange, + fields, + patterns, }: { rawSql: string onChange: (next: string) => void + fields: IndexProperty[] + patterns: { pattern: string }[] }) { const { t } = useTranslation() return (
{t('dashboards.editor.tabs.sql')} -