Monorepo for Tangled tangled.org

Add knot server status indicator with server-side fallback #1085

Summary#

  • Add a knot status indicator that polls each unique knot server every 60s and shows a colored dot (amber=checking, green=online, red=offline) next to repo links and knot domain names
  • Hovering the indicator shows a popover with status details and a link to the knot URL
  • Clicking a repo link while its knot is offline shows a toast warning on the first click; the second click navigates normally
  • Hybrid check strategy: client tries CORS first (fast path, no server load); on failure, falls back to a new appview /knot-status endpoint that probes server-side via the tangled.Owner XRPC call
  • CORS-capable knots are remembered across poll cycles so subsequent checks skip the server entirely

Motivation#

Ephemeral knot servers (e.g. running on a laptop, https://smart-knowledge-systems.com/knot) go offline when the machine sleeps. Currently, there's no indication that a knot is down — clicking a repo link hangs. This gives users a heads-up before they click.

Standard knot servers don't send CORS headers, so browser-only fetch with mode: "cors" can't distinguish "offline" from "no CORS". The server-side fallback resolves this ambiguity while giving knot operators a migration path: add CORS headers for direct checks, or rely on the appview proxy.

Changes#

  • appview/state/knot_status.go — new file: TTL cache (30s), probeKnot() using tangled.Owner XRPC with 5s timeout, GET /knot-status?domains=... handler with SSRF prevention (only registered knots are probed), concurrent probing, JSON response with Cache-Control: public, max-age=15
  • appview/state/state.go — add knotStatusCache field to State struct, initialize in Make()
  • appview/state/router.go — register GET /knot-status route (no auth middleware)
  • appview/pages/templates/fragments/knotStatus.html — hybrid CORS-first + server fallback JS: try direct fetch, remember CORS-capable domains, batch-fallback to /knot-status for failures
  • appview/pages/templates/layouts/base.html — include the knot status script before </body>
  • appview/pages/templates/user/fragments/repoCard.html — show indicator on repo cards
  • appview/pages/templates/layouts/repobase.html — show indicator in repo page header
  • appview/pages/templates/knots/fragments/knotListing.html — show indicator on settings/knots page

Test plan#

  • Visit a profile page with repos on an ephemeral knot — verify dot appears next to repo names
  • Turn off the knot server — verify dot turns red within 60s
  • Hover the dot or repo link — verify popover shows status and knot URL
  • Click a repo link while knot is offline — verify toast warning appears
  • Click again — verify navigation proceeds
  • Turn knot back on — verify dot turns green within 60s
  • Visit a repo page — verify indicator in the repo header
  • Visit settings/knots — verify indicator next to knot domains
  • Verify polling pauses when tab is hidden, resumes when focused
  • Test knot with CORS support — should resolve client-side, no /knot-status call in network tab
  • Test knot without CORS — CORS fails silently, falls back to /knot-status, shows correct status
  • Test unregistered domain in /knot-status — returns "unknown", no outbound probe
  • Verify caching: second /knot-status request within 30s returns instantly without re-probing
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:i2fgba5nignuw4nccml33wjp/sh.tangled.repo.pull/3mfhtdyyxnm22
+725 -3
Diff #0
+539
appview/pages/templates/fragments/knotStatus.html
··· 1 + {{ define "fragments/knotStatus" }} 2 + <span class="knot-status" data-knot="{{ . }}"> 3 + <span class="knot-dot knot-checking" aria-hidden="true"></span> 4 + <span class="knot-tip"> 5 + <span class="knot-tip-status"> 6 + <span class="knot-tip-dot knot-checking"></span> 7 + <span class="knot-tip-msg">Checking...</span> 8 + </span> 9 + <a class="knot-tip-url" href="https://{{ . }}" target="_blank" rel="noopener">{{ . }}</a> 10 + </span> 11 + </span> 12 + {{ end }} 13 + 14 + {{ define "fragments/knotStatusScript" }} 15 + <style> 16 + /* ── knot status indicator ── */ 17 + .knot-status-group { 18 + display: inline-flex; 19 + align-items: center; 20 + position: relative; 21 + max-width: 100%; 22 + } 23 + 24 + .knot-status { 25 + display: inline-flex; 26 + align-items: center; 27 + margin-left: 6px; 28 + position: relative; 29 + } 30 + 31 + .knot-dot { 32 + width: 7px; 33 + height: 7px; 34 + border-radius: 50%; 35 + display: block; 36 + flex-shrink: 0; 37 + } 38 + 39 + /* ── dot states ── */ 40 + .knot-dot.knot-checking { 41 + background: #d08c1a; 42 + box-shadow: 0 0 0 0 rgba(208, 140, 26, 0.35); 43 + animation: knot-pulse-amber 2.4s ease-in-out infinite; 44 + } 45 + 46 + .knot-dot.knot-online { 47 + background: #40a02b; 48 + box-shadow: none; 49 + animation: none; 50 + } 51 + 52 + .knot-dot.knot-offline { 53 + background: #d20f39; 54 + box-shadow: 0 0 0 0 rgba(210, 15, 57, 0.3); 55 + animation: knot-pulse-red 3s ease-in-out infinite; 56 + } 57 + 58 + @media (prefers-color-scheme: dark) { 59 + .knot-dot.knot-checking { 60 + background: #e2b050; 61 + box-shadow: 0 0 0 0 rgba(226, 176, 80, 0.3); 62 + } 63 + .knot-dot.knot-online { 64 + background: #a6da95; 65 + } 66 + .knot-dot.knot-offline { 67 + background: #ed8796; 68 + box-shadow: 0 0 0 0 rgba(237, 135, 150, 0.25); 69 + } 70 + } 71 + 72 + @keyframes knot-pulse-amber { 73 + 0%, 100% { box-shadow: 0 0 0 0 rgba(208, 140, 26, 0); } 74 + 50% { box-shadow: 0 0 0 3px rgba(208, 140, 26, 0.25); } 75 + } 76 + 77 + @keyframes knot-pulse-red { 78 + 0%, 100% { box-shadow: 0 0 0 0 rgba(210, 15, 57, 0); } 79 + 50% { box-shadow: 0 0 0 3px rgba(210, 15, 57, 0.2); } 80 + } 81 + 82 + @media (prefers-color-scheme: dark) { 83 + @keyframes knot-pulse-amber { 84 + 0%, 100% { box-shadow: 0 0 0 0 rgba(226, 176, 80, 0); } 85 + 50% { box-shadow: 0 0 0 3px rgba(226, 176, 80, 0.2); } 86 + } 87 + @keyframes knot-pulse-red { 88 + 0%, 100% { box-shadow: 0 0 0 0 rgba(237, 135, 150, 0); } 89 + 50% { box-shadow: 0 0 0 3px rgba(237, 135, 150, 0.15); } 90 + } 91 + } 92 + 93 + /* ── popover ── */ 94 + .knot-tip { 95 + display: none; 96 + position: absolute; 97 + bottom: calc(100% + 8px); 98 + left: 50%; 99 + transform: translateX(-50%); 100 + z-index: 50; 101 + min-width: 160px; 102 + padding: 8px 10px; 103 + border-radius: 4px; 104 + font-size: 12px; 105 + line-height: 1.4; 106 + white-space: nowrap; 107 + pointer-events: none; 108 + opacity: 0; 109 + background: #ffffff; 110 + border: 1px solid #d1d5db; 111 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); 112 + } 113 + 114 + @media (prefers-color-scheme: dark) { 115 + .knot-tip { 116 + background: #1e2030; 117 + border-color: #3b3f54; 118 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); 119 + } 120 + } 121 + 122 + .knot-tip::after { 123 + content: ""; 124 + position: absolute; 125 + top: 100%; 126 + left: 50%; 127 + transform: translateX(-50%); 128 + border: 5px solid transparent; 129 + border-top-color: #d1d5db; 130 + } 131 + 132 + .knot-tip::before { 133 + content: ""; 134 + position: absolute; 135 + top: 100%; 136 + left: 50%; 137 + transform: translateX(-50%); 138 + border: 4px solid transparent; 139 + border-top-color: #ffffff; 140 + z-index: 1; 141 + } 142 + 143 + @media (prefers-color-scheme: dark) { 144 + .knot-tip::after { 145 + border-top-color: #3b3f54; 146 + } 147 + .knot-tip::before { 148 + border-top-color: #1e2030; 149 + } 150 + } 151 + 152 + .knot-status-group:hover .knot-tip, 153 + .knot-status:hover .knot-tip { 154 + display: flex; 155 + flex-direction: column; 156 + gap: 4px; 157 + pointer-events: auto; 158 + animation: knot-tip-in 0.15s ease-out forwards; 159 + } 160 + 161 + @keyframes knot-tip-in { 162 + from { opacity: 0; transform: translateX(-50%) translateY(2px); } 163 + to { opacity: 1; transform: translateX(-50%) translateY(0); } 164 + } 165 + 166 + .knot-tip-status { 167 + display: flex; 168 + align-items: center; 169 + gap: 6px; 170 + color: #4c4f69; 171 + } 172 + 173 + @media (prefers-color-scheme: dark) { 174 + .knot-tip-status { color: #cad3f5; } 175 + } 176 + 177 + .knot-tip-dot { 178 + width: 6px; 179 + height: 6px; 180 + border-radius: 50%; 181 + flex-shrink: 0; 182 + } 183 + 184 + .knot-tip-dot.knot-checking { background: #d08c1a; } 185 + .knot-tip-dot.knot-online { background: #40a02b; } 186 + .knot-tip-dot.knot-offline { background: #d20f39; } 187 + 188 + @media (prefers-color-scheme: dark) { 189 + .knot-tip-dot.knot-checking { background: #e2b050; } 190 + .knot-tip-dot.knot-online { background: #a6da95; } 191 + .knot-tip-dot.knot-offline { background: #ed8796; } 192 + } 193 + 194 + .knot-tip-url { 195 + font-family: "IBMPlexMono", monospace; 196 + font-size: 11px; 197 + color: #7c7f93 !important; 198 + text-decoration: none !important; 199 + } 200 + 201 + .knot-tip-url:hover { 202 + color: #4c4f69 !important; 203 + text-decoration: underline !important; 204 + } 205 + 206 + @media (prefers-color-scheme: dark) { 207 + .knot-tip-url { color: #6e738d !important; } 208 + .knot-tip-url:hover { color: #b8c0e0 !important; } 209 + } 210 + 211 + /* ── toast ── */ 212 + .knot-offline-toast { 213 + position: fixed; 214 + bottom: 24px; 215 + left: 50%; 216 + transform: translateX(-50%); 217 + z-index: 9999; 218 + padding: 10px 16px; 219 + border-radius: 4px; 220 + font-size: 13px; 221 + line-height: 1.4; 222 + max-width: 420px; 223 + text-align: center; 224 + color: #4c4f69; 225 + background: #ffffff; 226 + border: 1px solid #d1d5db; 227 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06); 228 + animation: knot-toast-in 0.25s ease-out forwards; 229 + } 230 + 231 + @media (prefers-color-scheme: dark) { 232 + .knot-offline-toast { 233 + color: #cad3f5; 234 + background: #1e2030; 235 + border-color: #3b3f54; 236 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.25); 237 + } 238 + } 239 + 240 + .knot-offline-toast.knot-toast-out { 241 + animation: knot-toast-out 0.2s ease-in forwards; 242 + } 243 + 244 + @keyframes knot-toast-in { 245 + from { opacity: 0; transform: translateX(-50%) translateY(8px); } 246 + to { opacity: 1; transform: translateX(-50%) translateY(0); } 247 + } 248 + 249 + @keyframes knot-toast-out { 250 + from { opacity: 1; transform: translateX(-50%) translateY(0); } 251 + to { opacity: 0; transform: translateX(-50%) translateY(8px); } 252 + } 253 + 254 + .knot-toast-warn { 255 + color: #d08c1a; 256 + font-weight: 600; 257 + } 258 + 259 + @media (prefers-color-scheme: dark) { 260 + .knot-toast-warn { color: #e2b050; } 261 + } 262 + </style> 263 + 264 + <script> 265 + (function() { 266 + "use strict"; 267 + 268 + var POLL_INTERVAL = 60000; 269 + var INITIAL_DELAY = 2000; 270 + var FETCH_TIMEOUT = 5000; 271 + var WARN_RESET = 5000; 272 + var TOAST_DURATION = 4000; 273 + 274 + // domain -> "checking" | "online" | "offline" 275 + var knotStates = {}; 276 + // domains known to support CORS (persists across poll cycles) 277 + var corsDomains = {}; 278 + var pollTimer = null; 279 + var toastEl = null; 280 + var toastTimer = null; 281 + 282 + function getAllIndicators() { 283 + return document.querySelectorAll("[data-knot]"); 284 + } 285 + 286 + function getUniqueDomains() { 287 + var domains = {}; 288 + getAllIndicators().forEach(function(el) { 289 + var d = el.getAttribute("data-knot"); 290 + if (d) domains[d] = true; 291 + }); 292 + return Object.keys(domains); 293 + } 294 + 295 + function updateIndicators(domain, state) { 296 + knotStates[domain] = state; 297 + document.querySelectorAll('[data-knot="' + domain + '"]').forEach(function(el) { 298 + var dot = el.querySelector(".knot-dot"); 299 + var tipDot = el.querySelector(".knot-tip-dot"); 300 + var msg = el.querySelector(".knot-tip-msg"); 301 + if (dot) { 302 + dot.classList.remove("knot-checking", "knot-online", "knot-offline"); 303 + dot.classList.add("knot-" + state); 304 + } 305 + if (tipDot) { 306 + tipDot.classList.remove("knot-checking", "knot-online", "knot-offline"); 307 + tipDot.classList.add("knot-" + state); 308 + } 309 + if (msg) { 310 + if (state === "checking") msg.textContent = "Checking..."; 311 + else if (state === "online") msg.textContent = "Knot is online"; 312 + else msg.textContent = "Knot may be offline"; 313 + } 314 + }); 315 + } 316 + 317 + function tryCors(domain) { 318 + return new Promise(function(resolve) { 319 + var controller = new AbortController(); 320 + var timer = setTimeout(function() { controller.abort(); }, FETCH_TIMEOUT); 321 + fetch("https://" + domain, { 322 + method: "HEAD", 323 + mode: "cors", 324 + signal: controller.signal 325 + }).then(function() { 326 + clearTimeout(timer); 327 + resolve(true); 328 + }).catch(function() { 329 + clearTimeout(timer); 330 + resolve(false); 331 + }); 332 + }); 333 + } 334 + 335 + function fetchServerStatus(domains) { 336 + return fetch("/knot-status?domains=" + encodeURIComponent(domains.join(","))) 337 + .then(function(res) { return res.json(); }) 338 + .then(function(data) { return data.statuses || {}; }); 339 + } 340 + 341 + function pollAll() { 342 + var allDomains = getUniqueDomains(); 343 + if (allDomains.length === 0) return; 344 + 345 + var knownCors = []; 346 + var unknown = []; 347 + allDomains.forEach(function(d) { 348 + if (corsDomains[d]) { 349 + knownCors.push(d); 350 + } else { 351 + unknown.push(d); 352 + } 353 + }); 354 + 355 + var fallback = []; 356 + 357 + // check known CORS domains directly 358 + var corsPromises = knownCors.map(function(d) { 359 + return tryCors(d).then(function(ok) { 360 + if (ok) { 361 + updateIndicators(d, "online"); 362 + } else { 363 + // CORS may have stopped working, fall back to server 364 + delete corsDomains[d]; 365 + fallback.push(d); 366 + } 367 + }); 368 + }); 369 + 370 + // try CORS on unknown domains 371 + var unknownPromises = unknown.map(function(d) { 372 + return tryCors(d).then(function(ok) { 373 + if (ok) { 374 + corsDomains[d] = true; 375 + updateIndicators(d, "online"); 376 + } else { 377 + fallback.push(d); 378 + } 379 + }); 380 + }); 381 + 382 + Promise.all(corsPromises.concat(unknownPromises)).then(function() { 383 + if (fallback.length === 0) return; 384 + 385 + fetchServerStatus(fallback).then(function(statuses) { 386 + fallback.forEach(function(d) { 387 + var s = statuses[d]; 388 + if (s === "online" || s === "offline") { 389 + updateIndicators(d, s); 390 + } else { 391 + updateIndicators(d, "offline"); 392 + } 393 + }); 394 + }).catch(function() { 395 + // server unreachable, mark all fallback domains as offline 396 + fallback.forEach(function(d) { 397 + updateIndicators(d, "offline"); 398 + }); 399 + }); 400 + }); 401 + } 402 + 403 + function startPolling() { 404 + if (pollTimer) return; 405 + pollAll(); 406 + pollTimer = setInterval(pollAll, POLL_INTERVAL); 407 + } 408 + 409 + function stopPolling() { 410 + if (pollTimer) { 411 + clearInterval(pollTimer); 412 + pollTimer = null; 413 + } 414 + } 415 + 416 + function showToast(text) { 417 + dismissToast(); 418 + var el = document.createElement("div"); 419 + el.className = "knot-offline-toast"; 420 + var warn = document.createElement("span"); 421 + warn.className = "knot-toast-warn"; 422 + warn.textContent = "Knot offline."; 423 + el.appendChild(warn); 424 + el.appendChild(document.createTextNode(" " + text)); 425 + document.body.appendChild(el); 426 + toastEl = el; 427 + toastTimer = setTimeout(dismissToast, TOAST_DURATION); 428 + } 429 + 430 + function dismissToast() { 431 + if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } 432 + if (toastEl) { 433 + var el = toastEl; 434 + toastEl = null; 435 + el.classList.add("knot-toast-out"); 436 + setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 200); 437 + } 438 + } 439 + 440 + function handleClick(e) { 441 + var link = e.target.closest("a[data-knot-link]"); 442 + if (!link) return; 443 + var group = link.closest(".knot-status-group"); 444 + if (!group) return; 445 + var statusEl = group.querySelector("[data-knot]"); 446 + if (!statusEl) return; 447 + var domain = statusEl.getAttribute("data-knot"); 448 + if (knotStates[domain] !== "offline") return; 449 + 450 + if (link.getAttribute("data-knot-warned")) return; 451 + 452 + e.preventDefault(); 453 + e.stopPropagation(); 454 + link.setAttribute("data-knot-warned", "1"); 455 + showToast("Click the link again to try anyway."); 456 + setTimeout(function() { link.removeAttribute("data-knot-warned"); }, WARN_RESET); 457 + } 458 + 459 + function checkNewDomains(domains) { 460 + var newDomains = []; 461 + domains.forEach(function(d) { 462 + if (!knotStates[d]) { 463 + knotStates[d] = "checking"; 464 + newDomains.push(d); 465 + } else { 466 + updateIndicators(d, knotStates[d]); 467 + } 468 + }); 469 + 470 + if (newDomains.length === 0) return; 471 + 472 + // try CORS first, then server fallback for failures 473 + var fallback = []; 474 + var promises = newDomains.map(function(d) { 475 + return tryCors(d).then(function(ok) { 476 + if (ok) { 477 + corsDomains[d] = true; 478 + updateIndicators(d, "online"); 479 + } else { 480 + fallback.push(d); 481 + } 482 + }); 483 + }); 484 + 485 + Promise.all(promises).then(function() { 486 + if (fallback.length === 0) return; 487 + 488 + fetchServerStatus(fallback).then(function(statuses) { 489 + fallback.forEach(function(d) { 490 + var s = statuses[d]; 491 + if (s === "online" || s === "offline") { 492 + updateIndicators(d, s); 493 + } else { 494 + updateIndicators(d, "offline"); 495 + } 496 + }); 497 + }).catch(function() { 498 + fallback.forEach(function(d) { 499 + updateIndicators(d, "offline"); 500 + }); 501 + }); 502 + }); 503 + } 504 + 505 + function init() { 506 + var domains = getUniqueDomains(); 507 + if (domains.length === 0) return; 508 + domains.forEach(function(d) { 509 + if (!knotStates[d]) knotStates[d] = "checking"; 510 + }); 511 + setTimeout(startPolling, INITIAL_DELAY); 512 + } 513 + 514 + document.addEventListener("click", handleClick, true); 515 + 516 + document.addEventListener("visibilitychange", function() { 517 + if (document.hidden) { 518 + stopPolling(); 519 + } else if (getUniqueDomains().length > 0) { 520 + startPolling(); 521 + } 522 + }); 523 + 524 + document.addEventListener("htmx:afterSettle", function() { 525 + var domains = getUniqueDomains(); 526 + checkNewDomains(domains); 527 + if (domains.length > 0 && !pollTimer && !document.hidden) { 528 + startPolling(); 529 + } 530 + }); 531 + 532 + if (document.readyState === "loading") { 533 + document.addEventListener("DOMContentLoaded", init); 534 + } else { 535 + init(); 536 + } 537 + })(); 538 + </script> 539 + {{ end }}
+2
appview/pages/templates/knots/fragments/knotListing.html
··· 12 12 <span class="hover:underline"> 13 13 {{ .Domain }} 14 14 </span> 15 + {{ template "fragments/knotStatus" .Domain }} 15 16 <span class="text-gray-500"> 16 17 {{ template "repo/fragments/shortTimeAgo" .Created }} 17 18 </span> ··· 20 21 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 22 {{ i "hard-drive" "w-4 h-4" }} 22 23 {{ .Domain }} 24 + {{ template "fragments/knotStatus" .Domain }} 23 25 <span class="text-gray-500"> 24 26 {{ template "repo/fragments/shortTimeAgo" .Created }} 25 27 </span>
+1
appview/pages/templates/layouts/base.html
··· 80 80 {{ template "layouts/fragments/footer" . }} 81 81 </footer> 82 82 {{ end }} 83 + {{ template "fragments/knotStatusScript" }} 83 84 </body> 84 85 </html> 85 86 {{ end }}
+4 -1
appview/pages/templates/layouts/repobase.html
··· 68 68 <div class="flex items-center gap-2 flex-wrap text-lg"> 69 69 {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 70 70 <span class="select-none">/</span> 71 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 71 + <span class="knot-status-group"> 72 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold" data-knot-link>{{ .RepoInfo.Name }}</a> 73 + {{ template "fragments/knotStatus" .RepoInfo.Knot }} 74 + </span> 72 75 </div> 73 76 {{ end }} 74 77
+5 -2
appview/pages/templates/user/fragments/repoCard.html
··· 22 22 {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 23 23 {{ end }} 24 24 {{ $repoOwner := resolve .Did }} 25 + <span class="knot-status-group min-w-0"> 25 26 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0" data-knot-link>{{ $repoOwner }}/{{ .Name }}</a> 27 28 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 29 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0" data-knot-link>{{ .Name }}</a> 29 30 {{- end -}} 31 + {{ template "fragments/knotStatus" .Knot }} 32 + </span> 30 33 </div> 31 34 {{ if and $starButton $root.LoggedInUser }} 32 35 <div class="shrink-0">
+170
appview/state/knot_status.go
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strings" 9 + "sync" 10 + "time" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + type knotStatusEntry struct { 19 + Online bool 20 + CheckedAt time.Time 21 + } 22 + 23 + type knotStatusCache struct { 24 + mu sync.RWMutex 25 + entries map[string]knotStatusEntry 26 + ttl time.Duration 27 + } 28 + 29 + func newKnotStatusCache(ttl time.Duration) *knotStatusCache { 30 + return &knotStatusCache{ 31 + entries: make(map[string]knotStatusEntry), 32 + ttl: ttl, 33 + } 34 + } 35 + 36 + func (c *knotStatusCache) get(domain string) (knotStatusEntry, bool) { 37 + c.mu.RLock() 38 + defer c.mu.RUnlock() 39 + entry, ok := c.entries[domain] 40 + if !ok || time.Since(entry.CheckedAt) > c.ttl { 41 + return knotStatusEntry{}, false 42 + } 43 + return entry, true 44 + } 45 + 46 + func (c *knotStatusCache) set(domain string, online bool) { 47 + c.mu.Lock() 48 + defer c.mu.Unlock() 49 + c.entries[domain] = knotStatusEntry{ 50 + Online: online, 51 + CheckedAt: time.Now(), 52 + } 53 + } 54 + 55 + func probeKnot(ctx context.Context, domain string, dev bool) bool { 56 + scheme := "https" 57 + if dev { 58 + scheme = "http" 59 + } 60 + 61 + host := fmt.Sprintf("%s://%s", scheme, domain) 62 + xrpcc := &indigoxrpc.Client{ 63 + Host: host, 64 + } 65 + 66 + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 67 + defer cancel() 68 + 69 + _, err := tangled.Owner(probeCtx, xrpcc) 70 + return err == nil 71 + } 72 + 73 + func (s *State) KnotStatus(w http.ResponseWriter, r *http.Request) { 74 + domainsParam := strings.TrimSpace(r.URL.Query().Get("domains")) 75 + if domainsParam == "" { 76 + http.Error(w, "missing domains parameter", http.StatusBadRequest) 77 + return 78 + } 79 + 80 + raw := strings.Split(domainsParam, ",") 81 + 82 + // deduplicate and trim, cap at 50 83 + seen := make(map[string]bool) 84 + var domains []string 85 + for _, d := range raw { 86 + d = strings.TrimSpace(d) 87 + if d == "" || seen[d] { 88 + continue 89 + } 90 + seen[d] = true 91 + domains = append(domains, d) 92 + if len(domains) >= 50 { 93 + break 94 + } 95 + } 96 + 97 + if len(domains) == 0 { 98 + http.Error(w, "missing domains parameter", http.StatusBadRequest) 99 + return 100 + } 101 + 102 + // SSRF prevention: only probe registered knots 103 + regs, err := db.GetRegistrations( 104 + s.db, 105 + orm.FilterIn("domain", domains), 106 + orm.FilterIsNot("registered", nil), 107 + ) 108 + if err != nil { 109 + s.logger.Error("knot-status: failed to query registrations", "err", err) 110 + http.Error(w, "internal error", http.StatusInternalServerError) 111 + return 112 + } 113 + 114 + registered := make(map[string]bool) 115 + for _, reg := range regs { 116 + registered[reg.Domain] = true 117 + } 118 + 119 + statuses := make(map[string]string) 120 + 121 + // check cache first, collect uncached registered domains 122 + var toProbe []string 123 + for _, d := range domains { 124 + if !registered[d] { 125 + statuses[d] = "unknown" 126 + continue 127 + } 128 + if entry, ok := s.knotStatusCache.get(d); ok { 129 + if entry.Online { 130 + statuses[d] = "online" 131 + } else { 132 + statuses[d] = "offline" 133 + } 134 + } else { 135 + toProbe = append(toProbe, d) 136 + } 137 + } 138 + 139 + // probe uncached domains concurrently 140 + if len(toProbe) > 0 { 141 + type result struct { 142 + domain string 143 + online bool 144 + } 145 + 146 + ch := make(chan result, len(toProbe)) 147 + for _, d := range toProbe { 148 + go func(domain string) { 149 + online := probeKnot(r.Context(), domain, s.config.Core.Dev) 150 + ch <- result{domain, online} 151 + }(d) 152 + } 153 + 154 + for range toProbe { 155 + res := <-ch 156 + s.knotStatusCache.set(res.domain, res.online) 157 + if res.online { 158 + statuses[res.domain] = "online" 159 + } else { 160 + statuses[res.domain] = "offline" 161 + } 162 + } 163 + } 164 + 165 + w.Header().Set("Content-Type", "application/json") 166 + w.Header().Set("Cache-Control", "public, max-age=15") 167 + json.NewEncoder(w).Encode(map[string]any{ 168 + "statuses": statuses, 169 + }) 170 + }
+2
appview/state/router.go
··· 185 185 r.Get("/privacy", s.PrivacyPolicy) 186 186 r.Get("/brand", s.Brand) 187 187 188 + r.Get("/knot-status", s.KnotStatus) 189 + 188 190 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 189 191 w.WriteHeader(http.StatusNotFound) 190 192 s.pages.Error404(w)
+2
appview/state/state.go
··· 60 60 spindlestream *eventconsumer.Consumer 61 61 logger *slog.Logger 62 62 validator *validator.Validator 63 + knotStatusCache *knotStatusCache 63 64 } 64 65 65 66 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 197 198 spindlestream, 198 199 logger, 199 200 validator, 201 + newKnotStatusCache(30 * time.Second), 200 202 } 201 203 202 204 return state, nil

History

1 round 2 comments
sign up or login to add to the discussion
9 commits
expand
appview/pages: add knot status indicator fragment
appview/pages: show knot status on repo cards
appview/pages: show knot status in repo header
appview/pages: show knot status on knot listings
appview/pages: replace knot-link class with data-knot-link attribute
appview/pages: move knot-status-group wrapping into templates
appview/pages: use DOM construction instead of innerHTML in knot toast
appview/state: add knot status health check endpoint
appview/pages: use hybrid cors and server fallback for knot status
expand 2 comments

Wow this is a lot of work. Thank you for your effort!

Unfortunately, we are already working on similar internal service called KnotMirror which mirrors all known repos. It is basically relay+ingester but for git objects and knots instead of CBORs and PDSs. To sync from distributed knots, KnotMirror holds the knot availability status and Appview can show that on web UI.

Though the UI part of your contribution is pretty valuable so I will co-author you when I merge part of your codes. Again, I really appreciate your effort so I'd recommend to make proposal issue before investing next time to make sure nothing is conflicting with ongoing works/plans.

closed without merging