From 8203dd6a1464553c451cb93bd4725128616d3f80 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 6 Mar 2026 22:52:36 -0500 Subject: [PATCH] fix: tty parsing, timezone mapping, and floating action bar for global logs --- CHANGELOG.md | 3 + backend/src/index.ts | 67 +++---- .../components/GlobalObservabilityView.tsx | 168 ++++++++---------- 3 files changed, 110 insertions(+), 128 deletions(-) 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 ( -
-
-
-

Global Logs

-

Centralized log viewer across all containers.

-
-
- - - +
+ {/* Floating Action Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm w-48 bg-transparent" + />
+ + + + + + + {uniqueStacks.map(stack => ( + handleStackToggle(stack)} + > + {stack} + + ))} + {uniqueStacks.length === 0 && ( +
No stacks found
+ )} +
+
+ + + + +
- - {/* Action Bar */} - -
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9" - /> -
+ {loading && logs.length === 0 && ( +
+ +
+ )} -
- - - - - - {uniqueStacks.map(stack => ( - handleStackToggle(stack)} - > - {stack} - - ))} - {uniqueStacks.length === 0 && ( -
No stacks found
- )} -
-
- - + + {filteredLogs.length > 0 ? ( + filteredLogs.map((log, idx) => ( +
+ [{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] + [{log.containerName}] + {log.level}: + {log.message}
+ )) + ) : ( +
+ {logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
- - - {/* Terminal Window */} - - {loading && logs.length === 0 && ( -
- -
- )} - - {filteredLogs.length > 0 ? ( - filteredLogs.map((log, idx) => ( -
- {log.timestampStr} - [{log.stackName} | {log.containerName}] - {log.level}: - {log.message} -
- )) - ) : ( -
- {logs.length === 0 ? "No active logs found." : "No logs match the current filters."} -
- )} -
-
- + )} +
); }