diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c9c638..a020c7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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] +- **Fixed:** Docker raw byte multiplex headers leaking into global logs stream. +- **Changed:** Relocated historical CPU/RAM charts to the Home Dashboard and normalized data values (CPU relative to host cores, RAM to GB). +- **Added:** Dozzle-style Action Bar to Global Logs with multi-select stack filtering, search, and STDOUT/STDERR toggles. - **Added:** Centralized observability dashboard tracking 24-hour historical metrics and aggregating global tail logs across all running containers. - **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index d7919996..3d5ac5bd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -779,36 +779,60 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { await Promise.all(containers.map(async (c) => { const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; - const containerName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + + // Standardize naming: Strip stack name prefix if it exists + let containerName = rawName; + if (rawName.startsWith(`${stackName}-`)) { + containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); + } else if (rawName.startsWith(`${stackName}_`)) { + containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); + } + try { const container = dockerController.getDocker().getContainer(c.Id); - // Fetch last 50 lines with timestamps - const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 50, timestamps: true }); + const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer; - // Strip docker multiplex headers (non-tty) - const logsString = logsBuffer.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); - const lines = logsString.split('\n').filter(l => l.trim().length > 0); + let offset = 0; + while (offset < logsBuffer.length) { + // Parse 8-byte Docker multiplex header + const streamType = logsBuffer[offset]; // 1 = stdout, 2 = stderr + const length = logsBuffer.readUInt32BE(offset + 4); + offset += 8; - lines.forEach(line => { - let level = 'INFO'; - const lowerLine = line.toLowerCase(); - if (lowerLine.includes('error') || lowerLine.includes('err') || lowerLine.includes('fail') || lowerLine.includes('fatal')) level = 'ERROR'; - else if (lowerLine.includes('warn')) level = 'WARN'; + if (offset + length > logsBuffer.length) break; + const payload = logsBuffer.slice(offset, offset + length).toString('utf-8'); + offset += length; - // Extract Docker timestamp if present (usually ISO format at start of line) - const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); - const timestamp = timeMatch ? new Date(timeMatch[1]).getTime() : Date.now(); - const cleanMessage = timeMatch ? timeMatch[2] : line; + const lines = payload.split('\n').filter(l => l.trim().length > 0); + lines.forEach(line => { + // Extract Docker ISO timestamp + const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); + let timestampStr = ""; + let cleanMessage = line; + let timestampMs = Date.now(); - allLogs.push({ stackName, containerName, level, message: cleanMessage, timestamp }); - }); - } catch (err) { - // Ignore individual container fetch errors - } + if (timeMatch) { + const dateObj = new Date(timeMatch[1]); + timestampMs = dateObj.getTime(); + const formattedDate = dateObj.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }); + const formattedTime = dateObj.toLocaleTimeString('en-US', { hour12: false }); + timestampStr = `[${formattedDate} ${formattedTime}]`; + cleanMessage = timeMatch[2]; + } + + const source = streamType === 2 ? 'STDERR' : 'STDOUT'; + let level = source === 'STDERR' ? 'ERROR' : 'INFO'; + if (cleanMessage.toLowerCase().includes('warn')) level = 'WARN'; + + allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampStr, timestampMs }); + }); + } + } catch (err) { /* ignore */ } })); // Sort globally by timestamp descending and limit to 1000 lines to prevent payload bloat - allLogs.sort((a, b) => b.timestamp - a.timestamp); + allLogs.sort((a, b) => b.timestampMs - a.timestampMs); res.json(allLogs.slice(0, 1000)); } catch (error) { res.status(500).json({ error: 'Failed to fetch global logs' }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c219840f..1b755fe0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", @@ -1193,6 +1194,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 167971e3..f27a6570 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 13baeab4..0a74ee5d 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -1,56 +1,41 @@ import { useEffect, useState, useMemo } from 'react'; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; -import { RefreshCw, Activity, Terminal } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react'; import { ScrollArea } from '@/components/ui/scroll-area'; -interface Metric { - id: number; - container_id: string; - stack_name: string; - cpu_percent: number; - memory_mb: number; - net_rx_mb: number; - net_tx_mb: number; - timestamp: number; -} - interface LogEntry { stackName: string; containerName: string; + source: string; level: string; message: string; - timestamp: number; + timestampStr: string; + timestampMs: number; } export function GlobalObservabilityView() { - const [metrics, setMetrics] = useState([]); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); // Filters - const [selectedStack, setSelectedStack] = useState('all'); - const [selectedLevel, setSelectedLevel] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedStacks, setSelectedStacks] = useState([]); + const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL'); + const [clearedAt, setClearedAt] = useState(0); const fetchData = async () => { setLoading(true); try { - const [metricsRes, logsRes] = await Promise.all([ - fetch('/api/metrics/historical'), - fetch('/api/logs/global') - ]); - - if (metricsRes.ok) { - setMetrics(await metricsRes.json()); - } + const logsRes = await fetch('/api/logs/global'); if (logsRes.ok) { setLogs(await logsRes.json()); } } catch (error) { - console.error('Failed to fetch observability data:', error); + console.error('Failed to fetch global logs:', error); } finally { setLoading(false); } @@ -58,187 +43,149 @@ export function GlobalObservabilityView() { useEffect(() => { fetchData(); - const interval = setInterval(fetchData, 30000); // 30s auto-refresh + const interval = setInterval(fetchData, 10000); // Poll every 10s return () => clearInterval(interval); }, []); - // Aggregate metrics by timestamp (minute buckets) for the chart - const chartData = useMemo(() => { - const buckets: Record = {}; - - metrics.forEach(m => { - // Bucket by minute (ignoring seconds) - const date = new Date(m.timestamp); - date.setSeconds(0, 0); - const key = date.getTime() + ''; - - if (!buckets[key]) { - buckets[key] = { - time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - timestamp: date.getTime(), - cpu: 0, - ram: 0 - }; - } - buckets[key].cpu += m.cpu_percent; - buckets[key].ram += m.memory_mb; - }); - - return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp); - }, [metrics]); - - const chartConfig = { - cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' }, - ram: { label: 'RAM Usage (MB)', color: 'var(--chart-2)' }, - }; - - // Filter logs - const filteredLogs = useMemo(() => { - return logs.filter(log => { - if (selectedStack !== 'all' && log.stackName !== selectedStack) return false; - if (selectedLevel !== 'all' && log.level !== selectedLevel) return false; - return true; - }); - }, [logs, selectedStack, selectedLevel]); - const uniqueStacks = useMemo(() => { const stacks = new Set(logs.map(l => l.stackName)); return Array.from(stacks).sort(); }, [logs]); + const handleStackToggle = (stack: string) => { + setSelectedStacks(prev => + prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack] + ); + }; + + const handleClearLogs = () => { + setClearedAt(Date.now()); + }; + + const filteredLogs = useMemo(() => { + return logs.filter(log => { + if (log.timestampMs < clearedAt) return false; + if (selectedStacks.length > 0 && !selectedStacks.includes(log.stackName)) return false; + if (streamFilter !== 'ALL' && log.source !== streamFilter) return false; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return log.message.toLowerCase().includes(query) || + log.containerName.toLowerCase().includes(query) || + log.stackName.toLowerCase().includes(query); + } + return true; + }); + }, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]); + + const handleDownload = () => { + if (filteredLogs.length === 0) return; + const blob = new Blob([filteredLogs.map(l => `${l.timestampStr} [${l.stackName} | ${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `sencho-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + return ( -
-
+
+
-

Global Observability

-

24-hour historical metrics and centralized logging.

+

Global Logs

+

Centralized log viewer across all containers.

+
+
+ + +
-
- {/* Charts Section */} -
- - - - - Aggregate CPU Usage - - Total CPU percentage across all containers. - - - {chartData.length > 0 ? ( - - - - - `${val}%`} /> - } /> - - - - ) : ( -
- No historical CPU data available. -
- )} -
-
+ + {/* Action Bar */} + +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
- - - - - Aggregate Memory Usage - - Total Memory (MB) allocated across all containers. - - - {chartData.length > 0 ? ( - - - - - `${val}MB`} /> - } /> - - - - ) : ( -
- No historical Memory data available. -
- )} -
-
-
- - {/* Global Logs Section */} - - -
- - - Unified Global Logs - - -
- + {uniqueStacks.length === 0 && ( +
No stacks found
+ )} + + - setStreamFilter(val)}> + + - All Levels - INFO - WARN - ERROR + All Streams + STDOUT + STDERR
- - + + {/* Terminal Window */} + + {loading && logs.length === 0 && ( +
+ +
+ )} + {filteredLogs.length > 0 ? ( filteredLogs.map((log, idx) => ( -
- [{new Date(log.timestamp).toLocaleTimeString()}] - [{log.stackName}/{log.containerName}] - - {log.level}: - - {log.message} +
+ {log.timestampStr} + [{log.stackName} | {log.containerName}] + {log.level}: + {log.message}
)) ) : ( -
No logs found matching criteria.
+
+ {logs.length === 0 ? "No active logs found." : "No logs match the current filters."} +
)} diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 231dc11b..2b22a2d8 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { useState, useEffect, useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog'; @@ -58,6 +60,7 @@ export default function HomeDashboard() { const [newStackName, setNewStackName] = useState(''); const [stats, setStats] = useState({ active: 0, exited: 0, total: 0, inactive: 0 }); const [systemStats, setSystemStats] = useState(null); + const [metrics, setMetrics] = useState([]); // Fetch stats from backend useEffect(() => { @@ -79,11 +82,14 @@ export default function HomeDashboard() { useEffect(() => { const fetchSystemStats = async () => { try { - const res = await apiFetch('/system/stats'); - const data = await res.json(); - setSystemStats(data); + const [sysRes, metricsRes] = await Promise.all([ + apiFetch('/system/stats'), + apiFetch('/metrics/historical') + ]); + if (sysRes.ok) setSystemStats(await sysRes.json()); + if (metricsRes.ok) setMetrics(await metricsRes.json()); } catch (error) { - console.error('Failed to fetch system stats:', error); + console.error('Failed to fetch system stats or metrics:', error); } }; fetchSystemStats(); @@ -91,6 +97,35 @@ export default function HomeDashboard() { return () => clearInterval(interval); }, []); + const chartData = useMemo(() => { + const buckets: Record = {}; + const cores = systemStats?.cpu.cores || 1; + + metrics.forEach(m => { + const date = new Date(m.timestamp); + date.setSeconds(0, 0); + const key = date.getTime() + ''; + + if (!buckets[key]) { + buckets[key] = { + time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + timestamp: date.getTime(), + cpu: 0, + ram: 0 + }; + } + buckets[key].cpu += (m.cpu_percent / cores); + buckets[key].ram += (m.memory_mb / 1024); + }); + + return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp); + }, [metrics, systemStats]); + + const chartConfig = { + cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' }, + ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' }, + }; + const handleConvert = async () => { if (!dockerRunInput.trim()) return; setIsConverting(true); @@ -242,6 +277,63 @@ export default function HomeDashboard() {
+ {/* Historical Charts */} +
+ + + + + Normalized CPU Usage + + Total CPU percentage over total host cores. + + + {chartData.length > 0 ? ( + + + + + `${Number(val).toFixed(0)}%`} domain={[0, 100]} /> + } /> + + + + ) : ( +
+ No historical CPU data. +
+ )} +
+
+ + + + + + Normalized RAM Usage + + Total RAM allocation in GB. + + + {chartData.length > 0 ? ( + + + + + `${Number(val).toFixed(1)} GB`} /> + } /> + + + + ) : ( +
+ No historical RAM data. +
+ )} +
+
+
+ {/* Docker Run Converter */} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..5d01cd0b --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox }