internal/dashboard/frontend/src/components/EventTable/EventFilterMenu.vue

<script setup lang="ts">
import { IconFilter } from "@tabler/icons-vue";
import {
  onClickOutside,
  useElementBounding,
  useWindowSize,
} from "@vueuse/core";
import { type FilterActions, type GeoData, getHoneypot } from "src/store";
import { type HoneypotEvent } from "src/types";
import { icons } from "utils/icons";
import { computed, ref } from "vue";

const props = defineProps<{
  evt: HoneypotEvent;
  geo?: GeoData;
  filterActions: FilterActions;
  showLinks?: boolean;
}>();

const filterMenuOpen = ref(false);
const filterMenuRef = ref<HTMLElement | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const menuRef = ref<HTMLElement | null>(null);

const { top, left, bottom } = useElementBounding(triggerRef);
const { height: windowHeight, width: windowWidth } = useWindowSize();

const menuStyle = computed(() => {
  if (!filterMenuOpen.value) return {};

  const menuWidth = 200; // estimated min-width
  const menuHeight = 300; // estimated max-height
  const spaceBelow = windowHeight.value - bottom.value;
  const showAbove = spaceBelow < menuHeight && top.value > menuHeight;

  // Align to trigger, but keep within window bounds
  const leftPos = Math.max(
    10,
    Math.min(left.value, windowWidth.value - menuWidth - 10),
  );

  if (showAbove) {
    return {
      position: "fixed" as const,
      bottom: `${windowHeight.value - top.value + 4}px`,
      left: `${leftPos}px`,
    };
  } else {
    return {
      position: "fixed" as const,
      top: `${bottom.value + 4}px`,
      left: `${leftPos}px`,
    };
  }
});

onClickOutside(
  filterMenuRef,
  () => {
    filterMenuOpen.value = false;
  },
  { ignore: [menuRef] },
);

function isCtrlOrCmdKey(event: MouseEvent): boolean {
  return event.metaKey || event.ctrlKey;
}

function clickedIcon(event: MouseEvent) {
  return (event.target as Element)?.closest("svg") !== null;
}

function addFilter(
  action: (val: string) => void,
  value: string | number | undefined,
  event: MouseEvent,
) {
  if (value === undefined) return;
  const prefix = isCtrlOrCmdKey(event) || clickedIcon(event) ? "!" : "";
  action(prefix + value.toString());
  filterMenuOpen.value = false;
}
</script>

<template>
  <div class="relative" ref="filterMenuRef">
    <button
      ref="triggerRef"
      class="icon-button group-hover/row:opacity-100"
      :class="{
        'opacity-100': filterMenuOpen || showLinks,
        'opacity-0': !showLinks,
      }"
      @click.stop="filterMenuOpen = !filterMenuOpen"
      title="Filter by property"
    >
      <IconFilter size="16" />
    </button>

    <Teleport to="body">
      <div
        v-if="filterMenuOpen"
        ref="menuRef"
        class="menu z-50 min-w-40 overflow-hidden rounded bg-stone-900 shadow-xl ring-1 ring-stone-800"
        :style="menuStyle"
      >
        <div
          class="text-muted bg-stone-800/50 px-3 py-1.5 text-[10px] font-bold tracking-wider uppercase"
        >
          Add Filter
        </div>
        <div class="flex flex-col py-1">
          <button
            v-if="evt.type"
            @click="addFilter(filterActions.addHoneypotType, evt.type, $event)"
          >
            <component :is="icons.honeypotType" size="16" />
            Service: {{ getHoneypot(evt.type)?.label || evt.type }}
          </button>

          <button
            @click="
              addFilter(filterActions.addRemoteAddr, evt.remote_addr, $event)
            "
          >
            <component :is="icons.address" size="16" />
            IP: {{ evt.remote_addr }}
          </button>
          <button
            v-if="evt.event"
            @click="addFilter(filterActions.addEvent, evt.event, $event)"
          >
            <component :is="icons.eventType" size="16" />
            Event: {{ evt.event }}
          </button>
          <button
            v-if="evt.dst_port"
            @click="addFilter(filterActions.addDstPort, evt.dst_port, $event)"
          >
            <component :is="icons.port" size="16" />
            Port: {{ evt.dst_port }}
          </button>

          <template v-if="geo">
            <div class="mt-1 border-t border-stone-800 pt-1"></div>
            <button
              v-if="geo.domain"
              @click="addFilter(filterActions.addDomain, geo.domain, $event)"
            >
              <component :is="icons.domain" size="16" />
              Domain: {{ geo.domain }}
            </button>
            <button
              v-if="geo.fqdn"
              @click="addFilter(filterActions.addFqdn, geo.fqdn, $event)"
            >
              <component :is="icons.domain" size="16" />
              FQDN: {{ geo.fqdn }}
            </button>
            <button
              v-if="geo.asn"
              @click="
                addFilter(
                  filterActions.addAsn,
                  geo.asn.autonomous_system_number,
                  $event,
                )
              "
            >
              <component :is="icons.asn" size="16" />
              ASN {{ geo.asn.autonomous_system_number }}
            </button>
            <button
              v-if="geo.country?.iso_code"
              @click="
                addFilter(
                  filterActions.addCountry,
                  geo.country.iso_code,
                  $event,
                )
              "
            >
              <component :is="icons.country" size="16" />
              Country: {{ geo.country.iso_code }}
            </button>
            <button
              v-if="geo.city?.name"
              @click="addFilter(filterActions.addCity, geo.city.name, $event)"
            >
              <component :is="icons.city" size="16" />
              City: {{ geo.city.name }}
            </button>
          </template>
        </div>
        <div class="text-muted bg-stone-800/30 px-3 py-1.5 text-[9px] italic">
          Cmd/Ctrl + Click or click Icon for "NOT filter"
        </div>
      </div>
    </Teleport>
  </div>
</template>

<style scoped>
@reference "src/style.css";

.menu button {
  @apply flex items-center gap-2 rounded-none px-3 py-1.5 text-left text-xs;

  svg {
    @apply text-muted;
  }

  &:hover {
    @apply bg-primary-950/10 text-primary-100;

    svg {
      @apply text-primary-300;
    }
  }
}
</style>