mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
feat: implement real-time container log streaming via SSE
This commit is contained in:
@@ -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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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:** 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.
|
- **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.
|
- **Changed:** Standardized manual stack deletion to use the Two-Stage Teardown (Compose Down -> File Wipe) to prevent ghost networks.
|
||||||
|
|||||||
@@ -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) => {
|
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|||||||
@@ -286,6 +286,52 @@ class DockerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
|
||||||
|
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
|
// State-safe: silently ignores 304 "already started" errors
|
||||||
public async startContainer(containerId: string) {
|
public async startContainer(containerId: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
import { Badge } from './ui/badge';
|
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 { useAuth } from '@/context/AuthContext';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { SettingsModal } from './SettingsModal';
|
import { SettingsModal } from './SettingsModal';
|
||||||
import { StackAlertSheet } from './StackAlertSheet';
|
import { StackAlertSheet } from './StackAlertSheet';
|
||||||
import { AppStoreView } from './AppStoreView';
|
import { AppStoreView } from './AppStoreView';
|
||||||
|
import { LogViewer } from './LogViewer';
|
||||||
|
|
||||||
interface ContainerInfo {
|
interface ContainerInfo {
|
||||||
Id: string;
|
Id: string;
|
||||||
@@ -87,6 +88,10 @@ export default function EditorLayout() {
|
|||||||
const [bashModalOpen, setBashModalOpen] = useState(false);
|
const [bashModalOpen, setBashModalOpen] = useState(false);
|
||||||
const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null);
|
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
|
// Notifications & Settings state
|
||||||
const [notifications, setNotifications] = useState<any[]>([]);
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
@@ -569,6 +574,16 @@ export default function EditorLayout() {
|
|||||||
setSelectedContainer(null);
|
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
|
// Safe container list with fallback
|
||||||
const safeContainers = containers || [];
|
const safeContainers = containers || [];
|
||||||
// Safe content strings with fallback
|
// Safe content strings with fallback
|
||||||
@@ -986,6 +1001,22 @@ export default function EditorLayout() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg h-8 w-8"
|
||||||
|
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
||||||
|
disabled={container?.State !== 'running'}
|
||||||
|
>
|
||||||
|
<ScrollText className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>View Live Logs</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1147,6 +1178,16 @@ export default function EditorLayout() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* LogViewer Modal */}
|
||||||
|
{logContainer && (
|
||||||
|
<LogViewer
|
||||||
|
isOpen={logViewerOpen}
|
||||||
|
onClose={closeLogViewer}
|
||||||
|
containerId={logContainer.id}
|
||||||
|
containerName={logContainer.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Modal */}
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Loader2, Terminal } from "lucide-react";
|
||||||
|
|
||||||
|
interface LogViewerProps {
|
||||||
|
containerId: string | null;
|
||||||
|
containerName: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({ containerId, containerName, isOpen, onClose }: LogViewerProps) {
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-4xl h-[80vh] flex flex-col bg-background border-border">
|
||||||
|
<DialogHeader className="flex flex-row items-center gap-2 pb-2 border-b">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
<DialogTitle className="flex-1 text-left font-mono text-sm">
|
||||||
|
{containerName} {isConnected ? <span className="text-green-500 text-xs ml-2">(connected)</span> : <Loader2 className="inline w-3 h-3 ml-2 animate-spin" />}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 w-full bg-[#0c0c0c] text-green-400 p-4 rounded-md overflow-y-auto font-mono text-xs mt-2"
|
||||||
|
>
|
||||||
|
{logs.length === 0 && !isConnected ? (
|
||||||
|
<div className="text-muted-foreground">Connecting to container stream...</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log, i) => (
|
||||||
|
<div key={i} className="break-all whitespace-pre-wrap leading-tight mb-1">
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user