Kieran's opinionated (and probably slightly dumb) nix config
at main 19 kB view raw
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 font-size: 0.7rem; 189 font-weight: 500; 190 border-radius: 0; 191 margin-left: 0.25rem; 192 border: 1px solid; 193 } 194 195 .tunnel-url { 196 color: #8b949e; 197 font-size: 0.85rem; 198 } 199 200 .tunnel-url a { 201 color: #14b8a6; 202 text-decoration: none; 203 } 204 205 .tunnel-url a:hover { 206 text-decoration: underline; 207 } 208 209 .tunnel-status { 210 padding: 0.25rem 0.75rem; 211 border-radius: 0; 212 font-size: 0.8rem; 213 font-weight: 500; 214 } 215 216 .status-online { 217 background: rgba(20, 184, 166, 0.2); 218 color: #14b8a6; 219 border: 1px solid #14b8a6; 220 } 221 222 .empty-state { 223 text-align: center; 224 padding: 3rem 1rem; 225 color: #8b949e; 226 } 227 228 .empty-icon { 229 font-size: 3rem; 230 margin-bottom: 1rem; 231 opacity: 0.5; 232 } 233 234 code { 235 background: #0d1117; 236 padding: 0.2rem 0.5rem; 237 border-radius: 0; 238 color: #fb923c; 239 font-size: 0.9rem; 240 } 241 242 .usage { 243 background: #0d1117; 244 padding: 1rem; 245 border-radius: 0; 246 margin-top: 1rem; 247 } 248 249 .usage pre { 250 color: #8b949e; 251 font-size: 0.9rem; 252 overflow-x: auto; 253 } 254 255 .last-updated { 256 text-align: center; 257 color: #8b949e; 258 font-size: 0.8rem; 259 margin-top: 2rem; 260 padding: 2rem 0; 261 } 262 263 .last-updated a { 264 color: #14b8a6; 265 text-decoration: none; 266 } 267 268 .last-updated a:hover { 269 text-decoration: underline; 270 } 271 272 .offline-tunnels { 273 margin-top: 1.5rem; 274 padding-top: 1.5rem; 275 border-top: 1px solid #30363d; 276 } 277 278 .offline-tunnel { 279 padding: 0.5rem 0; 280 color: #8b949e; 281 font-size: 0.85rem; 282 display: flex; 283 justify-content: space-between; 284 align-items: center; 285 } 286 287 .offline-tunnel-name { 288 opacity: 0.6; 289 } 290 291 .offline-tunnel-stats { 292 font-size: 0.75rem; 293 opacity: 0.5; 294 } 295 </style> 296</head> 297 298<body class="loading"> 299 <div class="loading-bar"></div> 300 <main class="container"> 301 <header> 302 <h1>🚇 bore</h1> 303 <p class="subtitle">fancy tunnels @ terebithia</p> 304 </header> 305 306 <div class="stats"> 307 <div class="stat-card"> 308 <div class="stat-label">active tunnels</div> 309 <div class="stat-value" id="activeTunnels"></div> 310 </div> 311 <div class="stat-card"> 312 <div class="stat-label">active connections</div> 313 <div class="stat-value" id="totalConnections"></div> 314 </div> 315 <div class="stat-card"> 316 <div class="stat-label">server status</div> 317 <div class="stat-value orange" id="serverStatus"></div> 318 </div> 319 <div class="stat-card"> 320 <div class="stat-label">total upload</div> 321 <div class="stat-value" id="totalUpload"></div> 322 </div> 323 <div class="stat-card"> 324 <div class="stat-label">total download</div> 325 <div class="stat-value" id="totalDownload"></div> 326 </div> 327 </div> 328 329 <section class="section"> 330 <h2>~boreholes</h2> 331 <div class="tunnel-list" id="tunnelList"> 332 <div class="empty-state"> 333 <div class="empty-icon">🚇</div> 334 <p>no active tunnels</p> 335 </div> 336 </div> 337 </section> 338 </main> 339 340 <footer class="last-updated"> 341 last updated: <span id="lastUpdated">never</span><br> 342 made with ♥︎ by <a href="https://dunkirk.sh" target="_blank">kieran klukas</a> 343 </footer> 344 345 <script> 346 let fetchFailCount = 0; 347 const MAX_FAIL_COUNT = 3; 348 let lastProxiesState = null; 349 350 // Predefined color palette for labels 351 const labelColors = [ 352 { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.2)' }, // purple 353 { color: '#f472b6', bg: 'rgba(244, 114, 182, 0.2)' }, // pink 354 { color: '#facc15', bg: 'rgba(250, 204, 21, 0.2)' }, // yellow 355 { color: '#60a5fa', bg: 'rgba(96, 165, 250, 0.2)' }, // blue 356 { color: '#f87171', bg: 'rgba(248, 113, 113, 0.2)' }, // red 357 { color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.2)' }, // sky 358 { color: '#c084fc', bg: 'rgba(192, 132, 252, 0.2)' }, // violet 359 { color: '#fb7185', bg: 'rgba(251, 113, 133, 0.2)' }, // rose 360 ]; 361 362 // Hash string to index 363 function stringToColorIndex(str) { 364 let hash = 0; 365 for (let i = 0; i < str.length; i++) { 366 hash = str.charCodeAt(i) + ((hash << 5) - hash); 367 } 368 return Math.abs(hash) % labelColors.length; 369 } 370 371 // Get label color and styles 372 function getLabelStyle(label) { 373 const trimmedLabel = label.trim(); 374 if (trimmedLabel === 'prod') { 375 return { 376 color: '#22c55e', 377 bgColor: 'rgba(34, 197, 94, 0.2)', 378 borderColor: '#22c55e' 379 }; 380 } 381 382 if (trimmedLabel === 'dev') { 383 return { 384 color: '#fb923c', 385 bgColor: 'rgba(251, 146, 60, 0.2)', 386 borderColor: '#fb923c' 387 }; 388 } 389 390 const colorIndex = stringToColorIndex(trimmedLabel); 391 const colorScheme = labelColors[colorIndex]; 392 return { 393 color: colorScheme.color, 394 bgColor: colorScheme.bg, 395 borderColor: colorScheme.color 396 }; 397 } 398 399 async function fetchStats() { 400 try { 401 // Fetch server info 402 const serverResponse = await fetch('/api/serverinfo'); 403 if (!serverResponse.ok) throw new Error('API unavailable'); 404 const serverData = await serverResponse.json(); 405 406 // Fetch HTTP proxies (tunnels) 407 const proxiesResponse = await fetch('/api/proxy/http'); 408 const proxiesData = await proxiesResponse.json(); 409 410 // Reset fail count on success 411 fetchFailCount = 0; 412 413 // Update stats 414 document.getElementById('activeTunnels').textContent = serverData.clientCounts || 0; 415 document.getElementById('serverStatus').textContent = 'online'; 416 document.getElementById('totalConnections').textContent = serverData.curConns || 0; 417 document.getElementById('totalUpload').textContent = formatBytes(serverData.totalTrafficOut || 0); 418 document.getElementById('totalDownload').textContent = formatBytes(serverData.totalTrafficIn || 0); 419 420 // Update page title 421 const tunnelCount = serverData.clientCounts || 0; 422 const totalTraffic = formatBytes((serverData.totalTrafficIn || 0) + (serverData.totalTrafficOut || 0)); 423 document.title = tunnelCount > 0 424 ? `bore - ${tunnelCount} active • ${totalTraffic}` 425 : 'bore'; 426 427 // Check if tunnel list structure changed 428 const proxies = proxiesData.proxies || []; 429 const currentState = JSON.stringify(proxies.map(p => ({ name: p.name, status: p.status }))); 430 431 if (currentState !== lastProxiesState) { 432 // Structure changed, rebuild DOM 433 lastProxiesState = currentState; 434 renderTunnelList(proxies); 435 } else { 436 // Structure unchanged, just update data 437 updateTunnelData(proxies); 438 } 439 440 document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString(); 441 442 // Remove loading class after first successful fetch 443 document.body.classList.remove('loading'); 444 } catch (error) { 445 fetchFailCount++; 446 document.getElementById('serverStatus').textContent = 'offline'; 447 console.error('Failed to fetch stats:', error); 448 449 // Reload page if failed multiple times (server might have updated) 450 if (fetchFailCount >= MAX_FAIL_COUNT) { 451 console.log('Multiple fetch failures detected, reloading page...'); 452 window.location.reload(); 453 } 454 } 455 } 456 457 function renderTunnelList(proxies) { 458 const tunnelList = document.getElementById('tunnelList'); 459 const onlineTunnels = proxies.filter(p => p.status === 'online'); 460 const offlineTunnels = proxies.filter(p => p.status !== 'online'); 461 462 if (onlineTunnels.length === 0 && offlineTunnels.length === 0) { 463 tunnelList.innerHTML = ` 464 <div class="empty-state"> 465 <div class="empty-icon">🚇</div> 466 <p>no active tunnels</p> 467 </div> 468 `; 469 } else { 470 let html = ''; 471 472 // Render online tunnels 473 if (onlineTunnels.length > 0) { 474 html += onlineTunnels.map(proxy => { 475 const subdomain = proxy.conf?.subdomain || 'unknown'; 476 const url = `https://${subdomain}.bore.dunkirk.sh`; 477 478 // Parse labels from proxy name (format: subdomain[label1,label2]) 479 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 480 const labels = labelMatch ? labelMatch[1].split(',') : []; 481 const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 482 483 const labelHtml = labels.map(label => { 484 const trimmedLabel = label.trim(); 485 const style = getLabelStyle(trimmedLabel); 486 return `<span class="tunnel-label" style="color: ${style.color}; background: ${style.bgColor}; border-color: ${style.borderColor};">${trimmedLabel}</span>`; 487 }).join(''); 488 489 return ` 490 <div class="tunnel" data-tunnel="${proxy.name}"> 491 <div class="tunnel-info"> 492 <div class="tunnel-name"> 493 ${displayName || 'unnamed'} 494 ${labelHtml} 495 </div> 496 <div class="tunnel-url"> 497 <a href="${url}" target="_blank">${url}</a> 498 </div> 499 <div style="color: #8b949e; font-size: 0.75rem; margin-top: 0.25rem;"> 500 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> 501 </div> 502 </div> 503 <div class="tunnel-status status-online">online</div> 504 </div> 505 `; 506 }).join(''); 507 } 508 509 // Render offline tunnels 510 if (offlineTunnels.length > 0) { 511 html += '<div class="offline-tunnels">'; 512 html += '<div style="color: #8b949e; font-size: 0.85rem; margin-bottom: 0.75rem;">recently disconnected</div>'; 513 html += offlineTunnels.map(proxy => { 514 // Parse labels from proxy name (format: subdomain[label1,label2]) 515 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 516 const labels = labelMatch ? labelMatch[1].split(',').map(l => l.trim()) : []; 517 const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 518 const labelStr = labels.length > 0 ? ` [${labels.join(', ')}]` : ''; 519 520 if (!proxy.conf) { 521 return ` 522 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 523 <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}</span> 524 <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> 525 </div> 526 `; 527 } 528 529 const subdomain = proxy.conf.subdomain || 'unknown'; 530 const url = `https://${subdomain}.bore.dunkirk.sh`; 531 return ` 532 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 533 <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}${url}</span> 534 <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> 535 </div> 536 `; 537 }).join(''); 538 html += '</div>'; 539 } 540 541 tunnelList.innerHTML = html; 542 543 // Update all relative times 544 updateRelativeTimes(); 545 546 547 } 548 549 // Update data 550 updateTunnelData(proxies); 551 } 552 553 function updateTunnelData(proxies) { 554 proxies.forEach(proxy => { 555 const trafficInEl = document.querySelector(`[data-traffic-in="${proxy.name}"]`); 556 const trafficOutEl = document.querySelector(`[data-traffic-out="${proxy.name}"]`); 557 558 if (trafficInEl) trafficInEl.textContent = formatBytes(proxy.todayTrafficIn || 0); 559 if (trafficOutEl) trafficOutEl.textContent = formatBytes(proxy.todayTrafficOut || 0); 560 561 562 }); 563 564 // Update relative times 565 updateRelativeTimes(); 566 } 567 568 function formatBytes(bytes) { 569 if (bytes === 0) return '0 B'; 570 const k = 1024; 571 const sizes = ['B', 'KB', 'MB', 'GB']; 572 const i = Math.floor(Math.log(bytes) / Math.log(k)); 573 return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; 574 } 575 576 function formatTime(timeStr) { 577 // Input format: "12-08 20:15:20" (MM-DD HH:MM:SS) 578 const [datePart, timePart] = timeStr.split(' '); 579 const [month, day] = datePart.split('-'); 580 const [hour, minute, second] = timePart.split(':'); 581 582 const now = new Date(); 583 const inputDate = new Date(now.getFullYear(), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second)); 584 585 const diffInSeconds = (now.getTime() - inputDate.getTime()) / 1000; 586 const diffInMinutes = Math.round(diffInSeconds / 60); 587 const diffInHours = Math.round(diffInMinutes / 60); 588 589 if (diffInSeconds < 60) { 590 return 'just now'; 591 } else if (diffInHours < 1) { 592 return diffInMinutes === 1 ? '1 minute ago' : `${diffInMinutes} minutes ago`; 593 } else if (now.toDateString() === inputDate.toDateString()) { 594 return 'today'; 595 } else if ( 596 new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).toDateString() === 597 inputDate.toDateString() 598 ) { 599 return 'yesterday'; 600 } else { 601 return inputDate.toLocaleTimeString([], { 602 month: 'numeric', 603 day: 'numeric', 604 hour: 'numeric', 605 minute: 'numeric', 606 }); 607 } 608 } 609 610 function updateRelativeTimes() { 611 document.querySelectorAll('[data-start-time]').forEach(element => { 612 const timeStr = element.getAttribute('data-start-time'); 613 element.textContent = formatTime(timeStr); 614 }); 615 } 616 617 // Fetch immediately and then every 5 seconds 618 fetchStats(); 619 setInterval(fetchStats, 5000); 620 621 // Update relative times every 10 seconds 622 setInterval(updateRelativeTimes, 10000); 623 </script> 624</body> 625 626</html>