852 lines
24 KiB
HTML
852 lines
24 KiB
HTML
<!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>
|