From b765403dcfb4319fbe178d1b3a19d9823f74d2d5 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 6 Mar 2026 15:41:06 -0500 Subject: [PATCH] feat: implement real-time container log streaming via SSE --- CHANGELOG.md | 1 + backend/src/index.ts | 11 ++++ backend/src/services/DockerController.ts | 46 +++++++++++++ frontend/src/components/EditorLayout.tsx | 43 +++++++++++- frontend/src/components/LogViewer.tsx | 83 ++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/LogViewer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f69d7b59..273b7314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** Live Container Logs viewer using Server-Sent Events (SSE) for real-time terminal output. - **Added:** Pre-deploy folder collision check to prevent silent configuration overwrites in the App Store. - **Added:** UI subtitle during deployment to reassure users during long image downloads. - **Changed:** Standardized manual stack deletion to use the Two-Stage Teardown (Compose Down -> File Wipe) to prevent ghost networks. diff --git a/backend/src/index.ts b/backend/src/index.ts index 2cd56825..9d7fe69a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -598,6 +598,17 @@ app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) } }); +app.get('/api/containers/:id/logs', async (req: Request, res: Response) => { + try { + const id = req.params.id as string; + const dockerController = DockerController.getInstance(); + // Pass both req and res so we can listen for the client disconnect + await dockerController.streamContainerLogs(id, req, res); + } catch (error) { + res.status(500).json({ error: 'Failed to initialize log stream' }); + } +}); + app.post('/api/containers/:id/start', async (req: Request, res: Response) => { try { const id = req.params.id as string; diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 1e5b75a5..17832227 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -286,6 +286,52 @@ class DockerController { } } + public async streamContainerLogs(containerId: string, req: any, res: any): Promise { + const container = this.docker.getContainer(containerId); + + // 1. Set SSE Headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + try { + const logStream = await container.logs({ + follow: true, + stdout: true, + stderr: true, + tail: 100 // Send the last 100 lines immediately for context + }); + + // 2. Process and forward the stream + logStream.on('data', (chunk: Buffer) => { + // Docker multiplexes stdout/stderr with an 8-byte header if TTY is false. + let data = chunk; + if (chunk.length > 8 && (chunk[0] === 1 || chunk[0] === 2)) { + data = chunk.slice(8); + } + + const text = data.toString('utf-8'); + const lines = text.split('\n'); + + lines.forEach(line => { + if (line.trim()) { + res.write(`data: ${JSON.stringify(line)}\n\n`); + } + }); + }); + + // 3. Cleanup on disconnect + req.on('close', () => { + (logStream as any).destroy(); + }); + + } catch (error: any) { + res.write(`data: ${JSON.stringify('[Sencho] Error fetching logs: ' + error.message)}\n\n`); + res.end(); + } + } + // State-safe: silently ignores 304 "already started" errors public async startContainer(containerId: string) { try { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 388163a2..75f6b61b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; @@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; +import { LogViewer } from './LogViewer'; interface ContainerInfo { Id: string; @@ -87,6 +88,10 @@ export default function EditorLayout() { const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null); + // LogViewer state + const [logViewerOpen, setLogViewerOpen] = useState(false); + const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null); + // Notifications & Settings state const [notifications, setNotifications] = useState([]); @@ -569,6 +574,16 @@ export default function EditorLayout() { setSelectedContainer(null); }; + const openLogViewer = (containerId: string, containerName: string) => { + setLogContainer({ id: containerId, name: containerName }); + setLogViewerOpen(true); + }; + + const closeLogViewer = () => { + setLogViewerOpen(false); + setLogContainer(null); + }; + // Safe container list with fallback const safeContainers = containers || []; // Safe content strings with fallback @@ -986,6 +1001,22 @@ export default function EditorLayout() { )} + + + + + + View Live Logs + + @@ -1147,6 +1178,16 @@ export default function EditorLayout() { /> )} + {/* LogViewer Modal */} + {logContainer && ( + + )} + {/* Settings Modal */} void; +} + +export function LogViewer({ containerId, containerName, isOpen, onClose }: LogViewerProps) { + const [logs, setLogs] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const scrollRef = useRef(null); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + useEffect(() => { + if (!isOpen || !containerId) return; + + setLogs([]); + setIsConnected(false); + + const eventSource = new EventSource(`/api/containers/${containerId}/logs`); + + eventSource.onopen = () => setIsConnected(true); + + eventSource.onmessage = (event) => { + try { + const newLog = JSON.parse(event.data); + setLogs(prev => { + const updated = [...prev, newLog]; + return updated.length > 1000 ? updated.slice(updated.length - 1000) : updated; + }); + } catch (err) { + console.error("Failed to parse log line", err); + } + }; + + eventSource.onerror = () => { + setIsConnected(false); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [isOpen, containerId]); + + return ( + !open && onClose()}> + + + + + {containerName} {isConnected ? (connected) : } + + + +
+ {logs.length === 0 && !isConnected ? ( +
Connecting to container stream...
+ ) : ( + logs.map((log, i) => ( +
+ {log} +
+ )) + )} +
+
+
+ ); +}