From 6d54f88092258a6cba18dfd5114a18476f2254a2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 10:59:04 -0700 Subject: [PATCH 1/5] feat(dashboards): embed live dashboard panels in markdown --- .../rich-markdown-editor/code-block.tsx | 49 +-- .../markdown-streaming-context.ts | 4 + .../rich-markdown-editor.css | 6 + .../rich-markdown-editor.tsx | 11 +- .../rich-markdown-field.tsx | 21 +- .../slash-command/commands.ts | 39 +++ apps/sim/components/charts/echarts-view.tsx | 24 +- .../components/charts/time-series-chart.tsx | 5 +- .../dashboards/dashboard-controls.tsx | 40 +-- .../components/dashboards/dashboard-embed.tsx | 155 ++++++++++ .../dashboards/dashboard-layout.tsx | 3 + .../components/dashboards/dashboard-panel.tsx | 30 +- .../dashboards/dashboard-preview.tsx | 124 ++------ .../dashboards/use-dashboard-time.ts | 139 +++++++++ apps/sim/lib/charts/annotations.test.ts | 100 ++++++ apps/sim/lib/charts/annotations.ts | 161 ++++++++++ apps/sim/lib/charts/summary.ts | 2 + apps/sim/lib/charts/theme.ts | 18 ++ apps/sim/lib/dashboards/spec.test.ts | 91 +++++- apps/sim/lib/dashboards/spec.ts | 285 ++++++++++++++---- apps/sim/lib/dashboards/time.ts | 48 ++- 21 files changed, 1117 insertions(+), 238 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context.ts create mode 100644 apps/sim/components/dashboards/dashboard-embed.tsx create mode 100644 apps/sim/components/dashboards/use-dashboard-time.ts create mode 100644 apps/sim/lib/charts/annotations.test.ts create mode 100644 apps/sim/lib/charts/annotations.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index b5a1d64d259..25c805895d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { chipVariants, cn, @@ -11,6 +11,9 @@ import { import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { DashboardEmbed } from '@/components/dashboards/dashboard-embed' +import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec' +import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' import { MarkdownCodeBlock } from './code-block-schema' import { detectLanguage } from './detect-language' @@ -51,7 +54,8 @@ const CONTROL_CLASS = * whenever the cursor is outside it (and always in read-only), and as editable source while the * cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `
` stays mounted
  * (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
- * ordinary code block, so markdown round-trips unchanged.
+ * ordinary code block, so markdown round-trips unchanged. A ```dashboard fence renders live
+ * dashboard panels the same way.
  */
 function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
   const [wrap, setWrap] = useState(false)
@@ -60,16 +64,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
   const [peekSource, setPeekSource] = useState(false)
   const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
   const editable = useEditorEditable(editor)
+  const isStreaming = useContext(MarkdownStreamingContext)
 
   const explicitLanguage = node.attrs.language as string | null
   const text = node.textContent
   const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
+  const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE
+  const isRendered = isMermaid || isDashboard
 
   // Editable Mermaid shows source while the caret is focused inside the block and re-renders the
   // diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
   // focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
   useEffect(() => {
-    if (!isMermaid || !editable) {
+    if (!isRendered || !editable) {
       setEditingInline(false)
       return
     }
@@ -92,13 +99,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
       editor.off('focus', sync)
       editor.off('blur', sync)
     }
-  }, [editor, getPos, isMermaid, editable])
+  }, [editor, getPos, isRendered, editable])
 
   const showSource = editable ? editingInline : peekSource
-  const showDiagram = isMermaid && text.trim().length > 0 && !showSource
+  const showRendered = isRendered && text.trim().length > 0 && !showSource
 
-  // Skip language detection on the mermaid path — the picker/label never render there.
-  const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
+  // Skip language detection on rendered blocks — the picker/label never render there.
+  const language = explicitLanguage ?? (isRendered ? null : detectLanguage(text)) ?? PLAIN
   const label =
     LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
     explicitLanguage ??
@@ -133,10 +140,12 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
         )}
         contentEditable={false}
       >
-        {isMermaid && (
+        {isRendered && (
           
         )}
-        {!isMermaid &&
+        {!isRendered &&
           (editable ? (
             // Editable: a language picker. Read-only: a static label — selecting a language calls
             // updateAttributes, which would mutate a doc that must not change.
@@ -180,7 +189,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
               {label}
             
           ))}
-        {!isMermaid && editable && (
+        {!isRendered && editable && (
           
       
-      
+      
          as='code' />
       
- {showDiagram && ( - // Clicking the diagram selects the whole node (same selection ring as an image/code block) - // instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret, - // which would otherwise flip to source. Editing is an explicit Show source / blur action. + {showRendered && ( + // Select the whole node instead of placing a caret, which would flip the block to source.
{ + const target = event.target + if (!(target instanceof Element) || !event.currentTarget.contains(target)) return + if (target.closest('button, input')) return event.preventDefault() const pos = typeof getPos === 'function' ? getPos() : null if (typeof pos === 'number') editor.commands.setNodeSelection(pos) }} > - + {isDashboard ? ( + + ) : ( + + )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context.ts new file mode 100644 index 00000000000..6600bb4fc74 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react' + +/** True while agent output is streaming into the editor, for node views that render its content. */ +export const MarkdownStreamingContext = createContext(false) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index b6a13b7e214..7fd223915c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -391,6 +391,12 @@ margin: 1rem 0; } +/* A dashboard results table sizes its own columns and sits flush in its panel. */ +.rich-markdown-nodes .dashboard-embed table { + table-layout: auto; + margin: 0; +} + .rich-markdown-nodes th > p, .rich-markdown-nodes td > p { margin: 0; diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 7fde7d1a97d..ccf0adbc76b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -68,6 +68,7 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' +import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention' import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu' import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card' @@ -1446,10 +1447,12 @@ export function LoadedRichMarkdownEditor({ workspaceId={workspaceId} /> )} - + + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 2a568884595..40d4d1ea85c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -27,6 +27,7 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' +import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention' import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu' import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card' @@ -444,15 +445,17 @@ function LoadedRichMarkdownField({ }} /> )} - + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index 000a434eba4..c7bef46448a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -1,5 +1,6 @@ import type { ComponentType, SVGProps } from 'react' import { + ChartColumn, Code, Heading1, Heading2, @@ -14,6 +15,26 @@ import { TextQuote, } from '@sim/emcn/icons' import type { Editor, Range } from '@tiptap/core' +import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec' + +/** A time-series starter; the table id is left for the author to fill in. */ +const DASHBOARD_EMBED_STARTER = `title: Rows over time +time: 7d +source: + tableId: # table id +blocks: + - chart: Rows per day + source: + groupBy: [createdAt] + bucket: day + aggregate: + rows: { op: count } + option: + xAxis: { type: time } + yAxis: { type: value } + series: + - { type: line, encode: { x: createdAt, y: rows } } +` export interface SlashCommandContext { editor: Editor @@ -121,6 +142,24 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ shortcut: '⌘⌥C', run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, + { + title: 'Chart', + group: 'Blocks', + icon: ChartColumn, + aliases: ['dashboard', 'graph', 'metric', 'live data'], + run: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: 'codeBlock', + attrs: { language: DASHBOARD_EMBED_LANGUAGE }, + content: [{ type: 'text', text: DASHBOARD_EMBED_STARTER }], + }) + .setTextSelection(range.from + 1) + .run(), + }, { title: 'Table', group: 'Blocks', diff --git a/apps/sim/components/charts/echarts-view.tsx b/apps/sim/components/charts/echarts-view.tsx index 041ef6c5945..72df42611cf 100644 --- a/apps/sim/components/charts/echarts-view.tsx +++ b/apps/sim/components/charts/echarts-view.tsx @@ -5,9 +5,14 @@ import { cn } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import type { EChartsType } from 'echarts' import { useTheme } from 'next-themes' +import { applyChartAnnotations, type ChartAnnotations } from '@/lib/charts/annotations' import { installBarRowHighlight } from '@/lib/charts/bar-row-highlight' import { chartSummaryExtension } from '@/lib/charts/summary' -import { applyChartTooltipDefaults, readEmcnChartTheme } from '@/lib/charts/theme' +import { + applyChartTooltipDefaults, + readChartTonePalette, + readEmcnChartTheme, +} from '@/lib/charts/theme' interface EChartsViewProps { option: Record @@ -15,6 +20,8 @@ interface EChartsViewProps { className?: string createController?: (chart: EChartsType) => EChartsController revision?: string + /** Highlights and thresholds, coloured from the theme at render time. */ + annotations?: ChartAnnotations } export interface EChartsController { @@ -33,6 +40,7 @@ export function EChartsView({ className, createController, revision, + annotations, }: EChartsViewProps) { const containerRef = useRef(null) const chartRef = useRef(null) @@ -40,14 +48,24 @@ export function EChartsView({ const rowHighlightRef = useRef<(() => void) | null>(null) const { resolvedTheme } = useTheme() const [status, setStatus] = useState<{ theme: string | undefined; error?: string } | null>(null) - const optionKey = JSON.stringify(option) + const optionKey = JSON.stringify({ option, annotations }) const applyOption = useEffectEvent((chart: EChartsType, nextOption: string) => { try { controllerRef.current?.dispose() controllerRef.current = null const controller = createController?.(chart) controllerRef.current = controller ?? null - const parsed = applyChartTooltipDefaults(JSON.parse(nextOption)) + const next: { option: Record; annotations?: ChartAnnotations } = + JSON.parse(nextOption) + const parsed = applyChartTooltipDefaults( + next.annotations + ? applyChartAnnotations( + next.option, + next.annotations, + readChartTonePalette(chart.getDom()) + ) + : next.option + ) chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true }) controller?.afterUpdate() rowHighlightRef.current?.() diff --git a/apps/sim/components/charts/time-series-chart.tsx b/apps/sim/components/charts/time-series-chart.tsx index 8554a8f4df0..9b1755e78cf 100644 --- a/apps/sim/components/charts/time-series-chart.tsx +++ b/apps/sim/components/charts/time-series-chart.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react' import { cn, scrollFadeAttributes, scrollFadeXClass, useScrollEdges } from '@sim/emcn' import { EChartsView } from '@/components/charts/echarts-view' +import type { ChartAnnotations } from '@/lib/charts/annotations' import { bindTimeSeriesInteractions, type ChartReadout, @@ -13,9 +14,10 @@ import { dashboardTimeLabel } from '@/lib/dashboards/time' interface TimeSeriesChartProps extends Omit { label: string option: Record + annotations?: ChartAnnotations } -export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartProps) { +export function TimeSeriesChart({ label, option, annotations, ...config }: TimeSeriesChartProps) { const valuesRef = useRef(null) const edges = useScrollEdges(valuesRef, { axis: 'x' }) const [readout, setReadout] = useState(null) @@ -58,6 +60,7 @@ export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartPro void onRefresh: () => void } -const RANGE_OPTIONS = [ - { value: '1h', label: 'Last hour' }, - { value: '24h', label: 'Last 24 hours' }, - { value: '7d', label: 'Last 7 days' }, - { value: '30d', label: 'Last 30 days' }, - { value: '90d', label: 'Last 90 days' }, -] as const +const RANGE_OPTIONS = DASHBOARD_RANGES.map((value) => ({ + value, + label: DASHBOARD_RANGE_LABELS[value], +})) export function DashboardControls({ period, @@ -54,26 +56,14 @@ export function DashboardControls({ new Intl.DateTimeFormat('en-US', { timeZone: localTimeZone, timeZoneName: 'short' }) .formatToParts(new Date(range.to)) .find((part) => part.type === 'timeZoneName')?.value ?? localTimeZone - const from = new Date(range.from) - const to = new Date(Date.parse(range.to) - 1) - const fromLocal = zonedWallClock(from, timeZone) - const toLocal = zonedWallClock(to, timeZone) - const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10) - const dates = new Intl.DateTimeFormat('en-US', { - timeZone, - month: 'short', - day: 'numeric', - year: fromLocal.slice(0, 4) === toLocal.slice(0, 4) ? undefined : 'numeric', - hour: sameDay ? '2-digit' : undefined, - minute: sameDay ? '2-digit' : undefined, - hourCycle: 'h23', - }) + const fromLocal = zonedWallClock(new Date(range.from), timeZone) + const toLocal = zonedWallClock(new Date(Date.parse(range.to) - 1), timeZone) const label = period === 'custom' ? rangeError ? 'Custom: choose range' - : `Custom: ${dates.formatRange(from, to)}` - : RANGE_OPTIONS.find((option) => option.value === period)!.label + : `Custom: ${dashboardRangeText(range, timeZone)}` + : DASHBOARD_RANGE_LABELS[period] return (
+ {children} +
+ ) +} + +/** + * A ```dashboard fence rendered as live panels. Data is read with the viewer's own session, so + * the fence only renders inside its workspace; a public share shows a notice instead of querying. + */ +export function DashboardEmbed({ source, isStreaming }: DashboardEmbedProps) { + const params = useParams() + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : null + if (isStreaming) return The chart loads when Sim finishes writing. + if (!workspaceId) + return Open this document in its workspace to see live data. + return ( + + + + ) +} + +function LiveDashboardEmbed({ source, workspaceId }: LiveDashboardEmbedProps) { + const parsed = useMemo(() => parseDashboardEmbed(source), [source]) + if (!parsed.spec) return {parsed.error} + return +} + +function EmbedView({ spec, workspaceId }: EmbedViewProps) { + const embedId = useId() + const [state, setState] = useState({ + range: null, + from: null, + to: null, + zone: 'local', + }) + const time = useDashboardTime({ + state, + setState: (update) => setState((current) => ({ ...current, ...update })), + time: spec.time, + workspaceId, + tableIds: dashboardTableIds(spec.blocks, spec.source), + live: true, + }) + const zoomed = state.range !== null + const { period } = time.controls + const caption = + period === 'custom' + ? dashboardRangeText(time.range, time.interactions.timeZone) + : DASHBOARD_RANGE_LABELS[period] + return ( +
+ {spec.title && ( +

{spec.title}

+ )} +

+ + + {caption} + + + {dashboardTimeLabel(time.range.from, time.interactions.timeZone)} –{' '} + {dashboardTimeLabel(time.range.to, time.interactions.timeZone)} + + + {zoomed && ( + <> + {' · '} + + + )} +

+ {time.rangeError ? ( +

+ {time.rangeError} +

+ ) : ( + + + + )} +
+ ) +} diff --git a/apps/sim/components/dashboards/dashboard-layout.tsx b/apps/sim/components/dashboards/dashboard-layout.tsx index f3c876c97bb..e60470aae99 100644 --- a/apps/sim/components/dashboards/dashboard-layout.tsx +++ b/apps/sim/components/dashboards/dashboard-layout.tsx @@ -4,6 +4,7 @@ import { cn, TabStrip } from '@sim/emcn' import { useQueryState } from 'nuqs' import { DashboardPanel } from '@/components/dashboards/dashboard-panel' import { dashboardTabParser, dashboardUrlOptions } from '@/components/dashboards/search-params' +import type { ChartHighlight } from '@/lib/charts/annotations' import type { DashboardBlock, DashboardSource, DashboardTabs } from '@/lib/dashboards/spec' import type { DashboardTimeRange } from '@/lib/dashboards/time' @@ -16,6 +17,8 @@ interface DashboardLayoutProps { now: number path?: string startIndex?: number + embedded?: boolean + highlights?: ChartHighlight[] } interface DashboardTabsProps extends Omit { block: DashboardTabs diff --git a/apps/sim/components/dashboards/dashboard-panel.tsx b/apps/sim/components/dashboards/dashboard-panel.tsx index 2a35bf9b98c..62d1341ed5a 100644 --- a/apps/sim/components/dashboards/dashboard-panel.tsx +++ b/apps/sim/components/dashboards/dashboard-panel.tsx @@ -15,6 +15,7 @@ import { EChartsView } from '@/components/charts/echarts-view' import { TimeSeriesChart } from '@/components/charts/time-series-chart' import { useDashboardInteractions } from '@/components/dashboards/dashboard-interactions' import type { QueryTableAnalyticsResponse } from '@/lib/api/contracts/table-analytics' +import type { ChartAnnotations, ChartHighlight } from '@/lib/charts/annotations' import { buildChartRenderOption, horizontalBarChartHeight } from '@/lib/charts/option' import { isTimeSeriesOption } from '@/lib/charts/time-series' import { @@ -36,6 +37,10 @@ interface DashboardPanelProps { workspaceId: string range: DashboardTimeRange now: number + /** Inside a markdown document, where headings belong to the document outline. */ + embedded?: boolean + /** Drawn on time-series charts only. */ + highlights?: ChartHighlight[] } function displayValue(value: string | number | boolean | null | undefined): string { @@ -83,7 +88,15 @@ function ResultsTable({ data, timeField, timeZone }: ResultsTableProps) { ) } -export function DashboardPanel({ block, defaults, workspaceId, range, now }: DashboardPanelProps) { +export function DashboardPanel({ + block, + defaults, + workspaceId, + range, + now, + embedded = false, + highlights, +}: DashboardPanelProps) { const interactions = useDashboardInteractions() const source = resolveDashboardSource(defaults, block.source) const panelRange = source.range ? relativeDashboardRange(source.range, now) : range @@ -109,6 +122,10 @@ export function DashboardPanel({ block, defaults, workspaceId, range, now }: Das rows: data.rows, }) : null + const annotations: ChartAnnotations | undefined = + 'chart' in block && ((timeSeries && highlights) || block.thresholds) + ? { highlights: timeSeries ? highlights : undefined, thresholds: block.thresholds } + : undefined const barChartHeight = 'chart' in block ? horizontalBarChartHeight(block.option, data?.rows.length ?? 10) : null const times = @@ -144,6 +161,15 @@ export function DashboardPanel({ block, defaults, workspaceId, range, now }: Das unit={!query.isError && metric != null ? block.unit : undefined} loading={query.isPending} /> + ) : embedded ? ( +

+ {title} +

) : (

)} {data.rows.length === 0 && ( diff --git a/apps/sim/components/dashboards/dashboard-preview.tsx b/apps/sim/components/dashboards/dashboard-preview.tsx index d2337d1b9bc..1449853d6b9 100644 --- a/apps/sim/components/dashboards/dashboard-preview.tsx +++ b/apps/sim/components/dashboards/dashboard-preview.tsx @@ -1,9 +1,6 @@ 'use client' -import { Suspense, useMemo, useRef, useState } from 'react' -import { getErrorMessage } from '@sim/utils/errors' -import { toRecord } from '@sim/utils/object' -import { useIsFetching, useQueryClient } from '@tanstack/react-query' +import { Suspense, useMemo } from 'react' import { useQueryStates } from 'nuqs' import { DashboardControls } from '@/components/dashboards/dashboard-controls' import { DashboardInteractionContext } from '@/components/dashboards/dashboard-interactions' @@ -13,21 +10,8 @@ import { dashboardUrlKeys, dashboardUrlOptions, } from '@/components/dashboards/search-params' -import { getBrowserTimezone } from '@/lib/core/utils/timezone' -import { - type DashboardBlock, - type DashboardSpec, - parseDashboardSpec, - resolveDashboardSource, -} from '@/lib/dashboards/spec' -import { - type DashboardTimeRange, - dashboardRangeFromCalendar, - parseDashboardCustomRange, - relativeDashboardRange, -} from '@/lib/dashboards/time' -import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics' -import { createDashboardCursorStore, type DashboardCursorStore } from '@/stores/dashboards/cursor' +import { useDashboardTime } from '@/components/dashboards/use-dashboard-time' +import { type DashboardSpec, dashboardTableIds, parseDashboardSpec } from '@/lib/dashboards/spec' interface DashboardPreviewProps { content: string @@ -42,60 +26,21 @@ interface DashboardViewProps { dashboardId: string } -function dashboardTableIds(spec: DashboardSpec): Set { - const ids = new Set() - const visit = (blocks: DashboardBlock[]) => { - for (const block of blocks) { - if ('row' in block) visit(block.row) - else if ('tabs' in block) Object.values(block.tabs).forEach(visit) - else if (!('text' in block)) - ids.add(resolveDashboardSource(spec.source, block.source).tableId) - } - } - visit(spec.blocks) - return ids -} - function DashboardView({ spec, workspaceId, dashboardId }: DashboardViewProps) { - const cursorStoreRef = useRef(null) - cursorStoreRef.current ??= createDashboardCursorStore() const [state, setState] = useQueryStates(dashboardParsers, { ...dashboardUrlOptions, urlKeys: dashboardUrlKeys(dashboardId), }) - const [now, setNow] = useState(() => Date.now()) - const [inputError, setInputError] = useState(null) - const queryClient = useQueryClient() - const tableIds = dashboardTableIds(spec) - const queryFilter = { - queryKey: tableAnalyticsKeys.queries(), - predicate: (query: { queryKey: readonly unknown[] }) => { - return ( - toRecord(query.queryKey[3]).workspaceId === workspaceId && - tableIds.has(String(query.queryKey[2])) - ) - }, - } - const isFetching = useIsFetching(queryFilter) > 0 - const localTimeZone = getBrowserTimezone() - const timeZone = state.zone === 'local' ? localTimeZone : 'UTC' - const period = state.range ?? spec.time ?? '7d' + const time = useDashboardTime({ + state, + setState: (update) => void setState(update), + time: spec.time, + workspaceId, + tableIds: dashboardTableIds(spec.blocks, spec.source), + }) const firstBlock = spec.blocks[0] const description = firstBlock && 'text' in firstBlock ? firstBlock.text : null const startIndex = description === null ? 0 : 1 - let range = relativeDashboardRange(period === 'custom' ? '7d' : period, now) - let rangeError: string | null = null - if (period === 'custom') { - try { - range = parseDashboardCustomRange(state.from ?? '', state.to ?? '') - } catch (error) { - rangeError = getErrorMessage(error, 'Choose a custom range') - } - } - const onZoom = (selected: DashboardTimeRange) => { - setInputError(null) - void setState({ range: 'custom', ...selected }) - } return (
@@ -109,59 +54,28 @@ function DashboardView({ spec, workspaceId, dashboardId }: DashboardViewProps) {

)}
- { - setInputError(null) - cursorStoreRef.current?.getState().clearCursor() - setNow(Date.now()) - void setState({ range: value, from: null, to: null }) - }} - onCalendarChange={(from, to) => { - try { - const selected = dashboardRangeFromCalendar(from, to, timeZone) - setInputError(null) - void setState({ range: 'custom', ...selected }) - return true - } catch (error) { - setInputError(getErrorMessage(error, 'Invalid range')) - return false - } - }} - onRefresh={() => { - setNow(Date.now()) - cursorStoreRef.current?.getState().clearCursor() - if (period === 'custom') void queryClient.invalidateQueries(queryFilter) - }} - onZoneChange={(zone) => void setState({ zone })} - /> + - {inputError && ( + {time.inputError && (

- {inputError} + {time.inputError}

)} - {rangeError ? ( + {time.rangeError ? (

- {rangeError} + {time.rangeError}

) : ( - + )} diff --git a/apps/sim/components/dashboards/use-dashboard-time.ts b/apps/sim/components/dashboards/use-dashboard-time.ts new file mode 100644 index 00000000000..a032a7f952b --- /dev/null +++ b/apps/sim/components/dashboards/use-dashboard-time.ts @@ -0,0 +1,139 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' +import { useIsFetching, useQueryClient } from '@tanstack/react-query' +import { getBrowserTimezone } from '@/lib/core/utils/timezone' +import type { DashboardRange, DashboardTime } from '@/lib/dashboards/spec' +import { + type DashboardTimeRange, + dashboardRangeFromCalendar, + parseDashboardCustomRange, + relativeDashboardRange, +} from '@/lib/dashboards/time' +import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics' +import { createDashboardCursorStore, type DashboardCursorStore } from '@/stores/dashboards/cursor' + +/** How often a relative range advances its end to the present while the page is visible. */ +const LIVE_TICK_MS = 60_000 + +export interface DashboardTimeState { + range: DashboardRange | 'custom' | null + from: string | null + to: string | null + zone: 'utc' | 'local' +} + +interface UseDashboardTimeProps { + state: DashboardTimeState + setState: (update: Partial) => void + /** The authored default; the viewer's selection in `state` wins over it. */ + time: DashboardTime | undefined + workspaceId: string + tableIds: ReadonlySet + /** Advances relative ranges to the present every minute, so the view keeps up with new rows. */ + live?: boolean +} + +/** + * Resolves the active time range and wires the range controls, zoom, refresh, and cursor sync. + * The caller owns where the selection lives: the URL on the dashboard page, local state in an + * embedded fence. + */ +export function useDashboardTime({ + state, + setState, + time, + workspaceId, + tableIds, + live = false, +}: UseDashboardTimeProps) { + const cursorStoreRef = useRef(null) + cursorStoreRef.current ??= createDashboardCursorStore() + const cursorStore = cursorStoreRef.current + const queryClient = useQueryClient() + const [now, setNow] = useState(() => Date.now()) + const [inputError, setInputError] = useState(null) + const queryFilter = { + queryKey: tableAnalyticsKeys.queries(), + predicate: (query: { queryKey: readonly unknown[] }) => + toRecord(query.queryKey[3]).workspaceId === workspaceId && + tableIds.has(String(query.queryKey[2])), + } + const isFetching = useIsFetching(queryFilter) > 0 + const timeZone = state.zone === 'local' ? getBrowserTimezone() : 'UTC' + const fixed = typeof time === 'object' ? time : null + const preset = typeof time === 'string' ? time : '7d' + const period = state.range ?? (fixed ? 'custom' : preset) + + let range: DashboardTimeRange = relativeDashboardRange(period === 'custom' ? '7d' : period, now) + let rangeError: string | null = null + if (period === 'custom') { + const custom = state.range === 'custom' ? { from: state.from ?? '', to: state.to ?? '' } : fixed + try { + if (!custom) throw new Error('Choose a custom range') + range = parseDashboardCustomRange(custom.from, custom.to) + } catch (error) { + rangeError = getErrorMessage(error, 'Choose a custom range') + } + } + + const relative = period !== 'custom' + useEffect(() => { + if (!live || !relative) return + const tick = () => { + if (document.visibilityState === 'visible') setNow(Date.now()) + } + const interval = setInterval(tick, LIVE_TICK_MS) + document.addEventListener('visibilitychange', tick) + return () => { + clearInterval(interval) + document.removeEventListener('visibilitychange', tick) + } + }, [live, relative]) + + const onZoom = (selected: DashboardTimeRange) => { + setInputError(null) + setState({ range: 'custom', ...selected }) + } + + return { + range, + now, + rangeError, + inputError, + interactions: { cursorStore, timeZone, onZoom }, + controls: { + period, + range, + timeZone, + zone: state.zone, + isFetching, + rangeError: rangeError !== null, + onPeriodChange: (value: DashboardRange | 'custom') => { + setInputError(null) + cursorStore.getState().clearCursor() + setNow(Date.now()) + setState({ range: value, from: null, to: null }) + }, + onCalendarChange: (from: string, to: string) => { + try { + const selected = dashboardRangeFromCalendar(from, to, timeZone) + setInputError(null) + setState({ range: 'custom', ...selected }) + return true + } catch (error) { + setInputError(getErrorMessage(error, 'Invalid range')) + return false + } + }, + onRefresh: () => { + setNow(Date.now()) + cursorStore.getState().clearCursor() + if (period === 'custom') void queryClient.invalidateQueries(queryFilter) + }, + onZoneChange: (zone: 'utc' | 'local') => setState({ zone }), + }, + } +} diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts new file mode 100644 index 00000000000..89b05dc867f --- /dev/null +++ b/apps/sim/lib/charts/annotations.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { applyChartAnnotations, CHART_ANNOTATION_SERIES_ID } from '@/lib/charts/annotations' + +const palette = { tones: { neutral: 'grey', error: 'red', info: 'blue' } } +const timeSeries = { + xAxis: { type: 'time' }, + yAxis: { type: 'value' }, + series: [{ type: 'line' }, { type: 'line' }], +} +type Series = Record + +describe('chart annotations', () => { + it('draws marks under the first series and their labels on a series above', () => { + const option = applyChartAnnotations( + timeSeries, + { + highlights: [ + { + from: '2026-08-21T00:00:00.000Z', + to: '2026-09-07T00:00:00.000Z', + label: 'Outage', + tone: 'error', + }, + { at: '2026-09-10T14:00:00.000Z', tone: 'info' }, + ], + thresholds: [{ value: 50, label: 'SLO' }], + }, + palette + ) + const [first, second, labels] = option.series as Series[] + expect(first.markArea).toMatchObject({ + data: [ + [ + { + xAxis: '2026-08-21T00:00:00.000Z', + itemStyle: { color: 'red' }, + label: { show: false }, + }, + { xAxis: '2026-09-07T00:00:00.000Z' }, + ], + ], + }) + expect(first.markLine).toMatchObject({ + z: 1, + data: [ + { xAxis: '2026-09-10T14:00:00.000Z', lineStyle: { color: 'blue', opacity: 1 } }, + { yAxis: 50, lineStyle: { color: 'grey', opacity: 1 }, label: { show: false } }, + ], + }) + expect(second).toEqual({ type: 'line' }) + expect(labels).toMatchObject({ + id: CHART_ANNOTATION_SERIES_ID, + data: [], + z: 3, + markArea: { + data: [ + [{ name: 'Outage', itemStyle: { opacity: 0 }, label: { show: true, color: 'red' } }, {}], + ], + }, + markLine: { + data: [{ yAxis: 50, name: 'SLO', lineStyle: { opacity: 0 }, label: { color: 'grey' } }], + }, + }) + }) + + it('labels vertical lines at the visual top of an inverted bar axis', () => { + const option = applyChartAnnotations( + { + xAxis: { type: 'value' }, + yAxis: { type: 'category', inverse: true }, + series: [{ type: 'bar' }], + }, + { thresholds: [{ value: 100, label: 'Needs tuning' }] }, + palette + ) + const [, labels] = option.series as Series[] + expect(labels.markLine).toMatchObject({ + data: [{ xAxis: 100, label: { position: 'start' } }], + }) + }) + + it('adds no label series when nothing is labelled', () => { + const option = applyChartAnnotations(timeSeries, { thresholds: [{ value: 1 }] }, palette) + expect(option.series).toHaveLength(2) + }) + + it('leaves an option without annotations untouched', () => { + expect(applyChartAnnotations(timeSeries, {}, palette)).toBe(timeSeries) + }) + + it('refuses to merge with hand-written mark components', () => { + expect(() => + applyChartAnnotations( + { ...timeSeries, series: [{ type: 'line', markLine: { data: [] } }] }, + { thresholds: [{ value: 1 }] }, + palette + ) + ).toThrow('Use highlights and thresholds instead of markArea or markLine on the series') + }) +}) diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts new file mode 100644 index 00000000000..fd8cdc5d65b --- /dev/null +++ b/apps/sim/lib/charts/annotations.ts @@ -0,0 +1,161 @@ +import { toRecord } from '@sim/utils/object' + +export const CHART_TONES = ['neutral', 'error', 'info'] as const +export type ChartTone = (typeof CHART_TONES)[number] +/** Tone colours for bands, lines, and their labels; neutral matches the axis labels. */ +export interface ChartTonePalette { + tones: Record +} + +/** A shaded stretch of time, or a single instant drawn as a vertical line. */ +export type ChartHighlight = + | { from: string; to: string; label?: string; tone?: ChartTone } + | { at: string; label?: string; tone?: ChartTone } + +/** A horizontal (or, on horizontal bars, vertical) reference line on the value axis. */ +export interface ChartThreshold { + value: number + label?: string + tone?: ChartTone +} + +export interface ChartAnnotations { + /** Applied only to charts with a time x-axis. */ + highlights?: readonly ChartHighlight[] + thresholds?: readonly ChartThreshold[] +} + +const BAND_OPACITY = 0.08 + +function firstAxis(axis: unknown): Record { + return toRecord(Array.isArray(axis) ? axis[0] : axis) +} + +/** The axis a threshold is measured on; ECharts defaults an unspecified yAxis to a value axis. */ +export function valueAxisKey(option: Record): 'xAxis' | 'yAxis' { + const y = firstAxis(option.yAxis) + if (y.type === undefined || y.type === 'value' || y.type === 'log') return 'yAxis' + const x = firstAxis(option.xAxis) + if (x.type === 'value' || x.type === 'log') return 'xAxis' + throw new Error('Thresholds require a value axis') +} + +/** + * A vertical line runs from the y-axis start to its end, so its visual top is the start when the + * y axis is inverted (as horizontal bar charts usually are). + */ +function verticalTop(option: Record): 'start' | 'end' { + return firstAxis(option.yAxis).inverse === true ? 'start' : 'end' +} + +/** + * Id of the empty series that carries annotation labels. ECharts draws a mark's label at the mark's + * depth, so the marks sit under the data and their labels ride on this series above it. + */ +export const CHART_ANNOTATION_SERIES_ID = '\u0000annotations' + +interface AnnotationMark { + label?: string + color: string + position: string +} + +/** Vertical lines label upright past their top end; ECharts rotates `inside*` labels. */ +function markLabel(mark: AnnotationMark) { + return { show: true, formatter: '{b}', color: mark.color, fontSize: 12, position: mark.position } +} + +/** + * Draws highlights and thresholds as `markArea` and `markLine` under the first series, with their + * labels on a silent series drawn above every series, coloured from the theme palette. + */ +export function applyChartAnnotations( + option: Record, + annotations: ChartAnnotations, + palette: ChartTonePalette +): Record { + const highlights = annotations.highlights ?? [] + const thresholds = annotations.thresholds ?? [] + if (highlights.length === 0 && thresholds.length === 0) return option + const series = Array.isArray(option.series) ? option.series : [option.series] + if (series[0] === undefined) throw new Error('Highlights and thresholds require a series') + const first = toRecord(series[0]) + if (first.markArea !== undefined || first.markLine !== undefined) + throw new Error('Use highlights and thresholds instead of markArea or markLine on the series') + + const bands: Array = [] + const lines: Array }> = [] + for (const highlight of highlights) { + const color = palette.tones[highlight.tone ?? 'neutral'] + if ('from' in highlight) bands.push({ ...highlight, color, position: 'insideTop' }) + else + lines.push({ + label: highlight.label, + color, + position: verticalTop(option), + coord: { xAxis: highlight.at }, + }) + } + for (const threshold of thresholds) { + const axis = valueAxisKey(option) + lines.push({ + label: threshold.label, + color: palette.tones[threshold.tone ?? 'neutral'], + position: axis === 'xAxis' ? verticalTop(option) : 'insideEndTop', + coord: { [axis]: threshold.value }, + }) + } + + const band = (mark: (typeof bands)[number], visible: boolean) => [ + { + name: mark.label ?? '', + xAxis: mark.from, + itemStyle: { color: mark.color, opacity: visible ? BAND_OPACITY : 0 }, + label: visible ? { show: false } : markLabel(mark), + }, + { xAxis: mark.to }, + ] + const line = (mark: (typeof lines)[number], visible: boolean) => ({ + name: mark.label ?? '', + ...mark.coord, + lineStyle: { color: mark.color, type: 'dashed', width: 1, opacity: visible ? 1 : 0 }, + label: visible ? { show: false } : markLabel(mark), + }) + const markArea = (marks: typeof bands, visible: boolean) => + marks.length > 0 && { markArea: { silent: true, data: marks.map((m) => band(m, visible)) } } + const markLine = (marks: typeof lines, visible: boolean) => + marks.length > 0 && { + markLine: { + silent: true, + symbol: ['none', 'none'], + z: 1, + data: marks.map((m) => line(m, visible)), + }, + } + + const labelled = (marks: T[]) => marks.filter((mark) => mark.label) + const labelSeries = [ + ...(labelled(bands).length + labelled(lines).length > 0 + ? [ + { + id: CHART_ANNOTATION_SERIES_ID, + type: 'line', + data: [], + silent: true, + z: 3, + tooltip: { show: false }, + ...markArea(labelled(bands), false), + ...markLine(labelled(lines), false), + }, + ] + : []), + ] + return { + ...option, + series: [ + { ...first, ...markArea(bands, true), ...markLine(lines, true) }, + ...series.slice(1), + ...labelSeries, + ], + } +} diff --git a/apps/sim/lib/charts/summary.ts b/apps/sim/lib/charts/summary.ts index 0744eb5bf91..1157bbb358f 100644 --- a/apps/sim/lib/charts/summary.ts +++ b/apps/sim/lib/charts/summary.ts @@ -1,5 +1,6 @@ import { toRecord } from '@sim/utils/object' import type { EChartsType, registerUpdateLifecycle } from 'echarts' +import { CHART_ANNOTATION_SERIES_ID } from '@/lib/charts/annotations' import type { ChartReadout, ChartReadoutValue } from '@/lib/charts/time-series' type ChartModel = Parameters>[1]>[0] @@ -43,6 +44,7 @@ export function summarizeChart(model: ChartModel, labels: Record const values: ChartReadoutValue[] = [] model.eachSeries((series) => { if (series.get('coordinateSystem') !== 'cartesian2d') return + if (series.id === CHART_ANNOTATION_SERIES_ID) return const data = series.getRawData() const axis = model.getComponent('yAxis', Number(toRecord(series.option).yAxisIndex ?? 0)) const format = toRecord(toRecord(axis?.option).axisLabel).formatter diff --git a/apps/sim/lib/charts/theme.ts b/apps/sim/lib/charts/theme.ts index 86063bae94f..9846fb932d4 100644 --- a/apps/sim/lib/charts/theme.ts +++ b/apps/sim/lib/charts/theme.ts @@ -1,4 +1,5 @@ import { isRecordLike, toRecord } from '@sim/utils/object' +import type { ChartTonePalette } from '@/lib/charts/annotations' import { CHART_BAR_MAX_WIDTH, mapTooltipEntries } from '@/lib/charts/option' import { formatChartValue } from '@/lib/charts/summary' @@ -97,6 +98,23 @@ export function applyChartTooltipDefaults(option: Record) { return option } +/** Colours for highlights and thresholds; neutral matches the axis labels. */ +export function readChartTonePalette(element: HTMLElement): ChartTonePalette { + const styles = getComputedStyle(element) + const token = (name: string) => { + const value = styles.getPropertyValue(name).trim() + if (!value) throw new Error(`Missing chart theme token ${name}`) + return value + } + return { + tones: { + neutral: token('--text-tertiary'), + error: token('--text-error'), + info: token('--brand-blue'), + }, + } +} + /** Canvas cannot resolve CSS variables; read the same tokens as EMCN at its own container. */ export function readEmcnChartTheme(element: HTMLElement): Record { const styles = getComputedStyle(element) diff --git a/apps/sim/lib/dashboards/spec.test.ts b/apps/sim/lib/dashboards/spec.test.ts index 2df96eb0eba..02885422068 100644 --- a/apps/sim/lib/dashboards/spec.test.ts +++ b/apps/sim/lib/dashboards/spec.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { parseDashboardSpec, resolveDashboardSource } from '@/lib/dashboards/spec' +import { + parseDashboardEmbed, + parseDashboardSpec, + resolveDashboardSource, +} from '@/lib/dashboards/spec' import { dashboardRangeFromCalendar, parseDashboardCustomRange, @@ -161,3 +165,88 @@ describe('dashboard parse errors', () => { ) }) }) + +describe('fixed time ranges', () => { + it('accepts fixed instants and rejects reversed bounds', () => { + expect( + parseDashboardSpec( + 'title: T\ntime: {from: 2026-09-20T00:00:00Z, to: 2026-09-27T00:00:00Z}\nsource: {tableId: tbl_1}\nblocks:\n - stat: Total\n source: {aggregate: {n: {op: count}}}\n' + ).spec?.time + ).toEqual({ from: '2026-09-20T00:00:00Z', to: '2026-09-27T00:00:00Z' }) + expect( + parseDashboardSpec( + 'title: T\ntime: {from: 2026-09-27T00:00:00Z, to: 2026-09-20T00:00:00Z}\nsource: {tableId: tbl_1}\nblocks:\n - stat: Total\n source: {aggregate: {n: {op: count}}}\n' + ).error + ).toContain('The start must be earlier than the end') + }) +}) + +describe('dashboard embeds', () => { + it('parses data blocks and rows without a title', () => { + const parsed = parseDashboardEmbed( + 'time: 24h\nsource: {tableId: tbl_1}\nblocks:\n - row:\n - stat: Total\n source: {aggregate: {n: {op: count}}}\n - table: Rows\n source: {columns: [status]}\n' + ) + expect(parsed.error).toBeUndefined() + expect(parsed.spec?.title).toBeUndefined() + }) + + it('rejects text and tabs, which the surrounding document provides', () => { + expect(parseDashboardEmbed('source: {tableId: tbl_1}\nblocks:\n - text: hi\n').error).toBe( + 'blocks.0: expected a block with one of stat, chart, table, row; unknown key "text"' + ) + expect( + parseDashboardEmbed( + 'source: {tableId: tbl_1}\nblocks:\n - tabs: {A: [{table: Rows, source: {columns: [id]}}]}\n' + ).error + ).toContain('unknown key "tabs"') + }) + + it('caps the number of embedded blocks', () => { + const row = ` - row:\n${' - stat: Total\n source: {aggregate: {n: {op: count}}}\n'.repeat(6)}` + expect(parseDashboardEmbed(`source: {tableId: tbl_1}\nblocks:\n${row}${row}`).error).toBe( + 'Dashboard exceeds 12 blocks' + ) + }) +}) + +describe('highlights and thresholds', () => { + const chart = + 'blocks:\n - chart: Weekly\n source: {groupBy: [createdAt], bucket: week, aggregate: {n: {op: count}}}\n option: {xAxis: {type: time}, yAxis: {type: value}, series: [{type: line}]}\n' + + it('normalizes highlight instants to UTC ISO', () => { + const parsed = parseDashboardEmbed( + `source: {tableId: tbl_1}\nhighlights:\n - {from: 2026-08-21T00:00, to: 2026-09-07T00:00:00Z, label: Outage}\n - {at: 2026-09-10T14:00:00Z, tone: info}\n${chart}` + ) + expect(parsed.error).toBeUndefined() + expect(parsed.spec?.highlights).toEqual([ + { from: '2026-08-21T00:00:00.000Z', to: '2026-09-07T00:00:00.000Z', label: 'Outage' }, + { at: '2026-09-10T14:00:00.000Z', tone: 'info' }, + ]) + }) + + it('rejects reversed highlights and unknown tones', () => { + expect( + parseDashboardEmbed( + `source: {tableId: tbl_1}\nhighlights: [{from: 2026-09-07T00:00:00Z, to: 2026-08-21T00:00:00Z}]\n${chart}` + ).error + ).toContain('A highlight must start before it ends') + expect( + parseDashboardEmbed( + `source: {tableId: tbl_1}\nhighlights: [{at: 2026-09-07T00:00:00Z, tone: red}]\n${chart}` + ).error + ).toBe('highlights.0.tone: Invalid option: expected one of "neutral"|"error"|"info"') + }) + + it('accepts thresholds on a chart with a value axis and rejects them without one', () => { + expect( + parseDashboardEmbed( + `source: {tableId: tbl_1}\n${chart} thresholds: [{value: 50, label: SLO}]\n` + ).error + ).toBeUndefined() + expect( + parseDashboardEmbed( + 'source: {tableId: tbl_1}\nblocks:\n - chart: No value axis\n source: {groupBy: [status], aggregate: {n: {op: count}}}\n option: {xAxis: {type: category}, yAxis: {type: category}, series: [{type: bar}]}\n thresholds: [{value: 5}]\n' + ).error + ).toBe('Thresholds require a value axis') + }) +}) diff --git a/apps/sim/lib/dashboards/spec.ts b/apps/sim/lib/dashboards/spec.ts index 3b21faa071a..4e80be7e7f7 100644 --- a/apps/sim/lib/dashboards/spec.ts +++ b/apps/sim/lib/dashboards/spec.ts @@ -2,7 +2,18 @@ import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { JSON_SCHEMA, load } from 'js-yaml' import { z } from 'zod' +import { + CHART_TONES, + type ChartHighlight, + type ChartThreshold, + valueAxisKey, +} from '@/lib/charts/annotations' import { parseChartSpec } from '@/lib/charts/spec' +import { + type DashboardTimeRange, + parseDashboardCustomRange, + parseDashboardInstant, +} from '@/lib/dashboards/time' import { measureYamlExpansion } from '@/lib/file-parsers/yaml-limits' import { type AnalyticsSelection, @@ -12,11 +23,30 @@ import { /** Dashboard YAML is bounded before parsing; writes report the same limit without decoding. */ export const MAX_DASHBOARD_SOURCE_BYTES = 128 * 1024 +/** A dashboard fence embedded in a markdown document. */ +export const DASHBOARD_EMBED_LANGUAGE = 'dashboard' +const MAX_DASHBOARD_EMBED_SOURCE_BYTES = 32 * 1024 +const MAX_DASHBOARD_EMBED_BLOCKS = 12 export const DASHBOARD_SOURCE_TOO_LARGE = 'Dashboard source exceeds 128 KB' export const DASHBOARD_RANGES = ['1h', '24h', '7d', '30d', '90d'] as const export type DashboardRange = (typeof DASHBOARD_RANGES)[number] const rangeSchema = z.enum(DASHBOARD_RANGES) +/** A relative preset, or fixed instants for a chart that must not slide with the clock. */ +export type DashboardTime = DashboardRange | DashboardTimeRange +const timeSchema = z.union([ + rangeSchema, + z + .object({ from: z.string(), to: z.string() }) + .strict() + .superRefine((value, ctx) => { + try { + parseDashboardCustomRange(value.from, value.to) + } catch (error) { + ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid time range') }) + } + }), +]) const titleSchema = z.string().min(1).max(160) export const dashboardSourceSchema = analyticsSelectionSchema .extend({ @@ -42,6 +72,7 @@ export interface DashboardChart extends BlockBase { chart: string source?: DashboardSource option: Record + thresholds?: ChartThreshold[] } export interface DashboardTable extends BlockBase { table: string @@ -57,19 +88,81 @@ export type DashboardDataBlock = DashboardStat | DashboardChart | DashboardTable export type DashboardBlock = DashboardText | DashboardDataBlock | DashboardRow | DashboardTabs export interface DashboardSpec { title: string - time?: DashboardRange + time?: DashboardTime source?: DashboardSource + /** Time bands and instants drawn on every time-series chart. */ + highlights?: ChartHighlight[] blocks: DashboardBlock[] } +export type DashboardEmbedBlock = DashboardDataBlock | DashboardEmbedRow +export interface DashboardEmbedRow extends BlockBase { + row: DashboardEmbedBlock[] +} +/** A dashboard fragment inside markdown: the surrounding document supplies text and structure. */ +export interface DashboardEmbedSpec { + title?: string + time?: DashboardTime + source?: DashboardSource + highlights?: ChartHighlight[] + blocks: DashboardEmbedBlock[] +} + +const toneSchema = z.enum(CHART_TONES) +const annotationLabelSchema = z.string().min(1).max(80) +/** Instants are normalized to ISO with `Z` so the canvas never reads them as local time. */ +const instantSchema = z.string().transform((value, ctx) => { + try { + return parseDashboardInstant(value) + } catch (error) { + ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid time') }) + return z.NEVER + } +}) +const highlightSchema = z.union([ + z + .object({ + from: instantSchema, + to: instantSchema, + label: annotationLabelSchema.optional(), + tone: toneSchema.optional(), + }) + .strict() + .refine((value) => value.from < value.to, 'A highlight must start before it ends'), + z + .object({ + at: instantSchema, + label: annotationLabelSchema.optional(), + tone: toneSchema.optional(), + }) + .strict(), +]) +const highlightsSchema = z.array(highlightSchema).min(1).max(20) +const thresholdSchema = z + .object({ + value: z.number().finite(), + label: annotationLabelSchema.optional(), + tone: toneSchema.optional(), + }) + .strict() const base = { flex: z.number().int().min(1).max(12).optional() } const source = { ...base, source: dashboardSourceSchema.optional() } +const dataBlockSchemas = [ + z.object({ ...source, stat: titleSchema, unit: z.string().max(24).optional() }).strict(), + z + .object({ + ...source, + chart: titleSchema, + option: z.record(z.string(), z.unknown()), + thresholds: z.array(thresholdSchema).min(1).max(5).optional(), + }) + .strict(), + z.object({ ...source, table: titleSchema }).strict(), +] as const const blockSchema: z.ZodType = z.lazy(() => z.union([ z.object({ ...base, text: z.string().min(1).max(10000) }).strict(), - z.object({ ...source, stat: titleSchema, unit: z.string().max(24).optional() }).strict(), - z.object({ ...source, chart: titleSchema, option: z.record(z.string(), z.unknown()) }).strict(), - z.object({ ...source, table: titleSchema }).strict(), + ...dataBlockSchemas, z.object({ ...base, row: z.array(blockSchema).min(1).max(12) }).strict(), z .object({ @@ -87,11 +180,27 @@ const blockSchema: z.ZodType = z.lazy(() => const dashboardSchema: z.ZodType = z .object({ title: titleSchema, - time: rangeSchema.optional(), + time: timeSchema.optional(), source: dashboardSourceSchema.optional(), + highlights: highlightsSchema.optional(), blocks: z.array(blockSchema).min(1).max(48), }) .strict() +const embedBlockSchema: z.ZodType = z.lazy(() => + z.union([ + ...dataBlockSchemas, + z.object({ ...base, row: z.array(embedBlockSchema).min(1).max(12) }).strict(), + ]) +) +const dashboardEmbedSchema: z.ZodType = z + .object({ + title: titleSchema.optional(), + time: timeSchema.optional(), + source: dashboardSourceSchema.optional(), + highlights: highlightsSchema.optional(), + blocks: z.array(embedBlockSchema).min(1).max(MAX_DASHBOARD_EMBED_BLOCKS), + }) + .strict() function queryMode(source: DashboardSource | undefined): 'columns' | 'aggregate' | undefined { return source?.columns ? 'columns' : source?.aggregate ? 'aggregate' : undefined @@ -127,6 +236,7 @@ export function resolveDashboardSource( } const BLOCK_KINDS = ['text', 'stat', 'chart', 'table', 'row', 'tabs'] as const +const EMBED_BLOCK_KINDS = ['stat', 'chart', 'table', 'row'] as const /** * One line per issue, `path: message`. A block reports the errors of the kind it declares; a @@ -135,6 +245,7 @@ const BLOCK_KINDS = ['text', 'stat', 'chart', 'table', 'row', 'tabs'] as const */ function describeSchemaIssues( issues: readonly z.core.$ZodIssue[], + kinds: readonly string[], prefix: PropertyKey[] = [] ): string[] { return issues.flatMap((issue) => { @@ -148,17 +259,17 @@ function describeSchemaIssues( (entry) => entry.code === 'invalid_type' && entry.path.length === 1 && - BLOCK_KINDS.some((kind) => kind === entry.path[0]) + kinds.some((kind) => kind === entry.path[0]) ) ) const isBlock = issue.errors.some((branch) => - branch.some((entry) => entry.path.length === 1 && entry.path[0] === 'text') + branch.some((entry) => entry.path.length === 1 && entry.path[0] === kinds[0]) ) if (!isBlock || declared) { const branch = - declared ?? + (isBlock ? declared : undefined) ?? issue.errors.reduce((fewest, next) => (next.length < fewest.length ? next : fewest)) - return describeSchemaIssues(branch, path) + return describeSchemaIssues(branch, kinds, path) } const rejected = issue.errors.map( (branch) => @@ -171,71 +282,121 @@ function describeSchemaIssues( const unknown = [...rejected[0]].filter((key) => rejected.every((keys) => keys.has(key))) const keys = unknown.map((key) => `"${key}"`).join(', ') return [ - `${at}expected a block with one of ${BLOCK_KINDS.join(', ')}${keys ? `; unknown key ${keys}` : ''}`, + `${at}expected a block with one of ${kinds.join(', ')}${keys ? `; unknown key ${keys}` : ''}`, ] }) } -/** Strict YAML validation, including expansion limits before recursive parsing. */ -export function parseDashboardSpec( - content: string -): { spec: DashboardSpec; error?: never } | { error: string; spec?: never } { - try { - if (new TextEncoder().encode(content).byteLength > MAX_DASHBOARD_SOURCE_BYTES) - throw new Error(DASHBOARD_SOURCE_TOO_LARGE) - const raw: unknown = load(content, { schema: JSON_SCHEMA }) - const measured = measureYamlExpansion(raw, { - maxNodes: 10000, - maxDepth: 24, - maxSerializedBytes: 256 * 1024, - }) - if (!measured.within) throw new Error(measured.reason) - const parsed = dashboardSchema.safeParse(raw) - if (!parsed.success) throw new Error(describeSchemaIssues(parsed.error.issues).join('\n')) - const spec = parsed.data - let count = 0 - const visit = (blocks: DashboardBlock[], depth: number): void => { - if (depth > 4) throw new Error('Dashboard layout exceeds 4 levels') - for (const block of blocks) { - if (++count > 48) throw new Error('Dashboard exceeds 48 blocks') - if ('row' in block) visit(block.row, depth + 1) - else if ('tabs' in block) - Object.values(block.tabs).forEach((children) => visit(children, depth + 1)) - else if (!('text' in block)) { - const resolved = resolveDashboardSource(spec.source, block.source) - const { tableId: _tableId, range: _range, ...selection } = resolved - const parsed = analyticsQuerySchema.safeParse({ - ...selection, - from: '2026-01-01T00:00:00Z', - to: '2026-01-02T00:00:00Z', - }) - if (!parsed.success) - throw new Error(parsed.error.issues.map((issue) => issue.message).join('; ')) - if ( - 'stat' in block && - (!selection.aggregate || - Object.keys(selection.aggregate).length !== 1 || - selection.groupBy) - ) { - throw new Error(`Stat "${block.stat}" requires exactly one aggregate and no groupBy`) - } - if ('chart' in block) { - const chart = parseChartSpec( - JSON.stringify({ schema_version: 1, option: block.option }) - ) - if (!chart.spec) throw new Error(chart.error) - block.option = chart.spec.option - } +type ParseResult = { spec: T; error?: never } | { error: string; spec?: never } + +/** Bounded YAML decoding: byte size and expansion limits before recursive schema parsing. */ +function loadBoundedYaml(content: string, maxBytes: number, tooLarge: string): unknown { + if (new TextEncoder().encode(content).byteLength > maxBytes) throw new Error(tooLarge) + const raw: unknown = load(content, { schema: JSON_SCHEMA }) + const measured = measureYamlExpansion(raw, { + maxNodes: 10000, + maxDepth: 24, + maxSerializedBytes: 256 * 1024, + }) + if (!measured.within) throw new Error(measured.reason) + return raw +} + +/** + * Validates each data block's resolved query and sanitizes chart options in place. Limits nesting + * and the total block count, including nested rows and tabs. + */ +function validateDashboardBlocks( + blocks: DashboardBlock[], + defaults: DashboardSource | undefined, + maxBlocks: number +): void { + let count = 0 + const visit = (children: DashboardBlock[], depth: number): void => { + if (depth > 4) throw new Error('Dashboard layout exceeds 4 levels') + for (const block of children) { + if (++count > maxBlocks) throw new Error(`Dashboard exceeds ${maxBlocks} blocks`) + if ('row' in block) visit(block.row, depth + 1) + else if ('tabs' in block) Object.values(block.tabs).forEach((tab) => visit(tab, depth + 1)) + else if (!('text' in block)) { + const resolved = resolveDashboardSource(defaults, block.source) + const { tableId: _tableId, range: _range, ...selection } = resolved + const parsed = analyticsQuerySchema.safeParse({ + ...selection, + from: '2026-01-01T00:00:00Z', + to: '2026-01-02T00:00:00Z', + }) + if (!parsed.success) + throw new Error(parsed.error.issues.map((issue) => issue.message).join('; ')) + if ( + 'stat' in block && + (!selection.aggregate || + Object.keys(selection.aggregate).length !== 1 || + selection.groupBy) + ) { + throw new Error(`Stat "${block.stat}" requires exactly one aggregate and no groupBy`) + } + if ('chart' in block) { + const chart = parseChartSpec(JSON.stringify({ schema_version: 1, option: block.option })) + if (!chart.spec) throw new Error(chart.error) + block.option = chart.spec.option + if (block.thresholds) valueAxisKey(block.option) } } } - visit(spec.blocks, 1) - return { spec } + } + visit(blocks, 1) +} + +/** Strict YAML validation, including expansion limits before recursive parsing. */ +export function parseDashboardSpec(content: string): ParseResult { + try { + const raw = loadBoundedYaml(content, MAX_DASHBOARD_SOURCE_BYTES, DASHBOARD_SOURCE_TOO_LARGE) + const parsed = dashboardSchema.safeParse(raw) + if (!parsed.success) + throw new Error(describeSchemaIssues(parsed.error.issues, BLOCK_KINDS).join('\n')) + validateDashboardBlocks(parsed.data.blocks, parsed.data.source, 48) + return { spec: parsed.data } } catch (error) { return { error: getErrorMessage(error, 'Invalid dashboard') } } } +/** Parses a markdown ```dashboard fence body with the dashboard grammar, minus text and tabs. */ +export function parseDashboardEmbed(content: string): ParseResult { + try { + const raw = loadBoundedYaml( + content, + MAX_DASHBOARD_EMBED_SOURCE_BYTES, + 'Dashboard embed exceeds 32 KB' + ) + const parsed = dashboardEmbedSchema.safeParse(raw) + if (!parsed.success) + throw new Error(describeSchemaIssues(parsed.error.issues, EMBED_BLOCK_KINDS).join('\n')) + validateDashboardBlocks(parsed.data.blocks, parsed.data.source, MAX_DASHBOARD_EMBED_BLOCKS) + return { spec: parsed.data } + } catch (error) { + return { error: getErrorMessage(error, 'Invalid dashboard embed') } + } +} + +/** Every table a dashboard's data blocks read, for scoping refresh and fetch state. */ +export function dashboardTableIds( + blocks: readonly DashboardBlock[], + defaults: DashboardSource | undefined +): Set { + const ids = new Set() + const visit = (children: readonly DashboardBlock[]) => { + for (const block of children) { + if ('row' in block) visit(block.row) + else if ('tabs' in block) Object.values(block.tabs).forEach(visit) + else if (!('text' in block)) ids.add(resolveDashboardSource(defaults, block.source).tableId) + } + } + visit(blocks) + return ids +} + export function dashboardSelection(source: ResolvedDashboardSource): AnalyticsSelection { const { tableId: _tableId, range: _range, ...selection } = source return selection diff --git a/apps/sim/lib/dashboards/time.ts b/apps/sim/lib/dashboards/time.ts index 0446823ab3b..d3a8dfd9a83 100644 --- a/apps/sim/lib/dashboards/time.ts +++ b/apps/sim/lib/dashboards/time.ts @@ -17,18 +17,20 @@ export function relativeDashboardRange(range: DashboardRange, now: number): Dash return { from: new Date(now - RANGE_MS[range]).toISOString(), to: new Date(now).toISOString() } } +/** A UTC instant written with or without a trailing `Z`, normalized to ISO. */ +export function parseDashboardInstant(value: string): string { + const normalized = value.endsWith('Z') ? value.slice(0, -1) : value + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{3})?)?$/.test(normalized)) + throw new Error('Choose a complete start and end date') + const date = new Date(`${normalized}Z`) + if (!Number.isFinite(date.getTime()) || !date.toISOString().startsWith(normalized)) + throw new Error('Invalid UTC date and time') + return date.toISOString() +} + /** URL bounds are instants; legacy offset-free URLs continue to mean UTC. */ export function parseDashboardCustomRange(from: string, to: string): DashboardTimeRange { - const instant = (value: string) => { - const normalized = value.endsWith('Z') ? value.slice(0, -1) : value - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{3})?)?$/.test(normalized)) - throw new Error('Choose a complete start and end date') - const date = new Date(`${normalized}Z`) - if (!Number.isFinite(date.getTime()) || !date.toISOString().startsWith(normalized)) - throw new Error('Invalid UTC date and time') - return date.toISOString() - } - const range = { from: instant(from), to: instant(to) } + const range = { from: parseDashboardInstant(from), to: parseDashboardInstant(to) } if (range.from >= range.to) throw new Error('The start must be earlier than the end') return range } @@ -73,6 +75,32 @@ export function dashboardZoomRange( return { from: new Date(from).toISOString(), to: new Date(to).toISOString() } } +export const DASHBOARD_RANGE_LABELS: Record = { + '1h': 'Last hour', + '24h': 'Last 24 hours', + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', +} + +/** Compact dates for a range; times appear only when it starts and ends on the same day. */ +export function dashboardRangeText(range: DashboardTimeRange, timeZone: string): string { + const from = new Date(range.from) + const to = new Date(Date.parse(range.to) - 1) + const fromLocal = zonedWallClock(from, timeZone) + const toLocal = zonedWallClock(to, timeZone) + const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10) + return new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'short', + day: 'numeric', + year: fromLocal.slice(0, 4) === toLocal.slice(0, 4) ? undefined : 'numeric', + hour: sameDay ? '2-digit' : undefined, + minute: sameDay ? '2-digit' : undefined, + hourCycle: 'h23', + }).formatRange(from, to) +} + export function dashboardTimeLabel(instant: number | string, timeZone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone, From b42e3f387f3529ec9b3ccce884448c8e42ddddf9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 11:06:34 -0700 Subject: [PATCH 2/5] improvement(dashboards): lazy-load markdown dashboard embeds --- .../rich-markdown-editor/code-block.tsx | 14 +++++--- .../slash-command/commands.ts | 2 +- apps/sim/lib/dashboards/embed-language.ts | 2 ++ apps/sim/lib/dashboards/spec.ts | 2 -- ...check-tool-registry-boundary.baseline.json | 32 +++++++++---------- 5 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/dashboards/embed-language.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 25c805895d0..227331565a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from 'react' +import { lazy, Suspense, useContext, useEffect, useState } from 'react' import { chipVariants, cn, @@ -11,14 +11,18 @@ import { import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' -import { DashboardEmbed } from '@/components/dashboards/dashboard-embed' -import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec' +import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' import { MarkdownCodeBlock } from './code-block-schema' import { detectLanguage } from './detect-language' import { useEditorEditable } from './use-editor-editable' +/** Kept out of every rich-markdown surface's graph until a document actually holds a dashboard. */ +const DashboardEmbed = lazy(() => + import('@/components/dashboards/dashboard-embed').then((m) => ({ default: m.DashboardEmbed })) +) + const PLAIN = 'plain' const MERMAID = 'mermaid' @@ -232,7 +236,9 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView }} > {isDashboard ? ( - + + + ) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index c7bef46448a..30d81d71c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -15,7 +15,7 @@ import { TextQuote, } from '@sim/emcn/icons' import type { Editor, Range } from '@tiptap/core' -import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec' +import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' /** A time-series starter; the table id is left for the author to fill in. */ const DASHBOARD_EMBED_STARTER = `title: Rows over time diff --git a/apps/sim/lib/dashboards/embed-language.ts b/apps/sim/lib/dashboards/embed-language.ts new file mode 100644 index 00000000000..4780070c70b --- /dev/null +++ b/apps/sim/lib/dashboards/embed-language.ts @@ -0,0 +1,2 @@ +/** The fence language that renders a dashboard embed in markdown; kept apart from the parser. */ +export const DASHBOARD_EMBED_LANGUAGE = 'dashboard' diff --git a/apps/sim/lib/dashboards/spec.ts b/apps/sim/lib/dashboards/spec.ts index 4e80be7e7f7..f505eb936e6 100644 --- a/apps/sim/lib/dashboards/spec.ts +++ b/apps/sim/lib/dashboards/spec.ts @@ -23,8 +23,6 @@ import { /** Dashboard YAML is bounded before parsing; writes report the same limit without decoding. */ export const MAX_DASHBOARD_SOURCE_BYTES = 128 * 1024 -/** A dashboard fence embedded in a markdown document. */ -export const DASHBOARD_EMBED_LANGUAGE = 'dashboard' const MAX_DASHBOARD_EMBED_SOURCE_BYTES = 32 * 1024 const MAX_DASHBOARD_EMBED_BLOCKS = 12 export const DASHBOARD_SOURCE_TOO_LARGE = 'Dashboard source exceeds 128 KB' diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index d3dfa6f67d5..b3b2fc3e2c9 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -442,16 +442,16 @@ } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1421, + "modules": 1457, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1420, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1456, "apps/sim/triggers/registry.ts": 528, - "apps/sim/blocks/registry.ts": 376, - "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 232, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 229, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 93, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 82 + "apps/sim/blocks/registry.ts": 374, + "apps/sim/blocks/registry-maps.ts": 372, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 268, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 265, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 117, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 92 } }, "app/workspace/[workspaceId]/skills/error.tsx": { @@ -459,16 +459,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1419, + "modules": 1455, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1418, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1454, "apps/sim/triggers/registry.ts": 528, - "apps/sim/blocks/registry.ts": 376, - "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 232, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 229, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 93, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 82 + "apps/sim/blocks/registry.ts": 374, + "apps/sim/blocks/registry-maps.ts": 372, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 268, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 265, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 117, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 92 } }, "app/workspace/[workspaceId]/skills/page.tsx": { From 6b4f7bde1fd705f9d97e0255305eeae2acced9db Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 11:10:57 -0700 Subject: [PATCH 3/5] fix(dashboards): address review on chart annotations and embed refresh --- apps/sim/components/charts/echarts-view.tsx | 18 ++++++++--------- .../components/dashboards/dashboard-embed.tsx | 18 ++++++++++++++--- .../components/dashboards/dashboard-panel.tsx | 2 +- .../dashboards/use-dashboard-time.ts | 20 +++++++++++-------- apps/sim/lib/charts/annotations.ts | 13 +++++++++--- apps/sim/lib/charts/spec.ts | 7 +++++++ apps/sim/lib/charts/time-series.ts | 6 ------ apps/sim/lib/dashboards/spec.test.ts | 15 ++++++++++++++ apps/sim/lib/dashboards/spec.ts | 17 ++++++++++++---- 9 files changed, 81 insertions(+), 35 deletions(-) diff --git a/apps/sim/components/charts/echarts-view.tsx b/apps/sim/components/charts/echarts-view.tsx index 72df42611cf..aed2276c426 100644 --- a/apps/sim/components/charts/echarts-view.tsx +++ b/apps/sim/components/charts/echarts-view.tsx @@ -57,16 +57,14 @@ export function EChartsView({ controllerRef.current = controller ?? null const next: { option: Record; annotations?: ChartAnnotations } = JSON.parse(nextOption) - const parsed = applyChartTooltipDefaults( - next.annotations - ? applyChartAnnotations( - next.option, - next.annotations, - readChartTonePalette(chart.getDom()) - ) - : next.option - ) - chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true }) + const parsed = applyChartTooltipDefaults(next.option) + // Annotate after the bar helpers read the option: the label series would read as a non-bar chart. + const rendered = next.annotations + ? applyChartAnnotations(parsed, next.annotations, readChartTonePalette(chart.getDom())) + : parsed + chart.setOption(controller ? controller.prepareOption(rendered) : rendered, { + notMerge: true, + }) controller?.afterUpdate() rowHighlightRef.current?.() rowHighlightRef.current = installBarRowHighlight(chart, parsed) diff --git a/apps/sim/components/dashboards/dashboard-embed.tsx b/apps/sim/components/dashboards/dashboard-embed.tsx index 0634d6eb4e9..069283ca623 100644 --- a/apps/sim/components/dashboards/dashboard-embed.tsx +++ b/apps/sim/components/dashboards/dashboard-embed.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ReactNode, useId, useMemo, useState } from 'react' +import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react' import { cn, Tooltip } from '@sim/emcn' import { useParams } from 'next/navigation' import { DashboardFeatureGate } from '@/components/dashboards/dashboard-feature-gate' @@ -81,7 +81,9 @@ function LiveDashboardEmbed({ source, workspaceId }: LiveDashboardEmbedProps) { } function EmbedView({ spec, workspaceId }: EmbedViewProps) { + const rootRef = useRef(null) const embedId = useId() + const [inView, setInView] = useState(false) const [state, setState] = useState({ range: null, from: null, @@ -94,8 +96,15 @@ function EmbedView({ spec, workspaceId }: EmbedViewProps) { time: spec.time, workspaceId, tableIds: dashboardTableIds(spec.blocks, spec.source), - live: true, + live: inView, }) + useEffect(() => { + const root = rootRef.current + if (!root) return + const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting)) + observer.observe(root) + return () => observer.disconnect() + }, []) const zoomed = state.range !== null const { period } = time.controls const caption = @@ -103,7 +112,10 @@ function EmbedView({ spec, workspaceId }: EmbedViewProps) { ? dashboardRangeText(time.range, time.interactions.timeZone) : DASHBOARD_RANGE_LABELS[period] return ( -
+
{spec.title && (

{spec.title}

)} diff --git a/apps/sim/components/dashboards/dashboard-panel.tsx b/apps/sim/components/dashboards/dashboard-panel.tsx index 62d1341ed5a..618b87164df 100644 --- a/apps/sim/components/dashboards/dashboard-panel.tsx +++ b/apps/sim/components/dashboards/dashboard-panel.tsx @@ -17,7 +17,7 @@ import { useDashboardInteractions } from '@/components/dashboards/dashboard-inte import type { QueryTableAnalyticsResponse } from '@/lib/api/contracts/table-analytics' import type { ChartAnnotations, ChartHighlight } from '@/lib/charts/annotations' import { buildChartRenderOption, horizontalBarChartHeight } from '@/lib/charts/option' -import { isTimeSeriesOption } from '@/lib/charts/time-series' +import { isTimeSeriesOption } from '@/lib/charts/spec' import { type DashboardDataBlock, type DashboardSource, diff --git a/apps/sim/components/dashboards/use-dashboard-time.ts b/apps/sim/components/dashboards/use-dashboard-time.ts index a032a7f952b..3407b8c2f32 100644 --- a/apps/sim/components/dashboards/use-dashboard-time.ts +++ b/apps/sim/components/dashboards/use-dashboard-time.ts @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useEffectEvent, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { toRecord } from '@sim/utils/object' import { useIsFetching, useQueryClient } from '@tanstack/react-query' @@ -32,7 +32,10 @@ interface UseDashboardTimeProps { time: DashboardTime | undefined workspaceId: string tableIds: ReadonlySet - /** Advances relative ranges to the present every minute, so the view keeps up with new rows. */ + /** + * Refreshes every minute while true and the page is visible: relative ranges advance to the + * present, fixed and zoomed ranges refetch in place. + */ live?: boolean } @@ -79,19 +82,20 @@ export function useDashboardTime({ } } - const relative = period !== 'custom' + const tick = useEffectEvent(() => { + if (document.visibilityState !== 'visible') return + if (period === 'custom') void queryClient.invalidateQueries(queryFilter) + else setNow(Date.now()) + }) useEffect(() => { - if (!live || !relative) return - const tick = () => { - if (document.visibilityState === 'visible') setNow(Date.now()) - } + if (!live) return const interval = setInterval(tick, LIVE_TICK_MS) document.addEventListener('visibilitychange', tick) return () => { clearInterval(interval) document.removeEventListener('visibilitychange', tick) } - }, [live, relative]) + }, [live]) const onZoom = (selected: DashboardTimeRange) => { setInputError(null) diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts index fd8cdc5d65b..d94727bd807 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -48,6 +48,15 @@ function verticalTop(option: Record): 'start' | 'end' { return firstAxis(option.yAxis).inverse === true ? 'start' : 'end' } +/** A chart annotations can draw on: a first series with no hand-written marks to collide with. */ +export function assertAnnotatable(option: Record): void { + const series = Array.isArray(option.series) ? option.series : [option.series] + if (series[0] === undefined) throw new Error('Highlights and thresholds require a series') + const first = toRecord(series[0]) + if (first.markArea !== undefined || first.markLine !== undefined) + throw new Error('Use highlights and thresholds instead of markArea or markLine on the series') +} + /** * Id of the empty series that carries annotation labels. ECharts draws a mark's label at the mark's * depth, so the marks sit under the data and their labels ride on this series above it. @@ -77,11 +86,9 @@ export function applyChartAnnotations( const highlights = annotations.highlights ?? [] const thresholds = annotations.thresholds ?? [] if (highlights.length === 0 && thresholds.length === 0) return option + assertAnnotatable(option) const series = Array.isArray(option.series) ? option.series : [option.series] - if (series[0] === undefined) throw new Error('Highlights and thresholds require a series') const first = toRecord(series[0]) - if (first.markArea !== undefined || first.markLine !== undefined) - throw new Error('Use highlights and thresholds instead of markArea or markLine on the series') const bands: Array = [] const lines: Array }> = [] diff --git a/apps/sim/lib/charts/spec.ts b/apps/sim/lib/charts/spec.ts index 37bc55f0aad..c27f8a089b8 100644 --- a/apps/sim/lib/charts/spec.ts +++ b/apps/sim/lib/charts/spec.ts @@ -5,6 +5,7 @@ */ import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnDefinition } from '@/lib/table/types' @@ -270,3 +271,9 @@ export function shapeTableRows( } return out } + +/** Cartesian charts with one horizontal time axis share dashboard interactions. */ +export function isTimeSeriesOption(option: Record): boolean { + const axes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis] + return axes.length === 1 && toRecord(axes[0]).type === 'time' +} diff --git a/apps/sim/lib/charts/time-series.ts b/apps/sim/lib/charts/time-series.ts index b5ef62073a5..06b9b5e7cf2 100644 --- a/apps/sim/lib/charts/time-series.ts +++ b/apps/sim/lib/charts/time-series.ts @@ -30,12 +30,6 @@ export interface TimeSeriesInteractionOptions { onZoom?: (range: DashboardTimeRange) => void } -/** Cartesian charts with one horizontal time axis share dashboard interactions. */ -export function isTimeSeriesOption(option: Record): boolean { - const axes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis] - return axes.length === 1 && toRecord(axes[0]).type === 'time' -} - /** Use ECharts' resolved encodings and colors, including transformed datasets. */ export function readTimeSeriesTooltip( params: unknown, diff --git a/apps/sim/lib/dashboards/spec.test.ts b/apps/sim/lib/dashboards/spec.test.ts index 02885422068..7907c851ec7 100644 --- a/apps/sim/lib/dashboards/spec.test.ts +++ b/apps/sim/lib/dashboards/spec.test.ts @@ -237,6 +237,21 @@ describe('highlights and thresholds', () => { ).toBe('highlights.0.tone: Invalid option: expected one of "neutral"|"error"|"info"') }) + it('rejects highlights or thresholds on a chart with hand-written marks', () => { + const marked = + 'blocks:\n - chart: Weekly\n source: {groupBy: [createdAt], bucket: week, aggregate: {n: {op: count}}}\n option: {xAxis: {type: time}, yAxis: {type: value}, series: [{type: line, markLine: {data: []}}]}\n' + const message = 'Use highlights and thresholds instead of markArea or markLine on the series' + expect( + parseDashboardEmbed( + `source: {tableId: tbl_1}\nhighlights: [{at: 2026-09-10T00:00:00Z}]\n${marked}` + ).error + ).toBe(message) + expect( + parseDashboardEmbed(`source: {tableId: tbl_1}\n${marked} thresholds: [{value: 5}]\n`).error + ).toBe(message) + expect(parseDashboardEmbed(`source: {tableId: tbl_1}\n${marked}`).error).toBeUndefined() + }) + it('accepts thresholds on a chart with a value axis and rejects them without one', () => { expect( parseDashboardEmbed( diff --git a/apps/sim/lib/dashboards/spec.ts b/apps/sim/lib/dashboards/spec.ts index f505eb936e6..e9c54557013 100644 --- a/apps/sim/lib/dashboards/spec.ts +++ b/apps/sim/lib/dashboards/spec.ts @@ -3,12 +3,13 @@ import { omit } from '@sim/utils/object' import { JSON_SCHEMA, load } from 'js-yaml' import { z } from 'zod' import { + assertAnnotatable, CHART_TONES, type ChartHighlight, type ChartThreshold, valueAxisKey, } from '@/lib/charts/annotations' -import { parseChartSpec } from '@/lib/charts/spec' +import { isTimeSeriesOption, parseChartSpec } from '@/lib/charts/spec' import { type DashboardTimeRange, parseDashboardCustomRange, @@ -307,7 +308,8 @@ function loadBoundedYaml(content: string, maxBytes: number, tooLarge: string): u function validateDashboardBlocks( blocks: DashboardBlock[], defaults: DashboardSource | undefined, - maxBlocks: number + maxBlocks: number, + highlights: ChartHighlight[] | undefined ): void { let count = 0 const visit = (children: DashboardBlock[], depth: number): void => { @@ -339,6 +341,8 @@ function validateDashboardBlocks( if (!chart.spec) throw new Error(chart.error) block.option = chart.spec.option if (block.thresholds) valueAxisKey(block.option) + if (block.thresholds || (highlights && isTimeSeriesOption(block.option))) + assertAnnotatable(block.option) } } } @@ -353,7 +357,7 @@ export function parseDashboardSpec(content: string): ParseResult const parsed = dashboardSchema.safeParse(raw) if (!parsed.success) throw new Error(describeSchemaIssues(parsed.error.issues, BLOCK_KINDS).join('\n')) - validateDashboardBlocks(parsed.data.blocks, parsed.data.source, 48) + validateDashboardBlocks(parsed.data.blocks, parsed.data.source, 48, parsed.data.highlights) return { spec: parsed.data } } catch (error) { return { error: getErrorMessage(error, 'Invalid dashboard') } @@ -371,7 +375,12 @@ export function parseDashboardEmbed(content: string): ParseResult Date: Thu, 1 Oct 2026 11:12:45 -0700 Subject: [PATCH 4/5] fix(dashboards): re-anchor reset embeds and cover the collab placeholder while streaming --- .../rich-markdown-editor/rich-markdown-editor.tsx | 14 +++++++------- apps/sim/components/dashboards/dashboard-embed.tsx | 4 +--- .../components/dashboards/use-dashboard-time.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index ccf0adbc76b..4c3dc3564d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1440,14 +1440,14 @@ export function LoadedRichMarkdownEditor({ void insertImagesRef.current(images, range) }} /> - {showPlaceholder && placeholder && ( - - )} + {showPlaceholder && placeholder && ( + + )} - setState((current) => ({ ...current, range: null, from: null, to: null })) - } + onClick={time.reset} > Reset diff --git a/apps/sim/components/dashboards/use-dashboard-time.ts b/apps/sim/components/dashboards/use-dashboard-time.ts index 3407b8c2f32..e115c7c7906 100644 --- a/apps/sim/components/dashboards/use-dashboard-time.ts +++ b/apps/sim/components/dashboards/use-dashboard-time.ts @@ -87,8 +87,13 @@ export function useDashboardTime({ if (period === 'custom') void queryClient.invalidateQueries(queryFilter) else setNow(Date.now()) }) + /** A view that resumes after a pause catches up at once instead of on the next tick. */ + const catchUp = useEffectEvent(() => { + if (period !== 'custom' && Date.now() - now >= LIVE_TICK_MS) tick() + }) useEffect(() => { if (!live) return + catchUp() const interval = setInterval(tick, LIVE_TICK_MS) document.addEventListener('visibilitychange', tick) return () => { @@ -101,12 +106,19 @@ export function useDashboardTime({ setInputError(null) setState({ range: 'custom', ...selected }) } + /** Returns to the authored range, re-anchored to the present. */ + const reset = () => { + cursorStore.getState().clearCursor() + setNow(Date.now()) + setState({ range: null, from: null, to: null }) + } return { range, now, rangeError, inputError, + reset, interactions: { cursorStore, timeZone, onZoom }, controls: { period, From 2b0d291b3179ffd2e40d402648969a0494f13727 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 11:31:34 -0700 Subject: [PATCH 5/5] fix(dashboards): scope live refresh to each embed and place the chart starter caret --- .../slash-command/commands.test.ts | 29 +++++++++++++++++++ .../slash-command/commands.ts | 12 +++++++- .../components/dashboards/dashboard-panel.tsx | 1 + .../dashboards/dashboard-preview.test.tsx | 26 +++++++++++------ .../dashboards/use-dashboard-time.ts | 11 +++---- apps/sim/hooks/queries/table-analytics.ts | 10 ++++--- apps/sim/lib/charts/annotations.test.ts | 19 ++++++++++++ apps/sim/lib/charts/annotations.ts | 10 ++++++- 8 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts new file mode 100644 index 00000000000..08b4f65a381 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { SLASH_COMMANDS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands' + +const chart = SLASH_COMMANDS.find((command) => command.title === 'Chart') + +describe('Chart slash command', () => { + it.each([ + ['an empty paragraph', '

/chart

'], + ['the end of existing text', '

Intro /chart

'], + ])('puts the caret at the start of the starter fence from %s', (_, content) => { + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content, + }) + const end = editor.state.doc.content.size - 1 + const from = editor.state.doc.textBetween(0, end).indexOf('/') + 1 + chart?.run({ editor, range: { from, to: end } }) + const { $from } = editor.state.selection + expect($from.parent.type.name).toBe('codeBlock') + expect($from.parent.attrs.language).toBe('dashboard') + expect($from.parentOffset).toBe(0) + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index 30d81d71c0b..8fb32c79595 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -15,6 +15,7 @@ import { TextQuote, } from '@sim/emcn/icons' import type { Editor, Range } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' /** A time-series starter; the table id is left for the author to fill in. */ @@ -157,7 +158,16 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ attrs: { language: DASHBOARD_EMBED_LANGUAGE }, content: [{ type: 'text', text: DASHBOARD_EMBED_STARTER }], }) - .setTextSelection(range.from + 1) + .command(({ tr }) => { + let fence = -1 + tr.doc.nodesBetween(range.from - 1, tr.doc.content.size, (node, pos) => { + if (fence < 0 && node.type.name === 'codeBlock') fence = pos + return fence < 0 + }) + if (fence < 0) return false + tr.setSelection(TextSelection.create(tr.doc, fence + 1)) + return true + }) .run(), }, { diff --git a/apps/sim/components/dashboards/dashboard-panel.tsx b/apps/sim/components/dashboards/dashboard-panel.tsx index 618b87164df..f75d4f924aa 100644 --- a/apps/sim/components/dashboards/dashboard-panel.tsx +++ b/apps/sim/components/dashboards/dashboard-panel.tsx @@ -110,6 +110,7 @@ export function DashboardPanel({ ...panelRange, }, }, + refreshedAt: now, }) const title = 'stat' in block ? block.stat : 'chart' in block ? block.chart : block.table const data = query.data diff --git a/apps/sim/components/dashboards/dashboard-preview.test.tsx b/apps/sim/components/dashboards/dashboard-preview.test.tsx index ad877bbac24..d53a3d1496d 100644 --- a/apps/sim/components/dashboards/dashboard-preview.test.tsx +++ b/apps/sim/components/dashboards/dashboard-preview.test.tsx @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DashboardPreview } from '@/components/dashboards/dashboard-preview' import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics' +const { layout } = vi.hoisted(() => ({ layout: vi.fn((_props: { now: number }) => null) })) + /** Panels would issue analytics requests; this suite exercises the controls and query cache. */ -vi.mock('@/components/dashboards/dashboard-layout', () => ({ DashboardLayout: () => null })) +vi.mock('@/components/dashboards/dashboard-layout', () => ({ DashboardLayout: layout })) const content = 'title: Example\ntime: 7d\nsource: {tableId: table-1}\nblocks: [{stat: Total, source: {aggregate: {total: {op: count}}}}]' @@ -20,6 +22,7 @@ describe('dashboard refresh', () => { let client: QueryClient beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + layout.mockClear() client = new QueryClient() container = document.createElement('div') document.body.append(container) @@ -31,12 +34,13 @@ describe('dashboard refresh', () => { client.clear() }) - it('refreshes only this workspace and this dashboard tables', async () => { + it('refreshes by re-keying its panels to a new now, leaving other caches alone', async () => { const key = (workspaceId: string, tableId: string) => - tableAnalyticsKeys.query(tableId, { - workspaceId, - query: { ...range, aggregate: { total: { op: 'count' } } }, - }) + tableAnalyticsKeys.query( + tableId, + { workspaceId, query: { ...range, aggregate: { total: { op: 'count' } } } }, + 0 + ) const matching = key('workspace-1', 'table-1') const otherTable = key('workspace-1', 'other-table') const otherWorkspace = key('workspace-2', 'table-1') @@ -60,9 +64,13 @@ describe('dashboard refresh', () => { if (!button) throw new Error('Refresh button not rendered') return button }) + const before = layout.mock.lastCall?.[0].now + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + 60_000) await act(async () => refresh.click()) - expect(client.getQueryState(matching)?.isInvalidated).toBe(true) - expect(client.getQueryState(otherTable)?.isInvalidated).toBe(false) - expect(client.getQueryState(otherWorkspace)?.isInvalidated).toBe(false) + vi.useRealTimers() + expect(layout.mock.lastCall?.[0].now).toBeGreaterThan(before ?? Number.POSITIVE_INFINITY) + for (const queryKey of [matching, otherTable, otherWorkspace]) + expect(client.getQueryState(queryKey)?.isInvalidated).toBe(false) }) }) diff --git a/apps/sim/components/dashboards/use-dashboard-time.ts b/apps/sim/components/dashboards/use-dashboard-time.ts index e115c7c7906..f96b8ad0b3b 100644 --- a/apps/sim/components/dashboards/use-dashboard-time.ts +++ b/apps/sim/components/dashboards/use-dashboard-time.ts @@ -3,7 +3,7 @@ import { useEffect, useEffectEvent, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { toRecord } from '@sim/utils/object' -import { useIsFetching, useQueryClient } from '@tanstack/react-query' +import { useIsFetching } from '@tanstack/react-query' import { getBrowserTimezone } from '@/lib/core/utils/timezone' import type { DashboardRange, DashboardTime } from '@/lib/dashboards/spec' import { @@ -55,7 +55,6 @@ export function useDashboardTime({ const cursorStoreRef = useRef(null) cursorStoreRef.current ??= createDashboardCursorStore() const cursorStore = cursorStoreRef.current - const queryClient = useQueryClient() const [now, setNow] = useState(() => Date.now()) const [inputError, setInputError] = useState(null) const queryFilter = { @@ -82,14 +81,13 @@ export function useDashboardTime({ } } + /** Advancing `now` rolls relative ranges and re-keys fixed ones, refetching only this view. */ const tick = useEffectEvent(() => { - if (document.visibilityState !== 'visible') return - if (period === 'custom') void queryClient.invalidateQueries(queryFilter) - else setNow(Date.now()) + if (document.visibilityState === 'visible') setNow(Date.now()) }) /** A view that resumes after a pause catches up at once instead of on the next tick. */ const catchUp = useEffectEvent(() => { - if (period !== 'custom' && Date.now() - now >= LIVE_TICK_MS) tick() + if (Date.now() - now >= LIVE_TICK_MS) tick() }) useEffect(() => { if (!live) return @@ -147,7 +145,6 @@ export function useDashboardTime({ onRefresh: () => { setNow(Date.now()) cursorStore.getState().clearCursor() - if (period === 'custom') void queryClient.invalidateQueries(queryFilter) }, onZoneChange: (zone: 'utc' | 'local') => setState({ zone }), }, diff --git a/apps/sim/hooks/queries/table-analytics.ts b/apps/sim/hooks/queries/table-analytics.ts index 9ed2648a3cc..1bb6aefed77 100644 --- a/apps/sim/hooks/queries/table-analytics.ts +++ b/apps/sim/hooks/queries/table-analytics.ts @@ -12,17 +12,19 @@ export const TABLE_ANALYTICS_STALE_TIME = 60_000 export const tableAnalyticsKeys = { all: ['table-analytics'] as const, queries: () => [...tableAnalyticsKeys.all, 'query'] as const, - query: (tableId: string, body: QueryTableAnalyticsBody) => - [...tableAnalyticsKeys.queries(), tableId, body] as const, + /** `refreshedAt` re-keys a fixed range when its view refreshes, so only that view refetches. */ + query: (tableId: string, body: QueryTableAnalyticsBody, refreshedAt: number) => + [...tableAnalyticsKeys.queries(), tableId, body, refreshedAt] as const, } interface UseTableAnalyticsProps { tableId: string body: QueryTableAnalyticsBody + refreshedAt: number } -export function useTableAnalytics({ tableId, body }: UseTableAnalyticsProps) { +export function useTableAnalytics({ tableId, body, refreshedAt }: UseTableAnalyticsProps) { return useQuery({ - queryKey: tableAnalyticsKeys.query(tableId, body), + queryKey: tableAnalyticsKeys.query(tableId, body, refreshedAt), queryFn: async ({ signal }) => { const result = await requestJson(queryTableAnalyticsContract, { params: { tableId }, diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index 89b05dc867f..60aafc426bd 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -79,6 +79,25 @@ describe('chart annotations', () => { }) }) + it('puts the label series on the same axes as the first series', () => { + const option = applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: [{ type: 'value' }, { type: 'value' }], + series: [{ type: 'line', yAxisIndex: 1 }], + }, + { thresholds: [{ value: 5, label: 'Limit' }] }, + palette + ) + expect((option.series as Series[])[1]).toMatchObject({ yAxisIndex: 1 }) + }) + + it('rejects thresholds on a chart without axes', () => { + expect(() => + applyChartAnnotations({ series: [{ type: 'pie' }] }, { thresholds: [{ value: 1 }] }, palette) + ).toThrow('Thresholds require a value axis') + }) + it('adds no label series when nothing is labelled', () => { const option = applyChartAnnotations(timeSeries, { thresholds: [{ value: 1 }] }, palette) expect(option.series).toHaveLength(2) diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts index d94727bd807..79c41a1e2f6 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -1,4 +1,4 @@ -import { toRecord } from '@sim/utils/object' +import { filterUndefined, toRecord } from '@sim/utils/object' export const CHART_TONES = ['neutral', 'error', 'info'] as const export type ChartTone = (typeof CHART_TONES)[number] @@ -33,6 +33,8 @@ function firstAxis(axis: unknown): Record { /** The axis a threshold is measured on; ECharts defaults an unspecified yAxis to a value axis. */ export function valueAxisKey(option: Record): 'xAxis' | 'yAxis' { + if (option.xAxis === undefined && option.yAxis === undefined) + throw new Error('Thresholds require a value axis') const y = firstAxis(option.yAxis) if (y.type === undefined || y.type === 'value' || y.type === 'log') return 'yAxis' const x = firstAxis(option.xAxis) @@ -147,6 +149,12 @@ export function applyChartAnnotations( { id: CHART_ANNOTATION_SERIES_ID, type: 'line', + ...filterUndefined({ + xAxisIndex: first.xAxisIndex, + yAxisIndex: first.yAxisIndex, + xAxisId: first.xAxisId, + yAxisId: first.yAxisId, + }), data: [], silent: true, z: 3,