mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix live streaming for terminal previews with asciinema player
- Configure asciinema player with eventsource driver for SSE support - Update server stream endpoint to properly format SSE for asciinema - Session preview cards now display live terminal output 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
520cf9641d
commit
a7dce56757
2 changed files with 36 additions and 36 deletions
|
|
@ -47,7 +47,7 @@ export class SessionList extends LitElement {
|
||||||
// Just mark as loaded and create the player with the endpoint URL
|
// Just mark as loaded and create the player with the endpoint URL
|
||||||
this.loadedSnapshots.set(sessionId, sessionId);
|
this.loadedSnapshots.set(sessionId, sessionId);
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
// Create asciinema player after the element is rendered
|
// Create asciinema player after the element is rendered
|
||||||
setTimeout(() => this.createPlayer(sessionId), 10);
|
setTimeout(() => this.createPlayer(sessionId), 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -72,16 +72,16 @@ export class SessionList extends LitElement {
|
||||||
const newSessionIdsList = this.sessions
|
const newSessionIdsList = this.sessions
|
||||||
.filter(session => !prevSessions.find((prev: Session) => prev.id === session.id))
|
.filter(session => !prevSessions.find((prev: Session) => prev.id === session.id))
|
||||||
.map(session => session.id);
|
.map(session => session.id);
|
||||||
|
|
||||||
// Track new sessions
|
// Track new sessions
|
||||||
newSessionIdsList.forEach(id => this.newSessionIds.add(id));
|
newSessionIdsList.forEach(id => this.newSessionIds.add(id));
|
||||||
|
|
||||||
// Load existing sessions immediately
|
// Load existing sessions immediately
|
||||||
const existingSessions = this.sessions.filter(session =>
|
const existingSessions = this.sessions.filter(session =>
|
||||||
!newSessionIdsList.includes(session.id)
|
!newSessionIdsList.includes(session.id)
|
||||||
);
|
);
|
||||||
existingSessions.forEach(session => this.loadSnapshot(session.id));
|
existingSessions.forEach(session => this.loadSnapshot(session.id));
|
||||||
|
|
||||||
// Load new sessions after a delay to let them generate some output
|
// Load new sessions after a delay to let them generate some output
|
||||||
if (newSessionIdsList.length > 0) {
|
if (newSessionIdsList.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -99,9 +99,9 @@ export class SessionList extends LitElement {
|
||||||
const playerElement = this.querySelector(`#player-${sessionId}`) as HTMLElement;
|
const playerElement = this.querySelector(`#player-${sessionId}`) as HTMLElement;
|
||||||
if (playerElement && (window as any).AsciinemaPlayer) {
|
if (playerElement && (window as any).AsciinemaPlayer) {
|
||||||
try {
|
try {
|
||||||
const snapshotUrl = `/api/sessions/${sessionId}/snapshot`;
|
const streamUrl = `/api/sessions/${sessionId}/stream`;
|
||||||
|
|
||||||
(window as any).AsciinemaPlayer.create(snapshotUrl, playerElement, {
|
(window as any).AsciinemaPlayer.create({driver: "eventsource", url: streamUrl}, playerElement, {
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
loop: false,
|
loop: false,
|
||||||
controls: false,
|
controls: false,
|
||||||
|
|
@ -191,7 +191,7 @@ export class SessionList extends LitElement {
|
||||||
|
|
||||||
private async handleCleanExited() {
|
private async handleCleanExited() {
|
||||||
const exitedSessions = this.sessions.filter(session => session.status === 'exited');
|
const exitedSessions = this.sessions.filter(session => session.status === 'exited');
|
||||||
|
|
||||||
if (exitedSessions.length === 0) {
|
if (exitedSessions.length === 0) {
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
this.dispatchEvent(new CustomEvent('error', {
|
||||||
detail: 'No exited sessions to clean'
|
detail: 'No exited sessions to clean'
|
||||||
|
|
@ -211,20 +211,20 @@ export class SessionList extends LitElement {
|
||||||
const response = await fetch('/api/cleanup-exited', {
|
const response = await fetch('/api/cleanup-exited', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to cleanup exited sessions');
|
throw new Error('Failed to cleanup exited sessions');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
this.dispatchEvent(new CustomEvent('error', {
|
||||||
detail: `Successfully cleaned ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}`
|
detail: `Successfully cleaned ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh the list after cleanup
|
// Refresh the list after cleanup
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.handleRefresh();
|
this.handleRefresh();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cleaning exited sessions:', error);
|
console.error('Error cleaning exited sessions:', error);
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
this.dispatchEvent(new CustomEvent('error', {
|
||||||
|
|
@ -237,14 +237,14 @@ export class SessionList extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private get filteredSessions() {
|
private get filteredSessions() {
|
||||||
return this.hideExited
|
return this.hideExited
|
||||||
? this.sessions.filter(session => session.status === 'running')
|
? this.sessions.filter(session => session.status === 'running')
|
||||||
: this.sessions;
|
: this.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const sessionsToShow = this.filteredSessions;
|
const sessionsToShow = this.filteredSessions;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="font-mono text-sm p-4">
|
<div class="font-mono text-sm p-4">
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
|
|
@ -252,14 +252,14 @@ export class SessionList extends LitElement {
|
||||||
<!-- Mobile: Stack everything -->
|
<!-- Mobile: Stack everything -->
|
||||||
<div class="flex flex-col space-y-3 md:hidden">
|
<div class="flex flex-col space-y-3 md:hidden">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm flex-1"
|
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm flex-1"
|
||||||
@click=${() => this.showCreateModal = true}
|
@click=${() => this.showCreateModal = true}
|
||||||
>
|
>
|
||||||
CREATE
|
CREATE
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors disabled:opacity-50 text-sm flex-1"
|
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors disabled:opacity-50 text-sm flex-1"
|
||||||
@click=${this.handleCleanExited}
|
@click=${this.handleCleanExited}
|
||||||
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
||||||
|
|
@ -267,12 +267,12 @@ CREATE
|
||||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN'}
|
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
.checked=${this.hideExited}
|
.checked=${this.hideExited}
|
||||||
@change=${(e: Event) => this.hideExited = (e.target as HTMLInputElement).checked}
|
@change=${(e: Event) => this.hideExited = (e.target as HTMLInputElement).checked}
|
||||||
>
|
>
|
||||||
|
|
@ -289,18 +289,18 @@ CREATE
|
||||||
--filter-exited
|
--filter-exited
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: Side by side -->
|
<!-- Desktop: Side by side -->
|
||||||
<div class="hidden md:flex md:items-center md:justify-between">
|
<div class="hidden md:flex md:items-center md:justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors"
|
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors"
|
||||||
@click=${() => this.showCreateModal = true}
|
@click=${() => this.showCreateModal = true}
|
||||||
>
|
>
|
||||||
CREATE SESSION
|
CREATE SESSION
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors disabled:opacity-50"
|
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors disabled:opacity-50"
|
||||||
@click=${this.handleCleanExited}
|
@click=${this.handleCleanExited}
|
||||||
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
||||||
|
|
@ -308,12 +308,12 @@ CREATE SESSION
|
||||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
<label class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
.checked=${this.hideExited}
|
.checked=${this.hideExited}
|
||||||
@change=${(e: Event) => this.hideExited = (e.target as HTMLInputElement).checked}
|
@change=${(e: Event) => this.hideExited = (e.target as HTMLInputElement).checked}
|
||||||
>
|
>
|
||||||
|
|
@ -339,14 +339,14 @@ CREATE SESSION
|
||||||
` : html`
|
` : html`
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
${sessionsToShow.map(session => html`
|
${sessionsToShow.map(session => html`
|
||||||
<div
|
<div
|
||||||
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
||||||
@click=${() => this.handleSessionClick(session)}
|
@click=${() => this.handleSessionClick(session)}
|
||||||
>
|
>
|
||||||
<!-- Compact Header -->
|
<!-- Compact Header -->
|
||||||
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
|
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
|
||||||
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${session.command}</div>
|
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${session.command}</div>
|
||||||
<button
|
<button
|
||||||
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
|
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
|
||||||
@click=${(e: Event) => this.handleKillSession(e, session.id)}
|
@click=${(e: Event) => this.handleKillSession(e, session.id)}
|
||||||
?disabled=${this.killingSessionIds.has(session.id)}
|
?disabled=${this.killingSessionIds.has(session.id)}
|
||||||
|
|
@ -361,8 +361,8 @@ CREATE SESSION
|
||||||
<div id="player-${session.id}" class="w-full h-full" style="overflow: hidden;"></div>
|
<div id="player-${session.id}" class="w-full h-full" style="overflow: hidden;"></div>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="text-vs-muted text-xs">
|
<div class="text-vs-muted text-xs">
|
||||||
${this.newSessionIds.has(session.id)
|
${this.newSessionIds.has(session.id)
|
||||||
? '[~] init_session...'
|
? '[~] init_session...'
|
||||||
: (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...')
|
: (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -383,7 +383,7 @@ CREATE SESSION
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<session-create-form
|
<session-create-form
|
||||||
.visible=${this.showCreateModal}
|
.visible=${this.showCreateModal}
|
||||||
@session-created=${this.handleSessionCreated}
|
@session-created=${this.handleSessionCreated}
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,7 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
||||||
|
|
||||||
// === TERMINAL I/O ===
|
// === TERMINAL I/O ===
|
||||||
|
|
||||||
// Server-sent events for terminal output streaming
|
// Live streaming cast file for asciinema player
|
||||||
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue