internal/dashboard/frontend/src/views/ActivityChart.vue

<script setup lang="ts">
import { useResizeObserver } from "@vueuse/core";
import LoaderOverlay from "components/LoaderOverlay.vue";
import Plotly from "plotly.js-dist-min";
import {
  buildQueryStringFromState,
  getHoneypot,
  useChartStore,
} from "src/store";
import {
  activityChartMargin,
  activityDataLimit,
  getCalculatedColor,
  gridcolor,
  hoverlabel,
  labelFontSize,
  modebar,
  spikecolor,
  tickfontcolor,
} from "utils/chart";
import { formatDateTimeForAPI } from "utils/formatting";
import { URL } from "utils/utils";
import { onMounted, ref, useTemplateRef, watch } from "vue";

defineProps<{
  chartData?: any;
  loading?: boolean;
  connectionStatus?: string;
}>();

const chartStore = useChartStore();
const chartContainer = useTemplateRef<HTMLDivElement>("chartContainer");

const loading = ref(false);
const activityData = ref<any[]>([]);

async function fetchActivity() {
  loading.value = true;
  try {
    const queryString = buildQueryStringFromState(chartStore.state);
    const params = new URLSearchParams(queryString);

    // Always send the time filter. If set manually, use it; otherwise, use the zoom range.
    if (!params.get("time_start") && chartStore.state.x_range?.[0]) {
      params.set(
        "time_start",
        formatDateTimeForAPI(chartStore.state.x_range[0]),
      );
    }
    if (!params.get("time_end") && chartStore.state.x_range?.[1]) {
      params.set("time_end", formatDateTimeForAPI(chartStore.state.x_range[1]));
    }

    // We don't need limit for activity chart usually, or we want a large one
    params.set("limit", activityDataLimit.toString());

    const response = await fetch(
      `${URL()}/api/stats/activity-over-time?${params.toString()}`,
    );
    if (!response.ok) throw new Error("Failed to fetch activity");
    activityData.value = await response.json();
    drawChart();
  } catch (err) {
    console.error(err);
  } finally {
    loading.value = false;
  }
}

function drawChart() {
  if (!chartContainer.value) return;

  if (activityData.value.length === 0) {
    Plotly.purge(chartContainer.value);
    return;
  }

  // Group data by type
  const groups: Record<string, { x: Date[]; y: number[] }> = {};
  activityData.value.forEach((d: any) => {
    if (!groups[d.type]) groups[d.type] = { x: [], y: [] };
    const group = groups[d.type]!;
    group.x.push(new Date(d.time));
    group.y.push(d.count);
  });

  const traces: Plotly.Data[] = Object.entries(groups).map(
    ([type, data], index) => ({
      x: data.x,
      y: data.y,
      name: getHoneypot(type)?.label || type,
      type: "scatter" as const,
      mode: "lines" as const,
      line: {
        shape: "linear" as const,
        width: 2,
        color: getCalculatedColor(index, Object.keys(groups).length),
      },
      hoverlabel: {
        bgcolor: "#141210",
        font: { color: "#ccc", size: 12 },
      },
    }),
  );

  const layout: Partial<Plotly.Layout> = {
    xaxis: {
      title: {
        text: "Time",
        font: { size: labelFontSize, color: tickfontcolor },
      },
      type: "date" as const,
      gridcolor: gridcolor,
      range:
        chartStore.state.x_range && chartStore.state.x_range.length === 2
          ? [...chartStore.state.x_range]
          : undefined,
      tickfont: { color: tickfontcolor, size: labelFontSize },
      showspikes: true,
      spikemode: "across",
      spikecolor: spikecolor,
      spikethickness: -2,
    },
    yaxis: {
      title: {
        text: "Events per Minute",
        font: { size: labelFontSize, color: tickfontcolor },
      },
      gridcolor: gridcolor,
      zerolinecolor: gridcolor,
      tickfont: { color: tickfontcolor, size: labelFontSize },
    },
    margin: activityChartMargin,
    paper_bgcolor: "transparent",
    plot_bgcolor: "transparent",
    font: { color: tickfontcolor },
    showlegend: true,
    legend: {
      orientation: "h" as const,
      y: -0.2,
      font: { size: labelFontSize },
    },
    hovermode: "x unified" as const,
    hoverlabel: hoverlabel,
    modebar: modebar,
  };

  const config: any = {
    responsive: true,
    displaylogo: false,
    modeBarButtonsToRemove: ["lasso2d", "select2d"],
  };

  Plotly.react(chartContainer.value, traces, layout, config);

  const plotDiv = chartContainer.value as any;
  plotDiv.removeAllListeners?.("plotly_relayout");
  plotDiv.on("plotly_relayout", (event: any) => {
    if (event["xaxis.range[0]"] && event["xaxis.range[1]"]) {
      const start = event["xaxis.range[0]"].toString();
      const end = event["xaxis.range[1]"].toString();

      chartStore.state.x_range = [start, end];

      if (chartStore.state.sync_time_with_chart) {
        chartStore.state.time_start = start;
        chartStore.state.time_end = end;
      }
    }
    if (event["xaxis.autorange"]) {
      chartStore.state.x_range = [];
      if (chartStore.state.sync_time_with_chart) {
        chartStore.state.time_start = "";
        chartStore.state.time_end = "";
      }
    }
  });
}

onMounted(() => {
  fetchActivity();
});

watch(
  [
    () => buildQueryStringFromState(chartStore.state),
    () => chartStore.state.x_range,
  ],
  () => {
    fetchActivity();
  },
  { deep: true },
);

useResizeObserver(chartContainer, () => {
  if (chartContainer.value) Plotly.Plots.resize(chartContainer.value);
});
</script>

<template>
  <div class="flex h-full flex-col gap-4 pt-2">
    <div class="chart-container">
      <LoaderOverlay v-if="loading" />
      <div ref="chartContainer" class="h-full w-full rounded-lg"></div>
    </div>
  </div>
</template>