/ frontend / dashboard.js
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  });