From 8285f8b5e56eac7ead853115fce594305bf3b72a Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Thu, 15 Jan 2026 21:57:28 +0100 Subject: [PATCH 1/2] add notifications --- src/Terrabuild.UI/package.json | 1 + src/Terrabuild.UI/pnpm-lock.yaml | 58 ++++++++++++ src/Terrabuild.UI/src/App.tsx | 143 +++++++++++++++++++++++++++--- src/Terrabuild.UI/src/main.tsx | 2 + src/Terrabuild/Web/GraphServer.fs | 20 +++++ 5 files changed, 210 insertions(+), 14 deletions(-) diff --git a/src/Terrabuild.UI/package.json b/src/Terrabuild.UI/package.json index 25c03654..10bde0af 100644 --- a/src/Terrabuild.UI/package.json +++ b/src/Terrabuild.UI/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.11.5", "@mantine/core": "^6.0.0", "@mantine/hooks": "^6.0.0", + "@mantine/notifications": "^6.0.0", "@tabler/icons-react": "^3.19.0", "dagre": "^0.8.5", "react": "^18.3.1", diff --git a/src/Terrabuild.UI/pnpm-lock.yaml b/src/Terrabuild.UI/pnpm-lock.yaml index eee07750..2120e140 100644 --- a/src/Terrabuild.UI/pnpm-lock.yaml +++ b/src/Terrabuild.UI/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@mantine/hooks': specifier: ^6.0.0 version: 6.0.22(react@18.3.1) + '@mantine/notifications': + specifier: ^6.0.0 + version: 6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tabler/icons-react': specifier: ^3.19.0 version: 3.36.1(react@18.3.1) @@ -388,6 +391,14 @@ packages: peerDependencies: react: '>=16.8.0' + '@mantine/notifications@6.0.22': + resolution: {integrity: sha512-x7iIil2yC81fEv/7YK6NYn6CKaftlw0E/hdprmxGWFhy87W9sYiYzPqigXZh11IJZFFW9ZPftpjPQFvDwE4KOw==} + peerDependencies: + '@mantine/core': 6.0.22 + '@mantine/hooks': 6.0.22 + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@mantine/styles@6.0.22': resolution: {integrity: sha512-Rud/IQp2EFYDiP4csRy2XBrho/Ct+W2/b+XbvCRTeQTmpFy/NfAKm/TWJa5zPvuv/iLTjGkVos9SHw/DteESpQ==} peerDependencies: @@ -857,6 +868,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -956,6 +970,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -978,6 +996,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1026,6 +1047,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-transition-group@4.4.2: + resolution: {integrity: sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1535,6 +1562,15 @@ snapshots: dependencies: react: 18.3.1 + '@mantine/notifications@6.0.22(@mantine/core@6.0.22(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mantine/core': 6.0.22(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(@mantine/hooks@6.0.22(react@18.3.1))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 6.0.22(react@18.3.1) + '@mantine/utils': 6.0.22(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/styles@6.0.22(@emotion/react@11.14.0(@types/react@18.3.27)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.27)(react@18.3.1) @@ -2036,6 +2072,11 @@ snapshots: detect-node-es@1.1.0: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.6 + csstype: 3.2.3 + electron-to-chromium@1.5.267: {} error-ex@1.3.4: @@ -2132,6 +2173,8 @@ snapshots: node-releases@2.0.27: {} + object-assign@4.1.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2155,6 +2198,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -2201,6 +2250,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-transition-group@4.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.6 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/src/Terrabuild.UI/src/App.tsx b/src/Terrabuild.UI/src/App.tsx index 54514067..369c924f 100644 --- a/src/Terrabuild.UI/src/App.tsx +++ b/src/Terrabuild.UI/src/App.tsx @@ -17,6 +17,7 @@ import { useMantineTheme, useMantineColorScheme, } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; import ReactFlow, { Background, Controls, @@ -36,6 +37,7 @@ import { FitAddon } from "xterm-addon-fit"; import "xterm/css/xterm.css"; import { IconAffiliate, + IconCopy, IconMoon, IconSun, IconSquareRoundedChevronDown, @@ -483,24 +485,108 @@ const App = () => { terminal.current.write(chunk); } } + try { + const params = new URLSearchParams(); + selectedTargets.forEach((target) => params.append("targets", target)); + selectedProjects.forEach((project) => params.append("projects", project)); + const statusResponse = await fetch( + `/api/build/status?${params.toString()}` + ); + if (statusResponse.ok) { + const statusData = (await statusResponse.json()) as ProjectStatus[]; + const hasFailure = statusData.some((item) => item.status === "failed"); + notifications.show({ + color: hasFailure ? "red" : "green", + title: hasFailure ? "Build completed with failures" : "Build completed", + message: hasFailure + ? "One or more targets failed." + : "All targets completed successfully.", + }); + } else { + notifications.show({ + color: "yellow", + title: "Build completed", + message: "Unable to fetch build status.", + }); + } + } catch { + notifications.show({ + color: "yellow", + title: "Build completed", + message: "Unable to fetch build status.", + }); + } setBuildRunning(false); }; - const startBuild = async () => { - if (selectedTargets.length === 0 || buildRunning) { - return; - } - setBuildRunning(true); - setBuildError(null); + const buildPayload = () => { const parallel = parallelism.trim().length > 0 ? Number(parallelism) : null; - const payload = { + return { targets: selectedTargets, projects: selectedProjects, parallelism: parallel && parallel > 0 ? parallel : undefined, force: forceBuild, retry: retryBuild, }; + }; + + const copyTextToClipboard = async (value: string) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + }; + + const copyBuildCommand = async () => { + if (selectedTargets.length === 0) { + return; + } + try { + const response = await fetch("/api/build/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildPayload()), + }); + if (!response.ok) { + notifications.show({ + color: "red", + title: "Copy failed", + message: "Failed to generate build command.", + }); + return; + } + const command = await response.text(); + await copyTextToClipboard(command); + notifications.show({ + color: "green", + title: "Copied", + message: "Build command copied to clipboard.", + }); + } catch { + notifications.show({ + color: "red", + title: "Copy failed", + message: "Failed to copy build command.", + }); + } + }; + + const startBuild = async () => { + if (selectedTargets.length === 0 || buildRunning) { + return; + } + setBuildRunning(true); + setBuildError(null); + const payload = buildPayload(); const response = await fetch("/api/build", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -508,11 +594,27 @@ const App = () => { }); if (!response.ok) { setBuildRunning(false); - setBuildError(await response.text()); + const message = await response.text(); + setBuildError(message); + notifications.show({ + color: "red", + title: "Build failed to start", + message, + }); return; } + notifications.show({ + color: "blue", + title: "Build started", + message: "Build is running.", + }); startLogStream().catch(() => { setBuildRunning(false); + notifications.show({ + color: "red", + title: "Build failed", + message: "Build log stream failed.", + }); }); }; @@ -744,18 +846,31 @@ const App = () => { }} /> - + + + + + + {buildError && ( {buildError} )} + diff --git a/src/Terrabuild.UI/src/main.tsx b/src/Terrabuild.UI/src/main.tsx index 6e2bae13..d531c0da 100644 --- a/src/Terrabuild.UI/src/main.tsx +++ b/src/Terrabuild.UI/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { ColorScheme, ColorSchemeProvider, MantineProvider } from "@mantine/core"; +import { Notifications } from "@mantine/notifications"; import { useLocalStorage } from "@mantine/hooks"; import App from "./App"; import "./styles.css"; @@ -25,6 +26,7 @@ const Root = () => { withNormalizeCSS theme={{ colorScheme }} > + diff --git a/src/Terrabuild/Web/GraphServer.fs b/src/Terrabuild/Web/GraphServer.fs index 5464ed73..6c133e86 100644 --- a/src/Terrabuild/Web/GraphServer.fs +++ b/src/Terrabuild/Web/GraphServer.fs @@ -431,6 +431,26 @@ let start (graphArgs: ParseResults) = })) |> ignore + app.MapPost("/api/build/command", Func>(fun ctx -> + task { + let! body = readBody ctx + let request = + try + Json.Deserialize body |> Ok + with ex -> + Error ex.Message + match request with + | Error err -> return Results.BadRequest(err) + | Ok request -> + let targets = request.Targets |> Option.defaultValue [] + if targets.IsEmpty then + return Results.BadRequest("At least one target is required.") + else + let command, args = createBuildCommand workspace request + return Results.Text($"{command} {args}", "text/plain") + })) + |> ignore + app.MapGet("/api/build/log", Func(fun ctx -> task { ctx.Response.Headers.CacheControl <- "no-cache" From 09712f84993241d16daef119032e491b2efce0c0 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Thu, 15 Jan 2026 21:59:15 +0100 Subject: [PATCH 2/2] better constrast for notifications --- src/Terrabuild.UI/src/main.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Terrabuild.UI/src/main.tsx b/src/Terrabuild.UI/src/main.tsx index d531c0da..6f7181d9 100644 --- a/src/Terrabuild.UI/src/main.tsx +++ b/src/Terrabuild.UI/src/main.tsx @@ -16,6 +16,20 @@ const Root = () => { const toggleColorScheme = (value?: ColorScheme) => setColorScheme(value || (colorScheme === "dark" ? "light" : "dark")); + const notificationTheme = { + colorScheme, + components: { + Notification: { + styles: (theme: any) => ({ + root: { + border: `1px solid ${theme.colors.gray[3]}`, + borderRadius: 8, + }, + }), + }, + }, + }; + return ( {