<script setup lang="ts">
import type { Icon } from "@tabler/icons-vue";
import { useWindowSize } from "@vueuse/core";
import DetailCard from "components/DetailView/DetailCard.vue";
import DetailChart from "components/DetailView/DetailChart.vue";
import EventTable from "components/EventTable/EventTable.vue";
import EventTableMobileRow from "components/EventTable/EventTableMobileRow.vue";
import EventTableRow from "components/EventTable/EventTableRow.vue";
import LiveUpdateButton from "components/LiveUpdateButton.vue";
import PageHeader from "components/PageHeader.vue";
import ScoreTag from "components/ScoreTag.vue";
import SectionCard from "components/SectionCard.vue";
import SparkChart from "components/SparkChart.vue";
import StatBarTable, { type StatBarItem } from "components/StatBarTable.vue";
import { useChartData } from "composables/useChartData";
import { useLogEntries } from "composables/useLogEntries";
import { countries } from "src/countries";
import {
createFilterState,
getHoneypot,
useBlocklistStore,
useFilterActions,
useIPInfoStore,
} from "src/store";
import { type BlocklistEntry, type TopNField } from "src/types";
import { getSparkData } from "utils/filters";
import {
formatLocalDateTime,
formatLocalNumber,
maskIp,
timeAgo,
} from "utils/formatting";
import { icons } from "utils/icons";
import { isValidIP, isValidPort, URL } from "utils/utils";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// -- View Type Determination --
const viewType = computed(() => {
const name = route.name as string;
if (name === "ip") return "ip";
if (name === "subnet") return "subnet";
if (name === "port") return "port";
if (name === "honeypot") return "honeypot";
if (["city", "country", "asn", "domain", "fqdn"].includes(name)) return "geo";
return "unknown";
});
// -- Params & Identifiers --
const ip = computed(() => route.params.ip as string);
const port = computed(() => route.params.port as string);
const mask = computed(() =>
route.params.mask ? parseInt(route.params.mask as string) : 24,
);
const honeypotParam = computed(() => route.params.honeypot as string);
const props = defineProps<{
type?: string;
value?: string;
}>();
const resolvedGeoType = computed(() => {
if (viewType.value !== "geo") return "";
if (props.type) return props.type;
if (route.params.type) return route.params.type as string;
const name = route.name as string;
if (name === "domain") return "domain";
if (name === "fqdn") return "fqdn";
return "";
});
const resolvedGeoValue = computed(() => {
if (viewType.value !== "geo") return "";
if (props.value) return props.value;
if (route.params.value) return route.params.value as string;
return "";
});
// -- Entity Identification --
// Used for display and filtering
const entityIdentifier = computed(() => {
switch (viewType.value) {
case "ip":
return ip.value;
case "subnet":
return `${maskIp(ip.value, mask.value)}/${mask.value}`;
case "port":
return port.value;
case "honeypot":
return honeypotParam.value;
case "geo":
return resolvedGeoValue.value;
default:
return "";
}
});
// -- Stores --
const blocklistStore = useBlocklistStore();
const ipInfoStore = useIPInfoStore();
const { getIPInfo, ipInfoIsLoading, getDomainByIp } = ipInfoStore;
// -- IP Specific Computed --
const ipDomain = computed(() =>
viewType.value === "ip" ? getDomainByIp(ip.value).value : null,
);
const ipGeo = computed(() =>
viewType.value === "ip" || viewType.value === "subnet"
? getIPInfo(ip.value).value
: null,
);
// -- Blocklist / Tags --
const tags = computed(() => {
if (viewType.value === "ip" || viewType.value === "subnet") {
return blocklistStore.getTagsByIp(entityIdentifier.value);
}
return [];
});
const expiration = computed(() => {
if (viewType.value === "ip" || viewType.value === "subnet") {
const entry = blocklistStore.getBlocklistEntryByIp(entityIdentifier.value);
if (entry) return entry.expires;
const botnet = blocklistStore.getBotnetEntryByIp(entityIdentifier.value);
if (botnet) return botnet.expires;
}
return null;
});
// -- Filter Setup --
const filterInitialState = computed<Partial<import("src/store").FilterState>>(
() => {
switch (viewType.value) {
case "ip":
return { remote_addr: [ip.value] };
case "subnet":
return { remote_addr: [entityIdentifier.value] }; // Subnet string
case "port":
return { dst_port: [port.value] };
case "honeypot":
// Honeypot filter uses 'type' which corresponds to honeypot name
const hp = getHoneypot(honeypotParam.value);
return { type: hp ? [hp.name] : [] };
case "geo":
if (
resolvedGeoType.value &&
["asn", "city", "country", "domain", "fqdn"].includes(
resolvedGeoType.value,
)
) {
return { [resolvedGeoType.value]: [resolvedGeoValue.value] };
}
return {};
default:
return {};
}
},
);
const localFilterActions = useFilterActions(
createFilterState(filterInitialState.value),
);
// Watch for route changes to update filter
watch(
[viewType, entityIdentifier, resolvedGeoType, resolvedGeoValue],
() => {
// Reset relevant fields
localFilterActions.state.remote_addr = [];
localFilterActions.state.dst_port = [];
localFilterActions.state.country = [];
localFilterActions.state.city = [];
localFilterActions.state.asn = [];
localFilterActions.state.domain = [];
localFilterActions.state.fqdn = [];
localFilterActions.state.type = [];
if (viewType.value === "ip") {
localFilterActions.state.remote_addr = [ip.value];
} else if (viewType.value === "subnet") {
localFilterActions.state.remote_addr = [entityIdentifier.value];
} else if (viewType.value === "port") {
localFilterActions.state.dst_port = [port.value];
} else if (viewType.value === "honeypot") {
const hp = getHoneypot(honeypotParam.value);
if (hp) localFilterActions.state.type = [hp.name];
} else if (viewType.value === "geo") {
if (
resolvedGeoType.value &&
["asn", "city", "country", "domain", "fqdn"].includes(
resolvedGeoType.value,
)
) {
(localFilterActions.state as any)[resolvedGeoType.value] = [
resolvedGeoValue.value,
];
}
}
},
{ deep: true },
);
// -- Data States --
// Common Stats Interface
interface GeneralStats {
total_events: number;
first_seen?: string;
last_seen?: string;
top_ports?: TopNField[];
top_events?: TopNField[];
top_addrs?: TopNField[]; // For Port/Geo/Honeypot
top_subdomains?: TopNField[]; // For Geo
blocklist?: BlocklistEntry[]; // For IP/Subnet
title?: string; // Geo
metadata?: Record<string, any>; // Geo
remote_addrs?: TopNField[];
}
const stats = ref<GeneralStats | null>(null);
const loadingStats = ref(false);
// Subnet Stats (Specific to IP View for the subnet card)
const subnetMaskForIPView = ref(24);
const ipViewSubnetStats = ref<TopNField[]>([]);
const loadingIPViewSubnet = ref(false);
// Subnet View Specific
// The active addresses in the subnet
const subnetViewActiveAddrs = ref<TopNField[]>([]);
const loadingSubnetViewActiveAddrs = ref(false);
// Honeypot other stats
const rawHoneypotStats = ref<Record<string, any>>({});
// -- Fetching Logic --
async function fetchStats() {
loadingStats.value = true;
stats.value = null;
rawHoneypotStats.value = {};
let url = "";
try {
if (viewType.value === "ip") {
if (!ip.value) return;
url = `${URL()}/api/stats/ip?ip=${ip.value}`;
} else if (viewType.value === "subnet") {
if (!ip.value) return; // entityIdentifier has mask
url = `${URL()}/api/stats/ip?ip=${entityIdentifier.value}`; // API uses 'ip' param for subnet too? Checked SubnetDetailView: yes, ?ip=${subnet.value}
} else if (viewType.value === "port") {
if (!port.value) return;
url = `${URL()}/api/stats/port?port=${port.value}`;
} else if (viewType.value === "honeypot") {
const hp = getHoneypot(honeypotParam.value);
if (!hp) return;
url = `${URL()}/api/stats/honeypot?event_type=${hp.name}`;
} else if (viewType.value === "geo") {
if (!resolvedGeoValue.value) return;
url = `${URL()}/api/stats/geo?type=${resolvedGeoType.value}&value=${resolvedGeoValue.value}`;
}
if (url) {
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
if (viewType.value === "honeypot") {
rawHoneypotStats.value = data;
}
stats.value = data;
}
}
} catch (e) {
console.error("Failed to fetch stats", e);
} finally {
loadingStats.value = false;
}
}
// IP View: Fetch Subnet Preview
async function fetchIPViewSubnetStats() {
if (viewType.value !== "ip" || !ip.value) return;
loadingIPViewSubnet.value = true;
try {
const res = await fetch(
`${URL()}/api/stats/subnet?ip=${ip.value}&mask=${subnetMaskForIPView.value}`,
);
if (res.ok) {
ipViewSubnetStats.value = await res.json();
}
} catch (e) {
console.error("Failed to fetch subnet stats for IP view", e);
} finally {
loadingIPViewSubnet.value = false;
}
}
// Subnet View: Fetch Active Addresses (Wait, SubnetDetailView calls `api/stats/subnet` for this)
async function fetchSubnetViewStats() {
if (viewType.value !== "subnet" || !ip.value) return;
loadingSubnetViewActiveAddrs.value = true;
try {
const res = await fetch(
`${URL()}/api/stats/subnet?ip=${ip.value}&mask=${mask.value}`,
);
if (res.ok) {
subnetViewActiveAddrs.value = await res.json();
}
} catch (e) {
console.error("Failed to fetch subnet stats", e);
} finally {
loadingSubnetViewActiveAddrs.value = false;
}
}
// -- Watchers for Data Fetching --
watch(
() => [route.path, viewType.value],
() => {
// Basic validation
if (viewType.value === "ip" && !isValidIP(ip.value)) {
router.push("/");
return;
}
if (viewType.value === "port" && !isValidPort(port.value)) {
router.push("/");
return;
}
if (
viewType.value === "subnet" &&
(!isValidIP(ip.value) || isNaN(mask.value))
) {
router.push("/");
return;
}
if (viewType.value === "honeypot" && !honeypotParam.value) {
router.push("/");
return;
}
fetchStats();
if (viewType.value === "ip") fetchIPViewSubnetStats();
if (viewType.value === "subnet") fetchSubnetViewStats();
},
{ immediate: true },
);
watch(subnetMaskForIPView, () => {
if (viewType.value === "ip") fetchIPViewSubnetStats();
});
// -- Charts & Logs --
const chartFilterState = computed(() =>
createFilterState({
...filterInitialState.value,
limit: 10000,
columns: ["time", "event", "dst_port", "remote_addr", "type"],
}),
);
const { chartData: loadedChartData, loading: loadingAll } = useChartData({
filters: () => chartFilterState.value,
});
const {
entries: tableEntries,
total: tableTotal,
loading: loadingTable,
connectionStatus: tableStatus,
connect: connectTable,
disconnect: disconnectTable,
} = useLogEntries({
filterState: localFilterActions.state,
});
const sparkData = computed(() => getSparkData(loadedChartData.value));
const chartSelectedCount = ref(0);
const chartSelectedIPs = ref(0);
// -- Connection Toggle --
function toggleConnection() {
if (tableStatus.value === "open") {
disconnectTable();
} else {
connectTable();
}
}
// -- Derived Data for UI --
const topPortsData = computed<StatBarItem[]>(() => {
return (stats.value?.top_ports || []).map((p) => ({
label: p.label,
count: p.count,
routerLink: `/port/${p.label}`,
}));
});
const topEventsData = computed<StatBarItem[]>(() => {
return (stats.value?.top_events || []).map((e) => ({
id: e.label,
label:
viewType.value === "port"
? (getHoneypot(e.label)?.label ?? e.label)
: e.label,
count: e.count,
}));
});
const topAddrsData = computed<StatBarItem[]>(() => {
return (stats.value?.top_addrs || []).map((a) => ({
label: a.label,
count: a.count,
routerLink: `/ip/${a.label}`,
}));
});
const topSubdomainsData = computed<StatBarItem[]>(() => {
return (stats.value?.top_subdomains || []).map((f) => ({
label: f.label,
count: f.count,
routerLink: `/fqdn/${f.label}`,
}));
});
// For IP view subnet card
const ipViewSubnetStatsData = computed<StatBarItem[]>(() => {
return ipViewSubnetStats.value.map((s) => ({
label: s.label,
count: s.count,
routerLink: `/ip/${s.label}`,
}));
});
// For Subnet view active addresses
const subnetViewStatsData = computed<StatBarItem[]>(() => {
return subnetViewActiveAddrs.value.map((s) => ({
label: s.label,
count: s.count,
routerLink: `/ip/${s.label}`,
}));
});
const otherTopStats = computed(() => {
if (viewType.value !== "honeypot") return [];
const standardKeys = [
"top_events",
"top_addrs",
"top_ports",
"total_events",
"first_seen",
"last_seen",
"title",
"metadata",
];
return Object.keys(rawHoneypotStats.value).filter(
(key) => !standardKeys.includes(key),
);
});
// Geo Toggle
const showSubdomains = ref(false);
// Titles & Icons
const pageTitle = computed<{ title: string; icon: Icon | undefined }>(() => {
if (viewType.value === "ip")
return { title: `${ip.value}`, icon: icons.address };
if (viewType.value === "subnet")
return { title: `${entityIdentifier.value}`, icon: icons.subnet };
if (viewType.value === "port")
return { title: `Port ${port.value}`, icon: icons.port };
if (viewType.value === "honeypot") {
const hp = getHoneypot(honeypotParam.value);
return {
title: hp ? `${hp.label} Honeypot` : honeypotParam.value,
icon: icons.honeypotType,
};
}
if (viewType.value === "geo") {
if (loadingStats.value) return { title: "Loading...", icon: undefined };
if (resolvedGeoType.value === "country") {
const code = stats.value?.title || resolvedGeoValue.value;
const title =
(countries.get(code)?.[1] || code) +
" " +
(countries.get(code)?.[0] || "");
return { title, icon: icons.country };
}
if (resolvedGeoType.value === "asn") {
const org = stats.value?.metadata?.asn_org;
return { title: org || `ASN ${resolvedGeoValue.value}`, icon: icons.asn };
}
if (
resolvedGeoType.value === "domain" ||
resolvedGeoType.value === "fqdn"
) {
return {
title: stats.value?.title || resolvedGeoValue.value,
icon: icons.domain,
};
}
return {
title: stats.value?.title || resolvedGeoValue.value,
icon: icons.city,
};
}
return { title: "Details", icon: icons.activity };
});
// Geo Readable Name logic
const locationReadableName = computed(() => {
if (loadingStats.value || !stats.value?.metadata) return null;
const { country } = stats.value.metadata;
if (resolvedGeoType.value === "city" && country) {
return countries.get(country)?.[0] + " " + countries.get(country)?.[1];
}
if (resolvedGeoType.value === "asn") return `ASN ${resolvedGeoValue.value}`;
if (country && resolvedGeoType.value !== "country")
return countries.get(country)?.[1];
return null;
});
const chartLink = computed(() => {
if (viewType.value === "port") return undefined;
const state = filterInitialState.value;
const keys = Object.keys(state);
if (keys.length > 0) {
const key = keys[0];
if (key) {
const val = (state as any)[key];
if (Array.isArray(val) && val.length > 0) {
return `/charts?${key}=${val[0]}`;
}
}
}
return "/charts";
});
const eventTableColumns = computed(() => {
if (viewType.value === "ip") return ["time", "event", "details"];
return ["time", "event", "remote_addr", "details"];
});
const { width } = useWindowSize();
</script>
<template>
<div class="page-container">
<PageHeader
:title="pageTitle.title"
:icon="pageTitle.icon"
class="min-h-12"
>
<template #left>
<!-- IP/Subnet: Geo Info -->
<div
v-if="viewType === 'ip' || viewType === 'subnet'"
class="flex flex-wrap items-center gap-8"
>
<div class="flex flex-col">
<!-- Domain resolution for IP -->
<p
v-if="viewType === 'ip' && ipDomain && ipDomain !== ip"
class="text-stone-400"
>
<template
v-if="
ipGeo?.domain &&
ipGeo.domain !== ipDomain &&
ipDomain.endsWith(ipGeo.domain)
"
>
<router-link
:to="`/fqdn/${ipDomain}`"
class="peer hover:text-stone-50"
>
{{ ipDomain.slice(0, -ipGeo.domain.length) }}
</router-link>
<router-link
:to="`/domain/${ipGeo.domain}`"
class="peer-hover:text-stone-50 hover:text-stone-50"
>
{{ ipGeo.domain }}
</router-link>
</template>
<template v-else>
<router-link
:to="`/fqdn/${ipDomain}`"
class="hover:text-stone-50"
>
{{ ipDomain }}
</router-link>
</template>
</p>
<p
v-else-if="viewType === 'ip' && ipInfoIsLoading(ip)"
class="text-muted animate-pulse"
>
Looking up domain...
</p>
<!-- Geo Info -->
<p class="" v-if="ipGeo">
<span
v-if="ipGeo?.country?.iso_code"
:title="countries.get(ipGeo.country.iso_code)?.[1]"
class="stat-label"
>
{{ countries.get(ipGeo.country.iso_code)?.[0] }}
<router-link
v-if="ipGeo?.city?.name"
:to="`/city/${ipGeo.city.name}`"
class="hover:text-stone-200"
>
{{ ipGeo.city.name }},
</router-link>
<router-link
:to="`/country/${ipGeo.country.iso_code}`"
class="hover:text-stone-200"
>
{{ countries.get(ipGeo.country.iso_code)?.[1] }}
</router-link>
</span>
<span
v-if="ipGeo?.asn && ipGeo?.country?.iso_code"
class="text-muted"
>,
</span>
<span
v-if="ipGeo?.asn?.autonomous_system_organization"
class="stat-label"
>
<router-link
:to="`/asn/${ipGeo.asn.autonomous_system_number}`"
class="hover:text-stone-200"
>
{{ ipGeo?.asn?.autonomous_system_organization }} (ASN{{
ipGeo?.asn?.autonomous_system_number
}})
</router-link>
</span>
</p>
</div>
<!-- Tags -->
<div class="flex gap-2" v-if="tags && tags.length > 0">
<ScoreTag :tag="tag" v-for="tag in tags" :key="tag" />
<span class="stat-value" v-if="expiration">
Unblocked {{ expiration ? timeAgo(expiration) : "N/A" }}
</span>
</div>
</div>
<!-- Geo View Header Extra -->
<div
v-else-if="viewType === 'geo' && stats?.metadata"
class="flex items-center gap-4"
>
<div class="flex items-center gap-2 text-stone-300">
<span v-if="locationReadableName" class="text-stone-400">
{{ locationReadableName }}
</span>
</div>
</div>
</template>
<template #actions>
<LiveUpdateButton
:st="tableStatus"
@toggleConnection="toggleConnection"
/>
</template>
</PageHeader>
<div
class="grid grid-cols-1 gap-4 md:grid-cols-2 md:grid-rows-2 xl:grid-rows-1"
:class="{
'xl:grid-cols-[1fr_280px_250px_300px]':
viewType === 'ip' || viewType === 'subnet',
'lg:grid-cols-[1fr_350px]': viewType === 'port',
'xl:grid-cols-[1fr_250px_350px]':
viewType === 'geo' || viewType === 'honeypot',
}"
>
<DetailChart
v-model:selected-points="chartSelectedCount"
v-model:selected-i-ps="chartSelectedIPs"
:chartData="loadedChartData"
:local-filter-actions="localFilterActions"
title="Activity Timeline"
:loading="loadingAll"
:key="entityIdentifier"
:chart-link="chartLink"
:is-port="viewType === 'port'"
:is-honeypot="viewType === 'honeypot'"
/>
<!-- IP/Subnet: Blocklist -->
<SectionCard
v-if="viewType === 'ip' || viewType === 'subnet'"
:title="`Blocklist History (${stats?.blocklist?.length || 0})`"
:icon="icons.blocked"
>
<div class="h-75 overflow-y-auto pr-2 text-xs">
<div v-if="loadingStats" class="animate-pulse py-4 text-center">
Loading...
</div>
<div
v-else-if="!stats?.blocklist?.length"
class="py-4 text-center text-stone-500"
>
No blocklist history
</div>
<div
v-for="entry in stats?.blocklist || []"
:key="entry.id"
class="my-1 flex items-center justify-between gap-2"
>
<div class="w-1/3 whitespace-nowrap">
<template v-if="viewType === 'subnet'">
{{ entry.address }}
<br />
</template>
{{ formatLocalDateTime(entry.timestamp, true) }}
</div>
<div class="flex w-min flex-wrap justify-end gap-1">
<ScoreTag
v-for="tag in entry.reason.split(',')"
:key="tag"
:tag="tag.trim()"
/>
</div>
</div>
</div>
</SectionCard>
<SectionCard
v-if="viewType !== 'port'"
title="Top Ports"
:icon="icons.port"
>
<StatBarTable
:items="topPortsData"
:loading="loadingStats"
bar-min-width="10px"
class="max-h-75"
/>
</SectionCard>
<!-- IP: Subnet Preview -->
<SectionCard
v-if="viewType === 'ip'"
:title="`Subnet (${ipViewSubnetStatsData.length})`"
:icon="icons.subnet"
>
<template #header-right>
<div class="flex items-center gap-2">
<div class="flex gap-1">
<select
v-model="subnetMaskForIPView"
class="rounded border border-stone-700 bg-stone-800 px-2 py-0.5 text-xs text-stone-400 transition-colors hover:bg-stone-700"
>
<option
v-for="m in [24, 22, 20, 18, 16, 12, 8]"
:key="m"
:value="m"
>
/{{ m }}
</option>
</select>
</div>
<router-link
:to="`/ip/${ip}/${subnetMaskForIPView}`"
class="btn-secondary p-0.5 text-[10px]"
title="View full subnet details"
>
<component :is="icons.subnet" />
</router-link>
</div>
</template>
<StatBarTable
:items="ipViewSubnetStatsData"
:loading="loadingIPViewSubnet"
bar-min-width="10px"
class="max-h-75"
/>
</SectionCard>
<!-- Subnet: Active Addresses -->
<SectionCard
v-if="viewType === 'subnet'"
:title="`Active Addresses (${subnetViewStatsData.length})`"
:icon="icons.address"
>
<StatBarTable
:items="subnetViewStatsData"
:loading="loadingSubnetViewActiveAddrs"
bar-min-width="10px"
class="max-h-[300px]"
/>
</SectionCard>
<!-- Geo: Top Domains/Addresses Switch -->
<SectionCard
v-if="viewType === 'geo'"
:title="showSubdomains ? 'Top Domains' : 'Top Addresses'"
:icon="showSubdomains ? icons.domain : icons.address"
>
<div class="mb-2 flex">
<div class="flex rounded bg-stone-800 p-0.5">
<button
@click="showSubdomains = false"
class="rounded px-2 py-0.5 text-xs font-medium transition-colors"
:class="
!showSubdomains
? 'bg-stone-700 text-stone-200 shadow-sm'
: 'text-stone-400 hover:text-stone-200'
"
>
<component :is="icons.address" size="20" /> IP
</button>
<button
@click="showSubdomains = true"
class="rounded px-2 py-0.5 text-xs font-medium transition-colors"
:class="
showSubdomains
? 'bg-stone-700 text-stone-200 shadow-sm'
: 'text-stone-400 hover:text-stone-200'
"
>
<component :is="icons.domain" size="20" /> Domain
</button>
</div>
</div>
<StatBarTable
:items="showSubdomains ? topSubdomainsData : topAddrsData"
:loading="loadingStats"
bar-min-width="10px"
class="max-h-75"
/>
</SectionCard>
<!-- Port/Honeypot: Top Addresses (No switch) -->
<SectionCard
v-if="viewType === 'port' || viewType === 'honeypot'"
title="Top Addresses"
:icon="icons.address"
>
<StatBarTable
:items="topAddrsData"
:loading="loadingStats"
bar-min-width="10px"
class="max-h-[400px]"
/>
</SectionCard>
</div>
<!-- Stats Grid (Common) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<DetailCard label="Event Types" :icon="icons.eventType">
<div class="relative flex flex-col pt-2">
<StatBarTable :items="topEventsData" :loading="loadingAll" />
</div>
</DetailCard>
<DetailCard label="Recent Activity" :icon="icons.activity">
<SparkChart :data="sparkData" class="h-32" />
</DetailCard>
<DetailCard
label="Total Events"
:value="stats?.total_events"
:icon="icons.total"
/>
<DetailCard
v-if="viewType !== 'honeypot'"
label="Dates"
:icon="icons.time"
>
<div class="stat-label mt-4">First Seen:</div>
<div class="stat-value">
{{ timeAgo(stats?.first_seen) }}
<span class="text-muted font-normal">
({{
stats?.first_seen ? formatLocalDateTime(stats.first_seen) : ""
}})
</span>
</div>
<div class="stat-label mt-4">Last Seen:</div>
<div class="stat-value">
{{ timeAgo(stats?.last_seen) }}
<span class="text-muted font-normal">
({{ stats?.last_seen ? formatLocalDateTime(stats.last_seen) : "" }})
</span>
</div>
</DetailCard>
<!-- Honeypot: Top Ports -->
<DetailCard
v-if="viewType === 'honeypot'"
label="Destination Ports"
:value="topPortsData.length"
:icon="icons.port"
>
<StatBarTable
:items="topPortsData"
:loading="loadingAll"
bar-min-width="10px"
class="max-h-[100px]"
/>
</DetailCard>
</div>
<!-- Honeypot: Extra Stats -->
<details v-if="otherTopStats.length > 0">
<summary class="text-sm text-stone-400">Show more stats</summary>
<div class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<DetailCard v-for="stat in otherTopStats" :key="stat" :label="stat">
<StatBarTable
:items="rawHoneypotStats[stat] || []"
:loading="loadingStats"
class="max-h-[300px]"
label-column-width="w-[min-content] max-w-48 truncate"
:add-title="true"
/>
</DetailCard>
</div>
</details>
<!-- Table (Common) -->
<EventTable
:events="tableEntries"
:total-events="tableTotal"
:loading="loadingTable"
:columns="eventTableColumns"
:filter-actions="localFilterActions"
>
<template #header-right>
<div class="stat-label">
{{ formatLocalNumber(stats?.total_events || 0) }} total events
<template v-if="chartSelectedCount > 0">
({{ formatLocalNumber(chartSelectedCount) }} events,
{{ formatLocalNumber(chartSelectedIPs) }} IPs selected)
</template>
</div>
</template>
<template #rows="{ events }">
<EventTableRow
v-if="width > 768"
v-for="ev in events"
:key="ev.id"
:evt="ev"
:columns="eventTableColumns"
:filter-actions="localFilterActions"
:width="width"
/>
<EventTableMobileRow
v-else
v-for="ev in events"
:key="`mobile-${ev.id}`"
:evt="ev"
:columns="eventTableColumns"
:filter-actions="localFilterActions"
:width="width"
/>
</template>
</EventTable>
</div>
</template>