diff --git a/CHANGELOG.md b/CHANGELOG.md index a020c7a3..e5df8ed0 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:** TTY container log streams failing to parse globally. +- **Fixed:** Global logs displaying in UTC instead of local browser timezone. +- **Changed:** Global Logs UI revamped to use a floating, hover-based action bar to maximize terminal space. - **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d5ac5bd..5370d16a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -791,42 +791,45 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { try { const container = dockerController.getDocker().getContainer(c.Id); + const inspect = await container.inspect(); + const isTty = inspect.Config.Tty; const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer; - let offset = 0; - while (offset < logsBuffer.length) { + const parseAndPushLog = (line: string, source: string) => { + if (!line.trim()) return; + const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); + let cleanMessage = line; + let timestampMs = Date.now(); + + if (timeMatch) { + timestampMs = new Date(timeMatch[1]).getTime(); + cleanMessage = timeMatch[2]; + } + + let level = source === 'STDERR' ? 'ERROR' : 'INFO'; + if (cleanMessage.toLowerCase().includes('warn')) level = 'WARN'; + else if (cleanMessage.toLowerCase().includes('error') || cleanMessage.toLowerCase().includes('fail')) level = 'ERROR'; + + allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs }); + }; + + if (isTty) { + // No multiplex headers. Just split by newline. + const payload = logsBuffer.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); + payload.split('\n').forEach(line => parseAndPushLog(line, 'STDOUT')); + } else { // Parse 8-byte Docker multiplex header - const streamType = logsBuffer[offset]; // 1 = stdout, 2 = stderr - const length = logsBuffer.readUInt32BE(offset + 4); - offset += 8; + let offset = 0; + while (offset < logsBuffer.length) { + const streamType = logsBuffer[offset]; + const length = logsBuffer.readUInt32BE(offset + 4); + offset += 8; + if (offset + length > logsBuffer.length) break; - if (offset + length > logsBuffer.length) break; - const payload = logsBuffer.slice(offset, offset + length).toString('utf-8'); - offset += length; - - 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(); - - 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 }); - }); + const payload = logsBuffer.slice(offset, offset + length).toString('utf-8'); + offset += length; + payload.split('\n').forEach(line => parseAndPushLog(line, streamType === 2 ? 'STDERR' : 'STDOUT')); + } } } catch (err) { /* ignore */ } })); diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 0a74ee5d..aee3dc06 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useMemo } from 'react'; -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 { Input } from '@/components/ui/input'; @@ -13,7 +12,6 @@ interface LogEntry { source: string; level: string; message: string; - timestampStr: string; timestampMs: number; } @@ -79,7 +77,7 @@ export function GlobalObservabilityView() { 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 blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${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; @@ -91,105 +89,83 @@ export function GlobalObservabilityView() { }; return ( -
Centralized log viewer across all containers.
-