The file /Users/jan/tmp/cc-backend/web/frontend/src/generic/plots/MetricPlot.svelte has been updated successfully.

  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
  const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);

<!--
  @component Main plot component, based on uPlot; metricdata values by time

  Only width/height should change reactively.

  Properties:
  - `metric String`: The metric name
  - `scope String?`: Scope of the displayed data [Default: node]
  - `height Number?`: The plot height [Default: 300]
  - `timestep Number`: The timestep used for X-axis rendering
  - `series [GraphQL.Series]`: The metric data object
  - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
  - `cluster String?`: Cluster name of the parent job / data [Default: ""]
  - `subCluster String`: Name of the subCluster of the parent job
  - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false]
  - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
  - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
  - `numaccs Number?`: Number of job Accelerators [Default: 0]
  - `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
  - `thersholdState Object?`: The last threshold state to preserve on user zoom [Default: null]
  - `extendedLegendData Object?`: Additional information to be rendered in an extended legend [Default: null]
  - `onZoom Func`: Callback function to handle zoom-in event
-->

<script>
  import uPlot from "uplot";
  import { formatNumber, formatDurationTime } from "../units.js";
  import { getContext, onMount, onDestroy } from "svelte";
  import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";

  /* Svelte 5 Props */
  let {
    metric,
    scope = "node",
    width = 0,
    height = 300,
    timestep,
    series,
    statisticsSeries = null,
    cluster = "",
    subCluster,
    isShared = false,
    forNode = false,
    zoomState = null,
    thresholdState = null,
    extendedLegendData = null,
    plotSync = null,
    enableFlip = false,
    onZoom
  } = $props();

  /* Const Init */
  const clusterCockpitConfig = getContext("cc-config");
  const resampleConfig = getContext("resampling");
  const lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
  const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
  const renderSleepTime = 200;
  const normalLineColor = "#000000";
  const backgroundColors = {
    normal: "rgba(255, 255, 255, 1.0)",
    caution: cbmode ? "rgba(239, 230, 69, 0.3)" : "rgba(255, 128, 0, 0.3)",
    alert: cbmode ? "rgba(225, 86, 44, 0.3)" : "rgba(255, 0, 0, 0.3)",
  };

  /* Var Init */
  let timeoutId = null;

  /* State Init */
  let plotWrapper = $state(null);
  let uplot = $state(null);

  /* Derived */
  const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
  const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
  const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
  const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
  const thresholds = $derived(findJobAggregationThresholds(
    subClusterTopology,
    metricConfig,
    scope
  ));
  const longestSeries = $derived.by(() => {
    if (useStatsSeries) {
      return usesMeanStatsSeries ? statisticsSeries?.mean?.length : statisticsSeries?.median?.length;
    } else {
      return series.reduce((n, series) => Math.max(n, series.data.length), 0);
    }
  });
  const maxX = $derived(longestSeries * timestep);
  const maxY = $derived.by(() => {
    let pendingY = 0;
    if (useStatsSeries) {
      pendingY = statisticsSeries.max.reduce(
        (max, x) => Math.max(max, x),
        thresholds?.normal,
      ) || thresholds?.normal
    } else {
      pendingY = series.reduce(
          (max, series) => Math.max(max, series?.statistics?.max),
          thresholds?.normal,
        ) || thresholds?.normal;
    }

    if (pendingY >= 10 * thresholds.peak) {
      // Hard y-range render limit if outliers in series data
      return (10 * thresholds.peak);
    } else {
      return pendingY;
    }
  });
  const plotBands = $derived.by(() => {
    if (useStatsSeries) {
      return [
        { series: [2, 3], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
        { series: [3, 1], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
      ];
    };
    return null;
  })
  const plotData = $derived.by(() => {
    let pendingData = [new Array(longestSeries)];
    // X
    if (forNode === true) {
      // Negative Timestamp Buildup
      for (let i = 0; i <= longestSeries; i++) {
        pendingData[0][i] = (longestSeries - i) * timestep * -1;
      }
    } else {
      // Positive Timestamp Buildup
      for (let j = 0; j < longestSeries; j++) {
        pendingData[0][j] = j * timestep;
      };
    };
    // Y
    if (useStatsSeries) {
      pendingData.push(statisticsSeries.min);
      pendingData.push(statisticsSeries.max);
      if (usesMeanStatsSeries) {
        pendingData.push(statisticsSeries.mean);
      } else {
        pendingData.push(statisticsSeries.median);
      }

    } else {
      for (let i = 0; i < series.length; i++) {
        pendingData.push(series[i].data);
      };
    };
    return pendingData;
  })
  const plotSeries = $derived.by(() => {
    let pendingSeries = [
      // Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
      {
        label: "Runtime",
        value: (u, ts, sidx, didx) =>
        (didx == null) ? null : formatDurationTime(ts, forNode),
      }
    ];
    // Y
    if (useStatsSeries) {
      pendingSeries.push({
        label: "min",
        scale: "y",
        width: lineWidth,
        stroke: cbmode ? "rgb(0,255,0)" : "red",
      });
      pendingSeries.push({
        label: "max",
        scale: "y",
        width: lineWidth,
        stroke: cbmode ? "rgb(0,0,255)" : "green",
      });
      pendingSeries.push({
        label: usesMeanStatsSeries ? "mean" : "median",
        scale: "y",
        width: lineWidth,
        stroke: "black",
      });

    } else {
      for (let i = 0; i < series?.length; i++) {
        // Default
        if (!extendedLegendData) {
          pendingSeries.push({
            label:
              scope === "node"
              ? series[i].hostname
              : scope === "accelerator"
                ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
                : scope + " #" + (i + 1),
            scale: "y",
            width: lineWidth,
            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
          });
        }
        // Extended Legend For NodeList
        else {
          pendingSeries.push({
            label:
              scope === "node"
                ? series[i].hostname
                : scope === "accelerator"
                  ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
                  : scope + " #" + (i + 1),
            scale: "y",
            width: lineWidth,
            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
            values: (u, sidx, idx) => {
              // "i" = "sidx - 1" : sidx contains x-axis-data
              if (idx == null)
                return {
                  time: '-',
                  value: '-',
                  user: '-',
                  job: '-'
                };

              if (series[i].id in extendedLegendData) {
                return {
                  time: formatDurationTime(plotData[0][idx], forNode),
                  value: plotData[sidx][idx],
                  user: extendedLegendData[series[i].id].user,
                  job: extendedLegendData[series[i].id].job,
                };
              } else {
                return {
                  time: formatDurationTime(plotData[0][idx], forNode),
                  value: plotData[sidx][idx],
                  user: '-',
                  job: '-',
                };
              }
            }
          });
        }
      };
    };
    return pendingSeries;
  })

  // This updates plot on all size changes if wrapper (== data) exists
  $effect(() => {
    if (plotWrapper) {
      onSizeChange(width, height);
    }
  });

  /* Functions */
  function timeIncrs(timestep, maxX, forNode) {
    if (forNode === true) {
      return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
    } else {
      let incrs = [];
      for (let t = timestep; t < maxX; t *= 10)
        incrs.push(t, t * 2, t * 3, t * 5);

      return incrs;
    }
  }

  // removed arg "subcluster": input metricconfig and topology now directly derived from subcluster
  function findJobAggregationThresholds(
    subClusterTopology,
    metricConfig,
    scope
  ) {

    if (!subClusterTopology || !metricConfig || !scope) {
      console.warn("Argument missing for findJobAggregationThresholds!");
      return null;
    }

    // handle special *-stat scopes
    if (scope.match(/(.*)-stat$/)) {
      const statParts = scope.split('-');
      scope = statParts[0]
    }

    if (metricConfig?.aggregation == "avg") {
      // Return as Configured
      return {
        normal: metricConfig.normal,
        caution: metricConfig.caution,
        alert: metricConfig.alert,
        peak: metricConfig.peak,
      };
    }

    if (metricConfig?.aggregation == "sum") {
      let divisor;
      if (scope == 'node')              divisor = 1 // Node Scope: Always return unscaled (Maximum Scope)
      // Partial Scopes: Get from Topologies
      else if (scope == 'socket')       divisor = subClusterTopology?.socket?.length || 1;
      else if (scope == "memoryDomain") divisor = subClusterTopology?.memoryDomain?.length || 1;
      else if (scope == "core")         divisor = subClusterTopology?.core?.length || 1;
      else if (scope == "hwthread")     divisor = subClusterTopology?.node?.length || 1;
      else if (scope == "accelerator")  divisor = subClusterTopology?.accelerators?.length || 1;
      else {
        console.log('Unknown scope, return default aggregation thresholds for sum', scope)
        divisor = 1;
      }

      return {
        peak: metricConfig.peak / divisor,
        normal: metricConfig.normal / divisor,
        caution: metricConfig.caution / divisor,
        alert: metricConfig.alert / divisor,
      };
    }

    console.warn(
      "Missing or unkown aggregation mode (sum/avg) for metric:",
      metricConfig,
    );
    return null;
  }

  // UPLOT PLUGIN // converts the legend into a simple tooltip
  function legendAsTooltipPlugin({
    className,
    style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
  } = {}) {
    let legendEl;
    const dataSize = series.length;

    function init(u, opts) {
      legendEl = u.root.querySelector(".u-legend");

      legendEl.classList.remove("u-inline");
      className && legendEl.classList.add(className);

      uPlot.assign(legendEl.style, {
        minWidth: extendedLegendData ? "300px" : "100px",
        textAlign: "left",
        pointerEvents: "none",
        display: "none",
        position: "absolute",
        left: 0,
        top: 0,
        zIndex: 100,
        boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
        ...style,
      });

      // conditional hide series color markers:
      if (
        useStatsSeries || // Min/Max/Median Self-Explanatory
        dataSize === 1 || // Only one Y-Dataseries
        dataSize > 8 // More than 8 Y-Dataseries
      ) {
        const idents = legendEl.querySelectorAll(".u-marker");
        for (let i = 0; i < idents.length; i++)
          idents[i].style.display = "none";
      }

      const overEl = u.over;
      overEl.style.overflow = "visible";

      // move legend into plot bounds
      overEl.appendChild(legendEl);

      // show/hide tooltip on enter/exit
      overEl.addEventListener("mouseenter", () => {
        legendEl.style.display = null;
      });
      overEl.addEventListener("mouseleave", () => {
        legendEl.style.display = "none";
      });

      // let tooltip exit plot
      // overEl.style.overflow = "visible";
    }

    function update(u) {
      const { left, top } = u.cursor;
      const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
      if (enableFlip && (left < (width/2))) {
        legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
      } else {
        legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
      }
    }

    if (dataSize <= 12 || useStatsSeries) {
      return {
        hooks: {
          init: init,
          setCursor: update,
        },
      };
    } else {
      // Setting legend-opts show/live as object with false here will not work ...
      return {};
    }
  }

  // RETURN BG COLOR FROM THRESHOLD
  function backgroundColor() {
    if (
      clusterCockpitConfig.plotConfiguration_colorBackground == false ||
      !thresholds ||
      !(series && series.every((s) => s.statistics != null))
    )
      return backgroundColors.normal;

    let cond =
      thresholds.alert < thresholds.caution
        ? (a, b) => a <= b
        : (a, b) => a >= b;

    let avg =
      series.reduce((sum, series) => sum + series.statistics.avg, 0) /
      series.length;

    if (Number.isNaN(avg)) return backgroundColors.normal;

    if (cond(avg, thresholds.alert)) return backgroundColors.alert;

    if (cond(avg, thresholds.caution)) return backgroundColors.caution;

    return backgroundColors.normal;
  }

  function lineColor(index, colors) {
    return colors[index % colors.length];
  }

  function render(ren_width, ren_height) {
    // Set Options
    const opts = {
      width,
      height,
      plugins: [legendAsTooltipPlugin()],
      series: plotSeries,
      axes: [
        {
          scale: "x",
          space: 35,
          incrs: timeIncrs(timestep, maxX, forNode),
          values: (_, vals) => vals.map((v) => formatDurationTime(v, forNode)),
        },
        {
          scale: "y",
          grid: { show: true },
          labelFont: "sans-serif",
          values: (u, vals) => vals.map((v) => formatNumber(v)),
        },
      ],
      bands: plotBands,
      padding: [5, 10, -20, 0],
      hooks: {
        init: [
          (u) => {
            /* IF Zoom Enabled */
            if (resampleConfig && !forNode) {
              u.over.addEventListener("dblclick", (e) => {
                // console.log('Dispatch: Zoom Reset')
                onZoom({
                  lastZoomState: {
                    x: { time: false },
                    y: { auto: true }
                  }
                });
              });
            };
          },
        ],
        draw: [
          (u) => {
            // Draw plot type label:
            let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${
              useStatsSeries
                ? (usesMeanStatsSeries ? ": min/mean/max" : ": min/median/max")
                : metricConfig != null && scope != metricConfig.scope
                  ? ` (${metricConfig.aggregation})`
                  : ""
            }`;
            let textr = `${isShared && scope != "core" && scope != "accelerator" ? "[Shared]" : ""}`;
            u.ctx.save();
            u.ctx.textAlign = "start"; // 'end'
            u.ctx.fillStyle = "black";
            u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
            u.ctx.textAlign = "end";
            u.ctx.fillStyle = "black";
            u.ctx.fillText(
              textr,
              u.bbox.left + u.bbox.width - 10,
              u.bbox.top + (forNode ? 0 : 10),
            );
            // u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right

            if (!thresholds) {
              u.ctx.restore();
              return;
            }

            let y = u.valToPos(thresholds.normal, "y", true);
            u.ctx.save();
            u.ctx.lineWidth = lineWidth;
            u.ctx.strokeStyle = normalLineColor;
            u.ctx.setLineDash([5, 5]);
            u.ctx.beginPath();
            u.ctx.moveTo(u.bbox.left, y);
            u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
            u.ctx.stroke();
            u.ctx.restore();
          },
        ],
        setScale: [
          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
                // Which resolution to theoretically request to achieve 30 or more visible data points:
                const target = (numX * timestep) / resampleTrigger
                // Which configured resolution actually matches the closest to theoretical target:
                const closest = resampleResolutions.reduce(function(prev, curr) {
                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                });
                // Prevents non-required dispatches
                if (timestep !== closest) {
                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
                  onZoom({
                    newRes: closest,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                // console.log('Dispatch: Zoom Update States')
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },
        ]
      },
      scales: {
        x: { time: false },
        y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
      },
      legend: {
        // Display legend until max 12 Y-dataseries
        show: series.length <= 12 || useStatsSeries,
        live: series.length <= 12 || useStatsSeries,
      },
      cursor: {
        drag: { x: true, y: true },
      }
    };

    // Handle Render
    if (!uplot) {
      opts.width = ren_width;
      opts.height = ren_height;

      if (plotSync) {
        opts.cursor.sync = {
          key: plotSync.key,
          scales: ["x", null],
        }
      }

      if (zoomState && metricConfig?.aggregation == "avg") {
        opts.scales = {...zoomState}
      } else if (zoomState && metricConfig?.aggregation == "sum") {
        // Allow Zoom In === Ymin changed
        if (zoomState.y.min !== 0) { // scope change?: only use zoomState if thresholds match
          if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
        } // else: reset scaling to default
      }

      uplot = new uPlot(opts, plotData, plotWrapper);
    } else {
      uplot.setSize({ width: ren_width, height: ren_height });
    }
  }

  function onSizeChange(chg_width, chg_height) {
    if (!uplot) return;
    if (timeoutId != null) clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      timeoutId = null;
      render(chg_width, chg_height);
    }, renderSleepTime);
  }

  /* On Mount */
  onMount(() => {
    if (plotWrapper) {
      render(width, height);
    }
  });

  /* On Destroy */
  onDestroy(() => {
    if (timeoutId != null) clearTimeout(timeoutId);
    if (uplot) uplot.destroy();
  });

</script>

<!-- Define $width Wrapper and NoData Card -->
{#if series[0]?.data && series[0].data.length > 0}
  <div bind:this={plotWrapper} bind:clientWidth={width}
        style="background-color: {backgroundColor()};" class={forNode ? 'py-2 rounded' : 'rounded'}
  ></div>
{:else}
  <Card color="warning" class={forNode ? 'mx-2' : 'mt-2'}>
    <CardHeader class="mb-0">
      <b>Empty Metric</b>
    </CardHeader>
    <CardBody>
      <p>Cannot render plot for <b>{metric}</b>.</p>
      <p class="mb-1">Metric found but returned without timeseries data.</p>
    </CardBody>
  </Card>
{/if}

-  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);

+  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));

+  const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);


Now update the setScale hook to support dynamic resolution computation.


   513→        ],
   514→        setScale: [
   515→          (u, key) => { // If ZoomResample is Configured && Not System/Node View
   516→            if (resampleConfig && !forNode && key === 'x') {
   517→              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
   518→              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
   519→                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
   520→                // Which resolution to theoretically request to achieve 30 or more visible data points:
   521→                const target = (numX * timestep) / resampleTrigger
   522→                // Which configured resolution actually matches the closest to theoretical target:
   523→                const closest = resampleResolutions.reduce(function(prev, curr) {
   524→                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
   525→                });
   526→                // Prevents non-required dispatches
   527→                if (timestep !== closest) {
   528→                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
   529→                  onZoom({
   530→                    newRes: closest,
   531→                    lastZoomState: u?.scales,
   532→                    lastThreshold: thresholds?.normal
   533→                  });
   534→                }
   535→              } else {
   536→                // console.log('Dispatch: Zoom Update States')
   537→                onZoom({
   538→                  lastZoomState: u?.scales,
   539→                  lastThreshold: thresholds?.normal
   540→                });
   541→              };
   542→            };
   543→          },
   544→        ]
   545→      },
   546→      scales: {
   547→        x: { time: false },

        ],
        setScale: [
          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
                // Which resolution to theoretically request to achieve 30 or more visible data points:
                const target = (numX * timestep) / resampleTrigger
                // Which configured resolution actually matches the closest to theoretical target:
                const closest = resampleResolutions.reduce(function(prev, curr) {
                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                });
                // Prevents non-required dispatches
                if (timestep !== closest) {
                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
                  onZoom({
                    newRes: closest,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                // console.log('Dispatch: Zoom Update States')
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },
        ]
      },
      scales: {
        x: { time: false },


I also need to check what `frequency` variable refers to in this file — it might be the native metric timestep. Let me check the MetricPlot around line 459 where `resampleConfig` is also referenced.


   455→      padding: [5, 10, -20, 0],
   456→      hooks: {
   457→        init: [
   458→          (u) => {
   459→            /* IF Zoom Enabled */
   460→            if (resampleConfig && !forNode) {
   461→              u.over.addEventListener("dblclick", (e) => {
   462→                // console.log('Dispatch: Zoom Reset')
   463→                onZoom({
   464→                  lastZoomState: {
   465→                    x: { time: false },
   466→                    y: { auto: true }
   467→                  }
   468→                });
   469→              });

      padding: [5, 10, -20, 0],
      hooks: {
        init: [
          (u) => {
            /* IF Zoom Enabled */
            if (resampleConfig && !forNode) {
              u.over.addEventListener("dblclick", (e) => {
                // console.log('Dispatch: Zoom Reset')
                onZoom({
                  lastZoomState: {
                    x: { time: false },
                    y: { auto: true }
                  }
                });
              });


The `timestep` prop is the native metric timestep. Now I'll update the setScale hook to support both array-based and policy-based resolution computation.


Edit

          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
                // Which resolution to theoretically request to achieve 30 or more visible data points:
                const target = (numX * timestep) / resampleTrigger
                // Which configured resolution actually matches the closest to theoretical target:
                const closest = resampleResolutions.reduce(function(prev, curr) {
                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                });
                // Prevents non-required dispatches
                if (timestep !== closest) {
                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
                  onZoom({
                    newRes: closest,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                // console.log('Dispatch: Zoom Update States')
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },

          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                let newRes;
                if (resampleTargetPoints && !resampleResolutions) {
                  // Policy-based: compute resolution dynamically from visible window
                  const visibleDuration = (u.scales.x.max - u.scales.x.min);
                  const nativeTimestep = metricConfig?.timestep || timestep;
                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
                  if (newRes < nativeTimestep) newRes = nativeTimestep;
                } else if (resampleResolutions) {
                  // Array-based: find closest configured resolution
                  const target = (numX * timestep) / resampleTrigger;
                  newRes = resampleResolutions.reduce(function(prev, curr) {
                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                  });
                }
                // Prevents non-required dispatches
                if (newRes && timestep !== newRes) {
                  onZoom({
                    newRes: newRes,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },


          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                let newRes;
                if (resampleTargetPoints && !resampleResolutions) {
                  // Policy-based: compute resolution dynamically from visible window
                  const visibleDuration = (u.scales.x.max - u.scales.x.min);
                  const nativeTimestep = metricConfig?.timestep || timestep;
                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
                  if (newRes < nativeTimestep) newRes = nativeTimestep;
                } else if (resampleResolutions) {
                  // Array-based: find closest configured resolution
                  const target = (numX * timestep) / resampleTrigger;
                  newRes = resampleResolutions.reduce(function(prev, curr) {
                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                  });
                }
                // Prevents non-required dispatches
                if (newRes && timestep !== newRes) {
                  onZoom({
                    newRes: newRes,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },

<!--
  @component Main plot component, based on uPlot; metricdata values by time

  Only width/height should change reactively.

  Properties:
  - `metric String`: The metric name
  - `scope String?`: Scope of the displayed data [Default: node]
  - `height Number?`: The plot height [Default: 300]
  - `timestep Number`: The timestep used for X-axis rendering
  - `series [GraphQL.Series]`: The metric data object
  - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
  - `cluster String?`: Cluster name of the parent job / data [Default: ""]
  - `subCluster String`: Name of the subCluster of the parent job
  - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false]
  - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
  - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
  - `numaccs Number?`: Number of job Accelerators [Default: 0]
  - `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
  - `thersholdState Object?`: The last threshold state to preserve on user zoom [Default: null]
  - `extendedLegendData Object?`: Additional information to be rendered in an extended legend [Default: null]
  - `onZoom Func`: Callback function to handle zoom-in event
-->

<script>
  import uPlot from "uplot";
  import { formatNumber, formatDurationTime } from "../units.js";
  import { getContext, onMount, onDestroy } from "svelte";
  import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";

  /* Svelte 5 Props */
  let {
    metric,
    scope = "node",
    width = 0,
    height = 300,
    timestep,
    series,
    statisticsSeries = null,
    cluster = "",
    subCluster,
    isShared = false,
    forNode = false,
    zoomState = null,
    thresholdState = null,
    extendedLegendData = null,
    plotSync = null,
    enableFlip = false,
    onZoom
  } = $props();

  /* Const Init */
  const clusterCockpitConfig = getContext("cc-config");
  const resampleConfig = getContext("resampling");
  const lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
  const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
  const renderSleepTime = 200;
  const normalLineColor = "#000000";
  const backgroundColors = {
    normal: "rgba(255, 255, 255, 1.0)",
    caution: cbmode ? "rgba(239, 230, 69, 0.3)" : "rgba(255, 128, 0, 0.3)",
    alert: cbmode ? "rgba(225, 86, 44, 0.3)" : "rgba(255, 0, 0, 0.3)",
  };

  /* Var Init */
  let timeoutId = null;

  /* State Init */
  let plotWrapper = $state(null);
  let uplot = $state(null);

  /* Derived */
  const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
  const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
  const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
  const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);
  const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
  const thresholds = $derived(findJobAggregationThresholds(
    subClusterTopology,
    metricConfig,
    scope
  ));
  const longestSeries = $derived.by(() => {
    if (useStatsSeries) {
      return usesMeanStatsSeries ? statisticsSeries?.mean?.length : statisticsSeries?.median?.length;
    } else {
      return series.reduce((n, series) => Math.max(n, series.data.length), 0);
    }
  });
  const maxX = $derived(longestSeries * timestep);
  const maxY = $derived.by(() => {
    let pendingY = 0;
    if (useStatsSeries) {
      pendingY = statisticsSeries.max.reduce(
        (max, x) => Math.max(max, x),
        thresholds?.normal,
      ) || thresholds?.normal
    } else {
      pendingY = series.reduce(
          (max, series) => Math.max(max, series?.statistics?.max),
          thresholds?.normal,
        ) || thresholds?.normal;
    }

    if (pendingY >= 10 * thresholds.peak) {
      // Hard y-range render limit if outliers in series data
      return (10 * thresholds.peak);
    } else {
      return pendingY;
    }
  });
  const plotBands = $derived.by(() => {
    if (useStatsSeries) {
      return [
        { series: [2, 3], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
        { series: [3, 1], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
      ];
    };
    return null;
  })
  const plotData = $derived.by(() => {
    let pendingData = [new Array(longestSeries)];
    // X
    if (forNode === true) {
      // Negative Timestamp Buildup
      for (let i = 0; i <= longestSeries; i++) {
        pendingData[0][i] = (longestSeries - i) * timestep * -1;
      }
    } else {
      // Positive Timestamp Buildup
      for (let j = 0; j < longestSeries; j++) {
        pendingData[0][j] = j * timestep;
      };
    };
    // Y
    if (useStatsSeries) {
      pendingData.push(statisticsSeries.min);
      pendingData.push(statisticsSeries.max);
      if (usesMeanStatsSeries) {
        pendingData.push(statisticsSeries.mean);
      } else {
        pendingData.push(statisticsSeries.median);
      }

    } else {
      for (let i = 0; i < series.length; i++) {
        pendingData.push(series[i].data);
      };
    };
    return pendingData;
  })
  const plotSeries = $derived.by(() => {
    let pendingSeries = [
      // Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
      {
        label: "Runtime",
        value: (u, ts, sidx, didx) =>
        (didx == null) ? null : formatDurationTime(ts, forNode),
      }
    ];
    // Y
    if (useStatsSeries) {
      pendingSeries.push({
        label: "min",
        scale: "y",
        width: lineWidth,
        stroke: cbmode ? "rgb(0,255,0)" : "red",
      });
      pendingSeries.push({
        label: "max",
        scale: "y",
        width: lineWidth,
        stroke: cbmode ? "rgb(0,0,255)" : "green",
      });
      pendingSeries.push({
        label: usesMeanStatsSeries ? "mean" : "median",
        scale: "y",
        width: lineWidth,
        stroke: "black",
      });

    } else {
      for (let i = 0; i < series?.length; i++) {
        // Default
        if (!extendedLegendData) {
          pendingSeries.push({
            label:
              scope === "node"
              ? series[i].hostname
              : scope === "accelerator"
                ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
                : scope + " #" + (i + 1),
            scale: "y",
            width: lineWidth,
            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
          });
        }
        // Extended Legend For NodeList
        else {
          pendingSeries.push({
            label:
              scope === "node"
                ? series[i].hostname
                : scope === "accelerator"
                  ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
                  : scope + " #" + (i + 1),
            scale: "y",
            width: lineWidth,
            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
            values: (u, sidx, idx) => {
              // "i" = "sidx - 1" : sidx contains x-axis-data
              if (idx == null)
                return {
                  time: '-',
                  value: '-',
                  user: '-',
                  job: '-'
                };

              if (series[i].id in extendedLegendData) {
                return {
                  time: formatDurationTime(plotData[0][idx], forNode),
                  value: plotData[sidx][idx],
                  user: extendedLegendData[series[i].id].user,
                  job: extendedLegendData[series[i].id].job,
                };
              } else {
                return {
                  time: formatDurationTime(plotData[0][idx], forNode),
                  value: plotData[sidx][idx],
                  user: '-',
                  job: '-',
                };
              }
            }
          });
        }
      };
    };
    return pendingSeries;
  })

  // This updates plot on all size changes if wrapper (== data) exists
  $effect(() => {
    if (plotWrapper) {
      onSizeChange(width, height);
    }
  });

  /* Functions */
  function timeIncrs(timestep, maxX, forNode) {
    if (forNode === true) {
      return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
    } else {
      let incrs = [];
      for (let t = timestep; t < maxX; t *= 10)
        incrs.push(t, t * 2, t * 3, t * 5);

      return incrs;
    }
  }

  // removed arg "subcluster": input metricconfig and topology now directly derived from subcluster
  function findJobAggregationThresholds(
    subClusterTopology,
    metricConfig,
    scope
  ) {

    if (!subClusterTopology || !metricConfig || !scope) {
      console.warn("Argument missing for findJobAggregationThresholds!");
      return null;
    }

    // handle special *-stat scopes
    if (scope.match(/(.*)-stat$/)) {
      const statParts = scope.split('-');
      scope = statParts[0]
    }

    if (metricConfig?.aggregation == "avg") {
      // Return as Configured
      return {
        normal: metricConfig.normal,
        caution: metricConfig.caution,
        alert: metricConfig.alert,
        peak: metricConfig.peak,
      };
    }

    if (metricConfig?.aggregation == "sum") {
      let divisor;
      if (scope == 'node')              divisor = 1 // Node Scope: Always return unscaled (Maximum Scope)
      // Partial Scopes: Get from Topologies
      else if (scope == 'socket')       divisor = subClusterTopology?.socket?.length || 1;
      else if (scope == "memoryDomain") divisor = subClusterTopology?.memoryDomain?.length || 1;
      else if (scope == "core")         divisor = subClusterTopology?.core?.length || 1;
      else if (scope == "hwthread")     divisor = subClusterTopology?.node?.length || 1;
      else if (scope == "accelerator")  divisor = subClusterTopology?.accelerators?.length || 1;
      else {
        console.log('Unknown scope, return default aggregation thresholds for sum', scope)
        divisor = 1;
      }

      return {
        peak: metricConfig.peak / divisor,
        normal: metricConfig.normal / divisor,
        caution: metricConfig.caution / divisor,
        alert: metricConfig.alert / divisor,
      };
    }

    console.warn(
      "Missing or unkown aggregation mode (sum/avg) for metric:",
      metricConfig,
    );
    return null;
  }

  // UPLOT PLUGIN // converts the legend into a simple tooltip
  function legendAsTooltipPlugin({
    className,
    style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
  } = {}) {
    let legendEl;
    const dataSize = series.length;

    function init(u, opts) {
      legendEl = u.root.querySelector(".u-legend");

      legendEl.classList.remove("u-inline");
      className && legendEl.classList.add(className);

      uPlot.assign(legendEl.style, {
        minWidth: extendedLegendData ? "300px" : "100px",
        textAlign: "left",
        pointerEvents: "none",
        display: "none",
        position: "absolute",
        left: 0,
        top: 0,
        zIndex: 100,
        boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
        ...style,
      });

      // conditional hide series color markers:
      if (
        useStatsSeries || // Min/Max/Median Self-Explanatory
        dataSize === 1 || // Only one Y-Dataseries
        dataSize > 8 // More than 8 Y-Dataseries
      ) {
        const idents = legendEl.querySelectorAll(".u-marker");
        for (let i = 0; i < idents.length; i++)
          idents[i].style.display = "none";
      }

      const overEl = u.over;
      overEl.style.overflow = "visible";

      // move legend into plot bounds
      overEl.appendChild(legendEl);

      // show/hide tooltip on enter/exit
      overEl.addEventListener("mouseenter", () => {
        legendEl.style.display = null;
      });
      overEl.addEventListener("mouseleave", () => {
        legendEl.style.display = "none";
      });

      // let tooltip exit plot
      // overEl.style.overflow = "visible";
    }

    function update(u) {
      const { left, top } = u.cursor;
      const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
      if (enableFlip && (left < (width/2))) {
        legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
      } else {
        legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
      }
    }

    if (dataSize <= 12 || useStatsSeries) {
      return {
        hooks: {
          init: init,
          setCursor: update,
        },
      };
    } else {
      // Setting legend-opts show/live as object with false here will not work ...
      return {};
    }
  }

  // RETURN BG COLOR FROM THRESHOLD
  function backgroundColor() {
    if (
      clusterCockpitConfig.plotConfiguration_colorBackground == false ||
      !thresholds ||
      !(series && series.every((s) => s.statistics != null))
    )
      return backgroundColors.normal;

    let cond =
      thresholds.alert < thresholds.caution
        ? (a, b) => a <= b
        : (a, b) => a >= b;

    let avg =
      series.reduce((sum, series) => sum + series.statistics.avg, 0) /
      series.length;

    if (Number.isNaN(avg)) return backgroundColors.normal;

    if (cond(avg, thresholds.alert)) return backgroundColors.alert;

    if (cond(avg, thresholds.caution)) return backgroundColors.caution;

    return backgroundColors.normal;
  }

  function lineColor(index, colors) {
    return colors[index % colors.length];
  }

  function render(ren_width, ren_height) {
    // Set Options
    const opts = {
      width,
      height,
      plugins: [legendAsTooltipPlugin()],
      series: plotSeries,
      axes: [
        {
          scale: "x",
          space: 35,
          incrs: timeIncrs(timestep, maxX, forNode),
          values: (_, vals) => vals.map((v) => formatDurationTime(v, forNode)),
        },
        {
          scale: "y",
          grid: { show: true },
          labelFont: "sans-serif",
          values: (u, vals) => vals.map((v) => formatNumber(v)),
        },
      ],
      bands: plotBands,
      padding: [5, 10, -20, 0],
      hooks: {
        init: [
          (u) => {
            /* IF Zoom Enabled */
            if (resampleConfig && !forNode) {
              u.over.addEventListener("dblclick", (e) => {
                // console.log('Dispatch: Zoom Reset')
                onZoom({
                  lastZoomState: {
                    x: { time: false },
                    y: { auto: true }
                  }
                });
              });
            };
          },
        ],
        draw: [
          (u) => {
            // Draw plot type label:
            let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${
              useStatsSeries
                ? (usesMeanStatsSeries ? ": min/mean/max" : ": min/median/max")
                : metricConfig != null && scope != metricConfig.scope
                  ? ` (${metricConfig.aggregation})`
                  : ""
            }`;
            let textr = `${isShared && scope != "core" && scope != "accelerator" ? "[Shared]" : ""}`;
            u.ctx.save();
            u.ctx.textAlign = "start"; // 'end'
            u.ctx.fillStyle = "black";
            u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
            u.ctx.textAlign = "end";
            u.ctx.fillStyle = "black";
            u.ctx.fillText(
              textr,
              u.bbox.left + u.bbox.width - 10,
              u.bbox.top + (forNode ? 0 : 10),
            );
            // u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right

            if (!thresholds) {
              u.ctx.restore();
              return;
            }

            let y = u.valToPos(thresholds.normal, "y", true);
            u.ctx.save();
            u.ctx.lineWidth = lineWidth;
            u.ctx.strokeStyle = normalLineColor;
            u.ctx.setLineDash([5, 5]);
            u.ctx.beginPath();
            u.ctx.moveTo(u.bbox.left, y);
            u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
            u.ctx.stroke();
            u.ctx.restore();
          },
        ],
        setScale: [
          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
                // Which resolution to theoretically request to achieve 30 or more visible data points:
                const target = (numX * timestep) / resampleTrigger
                // Which configured resolution actually matches the closest to theoretical target:
                const closest = resampleResolutions.reduce(function(prev, curr) {
                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                });
                // Prevents non-required dispatches
                if (timestep !== closest) {
                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
                  onZoom({
                    newRes: closest,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                // console.log('Dispatch: Zoom Update States')
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },
        ]
      },
      scales: {
        x: { time: false },
        y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
      },
      legend: {
        // Display legend until max 12 Y-dataseries
        show: series.length <= 12 || useStatsSeries,
        live: series.length <= 12 || useStatsSeries,
      },
      cursor: {
        drag: { x: true, y: true },
      }
    };

    // Handle Render
    if (!uplot) {
      opts.width = ren_width;
      opts.height = ren_height;

      if (plotSync) {
        opts.cursor.sync = {
          key: plotSync.key,
          scales: ["x", null],
        }
      }

      if (zoomState && metricConfig?.aggregation == "avg") {
        opts.scales = {...zoomState}
      } else if (zoomState && metricConfig?.aggregation == "sum") {
        // Allow Zoom In === Ymin changed
        if (zoomState.y.min !== 0) { // scope change?: only use zoomState if thresholds match
          if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
        } // else: reset scaling to default
      }

      uplot = new uPlot(opts, plotData, plotWrapper);
    } else {
      uplot.setSize({ width: ren_width, height: ren_height });
    }
  }

  function onSizeChange(chg_width, chg_height) {
    if (!uplot) return;
    if (timeoutId != null) clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      timeoutId = null;
      render(chg_width, chg_height);
    }, renderSleepTime);
  }

  /* On Mount */
  onMount(() => {
    if (plotWrapper) {
      render(width, height);
    }
  });

  /* On Destroy */
  onDestroy(() => {
    if (timeoutId != null) clearTimeout(timeoutId);
    if (uplot) uplot.destroy();
  });

</script>

<!-- Define $width Wrapper and NoData Card -->
{#if series[0]?.data && series[0].data.length > 0}
  <div bind:this={plotWrapper} bind:clientWidth={width}
        style="background-color: {backgroundColor()};" class={forNode ? 'py-2 rounded' : 'rounded'}
  ></div>
{:else}
  <Card color="warning" class={forNode ? 'mx-2' : 'mt-2'}>
    <CardHeader class="mb-0">
      <b>Empty Metric</b>
    </CardHeader>
    <CardBody>
      <p>Cannot render plot for <b>{metric}</b>.</p>
      <p class="mb-1">Metric found but returned without timeseries data.</p>
    </CardBody>
  </Card>
{/if}

-                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */

-                // Which resolution to theoretically request to achieve 30 or more visible data points:

-                const target = (numX * timestep) / resampleTrigger

-                // Which configured resolution actually matches the closest to theoretical target:

-                const closest = resampleResolutions.reduce(function(prev, curr) {

-                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);

+                if (resampleTargetPoints && !resampleResolutions) {

+                  // Policy-based: compute resolution dynamically from visible window

+                  const visibleDuration = (u.scales.x.max - u.scales.x.min);

+                  const nativeTimestep = metricConfig?.timestep || timestep;

+                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;

+                  if (newRes < nativeTimestep) newRes = nativeTimestep;

+                } else if (resampleResolutions) {

+                  // Array-based: find closest configured resolution

+                  const target = (numX * timestep) / resampleTrigger;

+                  newRes = resampleResolutions.reduce(function(prev, curr) {

+                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);

-                if (timestep !== closest) {

-                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)

+                if (newRes && timestep !== newRes) {

-                // console.log('Dispatch: Zoom Update States')


Now update the route handler to pass policy-derived resample config. Let me read how the `Resampling` field is currently set.


   490→			// Get User -> What if NIL?
   491→			user := repository.GetUserFromContext(r.Context())
   492→
   493→			// Get Roles
   494→			availableRoles, _ := schema.GetValidRolesMap(user)
   495→
   496→			page := web.Page{
   497→				Title:      title,
   498→				User:       *user,
   499→				Roles:      availableRoles,
   500→				Build:      buildInfo,
   501→				Config:     conf,
   502→				Resampling: config.Keys.EnableResampling,
   503→				Infos:      infos,
   504→			}
   505→
   506→			if route.Filter {
   507→				page.FilterPresets = buildFilterPresets(r.URL.Query())
   508→			}
   509→
   510→			web.RenderTemplate(rw, route.Template, &page)
   511→		})
   512→	}
   513→}
   514→

			// Get User -> What if NIL?
			user := repository.GetUserFromContext(r.Context())

			// Get Roles
			availableRoles, _ := schema.GetValidRolesMap(user)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: config.Keys.EnableResampling,
				Infos:      infos,
			}

			if route.Filter {
				page.FilterPresets = buildFilterPresets(r.URL.Query())
			}

			web.RenderTemplate(rw, route.Template, &page)
		})
	}
}


I need to augment the `Resampling` config that's passed to the page to include `targetPoints` when a user has a policy set. Let me update the route handler.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package routerConfig
     6→
     7→import (
     8→	"fmt"
     9→	"net/http"
    10→	"net/url"
    11→	"os"
    12→	"strconv"
    13→	"strings"
    14→	"time"
    15→
    16→	"github.com/ClusterCockpit/cc-backend/internal/config"
    17→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    18→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    19→	"github.com/ClusterCockpit/cc-backend/web"
    20→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package routerConfig

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"


I need to modify the route setup to derive policy-based resampling config from user preferences. The `Resampling` field is `*config.ResampleConfig`, and I need to add `TargetPoints` to that struct. Let me update the `ResampleConfig` struct.


type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
}

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}


The file /Users/jan/tmp/cc-backend/internal/config/config.go has been updated successfully.

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package config implements the program configuration data structures, validation and parsing
package config

import (
	"bytes"
	"encoding/json"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/resampler"
)

type ProgramConfig struct {
	// Address where the http (or https) server will listen on (for example: 'localhost:80').
	Addr string `json:"addr"`

	// Addresses from which secured admin API endpoints can be reached, can be wildcard "*"
	APIAllowedIPs []string `json:"api-allowed-ips"`

	APISubjects *NATSConfig `json:"api-subjects"`

	// Drop root permissions once .env was read and the port was taken.
	User  string `json:"user"`
	Group string `json:"group"`

	// Disable authentication (for everything: API, Web-UI, ...)
	DisableAuthentication bool `json:"disable-authentication"`

	// If `embed-static-files` is true (default), the frontend files are directly
	// embeded into the go binary and expected to be in web/frontend. Only if
	// it is false the files in `static-files` are served instead.
	EmbedStaticFiles bool   `json:"embed-static-files"`
	StaticFiles      string `json:"static-files"`

	// Path to SQLite database file
	DB string `json:"db"`

	EnableJobTaggers bool `json:"enable-job-taggers"`

	// Validate json input against schema
	Validate bool `json:"validate"`

	// If 0 or empty, the session does not expire!
	SessionMaxAge string `json:"session-max-age"`

	// If both those options are not empty, use HTTPS using those certificates.
	HTTPSCertFile string `json:"https-cert-file"`
	HTTPSKeyFile  string `json:"https-key-file"`

	// If not the empty string and `addr` does not end in ":80",
	// redirect every request incoming at port 80 to that url.
	RedirectHTTPTo string `json:"redirect-http-to"`

	// Where to store MachineState files
	MachineStateDir string `json:"machine-state-dir"`

	// If not zero, automatically mark jobs as stopped running X seconds longer than their walltime.
	StopJobsExceedingWalltime int `json:"stop-jobs-exceeding-walltime"`

	// Defines time X in seconds in which jobs are considered to be "short" and will be filtered in specific views.
	ShortRunningJobsDuration int `json:"short-running-jobs-duration"`

	// Energy Mix CO2 Emission Constant [g/kWh]
	// If entered, displays estimated CO2 emission for job based on jobs totalEnergy
	EmissionConstant int `json:"emission-constant"`

	// If exists, will enable dynamic zoom in frontend metric plots using the configured values
	EnableResampling *ResampleConfig `json:"resampling"`

	// Systemd unit name for log viewer (default: "clustercockpit")
	SystemdUnit string `json:"systemd-unit"`

	// Node state retention configuration
	NodeStateRetention *NodeStateRetention `json:"nodestate-retention"`

	// Database tuning configuration
	DbConfig *DbConfig `json:"db-config"`
}

type DbConfig struct {
	CacheSizeMB               int `json:"cache-size-mb"`
	SoftHeapLimitMB           int `json:"soft-heap-limit-mb"`
	MaxOpenConnections        int `json:"max-open-connections"`
	MaxIdleConnections        int `json:"max-idle-connections"`
	ConnectionMaxIdleTimeMins int `json:"max-idle-time-minutes"`
	BusyTimeoutMs             int `json:"busy-timeout-ms"`
}

type NodeStateRetention struct {
	Policy             string `json:"policy"`      // "delete" or "move"
	Age                int    `json:"age"`         // hours, default 24
	TargetKind         string `json:"target-kind"` // "file" or "s3"
	TargetPath         string `json:"target-path"`
	TargetEndpoint     string `json:"target-endpoint"`
	TargetBucket       string `json:"target-bucket"`
	TargetAccessKey    string `json:"target-access-key"`
	TargetSecretKey    string `json:"target-secret-key"`
	TargetRegion       string `json:"target-region"`
	TargetUsePathStyle bool   `json:"target-use-path-style"`
	MaxFileSizeMB      int    `json:"max-file-size-mb"`
}

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
}

type NATSConfig struct {
	SubjectJobEvent  string `json:"subject-job-event"`
	SubjectNodeState string `json:"subject-node-state"`
	JobConcurrency   int    `json:"job-concurrency"`
	NodeConcurrency  int    `json:"node-concurrency"`
}

type IntRange struct {
	From int `json:"from"`
	To   int `json:"to"`
}

type TimeRange struct {
	From  *time.Time `json:"from"`
	To    *time.Time `json:"to"`
	Range string     `json:"range,omitempty"`
}

type FilterRanges struct {
	Duration  *IntRange  `json:"duration"`
	NumNodes  *IntRange  `json:"num-nodes"`
	StartTime *TimeRange `json:"start-time"`
}

var Keys ProgramConfig = ProgramConfig{
	Addr:                      "localhost:8080",
	EmbedStaticFiles:          true,
	DB:                        "./var/job.db",
	SessionMaxAge:             "168h",
	StopJobsExceedingWalltime: 0,
	ShortRunningJobsDuration:  5 * 60,
}

func Init(mainConfig json.RawMessage) {
	Validate(configSchema, mainConfig)
	dec := json.NewDecoder(bytes.NewReader(mainConfig))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&Keys); err != nil {
		cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
	}

	if Keys.EnableResampling != nil && Keys.EnableResampling.MinimumPoints > 0 {
		resampler.SetMinimumRequiredPoints(Keys.EnableResampling.MinimumPoints)
	}
}

+  // Policy-derived target point count (set dynamically from user preference, not from config.json)

+  TargetPoints int `json:"targetPoints,omitempty"`


Now update the route handler to merge user's policy into the resampling config.


			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: config.Keys.EnableResampling,
				Infos:      infos,
			}

			resampling := resamplingForUser(conf)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: resampling,
				Infos:      infos,
			}


The file /Users/jan/tmp/cc-backend/internal/routerconfig/routes.go has been updated successfully.

			resampling := resamplingForUser(conf)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: resampling,
				Infos:      infos,
			}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package routerConfig

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/go-chi/chi/v5"
)

type InfoType map[string]any

type Route struct {
	Route    string
	Template string
	Title    string
	Filter   bool
	Setup    func(i InfoType, r *http.Request) InfoType
}

var routes []Route = []Route{
	{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
	{"/config", "config.tmpl", "Settings", false, setupConfigRoute},
	{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
	{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
	{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
	{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
	{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
	{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
	{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
}

func setupHomeRoute(i InfoType, r *http.Request) InfoType {
	jobRepo := repository.GetJobRepository()
	groupBy := model.AggregateCluster

	// startJobCount := time.Now()
	stats, err := jobRepo.JobCountGrouped(r.Context(), nil, &groupBy)
	if err != nil {
		cclog.Warnf("failed to count jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startJobCount: %s", time.Since(startJobCount))

	// startRunningJobCount := time.Now()
	stats, err = jobRepo.AddJobCountGrouped(r.Context(), nil, &groupBy, stats, "running")
	if err != nil {
		cclog.Warnf("failed to count running jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startRunningJobCount: %s", time.Since(startRunningJobCount))

	i["clusters"] = stats

	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err != nil {
			cclog.Warnf("failed to read notice.txt file: %s", err.Error())
		} else {
			i["message"] = string(msg)
		}
	}

	return i
}

func setupConfigRoute(i InfoType, r *http.Request) InfoType {
	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err == nil {
			i["ncontent"] = string(msg)
		}
	}

	return i
}

func setupJobRoute(i InfoType, r *http.Request) InfoType {
	i["id"] = chi.URLParam(r, "id")
	if config.Keys.EmissionConstant != 0 {
		i["emission"] = config.Keys.EmissionConstant
	}
	return i
}

func setupUserRoute(i InfoType, r *http.Request) InfoType {
	username := chi.URLParam(r, "id")
	i["id"] = username
	i["username"] = username
	// TODO: If forbidden (== err exists), redirect to error page
	if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
		i["name"] = user.Name
		i["email"] = user.Email
	}
	return i
}

func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DASHBOARD"
	return i
}

func setupClusterDetailRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DETAILS"
	return i
}

func setupDashboardRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "PUBLIC" // Used in Main Template
	return i
}

func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "OVERVIEW"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	subcluster := chi.URLParam(r, "subcluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["sid"] = subcluster
	i["subCluster"] = subcluster
	i["displayType"] = "LIST"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupNodeRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	hostname := chi.URLParam(r, "hostname")
	i["cluster"] = cluster
	i["hostname"] = hostname
	i["id"] = fmt.Sprintf("%s (%s)", cluster, hostname)
	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" && to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupAnalysisRoute(i InfoType, r *http.Request) InfoType {
	i["cluster"] = chi.URLParam(r, "cluster")
	return i
}

func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
	jobRepo := repository.GetJobRepository()
	tags, counts, err := jobRepo.CountTags(repository.GetUserFromContext(r.Context()))
	tagMap := make(map[string][]map[string]any)
	if err != nil {
		cclog.Warnf("GetTags failed: %s", err.Error())
		i["tagmap"] = tagMap
		return i
	}
	// Reduces displayed tags for unauth'd users
	userAuthlevel := repository.GetUserFromContext(r.Context()).GetAuthLevel()
	// Uses tag.ID as second Map-Key component to differentiate tags with identical names
	if userAuthlevel >= 4 { // Support+ : Show tags for all scopes, regardless of count
		for _, tag := range tags {
			tagItem := map[string]any{
				"id":    tag.ID,
				"name":  tag.Name,
				"scope": tag.Scope,
				"count": counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)],
			}
			tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
		}
	} else if userAuthlevel < 4 && userAuthlevel >= 2 { // User+ : Show global and admin scope only if at least 1 tag used, private scope regardless of count
		for _, tag := range tags {
			tagCount := counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)]
			if ((tag.Scope == "global" || tag.Scope == "admin") && tagCount >= 1) || (tag.Scope != "global" && tag.Scope != "admin") {
				tagItem := map[string]any{
					"id":    tag.ID,
					"name":  tag.Name,
					"scope": tag.Scope,
					"count": tagCount,
				}
				tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
			}
		}
	} // auth < 2 return nothing for this route

	i["tagmap"] = tagMap
	return i
}

// FIXME: Lots of redundant code. Needs refactoring
func buildFilterPresets(query url.Values) map[string]any {
	filterPresets := map[string]any{}

	if query.Get("cluster") != "" {
		filterPresets["cluster"] = query.Get("cluster")
	}
	if query.Get("partition") != "" {
		filterPresets["partition"] = query.Get("partition")
	}
	if query.Get("project") != "" {
		filterPresets["project"] = query.Get("project")
		filterPresets["projectMatch"] = "eq"
	}
	if query.Get("jobName") != "" {
		filterPresets["jobName"] = query.Get("jobName")
	}
	if len(query["user"]) != 0 {
		if len(query["user"]) == 1 {
			filterPresets["user"] = query.Get("user")
			filterPresets["userMatch"] = "contains"
		} else {
			filterPresets["user"] = query["user"]
			filterPresets["userMatch"] = "in"
		}
	}
	if len(query["state"]) != 0 {
		filterPresets["state"] = query["state"]
	}
	if query.Get("shared") != "" {
		filterPresets["shared"] = query.Get("shared")
	}
	if query.Get("schedule") != "" {
		filterPresets["schedule"] = query.Get("schedule")
	}
	if rawtags, ok := query["tag"]; ok {
		tags := make([]int, len(rawtags))
		for i, tid := range rawtags {
			var err error
			tags[i], err = strconv.Atoi(tid)
			if err != nil {
				tags[i] = -1
			}
		}
		filterPresets["tags"] = tags
	}
	if query.Get("duration") != "" {
		parts := strings.Split(query.Get("duration"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["duration"] = map[string]int{"lessThan": lt, "from": 0, "to": 0}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["duration"] = map[string]int{"moreThan": mt, "from": 0, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["duration"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("node") != "" {
		filterPresets["node"] = query.Get("node")
	}
	if query.Get("numNodes") != "" {
		parts := strings.Split(query.Get("numNodes"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numNodes"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numNodes"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numNodes"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("numHWThreads") != "" {
		parts := strings.Split(query.Get("numHWThreads"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("numAccelerators") != "" {
		parts := strings.Split(query.Get("numAccelerators"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if len(query["dbId"]) != 0 {
		filterPresets["dbId"] = query["dbId"]
	}
	if query.Get("jobId") != "" {
		if len(query["jobId"]) == 1 {
			filterPresets["jobId"] = query.Get("jobId")
			filterPresets["jobIdMatch"] = "eq"
		} else {
			filterPresets["jobId"] = query["jobId"]
			filterPresets["jobIdMatch"] = "in"
		}
	}
	if query.Get("arrayJobId") != "" {
		if num, err := strconv.Atoi(query.Get("arrayJobId")); err == nil {
			filterPresets["arrayJobId"] = num
		}
	}
	if query.Get("startTime") != "" {
		parts := strings.Split(query.Get("startTime"), "-")
		if len(parts) == 2 { // Time in seconds, from - to
			a, e1 := strconv.ParseInt(parts[0], 10, 64)
			b, e2 := strconv.ParseInt(parts[1], 10, 64)
			if e1 == nil && e2 == nil {
				filterPresets["startTime"] = map[string]string{
					"from": time.Unix(a, 0).Format(time.RFC3339),
					"to":   time.Unix(b, 0).Format(time.RFC3339),
				}
			}
		} else { // named range
			filterPresets["startTime"] = map[string]string{
				"range": query.Get("startTime"),
			}
		}
	}
	if query.Get("energy") != "" {
		parts := strings.Split(query.Get("energy"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["energy"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["energy"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["energy"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if len(query["stat"]) != 0 {
		statList := make([]map[string]any, 0)
		for _, statEntry := range query["stat"] {
			parts := strings.Split(statEntry, "-")
			if len(parts) == 3 { // Metric Footprint Stat Field, from - to
				if parts[1] == "lessthan" {
					lt, lte := strconv.ParseInt(parts[2], 10, 64)
					if lte == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  1,
							"to":    lt,
						}
						statList = append(statList, statEntry)
					}
				} else if parts[1] == "morethan" {
					mt, mte := strconv.ParseInt(parts[2], 10, 64)
					if mte == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  mt,
							"to":    0,
						}
						statList = append(statList, statEntry)
					}
				} else {
					a, e1 := strconv.ParseInt(parts[1], 10, 64)
					b, e2 := strconv.ParseInt(parts[2], 10, 64)
					if e1 == nil && e2 == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  a,
							"to":    b,
						}
						statList = append(statList, statEntry)
					}
				}
			}
		}
		filterPresets["stats"] = statList
	}
	return filterPresets
}

func SetupRoutes(router chi.Router, buildInfo web.Build) {
	userCfgRepo := repository.GetUserCfgRepo()
	for _, route := range routes {
		router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
			if err != nil {
				http.Error(rw, err.Error(), http.StatusInternalServerError)
				return
			}

			title := route.Title
			infos := route.Setup(map[string]any{}, r)
			if id, ok := infos["id"]; ok {
				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
				if sid, ok := infos["sid"]; ok { // 2nd ID element
					title = strings.Replace(title, "<SID>", sid.(string), 1)
				}
			}

			// Get User -> What if NIL?
			user := repository.GetUserFromContext(r.Context())

			// Get Roles
			availableRoles, _ := schema.GetValidRolesMap(user)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: config.Keys.EnableResampling,
				Infos:      infos,
			}

			if route.Filter {
				page.FilterPresets = buildFilterPresets(r.URL.Query())
			}

			web.RenderTemplate(rw, route.Template, &page)
		})
	}
}

func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
	user := repository.GetUserFromContext(r.Context())
	availableRoles, _ := schema.GetValidRolesMap(user)

	if search := r.URL.Query().Get("searchId"); search != "" {
		repo := repository.GetJobRepository()
		splitSearch := strings.Split(search, ":")

		if len(splitSearch) == 2 {
			switch strings.Trim(splitSearch[0], " ") {
			case "jobId":
				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "jobName":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "projectId":
				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "arrayJobId":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "username":
				if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
					http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " "))+"&startTime=last30d", http.StatusFound)
				} else {
					web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
				}
			case "name":
				usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name")
				if len(usernames) != 0 {
					joinedNames := strings.Join(usernames, "&user=")
					http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames+"&startTime=last30d", http.StatusFound)
				} else {
					if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
						http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound&startTime=last30d", http.StatusPermanentRedirect)
					} else {
						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
					}
				}
			default:
				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
			}
		} else if len(splitSearch) == 1 {

			jobid, username, project, jobname := repo.FindUserOrProjectOrJobname(user, strings.Trim(search, " "))

			if jobid != "" {
				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(jobid), http.StatusFound) // JobId (Match)
			} else if username != "" {
				http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusFound) // User: Redirect to user page of first match
			} else if project != "" {
				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(project), http.StatusFound) // projectId (equal)
			} else if jobname != "" {
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(jobname), http.StatusFound) // 30D Fitler + JobName (contains)
			} else {
				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
			}

		} else {
			web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo})
		}
	} else {
		web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
	}
}

+      resampling := resamplingForUser(conf)

-        Resampling: config.Keys.EnableResampling,


Now add the `resamplingForUser` helper function and import `metricdispatch`.


     7→import (
     8→	"fmt"
     9→	"net/http"
    10→	"net/url"
    11→	"os"
    12→	"strconv"
    13→	"strings"
    14→	"time"
    15→
    16→	"github.com/ClusterCockpit/cc-backend/internal/config"
    17→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    18→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    19→	"github.com/ClusterCockpit/cc-backend/web"
    20→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    21→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    22→	"github.com/ClusterCockpit/cc-lib/v2/util"
    23→	"github.com/go-chi/chi/v5"
    24→)
    25→
    26→type InfoType map[string]any

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/go-chi/chi/v5"
)

type InfoType map[string]any


	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"