diff --git a/src/Terrabuild.UI/src/App.tsx b/src/Terrabuild.UI/src/App.tsx index a214cc81..0b7d130c 100644 --- a/src/Terrabuild.UI/src/App.tsx +++ b/src/Terrabuild.UI/src/App.tsx @@ -103,6 +103,7 @@ const App = () => { Record >({}); const [draggedNodeId, setDraggedNodeId] = useState(null); + const [buildEndedAt, setBuildEndedAt] = useState(null); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const flowInstance = useRef(null); @@ -121,6 +122,7 @@ const App = () => { null ); const pendingBuildLogRef = useRef(false); + const lastApiNoticeRef = useRef(0); const applyTerminalTheme = () => { if (!terminal.current || !terminalReady.current) { return; @@ -138,6 +140,19 @@ const App = () => { effectiveColorScheme === "dark" ? "#3b3f45" : "#c9d0d8", }; }; + + const notifyApiUnavailable = () => { + const now = Date.now(); + if (now - lastApiNoticeRef.current < 15000) { + return; + } + lastApiNoticeRef.current = now; + notifications.show({ + color: "red", + title: "API unavailable", + message: "Unable to reach the Terrabuild API.", + }); + }; const flushPendingTerminalActions = () => { if (!terminalReady.current) { return; @@ -278,15 +293,23 @@ const App = () => { useEffect(() => { const load = async () => { - const [targetsRes, projectsRes] = await Promise.all([ - fetch("/api/targets"), - fetch("/api/projects"), - ]); - if (targetsRes.ok) { - setTargets(await targetsRes.json()); - } - if (projectsRes.ok) { - setProjects(await projectsRes.json()); + try { + const [targetsRes, projectsRes] = await Promise.all([ + fetch("/api/targets"), + fetch("/api/projects"), + ]); + if (targetsRes.ok) { + setTargets(await targetsRes.json()); + } else { + notifyApiUnavailable(); + } + if (projectsRes.ok) { + setProjects(await projectsRes.json()); + } else { + notifyApiUnavailable(); + } + } catch { + notifyApiUnavailable(); } }; load().catch(() => null); @@ -345,6 +368,7 @@ const App = () => { fetchGraph().catch(() => { setGraphError("Failed to load graph."); setGraph(null); + notifyApiUnavailable(); }); }, [selectedTargets, selectedProjects, configuration, environment, engine]); @@ -510,7 +534,14 @@ const App = () => { const controller = new AbortController(); logAbort.current = controller; - const response = await fetch("/api/build/log", { signal: controller.signal }); + let response: Response; + try { + response = await fetch("/api/build/log", { signal: controller.signal }); + } catch { + notifyApiUnavailable(); + setBuildRunning(false); + return; + } if (!response.body) { return; } @@ -632,6 +663,7 @@ const App = () => { message: "Build command copied to clipboard.", }); } catch { + notifyApiUnavailable(); notifications.show({ color: "red", title: "Copy failed", @@ -647,13 +679,22 @@ const App = () => { setBuildRunning(true); setBuildError(null); const payload = buildPayload(); - const response = await fetch("/api/build", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + let response: Response; + try { + response = await fetch("/api/build", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch { + setBuildRunning(false); + setBuildEndedAt(null); + notifyApiUnavailable(); + return; + } if (!response.ok) { setBuildRunning(false); + setBuildEndedAt(null); const message = await response.text(); setBuildError(message); notifications.show({ @@ -663,6 +704,7 @@ const App = () => { }); return; } + setBuildEndedAt(null); notifications.show({ color: "blue", title: "Build started", @@ -670,6 +712,7 @@ const App = () => { }); startLogStream().catch(() => { setBuildRunning(false); + notifyApiUnavailable(); notifications.show({ color: "red", title: "Build failed", @@ -708,7 +751,9 @@ const App = () => { setProjectStatus(statusMap); } }; - refresh().catch(() => null); + refresh().catch(() => { + notifyApiUnavailable(); + }); }, [ buildRunning, selectedTargets, @@ -749,6 +794,12 @@ const App = () => { if (!terminal.current) { return; } + const summary = nodeResults[key]; + if (summary?.endedAt) { + setBuildEndedAt(new Date(summary.endedAt)); + } else { + setBuildEndedAt(null); + } setSelectedTargetKey(key); terminal.current.reset(); setShowTerminal(true); @@ -779,6 +830,16 @@ const App = () => { await loadTargetLog(key, target); }; + useEffect(() => { + if (!selectedTargetKey) { + return; + } + const summary = nodeResults[selectedTargetKey]; + if (summary?.endedAt) { + setBuildEndedAt(new Date(summary.endedAt)); + } + }, [selectedTargetKey, nodeResults]); + const handleNodesChange: OnNodesChange = (changes) => { setNodes((current) => { const updated = applyNodeChanges(changes, current); @@ -796,6 +857,13 @@ const App = () => { const terminalBackground = effectiveColorScheme === "dark" ? theme.colors.dark[7] : theme.white; + const buildLogTitle = buildEndedAt + ? `Build Log ${buildEndedAt + .toISOString() + .replace("T", " ") + .replace("Z", "") + .slice(0, 19)}` + : "Build Log"; return ( { setShowTerminal(false)} terminalRef={terminalRef} background={terminalBackground} diff --git a/src/Terrabuild.UI/src/components/BuildLogPanel.tsx b/src/Terrabuild.UI/src/components/BuildLogPanel.tsx index 8accf8c8..526f9410 100644 --- a/src/Terrabuild.UI/src/components/BuildLogPanel.tsx +++ b/src/Terrabuild.UI/src/components/BuildLogPanel.tsx @@ -5,6 +5,7 @@ import { RefObject } from "react"; type BuildLogPanelProps = { showTerminal: boolean; buildRunning: boolean; + title: string; onHide: () => void; terminalRef: RefObject; background: string; @@ -13,6 +14,7 @@ type BuildLogPanelProps = { const BuildLogPanel = ({ showTerminal, buildRunning, + title, onHide, terminalRef, background, @@ -35,7 +37,7 @@ const BuildLogPanel = ({ > {showTerminal && ( - Build Log + {title} {buildRunning ? "Live" : "Idle"} diff --git a/src/Terrabuild/Core/Logs.fs b/src/Terrabuild/Core/Logs.fs index a5a1219c..296dc31b 100644 --- a/src/Terrabuild/Core/Logs.fs +++ b/src/Terrabuild/Core/Logs.fs @@ -148,6 +148,8 @@ let dumpLogs (logId: Guid) (options: ConfigOptions.Options) (cache: ICache) (gra let dumpTerminal (nodes: GraphDef.Node seq) = let dumpTerminal (node: GraphDef.Node) = let label = $"{node.Target} {node.ProjectDir}" + let formatEndedAt (value: System.DateTime) = + value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) let getHeaderFooter success = let color = @@ -164,7 +166,9 @@ let dumpLogs (logId: Guid) (options: ConfigOptions.Options) (cache: ICache) (gra let dumpLogs () = summary.Operations |> Seq.iter (fun group -> group |> Seq.iter (fun step -> - $"{Ansi.Styles.yellow}{step.MetaCommand} {Ansi.Styles.dimwhite}(exit code {step.ExitCode}){Ansi.Styles.reset}" |> Terminal.writeLine + let endedAt = formatEndedAt step.EndedAt + $"{Ansi.Styles.yellow}{step.MetaCommand} {Ansi.Styles.dimwhite}(exit code {step.ExitCode} - {endedAt}){Ansi.Styles.reset}" + |> Terminal.writeLine if options.Debug then $"{Ansi.Styles.cyan}{step.Command} {step.Arguments}{Ansi.Styles.reset}" |> Terminal.writeLine step.Log |> IO.readTextFile |> Terminal.write @@ -189,6 +193,8 @@ let dumpLogs (logId: Guid) (options: ConfigOptions.Options) (cache: ICache) (gra let label = $"{node.Target} {node.ProjectDir}" let cacheEntryId = GraphDef.buildCacheKey node let summary = cache.TryGetSummaryOnly false cacheEntryId + let formatEndedAt (value: System.DateTime) = + value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) match summary with | Some (_, summary) -> @@ -198,7 +204,9 @@ let dumpLogs (logId: Guid) (options: ConfigOptions.Options) (cache: ICache) (gra | Some command -> match command |> List.tryLast with | Some operation -> - $"::group::{operation.MetaCommand}" |> Terminal.writeLine + let endedAt = formatEndedAt operation.EndedAt + $"::group::{operation.MetaCommand} (exit code {operation.ExitCode} - {endedAt})" + |> Terminal.writeLine operation.Log |> IO.readTextFile |> Terminal.write $"::endgroup::" |> Terminal.writeLine | _ -> () diff --git a/src/Terrabuild/Helpers/Exec.fs b/src/Terrabuild/Helpers/Exec.fs index b3b2b07f..48e29f12 100644 --- a/src/Terrabuild/Helpers/Exec.fs +++ b/src/Terrabuild/Helpers/Exec.fs @@ -168,16 +168,18 @@ let execCaptureTimestampedOutput (workingDir: string) (command: string) (args: s try use logWriter = new StreamWriter(logFile) let writeLock = Lock() - - let inline lockWrite (from: string) (msg: string | null) = + let inline lockWrite (msg: string | null) = match msg with - | NonNull msg -> lock writeLock (fun () -> logWriter.WriteLine($"{DateTime.UtcNow} {from} {msg}")) + | NonNull msg -> + lock writeLock (fun () -> + logWriter.WriteLine(msg) + ) | _ -> () Log.Debug("Running and capturing timestamped output of '{Command}' with arguments '{Args}' in working dir '{WorkingDir}'", command, args, workingDir) use proc = createProcess workingDir command args envs true - proc.OutputDataReceived.Add(fun e -> lockWrite "OUT" e.Data) - proc.ErrorDataReceived.Add(fun e -> lockWrite "ERR" e.Data) + proc.OutputDataReceived.Add(fun e -> lockWrite e.Data) + proc.ErrorDataReceived.Add(fun e -> lockWrite e.Data) proc.BeginOutputReadLine() proc.BeginErrorReadLine() proc.WaitForExit()