0.34.1: populate the log container filter (parse compose prefix)

The whole-stack log stream sends service:null on every line, so the
container filter dropdown only ever showed "All containers". docker compose
logs already prefixes each line with the container name (and an RFC3339
timestamp via --timestamps); parse that prefix client-side to recover the
container, populate the filter, and render time + container + message
separately (cleaner than the raw prefixed line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-16 07:58:26 +00:00
co-authored by Claude Opus 4.8
parent efb468560e
commit 844655d1c8
2 changed files with 47 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.34.0",
"version": "0.34.1",
"type": "module",
"scripts": {
"dev": "vite",
+46 -5
View File
@@ -48,7 +48,44 @@ const levelStyles: Record<Level, { text: string; row: string }> = {
debug: { text: "text-slate-400", row: "border-l-2 border-transparent" },
};
type LogLine = { service: string | null; line: string; level: Level };
// `docker compose logs` prefixes every line with the container name (padded
// for alignment) then `| `; with --timestamps an RFC3339 stamp follows. The
// whole-stack stream sends service:null, so we recover the container here.
const prefixRe = /^(\S+)\s+\|\s?(.*)$/s;
const tsRe = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s(.*)$/s;
function parseLine(
rawService: string | null,
raw: string
): { service: string | null; time: string | null; msg: string } {
let service = rawService;
let body = raw;
if (!service) {
const m = prefixRe.exec(raw);
if (m) {
service = m[1];
body = m[2];
}
}
let time: string | null = null;
const t = tsRe.exec(body);
if (t) {
const d = new Date(t[1]);
if (!Number.isNaN(d.getTime())) {
time = d.toLocaleTimeString();
body = t[2];
}
}
return { service, time, msg: body };
}
type LogLine = {
service: string | null;
time: string | null;
msg: string;
raw: string;
level: Level;
};
export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: number }) {
const [lines, setLines] = useState<LogLine[]>([]);
@@ -83,10 +120,11 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
try {
const msg = JSON.parse(ev.data);
if (msg.type === "log") {
const { service, time, msg: body } = parseLine(msg.service, msg.line);
setLines((prev) => {
const next = [
...prev,
{ service: msg.service, line: msg.line, level: levelFor(msg.line) },
{ service, time, msg: body, raw: msg.line, level: levelFor(body) },
];
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
});
@@ -112,7 +150,7 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
const q = search.trim().toLowerCase();
return lines.filter((l) => {
if (serviceFilter !== "__all__" && l.service !== serviceFilter) return false;
if (q && !l.line.toLowerCase().includes(q)) return false;
if (q && !l.msg.toLowerCase().includes(q)) return false;
return true;
});
}, [lines, serviceFilter, search]);
@@ -124,7 +162,7 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
}, [filtered, autoScroll]);
const download = () => {
const blob = new Blob([filtered.map((l) => l.line).join("\n")], {
const blob = new Blob([filtered.map((l) => l.raw).join("\n")], {
type: "text/plain",
});
const a = document.createElement("a");
@@ -212,10 +250,13 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
key={i}
className={`whitespace-pre-wrap break-all py-0.5 pl-2 ${style.row}`}
>
{l.time && (
<span className="mr-2 text-slate-500">{l.time}</span>
)}
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className={style.text}>{l.line}</span>
<span className={style.text}>{l.msg}</span>
</div>
);
})}