From 5d764cff2963d01f44b230f1daee5f352073a332 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 21 Sep 2026 22:43:01 +0800 Subject: [PATCH 01/11] 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 --- frontend/components/widgets/IPHistory.vue | 71 ++++++++++++--- frontend/locales/en.json | 10 ++- frontend/locales/fr.json | 10 ++- frontend/locales/pt-BR.json | 10 ++- frontend/locales/ru.json | 10 ++- frontend/locales/zh-TW.json | 10 ++- frontend/locales/zh.json | 10 ++- frontend/utils/ip-history.js | 46 +++++++++- tests/ip-history.test.js | 105 +++++++++++++++++++++- 9 files changed, 261 insertions(+), 21 deletions(-) diff --git a/frontend/components/widgets/IPHistory.vue b/frontend/components/widgets/IPHistory.vue index dcdb079c6..2196370a5 100644 --- a/frontend/components/widgets/IPHistory.vue +++ b/frontend/components/widgets/IPHistory.vue @@ -35,11 +35,26 @@ {{ t('ipHistory.Empty') }} + + + + {{ t('ipHistory.ByIP') }} + + + {{ t('ipHistory.ByDate') }} + + +
+

+ {{ t(viewMode === 'ip' ? 'ipHistory.UniqueIPCounts' : 'ipHistory.DailyRecordCounts') }} +

-
{{ t('ipHistory.NoMatch') }}
- -
-

+ +
+

{{ formatDay(group.day) }}

    @@ -98,6 +113,32 @@ {{ entry.asn }}

+ +
+

+ {{ t('ipHistory.LastSeen', { date: formatDay(entry.lastSeen) }) }} +

+

+ {{ t('ipHistory.FirstSeen', { date: formatDay(entry.firstSeen) }) }} +

+ + + + + +
    +
  • + +
  • +
+
+
+
@@ -119,25 +160,26 @@ diff --git a/frontend/composables/use-world-map-chart.js b/frontend/composables/use-world-map-chart.js new file mode 100644 index 000000000..11d7394e6 --- /dev/null +++ b/frontend/composables/use-world-map-chart.js @@ -0,0 +1,54 @@ +// Serialize lazy world-map renders and discard work after hide or scope disposal. +import { ref, watch, onScopeDispose, toValue } from 'vue'; +import { renderWorldMapChart } from '../utils/world-map-chart.js'; + +export const useWorldMapChart = ({ canvas, visible, options, theme }, { render = renderWorldMapChart } = {}) => { + const ready = ref(false); + const failed = ref(false); + let chart = null; + let queue = Promise.resolve(); + let generation = 0; + let disposed = false; + + const destroy = () => { + chart?.destroy(); + chart = null; + ready.value = false; + }; + + watch([canvas, visible, options, () => toValue(theme)], () => { + const current = ++generation; + failed.value = false; + if (!toValue(visible) || !toValue(canvas)) { + destroy(); + return; + } + queue = queue.then(async () => { + const isActive = () => !disposed && current === generation; + if (!isActive()) return; + try { + const next = await render({ ...toValue(options), canvas: toValue(canvas), chart, isActive }); + if (!isActive()) { + if (next && next !== chart) next.destroy(); + return; + } + chart = next; + ready.value = Boolean(chart); + failed.value = !chart; + } catch { + if (isActive()) { + destroy(); + failed.value = true; + } + } + }); + }, { immediate: true, flush: 'post' }); + + onScopeDispose(() => { + disposed = true; + generation += 1; + destroy(); + }); + + return { ready, failed }; +}; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 69009f933..57d0fa994 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1223,7 +1223,12 @@ "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" + "RecordDates": "All recorded dates", + "MapTitle": "IP countries / regions", + "MapUnavailable": "Map unavailable. You can still use the country filters.", + "MapSelect": "Click to filter this country / region", + "MapUnselect": "Click to remove this country / region filter", + "ViewMap": "View map" }, "ipInfos": { "Title": "IP Infos", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 9b3df4bcb..644c20ebb 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1223,7 +1223,12 @@ "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" + "RecordDates": "Toutes les dates enregistrées", + "MapTitle": "Pays / régions des IP", + "MapUnavailable": "Carte indisponible. Les filtres par pays restent disponibles.", + "MapSelect": "Cliquer pour filtrer ce pays / cette région", + "MapUnselect": "Cliquer pour retirer le filtre de ce pays / cette région", + "ViewMap": "Voir la carte" }, "ipInfos": { "Title": "Infos IP", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index f908c48fb..c91081031 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1223,7 +1223,12 @@ "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" + "RecordDates": "Todas as datas registradas", + "MapTitle": "Países / regiões dos IPs", + "MapUnavailable": "Mapa indisponível. Os filtros por país continuam disponíveis.", + "MapSelect": "Clique para filtrar este país / região", + "MapUnselect": "Clique para remover o filtro deste país / região", + "ViewMap": "Ver mapa" }, "ipInfos": { "Title": "Informações de IP", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index cbefca08a..f11ae11af 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1223,7 +1223,12 @@ "DailyRecordCounts": "Числа в фильтрах: записи за все сохранённые даты (одна на IP в день).", "LastSeen": "Последнее обнаружение: {date}", "FirstSeen": "Первое обнаружение: {date}", - "RecordDates": "Показать даты записей" + "RecordDates": "Все даты записей", + "MapTitle": "Страны / регионы IP", + "MapUnavailable": "Карта недоступна. Фильтры по странам по-прежнему доступны.", + "MapSelect": "Нажмите, чтобы выбрать страну / регион", + "MapUnselect": "Нажмите, чтобы снять фильтр по стране / региону", + "ViewMap": "Посмотреть карту" }, "ipInfos": { "Title": "Сведения об IP", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 97fb7ab18..7e5c93617 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1223,7 +1223,12 @@ "DailyRecordCounts": "標籤數量:所有保留日期的記錄數(同一 IP 每天計一筆)。", "LastSeen": "最近:{date}", "FirstSeen": "首次:{date}", - "RecordDates": "檢視記錄日期" + "RecordDates": "所有記錄日期", + "MapTitle": "IP 所屬國家 / 地區", + "MapUnavailable": "地圖暫時無法使用,仍可使用國家篩選。", + "MapSelect": "點擊篩選此國家 / 地區", + "MapUnselect": "點擊取消此國家 / 地區篩選", + "ViewMap": "查看地圖" }, "ipInfos": { "Title": "IP 資訊", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index f9246ed01..83403531b 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1223,7 +1223,12 @@ "DailyRecordCounts": "标签数量:全部保留日期的记录数(同一 IP 每天计一条)。", "LastSeen": "最近:{date}", "FirstSeen": "首次:{date}", - "RecordDates": "查看记录日期" + "RecordDates": "全部记录日期", + "MapTitle": "IP 所属国家 / 地区", + "MapUnavailable": "地图暂不可用,仍可使用国家筛选。", + "MapSelect": "点击筛选此国家 / 地区", + "MapUnselect": "点击取消此国家 / 地区筛选", + "ViewMap": "查看地图" }, "ipInfos": { "Title": "IP 信息", diff --git a/frontend/utils/world-map-chart.js b/frontend/utils/world-map-chart.js index 6fe4abc72..f60b12532 100644 --- a/frontend/utils/world-map-chart.js +++ b/frontend/utils/world-map-chart.js @@ -16,9 +16,13 @@ // below the name; return a string array for multi-line // tooltipOnMissing — default true; false hides the tooltip entirely on // countries that carry no value -// }) → Promise (null only if the lazy modules failed to load) -import { NUMERIC_TO_ALPHA2 } from '@/data/country-numeric.js'; -import getCountryName from '@/data/country-name.js'; +// uniformColor — true uses the info token for every recorded country +// selectedCountries — ISO alpha-2 codes to outline +// onCountryClick — optional callback, only called for countries with data +// isActive — optional guard checked after lazy loading +// }) → Promise (null on load failure or a stale render) +import { NUMERIC_TO_ALPHA2 } from '../data/country-numeric.js'; +import getCountryName from '../data/country-name.js'; let chartCtor = null; let worldFeatures = null; @@ -90,14 +94,50 @@ export const renderWorldMapChart = async ({ canvas, chart, values, lang, colorFrom, colorTo, formatValue, tooltipOnMissing = true, projectionScale = 1.2, projectionOffset = [-18, 16], + uniformColor = false, selectedCountries = [], onCountryClick, + isActive = () => true, }) => { if (!(await loadModules()) || !canvas) return chart ?? null; + if (!isActive()) return null; const data = worldFeatures.map((feature) => ({ feature, value: values[NUMERIC_TO_ALPHA2[feature.id]], })); + // Selection changes the outline only; IPv6 churn cannot change fill weight. + const selected = new Set(selectedCountries); + const border = cssColor('--border', '#e5e7eb'); + const foreground = cssColor('--foreground', '#171717'); + const fill = cssColor('--info', '#0ea5e9'); + const missing = cssColor('--muted', '#f4f4f5'); + const dataset = { + data, + borderColor: data.map(({ feature }) => selected.has(NUMERIC_TO_ALPHA2[feature.id]) ? foreground : border), + borderWidth: data.map(({ feature }) => selected.has(NUMERIC_TO_ALPHA2[feature.id]) ? 2 : 0.5), + ...(uniformColor ? { backgroundColor: data.map(({ value }) => value === undefined ? missing : fill) } : {}), + }; + const clickedCountry = (elements, instance) => { + const hit = elements[0]; + const item = hit && instance.data.datasets[hit.datasetIndex]?.data[hit.index]; + return item?.value !== undefined ? NUMERIC_TO_ALPHA2[item.feature?.id] : undefined; + }; + const onClick = onCountryClick ? (_event, elements, instance) => { + const code = clickedCountry(elements, instance); + if (code) onCountryClick(code); + } : undefined; + const onHover = onCountryClick ? (_event, elements, instance) => { + instance.canvas.style.cursor = clickedCountry(elements, instance) ? 'pointer' : 'default'; + } : undefined; + const tooltip = { + displayColors: false, + filter: tooltipOnMissing ? undefined : (item) => item.raw?.value !== undefined, + callbacks: { + title: (items) => (items[0]?.raw?.feature ? featureName(items[0].raw.feature, lang) : ''), + label: (ctx) => formatValue(ctx.raw.value, NUMERIC_TO_ALPHA2[ctx.raw.feature?.id]), + }, + }; + // A kept instance may point at an unmounted canvas (panels destroy their // content on close) — rebuild then; update in place otherwise. if (chart && chart.canvas !== canvas) { @@ -105,13 +145,18 @@ export const renderWorldMapChart = async ({ chart = null; } if (chart) { - chart.data.datasets[0].data = data; + chart.data.datasets[0] = dataset; + chart.options.onClick = onClick; + chart.options.onHover = onHover; + chart.options.plugins.tooltip = tooltip; + chart.options.scales.color.missing = missing; + chart.canvas.style.cursor = 'default'; chart.update(); return chart; } - const from = hexToRgb(colorFrom); - const to = hexToRgb(colorTo); + const from = hexToRgb(colorFrom || '#0ea5e9'); + const to = hexToRgb(colorTo || '#0ea5e9'); const interpolate = (v) => { const t = Math.min(1, Math.max(0, v)); const mix = from.map((c, i) => Math.round(c + (to[i] - c) * t)); @@ -121,36 +166,18 @@ export const renderWorldMapChart = async ({ return new chartCtor(canvas, { type: 'choropleth', data: { - datasets: [{ - data, - borderColor: cssColor('--border', '#e5e7eb'), - borderWidth: 0.5, - }], + datasets: [dataset], }, options: { responsive: true, maintainAspectRatio: false, showOutline: false, showGraticule: false, + onClick, + onHover, plugins: { legend: { display: false }, - tooltip: { - displayColors: false, - // With every item filtered out, Chart.js suppresses the - // tooltip box — no-data countries then get no hover UI. - filter: tooltipOnMissing - ? undefined - : (item) => item.raw?.value !== undefined, - callbacks: { - // Line 1: localized name (from the raw feature — the - // tooltip item's own .label is unreliable for the - // choropleth controller); below: caller-formatted. - title: (items) => (items[0]?.raw?.feature - ? featureName(items[0].raw.feature, lang) - : ''), - label: (ctx) => formatValue(ctx.raw.value, NUMERIC_TO_ALPHA2[ctx.raw.feature?.id]), - }, - }, + tooltip, }, scales: { projection: { @@ -164,7 +191,7 @@ export const renderWorldMapChart = async ({ display: false, min: 0, interpolate, - missing: cssColor('--muted', '#f4f4f5'), + missing, }, }, }, diff --git a/tests/composable-world-map-chart.test.js b/tests/composable-world-map-chart.test.js new file mode 100644 index 000000000..9a940a2b7 --- /dev/null +++ b/tests/composable-world-map-chart.test.js @@ -0,0 +1,110 @@ +// Verify lazy map rendering stays bounded across updates, folds and unmounts. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { effectScope, ref, shallowRef, nextTick } from 'vue'; +import { useWorldMapChart } from '../frontend/composables/use-world-map-chart.js'; + +const flush = async () => { + await nextTick(); + await new Promise((resolve) => setImmediate(resolve)); +}; +const fixture = () => { + const scope = effectScope(); + const canvas = shallowRef(null); + const visible = ref(true); + const options = ref({ values: { US: 1 } }); + const theme = ref(false); + const pending = []; + const state = scope.run(() => useWorldMapChart({ canvas, visible, options, theme }, { + render: (args) => new Promise((resolve, reject) => pending.push({ args, resolve, reject })), + })); + return { scope, canvas, visible, options, theme, pending, ...state }; +}; +const chart = () => ({ destroyed: false, destroy() { this.destroyed = true; } }); + +test('waits for a canvas, then serializes changes and renders only the latest state', async () => { + const f = fixture(); + await flush(); + assert.equal(f.pending.length, 0); + f.canvas.value = {}; + await flush(); + assert.equal(f.pending.length, 1); + f.options.value = { values: { DE: 1 } }; + await flush(); + f.options.value = { values: { SG: 1 } }; + await flush(); + assert.equal(f.pending.length, 1); + assert.equal(f.pending[0].args.isActive(), false); + const stale = chart(); + f.pending[0].resolve(stale); + await flush(); + assert.equal(stale.destroyed, true); + assert.equal(f.pending.length, 2); + assert.deepEqual(f.pending[1].args.values, { SG: 1 }); + const current = chart(); + f.pending[1].resolve(current); + await flush(); + assert.equal(f.ready.value, true); + f.scope.stop(); + assert.equal(current.destroyed, true); +}); + +test('folding during load discards the render; reopening creates a fresh chart', async () => { + const f = fixture(); + f.canvas.value = {}; + await flush(); + f.visible.value = false; + await flush(); + assert.equal(f.pending[0].args.isActive(), false); + f.pending[0].resolve(null); + await flush(); + assert.equal(f.ready.value, false); + assert.equal(f.failed.value, false); + f.visible.value = true; + await flush(); + const current = chart(); + f.pending[1].resolve(current); + await flush(); + assert.equal(f.ready.value, true); + f.visible.value = false; + await flush(); + assert.equal(current.destroyed, true); + f.scope.stop(); +}); + +test('scope disposal invalidates an unfinished render and destroys any late result', async () => { + const f = fixture(); + f.canvas.value = {}; + await flush(); + f.scope.stop(); + assert.equal(f.pending[0].args.isActive(), false); + const late = chart(); + f.pending[0].resolve(late); + await flush(); + assert.equal(late.destroyed, true); + assert.equal(f.ready.value, false); +}); + +test('failure is recoverable and theme changes update the existing instance', async () => { + const f = fixture(); + f.canvas.value = {}; + await flush(); + f.pending[0].reject(new Error('chunk unavailable')); + await flush(); + assert.equal(f.failed.value, true); + f.visible.value = false; + await flush(); + f.visible.value = true; + await flush(); + const current = chart(); + f.pending[1].resolve(current); + await flush(); + assert.equal(f.failed.value, false); + f.theme.value = true; + await flush(); + assert.equal(f.pending[2].args.chart, current); + f.pending[2].resolve(current); + await flush(); + assert.equal(f.ready.value, true); + f.scope.stop(); +}); diff --git a/tests/world-map-chart.test.js b/tests/world-map-chart.test.js new file mode 100644 index 000000000..3d89ed1e6 --- /dev/null +++ b/tests/world-map-chart.test.js @@ -0,0 +1,120 @@ +// Exercise the map's country joins, interaction and updates without a browser. +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { registerHooks } from 'node:module'; + +const created = []; +globalThis.__mapTestCharts = created; +globalThis.document = { documentElement: {} }; +let theme = { '--info': '#38bdf8', '--border': '#eeeeee', '--foreground': '#111111', '--muted': '#f4f4f5' }; +globalThis.getComputedStyle = () => ({ getPropertyValue: (key) => theme[key] || '' }); +const stubs = { + 'chart.js/auto': ` + export const registerables = []; + export class Chart { + static register() {} + constructor(canvas, config) { + this.canvas = canvas; this.data = config.data; this.options = config.options; + this.updates = 0; this.destroyed = false; + globalThis.__mapTestCharts.push(this); + } + update() { this.updates += 1; } + destroy() { this.destroyed = true; } + } + `, + 'chartjs-chart-geo': ` + export const ChoroplethController = {}, GeoFeature = {}, ColorScale = {}, ProjectionScale = {}; + export const topojson = { feature: () => ({ features: [ + { id: '840', properties: { name: 'United States' } }, + { id: '276', properties: { name: 'Germany' } }, + { id: '010', properties: { name: 'Antarctica' } }, + ] }) }; + `, + 'world-atlas/countries-110m.json': 'export default { objects: { countries: {} } };', +}; +const hook = registerHooks({ + resolve(specifier, context, next) { + if (stubs[specifier]) return { url: `map-test:${specifier}`, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url.startsWith('map-test:')) return { format: 'module', source: stubs[url.slice(9)], shortCircuit: true }; + return next(url, context); + }, +}); +after(() => { + hook.deregister(); + delete globalThis.__mapTestCharts; + delete globalThis.document; + delete globalThis.getComputedStyle; +}); + +const { renderWorldMapChart } = await import('../frontend/utils/world-map-chart.js'); +const options = () => ({ + canvas: { style: {} }, values: { US: 1 }, lang: 'en', + colorFrom: '#000000', colorTo: '#ffffff', formatValue: (value) => `value:${value}`, +}); + +test('uniform countries ignore IP count, omit Antarctica and keep missing countries neutral', async () => { + const chart = await renderWorldMapChart({ ...options(), uniformColor: true, values: { US: 1, DE: 99 } }); + assert.deepEqual(chart.data.datasets[0].backgroundColor, ['#38bdf8', '#38bdf8']); + assert.equal(chart.data.datasets[0].data.length, 2); + await renderWorldMapChart({ ...options(), canvas: chart.canvas, chart, uniformColor: true }); + assert.deepEqual(chart.data.datasets[0].backgroundColor, ['#38bdf8', '#f4f4f5']); +}); + +test('click and pointer feedback apply only to recorded countries', async () => { + const selected = []; + const chart = await renderWorldMapChart({ ...options(), onCountryClick: (code) => selected.push(code), tooltipOnMissing: false }); + const hit = (index) => [{ datasetIndex: 0, index }]; + chart.options.onClick({}, hit(0), chart); + chart.options.onClick({}, hit(1), chart); + chart.options.onClick({}, [], chart); + assert.deepEqual(selected, ['US']); + chart.options.onHover({}, hit(0), chart); + assert.equal(chart.canvas.style.cursor, 'pointer'); + chart.options.onHover({}, hit(1), chart); + assert.equal(chart.canvas.style.cursor, 'default'); + assert.equal(chart.options.plugins.tooltip.filter({ raw: { value: 1 } }), true); + assert.equal(chart.options.plugins.tooltip.filter({ raw: {} }), false); +}); + +test('an update refreshes selection, theme, language and click callbacks on the same chart', async () => { + const chart = await renderWorldMapChart({ ...options(), uniformColor: true, selectedCountries: ['US'] }); + assert.deepEqual(chart.data.datasets[0].borderWidth, [2, 0.5]); + theme = { ...theme, '--info': '#7dd3fc', '--foreground': '#ffffff' }; + const clicks = []; + const updated = await renderWorldMapChart({ + ...options(), canvas: chart.canvas, chart, uniformColor: true, values: { DE: 1 }, + selectedCountries: ['DE'], lang: 'de', formatValue: () => 'updated', + onCountryClick: (code) => clicks.push(code), + }); + assert.equal(updated, chart); + assert.deepEqual(chart.data.datasets[0].borderWidth, [0.5, 2]); + assert.equal(chart.data.datasets[0].borderColor[1], '#ffffff'); + assert.equal(chart.data.datasets[0].backgroundColor[1], '#7dd3fc'); + const tooltip = chart.options.plugins.tooltip.callbacks; + assert.equal(tooltip.title([{ raw: { feature: { id: '276' } } }]), 'Deutschland'); + assert.equal(tooltip.label({ raw: { value: 1, feature: { id: '276' } } }), 'updated'); + chart.options.onClick({}, [{ datasetIndex: 0, index: 1 }], chart); + assert.deepEqual(clicks, ['DE']); +}); + +test('heatmap callers retain the gradient and no click behavior', async () => { + const chart = await renderWorldMapChart(options()); + assert.equal(chart.data.datasets[0].backgroundColor, undefined); + assert.equal(chart.options.scales.color.interpolate(0), 'rgb(0, 0, 0)'); + assert.equal(chart.options.scales.color.interpolate(1), 'rgb(255, 255, 255)'); + assert.equal(chart.options.onClick, undefined); + assert.equal(chart.options.onHover, undefined); +}); + +test('a stale lazy render does not construct a chart, and canvas replacement destroys the old one', async () => { + const count = created.length; + assert.equal(await renderWorldMapChart({ ...options(), isActive: () => false }), null); + assert.equal(created.length, count); + const chart = await renderWorldMapChart(options()); + const replacement = await renderWorldMapChart({ ...options(), chart }); + assert.notEqual(replacement, chart); + assert.equal(chart.destroyed, true); +}); From f7e332c15def68098843ade4551af5e50a78cf63 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 21 Sep 2026 23:17:23 +0800 Subject: [PATCH 03/11] Improvements --- frontend/data/changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 5eefac6fe..7220c69df 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -2089,7 +2089,7 @@ }, { "version": "v7.6.0", - "date": "Beta", + "date": "2026-09-21", "content": [ { "type": "add", From d77342cc83b153040ba237c9d3dcd3f67aed089d Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 21 Sep 2026 23:21:46 +0800 Subject: [PATCH 04/11] Improvements --- frontend/data/changelog.json | 28 ++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 7220c69df..0eec8c9fe 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -2136,5 +2136,33 @@ } } ] + }, + { + "version": "v7.7.0", + "date": "Beta", + "content": [ + { + "type": "improve", + "change": { + "en": "IP History can now be viewed by IP or date", + "zh": "IP 历史记录可以按 IP/日期视图进行展示", + "zh-TW": "IP 歷史記錄可以按 IP/日期視圖進行展示", + "fr": "L'historique des IP peut être affiché par IP ou date", + "ru": "История IP может быть отображена по IP или дате", + "pt-BR": "O histórico de IP pode ser exibido por IP ou data" + } + }, + { + "type": "fix", + "change": { + "en": "Fixed some issues", + "zh": "修复了一些问题", + "zh-TW": "修正了一些問題", + "fr": "Correction de divers problèmes", + "ru": "Исправлены некоторые проблемы", + "pt-BR": "Corrigidos alguns problemas" + } + } + ] } ] diff --git a/package.json b/package.json index c0ceeccef..e46b819c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "7.6.0", + "version": "7.7.0", "type": "module", "packageManager": "pnpm@12.4.2", "engines": { From 2b5485f3167ab4e3dfc12f6bd86dad3f409025d1 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 22 Sep 2026 15:30:32 +0800 Subject: [PATCH 05/11] Improvements --- frontend/components/widgets/IPHistory.vue | 2 +- frontend/components/widgets/IPHistoryMap.vue | 4 ++-- frontend/composables/use-world-map-chart.js | 10 +++++++--- tests/composable-world-map-chart.test.js | 7 ++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/components/widgets/IPHistory.vue b/frontend/components/widgets/IPHistory.vue index f0e376abd..38715fb36 100644 --- a/frontend/components/widgets/IPHistory.vue +++ b/frontend/components/widgets/IPHistory.vue @@ -129,7 +129,7 @@