Kieran's opinionated (and probably slightly dumb) nix config
1<!DOCTYPE html> 2<html lang="en"> 3 4<head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <title>bore</title> 8 <meta name="description" content="bore - secure tunneling service for exposing local services to the internet"> 9 <meta property="og:title" content="bore - tunnel dashboard"> 10 <meta property="og:description" content="secure tunneling service powered by frp on bore.dunkirk.sh"> 11 <meta property="og:type" content="website"> 12 <meta property="og:url" content="https://bore.dunkirk.sh"> 13 <meta name="twitter:card" content="summary"> 14 <meta name="twitter:title" content="bore - tunnel dashboard"> 15 <meta name="twitter:description" content="secure tunneling service powered by frp on bore.dunkirk.sh"> 16 <link rel="icon" 17 href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚇</text></svg>"> 18 <style> 19 * { 20 margin: 0; 21 padding: 0; 22 box-sizing: border-box; 23 } 24 25 body { 26 font-family: 'SF Mono', 'Monaco', monospace; 27 background: #0d1117; 28 color: #e6edf3; 29 padding: 2rem; 30 line-height: 1.5; 31 min-height: 100vh; 32 display: flex; 33 flex-direction: column; 34 } 35 36 body.loading .container { 37 opacity: 0; 38 } 39 40 .loading-bar { 41 position: fixed; 42 top: 0; 43 left: 0; 44 width: 100%; 45 height: 3px; 46 background: transparent; 47 z-index: 9999; 48 overflow: hidden; 49 } 50 51 .loading-bar::before { 52 content: ''; 53 position: absolute; 54 top: 0; 55 left: 0; 56 width: 100%; 57 height: 100%; 58 background: linear-gradient(90deg, #14b8a6, #fb923c); 59 animation: loading 1.5s ease-in-out infinite; 60 } 61 62 body:not(.loading) .loading-bar { 63 display: none; 64 } 65 66 @keyframes loading { 67 0% { 68 transform: translateX(-100%); 69 } 70 50% { 71 transform: translateX(0%); 72 } 73 100% { 74 transform: translateX(100%); 75 } 76 } 77 78 .container { 79 max-width: 1200px; 80 margin: 0 auto; 81 flex: 1; 82 width: 100%; 83 opacity: 1; 84 transition: opacity 0.3s ease-in-out; 85 } 86 87 header { 88 margin-bottom: 3rem; 89 } 90 91 h1 { 92 font-size: 2.5rem; 93 background: linear-gradient(135deg, #14b8a6, #fb923c); 94 -webkit-background-clip: text; 95 -webkit-text-fill-color: transparent; 96 margin-bottom: 0.5rem; 97 } 98 99 .subtitle { 100 color: #8b949e; 101 font-size: 0.95rem; 102 } 103 104 .stats { 105 display: grid; 106 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 107 gap: 1rem; 108 margin-bottom: 2rem; 109 } 110 111 .stat-card { 112 background: #161b22; 113 border: 1px solid #30363d; 114 border-radius: 0; 115 padding: 1.5rem; 116 } 117 118 .stat-label { 119 color: #8b949e; 120 font-size: 0.85rem; 121 margin-bottom: 0.5rem; 122 } 123 124 .stat-value { 125 font-size: 2rem; 126 font-weight: 600; 127 color: #14b8a6; 128 } 129 130 .stat-value.orange { 131 color: #fb923c; 132 } 133 134 .section { 135 background: #161b22; 136 border: 1px solid #30363d; 137 border-radius: 0; 138 padding: 1.5rem; 139 margin-bottom: 2rem; 140 } 141 142 h2 { 143 color: #14b8a6; 144 font-size: 1.2rem; 145 margin-bottom: 1.5rem; 146 display: flex; 147 align-items: center; 148 gap: 0.5rem; 149 } 150 151 .tunnel-list { 152 display: flex; 153 flex-direction: column; 154 gap: 1rem; 155 } 156 157 .tunnel { 158 background: #0d1117; 159 border: 1px solid #30363d; 160 border-radius: 0; 161 padding: 1rem; 162 display: grid; 163 grid-template-columns: 1fr auto; 164 gap: 1rem; 165 align-items: center; 166 } 167 168 .tunnel-icon { 169 display: none; 170 } 171 172 .tunnel-info { 173 flex: 1; 174 } 175 176 .tunnel-name { 177 color: #e6edf3; 178 font-weight: 600; 179 margin-bottom: 0.25rem; 180 display: flex; 181 align-items: center; 182 gap: 0.5rem; 183 } 184 185 .tunnel-label { 186 display: inline-block; 187 padding: 0.125rem 0.5rem; 188 background: rgba(251, 146, 60, 0.2); 189 color: #fb923c; 190 border: 1px solid #fb923c; 191 font-size: 0.7rem; 192 font-weight: 500; 193 border-radius: 0; 194 } 195 196 .tunnel-url { 197 color: #8b949e; 198 font-size: 0.85rem; 199 } 200 201 .tunnel-url a { 202 color: #14b8a6; 203 text-decoration: none; 204 } 205 206 .tunnel-url a:hover { 207 text-decoration: underline; 208 } 209 210 .tunnel-status { 211 padding: 0.25rem 0.75rem; 212 border-radius: 0; 213 font-size: 0.8rem; 214 font-weight: 500; 215 } 216 217 .status-online { 218 background: rgba(20, 184, 166, 0.2); 219 color: #14b8a6; 220 border: 1px solid #14b8a6; 221 } 222 223 .empty-state { 224 text-align: center; 225 padding: 3rem 1rem; 226 color: #8b949e; 227 } 228 229 .empty-icon { 230 font-size: 3rem; 231 margin-bottom: 1rem; 232 opacity: 0.5; 233 } 234 235 code { 236 background: #0d1117; 237 padding: 0.2rem 0.5rem; 238 border-radius: 0; 239 color: #fb923c; 240 font-size: 0.9rem; 241 } 242 243 .usage { 244 background: #0d1117; 245 padding: 1rem; 246 border-radius: 0; 247 margin-top: 1rem; 248 } 249 250 .usage pre { 251 color: #8b949e; 252 font-size: 0.9rem; 253 overflow-x: auto; 254 } 255 256 .last-updated { 257 text-align: center; 258 color: #8b949e; 259 font-size: 0.8rem; 260 margin-top: 2rem; 261 padding: 2rem 0; 262 } 263 264 .last-updated a { 265 color: #14b8a6; 266 text-decoration: none; 267 } 268 269 .last-updated a:hover { 270 text-decoration: underline; 271 } 272 273 .offline-tunnels { 274 margin-top: 1.5rem; 275 padding-top: 1.5rem; 276 border-top: 1px solid #30363d; 277 } 278 279 .offline-tunnel { 280 padding: 0.5rem 0; 281 color: #8b949e; 282 font-size: 0.85rem; 283 display: flex; 284 justify-content: space-between; 285 align-items: center; 286 } 287 288 .offline-tunnel-name { 289 opacity: 0.6; 290 } 291 292 .offline-tunnel-stats { 293 font-size: 0.75rem; 294 opacity: 0.5; 295 } 296 </style> 297</head> 298 299<body class="loading"> 300 <div class="loading-bar"></div> 301 <main class="container"> 302 <header> 303 <h1>🚇 bore</h1> 304 <p class="subtitle">fancy tunnels @ terebithia</p> 305 </header> 306 307 <div class="stats"> 308 <div class="stat-card"> 309 <div class="stat-label">active tunnels</div> 310 <div class="stat-value" id="activeTunnels"></div> 311 </div> 312 <div class="stat-card"> 313 <div class="stat-label">active connections</div> 314 <div class="stat-value" id="totalConnections"></div> 315 </div> 316 <div class="stat-card"> 317 <div class="stat-label">server status</div> 318 <div class="stat-value orange" id="serverStatus"></div> 319 </div> 320 <div class="stat-card"> 321 <div class="stat-label">total upload</div> 322 <div class="stat-value" id="totalUpload"></div> 323 </div> 324 <div class="stat-card"> 325 <div class="stat-label">total download</div> 326 <div class="stat-value" id="totalDownload"></div> 327 </div> 328 </div> 329 330 <section class="section"> 331 <h2>~boreholes</h2> 332 <div class="tunnel-list" id="tunnelList"> 333 <div class="empty-state"> 334 <div class="empty-icon">🚇</div> 335 <p>no active tunnels</p> 336 </div> 337 </div> 338 </section> 339 </main> 340 341 <footer class="last-updated"> 342 last updated: <span id="lastUpdated">never</span><br> 343 made with ♥︎ by <a href="https://dunkirk.sh" target="_blank">kieran klukas</a> 344 </footer> 345 346 <script> 347 let fetchFailCount = 0; 348 const MAX_FAIL_COUNT = 3; 349 let lastProxiesState = null; 350 351 async function fetchStats() { 352 try { 353 // Fetch server info 354 const serverResponse = await fetch('/api/serverinfo'); 355 if (!serverResponse.ok) throw new Error('API unavailable'); 356 const serverData = await serverResponse.json(); 357 358 // Fetch HTTP proxies (tunnels) 359 const proxiesResponse = await fetch('/api/proxy/http'); 360 const proxiesData = await proxiesResponse.json(); 361 362 // Reset fail count on success 363 fetchFailCount = 0; 364 365 // Update stats 366 document.getElementById('activeTunnels').textContent = serverData.clientCounts || 0; 367 document.getElementById('serverStatus').textContent = 'online'; 368 document.getElementById('totalConnections').textContent = serverData.curConns || 0; 369 document.getElementById('totalUpload').textContent = formatBytes(serverData.totalTrafficOut || 0); 370 document.getElementById('totalDownload').textContent = formatBytes(serverData.totalTrafficIn || 0); 371 372 // Update page title 373 const tunnelCount = serverData.clientCounts || 0; 374 const totalTraffic = formatBytes((serverData.totalTrafficIn || 0) + (serverData.totalTrafficOut || 0)); 375 document.title = tunnelCount > 0 376 ? `bore - ${tunnelCount} active • ${totalTraffic}` 377 : 'bore'; 378 379 // Check if tunnel list structure changed 380 const proxies = proxiesData.proxies || []; 381 const currentState = JSON.stringify(proxies.map(p => ({ name: p.name, status: p.status }))); 382 383 if (currentState !== lastProxiesState) { 384 // Structure changed, rebuild DOM 385 lastProxiesState = currentState; 386 renderTunnelList(proxies); 387 } else { 388 // Structure unchanged, just update data 389 updateTunnelData(proxies); 390 } 391 392 document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString(); 393 394 // Remove loading class after first successful fetch 395 document.body.classList.remove('loading'); 396 } catch (error) { 397 fetchFailCount++; 398 document.getElementById('serverStatus').textContent = 'offline'; 399 console.error('Failed to fetch stats:', error); 400 401 // Reload page if failed multiple times (server might have updated) 402 if (fetchFailCount >= MAX_FAIL_COUNT) { 403 console.log('Multiple fetch failures detected, reloading page...'); 404 window.location.reload(); 405 } 406 } 407 } 408 409 function renderTunnelList(proxies) { 410 const tunnelList = document.getElementById('tunnelList'); 411 const onlineTunnels = proxies.filter(p => p.status === 'online'); 412 const offlineTunnels = proxies.filter(p => p.status !== 'online'); 413 414 if (onlineTunnels.length === 0 && offlineTunnels.length === 0) { 415 tunnelList.innerHTML = ` 416 <div class="empty-state"> 417 <div class="empty-icon">🚇</div> 418 <p>no active tunnels</p> 419 </div> 420 `; 421 } else { 422 let html = ''; 423 424 // Render online tunnels 425 if (onlineTunnels.length > 0) { 426 html += onlineTunnels.map(proxy => { 427 const subdomain = proxy.conf?.subdomain || 'unknown'; 428 const url = `https://${subdomain}.bore.dunkirk.sh`; 429 430 // Parse label from proxy name (format: subdomain[label]) 431 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 432 const label = labelMatch ? labelMatch[1] : null; 433 const displayName = label ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 434 435 return ` 436 <div class="tunnel" data-tunnel="${proxy.name}"> 437 <div class="tunnel-info"> 438 <div class="tunnel-name"> 439 ${displayName || 'unnamed'} 440 ${label ? `<span class="tunnel-label">${label}</span>` : ''} 441 </div> 442 <div class="tunnel-url"> 443 <a href="${url}" target="_blank">${url}</a> 444 </div> 445 <div style="color: #8b949e; font-size: 0.75rem; margin-top: 0.25rem;"> 446 started: <span data-start-time="${proxy.lastStartTime || ''}"></span> • traffic in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span> 447 </div> 448 </div> 449 <div class="tunnel-status status-online">online</div> 450 </div> 451 `; 452 }).join(''); 453 } 454 455 // Render offline tunnels 456 if (offlineTunnels.length > 0) { 457 html += '<div class="offline-tunnels">'; 458 html += '<div style="color: #8b949e; font-size: 0.85rem; margin-bottom: 0.75rem;">recently disconnected</div>'; 459 html += offlineTunnels.map(proxy => { 460 // Parse label from proxy name (format: subdomain[label]) 461 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 462 const label = labelMatch ? labelMatch[1] : null; 463 const displayName = label ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 464 465 if (!proxy.conf) { 466 return ` 467 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 468 <span class="offline-tunnel-name">${displayName || 'unnamed'}${label ? ` [${label}]` : ''}</span> 469 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span> 470 </div> 471 `; 472 } 473 474 const subdomain = proxy.conf.subdomain || 'unknown'; 475 const url = `https://${subdomain}.bore.dunkirk.sh`; 476 return ` 477 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 478 <span class="offline-tunnel-name">${displayName || 'unnamed'}${label ? ` [${label}]` : ''}${url}</span> 479 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span> 480 </div> 481 `; 482 }).join(''); 483 html += '</div>'; 484 } 485 486 tunnelList.innerHTML = html; 487 488 // Update all relative times 489 updateRelativeTimes(); 490 491 492 } 493 494 // Update data 495 updateTunnelData(proxies); 496 } 497 498 function updateTunnelData(proxies) { 499 proxies.forEach(proxy => { 500 const trafficInEl = document.querySelector(`[data-traffic-in="${proxy.name}"]`); 501 const trafficOutEl = document.querySelector(`[data-traffic-out="${proxy.name}"]`); 502 503 if (trafficInEl) trafficInEl.textContent = formatBytes(proxy.todayTrafficIn || 0); 504 if (trafficOutEl) trafficOutEl.textContent = formatBytes(proxy.todayTrafficOut || 0); 505 506 507 }); 508 509 // Update relative times 510 updateRelativeTimes(); 511 } 512 513 function formatBytes(bytes) { 514 if (bytes === 0) return '0 B'; 515 const k = 1024; 516 const sizes = ['B', 'KB', 'MB', 'GB']; 517 const i = Math.floor(Math.log(bytes) / Math.log(k)); 518 return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; 519 } 520 521 function formatTime(timeStr) { 522 // Input format: "12-08 20:15:20" (MM-DD HH:MM:SS) 523 const [datePart, timePart] = timeStr.split(' '); 524 const [month, day] = datePart.split('-'); 525 const [hour, minute, second] = timePart.split(':'); 526 527 const now = new Date(); 528 const inputDate = new Date(now.getFullYear(), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second)); 529 530 const diffInSeconds = (now.getTime() - inputDate.getTime()) / 1000; 531 const diffInMinutes = Math.round(diffInSeconds / 60); 532 const diffInHours = Math.round(diffInMinutes / 60); 533 534 if (diffInSeconds < 60) { 535 return 'just now'; 536 } else if (diffInHours < 1) { 537 return diffInMinutes === 1 ? '1 minute ago' : `${diffInMinutes} minutes ago`; 538 } else if (now.toDateString() === inputDate.toDateString()) { 539 return 'today'; 540 } else if ( 541 new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).toDateString() === 542 inputDate.toDateString() 543 ) { 544 return 'yesterday'; 545 } else { 546 return inputDate.toLocaleTimeString([], { 547 month: 'numeric', 548 day: 'numeric', 549 hour: 'numeric', 550 minute: 'numeric', 551 }); 552 } 553 } 554 555 function updateRelativeTimes() { 556 document.querySelectorAll('[data-start-time]').forEach(element => { 557 const timeStr = element.getAttribute('data-start-time'); 558 element.textContent = formatTime(timeStr); 559 }); 560 } 561 562 // Fetch immediately and then every 5 seconds 563 fetchStats(); 564 setInterval(fetchStats, 5000); 565 566 // Update relative times every 10 seconds 567 setInterval(updateRelativeTimes, 10000); 568 </script> 569</body> 570 571</html>