Update traceroute-analyzer.html
This commit is contained in:
parent
0823f7d8ea
commit
074852dd41
@ -0,0 +1,851 @@
|
||||
<!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 & 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>
|
||||
Loading…
x
Reference in New Issue
Block a user