<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>