Traceroute_check/traceroute-analyzer.html

852 lines
24 KiB
HTML
Raw Normal View History

2026-03-07 02:58:57 +00:00
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Traceroute ISP Analyzer</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e1a;
--surface: #0f1525;
--border: #1e2d4a;
--accent: #00d4ff;
--accent2: #ff6b35;
--green: #00ff9d;
--text: #c8d8f0;
--muted: #4a6080;
--row-even: #0d1420;
--row-hover: #141e35;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'JetBrains Mono', monospace;
min-height: 100vh;
overflow-x: hidden;
}
/* Background grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 40px 24px;
position: relative;
z-index: 1;
}
/* Header */
header {
margin-bottom: 40px;
}
.header-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(0,212,255,0.08);
border: 1px solid rgba(0,212,255,0.2);
border-radius: 4px;
padding: 4px 12px;
font-size: 11px;
color: var(--accent);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 16px;
}
.header-badge::before {
content: '';
width: 6px; height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
h1 {
font-family: 'Syne', sans-serif;
font-size: clamp(28px, 5vw, 48px);
font-weight: 800;
line-height: 1.1;
color: #fff;
letter-spacing: -1px;
}
h1 span {
color: var(--accent);
}
.subtitle {
margin-top: 10px;
font-size: 13px;
color: var(--muted);
line-height: 1.6;
}
/* Input area */
.input-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 24px;
transition: border-color 0.2s;
}
.input-section:focus-within {
border-color: rgba(0,212,255,0.4);
box-shadow: 0 0 0 1px rgba(0,212,255,0.1), 0 4px 24px rgba(0,0,0,0.4);
}
.input-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.02);
}
.input-label {
font-size: 11px;
color: var(--muted);
letter-spacing: 1.5px;
text-transform: uppercase;
}
.dot-row {
display: flex;
gap: 6px;
}
.dot {
width: 10px; height: 10px;
border-radius: 50%;
}
.dot-r { background: #ff5f57; }
.dot-y { background: #febc2e; }
.dot-g { background: #28c840; }
textarea {
width: 100%;
min-height: 200px;
background: transparent;
border: none;
outline: none;
padding: 16px;
color: var(--text);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
resize: vertical;
}
textarea::placeholder {
color: var(--muted);
opacity: 0.6;
}
/* Buttons */
.actions {
display: flex;
gap: 12px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 24px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover {
background: #33ddff;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,212,255,0.3);
}
.btn-primary:active { transform: translateY(0); }
.btn-secondary {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--muted);
color: var(--text);
}
/* Status bar */
.status-bar {
display: none;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(0,212,255,0.05);
border: 1px solid rgba(0,212,255,0.15);
border-radius: 6px;
margin-bottom: 24px;
font-size: 12px;
color: var(--accent);
}
.status-bar.visible { display: flex; }
.spinner {
width: 14px; height: 14px;
border: 2px solid rgba(0,212,255,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Error */
.error-bar {
display: none;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(255,107,53,0.07);
border: 1px solid rgba(255,107,53,0.25);
border-radius: 6px;
margin-bottom: 24px;
font-size: 12px;
color: var(--accent2);
}
.error-bar.visible { display: flex; }
/* Results */
#results { display: none; }
#results.visible { display: block; }
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: gap;
gap: 12px;
}
.results-title {
font-family: 'Syne', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--green);
letter-spacing: 1px;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.results-title::before {
content: '';
width: 8px; height: 8px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 8px var(--green);
}
.hop-count {
font-size: 11px;
color: var(--muted);
background: rgba(255,255,255,0.04);
padding: 4px 10px;
border-radius: 4px;
border: 1px solid var(--border);
}
/* Table */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
thead tr {
background: rgba(0,212,255,0.05);
border-bottom: 1px solid var(--border);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid rgba(30,45,74,0.6);
transition: background 0.1s;
animation: rowIn 0.3s ease forwards;
opacity: 0;
transform: translateY(4px);
}
@keyframes rowIn {
to { opacity: 1; transform: translateY(0); }
}
tbody tr:nth-child(even) { background: var(--row-even); }
tbody tr:hover { background: var(--row-hover); }
td {
padding: 10px 14px;
vertical-align: middle;
white-space: nowrap;
}
.hop-num {
color: var(--muted);
font-size: 11px;
text-align: center;
width: 40px;
}
.ip-cell {
font-weight: 600;
color: #fff;
font-size: 13px;
}
.ip-private {
color: var(--muted);
font-size: 11px;
}
.ip-star {
color: var(--muted);
font-size: 18px;
line-height: 1;
}
.hostname-cell {
color: var(--muted);
font-size: 11px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.isp-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.asn-cell {
font-size: 11px;
color: var(--accent);
opacity: 0.7;
}
.country-cell {
font-size: 12px;
}
.latency-cell {
font-size: 11px;
color: var(--green);
text-align: right;
}
.latency-na { color: var(--muted); }
/* ISP color variants */
.isp-0 { background: rgba(0,212,255,0.1); color: #00d4ff; border: 1px solid rgba(0,212,255,0.2); }
.isp-1 { background: rgba(255,107,53,0.1); color: #ff6b35; border: 1px solid rgba(255,107,53,0.2); }
.isp-2 { background: rgba(0,255,157,0.1); color: #00ff9d; border: 1px solid rgba(0,255,157,0.2); }
.isp-3 { background: rgba(190,100,255,0.1);color: #be64ff; border: 1px solid rgba(190,100,255,0.2); }
.isp-4 { background: rgba(255,220,50,0.1); color: #ffdc32; border: 1px solid rgba(255,220,50,0.2); }
.isp-5 { background: rgba(255,80,120,0.1); color: #ff5078; border: 1px solid rgba(255,80,120,0.2); }
.isp-6 { background: rgba(80,200,255,0.1); color: #50c8ff; border: 1px solid rgba(80,200,255,0.2); }
.isp-7 { background: rgba(255,160,50,0.1); color: #ffa032; border: 1px solid rgba(255,160,50,0.2); }
.isp-private { background: rgba(74,96,128,0.12); color: var(--muted); border: 1px solid rgba(74,96,128,0.2); }
.isp-unknown { background: rgba(74,96,128,0.08); color: #3a5070; border: 1px solid rgba(74,96,128,0.15); }
/* Sample button */
.sample-link {
font-size: 11px;
color: var(--muted);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
background: none;
border: none;
font-family: inherit;
transition: color 0.15s;
}
.sample-link:hover { color: var(--accent); }
/* Footer */
footer {
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
footer a { color: var(--accent); text-decoration: none; opacity: 0.7; }
footer a:hover { opacity: 1; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-badge">Network Analyzer</div>
<h1>Traceroute<br><span>ISP Analyzer</span></h1>
<p class="subtitle">Paste traceroute output → phân tích từng hop thuộc ISP nào.<br>Hỗ trợ Windows <code>tracert</code> và Linux/macOS <code>traceroute</code>.</p>
</header>
<div class="input-section">
<div class="input-header">
<span class="input-label">Traceroute Output</span>
<div class="dot-row">
<div class="dot dot-r"></div>
<div class="dot dot-y"></div>
<div class="dot dot-g"></div>
</div>
</div>
<textarea id="traceInput" placeholder="Paste traceroute hoặc tracert output vào đây...
Ví dụ (Linux):
traceroute to google.com (142.250.66.46)
1 192.168.1.1 (192.168.1.1) 1.234 ms
2 10.0.0.1 (10.0.0.1) 5.678 ms
3 203.113.0.1 12.345 ms
...
Ví dụ (Windows):
1 <1 ms <1 ms <1 ms 192.168.1.1
2 5 ms 4 ms 5 ms 10.0.0.1
3 12 ms 11 ms 13 ms 203.113.0.1"></textarea>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="analyze()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Analyze Hops
</button>
<button class="btn btn-secondary" onclick="loadSample()">Load Sample</button>
<button class="btn btn-secondary" onclick="clearAll()">Clear</button>
</div>
<div class="status-bar" id="statusBar">
<div class="spinner"></div>
<span id="statusText">Đang query ISP data...</span>
</div>
<div class="error-bar" id="errorBar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="errorText"></span>
</div>
<div id="results">
<div class="results-header">
<div class="results-title">Analysis Complete</div>
<span class="hop-count" id="hopCount"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:center">#</th>
<th>IP Address</th>
<th>Hostname</th>
<th>ISP / Organization</th>
<th>ASN</th>
<th>Country</th>
<th style="text-align:right">Latency</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<footer>
<span>ISP data via <a href="https://ipinfo.io" target="_blank">ipinfo.io</a> — IPv4 &amp; IPv6, free tier</span>
<span>Chỉ dùng local · không gửi data lên server</span>
</footer>
</div>
<script>
// ─── ISP color map ───────────────────────────────────────────────────────────
const ispColorMap = {};
let ispColorIndex = 0;
function getIspClass(isp) {
if (!isp) return 'isp-unknown';
const key = isp.toLowerCase().replace(/\s+as\d+/i, '').trim();
if (!(key in ispColorMap)) {
ispColorMap[key] = `isp-${ispColorIndex % 8}`;
ispColorIndex++;
}
return ispColorMap[key];
}
// ─── IP detection helpers ────────────────────────────────────────────────────
const IPV4_RE = /\b(\d{1,3}(?:\.\d{1,3}){3})\b/;
// Match full IPv6 address — must come before any whitespace or closing paren
// Handles: full, compressed (::), and mixed forms
const IPV6_FULL_RE = /([0-9a-fA-F]{0,4}(?::[0-9a-fA-F]{0,4}){2,7})/;
function extractIP(str) {
if (!str) return null;
str = str.trim();
const v4 = str.match(IPV4_RE);
if (v4) return v4[1];
const v6 = str.match(IPV6_FULL_RE);
if (v6 && v6[1].includes(':')) return v6[1].toLowerCase();
return null;
}
function isIPv6(ip) {
return ip && ip.includes(':');
}
// ─── Parse traceroute ────────────────────────────────────────────────────────
function parseTraceroute(text) {
const lines = text.trim().split('\n');
const hops = [];
for (const line of lines) {
if (/^traceroute|^Tracing|^over a maximum|^\s*$/.test(line)) continue;
// Pattern A: hop# hostname (IP) latency...
// Handles both IPv4 and IPv6 inside parens
// e.g.: " 3 static.vnpt.vn (2001:ee0:37f:fff3::182) 0.798 ms ..."
let m = line.match(/^\s*(\d+)\s+(\S+)\s+\(([^)]+)\)\s+(.*)/);
if (m) {
const ip = extractIP(m[3]);
// m[2] could be the same as IP (some traceroute formats repeat it)
const hostname = (ip && m[2] === ip) ? null : m[2];
hops.push({ hop: parseInt(m[1]), hostname, ip, latencyRaw: m[4] });
continue;
}
// Pattern B: hop# bare_IP latency... (no hostname, no parens)
// Strategy: take everything from after hop# up to the first digit-ms or * pattern
// e.g.: " 7 2405:4802:f500::c1 5.459 ms 5.855 ms 5.858 ms"
// Split on 2+ spaces or transition from IP chars to space+digit
m = line.match(/^\s*(\d+)\s+(\S+)\s+((?:<?\d+(?:\.\d+)?\s*ms|\*)\s*.*)/);
if (m) {
const ip = extractIP(m[2]);
if (ip) {
hops.push({ hop: parseInt(m[1]), hostname: null, ip, latencyRaw: m[3] });
continue;
}
}
// Pattern C: Windows tracert
// e.g.: " 1 <1 ms <1 ms <1 ms 192.168.1.1"
m = line.match(/^\s*(\d+)\s+(<?\d+\s*ms|\*)\s+(<?\d+\s*ms|\*)\s+(<?\d+\s*ms|\*)\s+(\S+)/);
if (m) {
const raw = m[5];
const ip = extractIP(raw);
hops.push({
hop: parseInt(m[1]),
hostname: (ip && raw !== ip) ? raw : null,
ip: ip || null,
latencyRaw: [m[2], m[3], m[4]].join(' '),
});
continue;
}
// Pattern D: star-only hop
m = line.match(/^\s*(\d+)\s+[\*\s]+$/);
if (m) {
hops.push({ hop: parseInt(m[1]), hostname: null, ip: null, latencyRaw: '* * *' });
}
}
return hops;
}
// ─── Parse latency ───────────────────────────────────────────────────────────
function parseLatency(raw) {
if (!raw) return null;
const nums = raw.match(/[\d.]+(?=\s*ms)/g);
if (!nums || nums.length === 0) return null;
const avg = nums.reduce((a, b) => a + parseFloat(b), 0) / nums.length;
return avg.toFixed(1);
}
// ─── Private IP check ────────────────────────────────────────────────────────
function isPrivateIP(ip) {
if (!ip) return false;
// IPv6 loopback / link-local / unique-local
if (isIPv6(ip)) {
return ip === '::1' ||
/^fe80:/i.test(ip) ||
/^fc00:/i.test(ip) ||
/^fd/i.test(ip);
}
// IPv4
return /^10\./.test(ip) ||
/^192\.168\./.test(ip) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
/^127\./.test(ip) ||
/^169\.254\./.test(ip);
}
// ─── Query ipinfo.io (supports IPv4 + IPv6) ──────────────────────────────────
async function queryISPs(ips) {
if (ips.length === 0) return {};
// ipinfo.io doesn't have a batch endpoint (free tier), query in parallel
const results = await Promise.allSettled(
ips.map(ip =>
fetch(`https://ipinfo.io/${ip}/json`)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
)
);
const map = {};
for (let i = 0; i < ips.length; i++) {
const val = results[i].status === 'fulfilled' ? results[i].value : null;
if (val) map[ips[i]] = val;
}
return map;
}
// ─── Render table ────────────────────────────────────────────────────────────
function renderTable(hops, ispData) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
for (let i = 0; i < hops.length; i++) {
const h = hops[i];
const tr = document.createElement('tr');
tr.style.animationDelay = `${i * 40}ms`;
const latency = parseLatency(h.latencyRaw);
const isPriv = isPrivateIP(h.ip);
const info = h.ip && !isPriv ? ispData[h.ip] : null;
// Hop #
let hopTd = `<td class="hop-num">${h.hop}</td>`;
// IP
let ipTd;
if (!h.ip) {
ipTd = `<td><span class="ip-star">* * *</span></td>`;
} else if (isPriv) {
ipTd = `<td><span class="ip-cell">${h.ip}</span><br><span class="ip-private">private</span></td>`;
} else {
ipTd = `<td><span class="ip-cell">${h.ip}</span></td>`;
}
// Hostname
const hostnameDisplay = h.hostname || (info && info.hostname) || '—';
let hostTd = `<td class="hostname-cell" title="${hostnameDisplay}">${hostnameDisplay}</td>`;
// ISP — ipinfo.io returns: { org: "AS7552 Viettel", country: "VN", hostname: "..." }
let ispTd;
if (!h.ip) {
ispTd = `<td><span class="isp-badge isp-unknown">timeout</span></td>`;
} else if (isPriv) {
ispTd = `<td><span class="isp-badge isp-private">Private / LAN</span></td>`;
} else if (info && info.org) {
// Strip leading ASN from org name for display, keep full in title
const label = info.org.replace(/^AS\d+\s*/i, '') || info.org;
const cls = getIspClass(label);
ispTd = `<td><span class="isp-badge ${cls}" title="${info.org}">${label}</span></td>`;
} else {
ispTd = `<td><span class="isp-badge isp-unknown"></span></td>`;
}
// ASN
let asnTd;
if (info && info.org) {
const asnMatch = info.org.match(/^(AS\d+)/i);
asnTd = `<td class="asn-cell">${asnMatch ? asnMatch[1] : '—'}</td>`;
} else {
asnTd = `<td class="asn-cell" style="color:var(--muted)"></td>`;
}
// Country
let countryTd;
if (info && info.country) {
const code = info.country.toUpperCase();
const flag = String.fromCodePoint(...[...code].map(c => 0x1F1E6 - 65 + c.charCodeAt(0)));
countryTd = `<td class="country-cell">${flag} ${info.country}</td>`;
} else {
countryTd = `<td class="country-cell" style="color:var(--muted)"></td>`;
}
// Latency
let latTd;
if (latency) {
const ms = parseFloat(latency);
const color = ms < 20 ? 'var(--green)' : ms < 80 ? '#ffdc32' : 'var(--accent2)';
latTd = `<td class="latency-cell" style="color:${color}">${latency} ms</td>`;
} else {
latTd = `<td class="latency-cell latency-na"></td>`;
}
tr.innerHTML = hopTd + ipTd + hostTd + ispTd + asnTd + countryTd + latTd;
tbody.appendChild(tr);
}
}
// ─── Main analyze ────────────────────────────────────────────────────────────
async function analyze() {
const input = document.getElementById('traceInput').value.trim();
hideError();
hideResults();
if (!input) {
showError('Vui lòng paste traceroute output vào ô trên.');
return;
}
const hops = parseTraceroute(input);
if (hops.length === 0) {
showError('Không parse được hop nào. Kiểm tra lại format của traceroute output.');
return;
}
const publicIPs = hops.filter(h => h.ip && !isPrivateIP(h.ip)).map(h => h.ip);
const uniqueIPs = [...new Set(publicIPs)];
showStatus(`Đang query ${uniqueIPs.length} địa chỉ IP từ ipinfo.io...`);
try {
const ispData = await queryISPs(uniqueIPs);
hideStatus();
renderTable(hops, ispData);
document.getElementById('hopCount').textContent = `${hops.length} hops · ${uniqueIPs.length} public IPs`;
showResults();
} catch (err) {
hideStatus();
showError(`Lỗi khi gọi API: ${err.message}. Kiểm tra kết nối mạng và thử lại.`);
}
}
// ─── UI helpers ──────────────────────────────────────────────────────────────
function showStatus(msg) {
document.getElementById('statusText').textContent = msg;
document.getElementById('statusBar').classList.add('visible');
}
function hideStatus() {
document.getElementById('statusBar').classList.remove('visible');
}
function showError(msg) {
document.getElementById('errorText').textContent = msg;
document.getElementById('errorBar').classList.add('visible');
}
function hideError() {
document.getElementById('errorBar').classList.remove('visible');
}
function showResults() {
document.getElementById('results').classList.add('visible');
}
function hideResults() {
document.getElementById('results').classList.remove('visible');
}
function clearAll() {
document.getElementById('traceInput').value = '';
hideError();
hideResults();
ispColorIndex = 0;
Object.keys(ispColorMap).forEach(k => delete ispColorMap[k]);
}
function loadSample() {
document.getElementById('traceInput').value = `traceroute to google.com (2404:6800:4005:81e::200e), 30 hops max
1 192.168.1.1 (192.168.1.1) 1.123 ms 1.045 ms 0.987 ms
2 10.0.0.1 (10.0.0.1) 4.567 ms 4.321 ms 4.890 ms
3 static.vnpt.vn (2001:ee0:37f:fff3::182) 0.798 ms 0.788 ms 0.785 ms
4 static.vnpt.vn (2001:ee0:37f:ff00::49) 0.809 ms 0.828 ms 0.906 ms
5 * * *
6 2402:800:6360:b::2 (2402:800:6360:b::2) 4.271 ms 4.269 ms 4.291 ms
7 2405:4800:0:4:0:510b:4589:9001 6.563 ms 7.922 ms 7.957 ms
8 2404:6800:4005:81e::200e 22.456 ms 21.987 ms 22.234 ms`;
}
// Enter shortcut (Ctrl+Enter)
document.getElementById('traceInput').addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') analyze();
});
</script>
</body>
</html>