stability.html 27 KB

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