import { useThrottleFn } from "@vueuse/core";
import { defineStore } from "pinia";
import { isIpInSubnet } from "utils/filters";
import { formatDateTimeForAPI } from "utils/formatting";
import { URL } from "utils/utils";
import { computed, reactive, ref, watch, type Reactive } from "vue";
import type { DashboardStats } from "./types";
// Generic helpers for array state updates
const addToArray = <T>(arr: T[], val: T) => {
if (!arr.includes(val)) arr.push(val);
};
const removeFromArray = <T>(arr: T[], val: T) => {
const index = arr.indexOf(val);
if (index !== -1) arr.splice(index, 1);
};
export interface FilterState {
limit: number;
offset: number;
order_direction: string;
remote_addr: string[];
dst_port: string[]; // string is necessary for exclusion (!port)
time_start: string;
time_end: string;
type: string[];
event: string[];
asn: string[];
country: string[];
city: string[];
domain: string[];
fqdn: string[];
resolve_ips?: boolean;
expand_details?: boolean;
columns: string[];
id?: number;
x_range?: string[];
y_range?: string[];
sync_time_with_chart?: boolean;
sync_ports_with_chart?: boolean;
json_fields: Record<string, string[]>;
lat_range?: string[];
lon_range?: string[];
}
export const createFilterState = (
initial?: Partial<FilterState>,
): FilterState => {
const state: FilterState = {
limit: 50,
offset: 0,
order_direction: "desc",
remote_addr: [],
dst_port: [],
time_start: "",
time_end: "",
type: [],
event: [],
asn: [],
country: [],
city: [],
domain: [],
fqdn: [],
resolve_ips: false,
expand_details: false,
columns: [],
id: 0,
x_range: [],
y_range: [],
sync_time_with_chart: false,
sync_ports_with_chart: false,
json_fields: {},
lat_range: [],
lon_range: [],
};
if (initial) {
Object.entries(initial).forEach(([key, val]) => {
if (val !== undefined) {
(state as any)[key] = val;
}
});
}
return state;
};
export function buildQueryStringFromState(state: FilterState) {
const params = new URLSearchParams({
limit: state.limit.toString(),
offset: state.offset.toString(),
order_direction: state.order_direction,
});
const arrayFields: Record<string, string[]> = {
type: state.type,
columns: state.columns,
dst_port: state.dst_port,
remote_addr: state.remote_addr,
asn: state.asn,
country: state.country,
city: state.city,
domain: state.domain,
fqdn: state.fqdn,
};
Object.entries(arrayFields).forEach(([key, val]) => {
val?.forEach((v) => params.append(key, v));
});
if (state.id && state.id !== 0) params.set("id", state.id.toString());
const events = [...(state.event || [])];
events.forEach((e) => params.append("event", e));
if (state.time_start)
params.set("time_start", formatDateTimeForAPI(state.time_start));
if (state.time_end)
params.set("time_end", formatDateTimeForAPI(state.time_end));
// Add dynamic JSON fields
Object.entries(state.json_fields || {}).forEach(([key, values]) => {
values.forEach((v) => params.append("f:" + key, v));
});
return params.toString();
}
export type FilterActions = {
state: FilterState;
addRemoteAddr: (addr: string) => void;
removeRemoteAddr: (addr: string) => void;
addDstPort: (port: string) => void;
setDstPort: (port: string) => void;
setTimeStart: (start: string) => void;
setTimeEnd: (end: string) => void;
removeDstPort: (port: string) => void;
addHoneypotType: (honeypot: string) => void;
removeHoneypotType: (honeypot: string) => void;
addEvent: (event: string) => void;
removeEvent: (event: string) => void;
addAsn: (asn: string) => void;
removeAsn: (asn: string) => void;
addCountry: (country: string) => void;
removeCountry: (country: string) => void;
addCity: (city: string) => void;
removeCity: (city: string) => void;
addDomain: (domain: string) => void;
removeDomain: (domain: string) => void;
addFqdn: (fqdn: string) => void;
removeFqdn: (fqdn: string) => void;
addJsonField: (key: string, value: string) => void;
removeJsonField: (key: string, value: string) => void;
setJsonField: (key: string, values: string[]) => void;
getQueryString: () => string;
reset: (initial?: Partial<FilterState>) => void;
};
export const useFilterActions = (
initialState?: FilterState | Reactive<FilterState>,
): FilterActions => {
const state = reactive(initialState || createFilterState());
return {
state,
addRemoteAddr: (addr: string) => addToArray(state.remote_addr, addr),
removeRemoteAddr: (addr: string) =>
removeFromArray(state.remote_addr, addr),
addDstPort: (port: string) => addToArray(state.dst_port, port),
setDstPort: (port: string) => {
state.dst_port = [port];
},
setTimeStart: (start: string) => {
state.time_start = start;
},
setTimeEnd: (end: string) => {
state.time_end = end;
},
removeDstPort: (port: string) => removeFromArray(state.dst_port, port),
addHoneypotType: (honeypot: string) => addToArray(state.type, honeypot),
removeHoneypotType: (honeypot: string) =>
removeFromArray(state.type, honeypot),
addEvent: (event: string) => addToArray(state.event, event),
removeEvent: (event: string) => removeFromArray(state.event, event),
addAsn: (asn: string) => addToArray(state.asn, asn),
removeAsn: (asn: string) => removeFromArray(state.asn, asn),
addCountry: (country: string) => addToArray(state.country, country),
removeCountry: (country: string) => removeFromArray(state.country, country),
addCity: (city: string) => addToArray(state.city, city),
removeCity: (city: string) => removeFromArray(state.city, city),
addDomain: (domain: string) => addToArray(state.domain, domain),
removeDomain: (domain: string) => removeFromArray(state.domain, domain),
addFqdn: (fqdn: string) => addToArray(state.fqdn, fqdn),
removeFqdn: (fqdn: string) => removeFromArray(state.fqdn, fqdn),
addJsonField: (key: string, value: string) => {
if (!state.json_fields[key]) state.json_fields[key] = [];
addToArray(state.json_fields[key], value);
},
removeJsonField: (key: string, value: string) => {
if (state.json_fields[key]) {
removeFromArray(state.json_fields[key], value);
}
},
setJsonField: (key: string, values: string[]) => {
state.json_fields[key] = values;
},
getQueryString: () => buildQueryStringFromState(state),
reset: (initial?: Partial<FilterState>) => {
Object.assign(state, createFilterState(initial));
},
};
};
export const useFilterStore = defineStore("filter", () => useFilterActions());
export const useChartStore = defineStore("chart", () =>
useFilterActions(
reactive(
createFilterState({
limit: 10000,
columns: ["time", "remote_addr", "dst_port", "type", "event"],
}),
),
),
);
export const useUIStore = defineStore("ui", () => {
const selectedIP = ref<string | undefined>(undefined);
const selectedPort = ref<number | undefined>(undefined);
const selectedCountry = ref<string | undefined>(undefined);
const selectedCount = ref(0);
const selectedIPCount = ref(0);
const mapType = ref<"scattergeo" | "scattermap">("scattergeo");
function resetSelection() {
selectedIP.value = undefined;
selectedPort.value = undefined;
selectedCountry.value = undefined;
selectedCount.value = 0;
selectedIPCount.value = 0;
}
return {
selectedIP,
selectedPort,
selectedCountry,
selectedCount,
selectedIPCount,
resetSelection,
mapType,
};
});
// Generic lookup logic composable
function useQueuedLookup<T, R>(options: {
name: string;
apiUrl: string;
mapResponse: (data: R) => { found: Record<string, T>; notFound: string[] };
maxRetries?: number;
}) {
const cached = ref<Record<string, T>>({});
const failed = ref<Record<string, number>>({});
const queue = ref<string[]>([]);
const inProgress = new Set<string>();
const maxRetries = options.maxRetries ?? 2;
const lookup = useThrottleFn(
async () => {
const ids = Array.from(
new Set(
queue.value.filter(
(id) =>
!cached.value[id] &&
(failed.value[id] ?? 0) < maxRetries &&
!inProgress.has(id),
),
),
);
if (!ids.length) return;
ids.forEach((id) => inProgress.add(id));
try {
const res = await fetch(
`${URL()}${options.apiUrl}?ip=${ids.join(",")}`,
);
const data = (await res.json()) as R;
const { found, notFound } = options.mapResponse(data);
Object.entries(found).forEach(([id, val]) => {
cached.value[id] = val;
delete failed.value[id];
});
notFound?.forEach(
(id) => (failed.value[id] = (failed.value[id] ?? 0) + 1),
);
} catch (e) {
console.error(`${options.name} lookup failed`, e);
} finally {
const processed = new Set(ids);
queue.value = queue.value.filter((id) => !processed.has(id));
ids.forEach((id) => inProgress.delete(id));
}
},
500,
true,
true,
);
watch(
() => queue.value.length,
(len) => len > 0 && lookup(),
);
return {
cached,
failed,
queue,
getById: (id: string) =>
computed(() => {
if (cached.value[id]) return cached.value[id];
if (
!queue.value.includes(id) &&
(failed.value[id] ?? 0) < maxRetries &&
!inProgress.has(id)
) {
queue.value.push(id);
lookup();
}
return undefined;
}),
};
}
export const useIPInfoStore = defineStore("ipinfo", () => {
const { queue, getById } = useQueuedLookup<GeoData, any>({
name: "ipinfo",
apiUrl: "/api/ipinfo",
mapResponse: (data) => ({
found: data.results || {},
notFound: data.not_found || [],
}),
});
return {
getIPInfo: (ip: string) => getById(ip),
ipInfoIsLoading: (ip: string) => queue.value.includes(ip),
getDomainByIp: (ip: string) =>
computed(
() => getById(ip).value?.fqdn || getById(ip).value?.domain || ip,
),
};
});
export type GeoData = {
asn: {
autonomous_system_number: number;
autonomous_system_organization: string;
} | null;
city: {
name: string;
country: string;
} | null;
country: { iso_code: string } | null;
domain?: string;
fqdn?: string;
};
type BlocklistEntry = {
address: string;
timestamp: string;
expires: string;
reason: string;
};
type HoneypotType = {
name: string;
label: string;
ports: number[];
};
export const useBlocklistStore = defineStore("blocklist", () => {
const blocklist = ref<BlocklistEntry[]>([]);
const loading = ref(false);
const fetchBlocklist = async () => {
loading.value = true;
try {
const res = await fetch(`${URL()}/api/blocklist-entries`);
const data = await res.json();
blocklist.value = data ?? [];
} finally {
loading.value = false;
}
};
const getBlocklistEntryByIp = computed(() => (ip: string) => {
return blocklist.value.find((entry) => entry.address === ip);
});
const isInBotnet = computed(() => (ip: string) => {
const botnets = blocklist.value.filter((entry) =>
entry.reason.includes("botnet"),
);
return botnets.some((entry) => isIpInSubnet(ip, entry.address));
});
const getBotnetEntryByIp = computed(() => (ip: string) => {
return blocklist.value.find(
(entry) =>
entry.reason.includes("botnet") && isIpInSubnet(ip, entry.address),
);
});
const getTagsByIp = computed(() => (ip: string) => {
const entry = getBlocklistEntryByIp.value(ip);
const tags = entry?.reason
? entry.reason.split(",").map((s) => s.trim())
: [];
if (isInBotnet.value(ip) && !tags.includes("botnet")) {
tags.push("botnet");
}
return tags;
});
return {
blocklist,
loading,
fetchBlocklist,
getBlocklistEntryByIp,
isInBotnet,
getBotnetEntryByIp,
getTagsByIp,
};
});
export const useDashboardStore = defineStore("dashboard", () => {
const stats = ref<DashboardStats | null>(null);
const loading = ref(false);
const fetchStats = async () => {
loading.value = true;
try {
const response = await fetch(`${URL()}/api/stats`);
const data = await response.json();
stats.value = data ?? null;
} catch (e) {
console.error("Failed to fetch stats", e);
} finally {
loading.value = false;
}
};
return {
stats,
loading,
fetchStats,
};
});
export const honeypotTypes = ref<HoneypotType[]>([]);
export const fetchHoneypotTypes = async () => {
const res = await fetch(`${URL()}/api/active-honeypots`);
const data = await res.json();
honeypotTypes.value = data;
};
export const getHoneypot = (name: string) => {
return honeypotTypes.value.find(
(hp) => hp.name.toLowerCase() === name.toLowerCase(),
);
};
export const useAuthStore = defineStore("auth", () => {
const authRequired = ref(false);
const authenticated = ref(false);
const statusChecked = ref(false);
const fetchAuthStatus = async () => {
try {
const res = await fetch(`${URL()}/api/auth-status`);
const data = await res.json();
authRequired.value = data.auth_required;
authenticated.value = data.authenticated;
} catch (e) {
console.error("Failed to fetch auth status", e);
} finally {
statusChecked.value = true;
}
};
const login = async (password: string) => {
const res = await fetch(`${URL()}/api/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
if (res.ok) {
authenticated.value = true;
return true;
}
return false;
};
const logout = async () => {
await fetch(`${URL()}/api/logout`, { method: "POST" });
authenticated.value = false;
};
return {
authRequired,
authenticated,
statusChecked,
fetchAuthStatus,
login,
logout,
};
});