Skip to content

Commit 6a031db

Browse files
committed
feat: polecat management overlay and session logs
1 parent 777be9c commit 6a031db

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

client/dashboard.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class Dashboard {
121121
<div class="dashboard-summary-actions">
122122
<button class="dashboard-topbar-btn" id="dashboard-open-telemetry-details" title="View trends and histograms">📈 Details</button>
123123
<button class="dashboard-topbar-btn" id="dashboard-open-performance" title="Per-terminal resource usage">⚙ Perf</button>
124+
<button class="dashboard-topbar-btn" id="dashboard-open-polecats" title="Manage sessions (restart/kill/logs)">🐾 Polecats</button>
124125
<button class="dashboard-topbar-btn" id="dashboard-open-tests" title="Run tests across worktrees">🧪 Tests</button>
125126
<button class="dashboard-topbar-btn" id="dashboard-export-telemetry" title="Download telemetry CSV export">⬇ Export</button>
126127
<button class="dashboard-topbar-btn" id="dashboard-export-telemetry-json" title="Download telemetry JSON export">⬇ JSON</button>
@@ -205,6 +206,10 @@ class Dashboard {
205206
e.preventDefault();
206207
this.showPerformanceOverlay();
207208
});
209+
document.getElementById('dashboard-open-polecats')?.addEventListener('click', (e) => {
210+
e.preventDefault();
211+
this.showPolecatOverlay().catch(() => {});
212+
});
208213
document.getElementById('dashboard-open-tests')?.addEventListener('click', (e) => {
209214
e.preventDefault();
210215
try {
@@ -822,6 +827,201 @@ class Dashboard {
822827
</tbody>
823828
</table>
824829
`;
830+
}
831+
832+
async showPolecatOverlay() {
833+
const existing = document.getElementById('dashboard-polecats-overlay');
834+
if (existing) {
835+
existing.classList.remove('hidden');
836+
return;
837+
}
838+
839+
const overlay = document.createElement('div');
840+
overlay.id = 'dashboard-polecats-overlay';
841+
overlay.className = 'dashboard-telemetry-overlay';
842+
overlay.innerHTML = `
843+
<div class="dashboard-telemetry-panel" role="dialog" aria-label="Polecat management">
844+
<div class="dashboard-telemetry-header">
845+
<div class="dashboard-telemetry-title">Polecats — Sessions</div>
846+
<button class="dashboard-topbar-btn" id="dashboard-polecats-close" title="Close (Esc)">✕</button>
847+
</div>
848+
<div class="dashboard-telemetry-controls">
849+
<div class="dashboard-telemetry-actions">
850+
<button class="btn-secondary" type="button" id="dashboard-polecats-refresh">Refresh</button>
851+
</div>
852+
</div>
853+
<div id="dashboard-polecats-body" class="dashboard-telemetry-body">Loading…</div>
854+
</div>
855+
`;
856+
857+
document.body.appendChild(overlay);
858+
859+
const close = () => this.hidePolecatOverlay();
860+
overlay.addEventListener('click', (e) => {
861+
if (e.target === overlay) close();
862+
});
863+
overlay.querySelector('#dashboard-polecats-close')?.addEventListener('click', close);
864+
overlay.querySelector('#dashboard-polecats-refresh')?.addEventListener('click', () => {
865+
this.loadPolecatDetails().catch(() => {});
866+
});
867+
868+
const onKey = (e) => {
869+
if (e.key !== 'Escape') return;
870+
const el = document.getElementById('dashboard-polecats-overlay');
871+
if (!el || el.classList.contains('hidden')) return;
872+
close();
873+
};
874+
overlay._escHandler = onKey;
875+
document.addEventListener('keydown', onKey);
876+
877+
await this.loadPolecatDetails();
878+
}
879+
880+
hidePolecatOverlay() {
881+
const overlay = document.getElementById('dashboard-polecats-overlay');
882+
if (!overlay) return;
883+
overlay.classList.add('hidden');
884+
const handler = overlay._escHandler;
885+
if (handler) {
886+
document.removeEventListener('keydown', handler);
887+
overlay._escHandler = null;
888+
}
889+
overlay.remove();
890+
}
891+
892+
async loadPolecatDetails() {
893+
const bodyEl = document.getElementById('dashboard-polecats-body');
894+
if (!bodyEl) return;
895+
896+
const escapeHtml = (value) => String(value ?? '')
897+
.replace(/&/g, '&amp;')
898+
.replace(/</g, '&lt;')
899+
.replace(/>/g, '&gt;');
900+
901+
const sessions = Array.from(this.orchestrator?.sessions?.entries?.() || []);
902+
sessions.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
903+
904+
if (!sessions.length) {
905+
bodyEl.textContent = 'No sessions.';
906+
return;
907+
}
908+
909+
const state = {
910+
selected: sessions[0]?.[0] || ''
911+
};
912+
913+
const render = async () => {
914+
const selected = state.selected;
915+
const selectedSession = this.orchestrator?.sessions?.get?.(selected) || null;
916+
const selectedTitle = selectedSession ? `${selected} (${selectedSession.type || ''})` : selected;
917+
918+
const rows = sessions.map(([id, s]) => {
919+
const status = escapeHtml(s?.status || 'idle');
920+
const branch = escapeHtml(s?.branch || '');
921+
const type = escapeHtml(s?.type || '');
922+
const worktreeId = escapeHtml(s?.worktreeId || '');
923+
const repo = escapeHtml(s?.repositoryName || '');
924+
const label = repo ? `${repo}/${worktreeId || ''}` : (worktreeId || '');
925+
const isSel = id === selected;
926+
return `
927+
<tr data-polecat-session="${escapeHtml(id)}" style="${isSel ? 'background: rgba(255,255,255,0.04);' : ''}">
928+
<td class="mono">${escapeHtml(id)}</td>
929+
<td>${escapeHtml(label)}</td>
930+
<td>${type}</td>
931+
<td>${status}</td>
932+
<td class="mono">${branch}</td>
933+
<td style="white-space:nowrap;">
934+
<button class="btn-secondary" type="button" data-polecat-restart="${escapeHtml(id)}" title="Restart session">↻</button>
935+
<button class="btn-secondary" type="button" data-polecat-kill="${escapeHtml(id)}" title="Kill/close session">✕</button>
936+
</td>
937+
</tr>
938+
`;
939+
}).join('');
940+
941+
bodyEl.innerHTML = `
942+
<div style="display:flex; gap:12px; align-items:stretch; min-height: 50vh;">
943+
<div style="flex: 0 0 min(720px, 58vw); min-width: 320px; overflow:auto;">
944+
<table class="worktree-inspector-table">
945+
<thead>
946+
<tr>
947+
<th>Session</th>
948+
<th>Worktree</th>
949+
<th>Type</th>
950+
<th>Status</th>
951+
<th>Branch</th>
952+
<th>Actions</th>
953+
</tr>
954+
</thead>
955+
<tbody>
956+
${rows}
957+
</tbody>
958+
</table>
959+
</div>
960+
<div style="flex:1; min-width: 260px; display:flex; flex-direction:column;">
961+
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px;">
962+
<div class="mono" style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(selectedTitle)}</div>
963+
<button class="btn-secondary" type="button" id="dashboard-polecats-log-refresh" ${selected ? '' : 'disabled'}>Refresh log</button>
964+
</div>
965+
<pre id="dashboard-polecats-log" style="flex:1; margin:0; padding:10px; border-radius:8px; border:1px solid var(--border-color); background: rgba(0,0,0,0.25); overflow:auto; white-space:pre-wrap; word-break:break-word;">Loading…</pre>
966+
</div>
967+
</div>
968+
`;
969+
970+
const loadLog = async () => {
971+
const pre = bodyEl.querySelector('#dashboard-polecats-log');
972+
if (!pre) return;
973+
if (!selected) {
974+
pre.textContent = 'No session selected.';
975+
return;
976+
}
977+
pre.textContent = 'Loading…';
978+
try {
979+
const res = await fetch(`/api/sessions/${encodeURIComponent(selected)}/log?tailChars=20000`);
980+
const data = await res.json().catch(() => ({}));
981+
if (!res.ok || !data?.ok) throw new Error(data?.error || 'Failed to load log');
982+
pre.textContent = String(data.log || '');
983+
} catch (err) {
984+
pre.textContent = `Failed to load: ${String(err?.message || err)}`;
985+
}
986+
};
987+
988+
await loadLog();
989+
990+
bodyEl.querySelector('#dashboard-polecats-log-refresh')?.addEventListener('click', (e) => {
991+
e.preventDefault();
992+
loadLog().catch(() => {});
993+
});
994+
995+
bodyEl.querySelectorAll('[data-polecat-session]').forEach((row) => {
996+
row.addEventListener('click', () => {
997+
const id = row.getAttribute('data-polecat-session');
998+
if (!id) return;
999+
state.selected = id;
1000+
render().catch(() => {});
1001+
});
1002+
});
1003+
1004+
bodyEl.querySelectorAll('button[data-polecat-restart]').forEach((btn) => {
1005+
btn.addEventListener('click', (e) => {
1006+
e.preventDefault();
1007+
e.stopPropagation();
1008+
const id = btn.getAttribute('data-polecat-restart');
1009+
if (!id) return;
1010+
this.orchestrator?.socket?.emit?.('restart-session', { sessionId: id });
1011+
});
1012+
});
1013+
bodyEl.querySelectorAll('button[data-polecat-kill]').forEach((btn) => {
1014+
btn.addEventListener('click', (e) => {
1015+
e.preventDefault();
1016+
e.stopPropagation();
1017+
const id = btn.getAttribute('data-polecat-kill');
1018+
if (!id) return;
1019+
this.orchestrator?.socket?.emit?.('destroy-session', { sessionId: id });
1020+
});
1021+
});
1022+
};
1023+
1024+
await render();
8251025
}
8261026

8271027
hidePerformanceOverlay() {

server/index.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,37 @@ app.get('/api/activity', (req, res) => {
15201520
}
15211521
});
15221522

1523+
app.get('/api/sessions/:sessionId/log', (req, res) => {
1524+
try {
1525+
const sessionId = String(req.params.sessionId || '').trim();
1526+
if (!sessionId) return res.status(400).json({ ok: false, error: 'sessionId is required' });
1527+
1528+
const session = sessionManager.sessions.get(sessionId);
1529+
if (!session) return res.status(404).json({ ok: false, error: 'Session not found' });
1530+
1531+
const requested = Number(req.query?.tailChars ?? 20000);
1532+
const tailChars = Math.max(1000, Math.min(200000, Number.isFinite(requested) ? requested : 20000));
1533+
1534+
const buffer = String(session.buffer || '');
1535+
const log = buffer.length > tailChars ? buffer.slice(-tailChars) : buffer;
1536+
1537+
res.json({
1538+
ok: true,
1539+
sessionId,
1540+
tailChars,
1541+
status: session.status || null,
1542+
branch: session.branch || null,
1543+
worktreeId: session.worktreeId || null,
1544+
repositoryName: session.repositoryName || null,
1545+
cwd: session?.cwdState?.current || session?.config?.cwd || null,
1546+
log
1547+
});
1548+
} catch (error) {
1549+
logger.error('Failed to get session log', { sessionId: req.params.sessionId, error: error.message, stack: error.stack });
1550+
res.status(500).json({ ok: false, error: 'Failed to get session log' });
1551+
}
1552+
});
1553+
15231554
app.post('/api/workspaces/create-worktree', async (req, res) => {
15241555
try {
15251556
const { workspaceId, repositoryPath, worktreeNumber } = req.body;

0 commit comments

Comments
 (0)