dashboard.js
1 // CI Dashboard - Main JavaScript 2 3 class CIDashboard { 4 constructor(config) { 5 this.config = config; 6 this.elements = { 7 healthStatus: document.getElementById("health-status"), 8 runnersTable: document.getElementById("runners-table"), 9 queueList: document.getElementById("queue-list"), 10 queueCount: document.getElementById("queue-count"), 11 historyTable: document.getElementById("history-table"), 12 reposList: document.getElementById("repos-list"), 13 lastUpdated: document.getElementById("last-updated"), 14 refreshInterval: document.getElementById("refresh-interval"), 15 localCpuPercent: document.getElementById("local-cpu-percent"), 16 localMemPercent: document.getElementById("local-mem-percent"), 17 localDiskPercent: document.getElementById("local-disk-percent"), 18 ciCpuPercent: document.getElementById("ci-cpu-percent"), 19 ciMemPercent: document.getElementById("ci-mem-percent"), 20 ciDiskPercent: document.getElementById("ci-disk-percent") 21 }; 22 this.refreshTimer = null; 23 } 24 25 // API fetching with timeout 26 async fetchApi(endpoint) { 27 const url = this.config.apiBaseUrl + this.config.endpoints[endpoint]; 28 const controller = new AbortController(); 29 const timeout = setTimeout(() => controller.abort(), this.config.requestTimeout); 30 31 try { 32 const response = await fetch(url, { signal: controller.signal }); 33 clearTimeout(timeout); 34 35 if (!response.ok) { 36 throw new Error(`HTTP ${response.status}`); 37 } 38 39 return await response.json(); 40 } catch (error) { 41 clearTimeout(timeout); 42 console.error(`Failed to fetch ${endpoint}:`, error); 43 throw error; 44 } 45 } 46 47 // Format relative time 48 formatTimeAgo(dateString) { 49 if (!dateString) return "-"; 50 const date = new Date(dateString); 51 const now = new Date(); 52 const seconds = Math.floor((now - date) / 1000); 53 54 if (seconds < 60) return "just now"; 55 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 56 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 57 if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 58 59 return date.toLocaleDateString( 60 this.config.dateFormat.locale, 61 this.config.dateFormat.options 62 ); 63 } 64 65 // Format duration 66 formatDuration(seconds) { 67 if (!seconds || seconds <= 0) return "-"; 68 if (seconds < 60) return `${seconds}s`; 69 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; 70 const hours = Math.floor(seconds / 3600); 71 const mins = Math.floor((seconds % 3600) / 60); 72 return `${hours}h ${mins}m`; 73 } 74 75 // Get status badge HTML 76 getStatusBadge(status) { 77 const statusClass = status.toLowerCase().replace(/\s+/g, "-"); 78 return `<span class="status-badge ${statusClass}">${status}</span>`; 79 } 80 81 // Get CI status icon HTML 82 getCiStatusIcon(status) { 83 if (!status) return `<span class="ci-status skip">-</span>`; 84 85 const statusMap = { 86 pass: { class: "pass", icon: "\u2713" }, 87 passed: { class: "pass", icon: "\u2713" }, 88 success: { class: "pass", icon: "\u2713" }, 89 fail: { class: "fail", icon: "\u2717" }, 90 failed: { class: "fail", icon: "\u2717" }, 91 failure: { class: "fail", icon: "\u2717" }, 92 error: { class: "fail", icon: "!" }, 93 skip: { class: "skip", icon: "-" }, 94 skipped: { class: "skip", icon: "-" }, 95 running: { class: "running", icon: "\u25B6" }, 96 pending: { class: "pending", icon: "\u25CB" }, 97 queued: { class: "pending", icon: "\u25CB" }, 98 cancelled: { class: "skip", icon: "\u00D7" }, 99 unknown: { class: "skip", icon: "?" } 100 }; 101 102 const info = statusMap[status.toLowerCase()] || { class: "skip", icon: "?" }; 103 return `<span class="ci-status ${info.class}" title="${status}">${info.icon}</span>`; 104 } 105 106 // Update health status 107 async updateHealth() { 108 try { 109 const data = await this.fetchApi("health"); 110 const statusEl = this.elements.healthStatus; 111 const textEl = statusEl.querySelector(".status-text"); 112 113 statusEl.classList.remove("healthy", "unhealthy", "degraded"); 114 115 const status = data.status?.toLowerCase() || "unknown"; 116 if (status === "pass" || status === "healthy" || status === "ok" || status === "up") { 117 statusEl.classList.add("healthy"); 118 textEl.textContent = "Healthy"; 119 } else if (status === "degraded" || status === "warning" || status === "warn") { 120 statusEl.classList.add("degraded"); 121 textEl.textContent = "Degraded"; 122 } else { 123 statusEl.classList.add("unhealthy"); 124 textEl.textContent = data.status || "Unhealthy"; 125 } 126 } catch (error) { 127 const statusEl = this.elements.healthStatus; 128 statusEl.classList.remove("healthy", "degraded"); 129 statusEl.classList.add("unhealthy"); 130 statusEl.querySelector(".status-text").textContent = "Unreachable"; 131 } 132 } 133 134 // Update runners table 135 async updateRunners() { 136 try { 137 const data = await this.fetchApi("runners"); 138 const tbody = this.elements.runnersTable.querySelector("tbody"); 139 const runners = data.runners || data || []; 140 141 if (runners.length === 0) { 142 tbody.innerHTML = `<tr><td colspan="4" class="empty-state">No runners registered</td></tr>`; 143 return; 144 } 145 146 tbody.innerHTML = runners.map(runner => { 147 const status = runner.status || "unknown"; 148 const currentJob = runner.current_job; 149 const lastOnline = runner.last_online; 150 151 return ` 152 <tr> 153 <td> 154 <span class="runner-name">${this.escapeHtml(runner.name)}</span> 155 <br><small class="text-muted">${(runner.labels || []).join(", ")}</small> 156 </td> 157 <td>${this.getStatusBadge(status)}</td> 158 <td>${currentJob 159 ? `<span class="job-name">${this.escapeHtml(currentJob.name)}</span><br><small class="text-muted">${this.escapeHtml(currentJob.run_title || "")}</small>` 160 : `<span class="text-muted">Idle</span>`}</td> 161 <td><span class="time-ago">${lastOnline ? this.formatTimeAgo(lastOnline) : "-"}</span></td> 162 </tr> 163 `}).join(""); 164 } catch (error) { 165 const tbody = this.elements.runnersTable.querySelector("tbody"); 166 tbody.innerHTML = `<tr><td colspan="4" class="empty-state">Failed to load runners</td></tr>`; 167 } 168 } 169 170 // Update queue list with repo-level summary 171 async updateQueue() { 172 try { 173 const data = await this.fetchApi("queue"); 174 const list = this.elements.queueList; 175 const queue = data.queue || data || []; 176 177 this.elements.queueCount.textContent = queue.length; 178 179 if (queue.length === 0) { 180 list.innerHTML = `<li class="empty-state">No active runs</li>`; 181 return; 182 } 183 184 list.innerHTML = queue.map(item => { 185 const statusClass = item.status || "pending"; 186 const progressPercent = item.total > 0 ? Math.round((item.done / item.total) * 100) : 0; 187 188 return ` 189 <li class="queue-item ${statusClass}"> 190 <div class="queue-info"> 191 <span class="queue-repo">${this.escapeHtml(item.repo)}</span> 192 <span class="queue-progress">${item.done}/${item.total} done</span> 193 </div> 194 <div class="queue-bar"> 195 <div class="queue-bar-fill" style="width: ${progressPercent}%"></div> 196 </div> 197 </li> 198 `}).join(""); 199 } catch (error) { 200 this.elements.queueList.innerHTML = `<li class="empty-state">Failed to load queue</li>`; 201 this.elements.queueCount.textContent = "?"; 202 } 203 } 204 205 // Update history table 206 async updateHistory() { 207 try { 208 const data = await this.fetchApi("history"); 209 const tbody = this.elements.historyTable.querySelector("tbody"); 210 const history = (data.jobs || data.history || data || []).slice(0, this.config.historyLimit); 211 212 if (history.length === 0) { 213 tbody.innerHTML = `<tr><td colspan="6" class="empty-state">No recent runs</td></tr>`; 214 return; 215 } 216 217 tbody.innerHTML = history.map(job => { 218 const isRunning = job.result === "running" || job.result === "queued"; 219 const timeValue = isRunning 220 ? (job.started_at ? this.formatTimeAgo(job.started_at) : "-") 221 : (job.finished_at || job.completed_at || job.timestamp 222 ? this.formatTimeAgo(job.finished_at || job.completed_at || job.timestamp) 223 : "-"); 224 const timeLabel = isRunning ? "started" : ""; 225 const durationDisplay = isRunning ? "-" : this.formatDuration(job.duration); 226 227 return ` 228 <tr> 229 <td><span class="repo-name">${this.escapeHtml(job.repo || job.repository || "-")}</span></td> 230 <td><span class="branch-name">${this.escapeHtml(job.branch || "main")}</span></td> 231 <td>${this.escapeHtml(job.job || job.name || "-")}</td> 232 <td>${this.getStatusBadge(job.result || job.status || "unknown")}</td> 233 <td><span class="duration">${durationDisplay}</span></td> 234 <td><span class="time-ago">${timeValue}</span>${timeLabel ? `<br><small class="text-muted">${timeLabel}</small>` : ""}</td> 235 </tr> 236 `}).join(""); 237 } catch (error) { 238 const tbody = this.elements.historyTable.querySelector("tbody"); 239 tbody.innerHTML = `<tr><td colspan="6" class="empty-state">Failed to load history</td></tr>`; 240 } 241 } 242 243 // Update repos list with sync status 244 async updateRepos() { 245 try { 246 const data = await this.fetchApi("repos"); 247 const list = this.elements.reposList; 248 const repos = data.repos || data.repositories || data || []; 249 250 if (repos.length === 0) { 251 list.innerHTML = `<li class="empty-state">No repositories found</li>`; 252 return; 253 } 254 255 list.innerHTML = repos.map(repo => { 256 const syncStatus = repo.sync_status || "no-radicle"; 257 const syncClass = { 258 "synced": "sync-green", 259 "unsynced": "sync-yellow", 260 "no-radicle": "sync-red" 261 }[syncStatus] || "sync-red"; 262 263 const syncTitle = { 264 "synced": `Synced: ${repo.forgejo_head || "?"} = ${repo.radicle_head || "?"}`, 265 "unsynced": `Out of sync: Forgejo ${repo.forgejo_head || "?"} ≠ Radicle ${repo.radicle_head || "?"}`, 266 "no-radicle": "No Radicle origin" 267 }[syncStatus] || "Unknown"; 268 269 return ` 270 <li class="repo-item"> 271 <span class="sync-indicator ${syncClass}" title="${syncTitle}"></span> 272 <span class="repo-name">${this.escapeHtml(repo.name)}</span> 273 </li> 274 `}).join(""); 275 } catch (error) { 276 this.elements.reposList.innerHTML = `<li class="empty-state">Failed to load repositories</li>`; 277 } 278 } 279 280 // HTML escape utility 281 escapeHtml(text) { 282 if (!text) return ""; 283 const div = document.createElement("div"); 284 div.textContent = text; 285 return div.innerHTML; 286 } 287 288 // Update system stats for both local and CI servers 289 async updateSystem() { 290 try { 291 const data = await this.fetchApi("system"); 292 293 // Update local server metrics 294 this.updateServerMetrics("local", data.local); 295 296 // Update CI server metrics (may be null if unreachable) 297 this.updateServerMetrics("ci", data.ci); 298 } catch (error) { 299 console.error("Failed to update system stats:", error); 300 } 301 } 302 303 // Update metrics for a specific server (local or ci) 304 updateServerMetrics(prefix, serverData) { 305 const cpuEl = this.elements[`${prefix}CpuPercent`]; 306 const memEl = this.elements[`${prefix}MemPercent`]; 307 const diskEl = this.elements[`${prefix}DiskPercent`]; 308 309 if (!serverData) { 310 // Server unreachable - show offline state 311 if (cpuEl) { 312 cpuEl.textContent = "--"; 313 cpuEl.className = "stat-value stat-offline"; 314 } 315 if (memEl) { 316 memEl.textContent = "--"; 317 memEl.className = "stat-value stat-offline"; 318 } 319 if (diskEl) { 320 diskEl.textContent = "--"; 321 diskEl.className = "stat-value stat-offline"; 322 } 323 return; 324 } 325 326 // Update CPU 327 if (cpuEl) { 328 const cpuPct = serverData.cpu?.percent || 0; 329 cpuEl.textContent = `${Math.round(cpuPct)}%`; 330 cpuEl.className = `stat-value ${this.getStatClass(cpuPct)}`; 331 } 332 333 // Update Memory 334 if (memEl) { 335 const memPct = serverData.memory?.percent || 0; 336 memEl.textContent = `${Math.round(memPct)}%`; 337 memEl.className = `stat-value ${this.getStatClass(memPct)}`; 338 } 339 340 // Update Disk 341 if (diskEl) { 342 const diskPct = serverData.disk?.percent || 0; 343 diskEl.textContent = `${Math.round(diskPct)}%`; 344 diskEl.className = `stat-value ${this.getStatClass(diskPct)}`; 345 } 346 } 347 348 // Get color class based on usage percentage 349 getStatClass(percent) { 350 if (percent >= 90) return "stat-critical"; 351 if (percent >= 75) return "stat-warning"; 352 return "stat-normal"; 353 } 354 355 // Update last updated timestamp 356 updateTimestamp() { 357 const now = new Date(); 358 this.elements.lastUpdated.textContent = now.toLocaleTimeString(); 359 } 360 361 // Refresh all data 362 async refresh() { 363 await Promise.allSettled([ 364 this.updateHealth(), 365 this.updateRunners(), 366 this.updateQueue(), 367 this.updateHistory(), 368 this.updateRepos(), 369 this.updateSystem() 370 ]); 371 this.updateTimestamp(); 372 } 373 374 // Start automatic refresh 375 start() { 376 this.elements.refreshInterval.textContent = `${this.config.refreshInterval / 1000}s`; 377 this.refresh(); 378 this.refreshTimer = setInterval(() => this.refresh(), this.config.refreshInterval); 379 } 380 381 // Stop automatic refresh 382 stop() { 383 if (this.refreshTimer) { 384 clearInterval(this.refreshTimer); 385 this.refreshTimer = null; 386 } 387 } 388 } 389 390 // Initialize dashboard when DOM is ready 391 document.addEventListener("DOMContentLoaded", () => { 392 const dashboard = new CIDashboard(CONFIG); 393 dashboard.start(); 394 395 // Expose for debugging 396 window.dashboard = dashboard; 397 });