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..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 { useEffect, useState } from 'react' +import { lazy, Suspense, useContext, useEffect, useState } from 'react' import { chipVariants, cn, @@ -11,11 +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 { 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' @@ -51,7 +58,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 +68,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 +103,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 +144,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 +193,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..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
@@ -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'
@@ -1439,17 +1440,19 @@ export function LoadedRichMarkdownEditor({
void insertImagesRef.current(images, range)
}}
/>
- {showPlaceholder && placeholder && (
-
+ {showPlaceholder && placeholder && (
+
+ )}
+
- )}
-
+
)
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.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 000a434eba4..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
@@ -1,5 +1,6 @@
import type { ComponentType, SVGProps } from 'react'
import {
+ ChartColumn,
Code,
Heading1,
Heading2,
@@ -14,6 +15,27 @@ 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. */
+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 +143,33 @@ 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 }],
+ })
+ .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(),
+ },
{
title: 'Table',
group: 'Blocks',
diff --git a/apps/sim/components/charts/echarts-view.tsx b/apps/sim/components/charts/echarts-view.tsx
index 041ef6c5945..aed2276c426 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,15 +48,23 @@ 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))
- chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true })
+ const next: { option: Record; annotations?: ChartAnnotations } =
+ JSON.parse(nextOption)
+ 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/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 rootRef = useRef(null)
+ const embedId = useId()
+ const [inView, setInView] = useState(false)
+ 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: 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 =
+ 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..f75d4f924aa 100644
--- a/apps/sim/components/dashboards/dashboard-panel.tsx
+++ b/apps/sim/components/dashboards/dashboard-panel.tsx
@@ -15,8 +15,9 @@ 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 { isTimeSeriesOption } from '@/lib/charts/spec'
import {
type DashboardDataBlock,
type DashboardSource,
@@ -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
@@ -97,6 +110,7 @@ export function DashboardPanel({ block, defaults, workspaceId, range, now }: Das
...panelRange,
},
},
+ refreshedAt: now,
})
const title = 'stat' in block ? block.stat : 'chart' in block ? block.chart : block.table
const data = query.data
@@ -109,6 +123,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 +162,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.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/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..f96b8ad0b3b
--- /dev/null
+++ b/apps/sim/components/dashboards/use-dashboard-time.ts
@@ -0,0 +1,152 @@
+'use client'
+
+import { useEffect, useEffectEvent, useRef, useState } from 'react'
+import { getErrorMessage } from '@sim/utils/errors'
+import { toRecord } from '@sim/utils/object'
+import { useIsFetching } 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
+ /**
+ * 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
+}
+
+/**
+ * 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 [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')
+ }
+ }
+
+ /** Advancing `now` rolls relative ranges and re-keys fixed ones, refetching only this view. */
+ const tick = useEffectEvent(() => {
+ 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 (Date.now() - now >= LIVE_TICK_MS) tick()
+ })
+ useEffect(() => {
+ if (!live) return
+ catchUp()
+ const interval = setInterval(tick, LIVE_TICK_MS)
+ document.addEventListener('visibilitychange', tick)
+ return () => {
+ clearInterval(interval)
+ document.removeEventListener('visibilitychange', tick)
+ }
+ }, [live])
+
+ const onZoom = (selected: DashboardTimeRange) => {
+ 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,
+ 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()
+ },
+ 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
new file mode 100644
index 00000000000..60aafc426bd
--- /dev/null
+++ b/apps/sim/lib/charts/annotations.test.ts
@@ -0,0 +1,119 @@
+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('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)
+ })
+
+ 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..79c41a1e2f6
--- /dev/null
+++ b/apps/sim/lib/charts/annotations.ts
@@ -0,0 +1,176 @@
+import { filterUndefined, 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' {
+ 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)
+ 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'
+}
+
+/** 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.
+ */
+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
+ assertAnnotatable(option)
+ const series = Array.isArray(option.series) ? option.series : [option.series]
+ const first = toRecord(series[0])
+
+ 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',
+ ...filterUndefined({
+ xAxisIndex: first.xAxisIndex,
+ yAxisIndex: first.yAxisIndex,
+ xAxisId: first.xAxisId,
+ yAxisId: first.yAxisId,
+ }),
+ 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/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/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/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/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.test.ts b/apps/sim/lib/dashboards/spec.test.ts
index 2df96eb0eba..7907c851ec7 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,103 @@ 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('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(
+ `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..e9c54557013 100644
--- a/apps/sim/lib/dashboards/spec.ts
+++ b/apps/sim/lib/dashboards/spec.ts
@@ -2,7 +2,19 @@ import { getErrorMessage } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import { JSON_SCHEMA, load } from 'js-yaml'
import { z } from 'zod'
-import { parseChartSpec } from '@/lib/charts/spec'
+import {
+ assertAnnotatable,
+ CHART_TONES,
+ type ChartHighlight,
+ type ChartThreshold,
+ valueAxisKey,
+} from '@/lib/charts/annotations'
+import { isTimeSeriesOption, 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 +24,28 @@ import {
/** Dashboard YAML is bounded before parsing; writes report the same limit without decoding. */
export const MAX_DASHBOARD_SOURCE_BYTES = 128 * 1024
+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 +71,7 @@ export interface DashboardChart extends BlockBase {
chart: string
source?: DashboardSource
option: Record
+ thresholds?: ChartThreshold[]
}
export interface DashboardTable extends BlockBase {
table: string
@@ -57,19 +87,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 +179,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 +235,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 +244,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 +258,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 +281,129 @@ 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,
+ highlights: ChartHighlight[] | undefined
+): 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)
+ if (block.thresholds || (highlights && isTimeSeriesOption(block.option)))
+ assertAnnotatable(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, parsed.data.highlights)
+ 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,
+ parsed.data.highlights
+ )
+ 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,
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": {