agent-control-plane 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,10 @@ const profilesNode = document.querySelector("#profiles");
6
6
  const seenAlertIds = new Set();
7
7
  let notificationPermissionRequested = false;
8
8
  const THEME_STORAGE_KEY = "acp-dashboard-theme";
9
+ const ROWS_PER_PAGE = 10;
10
+
11
+ // Pagination state: { [tableId]: { page: number } }
12
+ window._acpPagination = {};
9
13
 
10
14
  function systemPrefersDark() {
11
15
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches;
@@ -72,6 +76,14 @@ function relativeTime(input) {
72
76
  return seconds >= 0 ? `${absolute}s ago` : `in ${absolute}s`;
73
77
  }
74
78
 
79
+ function formatCompactDate(input) {
80
+ if (!input) return "n/a";
81
+ const d = new Date(input);
82
+ if (Number.isNaN(d.getTime())) return input;
83
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
84
+ return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
85
+ }
86
+
75
87
  function formatDuration(seconds) {
76
88
  if (!seconds && seconds !== 0) return "n/a";
77
89
  const absSeconds = Math.abs(seconds);
@@ -107,7 +119,7 @@ function statusClass(status) {
107
119
  }
108
120
 
109
121
  function renderLifecycle(row) {
110
- const note = row.result_only_completion === "yes" ? `<div class="muted">Recovered from result file</div>` : "";
122
+ const note = row.result_only_completion === "yes" ? `<div class="muted">Recovered</div>` : "";
111
123
  return `<span class="status-pill ${statusClass(row.lifecycle_status || row.status)}">${row.lifecycle_status || row.status || "UNKNOWN"}</span>${note}`;
112
124
  }
113
125
 
@@ -172,12 +184,74 @@ function renderOverview(snapshot) {
172
184
  .join("");
173
185
  }
174
186
 
175
- function renderTable(columns, rows, emptyMessage = "No data right now.") {
187
+ // Build windowed pagination: max 5 page buttons with ellipsis
188
+ function buildWindowedPages(current, total) {
189
+ const pages = [];
190
+ const maxVisible = 5;
191
+ if (total <= maxVisible) {
192
+ for (let i = 1; i <= total; i++) pages.push(i);
193
+ return pages;
194
+ }
195
+ // Always show first, last, and surrounding pages
196
+ const pagesSet = new Set();
197
+ pagesSet.add(1);
198
+ pagesSet.add(total);
199
+ for (let i = Math.max(1, current - 1); i <= Math.min(total, current + 1); i++) {
200
+ pagesSet.add(i);
201
+ }
202
+ const sorted = Array.from(pagesSet).sort((a, b) => a - b);
203
+ // Add ellipsis markers
204
+ const result = [];
205
+ let prev = 0;
206
+ for (const p of sorted) {
207
+ if (p - prev > 1) result.push("...");
208
+ result.push(p);
209
+ prev = p;
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function renderPagination(tableId, currentPage, totalPages, totalRows) {
215
+ if (totalPages <= 1) return "";
216
+ const start = (currentPage - 1) * ROWS_PER_PAGE + 1;
217
+ const end = Math.min(currentPage * ROWS_PER_PAGE, totalRows);
218
+ const pages = buildWindowedPages(currentPage, totalPages);
219
+ const buttons = pages
220
+ .map((p) => {
221
+ if (p === "...") return `<span class="pagination-ellipsis">…</span>`;
222
+ return `<button class="${p === currentPage ? "active" : ""}" onclick="window._acpGoToPage('${tableId}',${p})">${p}</button>`;
223
+ })
224
+ .join("");
225
+ return `
226
+ <div class="pagination">
227
+ <span class="pagination-info">Showing ${start}-${end} of ${totalRows}</span>
228
+ <div class="pagination-controls">
229
+ <button ${currentPage <= 1 ? "disabled" : ""} onclick="window._acpGoToPage('${tableId}',${currentPage - 1})">‹</button>
230
+ ${buttons}
231
+ <button ${currentPage >= totalPages ? "disabled" : ""} onclick="window._acpGoToPage('${tableId}',${currentPage + 1})">›</button>
232
+ </div>
233
+ </div>`;
234
+ }
235
+
236
+ window._acpGoToPage = function(tableId, page) {
237
+ window._acpPagination[tableId] = { page };
238
+ rerenderAll();
239
+ };
240
+
241
+ function renderTableWithPagination(tableId, columns, rows, emptyMessage = "No data right now.") {
176
242
  if (!rows.length) {
177
243
  return `<div class="empty-state">${emptyMessage}</div>`;
178
244
  }
245
+ const state = window._acpPagination[tableId] || { page: 1 };
246
+ let { page } = state;
247
+ const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE);
248
+ if (page < 1) page = 1;
249
+ if (page > totalPages) page = totalPages;
250
+ window._acpPagination[tableId] = { page };
251
+ const start = (page - 1) * ROWS_PER_PAGE;
252
+ const pageRows = rows.slice(start, start + ROWS_PER_PAGE);
179
253
  const headers = columns.map((column) => `<th>${column.label}</th>`).join("");
180
- const body = rows
254
+ const body = pageRows
181
255
  .map((row) => {
182
256
  const cells = columns
183
257
  .map((column) => `<td>${column.render ? column.render(row) : row[column.key] ?? ""}</td>`)
@@ -185,7 +259,8 @@ function renderTable(columns, rows, emptyMessage = "No data right now.") {
185
259
  return `<tr>${cells}</tr>`;
186
260
  })
187
261
  .join("");
188
- return `<div class="table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${body}</tbody></table></div>`;
262
+ const paginationHtml = renderPagination(tableId, page, totalPages, rows.length);
263
+ return `<div class="table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${body}</tbody></table></div>${paginationHtml}`;
189
264
  }
190
265
 
191
266
  function renderAlerts(alerts) {
@@ -207,8 +282,8 @@ function renderAlerts(alerts) {
207
282
  </div>
208
283
  <p>${alert.message}</p>
209
284
  <div class="alert-meta">
210
- <span>${alert.reset_at ? `Reset: ${alert.reset_at}` : "Reset: n/a"}</span>
211
- <span>${alert.updated_at ? `${relativeTime(alert.updated_at)} · ${alert.updated_at}` : "updated n/a"}</span>
285
+ <span>${alert.reset_at ? `Reset: ${formatCompactDate(alert.reset_at)}` : "Reset: n/a"}</span>
286
+ <span>${alert.updated_at ? `${relativeTime(alert.updated_at)} · ${formatCompactDate(alert.updated_at)}` : "updated n/a"}</span>
212
287
  </div>
213
288
  </article>
214
289
  `,
@@ -225,13 +300,14 @@ function renderCodexRotation(rotation) {
225
300
  const candidates = (rotation.candidate_labels || []).length ? rotation.candidate_labels.join(", ") : "n/a";
226
301
  const ready = (rotation.ready_candidates || []).length ? rotation.ready_candidates.join(", ") : "none";
227
302
  const nextRetry = rotation.next_retry_at
228
- ? `${rotation.next_retry_label || "n/a"} · ${relativeTime(rotation.next_retry_at)}<div class="muted">${rotation.next_retry_at}</div>`
303
+ ? `${rotation.next_retry_label || "n/a"} · ${relativeTime(rotation.next_retry_at)}<div class="muted">${formatCompactDate(rotation.next_retry_at)}</div>`
229
304
  : "n/a";
230
305
  const lastSwitch = rotation.last_switch_label
231
306
  ? `${rotation.last_switch_label}${rotation.last_switch_reason ? ` · ${rotation.last_switch_reason}` : ""}`
232
307
  : "n/a";
233
308
 
234
- return renderTable(
309
+ return renderTableWithPagination(
310
+ "codex-rotation",
235
311
  [
236
312
  { label: "Current", render: () => `<div class="mono">${rotation.active_label}</div>` },
237
313
  { label: "Decision", render: () => `<span class="status-pill ${statusClass(rotation.switch_decision || "unknown")}">${rotation.switch_decision || "unknown"}</span>` },
@@ -304,7 +380,8 @@ function renderProfile(profile) {
304
380
  return true;
305
381
  });
306
382
 
307
- const runsTable = renderTable(
383
+ const runsTable = renderTableWithPagination(
384
+ `runs-${profile.id}`,
308
385
  [
309
386
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
310
387
  { label: "Task", render: (row) => `${row.task_kind || "n/a"} ${row.task_id || ""}`.trim() },
@@ -312,7 +389,7 @@ function renderProfile(profile) {
312
389
  { label: "Worker", key: "coding_worker" },
313
390
  { label: "Provider", render: (row) => row.provider_model || "n/a" },
314
391
  { label: "Result", render: renderResult },
315
- { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
392
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
316
393
  ],
317
394
  filteredRuns,
318
395
  "No active run directories for this profile.",
@@ -334,20 +411,22 @@ function renderProfile(profile) {
334
411
  return true;
335
412
  });
336
413
 
337
- const recentHistoryTable = renderTable(
414
+ const recentHistoryTable = renderTableWithPagination(
415
+ `history-${profile.id}`,
338
416
  [
339
417
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
340
418
  { label: "Task", render: (row) => `${row.task_kind || "n/a"} ${row.task_id || ""}`.trim() },
341
419
  { label: "Lifecycle", render: renderLifecycle },
342
420
  { label: "Worker", key: "coding_worker" },
343
421
  { label: "Result", render: renderResult },
344
- { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
422
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
345
423
  ],
346
424
  filteredHistory,
347
425
  "No recently archived runs.",
348
426
  );
349
427
 
350
- const controllerTable = renderTable(
428
+ const controllerTable = renderTableWithPagination(
429
+ `controllers-${profile.id}`,
351
430
  [
352
431
  { label: "Issue", key: "issue_id" },
353
432
  { label: "State", render: renderControllerState },
@@ -356,39 +435,42 @@ function renderProfile(profile) {
356
435
  { label: "Provider", render: (row) => `${row.provider_backend || "n/a"} ${row.provider_model || ""}`.trim() },
357
436
  { label: "Failover", render: (row) => `${row.provider_failover_count} failovers / ${row.provider_switch_count} switches` },
358
437
  { label: "Wait", render: (row) => `${row.provider_wait_count} waits / ${row.provider_wait_total_seconds}s` },
359
- { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
438
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
360
439
  ],
361
440
  profile.resident_controllers,
362
441
  "No resident controllers recorded for this profile.",
363
442
  );
364
443
 
365
- const retryTable = renderTable(
444
+ const retryTable = renderTableWithPagination(
445
+ `retries-${profile.id}`,
366
446
  [
367
447
  { label: "Issue", key: "issue_id" },
368
448
  { label: "Status", render: (row) => `<span class="status-pill ${row.ready ? "" : "waiting-provider"}">${row.ready ? "ready" : "retrying"}</span>` },
369
449
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
370
450
  { label: "Attempts", key: "attempts" },
371
- { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
451
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
372
452
  { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
373
453
  ],
374
454
  profile.issue_retries || [],
375
455
  "No issue retries recorded.",
376
456
  );
377
457
 
378
- const prRetryTable = renderTable(
458
+ const prRetryTable = renderTableWithPagination(
459
+ `pr-retries-${profile.id}`,
379
460
  [
380
461
  { label: "PR", key: "pr_number" },
381
462
  { label: "Status", render: (row) => `<span class="status-pill ${row.ready ? "" : "waiting-provider"}">${row.ready ? "ready" : "retrying"}</span>` },
382
463
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
383
464
  { label: "Attempts", key: "attempts" },
384
- { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
465
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
385
466
  { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
386
467
  ],
387
468
  profile.pr_retries || [],
388
469
  "No PR retries recorded.",
389
470
  );
390
471
 
391
- const workerTable = renderTable(
472
+ const workerTable = renderTableWithPagination(
473
+ `workers-${profile.id}`,
392
474
  [
393
475
  { label: "Key", render: (row) => `<div class="mono">${row.key}</div>` },
394
476
  { label: "Scope", key: "scope" },
@@ -397,61 +479,66 @@ function renderProfile(profile) {
397
479
  { label: "Lane", render: (row) => `${row.resident_lane_kind || "n/a"} / ${row.resident_lane_value || "n/a"}` },
398
480
  { label: "Tasks", key: "task_count" },
399
481
  { label: "Last status", render: (row) => row.last_status || "n/a" },
400
- { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${row.last_started_at}</div>` : "n/a" },
482
+ { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${formatCompactDate(row.last_started_at)}</div>` : "n/a" },
401
483
  ],
402
484
  profile.resident_workers,
403
485
  "No resident worker metadata yet.",
404
486
  );
405
487
 
406
- const cooldownTable = renderTable(
488
+ const cooldownTable = renderTableWithPagination(
489
+ `cooldowns-${profile.id}`,
407
490
  [
408
491
  { label: "Provider key", render: (row) => `<div class="mono">${row.provider_key}</div>` },
409
492
  { label: "State", render: (row) => `<span class="status-pill ${row.active ? "waiting-provider" : ""}">${row.active ? "cooldown" : "expired"}</span>` },
410
493
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
411
494
  { label: "Attempts", key: "attempts" },
412
- { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
495
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
413
496
  { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
414
497
  ],
415
498
  profile.provider_cooldowns,
416
499
  "No provider cooldowns recorded.",
417
500
  );
418
501
 
419
- const scheduledTable = renderTable(
502
+ const scheduledTable = renderTableWithPagination(
503
+ `scheduled-${profile.id}`,
420
504
  [
421
505
  { label: "Issue", key: "issue_id" },
422
506
  { label: "Interval", render: (row) => `${row.interval_seconds}s` },
423
- { label: "Next due", render: (row) => row.next_due_at ? `${relativeTime(row.next_due_at)}<div class="muted">${row.next_due_at}</div>` : "n/a" },
507
+ { label: "Next due", render: (row) => row.next_due_at ? `${relativeTime(row.next_due_at)}<div class="muted">${formatCompactDate(row.next_due_at)}</div>` : "n/a" },
424
508
  { label: "Time Remaining", render: (row) => row.next_due_at ? timeRemaining(row.next_due_at) : "n/a" },
425
- { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${row.last_started_at}</div>` : "n/a" },
509
+ { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${formatCompactDate(row.last_started_at)}</div>` : "n/a" },
426
510
  ],
427
511
  profile.scheduled_issues,
428
512
  "No scheduled issue state recorded.",
429
513
  );
430
514
 
431
- const queueTable = renderTable(
515
+ const queueTable = renderTableWithPagination(
516
+ `queue-${profile.id}`,
432
517
  [
433
518
  { label: "Issue", key: "issue_id" },
434
519
  { label: "Session", render: (row) => row.session ? `<div class="mono">${row.session}</div>` : "n/a" },
435
520
  { label: "Queued by", key: "queued_by" },
436
- { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
521
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
437
522
  ],
438
523
  profile.issue_queue.pending,
439
524
  "No pending leased issues.",
440
525
  );
441
526
 
442
- const claimsTable = renderTable(
527
+ const claimsTable = renderTableWithPagination(
528
+ `claims-${profile.id}`,
443
529
  [
444
530
  { label: "Issue", key: "issue_id" },
445
531
  { label: "Session", render: (row) => row.session ? `<div class="mono">${row.session}</div>` : "n/a" },
446
532
  { label: "Claimed by", key: "claimer" },
447
- { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
533
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
448
534
  ],
449
535
  profile.issue_queue.claims || [],
450
536
  "No claimed issues.",
451
537
  );
452
538
 
453
539
  const githubOutbox = profile.github_outbox || { counts: {}, pending: [] };
454
- const githubOutboxTable = renderTable(
540
+ const githubOutboxTable = renderTableWithPagination(
541
+ `github-outbox-${profile.id}`,
455
542
  [
456
543
  { label: "Type", render: (row) => row.type || "n/a" },
457
544
  { label: "Target", render: (row) => `${row.kind || row.type || "write"} #${row.number || "?"}` },
@@ -464,7 +551,7 @@ function renderProfile(profile) {
464
551
  return row.body_preview || "n/a";
465
552
  },
466
553
  },
467
- { label: "Created", render: (row) => row.created_at ? `${relativeTime(row.created_at)}<div class="muted">${row.created_at}</div>` : "n/a" },
554
+ { label: "Created", render: (row) => row.created_at ? `${relativeTime(row.created_at)}<div class="muted">${formatCompactDate(row.created_at)}</div>` : "n/a" },
468
555
  ],
469
556
  githubOutbox.pending || [],
470
557
  "No pending GitHub write intents.",
@@ -556,9 +643,9 @@ function renderProfile(profile) {
556
643
  <h3>Troubleshooting</h3>
557
644
  <p class="panel-subtitle">Run diagnostics or debugging tools against this live profile.</p>
558
645
  <div class="action-bar">
559
- <button class="action-btn" onclick="runDoctor('${profile.id}')">Run Doctor</button>
560
- <button class="action-btn" onclick="exportProfile('${profile.id}')">Export Profile</button>
561
- <button class="action-btn" onclick="document.getElementById('import-file-${profile.id}').click()">Import Profile</button>
646
+ <button class="action-btn" onclick="runDoctor('${profile.id}')">🔧 Run Doctor</button>
647
+ <button class="action-btn" onclick="exportProfile('${profile.id}')">📤 Export</button>
648
+ <button class="action-btn" onclick="document.getElementById('import-file-${profile.id}').click()">📥 Import</button>
562
649
  <input type="file" id="import-file-${profile.id}" style="display:none" accept=".json" onchange="importProfile('${profile.id}', this)">
563
650
  <span id="doctor-status-${profile.id}"></span>
564
651
  </div>
@@ -639,7 +726,7 @@ async function loadSnapshot() {
639
726
  }
640
727
 
641
728
  function renderFromSnapshot(snapshot) {
642
- generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
729
+ generatedAtNode.textContent = `Snapshot: ${formatCompactDate(snapshot.generated_at)}`;
643
730
  renderOverview(snapshot);
644
731
  profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
645
732
  }
@@ -716,7 +803,6 @@ async function importProfile(profileId, inputEl) {
716
803
  const result = await response.json();
717
804
  if (response.ok) {
718
805
  alert(`Profile ${profileId} imported successfully!`);
719
- // Refresh the page to show imported profile
720
806
  setTimeout(() => window.location.reload(), 1000);
721
807
  } else {
722
808
  alert(`Import failed: ${result.error || response.status}`);
@@ -750,9 +836,14 @@ function connectWebSocket() {
750
836
  console.log("ACP Dashboard: WebSocket connected");
751
837
  };
752
838
 
753
- ws.onmessage = (event) => {
839
+ ws.onmessage = async (event) => {
754
840
  try {
755
- const snapshot = JSON.parse(event.data);
841
+ let data = event.data;
842
+ // Handle Blob (binary) or string
843
+ if (data instanceof Blob) {
844
+ data = await data.text();
845
+ }
846
+ const snapshot = JSON.parse(data);
756
847
  window._acpSnapshot = snapshot;
757
848
  renderFromSnapshot(snapshot);
758
849
  maybeNotifyAlerts(snapshot);