Files
MyIP/frontend/components/SpeedTest.vue
T
jason5ng32andClaude Fable 5 eae285caf2 Feat(report): emit full test results and collect report snapshots
Every "my network" test now emits a <domain>:finished event with its
complete structured result on the app bus (existing thin events keep
their fields — achievement rules are untouched). Connectivity and
WebRTC additionally record locale-free status/NAT enums next to their
localized labels so downstream consumers never parse t() output.
A collector mounted once in App.vue normalizes each payload through
per-section builders (shape-based cleaning, no free text) and keeps
the latest schema-valid snapshot per section for the share dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:24:21 +08:00

605 lines
23 KiB
Vue

<template>
<!-- Speed Test -->
<section class="mb-10">
<!-- Header -->
<header class="mb-2 flex flex-col items-start justify-between gap-4">
<div class="flex flex-row items-center justify-between gap-4 w-full">
<h2 id="SpeedTest"
class="m-0 flex min-w-0 flex-1 items-center gap-2 text-xl md:text-3xl font-semibold tracking-tight leading-tight">
🚀 {{ t('speedtest.Title') }}
</h2>
</div>
<div class="text-base text-muted-foreground">
<p v-if="!isSimpleMode">{{ t('speedtest.Note') }}</p>
</div>
</header>
<Card class="keyboard-shortcut-card jn-card">
<CardContent class="p-4 md:p-6">
<!-- Control Area -->
<div class="flex justify-end mb-5">
<div class="inline-flex items-stretch">
<!-- Download addon -->
<span class="flex items-center px-3 border border-input rounded-l-md bg-muted" aria-hidden="true">
<CloudDownload class="size-4 text-muted-foreground" />
</span>
<!-- Download select -->
<Select :model-value="String(state.config.package.download.bytes)"
@update:model-value="(v) => v && (state.config.package.download.bytes = Number(v))"
:disabled="isRunning || isPaused">
<SelectTrigger aria-label="Download size"
class="rounded-none border-l-0 shadow-none gap-1.5 w-auto min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="size in sizeOptions" :key="size" :value="String(size)">
{{ size / 1e6 }} MB
</SelectItem>
</SelectContent>
</Select>
<!-- Upload addon -->
<span class="flex items-center px-3 border border-input border-l-0 bg-muted" aria-hidden="true">
<CloudUpload class="size-4 text-muted-foreground" />
</span>
<!-- Upload select -->
<Select :model-value="String(state.config.package.upload.bytes)"
@update:model-value="(v) => v && (state.config.package.upload.bytes = Number(v))"
:disabled="isRunning || isPaused">
<SelectTrigger aria-label="Upload size"
class="rounded-none border-l-0 shadow-none gap-1.5 w-auto min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="size in sizeOptions" :key="size" :value="String(size)">
{{ size / 1e6 }} MB
</SelectItem>
</SelectContent>
</Select>
<!-- Start/Pause/Restart button -->
<JnTooltip :text="t('Tooltips.SpeedTestButton')" side="top">
<Button size="icon" variant="outline" class="rounded-l-none border-l-0 shadow-none cursor-pointer"
@click="speedTestController" aria-label="Start/Pause Speed Test">
<component :is="ctaIcon" />
</Button>
</JnTooltip>
</div>
</div>
<!-- Connection Information -->
<div class="min-h-6 mb-3">
<Transition name="slide-fade">
<div v-if="state.speedTest.status !== 'idle' && state.connection.colo"
class="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-sm">
<span class="inline-flex items-center gap-1.5">
<PersonStanding class="size-4 text-muted-foreground" />
{{ state.connection.country }}
<Icon v-if="state.connection.country" :icon="'circle-flags:' + state.connection.loc.toLowerCase()"
class="size-4" />
</span>
<ArrowLeftRight class="size-4 text-muted-foreground" />
<span class="inline-flex items-center gap-1.5">
<Globe class="size-4 text-muted-foreground" />
{{ state.connection.colo }}, {{ state.connection.coloCountry }}
<Icon v-if="state.connection.coloCountry"
:icon="'circle-flags:' + state.connection.coloCountryCode.toLowerCase()" class="size-4" />
</span>
</div>
</Transition>
</div>
<!-- Progress Bar -->
<Progress :model-value="state.speedTest.progress" class="mb-6"
:class="[progressIndicatorClass, state.speedTest.status === 'idle' ? 'invisible' : '']" />
<!-- 4 Metrics Tiles -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div v-for="m in metrics" :key="m.key" class="text-center">
<p class="text-lg uppercase tracking-wider text-primary mb-1">{{ m.label }}</p>
<p class="text-2xl md:text-3xl font-mono font-light tabular-nums" :class="metricColorClass">
<span>{{ state.speedTest[m.key] }}</span>
<span v-if="state.speedTest.status !== 'idle'" class="ml-1 text-sm font-normal text-muted-foreground">{{
m.unit }}</span>
</p>
</div>
</div>
<div id="result"></div>
<!-- Charts Area -->
<div v-show="state.speedTest.status !== 'idle'" class="speed-charts-container jn-slide-in">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
<div class="chart-wrapper"><canvas ref="downloadChart"></canvas></div>
<div class="chart-wrapper"><canvas ref="uploadChart"></canvas></div>
<div class="chart-wrapper"><canvas ref="latencyChart"></canvas></div>
<div class="chart-wrapper"><canvas ref="jitterChart"></canvas></div>
</div>
</div>
<!-- Result Block -->
<div v-if="isFinished && state.speedTest.hasScores && state.connection.colo"
class="jn-slide-in rounded-md border border-success/30 bg-success/10 p-4">
<div class="flex items-start gap-2">
<CalendarCheck2 class="size-5 text-success shrink-0 mt-0.5" />
<span class="text-base text-success break-all" v-if="state.connection.colo">
<p class="mb-2">
{{ t('speedtest.connectionFrom') }}
<span class="font-mono">{{ state.connection.ip }}</span>
( {{ state.connection.country }} )
{{ t('speedtest.connectionTo') }}
{{ state.connection.colo }}
( {{ state.connection.coloCity }}, {{ state.connection.coloCountry }} )
{{ t('speedtest.connectionEnd') }}
</p>
<!-- Loaded latency = ping under full saturation, the "bufferbloat" signal. -->
<p v-if="state.speedTest.downLoadedLatency !== '-' || state.speedTest.upLoadedLatency !== '-'"
class="mb-2">
{{ t('speedtest.loadedLatency') }}
<span class="font-mono">↓ {{ state.speedTest.downLoadedLatency }} ms</span>
·
<span class="font-mono">↑ {{ state.speedTest.upLoadedLatency }} ms</span>
</p>
<p class="mb-2">
{{ t('speedtest.score') }}
{{ t('speedtest.videoStreaming') }}
<span :class="qualityBadgeClass(state.speedTest.streamingQuality)">{{ t('speedtest.quality.' +
state.speedTest.streamingQuality) }}</span>
{{ t('speedtest.gaming') }}
<span :class="qualityBadgeClass(state.speedTest.gamingQuality)">
{{ t('speedtest.quality.' + state.speedTest.gamingQuality) }}
</span>
{{ t('speedtest.rtc') }}
<span :class="qualityBadgeClass(state.speedTest.rtcQuality)">
{{ t('speedtest.quality.' + state.speedTest.rtcQuality) }}
</span>
</p>
</span>
</div>
</div>
<!-- Error Block — the engine gave up (e.g. speed test server unreachable). -->
<div v-else-if="isError" class="jn-slide-in rounded-md border border-destructive/30 bg-destructive/10 p-4">
<div class="flex items-start gap-2">
<CircleAlert class="size-5 text-destructive shrink-0 mt-0.5" />
<span class="text-base text-destructive">{{ t('speedtest.testFailed') }}</span>
</div>
</div>
</CardContent>
</Card>
</section>
</template>
<script setup>
import { reactive, computed, onMounted, markRaw, onUnmounted } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { isValidIP } from '@/utils/valid-ip.js';
import getCountryName from '@/data/country-name.js';
import SpeedTestEngine from '@cloudflare/speedtest';
import useSpeedTestCharts from '@/composables/use-speedtest-charts.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
ArrowLeftRight, CalendarCheck2, CircleAlert, Play, CloudDownload, CloudUpload,
Globe, Pause, PersonStanding, RotateCw,
} from '@lucide/vue';
import { Icon } from '@iconify/vue';
const { t } = useI18n();
const store = useMainStore();
const lang = computed(() => store.lang);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
// State Management
const state = reactive({
speedTest: {
id: 'speedTest',
downloadSpeed: '-',
uploadSpeed: '-',
latency: '-',
jitter: '-',
downLoadedLatency: '-',
upLoadedLatency: '-',
streamingScore: '-',
streamingQuality: '-',
gamingScore: '-',
gamingQuality: '-',
rtcScore: '-',
rtcQuality: '-',
status: 'idle',
hasScores: false,
progress: 0,
},
connection: { ip: '', colo: '', loc: '', country: '', coloCountry: '', coloCountryCode: '', coloCity: '' },
config: {
package: {
download: { bytes: 50e6, count: 4 },
upload: { bytes: 15e6, count: 4 },
latency: { count: 30 },
},
},
});
const {
downloadChart, uploadChart, latencyChart, jitterChart,
updateCharts, initStartingPoints, destroyCharts, resetChartData,
} = useSpeedTestCharts(t);
// Optional speed test sizes (bytes) for ToggleGroup
const sizeOptions = [1e6, 10e6, 15e6, 50e6, 100e6];
// --- Derived State ----------------------------------------------------------
const isRunning = computed(() => state.speedTest.status === 'running');
const isPaused = computed(() => state.speedTest.status === 'paused');
const isFinished = computed(() => state.speedTest.status === 'finished');
const isError = computed(() => state.speedTest.status === 'error');
// CTA Icon: running=Pause, finished/error=Retry, otherwise=Start
const ctaIcon = computed(() => {
if (isRunning.value) return Pause;
if (isFinished.value || isError.value) return RotateCw;
return Play;
});
// Color Class for Metrics
const metricColorClass = computed(() => {
if (isRunning.value || isPaused.value) return 'text-info';
if (isFinished.value) return 'text-success';
if (isError.value) return 'text-destructive';
return '';
});
const progressIndicatorClass = computed(() => {
if (isFinished.value) return '[&>*]:bg-success';
if (isError.value) return '[&>*]:bg-destructive';
if (isRunning.value || isPaused.value) return '[&>*]:bg-info';
return '';
});
// 4 Metrics Metadata
const metrics = computed(() => [
{ key: 'downloadSpeed', label: t('speedtest.Download'), unit: 'Mb/s' },
{ key: 'uploadSpeed', label: t('speedtest.Upload'), unit: 'Mb/s' },
{ key: 'latency', label: t('speedtest.Latency'), unit: 'ms' },
{ key: 'jitter', label: t('speedtest.Jitter'), unit: 'ms' },
]);
// 5-tier classification → 3 color tokens (label carries the 5-way distinction).
const qualityBadgeClass = (classification) => {
switch (classification) {
case 'bad':
case 'poor':
return 'text-destructive';
case 'average':
return 'text-warning';
case 'good':
case 'great':
return 'text-success';
default:
return 'text-muted-foreground';
}
};
// --- Connection Data ----------------------------------------------------------
const connectionMethods = {
async getIPFromSpeedTest() {
try {
const response = await fetchWithTimeout('https://speed.cloudflare.com/cdn-cgi/trace');
const data = await response.text();
const lines = data.split('\n');
const ip = lines.find((l) => l.startsWith('ip='))?.split('=')[1];
const colo = lines.find((l) => l.startsWith('colo='))?.split('=')[1];
const loc = lines.find((l) => l.startsWith('loc='))?.split('=')[1];
if (!isValidIP(ip)) throw new Error('Invalid IP from SpeedTest Server');
const { default: getColoCountry } = await import('@/data/speedtest-colos.js');
return {
ip, colo, loc,
country: getCountryName(loc, lang.value) || '',
coloCountryCode: getColoCountry(colo).country || '',
coloCity: getColoCountry(colo).city || '',
coloCountry: getCountryName(getColoCountry(colo).country, lang.value) || '',
};
} catch (error) {
console.error('Error fetching IP from SpeedTest Server:', error);
return null;
}
},
};
// --- Test Engine ----------------------------------------------------------
let testEngine;
const engineMethods = {
// Build a fresh engine. State reset is owned by speedTestController.
reset() {
return markRaw(new SpeedTestEngine({
autoStart: false,
measurements: [
{ type: 'latency', numPackets: state.config.package.latency.count },
{ type: 'download', bytes: state.config.package.download.bytes, count: state.config.package.download.count },
{ type: 'upload', bytes: state.config.package.upload.bytes, count: state.config.package.upload.count },
],
}));
},
updateResults(results) {
const summary = results.getSummary();
// Any field can be null when its measurement produced no samples.
const fmt = (v, div = 1) => (v == null ? 0 : parseFloat((v / div).toFixed(2)));
Object.assign(state.speedTest, {
downloadSpeed: fmt(summary.download, 1000000),
uploadSpeed: fmt(summary.upload, 1000000),
latency: fmt(summary.latency),
jitter: fmt(summary.jitter),
// Loaded latency only present when the engine measured it.
downLoadedLatency: summary.downLoadedLatency == null ? '-' : fmt(summary.downLoadedLatency),
upLoadedLatency: summary.upLoadedLatency == null ? '-' : fmt(summary.upLoadedLatency),
});
},
updateProgress() {
const rawData = testEngine.results.raw;
// 3 stages: latency / download / upload.
const perStage = 100 / 3;
let progress = 0;
if (rawData.latency?.started) progress += rawData.latency.finished ? perStage : perStage / 2;
if (rawData.download?.started) progress += rawData.download.finished ? perStage : perStage / 2;
if (rawData.upload?.started) progress += rawData.upload.finished ? perStage : perStage / 2;
state.speedTest.progress = Math.min(progress, 100);
},
// Live values via engine getters (supported API). Getters can briefly return
// `null` between "started" and "first datapoint", so guard with `!= null`.
updateSpeedInRealTime() {
if (state.speedTest.status === 'finished' || state.speedTest.status === 'error') return;
try {
const results = testEngine?.results;
if (!results) return;
const dl = results.getDownloadBandwidth();
const ul = results.getUploadBandwidth();
const lat = results.getUnloadedLatency();
const jit = results.getUnloadedJitter();
if (dl != null) state.speedTest.downloadSpeed = parseFloat((dl / 1e6).toFixed(2));
if (ul != null) state.speedTest.uploadSpeed = parseFloat((ul / 1e6).toFixed(2));
if (lat != null) state.speedTest.latency = parseFloat(lat.toFixed(2));
if (jit != null) state.speedTest.jitter = parseFloat(jit.toFixed(2));
updateCharts(
state.speedTest.downloadSpeed,
state.speedTest.uploadSpeed,
state.speedTest.latency,
state.speedTest.jitter,
results.raw,
);
} catch (error) {
console.error('Error in updateSpeedInRealTime:', error);
}
},
};
// --- Test Control ----------------------------------------------------------
const setupTestEngine = async () => {
if (!state.connection.ip) {
const connectionData = await connectionMethods.getIPFromSpeedTest();
if (connectionData) {
Object.assign(state.connection, connectionData);
// Feed the Globalping picker + IP history.
store.updateAllIPs([{ ip: connectionData.ip, country: connectionData.loc || '', location: connectionData.country || '' }]);
}
}
testEngine.onRunningChange = () => { state.speedTest.status = 'running'; };
testEngine.onResultsChange = () => {
engineMethods.updateProgress();
engineMethods.updateSpeedInRealTime();
};
testEngine.onFinish = (results) => {
// The engine fires onFinish even after giving up (e.g. every request to
// the test server failed). An error already reported by onError is final,
// and a run with no positive measurement (the engine reports 0 for zero
// samples) is a failure — never flip either to a green all-zero result.
const summary = results?.getSummary?.() ?? {};
const measured = (v) => Number.isFinite(v) && v > 0;
const hasData = [summary.download, summary.upload, summary.latency].some(measured);
if (state.speedTest.status === 'error' || !hasData) {
state.speedTest.status = 'error';
testEngine.onRunningChange = () => {};
testEngine.onResultsChange = () => {};
testEngine.onError = () => {};
testEngine = null;
return;
}
state.speedTest.status = 'finished';
state.speedTest.progress = 100;
// No-op, not null: the engine's queued timers call these unconditionally.
testEngine.onRunningChange = () => {};
testEngine.onResultsChange = () => {};
testEngine.onError = () => {};
engineMethods.updateResults(results);
// Engine's getScores() reads getSummary() internally; when packetLoss is
// unmeasured it falls back to defaultPoints.packetLoss=0, which costs every
// experience a flat 10 points vs. an actually-measured 0% loss (+10 from
// the scaleThreshold formula). That single delta pushes gaming/rtc one or
// two tiers down. Since we don't run packetLoss (would need TURN setup),
// inject the optimistic 0 — matches Cloudflare's own UI behavior whenever
// the user's network has near-zero loss, which is the common case.
const origGetSummary = results.getSummary.bind(results);
results.getSummary = () => ({ ...origGetSummary(), packetLoss: 0 });
const scores = results.getScores();
if (scores?.streaming) {
state.speedTest.hasScores = true;
state.speedTest.streamingScore = scores.streaming.points;
state.speedTest.gamingScore = scores.gaming.points;
state.speedTest.rtcScore = scores.rtc.points;
state.speedTest.streamingQuality = scores.streaming.classificationName;
state.speedTest.gamingQuality = scores.gaming.classificationName;
state.speedTest.rtcQuality = scores.rtc.classificationName;
}
testEngine = null;
// Achievement rules for speed thresholds live in data/achievement-rules.js;
// the report collector consumes the full result (unmeasured fields keep
// their '-' placeholder — the builder drops non-numbers).
emitAppEvent('speedtest:finished', {
downloadSpeed: state.speedTest.downloadSpeed,
uploadSpeed: state.speedTest.uploadSpeed,
latency: state.speedTest.latency,
jitter: state.speedTest.jitter,
downLoadedLatency: state.speedTest.downLoadedLatency,
upLoadedLatency: state.speedTest.upLoadedLatency,
scores: state.speedTest.hasScores
? {
streaming: state.speedTest.streamingScore,
gaming: state.speedTest.gamingScore,
rtc: state.speedTest.rtcScore,
}
: null,
qualities: state.speedTest.hasScores
? {
streaming: state.speedTest.streamingQuality,
gaming: state.speedTest.gamingQuality,
rtc: state.speedTest.rtcQuality,
}
: null,
connection: { ...state.connection },
});
};
testEngine.onError = (e) => {
if (typeof e === 'string' && !e.includes('ICE')) {
state.speedTest.status = 'error';
}
console.error('Speed Test Error: ', e);
};
};
const speedTestController = async () => {
try {
if (state.speedTest.status === 'running') {
testEngine.pause();
state.speedTest.status = 'paused';
return;
}
if (state.speedTest.status === 'paused') {
testEngine.play();
return;
}
resetChartData();
destroyCharts();
await initStartingPoints();
Object.assign(state.speedTest, {
downloadSpeed: 0, uploadSpeed: 0, latency: 0, jitter: 0,
downLoadedLatency: '-', upLoadedLatency: '-',
streamingScore: '-', gamingScore: '-', rtcScore: '-',
hasScores: false, progress: 0,
});
testEngine = engineMethods.reset();
if (!testEngine) { console.error('Failed to initialize test engine'); return; }
await setupTestEngine();
trackEvent('Section', 'StartClick', 'SpeedTest');
testEngine.play();
} catch (error) {
console.error('Error in speedTestController:', error);
state.speedTest.status = 'error';
}
};
// --- Lifecycle ----------------------------------------------------------
onMounted(() => { store.setMountingStatus('SpeedTest', true); });
// If the user navigates away mid-test, detach the engine's callbacks before
// dropping the reference — otherwise any in-flight async work inside the
// SpeedTestEngine would still try to write state refs that no longer exist.
// No-ops, not null: the engine's queued timers call these unconditionally.
onUnmounted(() => {
if (testEngine) {
testEngine.onRunningChange = () => {};
testEngine.onResultsChange = () => {};
testEngine.onFinish = () => {};
testEngine.onError = () => {};
testEngine = null;
}
destroyCharts();
});
defineExpose({ speedTestController });
</script>
<style scoped>
/* Vue transition: Connection information line fade-in/out slide */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
/* Charts container: Chart.js canvas needs a parent container with a clear height */
.speed-charts-container {
margin: 20pt 0;
}
.chart-wrapper {
position: relative;
height: 130pt;
width: 100%;
margin-bottom: 15pt;
}
@media (max-width: 768px) {
.chart-wrapper {
height: 100pt;
margin-bottom: 20pt;
}
}
@media (min-width: 769px) and (max-width: 991px) {
.chart-wrapper {
height: 100pt;
margin-bottom: 25pt;
}
}
/* Test start / finish slide-in animation */
.jn-slide-in {
animation: jn-slide-in 0.2s ease-in forwards;
}
@keyframes jn-slide-in {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>