stability.html 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <link rel="shortcut icon" href="favicon.ico" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
  6. <meta charset="UTF-8" />
  7. <title>LibreSpeed - Stability Test</title>
  8. <style type="text/css">
  9. html,
  10. body {
  11. border: none;
  12. padding: 0;
  13. margin: 0;
  14. background: #ffffff;
  15. color: #202020;
  16. }
  17. body {
  18. text-align: center;
  19. font-family: "Roboto", sans-serif;
  20. }
  21. h1 {
  22. color: #404040;
  23. }
  24. nav {
  25. margin-bottom: 1em;
  26. }
  27. nav a {
  28. color: #6060aa;
  29. text-decoration: none;
  30. font-size: 0.95em;
  31. }
  32. nav a:hover {
  33. text-decoration: underline;
  34. }
  35. #controls {
  36. margin: 1em auto;
  37. }
  38. #durationSelect,
  39. #targetSelect {
  40. font-size: 1em;
  41. padding: 0.3em 0.5em;
  42. margin-right: 1em;
  43. }
  44. #startBtn {
  45. display: inline-block;
  46. margin: 0 auto;
  47. color: #6060aa;
  48. background-color: rgba(0, 0, 0, 0);
  49. border: 0.15em solid #6060ff;
  50. border-radius: 0.3em;
  51. transition: all 0.3s;
  52. box-sizing: border-box;
  53. width: 8em;
  54. height: 3em;
  55. line-height: 2.7em;
  56. cursor: pointer;
  57. box-shadow:
  58. 0 0 0 rgba(0, 0, 0, 0.1),
  59. inset 0 0 0 rgba(0, 0, 0, 0.1);
  60. }
  61. #startBtn:hover {
  62. box-shadow:
  63. 0 0 2em rgba(0, 0, 0, 0.1),
  64. inset 0 0 1em rgba(0, 0, 0, 0.1);
  65. }
  66. #startBtn.disabled {
  67. color: #a0a0a0;
  68. border-color: #c0c0c0;
  69. cursor: default;
  70. opacity: 0.8;
  71. }
  72. #startBtn.disabled:hover {
  73. box-shadow:
  74. 0 0 0 rgba(0, 0, 0, 0.1),
  75. inset 0 0 0 rgba(0, 0, 0, 0.1);
  76. }
  77. #startBtn:before {
  78. content: "Start";
  79. }
  80. #startBtn.finding:before {
  81. content: "Finding...";
  82. }
  83. #startBtn.running {
  84. background-color: #ff3030;
  85. border-color: #ff6060;
  86. color: #ffffff;
  87. cursor: pointer;
  88. opacity: 1;
  89. }
  90. #startBtn.running:before {
  91. content: "Abort";
  92. }
  93. #resetBtn {
  94. display: inline-block;
  95. color: #808080;
  96. background-color: rgba(0, 0, 0, 0);
  97. border: 0.15em solid #a0a0a0;
  98. border-radius: 0.3em;
  99. transition: all 0.3s;
  100. box-sizing: border-box;
  101. width: 6em;
  102. height: 3em;
  103. line-height: 2.7em;
  104. cursor: pointer;
  105. margin-left: 0.5em;
  106. box-shadow:
  107. 0 0 0 rgba(0, 0, 0, 0.1),
  108. inset 0 0 0 rgba(0, 0, 0, 0.1);
  109. }
  110. #resetBtn:hover {
  111. box-shadow:
  112. 0 0 2em rgba(0, 0, 0, 0.1),
  113. inset 0 0 1em rgba(0, 0, 0, 0.1);
  114. }
  115. #serverArea {
  116. margin: 0.8em 0;
  117. }
  118. #server {
  119. font-size: 1em;
  120. padding: 0.2em;
  121. }
  122. #test {
  123. margin-top: 2em;
  124. margin-bottom: 2em;
  125. }
  126. div.testGroup {
  127. display: block;
  128. margin: 0 auto;
  129. }
  130. div.testArea2 {
  131. display: inline-block;
  132. width: 10em;
  133. height: 7em;
  134. position: relative;
  135. box-sizing: border-box;
  136. text-align: center;
  137. }
  138. div.testArea2 div.testName {
  139. display: block;
  140. text-align: center;
  141. font-size: 1.4em;
  142. }
  143. div.testArea2 div.meterText {
  144. display: inline-block;
  145. font-size: 2.5em;
  146. }
  147. div.meterText:empty:before {
  148. content: "0.00";
  149. }
  150. div.testArea2 div.unit {
  151. display: inline-block;
  152. }
  153. #rating {
  154. font-size: 1.3em;
  155. font-weight: bold;
  156. margin: 0.5em 0;
  157. }
  158. #rating.great {
  159. color: #2ecc71;
  160. }
  161. #rating.good {
  162. color: #27ae60;
  163. }
  164. #rating.poor {
  165. color: #e67e22;
  166. }
  167. #rating.bad {
  168. color: #e74c3c;
  169. }
  170. #rating.none {
  171. color: #808080;
  172. }
  173. #chartContainer {
  174. margin: 1em auto;
  175. width: 95%;
  176. max-width: 60em;
  177. position: relative;
  178. }
  179. #pingChart {
  180. width: 100%;
  181. height: 280px;
  182. border: 1px solid #e0e0e0;
  183. border-radius: 4px;
  184. background: #fafafa;
  185. }
  186. #optionalControls {
  187. margin: 1.5em auto;
  188. max-width: 40em;
  189. }
  190. #optionalControls label {
  191. display: block;
  192. margin: 0.5em 0;
  193. }
  194. #alertThreshold {
  195. vertical-align: middle;
  196. width: 200px;
  197. }
  198. #downloadCsvBtn {
  199. margin-top: 0.8em;
  200. font-size: 0.9em;
  201. padding: 0.4em 1em;
  202. cursor: pointer;
  203. border: 1px solid #6060aa;
  204. background: none;
  205. color: #6060aa;
  206. border-radius: 0.3em;
  207. }
  208. #downloadCsvBtn:hover {
  209. background: #6060aa;
  210. color: #ffffff;
  211. }
  212. .footer {
  213. margin: 2em 0 1em;
  214. }
  215. .footer a {
  216. color: #808080;
  217. font-size: 0.85em;
  218. }
  219. @media all and (max-width: 40em) {
  220. body {
  221. font-size: 0.8em;
  222. }
  223. #pingChart {
  224. height: 200px;
  225. }
  226. }
  227. @media all and (prefers-color-scheme: dark) {
  228. html,
  229. body {
  230. background: #202020;
  231. color: #f4f4f4;
  232. color-scheme: dark;
  233. }
  234. h1 {
  235. color: #e0e0e0;
  236. }
  237. nav a {
  238. color: #9090ff;
  239. }
  240. #pingChart {
  241. border-color: #404040;
  242. background: #1a1a1a;
  243. }
  244. a {
  245. color: #9090ff;
  246. }
  247. #startBtn {
  248. color: #9090ff;
  249. border-color: #7070ff;
  250. }
  251. #startBtn.disabled {
  252. color: #707070;
  253. border-color: #505050;
  254. }
  255. #startBtn.running {
  256. background-color: #ff3030;
  257. border-color: #ff6060;
  258. color: #ffffff;
  259. }
  260. #resetBtn {
  261. color: #a0a0a0;
  262. border-color: #606060;
  263. }
  264. #downloadCsvBtn {
  265. color: #9090ff;
  266. border-color: #9090ff;
  267. }
  268. #downloadCsvBtn:hover {
  269. background: #9090ff;
  270. color: #202020;
  271. }
  272. .footer a {
  273. color: #a0a0a0;
  274. }
  275. }
  276. </style>
  277. </head>
  278. <body>
  279. <h1>LibreSpeed - Stability Test</h1>
  280. <nav><a href="./">&larr; Speed Test</a></nav>
  281. <div id="controls">
  282. <select id="durationSelect">
  283. <option value="60">60 Seconds</option>
  284. <option value="90">90 Seconds</option>
  285. <option value="120">2 Minutes</option>
  286. <option value="180">3 Minutes</option>
  287. <option value="300">5 Minutes</option>
  288. </select>
  289. <select id="targetSelect" onchange="updateStartButtonState()">
  290. <option value="">Local Server</option>
  291. <option value="https://www.google.com/generate_204">Google</option>
  292. <option value="https://www.cloudflare.com/cdn-cgi/trace">Cloudflare</option>
  293. <option value="https://www.apple.com/library/test/success.html">Apple</option>
  294. </select>
  295. <div id="startBtn" class="disabled finding" onclick="startStop()" aria-disabled="true" title="Finding best server..."></div>
  296. <div id="resetBtn" onclick="resetTest()">Reset</div>
  297. </div>
  298. <div id="serverArea" style="display: none">
  299. Server:
  300. <select id="server" onchange="onServerChange(this.value)"></select>
  301. </div>
  302. <div id="rating" class="none">--</div>
  303. <div id="test">
  304. <div class="testGroup">
  305. <div class="testArea2">
  306. <div class="testName">Current</div>
  307. <div id="statCurrent" class="meterText" style="color: #aa6060"></div>
  308. <div class="unit">ms</div>
  309. </div>
  310. <div class="testArea2">
  311. <div class="testName">Average</div>
  312. <div id="statAvg" class="meterText" style="color: #aa6060"></div>
  313. <div class="unit">ms</div>
  314. </div>
  315. <div class="testArea2">
  316. <div class="testName">Jitter</div>
  317. <div id="statJitter" class="meterText" style="color: #aa6060"></div>
  318. <div class="unit">ms</div>
  319. </div>
  320. </div>
  321. <div class="testGroup">
  322. <div class="testArea2">
  323. <div class="testName">Min</div>
  324. <div id="statMin" class="meterText"></div>
  325. <div class="unit">ms</div>
  326. </div>
  327. <div class="testArea2">
  328. <div class="testName">Max</div>
  329. <div id="statMax" class="meterText"></div>
  330. <div class="unit">ms</div>
  331. </div>
  332. <div class="testArea2">
  333. <div class="testName">Failed Requests</div>
  334. <div id="statLoss" class="meterText"></div>
  335. <div class="unit">%</div>
  336. </div>
  337. <div class="testArea2">
  338. <div class="testName">Elapsed</div>
  339. <div id="statElapsed" class="meterText">00:00</div>
  340. <div class="unit"></div>
  341. </div>
  342. </div>
  343. </div>
  344. <div id="chartContainer">
  345. <canvas id="pingChart"></canvas>
  346. </div>
  347. <div id="optionalControls">
  348. <label>
  349. Alert threshold:
  350. <input
  351. type="range"
  352. id="alertThreshold"
  353. min="0"
  354. max="500"
  355. step="10"
  356. value="0"
  357. oninput="updateThreshold(this.value)"
  358. />
  359. <span id="thresholdValue">Off</span>
  360. </label>
  361. <button id="downloadCsvBtn" onclick="downloadCsv()">Download CSV</button>
  362. </div>
  363. <div class="footer">
  364. <a href="https://github.com/librespeed/speedtest">Source code</a>
  365. </div>
  366. <script type="text/javascript">
  367. function I(i) {
  368. return document.getElementById(i);
  369. }
  370. // Server configuration (same pattern as index.html)
  371. // Set this to a different URL to load the server list from another location.
  372. var SPEEDTEST_SERVERS = "server-list.json";
  373. // State
  374. var worker = null;
  375. var updater = null;
  376. var running = false;
  377. var allPingData = []; // full dataset for chart and CSV
  378. var latestData = null;
  379. var alertThresholdMs = 0;
  380. var lastBeepTime = 0;
  381. var audioCtx = null;
  382. var selectedServer = null;
  383. var localServerReady = false;
  384. var serverDiscoveryPending = false;
  385. // Dark mode detection for canvas colors
  386. var isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
  387. try {
  388. window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function (e) {
  389. isDark = e.matches;
  390. });
  391. } catch (e) {}
  392. // Chart constants
  393. var VISIBLE_SECONDS = 60;
  394. var CHART_PADDING_LEFT = 50;
  395. var CHART_PADDING_RIGHT = 15;
  396. var CHART_PADDING_TOP = 15;
  397. var CHART_PADDING_BOTTOM = 30;
  398. function joinServerUrl(server, path) {
  399. if (!server) return path;
  400. if (!path) return server;
  401. if (server.charAt(server.length - 1) === "/" || path.charAt(0) === "/") return server + path;
  402. return server + "/" + path;
  403. }
  404. // Load server list dynamically (Docker exposes server-list.json; older setups may expose servers.json)
  405. function loadServers(callback) {
  406. if (Array.isArray(SPEEDTEST_SERVERS)) {
  407. callback();
  408. return;
  409. }
  410. var serverListUrl = typeof SPEEDTEST_SERVERS === "string" ? SPEEDTEST_SERVERS : "";
  411. if (!serverListUrl) {
  412. SPEEDTEST_SERVERS = [];
  413. callback();
  414. return;
  415. }
  416. var xhr = new XMLHttpRequest();
  417. xhr.onload = function () {
  418. var loadedServers = null;
  419. try {
  420. var servers = JSON.parse(xhr.responseText);
  421. if (Array.isArray(servers)) {
  422. loadedServers = servers;
  423. }
  424. } catch (e) {}
  425. if (loadedServers !== null) {
  426. SPEEDTEST_SERVERS = loadedServers;
  427. callback();
  428. } else if (!xhr._fallbackTried) {
  429. xhr._fallbackTried = true;
  430. xhr.open("GET", "servers.json?r=" + Math.random());
  431. xhr.send();
  432. } else {
  433. SPEEDTEST_SERVERS = [];
  434. callback();
  435. }
  436. };
  437. xhr.onerror = function () {
  438. if (xhr._fallbackTried) {
  439. SPEEDTEST_SERVERS = [];
  440. callback();
  441. } else {
  442. xhr._fallbackTried = true;
  443. xhr.open("GET", "servers.json?r=" + Math.random());
  444. xhr.send();
  445. }
  446. };
  447. xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random());
  448. try {
  449. xhr.timeout = 2000;
  450. xhr.ontimeout = xhr.onerror;
  451. } catch (e) {}
  452. xhr.send();
  453. }
  454. // Server initialization
  455. function initServers() {
  456. selectedServer = null;
  457. localServerReady = false;
  458. serverDiscoveryPending = false;
  459. I("server").innerHTML = "";
  460. if (SPEEDTEST_SERVERS.length === 0) {
  461. I("serverArea").style.display = "none";
  462. localServerReady = true;
  463. updateStartButtonState();
  464. } else {
  465. I("serverArea").style.display = "";
  466. serverDiscoveryPending = true;
  467. I("server").disabled = true;
  468. var pendingOpt = document.createElement("option");
  469. pendingOpt.value = "";
  470. pendingOpt.textContent = "Finding best server...";
  471. I("server").appendChild(pendingOpt);
  472. updateStartButtonState();
  473. // ping servers to find best one
  474. var completed = 0;
  475. var best = null;
  476. for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) {
  477. (function (idx) {
  478. pingServer(SPEEDTEST_SERVERS[idx], function (server, rtt) {
  479. SPEEDTEST_SERVERS[idx].pingT = rtt;
  480. if (rtt > 0 && (best === null || rtt < best.pingT)) best = SPEEDTEST_SERVERS[idx];
  481. completed++;
  482. if (completed === SPEEDTEST_SERVERS.length) {
  483. // populate dropdown
  484. I("server").innerHTML = "";
  485. for (var j = 0; j < SPEEDTEST_SERVERS.length; j++) {
  486. if (SPEEDTEST_SERVERS[j].pingT <= 0) continue;
  487. var opt = document.createElement("option");
  488. opt.value = j;
  489. opt.textContent = SPEEDTEST_SERVERS[j].name;
  490. if (SPEEDTEST_SERVERS[j] === best) opt.selected = true;
  491. I("server").appendChild(opt);
  492. }
  493. selectedServer = best;
  494. serverDiscoveryPending = false;
  495. localServerReady = selectedServer !== null || I("server").options.length > 0;
  496. I("server").disabled = !localServerReady;
  497. updateStartButtonState();
  498. }
  499. });
  500. })(i);
  501. }
  502. }
  503. }
  504. function pingServer(server, callback) {
  505. var baseUrl = joinServerUrl(server.server, server.pingURL);
  506. var url = baseUrl + (baseUrl.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random();
  507. var xhr = new XMLHttpRequest();
  508. var t = new Date().getTime();
  509. xhr.onload = function () {
  510. callback(server, new Date().getTime() - t);
  511. };
  512. xhr.onerror = function () {
  513. callback(server, -1);
  514. };
  515. xhr.open("GET", url);
  516. try {
  517. xhr.timeout = 2000;
  518. xhr.ontimeout = xhr.onerror;
  519. } catch (e) {}
  520. xhr.send();
  521. }
  522. function onServerChange(idx) {
  523. if (idx === "") return;
  524. selectedServer = SPEEDTEST_SERVERS[idx];
  525. updateStartButtonState();
  526. }
  527. function canStartTest() {
  528. return !!I("targetSelect").value || localServerReady;
  529. }
  530. function updateStartButtonState() {
  531. if (running) return;
  532. var startBtn = I("startBtn");
  533. var canStart = canStartTest();
  534. startBtn.className = canStart ? "" : serverDiscoveryPending ? "disabled finding" : "disabled";
  535. startBtn.setAttribute("aria-disabled", canStart ? "false" : "true");
  536. startBtn.title = canStart ? "" : serverDiscoveryPending ? "Finding best server..." : "No reachable local server found";
  537. I("server").disabled = !localServerReady || serverDiscoveryPending;
  538. }
  539. // Start/Stop
  540. function startStop() {
  541. if (running) {
  542. // abort
  543. if (worker) worker.postMessage("abort");
  544. stopTest();
  545. } else {
  546. if (!canStartTest()) return;
  547. startTest();
  548. }
  549. }
  550. function startTest() {
  551. if (!canStartTest()) return;
  552. allPingData = [];
  553. latestData = null;
  554. resetUI();
  555. running = true;
  556. I("startBtn").className = "running";
  557. I("durationSelect").disabled = true;
  558. I("targetSelect").disabled = true;
  559. I("server").disabled = true;
  560. var externalTarget = I("targetSelect").value;
  561. var workerSettings = {
  562. duration: parseInt(I("durationSelect").value),
  563. ping_allowPerformanceApi: true,
  564. url_ping_external: externalTarget
  565. };
  566. if (!externalTarget && selectedServer) {
  567. workerSettings.url_ping = joinServerUrl(selectedServer.server, selectedServer.pingURL);
  568. workerSettings.mpot = true;
  569. }
  570. var currentWorker = new Worker("stability_worker.js?r=" + Math.random());
  571. worker = currentWorker;
  572. worker.onmessage = function (e) {
  573. if (worker !== currentWorker) return;
  574. var data = JSON.parse(e.data);
  575. latestData = data;
  576. // accumulate ping data
  577. if (data.pingData && data.pingData.length > 0) {
  578. for (var i = 0; i < data.pingData.length; i++) {
  579. allPingData.push(data.pingData[i]);
  580. }
  581. }
  582. // check alert threshold
  583. if (alertThresholdMs > 0 && data.currentPing > alertThresholdMs) {
  584. playBeep();
  585. }
  586. if (data.testState >= 4) {
  587. stopTest();
  588. }
  589. };
  590. updater = setInterval(function () {
  591. if (worker) worker.postMessage("status");
  592. }, 200);
  593. worker.postMessage("start " + JSON.stringify(workerSettings));
  594. }
  595. function stopTest() {
  596. running = false;
  597. I("startBtn").className = "";
  598. I("durationSelect").disabled = false;
  599. I("targetSelect").disabled = false;
  600. I("server").disabled = false;
  601. updateStartButtonState();
  602. if (updater) {
  603. clearInterval(updater);
  604. updater = null;
  605. }
  606. // request final status
  607. if (worker) {
  608. var stoppedWorker = worker;
  609. stoppedWorker.postMessage("status");
  610. setTimeout(function () {
  611. try {
  612. stoppedWorker.terminate();
  613. } catch (e) {}
  614. if (worker === stoppedWorker) {
  615. worker = null;
  616. }
  617. }, 500);
  618. }
  619. }
  620. function resetTest() {
  621. if (running) {
  622. if (worker) worker.postMessage("abort");
  623. stopTest();
  624. }
  625. allPingData = [];
  626. latestData = null;
  627. resetUI();
  628. drawChart();
  629. }
  630. function resetUI() {
  631. I("statCurrent").textContent = "";
  632. I("statAvg").textContent = "";
  633. I("statMin").textContent = "";
  634. I("statMax").textContent = "";
  635. I("statJitter").textContent = "";
  636. I("statLoss").textContent = "";
  637. I("statElapsed").textContent = "";
  638. I("rating").textContent = "--";
  639. I("rating").className = "none";
  640. }
  641. // Format helpers
  642. function fmt(v) {
  643. v = Number(v);
  644. if (isNaN(v) || v <= 0) return "--";
  645. if (v < 10) return v.toFixed(2);
  646. if (v < 100) return v.toFixed(1);
  647. return v.toFixed(0);
  648. }
  649. function fmtTime(sec) {
  650. var m = Math.floor(sec / 60);
  651. var s = Math.floor(sec % 60);
  652. return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
  653. }
  654. // Rating calculation
  655. function getRating(avg, jit, loss) {
  656. if (avg <= 0) return { text: "--", cls: "none" };
  657. if (avg < 30 && jit < 5 && loss < 0.5) return { text: "Great", cls: "great" };
  658. if (avg < 60 && jit < 15 && loss < 2) return { text: "Good", cls: "good" };
  659. if (avg < 100 && jit < 30 && loss < 5) return { text: "Poor", cls: "poor" };
  660. return { text: "Bad", cls: "bad" };
  661. }
  662. // UI update loop
  663. function updateUI() {
  664. if (!latestData) return;
  665. var d = latestData;
  666. I("statCurrent").textContent = fmt(d.currentPing);
  667. I("statAvg").textContent = fmt(d.avgPing);
  668. I("statMin").textContent = d.minPing > 0 ? fmt(d.minPing) : "--";
  669. I("statMax").textContent = fmt(d.maxPing);
  670. I("statJitter").textContent = fmt(d.jitter);
  671. I("statLoss").textContent = d.totalSamples > 0 ? d.packetLoss.toFixed(1) : "--";
  672. I("statElapsed").textContent = fmtTime(d.elapsed);
  673. var r = getRating(d.avgPing, d.jitter, d.packetLoss);
  674. I("rating").textContent = r.text;
  675. I("rating").className = r.cls;
  676. }
  677. // Canvas chart rendering
  678. function drawChart() {
  679. var canvas = I("pingChart");
  680. var ctx = canvas.getContext("2d");
  681. var dp = window.devicePixelRatio || 1;
  682. var cw = canvas.clientWidth * dp;
  683. var ch = canvas.clientHeight * dp;
  684. if (canvas.width !== cw || canvas.height !== ch) {
  685. canvas.width = cw;
  686. canvas.height = ch;
  687. }
  688. ctx.clearRect(0, 0, cw, ch);
  689. var padL = CHART_PADDING_LEFT * dp;
  690. var padR = CHART_PADDING_RIGHT * dp;
  691. var padT = CHART_PADDING_TOP * dp;
  692. var padB = CHART_PADDING_BOTTOM * dp;
  693. var plotW = cw - padL - padR;
  694. var plotH = ch - padT - padB;
  695. // colors based on theme
  696. var lineColor = isDark ? "#9090FF" : "#6060AA";
  697. var fillColor = isDark ? "rgba(144,144,255,0.15)" : "rgba(96,96,170,0.15)";
  698. var gridColor = isDark ? "#404040" : "#E0E0E0";
  699. var textColor = isDark ? "#A0A0A0" : "#808080";
  700. var lostColor = "#e74c3c";
  701. var thresholdColor = "rgba(231,76,60,0.6)";
  702. // determine visible time window
  703. var data = allPingData;
  704. var maxTime = 0;
  705. if (data.length > 0) maxTime = data[data.length - 1].t;
  706. var timeStart, timeEnd;
  707. if (maxTime <= VISIBLE_SECONDS) {
  708. timeStart = 0;
  709. timeEnd = Math.max(VISIBLE_SECONDS, maxTime);
  710. } else {
  711. timeEnd = maxTime;
  712. timeStart = maxTime - VISIBLE_SECONDS;
  713. }
  714. // binary search to find first visible data point
  715. var visStart = 0;
  716. var lo = 0,
  717. hi = data.length - 1;
  718. while (lo <= hi) {
  719. var mid = (lo + hi) >> 1;
  720. if (data[mid].t < timeStart) lo = mid + 1;
  721. else hi = mid - 1;
  722. }
  723. visStart = lo;
  724. // determine Y-axis scale from visible data only
  725. var yMax = 50; // minimum 50ms scale
  726. for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
  727. if (!data[i].lost && data[i].ping > yMax) yMax = data[i].ping;
  728. }
  729. yMax = Math.ceil((yMax * 1.2) / 10) * 10; // round up to nearest 10 with 20% headroom
  730. var fontSize = 11 * dp;
  731. ctx.font = fontSize + "px sans-serif";
  732. ctx.textBaseline = "middle";
  733. // grid lines - horizontal
  734. var ySteps = getYSteps(yMax);
  735. ctx.strokeStyle = gridColor;
  736. ctx.lineWidth = dp;
  737. ctx.fillStyle = textColor;
  738. ctx.textAlign = "right";
  739. for (var s = 0; s < ySteps.length; s++) {
  740. var yVal = ySteps[s];
  741. if (yVal > yMax) continue;
  742. var y = padT + plotH - (yVal / yMax) * plotH;
  743. ctx.beginPath();
  744. ctx.moveTo(padL, y);
  745. ctx.lineTo(padL + plotW, y);
  746. ctx.stroke();
  747. ctx.fillText(yVal + "", padL - 5 * dp, y);
  748. }
  749. // grid lines - vertical (time ticks)
  750. ctx.textAlign = "center";
  751. ctx.textBaseline = "top";
  752. var timeSpan = timeEnd - timeStart;
  753. var tickInterval = timeSpan <= 60 ? 10 : timeSpan <= 120 ? 15 : 30;
  754. var firstTick = Math.ceil(timeStart / tickInterval) * tickInterval;
  755. for (var t = firstTick; t <= timeEnd; t += tickInterval) {
  756. var x = padL + ((t - timeStart) / timeSpan) * plotW;
  757. ctx.beginPath();
  758. ctx.strokeStyle = gridColor;
  759. ctx.moveTo(x, padT);
  760. ctx.lineTo(x, padT + plotH);
  761. ctx.stroke();
  762. ctx.fillStyle = textColor;
  763. ctx.fillText(fmtTime(t), x, padT + plotH + 4 * dp);
  764. }
  765. // axis labels
  766. ctx.fillStyle = textColor;
  767. ctx.textAlign = "right";
  768. ctx.textBaseline = "top";
  769. ctx.fillText("ms", padL - 5 * dp, padT - 2 * dp);
  770. // plot border
  771. ctx.strokeStyle = gridColor;
  772. ctx.lineWidth = dp;
  773. ctx.strokeRect(padL, padT, plotW, plotH);
  774. // alert threshold line
  775. if (alertThresholdMs > 0 && alertThresholdMs <= yMax) {
  776. var threshY = padT + plotH - (alertThresholdMs / yMax) * plotH;
  777. ctx.save();
  778. ctx.setLineDash([6 * dp, 4 * dp]);
  779. ctx.strokeStyle = thresholdColor;
  780. ctx.lineWidth = 1.5 * dp;
  781. ctx.beginPath();
  782. ctx.moveTo(padL, threshY);
  783. ctx.lineTo(padL + plotW, threshY);
  784. ctx.stroke();
  785. ctx.restore();
  786. }
  787. if (data.length < 2) return;
  788. // filter visible data (using binary search start index)
  789. var visible = [];
  790. for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
  791. visible.push(data[i]);
  792. }
  793. if (visible.length < 1) return;
  794. // draw filled area
  795. ctx.beginPath();
  796. var started = false;
  797. for (var i = 0; i < visible.length; i++) {
  798. if (visible[i].lost) continue;
  799. var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
  800. var py = padT + plotH - (visible[i].ping / yMax) * plotH;
  801. if (!started) {
  802. ctx.moveTo(px, padT + plotH);
  803. ctx.lineTo(px, py);
  804. started = true;
  805. } else {
  806. ctx.lineTo(px, py);
  807. }
  808. }
  809. // close the fill path
  810. if (started) {
  811. // find last non-lost point
  812. for (var i = visible.length - 1; i >= 0; i--) {
  813. if (!visible[i].lost) {
  814. var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
  815. ctx.lineTo(px, padT + plotH);
  816. break;
  817. }
  818. }
  819. ctx.closePath();
  820. ctx.fillStyle = fillColor;
  821. ctx.fill();
  822. }
  823. // draw line
  824. ctx.beginPath();
  825. started = false;
  826. for (var i = 0; i < visible.length; i++) {
  827. if (visible[i].lost) {
  828. started = false;
  829. continue;
  830. }
  831. var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
  832. var py = padT + plotH - (visible[i].ping / yMax) * plotH;
  833. if (!started) {
  834. ctx.moveTo(px, py);
  835. started = true;
  836. } else {
  837. ctx.lineTo(px, py);
  838. }
  839. }
  840. ctx.strokeStyle = lineColor;
  841. ctx.lineWidth = 2 * dp;
  842. ctx.lineJoin = "round";
  843. ctx.stroke();
  844. // draw failed request markers
  845. for (var i = 0; i < visible.length; i++) {
  846. if (!visible[i].lost) continue;
  847. var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
  848. ctx.beginPath();
  849. ctx.arc(px, padT + plotH - 10 * dp, 3 * dp, 0, Math.PI * 2);
  850. ctx.fillStyle = lostColor;
  851. ctx.fill();
  852. }
  853. }
  854. function getYSteps(yMax) {
  855. // generate sensible Y-axis grid steps
  856. if (yMax <= 50) return [10, 20, 30, 40, 50];
  857. if (yMax <= 100) return [20, 40, 60, 80, 100];
  858. if (yMax <= 200) return [50, 100, 150, 200];
  859. if (yMax <= 500) return [100, 200, 300, 400, 500];
  860. // for very high values
  861. var step = Math.ceil(yMax / 5 / 100) * 100;
  862. var steps = [];
  863. for (var v = step; v <= yMax; v += step) steps.push(v);
  864. return steps;
  865. }
  866. // Animation frame loop
  867. window.requestAnimationFrame =
  868. window.requestAnimationFrame ||
  869. window.webkitRequestAnimationFrame ||
  870. window.mozRequestAnimationFrame ||
  871. window.msRequestAnimationFrame ||
  872. function (callback) {
  873. setTimeout(callback, 1000 / 60);
  874. };
  875. function frame() {
  876. requestAnimationFrame(frame);
  877. updateUI();
  878. drawChart();
  879. }
  880. frame();
  881. // Alert threshold
  882. function updateThreshold(val) {
  883. alertThresholdMs = parseInt(val);
  884. I("thresholdValue").textContent = alertThresholdMs > 0 ? alertThresholdMs + " ms" : "Off";
  885. }
  886. function playBeep() {
  887. var now = new Date().getTime();
  888. if (now - lastBeepTime < 1000) return; // debounce 1s
  889. lastBeepTime = now;
  890. try {
  891. if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  892. var osc = audioCtx.createOscillator();
  893. var gain = audioCtx.createGain();
  894. osc.frequency.value = 800;
  895. gain.gain.value = 0.3;
  896. osc.connect(gain);
  897. gain.connect(audioCtx.destination);
  898. osc.start();
  899. osc.stop(audioCtx.currentTime + 0.1);
  900. } catch (e) {}
  901. }
  902. // CSV download
  903. function downloadCsv() {
  904. if (allPingData.length === 0) return;
  905. var csv = "elapsed_s,ping_ms,failed\n";
  906. for (var i = 0; i < allPingData.length; i++) {
  907. var d = allPingData[i];
  908. csv += d.t.toFixed(3) + "," + d.ping.toFixed(2) + "," + (d.lost ? "1" : "0") + "\n";
  909. }
  910. var blob = new Blob([csv], { type: "text/csv" });
  911. var url = URL.createObjectURL(blob);
  912. var a = document.createElement("a");
  913. a.href = url;
  914. a.download = "stability_test_" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".csv";
  915. document.body.appendChild(a);
  916. a.click();
  917. document.body.removeChild(a);
  918. URL.revokeObjectURL(url);
  919. }
  920. // Init — load server list first (async), then initialize UI
  921. loadServers(function () {
  922. initServers();
  923. drawChart();
  924. });
  925. </script>
  926. </body>
  927. </html>