@@ -459,6 +459,85 @@ describe('MCP Dashboard Tools', () => {
459459 } ) ;
460460 } ) ;
461461
462+ it ( 'should round-trip every MCP-specific heatmap field through save, get, update, and re-get' , async ( ) => {
463+ const sourceId = traceSource . _id . toString ( ) ;
464+
465+ const createConfig = {
466+ displayType : 'heatmap' as const ,
467+ sourceId,
468+ select : [
469+ {
470+ valueExpression : 'Duration' ,
471+ countExpression : 'count()' ,
472+ heatmapScaleType : 'log' as const ,
473+ } ,
474+ ] ,
475+ where : 'level:error' ,
476+ whereLanguage : 'lucene' as const ,
477+ numberFormat : { output : 'duration' as const , factor : 1e-9 } ,
478+ } ;
479+ // Mutate select + where on update; numberFormat, whereLanguage,
480+ // and sourceId carry forward via the spread. Re-asserting against
481+ // updatedConfig catches a regression where PUT silently drops any
482+ // carried-forward field.
483+ const updatedConfig = {
484+ ...createConfig ,
485+ select : [
486+ {
487+ valueExpression : "SpanAttributes['http.duration']" ,
488+ heatmapScaleType : 'linear' as const ,
489+ } ,
490+ ] ,
491+ where : 'level:error AND service:checkout' ,
492+ } ;
493+
494+ const saveResult = await callTool ( client , 'hyperdx_save_dashboard' , {
495+ name : 'Heatmap Full Round-Trip' ,
496+ tiles : [
497+ {
498+ name : 'Latency Heatmap' ,
499+ x : 0 ,
500+ y : 0 ,
501+ w : 12 ,
502+ h : 4 ,
503+ config : createConfig ,
504+ } ,
505+ ] ,
506+ } ) ;
507+ expect ( saveResult . isError ) . toBeFalsy ( ) ;
508+ const saved = JSON . parse ( getFirstText ( saveResult ) ) ;
509+ expect ( saved . tiles ) . toHaveLength ( 1 ) ;
510+ expect ( saved . tiles [ 0 ] . config ) . toMatchObject ( createConfig ) ;
511+
512+ const getResult = await callTool ( client , 'hyperdx_get_dashboard' , {
513+ id : saved . id ,
514+ } ) ;
515+ expect ( getResult . isError ) . toBeFalsy ( ) ;
516+ const fetched = JSON . parse ( getFirstText ( getResult ) ) ;
517+ expect ( fetched . tiles [ 0 ] . config ) . toMatchObject ( createConfig ) ;
518+
519+ const updateResult = await callTool ( client , 'hyperdx_save_dashboard' , {
520+ id : saved . id ,
521+ name : 'Heatmap Full Round-Trip' ,
522+ tiles : [
523+ {
524+ ...fetched . tiles [ 0 ] ,
525+ config : { ...fetched . tiles [ 0 ] . config , ...updatedConfig } ,
526+ } ,
527+ ] ,
528+ } ) ;
529+ expect ( updateResult . isError ) . toBeFalsy ( ) ;
530+ const updated = JSON . parse ( getFirstText ( updateResult ) ) ;
531+ expect ( updated . tiles [ 0 ] . config ) . toMatchObject ( updatedConfig ) ;
532+
533+ const getAfterUpdate = await callTool ( client , 'hyperdx_get_dashboard' , {
534+ id : saved . id ,
535+ } ) ;
536+ expect ( getAfterUpdate . isError ) . toBeFalsy ( ) ;
537+ const refetched = JSON . parse ( getFirstText ( getAfterUpdate ) ) ;
538+ expect ( refetched . tiles [ 0 ] . config ) . toMatchObject ( updatedConfig ) ;
539+ } ) ;
540+
462541 it ( 'should reject heatmap tile with empty valueExpression at the schema layer' , async ( ) => {
463542 const sourceId = traceSource . _id . toString ( ) ;
464543 const result = await callTool ( client , 'hyperdx_save_dashboard' , {
@@ -594,6 +673,160 @@ describe('MCP Dashboard Tools', () => {
594673 expect ( text ) . toContain ( 'Trace source' ) ;
595674 expect ( text ) . toContain ( logSource . _id . toString ( ) ) ;
596675 } ) ;
676+
677+ // Exercises the update-side source-kind gate via filterChangedHeatmapTiles
678+ // (displayType changed to heatmap on an existing tile).
679+ it ( 'should reject update that changes a tile to heatmap on a non-Trace source' , async ( ) => {
680+ const logSource = await Source . create ( {
681+ kind : SourceKind . Log ,
682+ team : team . _id ,
683+ from : { databaseName : DEFAULT_DATABASE , tableName : 'otel_logs' } ,
684+ timestampValueExpression : 'Timestamp' ,
685+ connection : connection . _id ,
686+ name : 'Logs' ,
687+ } ) ;
688+
689+ const created = await callTool ( client , 'hyperdx_save_dashboard' , {
690+ name : 'Line on Log Source' ,
691+ tiles : [
692+ {
693+ name : 'Line' ,
694+ config : {
695+ displayType : 'line' ,
696+ sourceId : logSource . _id . toString ( ) ,
697+ select : [ { aggFn : 'count' } ] ,
698+ } ,
699+ } ,
700+ ] ,
701+ } ) ;
702+ expect ( created . isError ) . toBeFalsy ( ) ;
703+ const saved = JSON . parse ( getFirstText ( created ) ) ;
704+
705+ const update = await callTool ( client , 'hyperdx_save_dashboard' , {
706+ id : saved . id ,
707+ name : 'Line on Log Source' ,
708+ tiles : [
709+ {
710+ ...saved . tiles [ 0 ] ,
711+ config : {
712+ ...saved . tiles [ 0 ] . config ,
713+ displayType : 'heatmap' ,
714+ select : [ { valueExpression : 'Duration' } ] ,
715+ } ,
716+ } ,
717+ ] ,
718+ } ) ;
719+ expect ( update . isError ) . toBe ( true ) ;
720+ const text = getFirstText ( update ) ;
721+ expect ( text ) . toContain ( 'Trace source' ) ;
722+ expect ( text ) . toContain ( logSource . _id . toString ( ) ) ;
723+ } ) ;
724+
725+ // Exercises the update-side source-kind gate via filterChangedHeatmapTiles
726+ // (sourceId changed on an existing heatmap tile).
727+ it ( 'should reject update that changes a heatmap tile source to a non-Trace source' , async ( ) => {
728+ const logSource = await Source . create ( {
729+ kind : SourceKind . Log ,
730+ team : team . _id ,
731+ from : { databaseName : DEFAULT_DATABASE , tableName : 'otel_logs' } ,
732+ timestampValueExpression : 'Timestamp' ,
733+ connection : connection . _id ,
734+ name : 'Logs' ,
735+ } ) ;
736+
737+ const created = await callTool ( client , 'hyperdx_save_dashboard' , {
738+ name : 'Heatmap re-pointed at Log' ,
739+ tiles : [
740+ {
741+ name : 'Heatmap' ,
742+ config : {
743+ displayType : 'heatmap' ,
744+ sourceId : traceSource . _id . toString ( ) ,
745+ select : [ { valueExpression : 'Duration' } ] ,
746+ } ,
747+ } ,
748+ ] ,
749+ } ) ;
750+ expect ( created . isError ) . toBeFalsy ( ) ;
751+ const saved = JSON . parse ( getFirstText ( created ) ) ;
752+
753+ const update = await callTool ( client , 'hyperdx_save_dashboard' , {
754+ id : saved . id ,
755+ name : 'Heatmap re-pointed at Log' ,
756+ tiles : [
757+ {
758+ ...saved . tiles [ 0 ] ,
759+ config : {
760+ ...saved . tiles [ 0 ] . config ,
761+ sourceId : logSource . _id . toString ( ) ,
762+ } ,
763+ } ,
764+ ] ,
765+ } ) ;
766+ expect ( update . isError ) . toBe ( true ) ;
767+ const text = getFirstText ( update ) ;
768+ expect ( text ) . toContain ( 'Trace source' ) ;
769+ expect ( text ) . toContain ( logSource . _id . toString ( ) ) ;
770+ } ) ;
771+
772+ // Asserts each tile's config survives the serializer/deserializer cycle
773+ // independently when mixed with other displayTypes on the same dashboard.
774+ it ( 'should round-trip a heatmap alongside line and number tiles in one dashboard' , async ( ) => {
775+ const sourceId = traceSource . _id . toString ( ) ;
776+
777+ const heatmapConfig = {
778+ displayType : 'heatmap' as const ,
779+ sourceId,
780+ select : [
781+ { valueExpression : 'Duration' , heatmapScaleType : 'log' as const } ,
782+ ] ,
783+ } ;
784+ const lineConfig = {
785+ displayType : 'line' as const ,
786+ sourceId,
787+ select : [ { aggFn : 'count' as const } ] ,
788+ groupBy : "SpanAttributes['service.name']" ,
789+ } ;
790+ const numberConfig = {
791+ displayType : 'number' as const ,
792+ sourceId,
793+ select : [ { aggFn : 'count' as const } ] ,
794+ numberFormat : { output : 'number' as const , average : true } ,
795+ } ;
796+
797+ const save = await callTool ( client , 'hyperdx_save_dashboard' , {
798+ name : 'Mixed Tile Round-Trip' ,
799+ tiles : [
800+ { name : 'Heatmap Tile' , config : heatmapConfig } ,
801+ { name : 'Line Tile' , config : lineConfig } ,
802+ { name : 'Number Tile' , config : numberConfig } ,
803+ ] ,
804+ } ) ;
805+ expect ( save . isError ) . toBeFalsy ( ) ;
806+ const saved = JSON . parse ( getFirstText ( save ) ) ;
807+ expect ( saved . tiles ) . toHaveLength ( 3 ) ;
808+
809+ const byName : Record < string , ExternalDashboardTileWithId > =
810+ Object . fromEntries (
811+ saved . tiles . map ( ( t : ExternalDashboardTileWithId ) => [ t . name , t ] ) ,
812+ ) ;
813+ expect ( byName [ 'Heatmap Tile' ] . config ) . toMatchObject ( heatmapConfig ) ;
814+ expect ( byName [ 'Line Tile' ] . config ) . toMatchObject ( lineConfig ) ;
815+ expect ( byName [ 'Number Tile' ] . config ) . toMatchObject ( numberConfig ) ;
816+
817+ const fetched = JSON . parse (
818+ getFirstText (
819+ await callTool ( client , 'hyperdx_get_dashboard' , { id : saved . id } ) ,
820+ ) ,
821+ ) ;
822+ const fetchedByName : Record < string , ExternalDashboardTileWithId > =
823+ Object . fromEntries (
824+ fetched . tiles . map ( ( t : ExternalDashboardTileWithId ) => [ t . name , t ] ) ,
825+ ) ;
826+ expect ( fetchedByName [ 'Heatmap Tile' ] . config ) . toMatchObject ( heatmapConfig ) ;
827+ expect ( fetchedByName [ 'Line Tile' ] . config ) . toMatchObject ( lineConfig ) ;
828+ expect ( fetchedByName [ 'Number Tile' ] . config ) . toMatchObject ( numberConfig ) ;
829+ } ) ;
597830 } ) ;
598831
599832 describe ( 'hyperdx_save_dashboard - containers and tabs' , ( ) => {
@@ -653,7 +886,7 @@ describe('MCP Dashboard Tools', () => {
653886 containerId : 'service-health' ,
654887 tabId : 'latency' ,
655888 } ) ,
656- // Tile inside a tabbed container without a tabId — renders in
889+ // Tile inside a tabbed container without a tabId renders in
657890 // the container shell rather than under a tab. Guards that the
658891 // schema does not accidentally require tabId for every tile in
659892 // a tabbed container.
0 commit comments