From 7a77a004a80bd8f93e386d97332a423aadfbe36f Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 18 Jul 2026 09:36:11 +0800 Subject: [PATCH 01/33] Improvements --- frontend/AGENTS.md | 9 ++++---- frontend/components/IpInfos.vue | 4 ++-- frontend/sentry-init.js | 39 +++++++++++++++++---------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index a41b8822b..df98cd5b5 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -86,10 +86,11 @@ philosophy as `firebase-init.js`). Two rules: SDK back into the main bundle. All Sentry config lives in `sentry-init.js`. - **Explicit signals go through the app-events bus**, like achievements: the component emits, `sentry-init.js` subscribes. One signal is captured: - `ip-source:exhausted` (an IP card's whole source chain failed). v4 cards - report directly; v6 cards report only when the `ipinfo:finished` snapshot - shows some card resolved a valid IPv6 — otherwise "our v6 chain failed" is - indistinguishable from "visitor has no IPv6", which is routine noise. + `ip-source:exhausted` (an IP card's whole source chain failed). Cards + report only when the `ipinfo:finished` snapshot shows some card resolved a + valid IP of the same version — otherwise "our chain failed" is + indistinguishable from visitor-side conditions (no IPv6 / dead network), + which is routine noise. Capture surface: uncaught errors; `console.error` (fingerprinted per message prefix so each source stays a distinct issue; individual `utils/getips/` diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index cd6981447..0caebdba7 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -172,8 +172,8 @@ const resolveIP = async (cardID, getFromSource) => { : t('ipInfos.IPv4Error'); // Domain event: this source AND all its internal fallbacks failed to // produce an IP. Emitted unconditionally (bus semantics); subscribers - // decide what matters — sentry-init.js reports v4 exhaustion directly - // and gates v6 exhaustion on evidence the visitor has working IPv6. + // decide what matters — sentry-init.js gates exhaustion on evidence the + // visitor's network works for that IP version (some card resolved one). emitAppEvent('ip-source:exhausted', { source: CARD_SOURCE_SLUGS[cardID] ?? `card-${cardID}`, ipVersion: isV6Card ? 'v6' : 'v4', diff --git a/frontend/sentry-init.js b/frontend/sentry-init.js index 4a5cc8dfc..0552f87bb 100644 --- a/frontend/sentry-init.js +++ b/frontend/sentry-init.js @@ -146,12 +146,14 @@ const initSentry = (app, router, earlyErrors = []) => { // ip-source:exhausted — an IP card's whole source chain (primary + all // fallbacks) produced no IP. Individual source failures are console.warn // (any single provider can be blocked on a given network); full-card - // exhaustion is the health signal. v4 cards report directly. v6 cards - // are held until the ipinfo:finished snapshot and only report when some - // card resolved a valid IPv6 — proof the visitor's network stack has - // working v6, so the exhausted chain is OUR problem. Without that proof, - // "our v6 chain failed" is indistinguishable from "visitor has no IPv6", - // which is routine (roughly half the internet) and pure noise. + // exhaustion is the health signal. Exhaustions are held until the + // ipinfo:finished snapshot and only report when some card resolved a + // valid IP of the same version — proof the visitor's network stack works + // for that version, so the exhausted chain is OUR problem. Without that + // proof the failure is indistinguishable from visitor-side conditions + // (no IPv6 — roughly half the internet — or a dead/offline network) and + // is pure noise. The snapshot re-emits after single-card refreshes, so + // queued entries always get flushed. const reportExhaustion = (source, ipVersion) => { Sentry.captureMessage(`IP source exhausted: ${source}`, { level: 'error', @@ -159,22 +161,21 @@ const initSentry = (app, router, earlyErrors = []) => { tags: { ip_version: ipVersion }, }); }; - let pendingV6Exhaustions = []; + const pendingExhaustions = { v4: [], v6: [] }; onAppEvent('ip-source:exhausted', ({ source, ipVersion }) => { - if (ipVersion === 'v6') { - pendingV6Exhaustions.push(source); - } else { - reportExhaustion(source, ipVersion); - } + (pendingExhaustions[ipVersion] ?? pendingExhaustions.v4).push(source); }); onAppEvent('ipinfo:finished', ({ cards }) => { - if (pendingV6Exhaustions.length === 0) return; - const queued = pendingV6Exhaustions; - pendingV6Exhaustions = []; - const visitorHasV6 = (cards ?? []).some( - (card) => typeof card.ip === 'string' && card.ip.includes(':') && isValidIP(card.ip) - ); - if (visitorHasV6) queued.forEach((source) => reportExhaustion(source, 'v6')); + for (const version of ['v4', 'v6']) { + const queued = pendingExhaustions[version]; + if (queued.length === 0) continue; + pendingExhaustions[version] = []; + const visitorHasVersion = (cards ?? []).some( + (card) => typeof card.ip === 'string' && isValidIP(card.ip) + && card.ip.includes(':') === (version === 'v6') + ); + if (visitorHasVersion) queued.forEach((source) => reportExhaustion(source, version)); + } }); }; From dcc2a3be8de7292a4d4665b411570970e5ec7121 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sun, 19 Jul 2026 09:08:45 +0800 Subject: [PATCH 02/33] Fix(ui): align MAC input validation with backend (exactly 12 hex digits) Frontend accepted 6-12 hex chars while the backend requires a full 48-bit MAC, so OUI-prefix inputs passed local validation only to get a 400 upstream, surfacing as a generic fetch error. Co-Authored-By: Claude Fable 5 --- frontend/components/advanced-tools/MacChecker.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/advanced-tools/MacChecker.vue b/frontend/components/advanced-tools/MacChecker.vue index 4fb32612a..92732f858 100644 --- a/frontend/components/advanced-tools/MacChecker.vue +++ b/frontend/components/advanced-tools/MacChecker.vue @@ -129,7 +129,7 @@ const tableItems = computed(() => [ const validateInput = (input) => { if (!input) return null; const normalizedInput = input.replace(/[:-]/g, '').replace(/\s+/g, ''); - if (normalizedInput.length < 6 || normalizedInput.length > 12 || !/^[0-9A-Fa-f]+$/.test(normalizedInput)) { + if (normalizedInput.length !== 12 || !/^[0-9A-Fa-f]+$/.test(normalizedInput)) { errorMsg.value = t('macchecker.invalidMAC'); return null; } From 0ccd23f698f81b46077547742dbf6fad3467795f Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 20 Jul 2026 09:33:18 +0800 Subject: [PATCH 03/33] UI Improvement --- frontend/components/advanced-tools/MtrTest.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/advanced-tools/MtrTest.vue b/frontend/components/advanced-tools/MtrTest.vue index b34daafec..9f854ca64 100644 --- a/frontend/components/advanced-tools/MtrTest.vue +++ b/frontend/components/advanced-tools/MtrTest.vue @@ -81,7 +81,7 @@ -
+
From 75a8ed3a409b68b462786f114eea337c95378c62 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 20 Jul 2026 17:37:42 +0800 Subject: [PATCH 04/33] Improvements --- common/service-status-providers.js | 3 +-- frontend/components/advanced-tools/ServiceStatus.vue | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/common/service-status-providers.js b/common/service-status-providers.js index 86650715e..331d09bdc 100644 --- a/common/service-status-providers.js +++ b/common/service-status-providers.js @@ -27,8 +27,7 @@ export const STATUS_PROVIDERS = [ // categories, leaving one "Cloudflare Sites and Services" rollup + 7 // continents — which keeps the card light consistent with this list. { id: 'cloudflare', name: 'Cloudflare', api: 'https://new.cloudflarestatus.com', page: 'https://new.cloudflarestatus.com' }, - // LangSmith is the status page for the LangChain platform. - { id: 'langchain', name: 'LangChain', api: 'https://status.smith.langchain.com', page: 'https://status.smith.langchain.com' }, + { id: 'groq', name: 'Groq', api: 'https://groqstatus.com', page: 'https://groqstatus.com' }, { id: 'notion', name: 'Notion', api: 'https://www.notion-status.com', page: 'https://www.notion-status.com' }, { id: 'vercel', name: 'Vercel', api: 'https://www.vercel-status.com', page: 'https://www.vercel-status.com' }, { id: 'netlify', name: 'Netlify', api: 'https://www.netlifystatus.com', page: 'https://www.netlifystatus.com' }, diff --git a/frontend/components/advanced-tools/ServiceStatus.vue b/frontend/components/advanced-tools/ServiceStatus.vue index 207fa35d5..3fe97be71 100644 --- a/frontend/components/advanced-tools/ServiceStatus.vue +++ b/frontend/components/advanced-tools/ServiceStatus.vue @@ -205,7 +205,7 @@ const PROVIDERS = [ { id: 'github', name: 'GitHub', icon: 'ri:github-line' }, { id: 'cloudflare', name: 'Cloudflare', icon: 'simple-icons:cloudflare' }, { id: 'elevenlabs', name: 'ElevenLabs', icon: 'simple-icons:elevenlabs' }, - { id: 'langchain', name: 'LangChain', icon: 'simple-icons:langchain' }, + { id: 'groq', name: 'Groq', icon: 'bxl:groq-ai' }, { id: 'vercel', name: 'Vercel', icon: 'simple-icons:vercel' }, { id: 'netlify', name: 'Netlify', icon: 'simple-icons:netlify' }, { id: 'render', name: 'Render', icon: 'simple-icons:render' }, From d5146c8ce7e5390519901731b14f61e6c163cdf2 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 21 Jul 2026 22:29:05 +0800 Subject: [PATCH 05/33] Improvements Co-Authored-By: Claude Fable 5 --- api/AGENTS.md | 4 ++ api/github-stars.js | 4 +- backend-server.js | 2 + common/fetch-with-timeout.js | 33 +++++++++++++-- common/upstream-ua.js | 30 ++++++++++++++ tests/fetch-with-timeout.test.js | 69 +++++++++++++++++++++++++++++++- tests/upstream-ua.test.js | 41 +++++++++++++++++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 common/upstream-ua.js create mode 100644 tests/upstream-ua.test.js diff --git a/api/AGENTS.md b/api/AGENTS.md index 534ebcd15..ba6bc7a69 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -27,6 +27,10 @@ states its route and purpose — read those for specifics. - **Every upstream call uses `fetchUpstream`** from `common/fetch-with-timeout.js` (8s timeout). Never a bare `fetch()` / `https.get()` — a hanging provider must time out, not pin the connection. + It also injects a default `User-Agent` of `MyIP/v/` + (registered at boot by `common/upstream-ua.js` — some upstream WAFs block + undici's default `node` UA); caller-supplied `User-Agent` headers, including + the private-API `{ ...req.headers }` pass-through, always win. - **Error shape.** `res.status(500).json({ error: error.message })` on upstream failure, `400` on bad input. Terse — the frontend doesn't display these verbatim. diff --git a/api/github-stars.js b/api/github-stars.js index 6ec847fc8..4a2e5aca6 100644 --- a/api/github-stars.js +++ b/api/github-stars.js @@ -6,7 +6,8 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; -// The project's own repository. GitHub requires a User-Agent on every request. +// The project's own repository. GitHub requires a User-Agent on every +// request; fetchUpstream's default project UA satisfies that. const REPO = 'jason5ng32/MyIP'; export default async (req, res) => { @@ -20,7 +21,6 @@ export default async (req, res) => { const apiRes = await fetchUpstream(`https://api.github.com/repos/${REPO}`, { headers: { 'Accept': 'application/vnd.github+json', - 'User-Agent': 'MyIP-IPCheck.ing', }, }); if (!apiRes.ok) { diff --git a/backend-server.js b/backend-server.js index c133d3f43..550828f6e 100644 --- a/backend-server.js +++ b/backend-server.js @@ -42,8 +42,10 @@ import { reloadMaxMindDatabases, startMaxMindFileWatcher } from './common/maxmin import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js'; import { startCaidaAutoUpdate, bootstrapCaidaIfMissing } from './common/caida-updater.js'; import { bootstrapServiceStatus, startServiceStatusPolling } from './common/service-status-store.js'; +import { initUpstreamUserAgent } from './common/upstream-ua.js'; dotenv.config({ quiet: true }); +initUpstreamUserAgent(); const app = express(); const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10); diff --git a/common/fetch-with-timeout.js b/common/fetch-with-timeout.js index ea4639a25..bdf146a5b 100644 --- a/common/fetch-with-timeout.js +++ b/common/fetch-with-timeout.js @@ -35,6 +35,33 @@ export async function fetchWithTimeout(url, init = {}) { } } -// Back-end preset: 8s default for server-to-server upstream calls. -export const fetchUpstream = (url, init = {}) => - fetchWithTimeout(url, { timeoutMs: 8000, ...init }); +// Optional User-Agent for fetchUpstream calls. Backend boot injects a +// project-identifying UA (see common/upstream-ua.js) because some upstream +// WAFs hard-block undici's default `User-Agent: node`. Injection keeps this +// file browser-safe: the frontend bundle never touches fs / process. +let upstreamUserAgent = null; + +export const setUpstreamUserAgent = (ua) => { + upstreamUserAgent = ua || null; +}; + +// True when caller-supplied headers already carry a User-Agent (any casing; +// plain object or Headers instance) — pass-through handlers forward the +// visitor's headers and must not have them overridden or duplicated. The +// array-of-pairs headers form isn't used in this repo; treat it as opting +// out of injection rather than attempting a merge. +const headersCarryUA = (h) => { + if (!h) return false; + if (typeof h.has === 'function') return h.has('user-agent'); + if (Array.isArray(h)) return true; + return Object.keys(h).some((k) => k.toLowerCase() === 'user-agent'); +}; + +// Back-end preset: 8s default for server-to-server upstream calls, plus the +// default User-Agent above unless the caller already set one. +export const fetchUpstream = (url, init = {}) => { + const headers = upstreamUserAgent && !headersCarryUA(init.headers) + ? { ...init.headers, 'User-Agent': upstreamUserAgent } + : init.headers; + return fetchWithTimeout(url, { timeoutMs: 8000, ...init, headers }); +}; diff --git a/common/upstream-ua.js b/common/upstream-ua.js new file mode 100644 index 000000000..19df49b88 --- /dev/null +++ b/common/upstream-ua.js @@ -0,0 +1,30 @@ +// Backend-only User-Agent bootstrap for fetchUpstream. +// +// Some upstream WAFs (e.g. Cloudflare's status page) hard-block undici's +// default `User-Agent: node`, so server-to-server calls identify themselves +// as `MyIP/v/` instead. Version comes from package.json; +// the site segment from VITE_SITE_URL, so forks advertise their own +// deployment rather than ipcheck.ing. Lives outside fetch-with-timeout.js on +// purpose: that module is shared with the browser bundle and must stay free +// of fs / process access. + +import { readFileSync } from 'node:fs'; + +import { setUpstreamUserAgent } from './fetch-with-timeout.js'; + +// Build and register the UA. Called from backend-server.js after +// dotenv.config() so VITE_SITE_URL from .env is visible — module import +// time would be too early (imports are hoisted above the config call). +// Missing pieces degrade gracefully: `MyIP/v7.1.0`, or just `MyIP`. +export const initUpstreamUserAgent = () => { + let version = ''; + try { + version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || ''; + } catch { + // Unreadable package.json → versionless UA. + } + const site = (process.env.VITE_SITE_URL || '').trim(); + const ua = ['MyIP', version && `v${version}`, site].filter(Boolean).join('/'); + setUpstreamUserAgent(ua); + return ua; +}; diff --git a/tests/fetch-with-timeout.test.js b/tests/fetch-with-timeout.test.js index a97245f95..a788879a7 100644 --- a/tests/fetch-with-timeout.test.js +++ b/tests/fetch-with-timeout.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it, afterEach } from 'node:test'; -import { fetchWithTimeout, fetchUpstream } from '../common/fetch-with-timeout.js'; +import { fetchWithTimeout, fetchUpstream, setUpstreamUserAgent } from '../common/fetch-with-timeout.js'; // Stub global fetch per-test so we can control whether it resolves, rejects, // or stays pending (and observe whether the wrapper aborted it via signal). @@ -175,3 +175,70 @@ describe('fetchUpstream()', () => { assert.ok(observedSignal instanceof AbortSignal); }); }); + +describe('fetchUpstream() default User-Agent', () => { + afterEach(() => { + restoreFetch(); + setUpstreamUserAgent(null); + }); + + it('sends no User-Agent header when none has been registered', async () => { + let observedHeaders; + installFetch(async (_input, init) => { + observedHeaders = init.headers; + return new Response('ok'); + }); + await fetchUpstream('https://example.test'); + assert.equal(observedHeaders, undefined); + }); + + it('injects the registered User-Agent when the caller sets no headers', async () => { + setUpstreamUserAgent('MyIP/v0.0.0/https://example.test'); + let observedHeaders; + installFetch(async (_input, init) => { + observedHeaders = init.headers; + return new Response('ok'); + }); + await fetchUpstream('https://example.test'); + assert.deepEqual(observedHeaders, { 'User-Agent': 'MyIP/v0.0.0/https://example.test' }); + }); + + it('merges the User-Agent into caller headers without clobbering them', async () => { + setUpstreamUserAgent('MyIP/v0.0.0/https://example.test'); + let observedHeaders; + installFetch(async (_input, init) => { + observedHeaders = init.headers; + return new Response('ok'); + }); + await fetchUpstream('https://example.test', { headers: { Accept: 'application/json' } }); + assert.deepEqual(observedHeaders, { + Accept: 'application/json', + 'User-Agent': 'MyIP/v0.0.0/https://example.test', + }); + }); + + it('never overrides or duplicates a caller-supplied User-Agent, any casing', async () => { + // Pass-through handlers forward the visitor's headers, whose + // user-agent key arrives lowercase from Node — that one must win. + setUpstreamUserAgent('MyIP/v0.0.0/https://example.test'); + let observedHeaders; + installFetch(async (_input, init) => { + observedHeaders = init.headers; + return new Response('ok'); + }); + await fetchUpstream('https://example.test', { headers: { 'user-agent': 'Mozilla/5.0' } }); + assert.deepEqual(observedHeaders, { 'user-agent': 'Mozilla/5.0' }); + }); + + it('respects a User-Agent carried in a Headers instance', async () => { + setUpstreamUserAgent('MyIP/v0.0.0/https://example.test'); + let observedHeaders; + installFetch(async (_input, init) => { + observedHeaders = init.headers; + return new Response('ok'); + }); + const headers = new Headers({ 'User-Agent': 'Mozilla/5.0' }); + await fetchUpstream('https://example.test', { headers }); + assert.equal(observedHeaders, headers); + }); +}); diff --git a/tests/upstream-ua.test.js b/tests/upstream-ua.test.js new file mode 100644 index 000000000..983b65415 --- /dev/null +++ b/tests/upstream-ua.test.js @@ -0,0 +1,41 @@ +// Specs for common/upstream-ua.js — the backend User-Agent bootstrap. +// Asserts the `MyIP/v/` format and its graceful degradation +// when VITE_SITE_URL is absent. Version is read from the real package.json, +// so the assertions match on shape rather than a hardcoded number. + +import assert from 'node:assert/strict'; +import { describe, it, afterEach } from 'node:test'; + +import { initUpstreamUserAgent } from '../common/upstream-ua.js'; +import { setUpstreamUserAgent } from '../common/fetch-with-timeout.js'; + +const ORIGINAL_SITE_URL = process.env.VITE_SITE_URL; + +describe('initUpstreamUserAgent()', () => { + afterEach(() => { + if (ORIGINAL_SITE_URL === undefined) { + delete process.env.VITE_SITE_URL; + } else { + process.env.VITE_SITE_URL = ORIGINAL_SITE_URL; + } + setUpstreamUserAgent(null); + }); + + it('builds MyIP/v/ from package.json and VITE_SITE_URL', () => { + process.env.VITE_SITE_URL = 'https://example.test'; + const ua = initUpstreamUserAgent(); + assert.match(ua, /^MyIP\/v\d+\.\d+\.\d+\/https:\/\/example\.test$/); + }); + + it('drops the site segment when VITE_SITE_URL is unset', () => { + delete process.env.VITE_SITE_URL; + const ua = initUpstreamUserAgent(); + assert.match(ua, /^MyIP\/v\d+\.\d+\.\d+$/); + }); + + it('ignores a whitespace-only VITE_SITE_URL', () => { + process.env.VITE_SITE_URL = ' '; + const ua = initUpstreamUserAgent(); + assert.match(ua, /^MyIP\/v\d+\.\d+\.\d+$/); + }); +}); From 69260453171a998c0de27cf88fb6cfc0bd7300fd Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 21 Jul 2026 22:33:50 +0800 Subject: [PATCH 06/33] Improvements --- frontend/data/changelog.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index f7ac8cdd1..b0c1ac070 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1426,7 +1426,7 @@ }, { "version": "v7.1.0", - "date": "Beta", + "date": "July 21, 2026", "content": [ { "type": "improve", diff --git a/package.json b/package.json index 378c20d47..5e920a048 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "7.0.0", + "version": "7.1.0", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { From bb600bb1cc358621abeb39d0b02275796a91617e Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 22 Jul 2026 16:30:02 +0800 Subject: [PATCH 07/33] Fix(ui): stop Safari page jump when closing the advanced tools drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On close, reka-ui's DialogContentModal refocuses the pre-open activeElement with a bare focus() (no preventScroll). Safari doesn't focus links on click, so it records the outer tabindex container as the trigger and closing scrolled the page back to that container's top. Prevent close-auto-focus like SheetContent already does, and disable vaul's Safari-only position:fixed body lock (noBodyStyles) — its rAF scroll restore is unreliable on WebKit and reka-ui's lock already covers background scrolling. Co-Authored-By: Claude Fable 5 --- frontend/components/ui/drawer/Drawer.vue | 6 ++++++ frontend/components/ui/drawer/DrawerContent.vue | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/frontend/components/ui/drawer/Drawer.vue b/frontend/components/ui/drawer/Drawer.vue index 5fb0b39cc..23f536d35 100644 --- a/frontend/components/ui/drawer/Drawer.vue +++ b/frontend/components/ui/drawer/Drawer.vue @@ -16,6 +16,11 @@ defineProps({ setBackgroundColorOnScale: { type: Boolean, default: false }, // 关闭时是否处理 body 滚动锁 dismissible: { type: Boolean, default: true }, + // Keep vaul away from body styles. Its Safari-only position:fixed scroll + // lock zeroes window.scrollY and restores it via a rAF scrollTo, which is + // unreliable on WebKit. reka-ui's own scroll lock (overflow:hidden + iOS + // touchmove guard, same as Sheet/Dialog) already covers us. + noBodyStyles: { type: Boolean, default: true }, // snap 点(数组,0~1 之间或 px 字符串);不传则自由高度 snapPoints: { type: Array, default: undefined }, activeSnapPoint: { type: [Number, String], default: undefined }, @@ -36,6 +41,7 @@ defineEmits(['update:open', 'update:activeSnapPoint']); :should-scale-background="shouldScaleBackground" :set-background-color-on-scale="setBackgroundColorOnScale" :dismissible="dismissible" + :no-body-styles="noBodyStyles" :snap-points="snapPoints" :active-snap-point="activeSnapPoint" :handle-only="handleOnly" diff --git a/frontend/components/ui/drawer/DrawerContent.vue b/frontend/components/ui/drawer/DrawerContent.vue index a05f7e803..ae3f04e9e 100644 --- a/frontend/components/ui/drawer/DrawerContent.vue +++ b/frontend/components/ui/drawer/DrawerContent.vue @@ -29,6 +29,12 @@ const contentStyle = computed(() => ({ \ No newline at end of file From 4b6e0aaf5b055d1f368201cd7d1261e0392ebe4d Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 25 Jul 2026 09:00:39 +0800 Subject: [PATCH 18/33] Feat(api): serve a compact Globalping probe-country inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /api/globalping-probes proxies Globalping's probe list and reduces it server-side to the country picker's shape (which countries have an online probe, bucketed into five continents) — a ~1.4KB payload instead of the multi-hundred-KB raw list on the visitor's connection. Coverage changes slowly and the pickers fail open, so the route sits behind a week of edge cache. Aggregation is a pure function in common/ with its own spec. Co-Authored-By: Claude Fable 5 --- api/AGENTS.md | 4 +-- api/globalping-probes.js | 28 +++++++++++++++++ backend-server.js | 5 +++ common/globalping-inventory.js | 35 +++++++++++++++++++++ tests/api-handlers.test.js | 13 ++++++++ tests/globalping-inventory.test.js | 50 ++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 api/globalping-probes.js create mode 100644 common/globalping-inventory.js create mode 100644 tests/globalping-inventory.test.js diff --git a/api/AGENTS.md b/api/AGENTS.md index fe3a845f6..2479c8712 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -15,8 +15,8 @@ Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` / `ipapi-com` / `ipapi-is` / `ip2location-io` / `ip-sb` / `ipcheck-ing` / `maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` / `cf-radar` / `asn-history` / `asn-connectivity` / `ooni-blocking` / -`service-status` / `google-map` / `github-stars` / `invisibility-test` / -`dns-leak-test`), user +`globalping-probes` / `service-status` / `google-map` / `github-stars` / +`invisibility-test` / `dns-leak-test`), user proxies (`get-user-info` / `update-user-achievement`), platform (`configs` / `sentry-tunnel` / `share-report`). Each file's header comment states its route and purpose — read those for specifics. diff --git a/api/globalping-probes.js b/api/globalping-probes.js new file mode 100644 index 000000000..5968bb259 --- /dev/null +++ b/api/globalping-probes.js @@ -0,0 +1,28 @@ +// /api/globalping-probes — compact country inventory of online Globalping +// probes for the frontend country pickers (MtrTest / GlobalLatencyTest / +// CensorshipCheck). Proxying instead of hitting api.globalping.io from the +// browser trades the multi-hundred-KB raw probe list for a few hundred +// bytes, probe-country coverage changes slowly. + +import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import { buildProbeInventory } from '../common/globalping-inventory.js'; +import logger from '../common/logger.js'; + +const GLOBALPING_PROBES_URL = 'https://api.globalping.io/v1/probes'; + +export default async (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + try { + const response = await fetchUpstream(GLOBALPING_PROBES_URL); + if (!response.ok) { + throw new Error(`Globalping probes responded with status ${response.status}`); + } + res.json(buildProbeInventory(await response.json())); + } catch (error) { + logger.error({ err: error }, 'globalping-probes handler failed'); + res.status(500).json({ error: error.message }); + } +}; diff --git a/backend-server.js b/backend-server.js index 066f8ada0..343894629 100644 --- a/backend-server.js +++ b/backend-server.js @@ -24,6 +24,7 @@ import cfHander from './api/cf-radar.js'; import asnHistoryHandler from './api/asn-history.js'; import asnConnectivityHandler from './api/asn-connectivity.js'; import ooniBlockingHandler from './api/ooni-blocking.js'; +import globalpingProbesHandler from './api/globalping-probes.js'; import dnsResolver from './api/dns-resolver.js'; import serviceStatusHandler, { detailHandler as serviceStatusDetailHandler, @@ -230,6 +231,7 @@ app.use('/api', requireReferer); const FIVE_MIN_CACHE = 5 * 60; const ONE_HOUR_CACHE = 60 * 60; const ONE_DAY_CACHE = 24 * 60 * 60; +const SEVEN_DAYS_CACHE = 7 * 24 * 60 * 60; const THIRTY_DAYS_CACHE = 30 * 24 * 60 * 60; const ONE_YEAR_CACHE = 365 * 24 * 60 * 60; @@ -253,6 +255,9 @@ app.get('/api/configs', cacheable(ONE_HOUR_CACHE), validateConfigs); // only drifts as new measurements land, so 1 day of edge cache keeps us polite // to OONI's free API without the view going meaningfully stale. app.get('/api/ooni-blocking', requireValidDomain(), cacheable(ONE_DAY_CACHE), ooniBlockingHandler); +// Which countries have online Globalping probes — coverage changes slowly, +// and the pickers fail open anyway, so a week of edge cache is fine. +app.get('/api/globalping-probes', cacheable(SEVEN_DAYS_CACHE), globalpingProbesHandler); // Cache for 30 days — registry / historical data that changes on a monthly // (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection, // and append-only BGP routing history. diff --git a/common/globalping-inventory.js b/common/globalping-inventory.js new file mode 100644 index 000000000..330c6da8c --- /dev/null +++ b/common/globalping-inventory.js @@ -0,0 +1,35 @@ +// Pure aggregation for the Globalping probe inventory: turns the raw +// /v1/probes payload (one entry per online probe) into the compact shape +// the frontend country pickers consume — which countries have a probe at +// all, grouped into the five continent buckets the UI displays (NA + SA +// merge into "americas"). Serving this from our backend keeps the multi- +// hundred-KB probe list off the visitor's connection. + +// Globalping continent codes → our display buckets. +const CONTINENT_BUCKETS = { AS: 'asia', EU: 'europe', AF: 'africa', NA: 'americas', SA: 'americas', OC: 'oceania' }; +const BUCKET_ORDER = ['asia', 'europe', 'africa', 'americas', 'oceania']; + +// probes: raw array from Globalping. Returns +// { countries: [cc], continents: [{ key, countries: [cc] }] } with +// continents in display order and country codes sorted alphabetically. +export const buildProbeInventory = (probes) => { + const countries = new Set(); + const buckets = new Map(); + + for (const probe of (Array.isArray(probes) ? probes : [])) { + const cc = probe?.location?.country; + if (!cc) continue; + countries.add(cc); + const bucket = CONTINENT_BUCKETS[probe?.location?.continent]; + if (!bucket) continue; + if (!buckets.has(bucket)) buckets.set(bucket, new Set()); + buckets.get(bucket).add(cc); + } + + return { + countries: [...countries].sort(), + continents: BUCKET_ORDER + .filter((key) => buckets.has(key)) + .map((key) => ({ key, countries: [...buckets.get(key)].sort() })), + }; +}; diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index 89ec45646..7b9c06e2c 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -24,6 +24,7 @@ import updateAchievementHandler from '../api/update-user-achievement.js'; import ipcheckIngHandler from '../api/ipcheck-ing.js'; import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js'; import ooniBlockingHandler from '../api/ooni-blocking.js'; +import globalpingProbesHandler from '../api/globalping-probes.js'; import serviceStatusHandler, { detailHandler as serviceStatusDetailHandler, } from '../api/service-status.js'; @@ -484,3 +485,15 @@ describe('ooni-blocking handler', () => { assert.equal(res.body.error, 'Method Not Allowed'); }); }); + +// -- globalping-probes handler ---------------------------------------------- +// No params to validate; the only pre-fetch branch is the method gate. + +describe('globalping-probes handler', () => { + it('rejects non-GET with 405 before hitting Globalping', async () => { + const res = createResponse(); + await globalpingProbesHandler(createRequest({ method: 'POST' }), res); + assert.equal(res.statusCode, 405); + assert.equal(res.body.error, 'Method Not Allowed'); + }); +}); diff --git a/tests/globalping-inventory.test.js b/tests/globalping-inventory.test.js new file mode 100644 index 000000000..2d0b9b57e --- /dev/null +++ b/tests/globalping-inventory.test.js @@ -0,0 +1,50 @@ +// Unit tests for the Globalping probe inventory aggregation +// (common/globalping-inventory.js). Fixtures mirror the raw /v1/probes +// shape: one entry per online probe with location.country / .continent. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildProbeInventory } from '../common/globalping-inventory.js'; + +const probe = (country, continent) => ({ location: { country, continent } }); + +describe('buildProbeInventory', () => { + it('returns empty shapes for empty or non-array input', () => { + assert.deepEqual(buildProbeInventory([]), { countries: [], continents: [] }); + assert.deepEqual(buildProbeInventory(null), { countries: [], continents: [] }); + }); + + it('dedupes countries and sorts codes alphabetically', () => { + const result = buildProbeInventory([ + probe('DE', 'EU'), probe('DE', 'EU'), probe('AT', 'EU'), probe('CH', 'EU'), + ]); + assert.deepEqual(result.countries, ['AT', 'CH', 'DE']); + assert.deepEqual(result.continents, [{ key: 'europe', countries: ['AT', 'CH', 'DE'] }]); + }); + + it('merges NA and SA into the americas bucket', () => { + const result = buildProbeInventory([ + probe('US', 'NA'), probe('BR', 'SA'), probe('CA', 'NA'), + ]); + assert.deepEqual(result.continents, [{ key: 'americas', countries: ['BR', 'CA', 'US'] }]); + }); + + it('keeps continents in display order (asia → europe → africa → americas → oceania)', () => { + const result = buildProbeInventory([ + probe('AU', 'OC'), probe('US', 'NA'), probe('ZA', 'AF'), probe('DE', 'EU'), probe('JP', 'AS'), + ]); + assert.deepEqual(result.continents.map((c) => c.key), ['asia', 'europe', 'africa', 'americas', 'oceania']); + }); + + it('skips probes without a country; unknown continents still count as available', () => { + const result = buildProbeInventory([ + probe(undefined, 'EU'), + probe('AQ', 'AN'), // Antarctica: no display bucket + { location: null }, + probe('JP', 'AS'), + ]); + assert.deepEqual(result.countries, ['AQ', 'JP']); + assert.deepEqual(result.continents, [{ key: 'asia', countries: ['JP'] }]); + }); +}); From b2c22a98b1814555b8e7cd76be8ca3d5efe07699 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 25 Jul 2026 09:00:54 +0800 Subject: [PATCH 19/33] Feat(ui): shared Globalping country picker for MTR / latency / censorship New GlobalpingCountryPicker renders the caller's suggestion sections plus the full per-continent catalog of probe-having countries (from the backend inventory), all over one v-model selection with section-level select-all and a sticky tally. Caps are soft: past the limit (30 for MTR/latency, 12 for censorship) the tally turns red and the run button locks. MTR and Global Latency keep their curated 28-country spread as the default-selected suggestion; CensorshipCheck migrates its inline picker to the component and gains the free catalog. Left picker column matches the results column's height once results exist (absolute-fill trick, min-height floor). Also folds CensorshipCheck's render-function table back into the template. Co-Authored-By: Claude Fable 5 --- .../advanced-tools/CensorshipCheck.vue | 112 ++------- .../advanced-tools/GlobalLatencyTest.vue | 71 +++++- .../GlobalpingCountryPicker.vue | 127 ++++++++++ .../components/advanced-tools/MtrTest.vue | 223 +++++++++++------- .../composables/use-globalping-measurement.js | 31 +-- frontend/utils/globalping-probes.js | 39 ++- 6 files changed, 376 insertions(+), 227 deletions(-) create mode 100644 frontend/components/advanced-tools/GlobalpingCountryPicker.vue diff --git a/frontend/components/advanced-tools/CensorshipCheck.vue b/frontend/components/advanced-tools/CensorshipCheck.vue index e07ff2b33..b1bced969 100644 --- a/frontend/components/advanced-tools/CensorshipCheck.vue +++ b/frontend/components/advanced-tools/CensorshipCheck.vue @@ -94,7 +94,8 @@

{{ t('censorshipcheck.RealtimeTitle') }}

+ @@ -159,6 +165,7 @@ import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import changelogData from '@/data/changelog.json'; import { trackEvent } from '@/utils/analytics'; +import { DOCS_URL, isDocsConfigured } from '@/composables/use-docs-assistant'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; From d403f8fb0c24d0eae0e3910725266b4ed8c9cfd6 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:16:24 +0800 Subject: [PATCH 26/33] Feat(nav): let the docs assistant read the visitor's own test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers a get_my_test_results tool so questions like "is this normal?" are answered against the visitor's actual data instead of generic docs. It reuses the report collector's schema-shaped snapshots, so no new data pipeline and the same whitelist (lookup tools stay out by design), and reports which sections have not been run yet so the assistant can suggest them. Reading is gated behind the embed's confirmation button — those snapshots carry the visitor's IP. Co-Authored-By: Claude Fable 5 --- frontend/composables/use-docs-assistant.js | 41 ++++++++++++++++++++++ frontend/locales/en.json | 6 +++- frontend/locales/fr.json | 6 +++- frontend/locales/ru.json | 6 +++- frontend/locales/zh.json | 6 +++- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/frontend/composables/use-docs-assistant.js b/frontend/composables/use-docs-assistant.js index dc8cbb05d..fdf5c48db 100644 --- a/frontend/composables/use-docs-assistant.js +++ b/frontend/composables/use-docs-assistant.js @@ -16,6 +16,11 @@ // reports back through `setOpen`. It drives the touch-only chrome there — a // tap-outside backdrop and a body scroll lock. // +// One tool is registered (`get_my_test_results`): the assistant can read the +// visitor's finished on-page diagnostics — the report collector's snapshots, +// reused as-is — so "is this normal?" is answered against their actual data. +// It asks for confirmation first, since those results carry their IP. +// // The welcome screen's greeting, suggestions and the sidebar action are // localized, and re-sent on every open so a language switch takes effect. The // embed has no language option of its own, so its remaining chrome — input @@ -26,6 +31,8 @@ // docs site in a new tab so the action never dead-ends. import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; +import { useCollectedReport } from '@/composables/use-report-collector.js'; +import { REPORT_SECTION_IDS } from '@/utils/report-schema.js'; export const DOCS_URL = (import.meta.env?.VITE_DOCS_URL || '').replace(/\/+$/, ''); export const isDocsConfigured = !!DOCS_URL; @@ -63,10 +70,43 @@ const loadEmbedScript = () => { export function useDocsAssistant() { const { t, tm, rt } = useI18n(); const isOpening = ref(false); + const { sections } = useCollectedReport(); // Localized suggestion questions (shared with the nav placeholder rotation). const docsQuestions = () => tm('nav.DocsQuestions').map((message) => rt(message)); + // Tool the assistant can call to read what the visitor is actually looking + // at — the same schema-shaped snapshots the share-report dialog uses, so + // no new data pipeline and the same whitelist (lookup tools like whois are + // excluded by design). Gated behind a confirmation button: the results + // include the visitor's IP and location, and answering "is this normal?" + // means handing them to the assistant. + const readResultsTool = { + name: 'get_my_test_results', + description: [ + "Read the diagnostic results currently on the visitor's own MyIP page:", + 'IP addresses and their geolocation/ASN, connectivity checks, WebRTC,', + 'DNS leak, speed test, and any other test they have run.', + 'Use it whenever the question is about their own situation — "what is my IP",', + '"is this result normal", "why is my location wrong", "am I leaking" —', + 'so the answer reflects their actual data instead of generic documentation.', + 'Returns only tests that have finished; `missingSections` lists the ones', + 'they have not run yet, which you may suggest running.', + ].join(' '), + inputSchema: { type: 'object', properties: {}, required: [] }, + confirmation: { icon: 'eye', label: t('nav.DocsToolConfirm') }, + execute: async () => { + const available = Object.keys(sections); + return { + output: { + results: JSON.parse(JSON.stringify(sections)), + missingSections: REPORT_SECTION_IDS.filter((id) => !available.includes(id)), + }, + summary: { icon: 'eye', text: t('nav.DocsToolSummary') }, + }; + }, + }; + const openDocsSite = () => window.open(DOCS_URL, '_blank', 'noopener'); // Reported by DocsAssistant.vue from the live panel — the embed fires no @@ -99,6 +139,7 @@ export function useDocsAssistant() { actions: [ { icon: 'book', label: t('nav.OpenDocs'), onClick: openDocsSite }, ], + tools: [readResultsTool], }); window.GitBook('open'); if (question) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 5a7cb2d1a..9b9805735 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -149,14 +149,18 @@ "RuleTest": "Rule Test", "AdvancedTools": "Advanced Tools", "SearchDocs": "Search docs…", + "DocsToolConfirm": "Share your current test results?", + "DocsToolSummary": "Read your on-page results", "OpenDocs": "Open docs", "DocsGreetingTitle": "How can I help?", "DocsGreetingSubtitle": "Ask me anything about MyIP — the tools, your results, or self-hosting.", "DocsQuestions": [ "Is my VPN leaking my real IP?", "Why is my IP location wrong?", + "Compare my WebRTC test and DNS leak test results and give me suggestions", "How do I read MTR results?", - "How do I deploy my own MyIP?" + "How do I deploy my own MyIP?", + "I want to see more help documents" ], "preferences": { "title": "Preferences", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 3dc3e3703..a84dc3c65 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -149,14 +149,18 @@ "RuleTest": "Test de règles", "AdvancedTools": "Outils avancés", "SearchDocs": "Rechercher dans la doc…", + "DocsToolConfirm": "Partager vos résultats actuels ?", + "DocsToolSummary": "Résultats de la page lus", "OpenDocs": "Ouvrir la doc", "DocsGreetingTitle": "Comment puis-je vous aider ?", "DocsGreetingSubtitle": "Posez vos questions sur MyIP : les outils, vos résultats ou l'auto-hébergement.", "DocsQuestions": [ "Mon VPN fuit-il ma vraie IP ?", "Pourquoi ma localisation IP est-elle fausse ?", + "Comparer mes résultats de test WebRTC et de fuite DNS et me donner des suggestions", "Comment lire les résultats MTR ?", - "Comment déployer mon propre MyIP ?" + "Comment déployer mon propre MyIP ?", + "Je veux voir plus de documents d'aide" ], "preferences": { "title": "Préférences", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 8b3543fa7..71b5a5b7a 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -149,14 +149,18 @@ "RuleTest": "Проверка правил", "AdvancedTools": "Расширенные инструменты", "SearchDocs": "Поиск по документации…", + "DocsToolConfirm": "Показать ваши текущие результаты?", + "DocsToolSummary": "Результаты со страницы прочитаны", "OpenDocs": "Открыть документацию", "DocsGreetingTitle": "Чем могу помочь?", "DocsGreetingSubtitle": "Спросите о MyIP: инструменты, ваши результаты или самостоятельный хостинг.", "DocsQuestions": [ "Мой VPN раскрывает мой настоящий IP?", "Почему геолокация моего IP неверна?", + "Сравните мои результаты теста WebRTC и теста утечки DNS и дайте мне советы", "Как читать результаты MTR?", - "Как развернуть свой MyIP?" + "Как развернуть свой MyIP?", + "Я хочу увидеть больше документации" ], "preferences": { "title": "Настройки", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 83135fbfb..b03b40c24 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -149,14 +149,18 @@ "RuleTest": "分流测试", "AdvancedTools": "高级工具", "SearchDocs": "搜索文档…", + "DocsToolConfirm": "把当前的检测结果给助手看?", + "DocsToolSummary": "已读取页面上的检测结果", "OpenDocs": "打开文档", "DocsGreetingTitle": "有什么可以帮你?", "DocsGreetingSubtitle": "关于 MyIP 的工具、测试结果或自部署,都可以问我。", "DocsQuestions": [ "我的 VPN 泄漏真实 IP 了吗?", "为什么我的 IP 归属地不对?", + "对比我的 WebRTC 测试和 DNS 泄漏测试结果并给出建议", "MTR 结果怎么看?", - "怎么部署一个自己的 MyIP?" + "怎么部署一个自己的 MyIP?", + "我想看更多帮助文档" ], "preferences": { "title": "偏好设置", From 528719bf4d989a1cd2c855212967f5ced757d121 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:16:34 +0800 Subject: [PATCH 27/33] Docs(privacy): document the docs assistant's data flow New section covering what the assistant sends to GitBook, that reading on-page results requires explicit confirmation, and that the conversation never passes through our servers. Carries its own gate (VITE_DOCS_URL + originalSite) rather than riding the report-sharing one, so a deployment never reads about a feature it doesn't ship. Co-Authored-By: Claude Fable 5 --- frontend/components/PrivacyPolicy.vue | 9 ++++++++- frontend/locales/privacy/en.json | 8 ++++++++ frontend/locales/privacy/fr.json | 8 ++++++++ frontend/locales/privacy/ru.json | 8 ++++++++ frontend/locales/privacy/zh.json | 8 ++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index f66b0c755..a5350473b 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -45,6 +45,7 @@ import { useI18n } from 'vue-i18n'; import { storeToRefs } from 'pinia'; import { useMainStore } from '@/store'; import { isAnalyticsEnabled } from '@/utils/analytics'; +import { isDocsConfigured } from '@/composables/use-docs-assistant.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; import Footer from '@/components/Footer.vue'; import StandalonePageHeader from '@/components/StandalonePageHeader.vue'; @@ -55,7 +56,7 @@ const store = useMainStore(); const { isFireBaseSet } = storeToRefs(store); // Bump when the policy text changes materially. -const LAST_UPDATED = '2026-07-17'; +const LAST_UPDATED = '2026-07-28'; // Sentry telemetry is a build-time decision (see frontend/AGENTS.md): the // section only renders on deployments actually built with a DSN. @@ -65,6 +66,11 @@ const isSentryEnabled = !!(import.meta.env ?? {}).VITE_SENTRY_DSN_FRONTEND; // the section only renders on deployments where links can actually be made. const isReportSharingEnabled = computed(() => store.configs?.reportSharing === true); +// The docs assistant carries its own gate (build-time VITE_DOCS_URL plus the +// canonical deployment) — same condition as its nav entry point, so the +// section never describes a feature this deployment doesn't ship. +const isDocsAssistantEnabled = computed(() => isDocsConfigured && store.configs?.originalSite === true); + // Privacy copy is loaded on demand per locale (mirrors the security-checklist // dataset pattern), then merged into i18n so t() / tm() can resolve it. const privacyLoaders = { @@ -104,6 +110,7 @@ watch(locale, async (loc) => { const order = computed(() => { const ids = ['tools']; if (isReportSharingEnabled.value) ids.push('sharedReports'); + if (isDocsAssistantEnabled.value) ids.push('docsAssistant'); if (isAnalyticsEnabled) ids.push('analytics'); if (isFireBaseSet.value) ids.push('account'); if (isSentryEnabled) ids.push('telemetry'); diff --git a/frontend/locales/privacy/en.json b/frontend/locales/privacy/en.json index b06ce16e2..7647d6311 100644 --- a/frontend/locales/privacy/en.json +++ b/frontend/locales/privacy/en.json @@ -19,6 +19,14 @@ "Copying a report for an AI assistant or downloading it as JSON happens entirely in your browser and stores nothing on our servers." ] }, + "docsAssistant": { + "title": "Documentation assistant", + "paragraphs": [ + "The documentation assistant answers questions from our documentation site. It only starts when you use it: the assistant is loaded on demand, and the question you type is sent to GitBook, which hosts our documentation and provides the assistant, to generate an answer.", + "The assistant can also read the test results currently on your page — your IP addresses with their location and network, and the results of any tests you have run. This never happens silently: the assistant must ask, and you approve it with the confirmation button shown in the chat. Declining still lets it answer from the documentation.", + "The conversation runs between your browser and GitBook; it does not pass through our servers, and we do not store it. GitBook's own privacy terms apply to the messages you send." + ] + }, "analytics": { "title": "What we collect through analytics", "paragraphs": [ diff --git a/frontend/locales/privacy/fr.json b/frontend/locales/privacy/fr.json index 4e645d073..97ebccc26 100644 --- a/frontend/locales/privacy/fr.json +++ b/frontend/locales/privacy/fr.json @@ -19,6 +19,14 @@ "Copier un rapport pour un assistant IA ou le télécharger en JSON se fait entièrement dans votre navigateur et ne stocke rien sur nos serveurs." ] }, + "docsAssistant": { + "title": "Assistant de documentation", + "paragraphs": [ + "L'assistant de documentation répond aux questions à partir de notre site de documentation. Il ne démarre que lorsque vous l'utilisez : l'assistant est chargé à la demande, et la question que vous saisissez est envoyée à GitBook, qui héberge notre documentation et fournit l'assistant, afin de générer une réponse.", + "L'assistant peut aussi lire les résultats de tests actuellement affichés sur votre page — vos adresses IP avec leur localisation et leur réseau, ainsi que les résultats des tests que vous avez lancés. Cela n'arrive jamais en silence : l'assistant doit le demander, et vous l'autorisez via le bouton de confirmation affiché dans la conversation. En cas de refus, il peut toujours répondre à partir de la documentation.", + "La conversation se déroule entre votre navigateur et GitBook ; elle ne transite pas par nos serveurs et nous ne la conservons pas. Les conditions de confidentialité de GitBook s'appliquent aux messages que vous envoyez." + ] + }, "analytics": { "title": "Ce que nous collectons via l'analyse", "paragraphs": [ diff --git a/frontend/locales/privacy/ru.json b/frontend/locales/privacy/ru.json index 11aa392f8..a718f230d 100644 --- a/frontend/locales/privacy/ru.json +++ b/frontend/locales/privacy/ru.json @@ -19,6 +19,14 @@ "Копирование отчёта для передачи ИИ-помощнику или его скачивание в формате JSON полностью выполняется в вашем браузере и не приводит к сохранению каких-либо данных на наших серверах." ] }, + "docsAssistant": { + "title": "Помощник по документации", + "paragraphs": [ + "Помощник по документации отвечает на вопросы на основе нашего сайта документации. Он запускается только когда вы им пользуетесь: помощник загружается по требованию, а введённый вами вопрос отправляется в GitBook — сервис, где размещена наша документация и который предоставляет помощника, — чтобы сформировать ответ.", + "Помощник также может прочитать результаты тестов, открытые сейчас на вашей странице, — ваши IP-адреса с их геолокацией и сетью, а также результаты всех запущенных вами тестов. Это никогда не происходит незаметно: помощник обязан спросить, а вы подтверждаете это кнопкой в чате. Если отказаться, он всё равно ответит по документации.", + "Диалог идёт между вашим браузером и GitBook; он не проходит через наши серверы, и мы его не храним. К отправляемым вами сообщениям применяются собственные условия конфиденциальности GitBook." + ] + }, "analytics": { "title": "Какие данные мы собираем с помощью аналитики", "paragraphs": [ diff --git a/frontend/locales/privacy/zh.json b/frontend/locales/privacy/zh.json index f869e81d5..16e397e82 100644 --- a/frontend/locales/privacy/zh.json +++ b/frontend/locales/privacy/zh.json @@ -19,6 +19,14 @@ "「复制给 AI」和「下载 JSON」完全在你的浏览器本地完成,不会向服务器存储任何内容。" ] }, + "docsAssistant": { + "title": "文档助手", + "paragraphs": [ + "文档助手基于我们的文档站回答问题。它只在你使用时才启动:助手按需加载,你输入的问题会发送给 GitBook(我们的文档站托管方,也是助手的提供方)以生成回答。", + "助手还可以读取你页面上当前的检测结果 —— 你的 IP 地址及其归属地与网络、以及你已经运行过的各项测试结果。这绝不会静默发生:助手必须先询问,由你点击对话中的确认按钮同意。即使拒绝,它依然可以基于文档回答你。", + "对话发生在你的浏览器与 GitBook 之间,不经过我们的服务器,我们也不会存储对话内容。你发送的消息适用 GitBook 自己的隐私条款。" + ] + }, "analytics": { "title": "我们通过统计收集什么", "paragraphs": [ From 971e592b24cf39ac6fc7c5f5495a29280b467341 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:17:53 +0800 Subject: [PATCH 28/33] =?UTF-8?q?Docs(changelog):=20v7.2.0=20=E2=80=94=20A?= =?UTF-8?q?I=20assistant=20and=20the=20documentation=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/data/changelog.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index db1ce97b9..c6fa69efb 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1488,6 +1488,24 @@ "ru": "В тестах MTR, глобальной задержки и цензуры можно выбирать страны из всех доступных зондов Globalping, сгруппированных по континентам" } }, + { + "type": "add", + "change": { + "en": "New AI assistant: IPChecking Copilot answers your questions from the docs, and can read your on-page results with your permission", + "zh": "新增 AI 助手 IPChecking Copilot:基于文档回答你的问题,并可在你授权后读取页面上的检测结果", + "fr": "Nouvel assistant IA : IPChecking Copilot répond à vos questions à partir de la documentation et peut lire vos résultats affichés avec votre autorisation", + "ru": "Новый ИИ-помощник IPChecking Copilot: отвечает на вопросы по документации и может прочитать результаты на странице с вашего разрешения" + } + }, + { + "type": "add", + "change": { + "en": "New documentation site for users and developers, with tool guides, network troubleshooting and self-hosting docs", + "zh": "新增用户与开发帮助文档,包含工具使用说明、网络问题排查和自部署指南", + "fr": "Nouveau site de documentation pour les utilisateurs et les développeurs : guides des outils, dépannage réseau et auto-hébergement", + "ru": "Новый сайт документации для пользователей и разработчиков: описания инструментов, диагностика сети и самостоятельный хостинг" + } + }, { "type": "improve", "change": { From 95d8ac69551ccc93e57489e25ca50c2eb6fe27e2 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:18:04 +0800 Subject: [PATCH 29/33] Style(nav): match the docs launcher to the FAB buttons The embed ships a pill with a text label anchored to the viewport edge; the FAB dock hugs the 1600px content area instead, so the two drifted apart on wide screens. Mirrors the dock's inset formula and restyles the launcher into the same 2.25rem circle, dropping the label and the pill's padding so the icon keeps its size-4 box instead of being squeezed. Co-Authored-By: Claude Fable 5 --- frontend/components/Nav.vue | 6 ++--- frontend/style/style.css | 49 ++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index 9135c1806..e24891590 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -86,7 +86,7 @@ The placeholder rotates through preset questions so visitors see this is an "ask anything" box, not plain search. -->
- @@ -296,7 +296,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Award, ChevronDown, HeartHandshake, - LogOut, Menu, Cog, MessageCircleQuestionMark, + LogOut, Menu, Cog, Sparkle, } from '@lucide/vue'; import { Input } from '@/components/ui/input'; import { useDocsAssistant, isDocsConfigured } from '@/composables/use-docs-assistant'; diff --git a/frontend/style/style.css b/frontend/style/style.css index d6c98e682..a7ee9360d 100644 --- a/frontend/style/style.css +++ b/frontend/style/style.css @@ -261,12 +261,59 @@ footer { #gitbook-widget-button, #gitbook-widget-window { --jn-gb-anchor: 2.25rem; + /* Mirror of FloatingDock's inset: it hugs the 1600px content area's right + edge with an 18px gutter, so past 1600px it steps inward by half the + overflow. max() keeps that at a plain 18px on narrower viewports. */ + --jn-gb-inset: max(18px, (100vw - 1600px) / 2 + 18px); right: auto !important; - left: 1rem !important; + left: var(--jn-gb-inset) !important; } +/* Launcher restyled to match the FAB dock's icon buttons: a 2.25rem circle + (size-9) with the same elevation. The embed ships it as a pill with a text + label, so the label is dropped and the padding/width constraints that came + with it are reset — otherwise the circle squashes the icon. The icon keeps + its own 1rem box (size-4, as in buttonVariants) rather than scaling with + the button. */ #gitbook-widget-button { bottom: var(--jn-gb-anchor) !important; + width: 2.25rem !important; + height: 2.25rem !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: none !important; + padding: 0 !important; + gap: 0 !important; + border-radius: 9999px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) !important; +} + +/* The embed names these parts inconsistently across versions, so match both + an id and a class of the same name. */ +#gitbook-widget-button-label, +#gitbook-widget-button .gitbook-widget-button-label { + display: none !important; +} + +/* flex:none is the point — inside a 2.25rem circle a flexible child would be + squeezed. The 1rem box matches the FAB icons (size-4 in buttonVariants). */ +#gitbook-widget-button-icon, +#gitbook-widget-button .gitbook-widget-button-icon, +#gitbook-widget-button > svg { + width: 1rem !important; + height: 1rem !important; + flex: none !important; + margin: 0 !important; +} + +#gitbook-widget-button-icon svg, +#gitbook-widget-button .gitbook-widget-button-icon svg { + width: 100% !important; + height: 100% !important; + display: block !important; } #gitbook-widget-window { From 5cc719305385a1aea07c6d719042b148af654d9a Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:18:24 +0800 Subject: [PATCH 30/33] Chore(ui): credit GitBook in the acknowledgements Co-Authored-By: Claude Fable 5 --- frontend/components/Footer.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/Footer.vue b/frontend/components/Footer.vue index 816e624d8..8d3b83624 100644 --- a/frontend/components/Footer.vue +++ b/frontend/components/Footer.vue @@ -204,6 +204,7 @@ const acknowledgementsList = [ { name: 'Sentry', link: 'https://www.sentry.io/' }, { name: '1Password', link: 'https://www.1password.com/' }, { name: 'Greptile', link: 'https://www.greptile.com/' }, + { name: 'GitBook', link: 'https://www.gitbook.com/' }, { name: 'Globalping by jsDelivr', link: 'https://globalping.io/' }, { name: 'ProxyCheck.io', link: 'https://proxycheck.io/' }, { name: 'Digital Defense', link: 'https://digital-defense.io/' }, From af59d93263d461f0e9863f52a1ada442ef7c44d5 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:36:17 +0800 Subject: [PATCH 31/33] Improvements --- frontend/components/Nav.vue | 74 +-------------- frontend/components/widgets/DocsSearch.vue | 101 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 70 deletions(-) create mode 100644 frontend/components/widgets/DocsSearch.vue diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index e24891590..306e65087 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -81,38 +81,8 @@
- - - - - -
- - - {{ docsPlaceholder }} - - -
- - - - - + + @@ -296,10 +266,9 @@ import { } from '@/components/ui/dropdown-menu'; import { Award, ChevronDown, HeartHandshake, - LogOut, Menu, Cog, Sparkle, + LogOut, Menu, Cog, } from '@lucide/vue'; -import { Input } from '@/components/ui/input'; -import { useDocsAssistant, isDocsConfigured } from '@/composables/use-docs-assistant'; +import DocsSearch from '@/components/widgets/DocsSearch.vue'; import { Icon } from '@iconify/vue'; import brandIcon from './svgicons/Brand.vue'; import { SECTION_IDS } from '@/data/sections'; @@ -333,41 +302,6 @@ const advancedTools = computed(() => // Mobile: Advanced Tools sub-list expanded by default for discoverability. const mobileToolsOpen = ref(true); -// Docs search → GitBook Assistant embed (lazy-loaded on first submit). -// Needs a docs site to answer from: configured at build time, and only on the -// canonical deployment — same gate as the original-site-only tools above. -const showDocsSearch = computed(() => isDocsConfigured && configs.value.originalSite); -const { askDocs, docsQuestions } = useDocsAssistant(); -const docsQuery = ref(''); -const submitDocsSearch = () => { - if (!docsQuery.value.trim()) return; - trackEvent('Nav', 'DocsSearch', 'submit'); - askDocs(docsQuery.value); - docsQuery.value = ''; -}; -const openDocsAssistant = () => { - trackEvent('Nav', 'DocsSearch', 'open'); - askDocs(''); -}; - -// Rotating placeholder: cycle through the preset questions so the box reads -// as "ask anything". Rotation is cosmetic — pausing it while the visitor is -// typing avoids distraction; locale switches re-resolve via docsQuestions(). -const docsPlaceholderIndex = ref(0); -const docsPlaceholder = computed(() => { - const questions = docsQuestions(); - return questions[docsPlaceholderIndex.value % questions.length] || t('nav.SearchDocs'); -}); -let docsPlaceholderTimer = null; -onMounted(() => { - docsPlaceholderTimer = setInterval(() => { - if (!docsQuery.value) docsPlaceholderIndex.value += 1; - }, 4000); -}); -onBeforeUnmount(() => { - if (docsPlaceholderTimer) clearInterval(docsPlaceholderTimer); -}); - // GitHub star count for the repo badge. Fetched from our own edge-cached // endpoint; stays null (badge hides the count) if the request fails. const githubStars = ref(null); diff --git a/frontend/components/widgets/DocsSearch.vue b/frontend/components/widgets/DocsSearch.vue new file mode 100644 index 000000000..172cb2fc9 --- /dev/null +++ b/frontend/components/widgets/DocsSearch.vue @@ -0,0 +1,101 @@ + + + From ed83d8ef0c18d6d298b41e7368d12870bbda0dfa Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:44:21 +0800 Subject: [PATCH 32/33] 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 c6fa69efb..e4ca250ea 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1468,7 +1468,7 @@ }, { "version": "v7.2.0", - "date": "Beta", + "date": "July 28, 2026", "content": [ { "type": "add", From 37f2e00e809cab1bf96fdb6f316a9cb192a62bc6 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Tue, 28 Jul 2026 20:58:52 +0800 Subject: [PATCH 33/33] Improvements --- .../components/advanced-tools/GlobalLatencyTest.vue | 13 +++++++++++-- frontend/components/advanced-tools/MtrTest.vue | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/components/advanced-tools/GlobalLatencyTest.vue b/frontend/components/advanced-tools/GlobalLatencyTest.vue index da6806240..6b1655a9a 100644 --- a/frontend/components/advanced-tools/GlobalLatencyTest.vue +++ b/frontend/components/advanced-tools/GlobalLatencyTest.vue @@ -44,7 +44,7 @@ autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true" @keyup.enter="startPingCheck" />