summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/nodes/StepLogFeed.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/nodes/StepLogFeed.tsx')
-rw-r--r--frontend/src/components/document/nodes/StepLogFeed.tsx277
1 files changed, 277 insertions, 0 deletions
diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx
new file mode 100644
index 0000000..0357de8
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepLogFeed.tsx
@@ -0,0 +1,277 @@
+import React, { useEffect, useRef, useState, useCallback } from 'react';
+
+interface StepLogFeedProps {
+ taskId: string;
+ stepName: string;
+ stepStatus: string;
+ onCollapse: () => void;
+}
+
+interface LogEntry {
+ timestamp: string;
+ content: string;
+ type: 'stdout' | 'stderr' | 'system' | 'user';
+}
+
+/**
+ * Live log feed for an expanded step row.
+ * Connects via WebSocket to stream task output and allows
+ * sending messages (comments) and interrupting the task.
+ */
+export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLogFeedProps) {
+ const [logs, setLogs] = useState<LogEntry[]>([]);
+ const [message, setMessage] = useState('');
+ const [sending, setSending] = useState(false);
+ const [connected, setConnected] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const logsEndRef = useRef<HTMLDivElement>(null);
+ const wsRef = useRef<WebSocket | null>(null);
+ const logContainerRef = useRef<HTMLDivElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ const isActive = ['running', 'starting'].includes(stepStatus.toLowerCase());
+
+ // Auto-scroll to bottom when new logs arrive
+ useEffect(() => {
+ logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
+ // Connect to WebSocket for live streaming
+ useEffect(() => {
+ if (!taskId) return;
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/api/v1/mesh/tasks/subscribe`;
+
+ let ws: WebSocket;
+ let reconnectTimer: ReturnType<typeof setTimeout>;
+ let shouldReconnect = true;
+
+ function connect() {
+ try {
+ ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.addEventListener('open', () => {
+ setConnected(true);
+ setError(null);
+ // Subscribe to this specific task
+ ws.send(JSON.stringify({ type: 'subscribe', taskId }));
+ });
+
+ ws.addEventListener('message', (evt) => {
+ try {
+ const data = JSON.parse(evt.data);
+ // Handle different message formats from the backend
+ if (data.taskId === taskId || data.task_id === taskId) {
+ const entry: LogEntry = {
+ timestamp: data.timestamp || new Date().toISOString(),
+ content: data.content || data.output || data.message || JSON.stringify(data),
+ type: data.type || data.stream || 'stdout',
+ };
+ setLogs(prev => [...prev, entry]);
+ }
+ } catch {
+ // Non-JSON message, treat as raw log
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: evt.data,
+ type: 'stdout',
+ }]);
+ }
+ });
+
+ ws.addEventListener('close', () => {
+ setConnected(false);
+ wsRef.current = null;
+ if (shouldReconnect && isActive) {
+ reconnectTimer = setTimeout(connect, 3000);
+ }
+ });
+
+ ws.addEventListener('error', () => {
+ setConnected(false);
+ setError('WebSocket connection failed');
+ });
+ } catch (err) {
+ setError('Failed to connect to log stream');
+ }
+ }
+
+ connect();
+
+ return () => {
+ shouldReconnect = false;
+ clearTimeout(reconnectTimer);
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ };
+ }, [taskId, isActive]);
+
+ // Keyboard shortcut: Escape to collapse
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onCollapse();
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [onCollapse]);
+
+ // Send a message/comment to the task
+ const handleSendMessage = useCallback(async () => {
+ if (!message.trim() || !taskId || sending) return;
+
+ setSending(true);
+ try {
+ const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: message.trim() }),
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({ message: response.statusText }));
+ throw new Error(body.message || body.error || `HTTP ${response.status}`);
+ }
+
+ // Add as a user message in the log
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: message.trim(),
+ type: 'user',
+ }]);
+ setMessage('');
+ inputRef.current?.focus();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to send message');
+ } finally {
+ setSending(false);
+ }
+ }, [message, taskId, sending]);
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ // Prevent Escape from bubbling when input is focused
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ inputRef.current?.blur();
+ }
+ }, [handleSendMessage]);
+
+ // Interrupt the running task
+ const handleInterrupt = useCallback(async () => {
+ if (!taskId) return;
+ try {
+ // Send a special interrupt message
+ const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: '/interrupt' }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: 'Interrupt signal sent',
+ type: 'system',
+ }]);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to interrupt');
+ }
+ }, [taskId]);
+
+ const formatTimestamp = (ts: string) => {
+ try {
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ } catch {
+ return '';
+ }
+ };
+
+ return (
+ <div className="step-log-feed">
+ {/* Header */}
+ <div className="step-log-feed-header">
+ <div className="step-log-feed-header-left">
+ <span className="step-log-feed-title">{stepName} - Logs</span>
+ <span className={`step-log-feed-status ${connected ? 'connected' : 'disconnected'}`}>
+ {connected ? 'Live' : 'Disconnected'}
+ </span>
+ </div>
+ <div className="step-log-feed-header-right">
+ {isActive && (
+ <button
+ className="step-log-feed-interrupt-btn"
+ onClick={handleInterrupt}
+ title="Interrupt this task"
+ >
+ &#x23F9; Interrupt
+ </button>
+ )}
+ <button
+ className="step-log-feed-collapse-btn"
+ onClick={onCollapse}
+ title="Collapse (Esc)"
+ >
+ &#x2715;
+ </button>
+ </div>
+ </div>
+
+ {/* Log content */}
+ <div className="step-log-feed-content" ref={logContainerRef}>
+ {logs.length === 0 && !error && (
+ <div className="step-log-feed-empty">
+ {isActive
+ ? 'Waiting for log output...'
+ : 'No logs available for this step.'}
+ </div>
+ )}
+
+ {error && (
+ <div className="step-log-feed-error">{error}</div>
+ )}
+
+ {logs.map((entry, idx) => (
+ <div key={idx} className={`step-log-entry step-log-entry--${entry.type}`}>
+ <span className="step-log-entry-time">{formatTimestamp(entry.timestamp)}</span>
+ <span className="step-log-entry-content">{entry.content}</span>
+ </div>
+ ))}
+ <div ref={logsEndRef} />
+ </div>
+
+ {/* Message input (comment/interrupt controls) */}
+ {isActive && (
+ <div className="step-log-feed-input">
+ <input
+ ref={inputRef}
+ type="text"
+ className="step-log-feed-input-field"
+ placeholder="Send a message to this task..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ disabled={sending}
+ />
+ <button
+ className="step-log-feed-send-btn"
+ onClick={handleSendMessage}
+ disabled={!message.trim() || sending}
+ title="Send message (Enter)"
+ >
+ {sending ? '...' : '&#x27A4;'}
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}