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..6f7181d9 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";
@@ -15,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 (
{
+
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"