Feat(history): group IP records with a daily view

Collapse repeated daily IP records into summaries ordered by last seen.
Keep country and IP-type filters across both views and expand dates only
for IPs recorded on multiple days. Preserve the daily storage format.

Co-Authored-By: GPT-6 <noreply@openai.com>
This commit is contained in:
jason5ng32andGPT-6 committed 2026-09-21 22:43:01 +08:00
1 parent ab42ba6acf
commit 5d764cff29
9 files changed
+261 -21

No files matched your search

+61 -10
View File
@@ -35,11 +35,26 @@
{{ t('ipHistory.Empty') }}
</div>
<!-- View selection keeps the country/type filters intact. -->
<ToggleGroup v-if="hasHistory" :model-value="viewMode" type="single" variant="outline"
class="w-full" :aria-label="t('ipHistory.ViewMode')"
@update:model-value="(value) => { if (value) viewMode = value; }">
<ToggleGroupItem value="ip" class="flex-1 cursor-pointer">
{{ t('ipHistory.ByIP') }}
</ToggleGroupItem>
<ToggleGroupItem value="date" class="flex-1 cursor-pointer">
{{ t('ipHistory.ByDate') }}
</ToggleGroupItem>
</ToggleGroup>
<!-- Filters — flat toggle tags: highlight any combination.
Within a row selections OR together, the two rows AND.
Nothing highlighted = no filter. Tags and counts come from
the full history so the layout never shifts mid-combo. -->
<div v-if="hasHistory && (showTypeFilter || showCountryFilter)" class="space-y-2">
<p class="text-xs text-muted-foreground">
{{ t(viewMode === 'ip' ? 'ipHistory.UniqueIPCounts' : 'ipHistory.DailyRecordCounts') }}
</p>
<!-- IP type tags -->
<ToggleGroup v-if="showTypeFilter" v-model="versionFilter" type="multiple" variant="outline"
:spacing="2" class="w-full flex-wrap justify-start"
@@ -69,15 +84,15 @@
</div>
<!-- Empty result for the selected tag combination -->
<div v-if="hasHistory && displayDays.length === 0"
<div v-if="hasHistory && displayGroups.length === 0"
class="flex flex-col items-center gap-2 py-10 text-center text-sm text-muted-foreground">
<ListFilter class="size-8 opacity-40" />
{{ t('ipHistory.NoMatch') }}
</div>
<!-- Day groups, newest first -->
<section v-for="group in displayDays" :key="group.day">
<h3 class="text-xs uppercase tracking-wide text-muted-foreground mb-2">
<!-- IP summaries sort by last seen; date groups sort newest first. -->
<section v-for="group in displayGroups" :key="`${viewMode}:${group.day || 'ips'}`">
<h3 v-if="group.day" class="text-xs uppercase tracking-wide text-muted-foreground mb-2">
{{ formatDay(group.day) }}
</h3>
<ul class="rounded-lg border bg-card divide-y">
@@ -98,6 +113,32 @@
{{ entry.asn }}
</Badge>
</div>
<!-- Single-day records need no expandable date list. -->
<div v-if="viewMode === 'ip'" class="mt-3 space-y-2">
<p class="text-xs font-medium">
{{ t('ipHistory.LastSeen', { date: formatDay(entry.lastSeen) }) }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('ipHistory.FirstSeen', { date: formatDay(entry.firstSeen) }) }}
</p>
<Collapsible v-if="entry.dayCount > 1">
<CollapsibleTrigger>
<Button type="button" variant="ghost" size="sm"
class="group h-auto max-w-full justify-start whitespace-normal px-0 py-1 text-xs text-muted-foreground cursor-pointer">
<ChevronDown class="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
{{ t('ipHistory.RecordDates') }}
<span class="sr-only">{{ entry.ip }}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul class="grid grid-cols-2 gap-x-3 gap-y-2 pt-2 pb-1 text-xs text-muted-foreground">
<li v-for="day in entry.dates" :key="day">
<time :datetime="day">{{ formatDay(day) }}</time>
</li>
</ul>
</CollapsibleContent>
</Collapsible>
</div>
</li>
</ul>
</section>
@@ -119,25 +160,26 @@
<script setup>
// IPHistory — local record of every IP detected while using the app.
// Data comes from store.allIPs via use-ip-history (localStorage, grouped by
// day, 90-day retention, never synced to the account). The panel mirrors the
// Preferences left-sheet shell.
// day, up to 90 days, never synced to the account). IP summaries are derived
// from the matching daily records; the date view keeps those records accessible.
import { ref, computed, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { useIpHistory } from '@/composables/use-ip-history.js';
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
import { filterHistoryDays, countryFacets, ipVersionCounts } from '@/utils/ip-history.js';
import { filterHistoryDays, groupHistoryByIP, countryFacets, ipVersionCounts } from '@/utils/ip-history.js';
import { formatIsoDate } from '@/utils/time-utils.js';
import getCountryName from '@/data/country-name.js';
import FitText from '@/components/widgets/FitText.vue';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Icon } from '@iconify/vue';
import { Globe, History, ListFilter, Trash2 } from '@lucide/vue';
import { ChevronDown, Globe, History, ListFilter, Trash2 } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
@@ -156,6 +198,7 @@ const openPanel = () => {
};
const lang = computed(() => store.lang);
const viewMode = ref('ip');
// Filters — flat toggle tags, any combination. Empty selection = that
// dimension unfiltered. Country values are country codes plus the 'unknown'
@@ -170,15 +213,23 @@ const countryFilter = ref([]); // country codes + 'unknown'
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
const tagCountClass = 'text-muted-foreground group-data-[state=on]:text-primary-foreground/70';
const versionCounts = computed(() => ipVersionCounts(sortedDays.value));
// Facets always cover the full retained history, with units matching the view.
// Country counts can overlap: one IP may have been recorded in multiple countries.
const facetOptions = computed(() => ({ uniqueIPs: viewMode.value === 'ip' }));
const versionCounts = computed(() => ipVersionCounts(sortedDays.value, facetOptions.value));
const showTypeFilter = computed(() => versionCounts.value.v4 > 0 && versionCounts.value.v6 > 0);
const countries = computed(() => countryFacets(sortedDays.value));
const countries = computed(() => countryFacets(sortedDays.value, facetOptions.value));
const showCountryFilter = computed(() => countries.value.length > 1);
const displayDays = computed(() => filterHistoryDays(sortedDays.value, {
versions: versionFilter.value.map((v) => (v === 'v6' ? 6 : 4)),
countries: countryFilter.value.map((c) => (c === 'unknown' ? '' : c)),
}));
const displayGroups = computed(() => {
if (viewMode.value === 'date') return displayDays.value;
const entries = groupHistoryByIP(displayDays.value);
return entries.length ? [{ day: null, entries }] : [];
});
// Drop selections whose records vanished entirely (retention pruning,
// clear-all), so stale hidden tags don't keep filtering the list.
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "Unknown",
"NoMatch": "No records match the selected filters.",
"ClearAll": "Clear History",
"ClearConfirm": "Click again to confirm"
"ClearConfirm": "Click again to confirm",
"ViewMode": "History view",
"ByIP": "By IP",
"ByDate": "By date",
"UniqueIPCounts": "Tag counts: unique IPs across all retained records.",
"DailyRecordCounts": "Tag counts: records across all retained dates (one per IP per day).",
"LastSeen": "Last seen: {date}",
"FirstSeen": "First seen: {date}",
"RecordDates": "View recorded dates"
},
"ipInfos": {
"Title": "IP Infos",
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "Inconnu",
"NoMatch": "Aucun enregistrement ne correspond aux filtres sélectionnés.",
"ClearAll": "Effacer l'historique",
"ClearConfirm": "Cliquez à nouveau pour confirmer"
"ClearConfirm": "Cliquez à nouveau pour confirmer",
"ViewMode": "Vue de l’historique",
"ByIP": "Par IP",
"ByDate": "Par date",
"UniqueIPCounts": "Compteurs des filtres : IP uniques dans l’ensemble des enregistrements conservés.",
"DailyRecordCounts": "Compteurs des filtres : enregistrements de toutes les dates conservées (un par IP et par jour).",
"LastSeen": "Dernière détection : {date}",
"FirstSeen": "Première détection : {date}",
"RecordDates": "Voir les dates enregistrées"
},
"ipInfos": {
"Title": "Infos IP",
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "Desconhecido",
"NoMatch": "Nenhum registro corresponde aos filtros selecionados.",
"ClearAll": "Limpar histórico",
"ClearConfirm": "Clique novamente para confirmar"
"ClearConfirm": "Clique novamente para confirmar",
"ViewMode": "Visualização do histórico",
"ByIP": "Por IP",
"ByDate": "Por data",
"UniqueIPCounts": "Contagens dos filtros: IPs únicos em todos os registros mantidos.",
"DailyRecordCounts": "Contagens dos filtros: registros de todas as datas mantidas (um por IP por dia).",
"LastSeen": "Última detecção: {date}",
"FirstSeen": "Primeira detecção: {date}",
"RecordDates": "Ver datas registradas"
},
"ipInfos": {
"Title": "Informações de IP",
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "Неизвестно",
"NoMatch": "Нет записей, соответствующих выбранным фильтрам.",
"ClearAll": "Очистить историю",
"ClearConfirm": "Нажмите ещё раз для подтверждения"
"ClearConfirm": "Нажмите ещё раз для подтверждения",
"ViewMode": "Вид истории",
"ByIP": "По IP",
"ByDate": "По дате",
"UniqueIPCounts": "Числа в фильтрах: уникальные IP среди всех сохранённых записей.",
"DailyRecordCounts": "Числа в фильтрах: записи за все сохранённые даты (одна на IP в день).",
"LastSeen": "Последнее обнаружение: {date}",
"FirstSeen": "Первое обнаружение: {date}",
"RecordDates": "Показать даты записей"
},
"ipInfos": {
"Title": "Сведения об IP",
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "未知",
"NoMatch": "沒有符合篩選條件的記錄。",
"ClearAll": "清空記錄",
"ClearConfirm": "再次點選確認清空"
"ClearConfirm": "再次點選確認清空",
"ViewMode": "歷史記錄檢視方式",
"ByIP": "依 IP",
"ByDate": "依日期",
"UniqueIPCounts": "標籤數量:所有保留記錄中的不重複 IP 數。",
"DailyRecordCounts": "標籤數量:所有保留日期的記錄數(同一 IP 每天計一筆)。",
"LastSeen": "最近:{date}",
"FirstSeen": "首次:{date}",
"RecordDates": "檢視記錄日期"
},
"ipInfos": {
"Title": "IP 資訊",
+9 -1
View File
@@ -1215,7 +1215,15 @@
"UnknownCountry": "未知",
"NoMatch": "没有符合筛选条件的记录。",
"ClearAll": "清空记录",
"ClearConfirm": "再次点击确认清空"
"ClearConfirm": "再次点击确认清空",
"ViewMode": "历史记录视图",
"ByIP": "按 IP",
"ByDate": "按日期",
"UniqueIPCounts": "标签数量:全部保留记录中的独立 IP 数。",
"DailyRecordCounts": "标签数量:全部保留日期的记录数(同一 IP 每天计一条)。",
"LastSeen": "最近:{date}",
"FirstSeen": "首次:{date}",
"RecordDates": "查看记录日期"
},
"ipInfos": {
"Title": "IP 信息",
+42 -4
View File
@@ -130,15 +130,50 @@ export const sortedHistoryDays = (history) =>
.sort(([a], [b]) => (a < b ? 1 : -1))
.map(([day, entries]) => ({ day, entries }));
// Aggregate retained (optionally filtered) records without changing storage.
// Keep the latest matching record's metadata together: combining fields from
// different days could invent a country/location/ASN combination never seen.
export const groupHistoryByIP = (days) => {
const byIp = new Map();
for (const { day, entries } of [...days].sort((a, b) => b.day.localeCompare(a.day))) {
for (const entry of entries) {
if (!byIp.has(entry.ip)) {
byIp.set(entry.ip, { ...entry, dates: new Set() });
}
byIp.get(entry.ip).dates.add(day);
}
}
return [...byIp.values()]
.map(({ dates, ...entry }) => {
const recordedDays = [...dates];
return {
...entry,
dates: recordedDays,
firstSeen: recordedDays.at(-1),
lastSeen: recordedDays[0],
dayCount: recordedDays.length,
};
})
.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || a.ip.localeCompare(b.ip));
};
// Aggregate entry counts per country over a sortedHistoryDays() list, most
// entries first (code asc as tie-breaker). Entries without a country roll up
// under ''. Takes the day list rather than the history object so facets can
// be computed on an already-filtered view (e.g. after the IP-type filter).
export const countryFacets = (days) => {
// Unique mode counts each IP once per country it was recorded in, so a
// historical country remains discoverable even if the latest record differs.
export const countryFacets = (days, { uniqueIPs = false } = {}) => {
const counts = new Map();
const seen = new Map();
for (const { entries } of days) {
for (const { country } of entries) {
for (const { ip, country } of entries) {
const code = country || '';
if (uniqueIPs) {
if (!seen.has(code)) seen.set(code, new Set());
if (seen.get(code).has(ip)) continue;
seen.get(code).add(ip);
}
counts.set(code, (counts.get(code) || 0) + 1);
}
}
@@ -159,12 +194,15 @@ export const distinctCountryCount = (history) => {
return codes.size;
};
// Entry counts per IP version over a sortedHistoryDays() list.
export const ipVersionCounts = (days) => {
// Entry counts per IP version, optionally deduplicated across all days.
export const ipVersionCounts = (days, { uniqueIPs = false } = {}) => {
let v4 = 0;
let v6 = 0;
const seen = new Set();
for (const { entries } of days) {
for (const { ip } of entries) {
if (uniqueIPs && seen.has(ip)) continue;
seen.add(ip);
if (isIPv6(ip)) v6 += 1;
else v4 += 1;
}
+104 -1
View File
@@ -1,6 +1,6 @@
// Tests for the pure IP-history helpers behind the local (browser-only)
// IP detection history: parsing/sanitizing the localStorage payload, per-day
// merge with field back-fill, retention pruning, and render-order sorting.
// merge with field back-fill, retention pruning, aggregation, facets and filtering.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
@@ -14,6 +14,7 @@ import {
mergeIntoHistory,
pruneHistory,
sortedHistoryDays,
groupHistoryByIP,
countryFacets,
distinctCountryCount,
ipVersionCounts,
@@ -187,6 +188,108 @@ describe('sortedHistoryDays', () => {
});
});
describe('groupHistoryByIP', () => {
it('collapses 90 days of one stable IP to one summary without modifying records', () => {
const days = Array.from({ length: 90 }, (_, i) => ({
day: localDayKey(new Date(2026, 6, 1 + i)),
entries: [entry('192.0.2.1')],
}));
const before = structuredClone(days);
const result = groupHistoryByIP(days);
assert.equal(result.length, 1);
assert.equal(result[0].dayCount, 90);
assert.equal(result[0].firstSeen, days[0].day);
assert.equal(result[0].lastSeen, days[89].day);
assert.deepEqual(result[0].dates, days.map(({ day }) => day).reverse());
assert.deepEqual(days, before);
});
it('counts distinct observed dates, not duplicates or unobserved days in the span', () => {
const days = [
{ day: '2026-07-01', entries: [entry('192.0.2.1'), entry('192.0.2.1')] },
{ day: '2026-07-09', entries: [entry('192.0.2.1')] },
];
assert.deepEqual(groupHistoryByIP(days)[0], {
...entry('192.0.2.1'),
dates: ['2026-07-09', '2026-07-01'],
firstSeen: '2026-07-01', lastSeen: '2026-07-09', dayCount: 2,
});
});
it('sorts by last seen descending with a stable IP tie-breaker, regardless of input order', () => {
const days = [
{ day: '2026-07-01', entries: [entry('192.0.2.1')] },
{ day: '2026-07-09', entries: [entry('2001:db8::1'), entry('192.0.2.2')] },
];
assert.deepEqual(groupHistoryByIP(days).map(({ ip }) => ip), [
'192.0.2.2', '2001:db8::1', '192.0.2.1',
]);
assert.deepEqual(groupHistoryByIP([...days].reverse()), groupHistoryByIP(days));
});
it('uses the latest matching snapshot without borrowing older geographic details', () => {
const days = [
{ day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US', location: 'New York', asn: 'AS1' })] },
{ day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG', asn: 'AS2' })] },
];
const [summary] = groupHistoryByIP(days);
assert.equal(summary.country, 'SG');
assert.equal(summary.location, '');
assert.equal(summary.asn, 'AS2');
const [historical] = groupHistoryByIP(filterHistoryDays(days, { countries: ['US'] }));
assert.equal(historical.country, 'US');
assert.equal(historical.location, 'New York');
assert.equal(historical.lastSeen, '2026-07-01');
assert.equal(historical.dayCount, 1);
});
it('derives first seen and counts from retained records only', () => {
const history = { version: 1, days: {
'2026-07-01': [entry('192.0.2.1')],
'2026-07-09': [entry('192.0.2.1')],
} };
const retained = pruneHistory(history, '2026-07-09', 1).history;
const [summary] = groupHistoryByIP(sortedHistoryDays(retained));
assert.equal(summary.firstSeen, '2026-07-09');
assert.equal(summary.lastSeen, '2026-07-09');
assert.equal(summary.dayCount, 1);
assert.deepEqual(groupHistoryByIP([]), []);
});
});
describe('unique IP facets', () => {
const days = [
{ day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
{ day: '2026-07-08', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
{ day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US' })] },
];
it('counts one IP per country while retaining every historical country', () => {
const facets = countryFacets(days, { uniqueIPs: true });
assert.deepEqual(facets, [
{ code: '', count: 1 }, { code: 'SG', count: 1 }, { code: 'US', count: 1 },
]);
for (const facet of facets) {
assert.equal(groupHistoryByIP(filterHistoryDays(days, { countries: [facet.code] })).length, facet.count);
}
assert.equal(countryFacets(days).find(({ code }) => code === 'SG').count, 2);
assert.deepEqual(countryFacets([], { uniqueIPs: true }), []);
});
it('deduplicates IP versions across days, with the original daily counts still available', () => {
assert.deepEqual(ipVersionCounts(days, { uniqueIPs: true }), { v4: 1, v6: 1 });
assert.deepEqual(ipVersionCounts(days), { v4: 3, v6: 2 });
assert.deepEqual(ipVersionCounts([], { uniqueIPs: true }), { v4: 0, v6: 0 });
});
it('combines country selections without counting a shared IP twice', () => {
const filtered = filterHistoryDays(days, { countries: ['SG', 'US'], versions: [4] });
const summaries = groupHistoryByIP(filtered);
assert.equal(summaries.length, 1);
assert.equal(summaries[0].dayCount, 3);
});
});
// Two-day history spanning US / SG / no-country entries and both IP versions,
// used by the filter suites below. Day 07-08: three v4 + one v6 (US);
// day 07-07: one v4 (US).