internal/dashboard/frontend/src/components/Filter/FilterBar.vue

<script setup lang="ts">
import {
  IconBraces,
  IconChevronDown,
  IconFilterPlus,
  IconX,
} from "@tabler/icons-vue";
import { onClickOutside } from "@vueuse/core";
import { eventTypes } from "src/constants";
import { honeypotTypes, useFilterStore, type FilterState } from "src/store";
import { icons } from "utils/icons";
import { computed, ref, useTemplateRef } from "vue";
import { useRoute } from "vue-router";
import CommaSeparatedFilter from "./CommaSeparatedFilter.vue";
import DropdownFilter from "./DropdownFilter.vue";
import HostFilter from "./HostFilter.vue";
import JsonFieldFilter from "./JsonFieldFilter.vue";
import PortFilter from "./PortFilter.vue";
import TimeRangeFilter from "./TimeRangeFilter.vue";
import ToggleSwitches from "./ToggleSwitches.vue";

const props = defineProps<{
  filters?: FilterState;
}>();

const route = useRoute();
const filterStore = props.filters || useFilterStore().state;
const showAddMenu = ref(false);

const typeOptions = computed(() =>
  honeypotTypes.value.map((t) => ({ value: t.name, label: t.label })),
);

// List of available filter types
const availableFilters = [
  { id: "type", label: "Honeypot Type", icon: icons.honeypotType },
  { id: "event", label: "Event", icon: icons.eventType },
  { id: "remote_addr", label: "Source IP", icon: icons.address },
  { id: "dst_port", label: "Destination Port", icon: icons.port },
  { id: "timerange", label: "Time Range", icon: icons.time },
  { id: "country", label: "Country", icon: icons.country },
  { id: "city", label: "City", icon: icons.city },
  { id: "asn", label: "ASN", icon: icons.asn },
  { id: "domain", label: "Domain", icon: icons.domain },
  { id: "fqdn", label: "FQDN", icon: icons.domain },
  { id: "json", label: "JSON Field", icon: IconBraces },
];

const visibleFilters = ref<Set<string>>(new Set());
const pendingJsonFilters = ref<{ key: string; values: string[] }[]>([]);

const addableFilters = computed(() => {
  return availableFilters.filter((f) => !visibleFilters.value.has(f.id));
});

// Initialize visible filters based on current state
if (filterStore.remote_addr?.length) visibleFilters.value.add("remote_addr");
if (filterStore.dst_port?.length) visibleFilters.value.add("dst_port");
if (filterStore.country?.length) visibleFilters.value.add("country");
if (filterStore.city?.length) visibleFilters.value.add("city");
if (filterStore.asn?.length) visibleFilters.value.add("asn");
if (filterStore.domain?.length) visibleFilters.value.add("domain");
if (filterStore.fqdn?.length) visibleFilters.value.add("fqdn");
if (filterStore.time_start || filterStore.time_end)
  visibleFilters.value.add("timerange");
if (filterStore.type?.length) visibleFilters.value.add("type");
if (filterStore.event?.length) visibleFilters.value.add("event");

// Helper to check if a filter is active
const isActive = (key: keyof FilterState) => {
  const val = filterStore[key];
  if (Array.isArray(val)) return val.length > 0;
  if (typeof val === "string") return val !== "";
  return !!val;
};

function addFilter(id: string) {
  if (id === "json") {
    pendingJsonFilters.value.push({ key: "", values: [] });
  } else {
    visibleFilters.value.add(id);
  }
  showAddMenu.value = false;
}

function removeFilter(id: string) {
  visibleFilters.value.delete(id);
  if (id === "remote_addr") filterStore.remote_addr = [];
  if (id === "dst_port") filterStore.dst_port = [];
  if (id === "type") filterStore.type = [];
  if (id === "country") filterStore.country = [];
  if (id === "city") filterStore.city = [];
  if (id === "asn") filterStore.asn = [];
  if (id === "domain") filterStore.domain = [];
  if (id === "fqdn") filterStore.fqdn = [];
  if (id === "event") filterStore.event = [];
  if (id === "timerange") {
    filterStore.time_start = "";
    filterStore.time_end = "";
  }
  filterStore.offset = 0;
}

const addMenu = useTemplateRef("addMenu");

onClickOutside(addMenu, () => {
  showAddMenu.value = false;
});
</script>

<template>
  <div class="flex w-full flex-col gap-3">
    <div class="flex flex-wrap justify-between gap-4">
      <div class="flex flex-wrap items-center gap-2">
        <ToggleSwitches
          :filters="filterStore"
          v-if="!['ip', 'charts'].includes(route.name as string)"
        />
        <div class="relative" ref="addMenu">
          <button
            @click="showAddMenu = !showAddMenu"
            class="flex h-8 items-center gap-2 rounded border border-stone-600 bg-stone-800 px-3 text-xs font-semibold text-stone-300 hover:bg-stone-700 hover:text-white"
          >
            <IconFilterPlus size="18" />
            Add Filter
            <IconChevronDown
              size="14"
              class="transition-transform"
              :class="{ 'rotate-180': showAddMenu }"
            />
          </button>
          <div
            v-if="showAddMenu"
            class="absolute top-full left-0 z-50 mt-1 w-max overflow-hidden rounded border border-stone-700 bg-stone-800 shadow-xl"
          >
            <button
              v-for="filter in addableFilters"
              :key="filter.id"
              @click="addFilter(filter.id)"
              class="flex w-full items-center gap-2 rounded-none border-b border-stone-700/50 px-3 py-1.5 text-left text-xs text-stone-300 last:border-0 hover:bg-stone-700 hover:text-white"
            >
              <component :is="filter.icon" size="16" />
              {{ filter.label }}
            </button>
          </div>
        </div>
      </div>
      <slot name="right" />
    </div>

    <div class="flex flex-wrap items-start gap-2">
      <div
        v-if="
          visibleFilters.size === 0 &&
          !Object.keys(filterStore.json_fields || {}).length &&
          pendingJsonFilters.length === 0 &&
          !isActive('type') &&
          !isActive('remote_addr') &&
          !isActive('dst_port') &&
          !isActive('country') &&
          !isActive('city') &&
          !isActive('asn') &&
          !isActive('time_start') &&
          !isActive('time_end') &&
          !isActive('event')
        "
        class="py-1 text-xs text-stone-500 italic"
      >
        No active filters
      </div>

      <div
        v-if="visibleFilters.has('type') || isActive('type')"
        class="group relative"
      >
        <DropdownFilter
          label="Type"
          :icon="icons.honeypotType"
          v-model="filterStore.type"
          :options="typeOptions"
          @change="filterStore.offset = 0"
          placeholder="Select type"
          class="filter-card"
        />
        <button
          @click="removeFilter('type')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('event') || isActive('event')"
        class="group relative"
      >
        <DropdownFilter
          label="Event"
          :icon="icons.eventType"
          v-model="filterStore.event"
          :options="eventTypes"
          @change="filterStore.offset = 0"
          placeholder="Select event"
          class="filter-card"
        />
        <button
          @click="removeFilter('event')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('remote_addr') || isActive('remote_addr')"
        class="group relative"
      >
        <HostFilter
          v-model="filterStore.remote_addr"
          @change="filterStore.offset = 0"
          class="filter-card"
        />
        <button
          @click="removeFilter('remote_addr')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('dst_port') || isActive('dst_port')"
        class="group relative"
      >
        <PortFilter
          v-model="filterStore.dst_port"
          v-model:sync-with-chart="filterStore.sync_ports_with_chart"
          :show-sync="route.name === 'charts-port'"
          @change="filterStore.offset = 0"
          class="filter-card"
        />
        <button
          @click="removeFilter('dst_port')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('country') || isActive('country')"
        class="group relative"
      >
        <CommaSeparatedFilter
          label="Country"
          :icon="icons.country"
          v-model="filterStore.country"
          @change="filterStore.offset = 0"
          placeholder="US, DE"
          inputClass="w-24"
          class="filter-card"
        />
        <button
          @click="removeFilter('country')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('city') || isActive('city')"
        class="group relative"
      >
        <CommaSeparatedFilter
          label="City"
          :icon="icons.city"
          v-model="filterStore.city"
          @change="filterStore.offset = 0"
          placeholder="New York, Zurich"
          inputClass="w-24"
          class="filter-card"
        />
        <button
          @click="removeFilter('city')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('asn') || isActive('asn')"
        class="group relative"
      >
        <CommaSeparatedFilter
          label="ASN"
          :icon="icons.asn"
          v-model="filterStore.asn"
          @change="filterStore.offset = 0"
          placeholder="12345"
          inputClass="w-24"
          class="filter-card"
        />
        <button
          @click="removeFilter('asn')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('domain') || isActive('domain')"
        class="group relative"
      >
        <CommaSeparatedFilter
          label="Domain"
          :icon="icons.domain"
          v-model="filterStore.domain"
          @change="filterStore.offset = 0"
          placeholder="example.com"
          inputClass="w-32"
          class="filter-card"
        />
        <button
          @click="removeFilter('domain')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="visibleFilters.has('fqdn') || isActive('fqdn')"
        class="group relative"
      >
        <CommaSeparatedFilter
          label="FQDN"
          :icon="icons.domain"
          v-model="filterStore.fqdn"
          @change="filterStore.offset = 0"
          placeholder="*.example.com"
          inputClass="w-32"
          class="filter-card"
        />
        <button
          @click="removeFilter('fqdn')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-if="
          visibleFilters.has('timerange') ||
          isActive('time_start') ||
          isActive('time_end')
        "
        class="group relative"
      >
        <TimeRangeFilter
          v-model:time-start="filterStore.time_start"
          v-model:time-end="filterStore.time_end"
          v-model:sync-with-chart="filterStore.sync_time_with_chart"
          :show-sync="
            route.name === 'charts-activity' || route.name === 'charts-port'
          "
          @change="filterStore.offset = 0"
          class="filter-card"
        />
        <button
          @click="removeFilter('timerange')"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-for="(values, key) in filterStore.json_fields"
        :key="key"
        class="group relative"
      >
        <JsonFieldFilter
          :field-key="key as string"
          :model-value="values"
          fixed-key
          @update:model-value="
            (vals) => (filterStore.json_fields[key as string] = vals)
          "
          class="filter-card"
        />
        <button
          @click="delete filterStore.json_fields[key as string]"
          title="Remove filter"
          class="filter-remove-button"
        >
          <IconX size="12" />
        </button>
      </div>

      <div
        v-for="(item, index) in pendingJsonFilters"
        :key="index"
        class="group relative"
      >
        <JsonFieldFilter
          v-model:field-key="item.key"
          :model-value="[]"
          @update:model-value="
            (vals) => {
              if (item.key && vals.length > 0) {
                useFilterStore().addJsonField(item.key, vals[0] as string);
                pendingJsonFilters.splice(index, 1);
              }
            }
          "
          class="filter-card"
        />
        <button
          @click="pendingJsonFilters.splice(index, 1)"
          class="filter-remove-button"
          title="Remove filter"
        >
          <IconX size="12" />
        </button>
      </div>
    </div>
  </div>
</template>

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

.filter-remove-button {
  @apply absolute -top-1.5 -right-1.5 rounded-full border border-stone-600 bg-stone-800 p-0.5 text-stone-400 opacity-0 shadow-md transition-opacity hover:text-white;
}

.group:hover .filter-remove-button {
  @apply opacity-100;
}

.filter-card {
  @apply rounded border border-stone-800 bg-stone-800/50 px-1.5 py-1;
}
</style>