Compare commits

...

39 Commits

Author SHA1 Message Date
M Atif Ali 999b3ddc39 fix(ci): reconcile branch preview DNS and TLS each deploy 2026-02-11 22:45:00 +05:00
M Atif Ali d82a386415 fix(ci): align branch deploy naming across templates 2026-02-11 22:22:38 +05:00
M Atif Ali b2dc2c2f5c fix(ci): tolerate existing branch DNS records 2026-02-11 22:12:53 +05:00
M Atif Ali 6db2ca345a chore(ci): remove harden-runner from branch deploy workflow 2026-02-11 22:02:31 +05:00
M Atif Ali 5f380216d4 chore(ci): use branch vars in branch deploy workflow 2026-02-11 19:35:14 +05:00
M Atif Ali e7131115d5 fix(ci): harden branch deploy DNS and cert waits 2026-02-11 19:29:40 +05:00
M Atif Ali 320a44913c ci: fix deploy-only dispatch gating 2026-02-11 17:55:34 +05:00
M Atif Ali de1795952c ci: add temporary deploy-only dispatch path 2026-02-11 17:54:33 +05:00
M Atif Ali 7f7f147783 ci: harden branch deploy failure detection 2026-02-11 17:51:39 +05:00
M Atif Ali 645029fb11 ci: add self-contained branch deploy workflow 2026-02-11 17:36:13 +05:00
dependabot[bot] 0938981ebf chore: bump github.com/go-git/go-git/v5 from 5.16.2 to 5.16.5 (#22016)
Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git)
from 5.16.2 to 5.16.5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-git/go-git/releases">github.com/go-git/go-git/v5's
releases</a>.</em></p>
<blockquote>
<h2>v5.16.5</h2>
<h2>What's Changed</h2>
<ul>
<li>build: Update module golang.org/x/crypto to v0.45.0 [SECURITY]
(releases/v5.x) by <a
href="https://github.com/go-git-renovate"><code>@​go-git-renovate</code></a>[bot]
in <a
href="https://redirect.github.com/go-git/go-git/pull/1744">go-git/go-git#1744</a></li>
<li>build: Bump Go test versions to 1.23-1.25 (v5) by <a
href="https://github.com/pjbgf"><code>@​pjbgf</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1746">go-git/go-git#1746</a></li>
<li>[v5] git: worktree, Don't delete local untracked files when
resetting worktree by <a
href="https://github.com/Ch00k"><code>@​Ch00k</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1800">go-git/go-git#1800</a></li>
<li>Expand packfile checks by <a
href="https://github.com/pjbgf"><code>@​pjbgf</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1836">go-git/go-git#1836</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-git/go-git/compare/v5.16.4...v5.16.5">https://github.com/go-git/go-git/compare/v5.16.4...v5.16.5</a></p>
<h2>v5.16.4</h2>
<h2>What's Changed</h2>
<ul>
<li>backport plumbing: format/idxfile, prevent panic by <a
href="https://github.com/swills"><code>@​swills</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1732">go-git/go-git#1732</a></li>
<li>[backport] build: test, Fix build on Windows. by <a
href="https://github.com/pjbgf"><code>@​pjbgf</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1734">go-git/go-git#1734</a></li>
<li>build: Update module golang.org/x/net to v0.38.0 [SECURITY]
(releases/v5.x) by <a
href="https://github.com/go-git-renovate"><code>@​go-git-renovate</code></a>[bot]
in <a
href="https://redirect.github.com/go-git/go-git/pull/1742">go-git/go-git#1742</a></li>
<li>build: Update module github.com/cloudflare/circl to v1.6.1
[SECURITY] (releases/v5.x) by <a
href="https://github.com/go-git-renovate"><code>@​go-git-renovate</code></a>[bot]
in <a
href="https://redirect.github.com/go-git/go-git/pull/1741">go-git/go-git#1741</a></li>
<li>build: Update module github.com/go-git/go-git/v5 to v5.13.0
[SECURITY] (releases/v5.x) by <a
href="https://github.com/go-git-renovate"><code>@​go-git-renovate</code></a>[bot]
in <a
href="https://redirect.github.com/go-git/go-git/pull/1743">go-git/go-git#1743</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-git/go-git/compare/v5.16.3...v5.16.4">https://github.com/go-git/go-git/compare/v5.16.3...v5.16.4</a></p>
<h2>v5.16.3</h2>
<h2>What's Changed</h2>
<ul>
<li>internal: Expand regex to fix build [5.x] by <a
href="https://github.com/baloo"><code>@​baloo</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1644">go-git/go-git#1644</a></li>
<li>build: raise timeouts for windows CI tests and disable CIFuzz [5.x]
by <a href="https://github.com/baloo"><code>@​baloo</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1646">go-git/go-git#1646</a></li>
<li>plumbing: support commits extra headers, support jujutsu signed
commit [5.x] by <a
href="https://github.com/baloo"><code>@​baloo</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1633">go-git/go-git#1633</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-git/go-git/compare/v5.16.2...v5.16.3">https://github.com/go-git/go-git/compare/v5.16.2...v5.16.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-git/go-git/commit/48a1ae05eec4fff4dd0343744a00bf8d6a7a0b4b"><code>48a1ae0</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1836">#1836</a>
from go-git/check-v5</li>
<li><a
href="https://github.com/go-git/go-git/commit/42bdf1f9044e2145acaed6ac4dbf1b8d257da5bd"><code>42bdf1f</code></a>
storage: filesystem, Verify idx matches pack file</li>
<li><a
href="https://github.com/go-git/go-git/commit/4146a5653f186f90057afecb7e0addd9e623cf19"><code>4146a56</code></a>
plumbing: format/idxfile, Verify idxfile's checksum</li>
<li><a
href="https://github.com/go-git/go-git/commit/63d78ec080cb176f8cd7bf46ce14f4ba01c1d2e5"><code>63d78ec</code></a>
plumbing: format/packfile, Add new ErrMalformedPackFile</li>
<li><a
href="https://github.com/go-git/go-git/commit/25f1624754395a0c67839e71b34956c853f2eb3d"><code>25f1624</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1800">#1800</a>
from Ch00k/no-delete-untracked-v5</li>
<li><a
href="https://github.com/go-git/go-git/commit/600fb139079e3c6886fcfeb20021c707e99e29b4"><code>600fb13</code></a>
git: worktree, Don't delete local untracked files when resetting
worktree</li>
<li><a
href="https://github.com/go-git/go-git/commit/390a56941510fdc19276aa298228d61889aad97a"><code>390a569</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1746">#1746</a>
from pjbgf/bump-go</li>
<li><a
href="https://github.com/go-git/go-git/commit/61c8b859ce3366257354695e99d78fc3739b60fb"><code>61c8b85</code></a>
build: Bump Go test versions to 1.23-1.25 (v5)</li>
<li><a
href="https://github.com/go-git/go-git/commit/e5a05ecd4fb91dc5323ec77667346ae94d84c043"><code>e5a05ec</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1744">#1744</a>
from go-git/renovate/releases/v5.x-go-golang.org-x-c...</li>
<li><a
href="https://github.com/go-git/go-git/commit/1495930b098b5e72394ae8ccc2d9396b8aa7e013"><code>1495930</code></a>
plumbing: Remove use of non-constant format strings</li>
<li>Additional commits viewable in <a
href="https://github.com/go-git/go-git/compare/v5.16.2...v5.16.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/go-git/go-git/v5&package-manager=go_modules&previous-version=5.16.2&new-version=5.16.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts page](https://github.com/coder/coder/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 08:37:52 +00:00
Jake Howell 87b382cc85 fix: resolve small screen layout for /tasks (#22036)
When discussing the changes needed for #22032 I was complaining about
how the `overflow-hidden` didn't work correctly so we could safely
remove it.

To continue these changes, I've refactored down how we work on mobile
within these triggers and enable full truncating and `max-w-`'s on each
of the content. Everything stemmed from the `<fieldset />` having a
`width: max-content` causing the content to extend past the bounds of
the container with `flex` in-toe.

Furthermore, the `(Default)` on `Preset` has been turned into a badge so
that we get the full truncation effect as we do with `Template Version`.

Follow-up improvements here might be to wrap the content of this input
on smaller displays.

### Preview

Top is the old, bottom is the new.

<img width="924" height="594" alt="preview"
src="https://github.com/user-attachments/assets/c1bbf152-03a6-4cad-b925-aad0549536a7"
/>
2026-02-11 13:26:54 +11:00
George K be94af386c chore(coderd/database): enforce workspace ACL JSON object constraints (#22019)
The constraints prevent faulty code from saving 'null' as JSON and breaking the `workspaces_expanded` view.
2026-02-10 16:17:29 -08:00
ケイラ e27c4dcd92 chore: replace usage of forwardRef with ref as a prop (#21956) 2026-02-10 16:41:20 -07:00
Cian Johnston c2c2b6f16f chore: remove call to taskname.Generate in dbgen (#22040)
I was trying to figure out why `goleak` was complaining about a dangling
http2 connection goroutine in tests. Turns out that `taskname.Generate`
will call out to Anthropic if an API key is set, and we're calling it in
`dbgen`. Modified to use testutil method instead.
2026-02-10 19:16:44 +00:00
Jake Howell 058f8f1f7c feat: remove emojimart external call to jsdelivr (#22034)
This pull-request ensures we don't have any reason to call out
externally to `jsdelivr`. Nobody has complained about this yet, but in
an [air-gapped environment](https://coder.com/docs/install/airgap) I
foresaw an issue where this might try to reach-out and fail to load the
image.

New behaviour for the spritesheet.

```diff
- https://cdn.jsdelivr.net/npm/emoji-datasource-apple@15.0.1/img/apple/sheets-256/64.png
+ /emojis/spritesheet.png
```
2026-02-11 03:43:23 +11:00
Danielle Maywood 0ab54fd63a fix(site): remove overflow-hidden (#22032)
Fixes an issue where the outline on the tasks page preset selector was
obscured by `overflow-hidden`.
2026-02-10 16:24:11 +00:00
Jake Howell 6ac0244960 fix: implement debounce <WorkspaceParametersPageViewExperimental /> (#22029)
Closes #22028

This pull-request simply takes debounces the message sent to our
web-socket backend and debounces it to ensure we're not overwriting the
users input as they type. As an added bonus this will debounce message
spam if people are going crazy on Radio Items or similar.

An extra flavour bit of flavour with resolving a good use-case for
`cn()` in diagnostic errors 🙂
2026-02-11 00:12:57 +11:00
Susana Ferreira 6338be3b30 chore: remove aibridgeproxyd README (#22027)
This README was unintentionally reintroduced during a Graphite stack
merge. It was removed in commit 910edbc2c6
on #21296, but upstack PR #21390 still had the old branch state with the
file, so it got merged back in. This PR removes the file.

The up-to-date documentation for AI Bridge Proxy can be found in
https://github.com/coder/coder/tree/main/docs/ai-coder/ai-bridge/ai-bridge-proxy
2026-02-10 10:45:26 +00:00
Paweł Banaszewski 72d7b6567b chore: update aibridge version (#22024)
Updates AI Bridge library to apply fix:
https://github.com/coder/aibridge/pull/174
2026-02-10 09:34:53 +00:00
Jake Howell 342d2e4bed feat: refactor <AuditLogRow* /> (#22014)
This pull-request takes the MUI based components from `<AuditLogRow />`
and its subsidiaries and updates them to use the correct newer Tailwind
based components.
2026-02-10 16:10:19 +11:00
Jake Howell 8bcfeab500 chore: revert "fix: apply overflow-y: scroll to <html /> (#21988)" (#22017)
This reverts commit 5224387c5a.

This is causing layout shifts to `0,0` when attempting to open
dropdowns. Something more battle-tested is needed unfortunately, Radix +
Scrollgutters is really annoying.
2026-02-10 03:09:13 +00:00
Jake Howell 5224387c5a fix: apply overflow-y: scroll to <html /> (#21988) 2026-02-10 12:16:44 +11:00
ケイラ 52af6eac68 fix: extend app test timeout (#21934) 2026-02-09 15:02:41 -07:00
Ehab Younes 8990a107a0 feat(site): add pause/resume actions to task page (#21952)
Add the ability to pause a running task and resume a paused task directly
from the TaskPage. This includes showing contextual messages when a task
is paused (manual vs timeout) and proper error handling with dialogs for
API errors.

- Extract task action logic into reusable mutations (api/queries/tasks.ts)
- Move TaskActionButton to modules/tasks for better organization
- Add pause button to TaskStartingAgent component
- Show appropriate state messages for transitioning states (pausing,
  canceling, deleting)
2026-02-09 23:37:44 +03:00
blinkagent[bot] 53ceea918b docs: remove broken image reference in contributing guide (#22013)
The "Deploy PR manually" image (`deploy-pr-manually.png`) referenced in
the contributing docs has never existed in the repository, resulting in
a broken image on the [docs
site](https://coder.com/docs/about/contributing/CONTRIBUTING#deploying-a-pr).

This PR removes the broken `<Image>` tag and ends the sentence with a
period instead. The `pr-deploy.yaml` workflow link remains intact for
users to navigate to the workflow dispatch page directly.

Created on behalf of @DavidFrawormo

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-02-09 15:50:47 +00:00
dependabot[bot] 19d24075da ci: bump the github-actions group with 4 updates (#22010)
Bumps the github-actions group with 4 updates:
[actions/cache](https://github.com/actions/cache),
[docker/login-action](https://github.com/docker/login-action),
[actions/attest](https://github.com/actions/attest) and
[nix-community/cache-nix-action](https://github.com/nix-community/cache-nix-action).

Updates `actions/cache` from 5.0.2 to 5.0.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/releases">actions/cache's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.3">https://github.com/actions/cache/compare/v5...v5.0.3</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's
changelog</a>.</em></p>
<blockquote>
<h1>Releases</h1>
<h2>How to prepare a release</h2>
<blockquote>
<p>[!NOTE]<br />
Relevant for maintainers with write access only.</p>
</blockquote>
<ol>
<li>Switch to a new branch from <code>main</code>.</li>
<li>Run <code>npm test</code> to ensure all tests are passing.</li>
<li>Update the version in <a
href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li>
<li>Run <code>npm run build</code> to update the compiled files.</li>
<li>Update this <a
href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a>
with the new version and changes in the <code>## Changelog</code>
section.</li>
<li>Run <code>licensed cache</code> to update the license report.</li>
<li>Run <code>licensed status</code> and resolve any warnings by
updating the <a
href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a>
file with the exceptions.</li>
<li>Commit your changes and push your branch upstream.</li>
<li>Open a pull request against <code>main</code> and get it reviewed
and merged.</li>
<li>Draft a new release <a
href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a>
use the same version number used in <code>package.json</code>
<ol>
<li>Create a new tag with the version number.</li>
<li>Auto generate release notes and update them to match the changes you
made in <code>RELEASES.md</code>.</li>
<li>Toggle the set as the latest release option.</li>
<li>Publish the release.</li>
</ol>
</li>
<li>Navigate to <a
href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a>
<ol>
<li>There should be a workflow run queued with the same version
number.</li>
<li>Approve the run to publish the new version and update the major tags
for this action.</li>
</ol>
</li>
</ol>
<h2>Changelog</h2>
<h3>5.0.3</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<h3>5.0.2</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.3 <a
href="https://redirect.github.com/actions/cache/pull/1692">#1692</a></li>
</ul>
<h3>5.0.1</h3>
<ul>
<li>Update <code>@azure/storage-blob</code> to <code>^12.29.1</code> via
<code>@actions/cache@5.0.1</code> <a
href="https://redirect.github.com/actions/cache/pull/1685">#1685</a></li>
</ul>
<h3>5.0.0</h3>
<blockquote>
<p>[!IMPORTANT]
<code>actions/cache@v5</code> runs on the Node.js 24 runtime and
requires a minimum Actions Runner version of <code>2.327.1</code>.
If you are using self-hosted runners, ensure they are updated before
upgrading.</p>
</blockquote>
<h3>4.3.0</h3>
<ul>
<li>Bump <code>@actions/cache</code> to <a
href="https://redirect.github.com/actions/toolkit/pull/2132">v4.1.0</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/cache/commit/cdf6c1fa76f9f475f3d7449005a359c84ca0f306"><code>cdf6c1f</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1695">#1695</a>
from actions/Link-/prepare-5.0.3</li>
<li><a
href="https://github.com/actions/cache/commit/a1bee22673bee4afb9ce4e0a1dc3da1c44060b7d"><code>a1bee22</code></a>
Add review for the <code>@​actions/http-client</code> license</li>
<li><a
href="https://github.com/actions/cache/commit/46957638dc5c5ff0c34c0143f443c07d3a7c769f"><code>4695763</code></a>
Add licensed output</li>
<li><a
href="https://github.com/actions/cache/commit/dc73bb9f7bf74a733c05ccd2edfd1f2ac9e5f502"><code>dc73bb9</code></a>
Upgrade dependencies and address security warnings</li>
<li><a
href="https://github.com/actions/cache/commit/345d5c2f761565bace4b6da356737147e9041e3a"><code>345d5c2</code></a>
Add 5.0.3 builds</li>
<li>See full diff in <a
href="https://github.com/actions/cache/compare/8b402f58fbc84540c8b491a91e594a4576fec3d7...cdf6c1fa76f9f475f3d7449005a359c84ca0f306">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/login-action` from 3.6.0 to 3.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v3.7.0</h2>
<ul>
<li>Add <code>scope</code> input to set scopes for the authentication
token by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/912">docker/login-action#912</a></li>
<li>Add support for AWS European Sovereign Cloud ECR by <a
href="https://github.com/dphi"><code>@​dphi</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/914">docker/login-action#914</a></li>
<li>Ensure passwords are redacted with <code>registry-auth</code> input
by <a href="https://github.com/crazy-max"><code>@​crazy-max</code></a>
in <a
href="https://redirect.github.com/docker/login-action/pull/911">docker/login-action#911</a></li>
<li>build(deps): bump lodash from 4.17.21 to 4.17.23 in <a
href="https://redirect.github.com/docker/login-action/pull/915">docker/login-action#915</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v3.6.0...v3.7.0">https://github.com/docker/login-action/compare/v3.6.0...v3.7.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/c94ce9fb468520275223c153574b00df6fe4bcc9"><code>c94ce9f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/915">#915</a>
from docker/dependabot/npm_and_yarn/lodash-4.17.23</li>
<li><a
href="https://github.com/docker/login-action/commit/8339c958ce8511f38d0c474c1886a87c802bf1ef"><code>8339c95</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/912">#912</a>
from docker/scope</li>
<li><a
href="https://github.com/docker/login-action/commit/c83e9320c8beb50b77dd007c46d5c8161f0cac4a"><code>c83e932</code></a>
build(deps): bump lodash from 4.17.21 to 4.17.23</li>
<li><a
href="https://github.com/docker/login-action/commit/b268aa57e39ff0a5386d2fd1eded4e2e1d60d705"><code>b268aa5</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/a60322927812ddc99316dd6252b4fba6d8f09ac1"><code>a603229</code></a>
documentation for scope input</li>
<li><a
href="https://github.com/docker/login-action/commit/7567f92a74b2639be1bd8bc932a112a0d81283da"><code>7567f92</code></a>
Add scope input to set scopes for the authentication token</li>
<li><a
href="https://github.com/docker/login-action/commit/0567fa5ae8c9a197cb207537dc5cbb43ca3d803f"><code>0567fa5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/914">#914</a>
from dphi/add-support-for-amazonaws.eu</li>
<li><a
href="https://github.com/docker/login-action/commit/f6ef57754547a85003a0e18f789be661346d4a6e"><code>f6ef577</code></a>
feat: add support for AWS European Sovereign Cloud ECR registries</li>
<li><a
href="https://github.com/docker/login-action/commit/916386b00027d425839f8da46d302dab33f5875b"><code>916386b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/911">#911</a>
from crazy-max/ensure-redact</li>
<li><a
href="https://github.com/docker/login-action/commit/5b3f94a294ea5478af3af437baa6ad0d3dcd04fd"><code>5b3f94a</code></a>
chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/5e57cd118135c172c3672efd75eb46360885c0ef...c94ce9fb468520275223c153574b00df6fe4bcc9">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/attest` from 3.1.0 to 3.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/attest/releases">actions/attest's
releases</a>.</em></p>
<blockquote>
<h2>v3.2.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump the npm-development group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/attest/pull/320">actions/attest#320</a></li>
<li>Validate repository org-ownership before storage record creation by
<a href="https://github.com/malancas"><code>@​malancas</code></a> in <a
href="https://redirect.github.com/actions/attest/pull/328">actions/attest#328</a></li>
<li>Update version to 3.2.0 by <a
href="https://github.com/malancas"><code>@​malancas</code></a> in <a
href="https://redirect.github.com/actions/attest/pull/334">actions/attest#334</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/attest/compare/v3.1.0...v3.2.0">https://github.com/actions/attest/compare/v3.1.0...v3.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/attest/commit/e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d"><code>e59cbc1</code></a>
Update version to 3.2.0 (<a
href="https://redirect.github.com/actions/attest/issues/334">#334</a>)</li>
<li><a
href="https://github.com/actions/attest/commit/20eb46ce7aac0a8d0fb0ba74463460bff36cc0bd"><code>20eb46c</code></a>
Validate repository org-ownership before storage record creation (<a
href="https://redirect.github.com/actions/attest/issues/328">#328</a>)</li>
<li><a
href="https://github.com/actions/attest/commit/7433fa7e7a4d4084bbd71358379fa9b45ce9d4d7"><code>7433fa7</code></a>
Update <code>undici</code> development dependency to the latest version
(<a
href="https://redirect.github.com/actions/attest/issues/332">#332</a>)</li>
<li><a
href="https://github.com/actions/attest/commit/c03bf4160d4018cb293f5dcbf204e47c1b2808e1"><code>c03bf41</code></a>
Bump the npm-development group with 3 updates (<a
href="https://redirect.github.com/actions/attest/issues/320">#320</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/attest/compare/7667f588f2f73a90cea6c7ac70e78266c4f76616...e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d">compare
view</a></li>
</ul>
</details>
<br />

Updates `nix-community/cache-nix-action` from 7.0.1 to 7.0.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/nix-community/cache-nix-action/releases">nix-community/cache-nix-action's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.2</h2>
<h2>What's Changed</h2>
<h2>Fixed</h2>
<ul>
<li>Fix: Nix versions under <code>v2.33</code> not supported by <a
href="https://github.com/deemp"><code>@​deemp</code></a> in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/295">nix-community/cache-nix-action#295</a></li>
<li>Use a more precise check by <a
href="https://github.com/deemp"><code>@​deemp</code></a> in
47869c4cbb023c803424e7311f07a744a2d66296</li>
</ul>
<h2>Changed (deps)</h2>
<!-- raw HTML omitted -->
<ul>
<li>chore(deps-dev): bump <code>@​typescript-eslint/eslint-plugin</code>
from 8.53.0 to 8.53.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/284">nix-community/cache-nix-action#284</a></li>
<li>chore(deps): bump DeterminateSystems/determinate-nix-action from
3.15.1 to 3.15.2 in the minor-actions-dependencies group by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/288">nix-community/cache-nix-action#288</a></li>
<li>chore(deps-dev): bump eslint-config-love from 144.0.0 to 147.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/287">nix-community/cache-nix-action#287</a></li>
<li>chore(deps-dev): bump prettier from 3.8.0 to 3.8.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/286">nix-community/cache-nix-action#286</a></li>
<li>chore(deps-dev): bump <code>@​typescript-eslint/parser</code> from
8.53.1 to 8.54.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/290">nix-community/cache-nix-action#290</a></li>
<li>chore(deps): bump <code>@​actions/github</code> from 7.0.0 to 8.0.0
by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/291">nix-community/cache-nix-action#291</a></li>
<li>chore(deps-dev): bump <code>@​typescript-eslint/eslint-plugin</code>
from 8.53.1 to 8.54.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/289">nix-community/cache-nix-action#289</a></li>
<li>chore(deps-dev): bump eslint-config-love from 147.0.0 to 149.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/nix-community/cache-nix-action/pull/294">nix-community/cache-nix-action#294</a></li>
</ul>
<!-- raw HTML omitted -->
<p><strong>Full Changelog</strong>: <a
href="https://github.com/nix-community/cache-nix-action/compare/v7...v7.0.2">https://github.com/nix-community/cache-nix-action/compare/v7...v7.0.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/7df957e333c1e5da7721f60227dbba6d06080569"><code>7df957e</code></a>
chore: build the action</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/47869c4cbb023c803424e7311f07a744a2d66296"><code>47869c4</code></a>
fix(action): use a more precise check</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/eca69c462eda8455304862773d53bfe08a7c1fad"><code>eca69c4</code></a>
Merge pull request <a
href="https://redirect.github.com/nix-community/cache-nix-action/issues/295">#295</a>
from nix-community/nix-versions-under-v233-not-supported</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/b6fd2e3f7b9992c952409248b26c3806976ca922"><code>b6fd2e3</code></a>
feat(ci): add test with Nix version &lt;2.33</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/ddd9cbc8ee25d0dbd64bc7bf380398d810fedcc0"><code>ddd9cbc</code></a>
fix(ci): bump action version</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/922e9060c19ec2c406a055d4255ec1760e0af798"><code>922e906</code></a>
chore: build the action</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/4038f94ae961f71f156295e34fc27af3846cb555"><code>4038f94</code></a>
refactor(action): rename constants for command results</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/dfde4d35b86aa2875e5829cfc8b6c2d4c203ab9b"><code>dfde4d3</code></a>
fix(action): choose command based on the Nix version</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/4b2dd9ec99b6d72fad66eeff381bc94d20d7207d"><code>4b2dd9e</code></a>
Merge pull request <a
href="https://redirect.github.com/nix-community/cache-nix-action/issues/294">#294</a>
from nix-community/dependabot/npm_and_yarn/eslint-con...</li>
<li><a
href="https://github.com/nix-community/cache-nix-action/commit/273d1a77100543feec627c2bdd09b6c7060b88ab"><code>273d1a7</code></a>
chore(deps-dev): bump eslint-config-love from 147.0.0 to 149.0.0</li>
<li>Additional commits viewable in <a
href="https://github.com/nix-community/cache-nix-action/compare/106bba72ed8e29c8357661199511ef07790175e9...7df957e333c1e5da7721f60227dbba6d06080569">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 13:35:13 +00:00
dependabot[bot] d017c27eaf chore: bump google.golang.org/api from 0.264.0 to 0.265.0 (#22007)
Bumps
[google.golang.org/api](https://github.com/googleapis/google-api-go-client)
from 0.264.0 to 0.265.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/releases">google.golang.org/api's
releases</a>.</em></p>
<blockquote>
<h2>v0.265.0</h2>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.264.0...v0.265.0">0.265.0</a>
(2026-02-04)</h2>
<h3>Features</h3>
<ul>
<li>Add checksums for single chunk json uploads (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3448">#3448</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0f1cb7b9b71b8f21e2bb14d69bd1e11a1ca7a9ff">0f1cb7b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3473">#3473</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/e617dd5dc920921e5fff184be3c33a8ab9c8ce41">e617dd5</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3476">#3476</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/986f55600724d148e102413766cfbdc278adba38">986f556</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3477">#3477</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/cdb1738722afcceb26e6d4be934bac46682c1c25">cdb1738</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3479">#3479</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/2aa3478d4e2a94b30eb6873ff5b41cffef0e89bd">2aa3478</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3480">#3480</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/29bd84381608db3db0385bd8f4544af458df7329">29bd843</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3482">#3482</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/afa65b7fb9b586aac07247474fdd1efc5812e824">afa65b7</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md">google.golang.org/api's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.264.0...v0.265.0">0.265.0</a>
(2026-02-04)</h2>
<h3>Features</h3>
<ul>
<li>Add checksums for single chunk json uploads (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3448">#3448</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0f1cb7b9b71b8f21e2bb14d69bd1e11a1ca7a9ff">0f1cb7b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3473">#3473</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/e617dd5dc920921e5fff184be3c33a8ab9c8ce41">e617dd5</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3476">#3476</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/986f55600724d148e102413766cfbdc278adba38">986f556</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3477">#3477</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/cdb1738722afcceb26e6d4be934bac46682c1c25">cdb1738</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3479">#3479</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/2aa3478d4e2a94b30eb6873ff5b41cffef0e89bd">2aa3478</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3480">#3480</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/29bd84381608db3db0385bd8f4544af458df7329">29bd843</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3482">#3482</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/afa65b7fb9b586aac07247474fdd1efc5812e824">afa65b7</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/e6edc1df27af3ccdceb9ec580e4e4189500e154f"><code>e6edc1d</code></a>
chore(main): release 0.265.0 (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3474">#3474</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/afa65b7fb9b586aac07247474fdd1efc5812e824"><code>afa65b7</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3482">#3482</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/0554404d716233619aee04791086c3fca768129f"><code>0554404</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3466">#3466</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/84932f3abee6aaff6e00d04099c1a10b69d8963d"><code>84932f3</code></a>
chore: replace old go teams with cloud-sdk-go-team (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3475">#3475</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/242927a161200a778bd00dc8ff3136e5eea85b53"><code>242927a</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3469">#3469</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/0f1cb7b9b71b8f21e2bb14d69bd1e11a1ca7a9ff"><code>0f1cb7b</code></a>
feat: add checksums for single chunk json uploads (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3448">#3448</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/e92945d638f320e93a83d875f0590c57d43396f4"><code>e92945d</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3470">#3470</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/ba218c11dc7d70f76529b2084eff74d4c252e8d0"><code>ba218c1</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3468">#3468</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/2e7d0f51983a1b4d905ac01669777b9d3910064d"><code>2e7d0f5</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3471">#3471</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/460b37cbd6a873dff58046a15abb1b0289d956ec"><code>460b37c</code></a>
chore: Migrate gsutil usage to gcloud storage (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3467">#3467</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/googleapis/google-api-go-client/compare/v0.264.0...v0.265.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.264.0&new-version=0.265.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 13:26:56 +00:00
dependabot[bot] 0bab4a2042 chore: bump the x group with 2 updates (#22005)
Bumps the x group with 2 updates:
[golang.org/x/oauth2](https://github.com/golang/oauth2) and
[golang.org/x/sys](https://github.com/golang/sys).

Updates `golang.org/x/oauth2` from 0.34.0 to 0.35.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/oauth2/commit/89ff2e1ac388c1a234a687cb2735341cde3f7122"><code>89ff2e1</code></a>
google: add safer credentials JSON loading options.</li>
<li>See full diff in <a
href="https://github.com/golang/oauth2/compare/v0.34.0...v0.35.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/sys` from 0.40.0 to 0.41.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/sys/commit/fc646e489fd944b6f77d327ab77f1a4bab81d5ad"><code>fc646e4</code></a>
cpu: use IsProcessorFeaturePresent to calculate ARM64 on windows</li>
<li><a
href="https://github.com/golang/sys/commit/f11c7bb268eb8a49f5a42afe15387a159a506935"><code>f11c7bb</code></a>
windows: add IsProcessorFeaturePresent and processor feature consts</li>
<li><a
href="https://github.com/golang/sys/commit/d25a7aaff8c2b056b2059fd7065afe1d4132e082"><code>d25a7aa</code></a>
unix: add IoctlSetString on all platforms</li>
<li><a
href="https://github.com/golang/sys/commit/6fb913b30f367555467f08da4d60f49996c9b17a"><code>6fb913b</code></a>
unix: return early on error in Recvmsg</li>
<li>See full diff in <a
href="https://github.com/golang/sys/compare/v0.40.0...v0.41.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 13:26:42 +00:00
dependabot[bot] f3cd74d9d8 chore: bump rust from df6ca8f to 760ad1d in /dogfood/coder (#22009)
Bumps rust from `df6ca8f` to `760ad1d`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rust&package-manager=docker&previous-version=slim&new-version=slim)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 13:26:12 +00:00
dependabot[bot] e3b4099c9d chore: bump github.com/prometheus-community/pro-bing from 0.7.0 to 0.8.0 (#22006)
Bumps
[github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing)
from 0.7.0 to 0.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/prometheus-community/pro-bing/releases">github.com/prometheus-community/pro-bing's
releases</a>.</em></p>
<blockquote>
<h2>v0.8.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Synchronize common files from prometheus/prometheus by <a
href="https://github.com/prombot"><code>@​prombot</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/155">prometheus-community/pro-bing#155</a></li>
<li>Bump golang.org/x/net from 0.38.0 to 0.39.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/154">prometheus-community/pro-bing#154</a></li>
<li>Synchronize common files from prometheus/prometheus by <a
href="https://github.com/prombot"><code>@​prombot</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/161">prometheus-community/pro-bing#161</a></li>
<li>Set ping traffic class to zero by default by <a
href="https://github.com/floatingstatic"><code>@​floatingstatic</code></a>
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/168">prometheus-community/pro-bing#168</a></li>
<li>Bump golang.org/x/net from 0.39.0 to 0.44.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/169">prometheus-community/pro-bing#169</a></li>
<li>Synchronize common files from prometheus/prometheus by <a
href="https://github.com/prombot"><code>@​prombot</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/167">prometheus-community/pro-bing#167</a></li>
<li>Update build by <a
href="https://github.com/SuperQ"><code>@​SuperQ</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/172">prometheus-community/pro-bing#172</a></li>
<li>Bump golang.org/x/sync from 0.13.0 to 0.17.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/170">prometheus-community/pro-bing#170</a></li>
<li>feat: support setting ICMP source address for outgoing packets by <a
href="https://github.com/snormore"><code>@​snormore</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/171">prometheus-community/pro-bing#171</a></li>
<li>Synchronize common files from prometheus/prometheus by <a
href="https://github.com/prombot"><code>@​prombot</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/173">prometheus-community/pro-bing#173</a></li>
<li>Bump golang.org/x/net from 0.44.0 to 0.49.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/183">prometheus-community/pro-bing#183</a></li>
<li>Bump golang.org/x/sync from 0.17.0 to 0.19.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/181">prometheus-community/pro-bing#181</a></li>
<li>Synchronize common files from prometheus/prometheus by <a
href="https://github.com/prombot"><code>@​prombot</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/179">prometheus-community/pro-bing#179</a></li>
<li>Optimize BPF code to reject non-Echo Reply ICMP packets by <a
href="https://github.com/nvksie"><code>@​nvksie</code></a> in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/180">prometheus-community/pro-bing#180</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/snormore"><code>@​snormore</code></a>
made their first contribution in <a
href="https://redirect.github.com/prometheus-community/pro-bing/pull/171">prometheus-community/pro-bing#171</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/prometheus-community/pro-bing/compare/v0.7.0...v0.8.0">https://github.com/prometheus-community/pro-bing/compare/v0.7.0...v0.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/112c6d152733673e7e7b463bd8a339230536260d"><code>112c6d1</code></a>
Merge pull request <a
href="https://redirect.github.com/prometheus-community/pro-bing/issues/180">#180</a>
from nvksie/main</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/c0e523e8e6d005a91f5700083239f903cf39ef2f"><code>c0e523e</code></a>
Merge pull request <a
href="https://redirect.github.com/prometheus-community/pro-bing/issues/179">#179</a>
from prometheus-community/repo_sync</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/dc59983a3a2c41b8b5a2fb3781056a89dd7af680"><code>dc59983</code></a>
Merge pull request <a
href="https://redirect.github.com/prometheus-community/pro-bing/issues/181">#181</a>
from prometheus-community/dependabot/go_modules/golan...</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/3b320ae455af8dfe6e2462e49fcdbdad81bf164f"><code>3b320ae</code></a>
Bump golang.org/x/sync from 0.17.0 to 0.19.0</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/df60cdb87f3c9d6a0ddef2a184254f8e0f9afeb2"><code>df60cdb</code></a>
Merge pull request <a
href="https://redirect.github.com/prometheus-community/pro-bing/issues/183">#183</a>
from prometheus-community/dependabot/go_modules/golan...</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/22f264b8c85e8e2ffc53a21b2e775aabccbb4666"><code>22f264b</code></a>
Bump golang.org/x/net from 0.44.0 to 0.49.0</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/3e7f4fe13f3401f6c2ce76995c564b656749dc2a"><code>3e7f4fe</code></a>
optimize bpf filter, accept Echo Reply only</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/13271982908ad062b4ed542e1cb6a5c77fa7804c"><code>1327198</code></a>
Update common Prometheus files</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/3b66532b7fd1f7ca238988d3654eb48ab4ddc88a"><code>3b66532</code></a>
Merge pull request <a
href="https://redirect.github.com/prometheus-community/pro-bing/issues/173">#173</a>
from prometheus-community/repo_sync</li>
<li><a
href="https://github.com/prometheus-community/pro-bing/commit/4d98d366567dd8b581d39fe59a4c667876d38174"><code>4d98d36</code></a>
Update common Prometheus files</li>
<li>Additional commits viewable in <a
href="https://github.com/prometheus-community/pro-bing/compare/v0.7.0...v0.8.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/prometheus-community/pro-bing&package-manager=go_modules&previous-version=0.7.0&new-version=0.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 13:25:33 +00:00
Zach fa2481c650 test: add synctest-based aibridged cache expiry test (#21984)
Resolves the TODO in TestPool by adding TestPool_Expiry which uses Go
1.25's testing/synctest to verify TTL-based cache eviction.

I wanted to get familiar with the new `synctest` package in Go 1.25 and
found this TODO comment, so I decided to take a stab at it 😄
2026-02-09 15:09:40 +02:00
Jake Howell 2c0ffdd590 feat: refactor <TerminalAlerts /> component (#22004)
Quick easy and simple set of changes, with some added flavour. Removes
two use-cases of MUI-based components with our drop-in-place links.
Added a refresh icon to the `Refresh` button and added the external link
icon `➚` to all of the links as they all link out to `/docs` (this is
inline with the rest of the application).

|    |    |
|---|---|
| Old | <img width="1152" height="65" alt="ALERT_1"
src="https://github.com/user-attachments/assets/5e0a0ce3-29ef-4fa1-8793-8aa89d80c661"
/> |
| New | <img width="1152" height="65" alt="ALERT_1_FIX"
src="https://github.com/user-attachments/assets/7be1f0b7-1594-478c-b7c1-6f2288064e13"
/> |

|    |    |
|---|---|
| Old | <img width="1152" height="81" alt="ALERT_2"
src="https://github.com/user-attachments/assets/f8e4d65f-5aa1-408c-9149-0511c8367e3b"
/> |
| New | <img width="1152" height="81" alt="ALERT_2_FIX"
src="https://github.com/user-attachments/assets/230e0754-dd18-40d5-825d-5e5082fe806a"
/> |
2026-02-10 00:01:48 +11:00
Jake Howell e8fa04404f fix: remove @mui/ components from <ConnectionLog* /> (#22003)
Migrates `ConnectionLogRow` and `ConnectionLogDescription` off MUI and
Emotion. Replaces `@mui/material/Link` with the existing shadcn-based
`Link` component, swaps the deprecated `Stack` wrappers for plain divs
with Tailwind flex utilities, and converts all Emotion `css` prop styles
to Tailwind classes.

Also fixes a pre-existing lint issue where `tabIndex` was set on a
non-interactive div.
2026-02-09 23:20:44 +11:00
Jake Howell f11a8086b0 fix: migrate all uses of visuallyHidden (#22001)
Replace all usages of MUI's `visuallyHidden` utility from `@mui/utils`
with Tailwind's `sr-only` class. Both produce identical CSS, so this is
a no-op behaviorally -- just removes another MUI dependency from the
codebase. Also updates the accessibility example in the frontend
contributing docs to match.
2026-02-09 23:17:03 +11:00
Spike Curtis 95b3bc9c7a test: fix failnow in goroutine in TestServer_TelemetryDisabled_FinalReport (#21973)
closes: https://github.com/coder/internal/issues/1331

Fixes up an issue in the test where we end up calling `FailNow` outside
the main test goroutine. Also adds the ability to name a `ptytest.PTY`
for cases like this one where we start multiple commands. This will help
debugging if we see the issue again.

This doesn't address the root cause of the failure, but I think we
should close the flake issue. I think we'd need like a stacktrace of all
goroutines at the point of failing the test, but that's way too much
effort unless we see this again.
2026-02-09 14:20:57 +04:00
Cian Johnston 93b000776f fix(cli): revert #21583 (#22000)
Relates to https://github.com/coder/internal/issues/1217

This reverts commit f799cba395.

@deansheather reported that this breaks ControlMaster.

Investigating alternative fixes to coder/internal#1217
2026-02-09 09:56:33 +00:00
Sas Swart e6fbf501ac feat: add an endpoint to manually pause a coder task (#21889)
Closes https://github.com/coder/internal/issues/1261.

This pull request adds an endpoint to pause coder tasks by stopping the
underlying workspace.
* Instead of `POST /api/v2/tasks/{user}/{task}/pause`, the endpoint is
currently experimental.
* We do not currently set the build reason to `task_manual_pause`,
because build reasons are currently only used on stop transitions.
2026-02-09 08:56:41 +02:00
Dean Sheather d3036d569e chore: only run lint-actions job on CI changes (#21999)
It was split to reduce flaking, but still always ran on `main` anyways
2026-02-09 05:31:17 +00:00
101 changed files with 2896 additions and 1663 deletions
+370
View File
@@ -0,0 +1,370 @@
name: Deploy Branch
on:
push:
workflow_dispatch:
inputs:
deploy_only:
description: "Skip build and only run deploy (debug-only)."
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build:
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.deploy_only }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
packages: write
env:
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
set -euo pipefail
go mod download
make gen/mark-fresh
export DOCKER_IMAGE_NO_PREREQUISITES=true
version="$(./scripts/version.sh)"
CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
export CODER_IMAGE_BUILD_BASE_TAG
make -j build/coder_linux_amd64
./scripts/build_docker.sh \
--arch amd64 \
--target "${CODER_IMAGE_TAG}" \
--version "$version" \
--push \
build/coder_linux_amd64
deploy:
needs: build
if: ${{ always() && (needs.build.result == 'success' || (github.event_name == 'workflow_dispatch' && inputs.deploy_only && needs.build.result == 'skipped')) }}
runs-on: ubuntu-latest
env:
BRANCH_NAME: ${{ github.ref_name }}
DEPLOY_NAME: "pr${{ github.ref_name }}"
TEST_DOMAIN_SUFFIX: "${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
BRANCH_HOSTNAME: "${{ github.ref_name }}.${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
REPO: ghcr.io/coder/coder-preview
EXPERIMENTS: "*"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up kubeconfig
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
- name: Verify cluster authentication
run: |
set -euo pipefail
kubectl auth can-i get namespaces > /dev/null
- name: Check if deployment exists
id: check
run: |
set -euo pipefail
set +e
helm_status_output="$(helm status "${DEPLOY_NAME}" --namespace "${DEPLOY_NAME}" 2>&1)"
helm_status_code=$?
set -e
if [ "$helm_status_code" -eq 0 ]; then
echo "new=false" >> "$GITHUB_OUTPUT"
elif echo "$helm_status_output" | grep -qi "release: not found"; then
echo "new=true" >> "$GITHUB_OUTPUT"
else
echo "$helm_status_output"
exit "$helm_status_code"
fi
# ---- Every push: ensure routing + TLS ----
- name: Ensure DNS records
run: |
set -euo pipefail
api_base_url="https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records"
base_name="${BRANCH_HOSTNAME}"
base_target="${TEST_DOMAIN_SUFFIX}"
wildcard_name="*.${BRANCH_HOSTNAME}"
ensure_cname_record() {
local record_name="$1"
local record_content="$2"
echo "Ensuring CNAME ${record_name} -> ${record_content}."
set +e
lookup_raw_response="$(
curl -sS -G "${api_base_url}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data-urlencode "name=${record_name}" \
--data-urlencode "per_page=100" \
-w '\n%{http_code}'
)"
lookup_exit_code=$?
set -e
if [ "$lookup_exit_code" -eq 0 ]; then
lookup_response="${lookup_raw_response%$'\n'*}"
lookup_http_code="${lookup_raw_response##*$'\n'}"
if [ "$lookup_http_code" = "200" ] && echo "$lookup_response" | jq -e '.success == true' > /dev/null 2>&1; then
if echo "$lookup_response" | jq -e '.result[]? | select(.type != "CNAME")' > /dev/null 2>&1; then
echo "Conflicting non-CNAME DNS record exists for ${record_name}."
echo "$lookup_response"
return 1
fi
existing_cname_id="$(echo "$lookup_response" | jq -r '.result[]? | select(.type == "CNAME") | .id' | head -n1)"
if [ -n "$existing_cname_id" ]; then
existing_content="$(echo "$lookup_response" | jq -r --arg id "$existing_cname_id" '.result[] | select(.id == $id) | .content')"
if [ "$existing_content" = "$record_content" ]; then
echo "CNAME already set for ${record_name}."
return 0
fi
echo "Updating existing CNAME for ${record_name}."
update_response="$(
curl -sS -X PUT "${api_base_url}/${existing_cname_id}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}'
)"
if echo "$update_response" | jq -e '.success == true' > /dev/null 2>&1; then
echo "Updated CNAME for ${record_name}."
return 0
fi
echo "Cloudflare API error while updating ${record_name}:"
echo "$update_response"
return 1
fi
fi
else
echo "Could not query DNS record ${record_name}; attempting create."
fi
max_attempts=6
attempt=1
last_response=""
last_http_code=""
while [ "$attempt" -le "$max_attempts" ]; do
echo "Creating DNS record ${record_name} (attempt ${attempt}/${max_attempts})."
set +e
raw_response="$(
curl -sS -X POST "${api_base_url}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}' \
-w '\n%{http_code}'
)"
curl_exit_code=$?
set -e
curl_failed=false
if [ "$curl_exit_code" -eq 0 ]; then
response="${raw_response%$'\n'*}"
http_code="${raw_response##*$'\n'}"
else
response="curl exited with code ${curl_exit_code}."
http_code="000"
curl_failed=true
fi
last_response="$response"
last_http_code="$http_code"
if echo "$response" | jq -e '.success == true' > /dev/null 2>&1; then
echo "Created DNS record ${record_name}."
return 0
fi
# 81057: identical record exists. 81053: host record conflict.
if echo "$response" | jq -e '.errors[]? | select(.code == 81057 or .code == 81053)' > /dev/null 2>&1; then
echo "DNS record already exists for ${record_name}."
return 0
fi
transient_error=false
if [ "$curl_failed" = true ] || [ "$http_code" = "429" ]; then
transient_error=true
elif [[ "$http_code" =~ ^[0-9]{3}$ ]] && [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; then
transient_error=true
fi
if echo "$response" | jq -e '.errors[]? | select(.code == 10000 or .code == 10001)' > /dev/null 2>&1; then
transient_error=true
fi
if [ "$transient_error" = true ] && [ "$attempt" -lt "$max_attempts" ]; then
sleep_seconds=$((attempt * 5))
echo "Transient Cloudflare API error (HTTP ${http_code}). Retrying in ${sleep_seconds}s."
sleep "$sleep_seconds"
attempt=$((attempt + 1))
continue
fi
break
done
echo "Cloudflare API error while creating DNS record ${record_name} after ${attempt} attempt(s):"
echo "HTTP status: ${last_http_code}"
echo "$last_response"
return 1
}
ensure_cname_record "${base_name}" "${base_target}"
ensure_cname_record "${wildcard_name}" "${base_name}"
# ---- First deploy only ----
- name: Create namespace
if: steps.check.outputs.new == 'true'
run: |
set -euo pipefail
kubectl delete namespace "${DEPLOY_NAME}" || true
kubectl create namespace "${DEPLOY_NAME}"
# ---- Every push: ensure deployment certificate ----
- name: Ensure certificate
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
run: |
set -euo pipefail
cert_secret_name="${DEPLOY_NAME}-tls"
envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f -
if ! kubectl -n pr-deployment-certs wait --for=condition=Ready "certificate/${cert_secret_name}" --timeout=10m; then
echo "Timed out waiting for certificate ${cert_secret_name} to become Ready after 10 minutes."
kubectl -n pr-deployment-certs describe certificate "${cert_secret_name}" || true
kubectl -n pr-deployment-certs get certificaterequest,order,challenge -l "cert-manager.io/certificate-name=${cert_secret_name}" || true
exit 1
fi
kubectl get secret "${cert_secret_name}" -n pr-deployment-certs -o json |
jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' |
kubectl -n "${DEPLOY_NAME}" apply -f -
- name: Set up PostgreSQL
if: steps.check.outputs.new == 'true'
run: |
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install coder-db bitnami/postgresql \
--namespace "${DEPLOY_NAME}" \
--set image.repository=bitnamilegacy/postgresql \
--set auth.username=coder \
--set auth.password=coder \
--set auth.database=coder \
--set persistence.size=10Gi
kubectl create secret generic coder-db-url -n "${DEPLOY_NAME}" \
--from-literal=url="postgres://coder:coder@coder-db-postgresql.${DEPLOY_NAME}.svc.cluster.local:5432/coder?sslmode=disable"
- name: Create RBAC
if: steps.check.outputs.new == 'true'
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
run: envsubst < ./.github/pr-deployments/rbac.yaml | kubectl apply -f -
# ---- Every push ----
- name: Create values.yaml
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
REPO: ${{ env.REPO }}
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}
run: envsubst < ./.github/pr-deployments/values.yaml > ./deploy-values.yaml
- name: Install/Upgrade Helm chart
run: |
set -euo pipefail
helm dependency update --skip-refresh ./helm/coder
helm upgrade --install "${DEPLOY_NAME}" ./helm/coder \
--namespace "${DEPLOY_NAME}" \
--values ./deploy-values.yaml \
--force
- name: Install coder-logstream-kube
if: steps.check.outputs.new == 'true'
run: |
helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube
helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \
--namespace "${DEPLOY_NAME}" \
--set url="https://${BRANCH_HOSTNAME}"
- name: Create first user and template
if: steps.check.outputs.new == 'true'
run: |
set -euo pipefail
URL="https://${BRANCH_HOSTNAME}/bin/coder-linux-amd64"
COUNT=0
until curl --output /dev/null --silent --head --fail "$URL"; do
sleep 5
COUNT=$((COUNT+1))
if [ "$COUNT" -ge 60 ]; then echo "Timed out"; exit 1; fi
done
curl -fsSL "$URL" -o /tmp/coder && chmod +x /tmp/coder
password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12)
echo "::add-mask::$password"
/tmp/coder login \
--first-user-username "${BRANCH_NAME}-admin" \
--first-user-email "${BRANCH_NAME}@coder.com" \
--first-user-password "$password" \
--first-user-trial=false \
--use-token-as-session \
"https://${BRANCH_HOSTNAME}"
cd .github/pr-deployments/template
/tmp/coder templates push -y --variable "namespace=${DEPLOY_NAME}" kubernetes
/tmp/coder create --template="kubernetes" kube \
--parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
/tmp/coder stop kube -y
+8 -6
View File
@@ -181,7 +181,7 @@ jobs:
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
- name: golangci-lint cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.LINT_CACHE_DIR }}
@@ -241,7 +241,9 @@ jobs:
lint-actions:
needs: changes
if: needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
# Only run this job if changes to CI workflow files are detected. This job
# can flake as it reaches out to GitHub to check referenced actions.
if: needs.changes.outputs.ci == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
@@ -1184,7 +1186,7 @@ jobs:
persist-credentials: false
- name: GHCR Login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -1391,7 +1393,7 @@ jobs:
id: attest_main
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:main"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1428,7 +1430,7 @@ jobs:
id: attest_latest
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:latest"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1465,7 +1467,7 @@ jobs:
id: attest_version
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
predicate-type: "https://slsa.dev/provenance/v1"
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
persist-credentials: false
- name: GHCR Login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Docker login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
# on version 2.29 and above.
nix_version: "2.28.5"
- uses: nix-community/cache-nix-action@106bba72ed8e29c8357661199511ef07790175e9 # v7.0.1
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
# restore and save a cache using this key
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
@@ -82,7 +82,7 @@ jobs:
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
+1 -1
View File
@@ -248,7 +248,7 @@ jobs:
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+4 -4
View File
@@ -233,7 +233,7 @@ jobs:
cat "$CODER_RELEASE_NOTES_FILE"
- name: Docker Login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -448,7 +448,7 @@ jobs:
id: attest_base
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.image-base-tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -564,7 +564,7 @@ jobs:
id: attest_main
if: ${{ !inputs.dry_run }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -608,7 +608,7 @@ jobs:
id: attest_latest
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.latest_tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
+24 -19
View File
@@ -2244,6 +2244,7 @@ type runServerOpts struct {
waitForSnapshot bool
telemetryDisabled bool
waitForTelemetryDisabledCheck bool
name string
}
func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
@@ -2266,25 +2267,23 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
"--cache-dir", cacheDir,
"--log-filter", ".*",
)
finished := make(chan bool, 2)
inv.Logger = inv.Logger.Named(opts.name)
errChan := make(chan error, 1)
pty := ptytest.New(t).Attach(inv)
pty := ptytest.New(t).Named(opts.name).Attach(inv)
go func() {
errChan <- inv.WithContext(ctx).Run()
finished <- true
// close the pty here so that we can start tearing down resources. This test creates multiple servers with
// associated ptys. There is a `t.Cleanup()` that does this, but it waits until the whole test is complete.
_ = pty.Close()
}()
go func() {
defer func() {
finished <- true
}()
if opts.waitForSnapshot {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
}
if opts.waitForTelemetryDisabledCheck {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
}
}()
<-finished
if opts.waitForSnapshot {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
}
if opts.waitForTelemetryDisabledCheck {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
}
return errChan, cancelFunc
}
waitForShutdown := func(t *testing.T, errChan chan error) error {
@@ -2298,7 +2297,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
return nil
}
errChan, cancelFunc := runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
errChan, cancelFunc := runServer(t, runServerOpts{
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "0disabled",
})
cancelFunc()
require.NoError(t, waitForShutdown(t, errChan))
@@ -2306,7 +2307,7 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
require.Empty(t, deployment)
require.Empty(t, snapshot)
errChan, cancelFunc = runServer(t, runServerOpts{waitForSnapshot: true})
errChan, cancelFunc = runServer(t, runServerOpts{waitForSnapshot: true, name: "1enabled"})
cancelFunc()
require.NoError(t, waitForShutdown(t, errChan))
// we expect to see a deployment and a snapshot twice:
@@ -2325,7 +2326,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
}
}
errChan, cancelFunc = runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
errChan, cancelFunc = runServer(t, runServerOpts{
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "2disabled",
})
cancelFunc()
require.NoError(t, waitForShutdown(t, errChan))
@@ -2341,7 +2344,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
t.Fatalf("timed out waiting for snapshot")
}
errChan, cancelFunc = runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
errChan, cancelFunc = runServer(t, runServerOpts{
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "3disabled",
})
cancelFunc()
require.NoError(t, waitForShutdown(t, errChan))
// Since telemetry is disabled and we've already sent a snapshot, we expect no
-58
View File
@@ -24,7 +24,6 @@ import (
"github.com/gofrs/flock"
"github.com/google/uuid"
"github.com/mattn/go-isatty"
"github.com/shirou/gopsutil/v4/process"
"github.com/spf13/afero"
gossh "golang.org/x/crypto/ssh"
gosshagent "golang.org/x/crypto/ssh/agent"
@@ -85,9 +84,6 @@ func (r *RootCmd) ssh() *serpent.Command {
containerName string
containerUser string
// Used in tests to simulate the parent exiting.
testForcePPID int64
)
cmd := &serpent.Command{
Annotations: workspaceCommand,
@@ -179,24 +175,6 @@ func (r *RootCmd) ssh() *serpent.Command {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// When running as a ProxyCommand (stdio mode), monitor the parent process
// and exit if it dies to avoid leaving orphaned processes. This is
// particularly important when editors like VSCode/Cursor spawn SSH
// connections and then crash or are killed - we don't want zombie
// `coder ssh` processes accumulating.
// Note: using gopsutil to check the parent process as this handles
// windows processes as well in a standard way.
if stdio {
ppid := int32(os.Getppid()) // nolint:gosec
checkParentInterval := 10 * time.Second // Arbitrary interval to not be too frequent
if testForcePPID > 0 {
ppid = int32(testForcePPID) // nolint:gosec
checkParentInterval = 100 * time.Millisecond // Shorter interval for testing
}
ctx, cancel = watchParentContext(ctx, quartz.NewReal(), ppid, process.PidExistsWithContext, checkParentInterval)
defer cancel()
}
// Prevent unnecessary logs from the stdlib from messing up the TTY.
// See: https://github.com/coder/coder/issues/13144
log.SetOutput(io.Discard)
@@ -797,12 +775,6 @@ func (r *RootCmd) ssh() *serpent.Command {
Value: serpent.BoolOf(&forceNewTunnel),
Hidden: true,
},
{
Flag: "test.force-ppid",
Description: "Override the parent process ID to simulate a different parent process. ONLY USE THIS IN TESTS.",
Value: serpent.Int64Of(&testForcePPID),
Hidden: true,
},
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
}
return cmd
@@ -1690,33 +1662,3 @@ func normalizeWorkspaceInput(input string) string {
return input // Fallback
}
}
// watchParentContext returns a context that is canceled when the parent process
// dies. It polls using the provided clock and checks if the parent is alive
// using the provided pidExists function.
func watchParentContext(ctx context.Context, clock quartz.Clock, originalPPID int32, pidExists func(context.Context, int32) (bool, error), interval time.Duration) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(ctx) // intentionally shadowed
go func() {
ticker := clock.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
alive, err := pidExists(ctx, originalPPID)
// If we get an error checking the parent process (e.g., permission
// denied, the process is in an unknown state), we assume the parent
// is still alive to avoid disrupting the SSH connection. We only
// cancel when we definitively know the parent is gone (alive=false, err=nil).
if !alive && err == nil {
cancel()
return
}
}
}
}()
return ctx, cancel
}
-96
View File
@@ -312,102 +312,6 @@ type fakeCloser struct {
err error
}
func TestWatchParentContext(t *testing.T) {
t.Parallel()
t.Run("CancelsWhenParentDies", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
mClock := quartz.NewMock(t)
trap := mClock.Trap().NewTicker()
defer trap.Close()
parentAlive := true
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
return parentAlive, nil
}, testutil.WaitShort)
defer cancel()
// Wait for the ticker to be created
trap.MustWait(ctx).MustRelease(ctx)
// When: we simulate parent death and advance the clock
parentAlive = false
mClock.AdvanceNext()
// Then: The context should be canceled
_ = testutil.TryReceive(ctx, t, childCtx.Done())
})
t.Run("DoesNotCancelWhenParentAlive", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
mClock := quartz.NewMock(t)
trap := mClock.Trap().NewTicker()
defer trap.Close()
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
return true, nil // Parent always alive
}, testutil.WaitShort)
defer cancel()
// Wait for the ticker to be created
trap.MustWait(ctx).MustRelease(ctx)
// When: we advance the clock several times with the parent alive
for range 3 {
mClock.AdvanceNext()
}
// Then: context should not be canceled
require.NoError(t, childCtx.Err())
})
t.Run("RespectsParentContext", func(t *testing.T) {
t.Parallel()
ctx, cancelParent := context.WithCancel(context.Background())
mClock := quartz.NewMock(t)
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
return true, nil
}, testutil.WaitShort)
defer cancel()
// When: we cancel the parent context
cancelParent()
// Then: The context should be canceled
require.ErrorIs(t, childCtx.Err(), context.Canceled)
})
t.Run("DoesNotCancelOnError", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
mClock := quartz.NewMock(t)
trap := mClock.Trap().NewTicker()
defer trap.Close()
// Simulate an error checking parent status (e.g., permission denied).
// We should not cancel the context in this case to avoid disrupting
// the SSH connection.
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
return false, xerrors.New("permission denied")
}, testutil.WaitShort)
defer cancel()
// Wait for the ticker to be created
trap.MustWait(ctx).MustRelease(ctx)
// When: we advance clock several times
for range 3 {
mClock.AdvanceNext()
}
// Context should NOT be canceled since we got an error (not a definitive "not alive")
require.NoError(t, childCtx.Err(), "context was canceled even though pidExists returned an error")
})
}
func (c *fakeCloser) Close() error {
*c.closes = append(*c.closes, c)
return c.err
-101
View File
@@ -1122,107 +1122,6 @@ func TestSSH(t *testing.T) {
}
})
// This test ensures that the SSH session exits when the parent process dies.
t.Run("StdioExitOnParentDeath", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
defer cancel()
// sleepStart -> agentReady -> sessionStarted -> sleepKill -> sleepDone -> cmdDone
sleepStart := make(chan int)
agentReady := make(chan struct{})
sessionStarted := make(chan struct{})
sleepKill := make(chan struct{})
sleepDone := make(chan struct{})
// Start a sleep process which we will pretend is the parent.
go func() {
sleepCmd := exec.Command("sleep", "infinity")
if !assert.NoError(t, sleepCmd.Start(), "failed to start sleep command") {
return
}
sleepStart <- sleepCmd.Process.Pid
defer close(sleepDone)
<-sleepKill
sleepCmd.Process.Kill()
_ = sleepCmd.Wait()
}()
client, workspace, agentToken := setupWorkspaceForAgent(t)
go func() {
defer close(agentReady)
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).WaitFor(coderdtest.AgentsReady)
}()
clientOutput, clientInput := io.Pipe()
serverOutput, serverInput := io.Pipe()
defer func() {
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
_ = c.Close()
}
}()
// Start a connection to the agent once it's ready
go func() {
<-agentReady
conn, channels, requests, err := ssh.NewClientConn(&testutil.ReaderWriterConn{
Reader: serverOutput,
Writer: clientInput,
}, "", &ssh.ClientConfig{
// #nosec
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if !assert.NoError(t, err, "failed to create SSH client connection") {
return
}
defer conn.Close()
sshClient := ssh.NewClient(conn, channels, requests)
defer sshClient.Close()
session, err := sshClient.NewSession()
if !assert.NoError(t, err, "failed to create SSH session") {
return
}
close(sessionStarted)
<-sleepDone
// Ref: https://github.com/coder/internal/issues/1289
// This may return either a nil error or io.EOF.
// There is an inherent race here:
// 1. Sleep process is killed -> sleepDone is closed.
// 2. watchParentContext detects parent death, cancels context,
// causing SSH session teardown.
// 3. We receive from sleepDone and attempt to call session.Close()
// Now either:
// a. Session teardown completes before we call Close(), resulting in io.EOF
// b. We call Close() first, resulting in a nil error.
_ = session.Close()
}()
// Wait for our "parent" process to start
sleepPid := testutil.RequireReceive(ctx, t, sleepStart)
// Wait for the agent to be ready
testutil.SoftTryReceive(ctx, t, agentReady)
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name, "--test.force-ppid", fmt.Sprintf("%d", sleepPid))
clitest.SetupConfig(t, client, root)
inv.Stdin = clientOutput
inv.Stdout = serverInput
inv.Stderr = io.Discard
// Start the command
clitest.Start(t, inv.WithContext(ctx))
// Wait for a session to be established
testutil.SoftTryReceive(ctx, t, sessionStarted)
// Now kill the fake "parent"
close(sleepKill)
// The sleep process should exit
testutil.SoftTryReceive(ctx, t, sleepDone)
// And then the command should exit. This is tracked by clitest.Start.
})
t.Run("ForwardAgent", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Test not supported on windows")
+60
View File
@@ -1244,3 +1244,63 @@ func (api *API) postWorkspaceAgentTaskLogSnapshot(rw http.ResponseWriter, r *htt
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Pause task
// @ID pause-task
// @Security CoderSessionToken
// @Accept json
// @Tags Tasks
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
// @Param task path string true "Task ID" format(uuid)
// @Success 202 {object} codersdk.PauseTaskResponse
// @Router /tasks/{user}/{task}/pause [post]
func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
task = httpmw.TaskParam(r)
)
if !task.WorkspaceID.Valid {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Task does not have a workspace.",
})
return
}
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task workspace.",
Detail: err.Error(),
})
return
}
buildReq := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
Reason: codersdk.CreateWorkspaceBuildReasonTaskManualPause,
}
build, err := api.postWorkspaceBuildsInternal(
ctx,
apiKey,
workspace,
buildReq,
func(action policy.Action, object rbac.Objecter) bool {
return api.Authorize(r, action, object)
},
audit.WorkspaceBuildBaggageFromRequest(r),
)
if err != nil {
httperror.WriteWorkspaceBuildError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.PauseTaskResponse{
WorkspaceBuild: &build,
})
}
+359
View File
@@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
agentapisdk "github.com/coder/agentapi-sdk-go"
"github.com/coder/coder/v2/agent"
@@ -26,11 +27,14 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -100,6 +104,36 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID,
}
}
type aiTaskStoreWrapper struct {
database.Store
getWorkspaceByID func(ctx context.Context, id uuid.UUID) (database.Workspace, error)
insertWorkspaceBuild func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error
}
func (s aiTaskStoreWrapper) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if s.getWorkspaceByID != nil {
return s.getWorkspaceByID(ctx, id)
}
return s.Store.GetWorkspaceByID(ctx, id)
}
func (s aiTaskStoreWrapper) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
if s.insertWorkspaceBuild != nil {
return s.insertWorkspaceBuild(ctx, arg)
}
return s.Store.InsertWorkspaceBuild(ctx, arg)
}
func (s aiTaskStoreWrapper) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return fn(aiTaskStoreWrapper{
Store: tx,
getWorkspaceByID: s.getWorkspaceByID,
insertWorkspaceBuild: s.insertWorkspaceBuild,
})
}, opts)
}
func TestTasks(t *testing.T) {
t.Parallel()
@@ -2422,3 +2456,328 @@ func TestPostWorkspaceAgentTaskSnapshot(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
}
func TestPauseTask(t *testing.T) {
t.Parallel()
setupClient := func(t *testing.T, db database.Store, ps pubsub.Pubsub, authorizer rbac.Authorizer) *codersdk.Client {
t.Helper()
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Authorizer: authorizer,
})
return client
}
setupWorkspaceTask := func(t *testing.T, db database.Store, user codersdk.CreateFirstUserResponse) (database.Task, uuid.UUID) {
t.Helper()
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithTask(database.TaskTable{
Prompt: "pause me",
}, nil).Do()
return workspaceBuild.Task, workspaceBuild.Workspace.ID
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: []*proto.Response{
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
HasAiTasks: true,
}}},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "pause me",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
resp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
build := *resp.WorkspaceBuild
require.NotNil(t, build)
require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition)
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber)
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskManualPause), string(build.Reason))
})
t.Run("Non-owner role access", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
owner := coderdtest.CreateFirstUser(t, client)
cases := []struct {
name string
roles []rbac.RoleIdentifier
expectedStatus int
}{
{
name: "org_member",
expectedStatus: http.StatusNotFound,
},
{
name: "org_admin",
roles: []rbac.RoleIdentifier{rbac.ScopedRoleOrgAdmin(owner.OrganizationID)},
expectedStatus: http.StatusAccepted,
},
{
name: "sitewide_member",
roles: []rbac.RoleIdentifier{rbac.RoleMember()},
expectedStatus: http.StatusNotFound,
},
{
name: "sitewide_admin",
roles: []rbac.RoleIdentifier{rbac.RoleOwner()},
expectedStatus: http.StatusAccepted,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
task, _ := setupWorkspaceTask(t, db, owner)
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, tc.roles...)
resp, err := userClient.PauseTask(ctx, codersdk.Me, task.ID)
if tc.expectedStatus == http.StatusAccepted {
require.NoError(t, err)
require.NotNil(t, resp.WorkspaceBuild)
require.NotEqual(t, uuid.Nil, resp.WorkspaceBuild.ID)
return
}
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, tc.expectedStatus, apiErr.StatusCode())
})
}
})
t.Run("Task not found", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.PauseTask(ctx, codersdk.Me, uuid.New())
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Task lookup forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionRead && object.Type == rbac.ResourceTask.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Workspace lookup forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionRead && object.Type == rbac.ResourceWorkspace.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("No Workspace for Task", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).Do()
task := dbgen.Task(t, db, database.TaskTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
TemplateVersionID: workspaceBuild.Build.TemplateVersionID,
Prompt: "no workspace",
})
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
require.Equal(t, "Task does not have a workspace.", apiErr.Message)
})
t.Run("Workspace not found", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
var workspaceID uuid.UUID
wrapped := aiTaskStoreWrapper{
Store: db,
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if id == workspaceID && id != uuid.Nil {
return database.Workspace{}, sql.ErrNoRows
}
return db.GetWorkspaceByID(ctx, id)
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
workspaceID = workspaceIDValue
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Workspace lookup internal error", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
var workspaceID uuid.UUID
wrapped := aiTaskStoreWrapper{
Store: db,
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if id == workspaceID && id != uuid.Nil {
return database.Workspace{}, xerrors.New("boom")
}
return db.GetWorkspaceByID(ctx, id)
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
workspaceID = workspaceIDValue
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
require.Equal(t, "Internal error fetching task workspace.", apiErr.Message)
})
t.Run("Build Forbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
auth := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionWorkspaceStop && object.Type == rbac.ResourceWorkspace.Type {
return rbac.UnauthorizedError{}
}
return nil
},
}
client := setupClient(t, db, ps, auth)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("Job already in progress", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).
WithTask(database.TaskTable{
Prompt: "pause me",
}, nil).
Starting().
Do()
_, err := client.PauseTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
})
t.Run("Build Internal Error", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
wrapped := aiTaskStoreWrapper{
Store: db,
insertWorkspaceBuild: func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
return xerrors.New("insert failed")
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
})
}
+56 -3
View File
@@ -5824,6 +5824,48 @@ const docTemplate = `{
}
}
},
"/tasks/{user}/{task}/pause": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"tags": [
"Tasks"
],
"summary": "Pause task",
"operationId": "pause-task",
"parameters": [
{
"type": "string",
"description": "Username, user ID, or 'me' for the authenticated user",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Task ID",
"name": "task",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/codersdk.PauseTaskResponse"
}
}
}
}
},
"/tasks/{user}/{task}/send": {
"post": {
"security": [
@@ -14102,14 +14144,16 @@ const docTemplate = `{
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection"
"jetbrains_connection",
"task_manual_pause"
],
"x-enum-varnames": [
"CreateWorkspaceBuildReasonDashboard",
"CreateWorkspaceBuildReasonCLI",
"CreateWorkspaceBuildReasonSSHConnection",
"CreateWorkspaceBuildReasonVSCodeConnection",
"CreateWorkspaceBuildReasonJetbrainsConnection"
"CreateWorkspaceBuildReasonJetbrainsConnection",
"CreateWorkspaceBuildReasonTaskManualPause"
]
},
"codersdk.CreateWorkspaceBuildRequest": {
@@ -14143,7 +14187,8 @@ const docTemplate = `{
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection"
"jetbrains_connection",
"task_manual_pause"
],
"allOf": [
{
@@ -17014,6 +17059,14 @@ const docTemplate = `{
}
}
},
"codersdk.PauseTaskResponse": {
"type": "object",
"properties": {
"workspace_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
}
}
},
"codersdk.Permission": {
"type": "object",
"properties": {
+52 -3
View File
@@ -5147,6 +5147,44 @@
}
}
},
"/tasks/{user}/{task}/pause": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"tags": ["Tasks"],
"summary": "Pause task",
"operationId": "pause-task",
"parameters": [
{
"type": "string",
"description": "Username, user ID, or 'me' for the authenticated user",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Task ID",
"name": "task",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/codersdk.PauseTaskResponse"
}
}
}
}
},
"/tasks/{user}/{task}/send": {
"post": {
"security": [
@@ -12662,14 +12700,16 @@
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection"
"jetbrains_connection",
"task_manual_pause"
],
"x-enum-varnames": [
"CreateWorkspaceBuildReasonDashboard",
"CreateWorkspaceBuildReasonCLI",
"CreateWorkspaceBuildReasonSSHConnection",
"CreateWorkspaceBuildReasonVSCodeConnection",
"CreateWorkspaceBuildReasonJetbrainsConnection"
"CreateWorkspaceBuildReasonJetbrainsConnection",
"CreateWorkspaceBuildReasonTaskManualPause"
]
},
"codersdk.CreateWorkspaceBuildRequest": {
@@ -12699,7 +12739,8 @@
"cli",
"ssh_connection",
"vscode_connection",
"jetbrains_connection"
"jetbrains_connection",
"task_manual_pause"
],
"allOf": [
{
@@ -15477,6 +15518,14 @@
}
}
},
"codersdk.PauseTaskResponse": {
"type": "object",
"properties": {
"workspace_build": {
"$ref": "#/definitions/codersdk.WorkspaceBuild"
}
}
},
"codersdk.Permission": {
"type": "object",
"properties": {
+1
View File
@@ -1078,6 +1078,7 @@ func New(options *Options) *API {
r.Patch("/input", api.taskUpdateInput)
r.Post("/send", api.taskSend)
r.Get("/logs", api.taskLogs)
r.Post("/pause", api.pauseTask)
})
})
})
+2
View File
@@ -17,4 +17,6 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
)
+2 -5
View File
@@ -19,7 +19,6 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -30,7 +29,6 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisionerd/proto"
@@ -1664,13 +1662,12 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
parameters = json.RawMessage([]byte("{}"))
}
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
ID: takeFirst(orig.ID, uuid.New()),
OrganizationID: orig.OrganizationID,
OwnerID: orig.OwnerID,
Name: takeFirst(orig.Name, taskName.Name),
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
Name: takeFirst(orig.Name, testutil.GetRandomNameHyphenated(t)),
DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomNameHyphenated(t)),
WorkspaceID: orig.WorkspaceID,
TemplateVersionID: orig.TemplateVersionID,
TemplateParameters: parameters,
+3 -1
View File
@@ -2736,7 +2736,9 @@ CREATE TABLE workspaces (
favorite boolean DEFAULT false NOT NULL,
next_start_at timestamp with time zone,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
);
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
@@ -0,0 +1,3 @@
ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS group_acl_is_object,
DROP CONSTRAINT IF EXISTS user_acl_is_object;
@@ -0,0 +1,9 @@
-- Add constraints that reject 'null'::jsonb for group and user ACLs
-- because they would break the new workspace_expanded view.
UPDATE workspaces SET group_acl = '{}'::jsonb WHERE group_acl = 'null'::jsonb;
UPDATE workspaces SET user_acl = '{}'::jsonb WHERE user_acl = 'null'::jsonb;
ALTER TABLE workspaces
ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
@@ -0,0 +1,35 @@
-- Fixture for migration 000417_workspace_acl_object_constraint.
-- Inserts a workspace with 'null'::json ACLs to ensure the migration
-- correctly normalizes such values.
INSERT INTO workspaces (
id,
created_at,
updated_at,
owner_id,
organization_id,
template_id,
deleted,
name,
last_used_at,
automatic_updates,
favorite,
group_acl,
user_acl
)
VALUES (
'6f6fdbee-4c18-4a5c-8a8d-9b811c9f0a28',
'2024-02-10 00:00:00+00',
'2024-02-10 00:00:00+00',
'30095c71-380b-457a-8995-97b8ee6e5307',
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
'4cc1f466-f326-477e-8762-9d0c6781fc56',
false,
'acl-null-workspace',
'0001-01-01 00:00:00+00',
'never',
false,
'null'::jsonb,
'null'::jsonb
)
ON CONFLICT DO NOTHING;
+59
View File
@@ -6765,6 +6765,65 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
}
}
func TestWorkspaceACLObjectConstraint(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
CreatedBy: user.ID,
OrganizationID: org.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
Deleted: false,
})
t.Run("GroupACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: nilACL,
UserACL: database.WorkspaceACL{},
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckGroupAclIsObject))
})
t.Run("UserACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: nilACL,
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckUserAclIsObject))
})
t.Run("ValidEmptyObjects", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: database.WorkspaceACL{},
})
require.NoError(t, err)
})
}
// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with
// workspaces and builds. It then tests that
// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some
+1 -1
View File
@@ -384,7 +384,7 @@ func (api *API) postWorkspaceBuildsInternal(
Experiments(api.Experiments).
TemplateVersionPresetID(createBuild.TemplateVersionPresetID)
if transition == database.WorkspaceTransitionStart && createBuild.Reason != "" {
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
builder = builder.Reason(database.BuildReason(createBuild.Reason))
}
+25
View File
@@ -329,6 +329,31 @@ func (c *Client) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID,
return nil
}
// PauseTaskResponse represents the response from pausing a task.
type PauseTaskResponse struct {
WorkspaceBuild *WorkspaceBuild `json:"workspace_build"`
}
// PauseTask pauses a task by stopping its workspace.
// Experimental: uses the /api/experimental endpoint.
func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (PauseTaskResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/pause", user, id.String()), nil)
if err != nil {
return PauseTaskResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusAccepted {
return PauseTaskResponse{}, ReadBodyAsError(res)
}
var resp PauseTaskResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return PauseTaskResponse{}, err
}
return resp, nil
}
// TaskLogType indicates the source of a task log entry.
type TaskLogType string
+2 -1
View File
@@ -109,6 +109,7 @@ const (
CreateWorkspaceBuildReasonSSHConnection CreateWorkspaceBuildReason = "ssh_connection"
CreateWorkspaceBuildReasonVSCodeConnection CreateWorkspaceBuildReason = "vscode_connection"
CreateWorkspaceBuildReasonJetbrainsConnection CreateWorkspaceBuildReason = "jetbrains_connection"
CreateWorkspaceBuildReasonTaskManualPause CreateWorkspaceBuildReason = "task_manual_pause"
)
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
@@ -129,7 +130,7 @@ type CreateWorkspaceBuildRequest struct {
// TemplateVersionPresetID is the ID of the template version preset to use for the build.
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
// Reason sets the reason for the workspace build.
Reason CreateWorkspaceBuildReason `json:"reason,omitempty" validate:"omitempty,oneof=dashboard cli ssh_connection vscode_connection jetbrains_connection"`
Reason CreateWorkspaceBuildReason `json:"reason,omitempty" validate:"omitempty,oneof=dashboard cli ssh_connection vscode_connection jetbrains_connection task_manual_pause"`
}
type WorkspaceOptions struct {
+1 -3
View File
@@ -119,9 +119,7 @@ this:
- Run `./scripts/deploy-pr.sh`
- Manually trigger the
[`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml)
GitHub Action workflow:
<Image src="./images/deploy-pr-manually.png" alt="Deploy PR manually" height="348px" align="center" />
GitHub Action workflow.
#### Available options
+2 -6
View File
@@ -220,16 +220,12 @@ screen-readers; a placeholder text value is not enough for all users.
When possible, make sure that all image/graphic elements have accompanying text
that describes the image. `<img />` elements should have an `alt` text value. In
other situations, it might make sense to place invisible, descriptive text
inside the component itself using MUI's `visuallyHidden` utility function.
inside the component itself using Tailwind's `sr-only` class.
```tsx
import { visuallyHidden } from "@mui/utils";
<Button>
<GearIcon />
<Box component="span" sx={visuallyHidden}>
Settings
</Box>
<span className="sr-only">Settings</span>
</Button>;
```
+227 -8
View File
@@ -2184,9 +2184,9 @@ This is required on creation to enable a user-flow of validating a template work
#### Enumerated Values
| Value(s) |
|-----------------------------------------------------------------------------------|
| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `vscode_connection` |
| Value(s) |
|--------------------------------------------------------------------------------------------------------|
| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `vscode_connection` |
## codersdk.CreateWorkspaceBuildRequest
@@ -2227,11 +2227,11 @@ This is required on creation to enable a user-flow of validating a template work
#### Enumerated Values
| Property | Value(s) |
|--------------|-----------------------------------------------------------------------------------|
| `log_level` | `debug` |
| `reason` | `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `vscode_connection` |
| `transition` | `delete`, `start`, `stop` |
| Property | Value(s) |
|--------------|--------------------------------------------------------------------------------------------------------|
| `log_level` | `debug` |
| `reason` | `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `vscode_connection` |
| `transition` | `delete`, `start`, `stop` |
## codersdk.CreateWorkspaceProxyRequest
@@ -6178,6 +6178,225 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `name` | string | true | | |
| `regenerate_token` | boolean | false | | |
## codersdk.PauseTaskResponse
```json
{
"workspace_build": {
"build_number": 0,
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"deadline": "2019-08-24T14:15:22Z",
"has_ai_task": true,
"has_external_agent": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"initiator_name": "string",
"job": {
"available_workers": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"canceled_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"created_at": "2019-08-24T14:15:22Z",
"error": "string",
"error_code": "REQUIRED_TEMPLATE_VARIABLES",
"file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"input": {
"error": "string",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478"
},
"logs_overflowed": true,
"metadata": {
"template_display_name": "string",
"template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"template_version_name": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string"
},
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"queue_position": 0,
"queue_size": 0,
"started_at": "2019-08-24T14:15:22Z",
"status": "pending",
"tags": {
"property1": "string",
"property2": "string"
},
"type": "template_version_import",
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b",
"worker_name": "string"
},
"matched_provisioners": {
"available": 0,
"count": 0,
"most_recently_seen": "2019-08-24T14:15:22Z"
},
"max_deadline": "2019-08-24T14:15:22Z",
"reason": "initiator",
"resources": [
{
"agents": [
{
"api_version": "string",
"apps": [
{
"command": "string",
"display_name": "string",
"external": true,
"group": "string",
"health": "disabled",
"healthcheck": {
"interval": 0,
"threshold": 0,
"url": "string"
},
"hidden": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"open_in": "slim-window",
"sharing_level": "owner",
"slug": "string",
"statuses": [
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335",
"created_at": "2019-08-24T14:15:22Z",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"message": "string",
"needs_user_attention": true,
"state": "working",
"uri": "string",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
}
],
"subdomain": true,
"subdomain_name": "string",
"tooltip": "string",
"url": "string"
}
],
"architecture": "string",
"connection_timeout_seconds": 0,
"created_at": "2019-08-24T14:15:22Z",
"directory": "string",
"disconnected_at": "2019-08-24T14:15:22Z",
"display_apps": [
"vscode"
],
"environment_variables": {
"property1": "string",
"property2": "string"
},
"expanded_directory": "string",
"first_connected_at": "2019-08-24T14:15:22Z",
"health": {
"healthy": false,
"reason": "agent has lost connection"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"instance_id": "string",
"last_connected_at": "2019-08-24T14:15:22Z",
"latency": {
"property1": {
"latency_ms": 0,
"preferred": true
},
"property2": {
"latency_ms": 0,
"preferred": true
}
},
"lifecycle_state": "created",
"log_sources": [
{
"created_at": "2019-08-24T14:15:22Z",
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1"
}
],
"logs_length": 0,
"logs_overflowed": true,
"name": "string",
"operating_system": "string",
"parent_id": {
"uuid": "string",
"valid": true
},
"ready_at": "2019-08-24T14:15:22Z",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"scripts": [
{
"cron": "string",
"display_name": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"log_path": "string",
"log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a",
"run_on_start": true,
"run_on_stop": true,
"script": "string",
"start_blocks_login": true,
"timeout": 0
}
],
"started_at": "2019-08-24T14:15:22Z",
"startup_script_behavior": "blocking",
"status": "connecting",
"subsystems": [
"envbox"
],
"troubleshooting_url": "string",
"updated_at": "2019-08-24T14:15:22Z",
"version": "string"
}
],
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"hide": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f",
"metadata": [
{
"key": "string",
"sensitive": true,
"value": "string"
}
],
"name": "string",
"type": "string",
"workspace_transition": "start"
}
],
"status": "pending",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"template_version_name": "string",
"template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1",
"transition": "start",
"updated_at": "2019-08-24T14:15:22Z",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------|----------------------------------------------------|----------|--------------|-------------|
| `workspace_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
## codersdk.Permission
```json
+32
View File
@@ -365,6 +365,38 @@ curl -X GET http://coder-server:8080/api/v2/tasks/{user}/{task}/logs \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Pause task
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/pause \
-H 'Accept: */*' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /tasks/{user}/{task}/pause`
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------------|----------|-------------------------------------------------------|
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
| `task` | path | string(uuid) | true | Task ID |
### Example responses
> 202 Response
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------------|-------------|--------------------------------------------------------------------|
| 202 | [Accepted](https://tools.ietf.org/html/rfc7231#section-6.3.3) | Accepted | [codersdk.PauseTaskResponse](schemas.md#codersdkpausetaskresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Send input to AI task
### Code samples
+1 -1
View File
@@ -1,5 +1,5 @@
# 1.86.0
FROM rust:slim@sha256:df6ca8f96d338697ccdbe3ccac57a85d2172e03a2429c2d243e74f3bb83ba2f5 AS rust-utils
FROM rust:slim@sha256:760ad1d638d70ebbd0c61e06210e1289cbe45ff6425e3ea6e01241de3e14d08e AS rust-utils
# Install rust helper programs
ENV CARGO_INSTALL_ROOT=/tmp/
# Use more reliable mirrors for Debian packages
+59 -4
View File
@@ -2,8 +2,8 @@ package aibridged_test
import (
"context"
_ "embed"
"testing"
"testing/synctest"
"time"
"github.com/google/uuid"
@@ -105,10 +105,65 @@ func TestPool(t *testing.T) {
require.EqualValues(t, 2, cacheMetrics.KeysEvicted())
require.EqualValues(t, 1, cacheMetrics.Hits())
require.EqualValues(t, 3, cacheMetrics.Misses())
}
// TODO: add test for expiry.
// This requires Go 1.25's [synctest](https://pkg.go.dev/testing/synctest) since the
// internal cache lib cannot be tested using coder/quartz.
func TestPool_Expiry(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
logger := slogtest.Make(t, nil)
ctrl := gomock.NewController(t)
client := mock.NewMockDRPCClient(ctrl)
mcpProxy := mcpmock.NewMockServerProxier(ctrl)
mcpProxy.EXPECT().Init(gomock.Any()).AnyTimes().Return(nil)
mcpProxy.EXPECT().Shutdown(gomock.Any()).AnyTimes().Return(nil)
const ttl = time.Second
opts := aibridged.PoolOptions{MaxItems: 1, TTL: ttl}
pool, err := aibridged.NewCachedBridgePool(opts, nil, logger, nil, testTracer)
require.NoError(t, err)
t.Cleanup(func() { pool.Shutdown(context.Background()) })
req := aibridged.Request{
SessionKey: "key",
InitiatorID: uuid.New(),
APIKeyID: uuid.New().String(),
}
clientFn := func() (aibridged.DRPCClient, error) {
return client, nil
}
ctx := t.Context()
// First acquire is a cache miss.
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
require.NoError(t, err)
// Second acquire is a cache hit.
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
require.NoError(t, err)
metrics := pool.CacheMetrics()
require.EqualValues(t, 1, metrics.Misses())
require.EqualValues(t, 1, metrics.Hits())
// TTL expires
time.Sleep(ttl + time.Millisecond)
// Third acquire is a cache miss because the entry expired.
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
require.NoError(t, err)
metrics = pool.CacheMetrics()
require.EqualValues(t, 2, metrics.Misses())
require.EqualValues(t, 1, metrics.Hits())
// Wait for all eviction goroutines to complete before gomock's ctrl.Finish()
// runs in test cleanup. ristretto's OnEvict callback spawns goroutines that
// need to finish calling mcpProxy.Shutdown() before ctrl.finish clears the
// expectations.
synctest.Wait()
})
}
var _ aibridged.MCPProxyBuilder = &mockMCPFactory{}
-77
View File
@@ -1,77 +0,0 @@
# AI Bridge Proxy
A MITM (Man-in-the-Middle) proxy server for intercepting and decrypting HTTPS requests to AI providers.
## Overview
The AI Bridge Proxy intercepts HTTPS traffic, decrypts it using a configured CA certificate, and forwards requests to AI Bridge for processing.
## Configuration
### Certificate Setup
Generate a CA key pair for MITM:
#### 1. Generate a new private key
```sh
openssl genrsa -out mitm.key 2048
chmod 400 mitm.key
```
#### 2. Create a self-signed CA certificate
```sh
openssl req -new -x509 -days 365 \
-key mitm.key \
-out mitm.crt \
-subj "/CN=Coder AI Bridge Proxy CA"
```
### Configuration options
| Environment Variable | Description | Default |
|------------------------------------|---------------------------------|---------|
| `CODER_AIBRIDGE_PROXY_ENABLED` | Enable the AI Bridge Proxy | `false` |
| `CODER_AIBRIDGE_PROXY_LISTEN_ADDR` | Address the proxy listens on | `:8888` |
| `CODER_AIBRIDGE_PROXY_CERT_FILE` | Path to the CA certificate file | - |
| `CODER_AIBRIDGE_PROXY_KEY_FILE` | Path to the CA private key file | - |
### Client Configuration
Clients must trust the proxy's CA certificate and authenticate with their Coder session token.
#### CA Certificate
Clients need to trust the MITM CA certificate:
```sh
# Node.js
export NODE_EXTRA_CA_CERTS="/path/to/mitm.crt"
# Python (requests, httpx)
export REQUESTS_CA_BUNDLE="/path/to/mitm.crt"
export SSL_CERT_FILE="/path/to/mitm.crt"
# Go
export SSL_CERT_FILE="/path/to/mitm.crt"
```
#### Proxy Authentication
Clients authenticate with the proxy using their Coder session token in the `Proxy-Authorization` header via HTTP Basic Auth.
The token is passed as the password (username is ignored):
```sh
export HTTP_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
export HTTPS_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
```
For example:
```sh
export HTTP_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
export HTTPS_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
```
Most HTTP clients and AI SDKs will automatically use these environment variables.
+7 -7
View File
@@ -163,7 +163,7 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e
github.com/pkg/sftp v1.13.7
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.67.4
@@ -198,14 +198,14 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.39.0
golang.org/x/text v0.33.0
golang.org/x/tools v0.41.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
google.golang.org/api v0.264.0
google.golang.org/api v0.265.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
@@ -450,7 +450,7 @@ require (
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
howett.net/plist v1.0.0 // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
@@ -473,7 +473,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.0.2
github.com/coder/aibridge v1.0.3
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.8.0
github.com/coder/preview v1.0.4
@@ -481,7 +481,7 @@ require (
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/elazarl/goproxy v1.8.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.2
github.com/go-git/go-git/v5 v5.16.5
github.com/icholy/replace v0.6.0
github.com/mark3labs/mcp-go v0.38.0
gonum.org/v1/gonum v0.17.0
+14 -14
View File
@@ -927,8 +927,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.0.2 h1:cVPr9+TFLIzULpKPGI/1lnL14+DruedR7KnjZHklIEU=
github.com/coder/aibridge v1.0.2/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aibridge v1.0.3 h1:gt3XKbnFBJ/jyls/yanU/iWZO5yhd6LVYuTQbEZ/SxQ=
github.com/coder/aibridge v1.0.3/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v0.8.0 h1:g/H6VIGY4IoWeKkbvao7zhO1BAQe7upSHfHzoAZxdik=
@@ -1149,8 +1149,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1743,8 +1743,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -2264,8 +2264,8 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2385,8 +2385,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
@@ -2591,8 +2591,8 @@ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2737,8 +2737,8 @@ google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+13 -3
View File
@@ -17,6 +17,7 @@ import (
"github.com/acarl005/stripansi"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/pty"
@@ -78,7 +79,7 @@ func newExpecter(t *testing.T, r io.Reader, name string) outExpecter {
ex := outExpecter{
t: t,
out: out,
name: name,
name: atomic.NewString(name),
runeReader: bufio.NewReaderSize(out, utf8.UTFMax),
}
@@ -140,7 +141,7 @@ type outExpecter struct {
t *testing.T
close func(reason string) error
out *stdbuf
name string
name *atomic.String
runeReader *bufio.Reader
}
@@ -361,7 +362,7 @@ func (e *outExpecter) logf(format string, args ...interface{}) {
// Match regular logger timestamp format, we seem to be logging in
// UTC in other places as well, so match here.
e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name, fmt.Sprintf(format, args...))
e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...))
}
func (e *outExpecter) fatalf(reason string, format string, args ...interface{}) {
@@ -430,6 +431,15 @@ func (p *PTY) WriteLine(str string) {
require.NoError(p.t, err, "write line failed")
}
// Named sets the PTY name in the logs. Defaults to "cmd". Make sure you set this before anything starts writing to the
// pty, or it may not be named consistently. E.g.
//
// p := New(t).Named("myCmd")
func (p *PTY) Named(name string) *PTY {
p.name.Store(name)
return p
}
type PTYCmd struct {
outExpecter
pty.PTYCmd
+1 -1
View File
@@ -62,7 +62,7 @@ test("app", async ({ context, page }) => {
const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page");
const pagePromise = context.waitForEvent("page", { timeout: 10_000 });
await page.getByText(appName).click({ timeout: 10_000 });
const app = await pagePromise;
await app.waitForLoadState("domcontentloaded");
+1 -2
View File
@@ -32,7 +32,7 @@
"test:watch": "vitest",
"test:watch-jest": "jest --watch",
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis"
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis && cp -f ./node_modules/emoji-datasource-apple/img/apple/sheets-256/64.png ./static/emojis/spritesheet.png"
},
"dependencies": {
"@emoji-mart/data": "1.2.1",
@@ -49,7 +49,6 @@
"@monaco-editor/react": "4.7.0",
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
"@mui/utils": "5.17.1",
"@mui/x-tree-view": "7.29.10",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
-3
View File
@@ -61,9 +61,6 @@ importers:
'@mui/system':
specifier: 5.18.0
version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2)
'@mui/utils':
specifier: 5.17.1
version: 5.17.1(@types/react@19.2.7)(react@19.2.2)
'@mui/x-tree-view':
specifier: 7.29.10
version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
+1
View File
@@ -36,6 +36,7 @@ declare module "@emoji-mart/react" {
emojiButtonSize?: number;
emojiSize?: number;
emojiVersion?: string;
getSpritesheetURL?: (set: string) => string;
onEmojiSelect: (emoji: EmojiData) => void;
}
+36
View File
@@ -0,0 +1,36 @@
import { API } from "api/api";
import type { Task } from "api/typesGenerated";
import type { QueryClient } from "react-query";
export const pauseTask = (task: Task, queryClient: QueryClient) => {
return {
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.stopWorkspace(task.workspace_id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
};
};
export const resumeTask = (task: Task, queryClient: QueryClient) => {
return {
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.startWorkspace(
task.workspace_id,
task.template_version_id,
undefined,
undefined,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
};
};
+10
View File
@@ -1425,6 +1425,7 @@ export type CreateWorkspaceBuildReason =
| "dashboard"
| "jetbrains_connection"
| "ssh_connection"
| "task_manual_pause"
| "vscode_connection";
export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [
@@ -1432,6 +1433,7 @@ export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [
"dashboard",
"jetbrains_connection",
"ssh_connection",
"task_manual_pause",
"vscode_connection",
];
@@ -3583,6 +3585,14 @@ export interface PatchWorkspaceProxy {
*/
export const PathAppSessionTokenCookie = "coder_path_app_session_token";
// From codersdk/aitasks.go
/**
* PauseTaskResponse represents the response from pausing a task.
*/
export interface PauseTaskResponse {
readonly workspace_build: WorkspaceBuild | null;
}
// From codersdk/roles.go
/**
* Permission is the format passed into the rego.
+12 -18
View File
@@ -7,13 +7,7 @@ import {
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import {
type FC,
forwardRef,
type PropsWithChildren,
type ReactNode,
useState,
} from "react";
import { type FC, type ReactNode, useState } from "react";
import { cn } from "utils/cn";
const alertVariants = cva(
@@ -131,7 +125,9 @@ export const Alert: FC<AlertProps> = ({
);
};
export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
export const AlertDetail: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return (
<span className="m-0 text-sm" data-chromatic="ignore">
{children}
@@ -139,13 +135,11 @@ export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
);
};
export const AlertTitle = forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h1
ref={ref}
className={cn("m-0 mb-1 text-sm font-medium", className)}
{...props}
/>
));
export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h1">> = ({
className,
...props
}) => {
return (
<h1 className={cn("m-0 mb-1 text-sm font-medium", className)} {...props} />
);
};
+11 -10
View File
@@ -13,7 +13,6 @@
import { useTheme } from "@emotion/react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { getExternalImageStylesFromUrl } from "theme/externalImages";
import { cn } from "utils/cn";
@@ -58,17 +57,22 @@ export type AvatarProps = AvatarPrimitive.AvatarProps &
VariantProps<typeof avatarVariants> & {
src?: string;
fallback?: string;
ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Root>>;
};
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
AvatarProps
>(({ className, size, variant, src, fallback, children, ...props }, ref) => {
export const Avatar: React.FC<AvatarProps> = ({
className,
size,
variant,
src,
fallback,
children,
...props
}) => {
const theme = useTheme();
return (
<AvatarPrimitive.Root
ref={ref}
className={cn(avatarVariants({ size, variant, className }))}
{...props}
>
@@ -85,7 +89,4 @@ const Avatar = React.forwardRef<
{children}
</AvatarPrimitive.Root>
);
});
Avatar.displayName = AvatarPrimitive.Root.displayName;
export { Avatar };
};
+21 -24
View File
@@ -4,7 +4,6 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const badgeVariants = cva(
@@ -58,28 +57,26 @@ const badgeVariants = cva(
},
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
asChild?: boolean;
}
type BadgeProps = React.ComponentPropsWithRef<"div"> &
VariantProps<typeof badgeVariants> & {
asChild?: boolean;
};
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
(
{ className, variant, size, border, hover, asChild = false, ...props },
ref,
) => {
const Comp = asChild ? Slot : "div";
export const Badge: React.FC<BadgeProps> = ({
className,
variant,
size,
border,
hover,
asChild = false,
...props
}) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
{...props}
ref={ref}
className={cn(
badgeVariants({ variant, size, border, hover }),
className,
)}
/>
);
},
);
return (
<Comp
{...props}
className={cn(badgeVariants({ variant, size, border, hover }), className)}
/>
);
};
+14 -20
View File
@@ -1,13 +1,7 @@
import { Badge } from "components/Badge/Badge";
import { Stack } from "components/Stack/Stack";
import {
type FC,
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
} from "react";
export const EnabledBadge: FC = () => {
export const EnabledBadge: React.FC = () => {
return (
<Badge className="option-enabled" variant="green" border="solid">
Enabled
@@ -15,25 +9,25 @@ export const EnabledBadge: FC = () => {
);
};
export const EntitledBadge: FC = () => {
export const EntitledBadge: React.FC = () => {
return (
<Badge border="solid" variant="green">
Entitled
</Badge>
);
};
export const DisabledBadge: FC = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>((props, ref) => {
export const DisabledBadge: React.FC<React.ComponentPropsWithRef<"div">> = ({
...props
}) => {
return (
<Badge ref={ref} {...props} className="option-disabled">
<Badge {...props} className="option-disabled">
Disabled
</Badge>
);
});
};
export const EnterpriseBadge: FC = () => {
export const EnterpriseBadge: React.FC = () => {
return (
<Badge variant="info" border="solid">
Enterprise
@@ -45,7 +39,7 @@ interface PremiumBadgeProps {
children?: React.ReactNode;
}
export const PremiumBadge: FC<PremiumBadgeProps> = ({
export const PremiumBadge: React.FC<PremiumBadgeProps> = ({
children = "Premium",
}) => {
return (
@@ -55,7 +49,7 @@ export const PremiumBadge: FC<PremiumBadgeProps> = ({
);
};
export const PreviewBadge: FC = () => {
export const PreviewBadge: React.FC = () => {
return (
<Badge variant="purple" border="solid">
Preview
@@ -63,7 +57,7 @@ export const PreviewBadge: FC = () => {
);
};
export const AlphaBadge: FC = () => {
export const AlphaBadge: React.FC = () => {
return (
<Badge variant="purple" border="solid">
Alpha
@@ -71,7 +65,7 @@ export const AlphaBadge: FC = () => {
);
};
export const DeprecatedBadge: FC = () => {
export const DeprecatedBadge: React.FC = () => {
return (
<Badge variant="warning" border="solid">
Deprecated
@@ -79,7 +73,7 @@ export const DeprecatedBadge: FC = () => {
);
};
export const Badges: FC<PropsWithChildren> = ({ children }) => {
export const Badges: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Stack
css={{ margin: "0 0 16px" }}
+91 -89
View File
@@ -4,62 +4,59 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { MoreHorizontal } from "lucide-react";
import {
type ComponentProps,
type ComponentPropsWithoutRef,
type FC,
forwardRef,
type ReactNode,
} from "react";
import { cn } from "utils/cn";
export const Breadcrumb = forwardRef<
HTMLElement,
ComponentPropsWithoutRef<"nav"> & {
separator?: ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
type BreadcrumbProps = React.ComponentPropsWithRef<"nav"> & {
separator?: React.ReactNode;
};
export const BreadcrumbList = forwardRef<
HTMLOListElement,
ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
className,
)}
{...props}
/>
));
export const Breadcrumb: React.FC<BreadcrumbProps> = ({ ...props }) => {
return <nav aria-label="breadcrumb" {...props} />;
};
export const BreadcrumbItem = forwardRef<
HTMLLIElement,
ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 text-content-secondary",
className,
)}
{...props}
/>
));
export const BreadcrumbList: React.FC<React.ComponentPropsWithRef<"ol">> = ({
className,
...props
}) => {
return (
<ol
className={cn(
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
className,
)}
{...props}
/>
);
};
export const BreadcrumbLink = forwardRef<
HTMLAnchorElement,
ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
export const BreadcrumbItem: React.FC<React.ComponentPropsWithRef<"li">> = ({
className,
...props
}) => {
return (
<li
className={cn(
"inline-flex items-center gap-1.5 text-content-secondary",
className,
)}
{...props}
/>
);
};
type BreadcrumbLinkProps = React.ComponentPropsWithRef<"a"> & {
asChild?: boolean;
};
export const BreadcrumbLink: React.FC<BreadcrumbLinkProps> = ({
asChild,
className,
...props
}) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn(
"text-content-secondary transition-colors hover:text-content-primary no-underline hover:underline",
className,
@@ -67,49 +64,54 @@ export const BreadcrumbLink = forwardRef<
{...props}
/>
);
});
};
export const BreadcrumbPage = forwardRef<
HTMLSpanElement,
ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
aria-current="page"
className={cn("flex items-center gap-2 text-content-secondary", className)}
{...props}
/>
));
export const BreadcrumbSeparator: FC<ComponentProps<"li">> = ({
children,
export const BreadcrumbPage: React.FC<React.ComponentPropsWithRef<"span">> = ({
className,
...props
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn(
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
className,
)}
{...props}
>
/
</li>
);
}) => {
return (
<span
aria-current="page"
className={cn(
"flex items-center gap-2 text-content-secondary",
className,
)}
{...props}
/>
);
};
export const BreadcrumbEllipsis: FC<ComponentProps<"span">> = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
export const BreadcrumbSeparator: React.FC<
Omit<React.ComponentPropsWithRef<"li">, "children">
> = ({ className, ...props }) => {
return (
<li
role="presentation"
aria-hidden="true"
className={cn(
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
className,
)}
{...props}
>
/
</li>
);
};
export const BreadcrumbEllipsis: React.FC<
Omit<React.ComponentPropsWithRef<"span">, "children">
> = ({ className, ...props }) => {
return (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
};
+30 -28
View File
@@ -4,7 +4,6 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
// Be careful when changing the child styles from the button such as images
@@ -58,31 +57,34 @@ const buttonVariants = cva(
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export type ButtonProps = React.ComponentPropsWithRef<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
{...props}
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
// Adding default button type to make sure that buttons don't
// accidentally trigger form actions when clicked. But because
// this Button component is so polymorphic (it's also used to
// make <a> elements look like buttons), we can only safely
// default to adding the prop when we know that we're rendering
// a real HTML button instead of an arbitrary Slot. Adding the
// type attribute to any non-buttons will produce invalid HTML
type={
props.type === undefined && Comp === "button" ? "button" : props.type
}
/>
);
},
);
export const Button: React.FC<ButtonProps> = ({
className,
variant,
size,
asChild = false,
...props
}) => {
const Comp = asChild ? Slot : "button";
// We want `type` to default to `"button"` when the component is not being
// used as a `Slot`. The default behavior of any given `<button>` element is
// to submit the closest parent `<form>` because Web Platform reasons. This
// prevents that. However, we don't want to set it on non-`<button>`s when
// `asChild` is set.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#type
if (!asChild && !props.type) {
props.type = "button";
}
return (
<Comp
{...props}
className={cn(buttonVariants({ variant, size }), className)}
/>
);
};
+25 -26
View File
@@ -4,41 +4,40 @@
*/
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check, Minus } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
/**
* To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined
*/
export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
export const Checkbox: React.FC<
React.ComponentPropsWithRef<typeof CheckboxPrimitive.Root>
> = ({ className, ...props }) => {
return (
<CheckboxPrimitive.Root
className={cn(
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-content-link focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:bg-surface-primary disabled:data-[state=checked]:bg-surface-tertiary
data-[state=unchecked]:bg-surface-primary
data-[state=checked]:bg-surface-invert-primary data-[state=checked]:text-content-invert
hover:enabled:border-border-hover hover:data-[state=checked]:bg-surface-invert-secondary`,
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current relative")}
className,
)}
{...props}
>
<div className="flex">
{(props.checked === true || props.defaultChecked === true) && (
<Check className="w-4 h-4" strokeWidth={2.5} />
)}
{props.checked === "indeterminate" && (
<Minus className="w-4 h-4" strokeWidth={2.5} />
)}
</div>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current relative")}
>
<div className="flex">
{(props.checked === true || props.defaultChecked === true) && (
<Check className="w-4 h-4" strokeWidth={2.5} />
)}
{props.checked === "indeterminate" && (
<Minus className="w-4 h-4" strokeWidth={2.5} />
)}
</div>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
};
@@ -1,19 +1,16 @@
import { useTheme } from "@emotion/react";
import { forwardRef, type ImgHTMLAttributes } from "react";
import { getExternalImageStylesFromUrl } from "theme/externalImages";
export const ExternalImage = forwardRef<
HTMLImageElement,
ImgHTMLAttributes<HTMLImageElement>
>((props, ref) => {
export const ExternalImage: React.FC<React.ComponentPropsWithRef<"img">> = ({
...props
}) => {
const theme = useTheme();
return (
// biome-ignore lint/a11y/useAltText: alt should be passed in as a prop
<img
ref={ref}
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
{...props}
/>
);
});
};
+37 -41
View File
@@ -5,7 +5,6 @@ import {
type ComponentProps,
createContext,
type FC,
forwardRef,
type HTMLProps,
type ReactNode,
useContext,
@@ -76,53 +75,50 @@ interface FormSectionProps {
};
alpha?: boolean;
deprecated?: boolean;
ref?: React.Ref<HTMLElement>;
}
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
(
{
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
},
ref,
) => {
const { direction } = useContext(FormContext);
export const FormSection: FC<FormSectionProps> = ({
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
ref,
}) => {
const { direction } = useContext(FormContext);
return (
<section
ref={ref}
return (
<section
ref={ref}
css={[
styles.formSection,
direction === "horizontal" && styles.formSectionHorizontal,
]}
className={classes.root}
>
<div
css={[
styles.formSection,
direction === "horizontal" && styles.formSectionHorizontal,
styles.formSectionInfo,
direction === "horizontal" && styles.formSectionInfoHorizontal,
]}
className={classes.root}
className={classes.sectionInfo}
>
<div
css={[
styles.formSectionInfo,
direction === "horizontal" && styles.formSectionInfoHorizontal,
]}
className={classes.sectionInfo}
>
<header className="flex items-center gap-4">
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
</h2>
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</header>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
<header className="flex items-center gap-4">
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
</h2>
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</header>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
{children}
</section>
);
},
);
{children}
</section>
);
};
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
return (
@@ -25,7 +25,7 @@ const custom = [
type EmojiPickerProps = Omit<
ComponentProps<typeof EmojiMart>,
"custom" | "data" | "set" | "theme"
"custom" | "data" | "set" | "theme" | "getSpritesheetURL"
>;
const EmojiPicker: FC<EmojiPickerProps> = (props) => {
@@ -53,6 +53,7 @@ const EmojiPicker: FC<EmojiPickerProps> = (props) => {
emojiVersion="15"
data={data}
custom={custom}
getSpritesheetURL={() => "/emojis/spritesheet.png"}
{...props}
/>
);
+1 -2
View File
@@ -1,7 +1,6 @@
import { css, Global, useTheme } from "@emotion/react";
import InputAdornment from "@mui/material/InputAdornment";
import TextField, { type TextFieldProps } from "@mui/material/TextField";
import { visuallyHidden } from "@mui/utils";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Loader } from "components/Loader/Loader";
@@ -116,7 +115,7 @@ export const IconField: FC<IconFieldProps> = ({
- Except we don't do it when running tests, because Jest doesn't define
`IntersectionObserver`, and it would make them slower anyway. */}
{process.env.NODE_ENV !== "test" && (
<div css={{ ...visuallyHidden }}>
<div className="sr-only" aria-hidden="true">
<Suspense>
<EmojiPicker onEmojiSelect={() => {}} />
</Suspense>
+6 -7
View File
@@ -2,13 +2,13 @@
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/input}
*/
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Input = forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, ...props }, ref) => {
export const Input: React.FC<React.ComponentPropsWithRef<"input">> = ({
className,
type,
...props
}) => {
return (
<input
type={type}
@@ -23,8 +23,7 @@ export const Input = forwardRef<
`,
className,
)}
ref={ref}
{...props}
/>
);
});
};
+7 -12
View File
@@ -1,10 +1,9 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Button, type ButtonProps } from "components/Button/Button";
import { Input } from "components/Input/Input";
import { type FC, forwardRef } from "react";
import { cn } from "utils/cn";
const InputGroup: FC<React.ComponentProps<"div">> = ({
export const InputGroup: React.FC<React.ComponentProps<"div">> = ({
className,
...props
}) => {
@@ -42,7 +41,7 @@ const inputGroupAddonVariants = cva(
},
);
const InputGroupAddon: FC<
export const InputGroupAddon: React.FC<
React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>
> = ({ className, align = "inline-start", ...props }) => {
return (
@@ -63,13 +62,11 @@ const InputGroupAddon: FC<
);
};
const InputGroupInput = forwardRef<
HTMLInputElement,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
export const InputGroupInput: React.FC<
React.ComponentPropsWithRef<typeof Input>
> = ({ className, ...props }) => {
return (
<Input
ref={ref}
className={cn(
// Reset Input's default styles that conflict with group
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0",
@@ -81,9 +78,9 @@ const InputGroupInput = forwardRef<
{...props}
/>
);
});
};
const InputGroupButton: FC<ButtonProps> = ({
export const InputGroupButton: React.FC<ButtonProps> = ({
className,
size = "sm",
variant = "subtle",
@@ -102,5 +99,3 @@ const InputGroupButton: FC<ButtonProps> = ({
/>
);
};
export { InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton };
+11 -12
View File
@@ -4,21 +4,20 @@
*/
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
type LabelProps = React.ComponentPropsWithRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>;
export const Label: React.FC<LabelProps> = ({ className, ...props }) => {
return (
<LabelPrimitive.Root
className={cn(labelVariants(), className)}
{...props}
/>
);
};
+22 -25
View File
@@ -1,7 +1,6 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const linkVariants = cva(
@@ -23,28 +22,26 @@ const linkVariants = cva(
},
);
interface LinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement>,
VariantProps<typeof linkVariants> {
asChild?: boolean;
showExternalIcon?: boolean;
}
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
VariantProps<typeof linkVariants> & {
asChild?: boolean;
showExternalIcon?: boolean;
ref?: React.Ref<HTMLAnchorElement>;
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
(
{ className, children, size, asChild, showExternalIcon = true, ...props },
ref,
) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
className={cn(linkVariants({ size }), className)}
ref={ref}
{...props}
>
<Slottable>{children}</Slottable>
{showExternalIcon && <SquareArrowOutUpRightIcon aria-hidden="true" />}
</Comp>
);
},
);
export const Link: React.FC<LinkProps> = ({
className,
children,
size,
asChild,
showExternalIcon = true,
...props
}) => {
const Comp = asChild ? Slot : "a";
return (
<Comp className={cn(linkVariants({ size }), className)} {...props}>
<Slottable>{children}</Slottable>
{showExternalIcon && <SquareArrowOutUpRightIcon />}
</Comp>
);
};
+26 -36
View File
@@ -2,16 +2,10 @@ import type { Interpolation, Theme } from "@emotion/react";
import CircularProgress, {
type CircularProgressProps,
} from "@mui/material/CircularProgress";
import {
type FC,
forwardRef,
type HTMLAttributes,
type ReactNode,
useMemo,
} from "react";
import { type FC, type ReactNode, useMemo } from "react";
import type { ThemeRole } from "theme/roles";
type PillProps = HTMLAttributes<HTMLDivElement> & {
type PillProps = React.ComponentPropsWithRef<"div"> & {
icon?: ReactNode;
type?: ThemeRole;
size?: "md" | "lg";
@@ -29,35 +23,31 @@ const PILL_HEIGHT = 24;
const PILL_ICON_SIZE = 14;
const PILL_ICON_SPACING = (PILL_HEIGHT - PILL_ICON_SIZE) / 2;
export const Pill: FC<PillProps> = forwardRef<HTMLDivElement, PillProps>(
(props, ref) => {
const {
icon,
type = "inactive",
children,
size = "md",
...divProps
} = props;
const typeStyles = useMemo(() => themeStyles(type), [type]);
export const Pill: FC<PillProps> = ({
icon,
type = "inactive",
children,
size = "md",
...divProps
}) => {
const typeStyles = useMemo(() => themeStyles(type), [type]);
return (
<div
ref={ref}
css={[
styles.pill,
Boolean(icon) && size === "md" && styles.pillWithIcon,
size === "lg" && styles.pillLg,
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
typeStyles,
]}
{...divProps}
>
{icon}
{children}
</div>
);
},
);
return (
<div
css={[
styles.pill,
Boolean(icon) && size === "md" && styles.pillWithIcon,
size === "lg" && styles.pillLg,
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
typeStyles,
]}
{...divProps}
>
{icon}
{children}
</div>
);
};
export const PillSpinner: FC<CircularProgressProps> = (props) => {
return (
+25 -30
View File
@@ -3,11 +3,6 @@
* @see {@link https://ui.shadcn.com/docs/components/popover}
*/
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";
import { cn } from "utils/cn";
export type PopoverContentProps = PopoverPrimitive.PopoverContentProps;
@@ -18,28 +13,28 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
export const PopoverContent: React.FC<
React.ComponentPropsWithRef<typeof PopoverPrimitive.Content>
> = ({ className, align = "center", sideOffset = 4, ...props }) => {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
};
+12 -18
View File
@@ -4,36 +4,30 @@
*/
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
export const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
export const RadioGroup: React.FC<
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Root>
> = ({ className, ...props }) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
};
export const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
export const RadioGroupItem: React.FC<
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Item>
> = ({ className, ...props }) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
`relative aspect-square h-4 w-4 rounded-full border border-solid border-border text-content-primary bg-surface-primary
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
hover:border-border-hover data-[state=checked]:border-border-hover`,
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
hover:border-border-hover data-[state=checked]:border-border-hover`,
className,
)}
{...props}
@@ -43,4 +37,4 @@ export const RadioGroupItem = React.forwardRef<
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
};
+36 -38
View File
@@ -3,44 +3,42 @@
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
*/
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "utils/cn";
export const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className="z-10" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export const ScrollArea: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root>
> = ({ className, children, ...props }) => {
return (
<ScrollAreaPrimitive.Root
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className="z-10" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
};
export const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
export const ScrollBar: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
> = ({ className, orientation = "vertical", ...props }) => {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
};
+44 -71
View File
@@ -4,7 +4,6 @@
*/
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
export const Select = SelectPrimitive.Root;
@@ -13,17 +12,16 @@ export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export type SelectTriggerProps = React.ComponentPropsWithoutRef<
export type SelectTriggerProps = React.ComponentPropsWithRef<
typeof SelectPrimitive.Trigger
>;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
SelectTriggerProps
>(({ className, children, id, ...props }, ref) => (
export const SelectTrigger: React.FC<SelectTriggerProps> = ({
className,
children,
...props
}) => (
<SelectPrimitive.Trigger
ref={ref}
id={id}
className={cn(
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
@@ -39,15 +37,12 @@ export const SelectTrigger = React.forwardRef<
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
);
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
const SelectScrollUpButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton>
> = ({ className, ...props }) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
@@ -56,34 +51,29 @@ const SelectScrollUpButton = React.forwardRef<
>
<ChevronUp className="size-icon-sm" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
);
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectScrollDownButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollDownButton>
> = ({ className, ...props }) => {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.ScrollDownButton>
);
};
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
export const SelectContent: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Content>
> = ({ className, children, position = "popper", ...props }) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border ",
"border-border border-solid bg-surface-primary text-content-primary shadow-md ",
@@ -112,27 +102,23 @@ export const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
);
export const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
export const SelectLabel: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Label>
> = ({ className, ...props }) => {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
};
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
export const SelectItem: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Item>
> = ({ className, children, ...props }) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ",
"pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary ",
@@ -148,17 +134,4 @@ export const SelectItem = React.forwardRef<
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
);
@@ -16,7 +16,6 @@ import { CheckIcon, ChevronDownIcon } from "lucide-react";
import {
Children,
type FC,
forwardRef,
type HTMLProps,
isValidElement,
type ReactElement,
@@ -46,15 +45,16 @@ type SelectMenuButtonProps = ButtonProps & {
startIcon?: React.ReactNode;
};
export const SelectMenuButton = forwardRef<
HTMLButtonElement,
SelectMenuButtonProps
>(({ className, startIcon, children, ...props }, ref) => {
export const SelectMenuButton: React.FC<SelectMenuButtonProps> = ({
className,
startIcon,
children,
...props
}) => {
return (
<Button
variant="outline"
size="lg"
ref={ref}
// Shrink padding right slightly to account for visual weight of
// the chevron
className={cn("flex flex-row gap-2 pr-1.5", className)}
@@ -67,7 +67,7 @@ export const SelectMenuButton = forwardRef<
<ChevronDownIcon />
</Button>
);
});
};
export const SelectMenuSearch: FC<SearchFieldProps> = ({
className,
+24 -25
View File
@@ -3,36 +3,35 @@
* @see {@link https://ui.shadcn.com/docs/components/slider}
*/
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "utils/cn";
export const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full items-center h-1.5",
className,
"touch-none select-none",
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
export const Slider: React.FC<
React.ComponentPropsWithRef<typeof SliderPrimitive.Root>
> = ({ className, ...props }) => {
return (
<SliderPrimitive.Root
className={cn(
"relative flex w-full items-center h-1.5",
className,
"touch-none select-none",
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
focus-visible:outline-none hover:border-content-primary
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
/>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
/>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
focus-visible:outline-none hover:border-content-primary
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
/>
</SliderPrimitive.Root>
));
/>
</SliderPrimitive.Root>
);
};
+4 -9
View File
@@ -1,22 +1,18 @@
import type { CSSObject } from "@emotion/react";
import { forwardRef } from "react";
/**
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
*/
type StackProps = {
type StackProps = React.ComponentPropsWithRef<"div"> & {
className?: string;
direction?: "column" | "row";
spacing?: number;
alignItems?: CSSObject["alignItems"];
justifyContent?: CSSObject["justifyContent"];
wrap?: CSSObject["flexWrap"];
} & React.HTMLProps<HTMLDivElement>;
};
/**
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
*/
export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
export const Stack: React.FC<StackProps> = (props) => {
const {
children,
direction = "column",
@@ -30,7 +26,6 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
return (
<div
{...divProps}
ref={ref}
css={{
display: "flex",
flexDirection: direction,
@@ -44,4 +39,4 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
{children}
</div>
);
});
};
@@ -4,7 +4,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { createContext, type FC, forwardRef, useContext } from "react";
import { createContext, type FC, useContext } from "react";
import { cn } from "utils/cn";
const statusIndicatorVariants = cva(
@@ -34,23 +34,24 @@ type StatusIndicatorContextValue = VariantProps<typeof statusIndicatorVariants>;
const StatusIndicatorContext = createContext<StatusIndicatorContextValue>({});
export interface StatusIndicatorProps
extends React.HTMLAttributes<HTMLDivElement>,
StatusIndicatorContextValue {}
export type StatusIndicatorProps = React.ComponentPropsWithRef<"div"> &
StatusIndicatorContextValue;
export const StatusIndicator = forwardRef<HTMLDivElement, StatusIndicatorProps>(
({ size, variant, className, ...props }, ref) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
ref={ref}
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
},
);
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
size,
variant,
className,
...props
}) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
};
const dotVariants = cva("rounded-full inline-block border-4 border-solid", {
variants: {
+4 -7
View File
@@ -3,13 +3,11 @@
* @see {@link https://ui.shadcn.com/docs/components/switch}
*/
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Switch = forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
export const Switch: React.FC<
React.ComponentPropsWithRef<typeof SwitchPrimitives.Root>
> = ({ className, ...props }) => (
<SwitchPrimitives.Root
className={cn(
`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full shadow-sm transition-colors
@@ -23,7 +21,6 @@ export const Switch = forwardRef<
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
@@ -32,4 +29,4 @@ export const Switch = forwardRef<
)}
/>
</SwitchPrimitives.Root>
));
);
+6 -8
View File
@@ -1,14 +1,13 @@
/**
* Copied from shadc/ui on 04/18/2025
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/textarea}
*/
import * as React from "react";
import { cn } from "utils/cn";
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
export const Textarea: React.FC<React.ComponentPropsWithRef<"textarea">> = ({
className,
...props
}) => {
return (
<textarea
className={cn(
@@ -18,8 +17,7 @@ export const Textarea = React.forwardRef<
disabled:cursor-not-allowed disabled:opacity-50 disabled:text-content-disabled md:text-sm`,
className,
)}
ref={ref}
{...props}
/>
);
});
};
@@ -1,18 +1,19 @@
import { TableRow, type TableRowProps } from "components/Table/Table";
import { forwardRef } from "react";
import { cn } from "utils/cn";
interface TimelineEntryProps extends TableRowProps {
ref?: React.Ref<HTMLTableRowElement>;
clickable?: boolean;
}
export const TimelineEntry = forwardRef<
HTMLTableRowElement,
TimelineEntryProps
>(({ children, clickable = true, className, ...props }, ref) => {
export const TimelineEntry: React.FC<TimelineEntryProps> = ({
children,
clickable = true,
className,
...props
}) => {
return (
<TableRow
ref={ref}
className={cn(
"focus:outline focus:-outline-offset-1 focus:outline-2 focus:outline-content-primary ",
"[&_td]:relative [&_td]:overflow-hidden",
@@ -25,4 +26,4 @@ export const TimelineEntry = forwardRef<
{children}
</TableRow>
);
});
};
+9 -9
View File
@@ -1,9 +1,8 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
/**
* Copied from shadc/ui on 02/05/2025
* @see {@link https://ui.shadcn.com/docs/components/tooltip}
*/
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "utils/cn";
export const TooltipProvider = TooltipPrimitive.Provider;
@@ -16,19 +15,20 @@ export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipArrow = TooltipPrimitive.Arrow;
export type TooltipContentProps = React.ComponentPropsWithoutRef<
export type TooltipContentProps = React.ComponentPropsWithRef<
typeof TooltipPrimitive.Content
> & {
disablePortal?: boolean;
};
export const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
TooltipContentProps
>(({ className, sideOffset = 4, disablePortal, ...props }, ref) => {
export const TooltipContent: React.FC<TooltipContentProps> = ({
className,
sideOffset = 4,
disablePortal,
...props
}) => {
const content = (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-surface-primary px-3 py-2 text-xs font-medium text-content-secondary",
@@ -47,4 +47,4 @@ export const TooltipContent = React.forwardRef<
) : (
<TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal>
);
});
};
@@ -1,5 +1,4 @@
import Skeleton from "@mui/material/Skeleton";
import { visuallyHidden } from "@mui/utils";
import type * as TypesGen from "api/typesGenerated";
import { Abbr } from "components/Abbr/Abbr";
import { Button } from "components/Button/Button";
@@ -74,7 +73,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg">
<span css={{ ...visuallyHidden }}>
<span className="sr-only">
Latency for {selectedProxy?.display_name ?? "your region"}
</span>
@@ -1,35 +1,29 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "utils/cn";
import { UnreadBadge } from "./UnreadBadge";
type InboxButtonProps = {
type InboxButtonProps = ButtonProps & {
unreadCount: number;
} & ButtonProps;
};
export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
({ unreadCount, ...props }, ref) => {
return (
<Button
size="icon-lg"
variant="outline"
className="relative"
ref={ref}
{...props}
>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className={cn([
"[--offset:calc(var(--unread-badge-size)/2)]",
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
"animate-in fade-in zoom-in duration-200",
])}
/>
)}
</Button>
);
},
);
export const InboxButton: React.FC<InboxButtonProps> = ({
unreadCount,
...props
}) => {
return (
<Button size="icon-lg" variant="outline" className="relative" {...props}>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className={cn([
"[--offset:calc(var(--unread-badge-size)/2)]",
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
"animate-in fade-in zoom-in duration-200",
])}
/>
)}
</Button>
);
};
+4 -6
View File
@@ -1,7 +1,5 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { forwardRef } from "react";
export const AgentButton = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
return <Button variant="outline" ref={ref} {...props} />;
},
);
export const AgentButton: React.FC<ButtonProps> = ({ ...props }) => {
return <Button variant="outline" {...props} />;
};
@@ -3,7 +3,7 @@ import { expect, fn, userEvent, within } from "storybook/test";
import { TaskActionButton } from "./TaskActionButton";
const meta: Meta<typeof TaskActionButton> = {
title: "pages/TasksPage/TaskActionButton",
title: "modules/tasks/TaskActionButton",
component: TaskActionButton,
args: {
onClick: fn(),
@@ -18,6 +18,7 @@ type PromptSelectTriggerProps = SelectTriggerProps & {
export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
className,
tooltip,
children,
...props
}) => {
return (
@@ -27,12 +28,19 @@ export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
<SelectTrigger
{...props}
className={cn([
className,
`w-auto border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-3
`w-full md:w-auto max-w-full overflow-hidden border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-4 md:px-3
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
h-8 data-[state=open]:bg-surface-tertiary`,
h-10 md:h-8 data-[state=open]:bg-surface-tertiary`,
className,
])}
/>
>
<span
data-slot="value"
className="overflow-hidden min-w-0 flex items-center gap-2"
>
{children}
</span>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
@@ -473,6 +473,28 @@ export const CheckExternalAuthOnChangingVersions: Story = {
},
};
// Regression test introduced in https://github.com/coder/coder/pull/22032
// A change was introduced that cause the focused selector to be mostly
// hidden due to an introduced `overflow-hidden`. The change wasn't spotted
// in the PR it was introduced as no stories triggered that behavior, so we
// have added one to ensure the behavior doesn't regress.
export const PresetSelectorFocused: Story = {
beforeEach: () => {
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(
MockPresets.map((preset, i) => ({
...preset,
Icon: i === 0 ? "/icon/code.svg" : i === 1 ? "/icon/database.svg" : "",
Description: i === 0 ? "For everyday development work" : "",
})),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const presetSelect = await canvas.findByLabelText(/preset/i);
presetSelect.focus();
},
};
export const CheckPresetsWhenChangingTemplate: Story = {
args: {
templates: [
@@ -8,6 +8,7 @@ import type {
TemplateVersionExternalAuth,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
@@ -235,7 +236,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
{externalAuthError && <ErrorAlert error={externalAuthError} />}
<fieldset
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary"
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary min-w-0"
disabled={createTaskMutation.isPending}
>
<label htmlFor="prompt" className="sr-only">
@@ -248,9 +249,9 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
isSubmitting={createTaskMutation.isPending}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-1">
<div>
<div className="flex items-center justify-between pt-2 gap-2">
<div className="flex items-center gap-1 flex-1 min-w-0">
<div className="min-w-0 max-w-[33.3%]">
<label htmlFor="templateID" className="sr-only">
Select template
</label>
@@ -292,7 +293,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
</div>
{permissions.updateTemplates && (
<div>
<div className="min-w-0 max-w-[33.3%]">
<label htmlFor="versionId" className="sr-only">
Template version
</label>
@@ -305,7 +306,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
</div>
)}
<div className="flex-1 overflow-hidden min-w-0">
<div className="flex-1 min-w-0">
<label htmlFor="presetID" className="sr-only">
Preset
</label>
@@ -324,40 +325,47 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
<PromptSelectTrigger
id="presetID"
tooltip="Preset"
className="w-full max-w-full [&_span]:flex [&_span]:items-center [&_span]:gap-2 [&_span]:min-w-0 [&_span]:overflow-hidden [&_span>span]:truncate [&_svg[data-slot='preset-description']]:hidden"
className="max-w-full [&_[data-slot=preset-name]]:truncate [&_[data-slot=preset-name]]:min-w-0 [&_[data-slot=preset-description]]:hidden"
>
<SelectValue placeholder="Select a preset" />
</PromptSelectTrigger>
<SelectContent>
{presets?.toSorted(sortByDefault).map((preset) => (
<SelectItem
value={preset.ID}
key={preset.ID}
className="[&_span]:flex [&_span]:items-center [&_span]:gap-2"
>
{preset.Icon && (
<img
src={preset.Icon}
alt={preset.Name}
className="size-icon-sm flex-shrink-0"
/>
)}
<span>
{preset.Name} {preset.Default && "(Default)"}
</span>
{preset.Description && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
className="size-4"
data-slot="preset-description"
/>
</TooltipTrigger>
<TooltipContent>
{preset.Description}
</TooltipContent>
</Tooltip>
)}
<SelectItem value={preset.ID} key={preset.ID}>
<div className="flex items-center gap-2">
{preset.Icon && (
<img
data-slot="preset-icon"
src={preset.Icon}
alt={preset.Name}
className="size-icon-sm shrink-0"
/>
)}
<span
data-slot="preset-name"
className="truncate min-w-0"
>
{preset.Name}
</span>
{preset.Default && (
<Badge size="xs" className="shrink-0">
Default
</Badge>
)}
{preset.Description && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
className="size-4"
data-slot="preset-description"
/>
</TooltipTrigger>
<TooltipContent>
{preset.Description}
</TooltipContent>
</Tooltip>
)}
</div>
</SelectItem>
))}
</SelectContent>
@@ -48,10 +48,10 @@ export const TemplateVersionSelect: FC<TemplateVersionSelectProps> = ({
{versions.map((version) => {
return (
<SelectItem value={version.id} key={version.id}>
<span className="flex items-center gap-2">
{version.name}
<span className="flex items-center gap-2 min-w-0">
<span className="truncate">{version.name}</span>
{activeVersionId === version.id && (
<Badge size="xs" variant="green">
<Badge size="xs" variant="green" className="shrink-0">
Active
</Badge>
)}
@@ -1,5 +1,6 @@
import {
MockDisplayNameTasks,
MockTask,
MockTasks,
MockUserOwner,
mockApiError,
@@ -131,3 +132,69 @@ export const OpenDeleteDialog: Story = {
});
},
};
export const PauseMenuOpen: Story = {
beforeEach: () => {
spyOn(API, "getTasks").mockResolvedValue(MockTasks);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const optionButtons = await canvas.findAllByRole("button", {
name: /task options/i,
});
await userEvent.click(optionButtons[0]);
},
};
export const ResumeMenuOpen: Story = {
beforeEach: () => {
spyOn(API, "getTasks").mockResolvedValue([
{ ...MockTask, status: "paused" },
...MockTasks.slice(1),
]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const optionButtons = await canvas.findAllByRole("button", {
name: /task options/i,
});
await userEvent.click(optionButtons[0]);
},
};
export const MixedStatuses: Story = {
beforeEach: () => {
spyOn(API, "getTasks").mockResolvedValue([
MockTask,
{
...MockTask,
id: "paused-task",
name: "paused-task",
display_name: "Paused task",
status: "paused",
},
{
...MockTask,
id: "error-task",
name: "error-task",
display_name: "Error task",
status: "error",
},
{
...MockTask,
id: "init-task",
name: "init-task",
display_name: "Initializing task",
status: "initializing",
},
]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const optionButtons = await canvas.findAllByRole("button", {
name: /task options/i,
});
// Open menu on the error task (third item) to show both Pause and Resume.
await userEvent.click(optionButtons[2]);
},
};
@@ -1,5 +1,6 @@
import { API } from "api/api";
import { getErrorMessage } from "api/errors";
import { pauseTask, resumeTask } from "api/queries/tasks";
import type { Task, TasksFilter } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
@@ -7,11 +8,14 @@ import {
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { displayError } from "components/GlobalSnackbar/utils";
import { CoderIcon } from "components/Icons/CoderIcon";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import { Spinner } from "components/Spinner/Spinner";
import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator";
import {
Tooltip,
@@ -21,13 +25,21 @@ import {
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
import {
EditIcon,
EllipsisIcon,
PanelLeftIcon,
PauseIcon,
PlayIcon,
TrashIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import { cn } from "utils/cn";
import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog";
import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus";
import { canPauseTask, canResumeTask, isPauseDisabled } from "../taskActions";
import { UserCombobox } from "./UserCombobox";
export const TasksSidebar: FC = () => {
@@ -180,6 +192,23 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
const isActive = task.id === taskId;
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const pauseMutation = useMutation({
...pauseTask(task, queryClient),
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to pause task."));
},
});
const resumeMutation = useMutation({
...resumeTask(task, queryClient),
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to resume task."));
},
});
const showPause = canPauseTask(task.status) && task.workspace_id;
const pauseDisabled = isPauseDisabled(task.status);
const showResume = canResumeTask(task.status) && task.workspace_id;
return (
<>
@@ -227,6 +256,35 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{showPause && (
<DropdownMenuItem
disabled={pauseDisabled || pauseMutation.isPending}
onClick={(e) => {
e.stopPropagation();
pauseMutation.mutate();
}}
>
<Spinner loading={pauseMutation.isPending}>
<PauseIcon />
</Spinner>
Pause
</DropdownMenuItem>
)}
{showResume && (
<DropdownMenuItem
disabled={resumeMutation.isPending}
onClick={(e) => {
e.stopPropagation();
resumeMutation.mutate();
}}
>
<Spinner loading={resumeMutation.isPending}>
<PlayIcon />
</Spinner>
Resume
</DropdownMenuItem>
)}
{(showPause || showResume) && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
+43
View File
@@ -0,0 +1,43 @@
import type { TaskStatus } from "api/typesGenerated";
/**
* Task statuses that allow pausing.
*/
const PAUSABLE_STATUSES: TaskStatus[] = [
"active",
"initializing",
"pending",
"error",
"unknown",
];
/**
* Task statuses where the pause button should be disabled (in transition).
*/
const PAUSE_DISABLED_STATUSES: TaskStatus[] = ["pending", "initializing"];
/**
* Task statuses that allow resuming.
*/
const RESUMABLE_STATUSES: TaskStatus[] = ["paused", "error", "unknown"];
/**
* Checks if a task can be paused based on its status.
*/
export function canPauseTask(status: TaskStatus): boolean {
return PAUSABLE_STATUSES.includes(status);
}
/**
* Checks if the pause action should be disabled for a task status.
*/
export function isPauseDisabled(status: TaskStatus): boolean {
return PAUSE_DISABLED_STATUSES.includes(status);
}
/**
* Checks if a task can be resumed based on its status.
*/
export function canResumeTask(status: TaskStatus): boolean {
return RESUMABLE_STATUSES.includes(status);
}
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { BuildAuditDescription } from "./BuildAuditDescription";
@@ -52,8 +52,10 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
<span>
{truncatedDescription}
{auditLog.resource_link ? (
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{target}</strong>
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={auditLog.resource_link}>
<strong>{target}</strong>
</RouterLink>
</Link>
) : (
<strong>{target}</strong>
@@ -70,8 +72,10 @@ function AppSessionAuditLogDescription({ auditLog }: AuditLogDescriptionProps) {
return (
<>
{connection_type} session to {workspace_owner}'s{" "}
<Link component={RouterLink} to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace{" "}
<strong>{auditLog.action === "disconnect" ? "closed" : "opened"}</strong>
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import { type FC, useMemo } from "react";
import { Link as RouterLink } from "react-router";
import { systemBuildReasons } from "utils/workspace";
@@ -38,8 +38,10 @@ export const BuildAuditDescription: FC<BuildAuditDescriptionProps> = ({
<span>
{user} <strong>{action}</strong> workspace{" "}
{auditLog.resource_link ? (
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
</RouterLink>
</Link>
) : (
<strong>{workspaceName}</strong>
@@ -1,10 +1,11 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Collapse from "@mui/material/Collapse";
import Link from "@mui/material/Link";
import type { AuditLog, BuildReason } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import {
Collapsible,
CollapsibleContent,
} from "components/Collapsible/Collapsible";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Stack } from "components/Stack/Stack";
import { Link } from "components/Link/Link";
import { StatusPill } from "components/StatusPill/StatusPill";
import { TableCell } from "components/Table/Table";
import { TimelineEntry } from "components/Timeline/TimelineEntry";
@@ -71,246 +72,186 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
data-testid={`audit-log-row-${auditLog.id}`}
clickable={shouldDisplayDiff}
>
<TableCell css={styles.auditLogCell}>
<Stack
direction="row"
alignItems="center"
css={styles.auditLogHeader}
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter") {
toggle();
}
}}
>
<Stack
direction="row"
alignItems="center"
css={styles.auditLogHeaderInfo}
<TableCell className="!p-0 border-0 border-b text-base">
<Collapsible open={isDiffOpen} onOpenChange={setIsDiffOpen}>
<div
className="flex flex-row items-center gap-4 py-4 px-8"
tabIndex={0}
role="button"
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter") {
toggle();
}
}}
>
<Stack direction="row" alignItems="center" css={styles.fullWidth}>
{/*
* Session logs don't have an associated user to the log,
* so when it happens we display a default icon to represent non user actions
*/}
{auditLog.user ? (
<Avatar
fallback={auditLog.user.username}
src={auditLog.user.avatar_url}
/>
) : (
<Avatar>
<NetworkIcon className="h-full w-full p-1" />
</Avatar>
)}
<div className="flex flex-row items-center gap-4 flex-1">
<div className="flex flex-row items-center gap-4 w-full">
{/*
* Session logs don't have an associated user to the log,
* so when it happens we display a default icon to represent non user actions
*/}
{auditLog.user ? (
<Avatar
fallback={auditLog.user.username}
src={auditLog.user.avatar_url}
/>
) : (
<Avatar>
<NetworkIcon className="h-full w-full p-1" />
</Avatar>
)}
<Stack
alignItems="baseline"
css={styles.fullWidth}
justifyContent="space-between"
direction="row"
>
<Stack
css={styles.auditLogSummary}
direction="row"
alignItems="baseline"
spacing={1}
>
<AuditLogDescription auditLog={auditLog} />
{auditLog.is_deleted && (
<span css={styles.deletedLabel}>(deleted)</span>
)}
<span css={styles.auditLogTime}>
{new Date(auditLog.time).toLocaleTimeString()}
</span>
</Stack>
<div className="flex flex-row items-baseline justify-between w-full font-normal gap-4">
<div className="flex flex-row items-baseline gap-2">
<AuditLogDescription auditLog={auditLog} />
{auditLog.is_deleted && (
<span className="text-xs text-content-secondary">
(deleted)
</span>
)}
<span className="text-content-secondary text-xs">
{new Date(auditLog.time).toLocaleTimeString()}
</span>
</div>
<Stack direction="row" alignItems="center">
<StatusPill isHttpCode={true} code={auditLog.status_code} />
<div className="flex flex-row items-center gap-4">
<StatusPill isHttpCode={true} code={auditLog.status_code} />
{/* With multi-org, there is not enough space so show
{/* With multi-org, there is not enough space so show
everything in a tooltip. */}
{showOrgDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
css={(theme) => ({
color: theme.palette.info.light,
})}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<div css={styles.auditLogInfoTooltip}>
{auditLog.ip && (
<div>
<h4 css={styles.auditLogInfoHeader}>IP:</h4>
<div>{auditLog.ip}</div>
</div>
)}
{userAgent?.os.name && (
<div>
<h4 css={styles.auditLogInfoHeader}>OS:</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 css={styles.auditLogInfoHeader}>Browser:</h4>
{showOrgDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="text-content-link" />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex flex-col gap-2">
{auditLog.ip && (
<div>
{userAgent.browser.name}{" "}
{userAgent.browser.version}
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
IP:
</h4>
<div>{auditLog.ip}</div>
</div>
</div>
)}
{auditLog.organization && (
<div>
<h4 css={styles.auditLogInfoHeader}>
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
</Link>
</div>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
)}
{userAgent?.os.name && (
<div>
<h4 css={styles.auditLogInfoHeader}>Reason:</h4>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
OS:
</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
Browser:
</h4>
<div>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
{userAgent.browser.name}{" "}
{userAgent.browser.version}
</div>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
) : (
<Stack direction="row" spacing={1} alignItems="baseline">
{auditLog.ip && (
<span css={styles.auditLogInfo}>
<span>IP: </span>
<strong>{auditLog.ip}</strong>
</span>
)}
{userAgent?.os.name && (
<span css={styles.auditLogInfo}>
<span>OS: </span>
<strong>{userAgent.os.name}</strong>
</span>
)}
{userAgent?.browser.name && (
<span css={styles.auditLogInfo}>
<span>Browser: </span>
<strong>
{userAgent.browser.name} {userAgent.browser.version}
</strong>
</span>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<span css={styles.auditLogInfo}>
<span>Reason: </span>
{auditLog.organization && (
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
Organization:
</h4>
<Link
asChild
showExternalIcon={false}
className="px-0"
>
<RouterLink
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
</RouterLink>
</Link>
</div>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<div>
<h4 className="m-0 text-content-primary leading-normal font-semibold">
Reason:
</h4>
<div>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</div>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
) : (
<div className="flex flex-row items-baseline gap-2">
{auditLog.ip && (
<span className="text-xs text-content-secondary block">
<span>IP: </span>
<strong>{auditLog.ip}</strong>
</span>
)}
{userAgent?.os.name && (
<span className="text-xs text-content-secondary block">
<span>OS: </span>
<strong>{userAgent.os.name}</strong>
</span>
)}
{userAgent?.browser.name && (
<span className="text-xs text-content-secondary block">
<span>Browser: </span>
<strong>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
{userAgent.browser.name}{" "}
{userAgent.browser.version}
</strong>
</span>
)}
</Stack>
)}
</Stack>
</Stack>
</Stack>
</Stack>
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<span className="text-xs text-content-secondary block">
<span>Reason: </span>
<strong>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</strong>
</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
{shouldDisplayDiff ? (
<div> {<DropdownArrow close={isDiffOpen} />}</div>
) : (
<div css={styles.columnWithoutDiff} />
{shouldDisplayDiff ? (
<div>
<DropdownArrow close={isDiffOpen} />
</div>
) : (
<div className="ml-6" />
)}
</div>
{shouldDisplayDiff && (
<CollapsibleContent>
<AuditLogDiff diff={auditDiff} />
</CollapsibleContent>
)}
</Stack>
{shouldDisplayDiff && (
<Collapse in={isDiffOpen}>
<AuditLogDiff diff={auditDiff} />
</Collapse>
)}
</Collapsible>
</TableCell>
</TimelineEntry>
);
};
const styles = {
auditLogCell: {
padding: "0 !important",
border: 0,
},
auditLogHeader: {
padding: "16px 32px",
},
auditLogHeaderInfo: {
flex: 1,
},
auditLogSummary: (theme) => ({
...(theme.typography.body1 as CSSObject),
fontFamily: "inherit",
}),
auditLogTime: (theme) => ({
color: theme.palette.text.secondary,
fontSize: 12,
}),
auditLogInfo: (theme) => ({
...(theme.typography.body2 as CSSObject),
fontSize: 12,
fontFamily: "inherit",
color: theme.palette.text.secondary,
display: "block",
}),
auditLogInfoHeader: (theme) => ({
margin: 0,
color: theme.palette.text.primary,
fontSize: 14,
lineHeight: "150%",
fontWeight: 600,
}),
auditLogInfoTooltip: {
display: "flex",
flexDirection: "column",
gap: 8,
},
// offset the absence of the arrow icon on diff-less logs
columnWithoutDiff: {
marginLeft: "24px",
},
fullWidth: {
width: "100%",
},
deletedLabel: (theme) => ({
...(theme.typography.caption as CSSObject),
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { ConnectionLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import type { FC, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import { connectionTypeToFriendlyName } from "utils/connection";
@@ -62,11 +62,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
<span>
{user ? user.username : "Unauthenticated user"} {actionText} in{" "}
{isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
<Link asChild showExternalIcon={false} className="text-base">
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace
</span>
@@ -81,11 +80,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
return (
<span>
{friendlyType} session to {workspace_owner_username}'s{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
<Link asChild showExternalIcon={false} className="text-base">
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace{" "}
</span>
@@ -1,8 +1,6 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import type { ConnectionLog } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Stack } from "components/Stack/Stack";
import { Link } from "components/Link/Link";
import { StatusPill } from "components/StatusPill/StatusPill";
import { TableCell } from "components/Table/Table";
import { TimelineEntry } from "components/Timeline/TimelineEntry";
@@ -38,18 +36,9 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
data-testid={`connection-log-row-${connectionLog.id}`}
clickable={false}
>
<TableCell css={styles.connectionLogCell}>
<Stack
direction="row"
alignItems="center"
css={styles.connectionLogHeader}
tabIndex={0}
>
<Stack
direction="row"
alignItems="center"
css={styles.connectionLogHeaderInfo}
>
<TableCell className="!p-0 border-0">
<div className="flex flex-row items-center gap-4 py-4 px-8">
<div className="flex flex-row items-center gap-4 flex-1">
{/* Non-web logs don't have an associated user, so we
* display a default network icon instead */}
{connectionLog.web_info?.user ? (
@@ -63,27 +52,17 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
</Avatar>
)}
<Stack
alignItems="center"
css={styles.fullWidth}
justifyContent="space-between"
direction="row"
>
<Stack
css={styles.connectionLogSummary}
direction="row"
alignItems="baseline"
spacing={1}
>
<div className="flex flex-row items-center justify-between w-full">
<div className="flex flex-row items-baseline gap-2 text-base">
<ConnectionLogDescription connectionLog={connectionLog} />
<span css={styles.connectionLogTime}>
<span className="text-content-secondary text-xs">
{new Date(connectionLog.connect_time).toLocaleTimeString()}
{connectionLog.ssh_info?.disconnect_time &&
`${new Date(connectionLog.ssh_info.disconnect_time).toLocaleTimeString()}`}
</span>
</Stack>
</div>
<Stack direction="row" alignItems="center">
<div className="flex flex-row items-center gap-4">
{code !== undefined && (
<StatusPill
code={code}
@@ -93,29 +72,31 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
)}
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
css={(theme) => ({
color: theme.palette.info.light,
})}
/>
<InfoIcon className="text-content-link" />
</TooltipTrigger>
<TooltipContent side="bottom">
<div css={styles.connectionLogInfoTooltip}>
<div className="flex flex-col gap-2">
{connectionLog.ip && (
<div>
<h4 css={styles.connectionLogInfoheader}>IP:</h4>
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
IP:
</h4>
<div>{connectionLog.ip}</div>
</div>
)}
{userAgent?.os.name && (
<div>
<h4 css={styles.connectionLogInfoheader}>OS:</h4>
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
OS:
</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 css={styles.connectionLogInfoheader}>Browser:</h4>
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
Browser:
</h4>
<div>
{userAgent.browser.name} {userAgent.browser.version}
</div>
@@ -123,21 +104,26 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
)}
{connectionLog.organization && (
<div>
<h4 css={styles.connectionLogInfoheader}>
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${connectionLog.organization.name}`}
asChild
showExternalIcon={false}
className="px-0 text-xs"
>
{connectionLog.organization.display_name ||
connectionLog.organization.name}
<RouterLink
to={`/organizations/${connectionLog.organization.name}`}
>
{connectionLog.organization.display_name ||
connectionLog.organization.name}
</RouterLink>
</Link>
</div>
)}
{connectionLog.ssh_info?.disconnect_reason && (
<div>
<h4 css={styles.connectionLogInfoheader}>
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
Close Reason:
</h4>
<div>{connectionLog.ssh_info?.disconnect_reason}</div>
@@ -146,54 +132,11 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
</div>
</TooltipContent>
</Tooltip>
</Stack>
</Stack>
</Stack>
</Stack>
</div>
</div>
</div>
</div>
</TableCell>
</TimelineEntry>
);
};
const styles = {
connectionLogCell: {
padding: "0 !important",
border: 0,
},
connectionLogHeader: {
padding: "16px 32px",
},
connectionLogHeaderInfo: {
flex: 1,
},
connectionLogSummary: (theme) => ({
...(theme.typography.body1 as CSSObject),
fontFamily: "inherit",
}),
connectionLogTime: (theme) => ({
color: theme.palette.text.secondary,
fontSize: 12,
}),
connectionLogInfoheader: (theme) => ({
margin: 0,
color: theme.palette.text.primary,
fontSize: 14,
lineHeight: "150%",
fontWeight: 600,
}),
connectionLogInfoTooltip: {
display: "flex",
flexDirection: "column",
gap: 8,
},
fullWidth: {
width: "100%",
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,7 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import { visuallyHidden } from "@mui/utils";
import { JobError } from "api/queries/templates";
import type { TemplateVersion } from "api/typesGenerated";
import { Button } from "components/Button/Button";
@@ -46,7 +45,7 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
<h3 css={styles.title}>Creating template...</h3>
<IconButton size="small" onClick={drawerProps.onClose}>
<XIcon className="size-icon-sm" />
<span style={visuallyHidden}>Close build logs</span>
<span className="sr-only">Close build logs</span>
</IconButton>
</header>
@@ -86,24 +86,14 @@ export const LicenseCard: FC<LicenseCardProps> = ({
</span>
</Stack>
{license.claims.nbf && (
<Stack
direction="column"
spacing={0}
alignItems="center"
width="134px" // standardize width of date column
>
<Stack direction="column" spacing={0} alignItems="center">
<span css={styles.secondaryMaincolor}>Valid From</span>
<span css={styles.licenseExpires} className="license-valid-from">
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
</span>
</Stack>
)}
<Stack
direction="column"
spacing={0}
alignItems="center"
width="134px" // standardize width of date column
>
<Stack direction="column" spacing={0} alignItems="center">
{dayjs(license.claims.license_expires * 1000).isBefore(dayjs()) ? (
<Pill css={styles.expiredBadge} type="error">
Expired
+3 -6
View File
@@ -10,7 +10,6 @@ import {
type ComponentProps,
cloneElement,
type FC,
forwardRef,
type HTMLAttributes,
type ReactElement,
} from "react";
@@ -155,17 +154,15 @@ export const SectionLabel: FC<HTMLAttributes<HTMLHeadingElement>> = (props) => {
);
};
type PillProps = HTMLAttributes<HTMLDivElement> & {
type PillProps = React.ComponentPropsWithRef<"div"> & {
icon: ReactElement<HTMLAttributes<HTMLElement>>;
};
export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
export const Pill: React.FC<PillProps> = ({ icon, children, ...divProps }) => {
const theme = useTheme();
const { icon, children, ...divProps } = props;
return (
<div
ref={ref}
css={{
display: "inline-flex",
alignItems: "center",
@@ -184,7 +181,7 @@ export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
{children}
</div>
);
});
};
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
value: boolean | null;
+1 -2
View File
@@ -1,4 +1,3 @@
import { visuallyHidden } from "@mui/utils";
import type { AuthMethods } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
@@ -80,7 +79,7 @@ const OidcIcon: FC<OidcIconProps> = ({ iconUrl }) => {
return (
<>
<img alt="" src={iconUrl} aria-labelledby={oidcId} />
<div id={oidcId} css={{ ...visuallyHidden }}>
<div id={oidcId} className="sr-only">
Open ID Connect
</div>
</>
+97 -15
View File
@@ -1,9 +1,13 @@
import {
MockCanceledWorkspace,
MockCancelingWorkspace,
MockDeletedWorkspace,
MockDeletingWorkspace,
MockDisplayNameTasks,
MockFailedWorkspace,
MockStartingWorkspace,
MockStoppedWorkspace,
MockStoppingWorkspace,
MockTask,
MockTasks,
MockUserOwner,
@@ -14,6 +18,7 @@ import {
MockWorkspaceAgentStarting,
MockWorkspaceApp,
MockWorkspaceAppStatus,
MockWorkspaceBuildStop,
MockWorkspaceResource,
mockApiError,
} from "testHelpers/entities";
@@ -180,6 +185,77 @@ export const DeletedWorkspace: Story = {
},
};
export const TaskPausing: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "active",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppingWorkspace,
);
},
};
export const TaskPaused: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "paused",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
},
};
export const TaskPausedTimeout: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "paused",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({
...MockStoppedWorkspace,
latest_build: {
...MockWorkspaceBuildStop,
status: "stopped",
reason: "autostop",
},
});
},
};
export const TaskCanceled: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "paused",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockCanceledWorkspace,
);
},
};
export const TaskCanceling: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockCancelingWorkspace,
);
},
};
export const TaskDeleting: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockDeletingWorkspace,
);
},
};
export const WaitingStartupScripts: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
@@ -403,7 +479,7 @@ export const MainAppHealthy: Story = mainAppHealthStory("healthy");
export const MainAppInitializing: Story = mainAppHealthStory("initializing");
export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy");
export const OutdatedWorkspace: Story = {
export const TaskPausedOutdated: Story = {
// Given: an 'outdated' workspace (that is, the latest build does not use template's active version)
parameters: {
queries: [
@@ -487,10 +563,13 @@ export const ActivePreview: Story = {
},
};
export const WorkspaceStarting: Story = {
export const TaskResuming: Story = {
decorators: [withGlobalSnackbar],
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "paused",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
@@ -514,10 +593,10 @@ export const WorkspaceStarting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const resumeButton = await canvas.findByText("Resume");
expect(resumeButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(resumeButton);
await waitFor(async () => {
expect(API.startWorkspace).toBeCalled();
@@ -525,10 +604,13 @@ export const WorkspaceStarting: Story = {
},
};
export const WorkspaceStartFailure: Story = {
export const TaskResumeFailure: Story = {
decorators: [withGlobalSnackbar],
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getTask").mockResolvedValue({
...MockTask,
status: "paused",
});
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockStoppedWorkspace,
);
@@ -552,10 +634,10 @@ export const WorkspaceStartFailure: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const resumeButton = await canvas.findByText("Resume");
expect(resumeButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(resumeButton);
await waitFor(async () => {
const errorMessage = await canvas.findByText("Some unexpected error");
@@ -564,7 +646,7 @@ export const WorkspaceStartFailure: Story = {
},
};
export const WorkspaceStartFailureWithDialog: Story = {
export const TaskResumeFailureWithDialog: Story = {
beforeEach: () => {
spyOn(API, "getTask").mockResolvedValue(MockTask);
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
@@ -594,10 +676,10 @@ export const WorkspaceStartFailureWithDialog: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
const resumeButton = await canvas.findByText("Resume");
expect(resumeButton).toBeInTheDocument();
await userEvent.click(startButton);
await userEvent.click(resumeButton);
await waitFor(async () => {
const body = within(canvasElement.ownerDocument.body);
+214 -97
View File
@@ -1,12 +1,10 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
import { pauseTask, resumeTask } from "api/queries/tasks";
import { template as templateQueryOptions } from "api/queries/templates";
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
import {
startWorkspace,
workspaceByOwnerAndName,
} from "api/queries/workspaces";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import type {
Task,
Workspace,
WorkspaceAgent,
WorkspaceStatus,
@@ -19,11 +17,17 @@ import { Margins } from "components/Margins/Margins";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Spinner } from "components/Spinner/Spinner";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import {
ArrowLeftIcon,
PauseIcon,
RotateCcwIcon,
TriangleAlertIcon,
} from "lucide-react";
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
import { useAgentLogs } from "modules/resources/useAgentLogs";
import { getAllAppsWithAgent } from "modules/tasks/apps";
import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar";
import { isPauseDisabled } from "modules/tasks/taskActions";
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
@@ -134,33 +138,48 @@ const TaskPage = () => {
);
} else if (workspace.latest_build.status === "failed") {
content = (
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build failed
</h3>
<span className="text-content-secondary text-sm">
Please check the logs for more details.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink
to={`/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`}
>
View logs
</RouterLink>
</Button>
</div>
</div>
<TaskBuildFailed
workspaceOwner={workspace.owner_name}
workspaceName={workspace.name}
buildNumber={workspace.latest_build.build_number}
/>
);
} else if (workspace.latest_build.status !== "running") {
} else if (workspace.latest_build.status === "stopping") {
content = (
<WorkspaceNotRunning
<TaskTransitioning
title="Pausing task"
subtitle="Your task is being paused..."
/>
);
} else if (
workspace.latest_build.status === "stopped" ||
workspace.latest_build.status === "canceled"
) {
content = (
<TaskPaused
task={task}
workspace={workspace}
onEditPrompt={() => setIsModifyDialogOpen(true)}
/>
);
} else if (workspace.latest_build.status === "canceling") {
content = (
<TaskTransitioning
title="Canceling task"
subtitle="Your task is being canceled..."
/>
);
} else if (workspace.latest_build.status === "deleting") {
content = (
<TaskTransitioning
title="Deleting task"
subtitle="Your task workspace is being deleted..."
/>
);
} else if (workspace.latest_build.status === "deleted") {
content = <TaskDeleted />;
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
content = <TaskStartingAgent task={task} agent={agent} />;
} else {
const chatApp = getAllAppsWithAgent(workspace).find(
(app) => app.id === task.workspace_app_id,
@@ -213,111 +232,187 @@ const TaskPage = () => {
export default TaskPage;
type WorkspaceNotRunningProps = {
/**
* Common component for task state messages (paused, deleted, transitioning, etc.)
* Similar to EmptyState but styled for task states.
*/
type TaskStateMessageProps = {
title: string;
description?: string;
icon?: ReactNode;
actions?: ReactNode;
detail?: ReactNode;
};
const TaskStateMessage: FC<TaskStateMessageProps> = ({
title,
description,
icon,
actions,
detail,
}) => {
return (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-base flex items-center gap-2">
{icon}
{title}
</h3>
{description && (
<span className="text-content-secondary text-sm">
{description}
</span>
)}
{detail}
{actions && <div className="mt-4">{actions}</div>}
</div>
</div>
</Margins>
);
};
type TaskTransitioningProps = {
title: string;
subtitle: string;
};
const TaskTransitioning: FC<TaskTransitioningProps> = ({ title, subtitle }) => {
return (
<TaskStateMessage
title={title}
description={subtitle}
icon={<Spinner loading />}
/>
);
};
const TaskDeleted: FC = () => {
return (
<TaskStateMessage
title="Task was deleted"
description="This task cannot be resumed. Create a new task to continue."
actions={
<Button size="sm" variant="outline" asChild>
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
}
/>
);
};
type TaskBuildFailedProps = {
workspaceOwner: string;
workspaceName: string;
buildNumber: number;
};
const TaskBuildFailed: FC<TaskBuildFailedProps> = ({
workspaceOwner,
workspaceName,
buildNumber,
}) => {
return (
<TaskStateMessage
title="Task build failed"
description="Please check the logs for more details."
icon={<TriangleAlertIcon className="size-4 text-content-destructive" />}
actions={
<Button size="sm" variant="outline" asChild>
<RouterLink
to={`/@${workspaceOwner}/${workspaceName}/builds/${buildNumber}`}
>
View full logs
</RouterLink>
</Button>
}
/>
);
};
type TaskPausedProps = {
task: Task;
workspace: Workspace;
onEditPrompt: () => void;
};
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
workspace,
onEditPrompt,
}) => {
const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
const queryClient = useQueryClient();
const { data: buildParameters } = useQuery(
workspaceBuildParameters(workspace.latest_build.id),
);
const mutateStartWorkspace = useMutation({
...startWorkspace(workspace, queryClient),
// Use mutation config directly to customize error handling:
// API errors are shown in a dialog, other errors show a toast.
const resumeMutation = useMutation({
...resumeTask(task, queryClient),
onError: (error: unknown) => {
if (!isApiError(error)) {
displayError(getErrorMessage(error, "Failed to build workspace."));
displayError(getErrorMessage(error, "Failed to resume task."));
}
},
});
// After requesting a workspace start, it may take a while to become ready.
// Show a loading state in the meantime.
// After requesting a task resume, it may take a while to become ready.
const isWaitingForStart =
mutateStartWorkspace.isPending || mutateStartWorkspace.isSuccess;
resumeMutation.isPending || resumeMutation.isSuccess;
const apiError = isApiError(mutateStartWorkspace.error)
? mutateStartWorkspace.error
// Determine if this was a timeout (autostop) or manual pause.
const isTimeout = workspace.latest_build.reason === "autostop";
const apiError = isApiError(resumeMutation.error)
? resumeMutation.error
: undefined;
const deleted = workspace.latest_build?.transition === ("delete" as const);
return deleted ? (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task workspace was deleted.
</h3>
<span className="text-content-secondary text-sm">
This task cannot be resumed. Delete this task and create a new one.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
</div>
</div>
</Margins>
) : (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Workspace is not running
</h3>
<span className="text-content-secondary text-sm">
Apps and previous statuses are not available
</span>
{workspace.outdated && (
return (
<>
<TaskStateMessage
title="Task paused"
description={
isTimeout
? "Your task timed out. Resume it to continue."
: "Resume the task to continue."
}
icon={<PauseIcon className="size-4" />}
detail={
workspace.outdated && (
<div
data-testid="workspace-outdated-tooltip"
className="flex items-center gap-1.5 mt-1 text-content-secondary text-sm"
>
<WorkspaceOutdatedTooltip workspace={workspace}>
You can update your task workspace to a newer version
A newer template version is available
</WorkspaceOutdatedTooltip>
</div>
)}
<div className="flex flex-row mt-4 gap-4">
)
}
actions={
<div className="flex flex-row gap-4">
<Button
size="sm"
data-testid="task-start-workspace"
disabled={isWaitingForStart}
onClick={() => {
mutateStartWorkspace.mutate({
buildParameters,
});
}}
onClick={() => resumeMutation.mutate()}
>
<Spinner loading={isWaitingForStart} />
Start workspace
Resume
</Button>
<Button size="sm" onClick={onEditPrompt} variant="outline">
Edit Prompt
Edit prompt
</Button>
</div>
</div>
</div>
}
/>
<WorkspaceErrorDialog
open={apiError !== undefined}
error={apiError}
onClose={mutateStartWorkspace.reset}
onClose={resumeMutation.reset}
showDetail={true}
workspaceOwner={workspace.owner_name}
workspaceName={workspace.name}
templateVersionId={workspace.latest_build.template_version_id}
isDeleting={false}
/>
</Margins>
</>
);
};
@@ -405,12 +500,21 @@ const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({
};
type TaskStartingAgentProps = {
task: Task;
agent: WorkspaceAgent;
};
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ task, agent }) => {
const logs = useAgentLogs({ agentId: agent.id });
const listRef = useRef<FixedSizeList>(null);
const queryClient = useQueryClient();
const pauseMutation = useMutation({
...pauseTask(task, queryClient),
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to pause task."));
},
});
const pauseDisabled = isPauseDisabled(task.status);
useLayoutEffect(() => {
if (listRef.current) {
@@ -422,13 +526,26 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
<section className="p-16 overflow-y-auto">
<div className="flex justify-center items-center w-full">
<div className="flex flex-col gap-8 items-center w-full">
<header className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-xl">
Running startup scripts
</h3>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
<header className="flex flex-col items-center text-center gap-3">
<div>
<h3 className="m-0 font-medium text-content-primary text-xl">
Running startup scripts
</h3>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={pauseDisabled || pauseMutation.isPending}
onClick={() => pauseMutation.mutate()}
>
<Spinner loading={pauseMutation.isPending}>
<PauseIcon className="size-4" />
</Spinner>
Pause
</Button>
</header>
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
+15 -42
View File
@@ -1,6 +1,6 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type { Task, TaskStatus as TaskStatusType } from "api/typesGenerated";
import { pauseTask, resumeTask } from "api/queries/tasks";
import type { Task } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
@@ -28,13 +28,18 @@ import {
} from "components/TableLoader/TableLoader";
import { useClickableTableRow } from "hooks";
import { EllipsisVertical, RotateCcwIcon, TrashIcon } from "lucide-react";
import { TaskActionButton } from "modules/tasks/TaskActionButton";
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
import {
canPauseTask,
canResumeTask,
isPauseDisabled,
} from "modules/tasks/taskActions";
import { type FC, type ReactNode, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { relativeTime } from "utils/time";
import { TaskActionButton } from "./TaskActionButton";
type TasksTableProps = {
tasks: readonly Task[] | undefined;
@@ -173,16 +178,6 @@ const TasksEmpty: FC = () => {
);
};
const pauseStatuses: TaskStatusType[] = [
"active",
"initializing",
"pending",
"error",
"unknown",
];
const pauseDisabledStatuses: TaskStatusType[] = ["pending", "initializing"];
const resumeStatuses: TaskStatusType[] = ["paused", "error", "unknown"];
type TaskRowProps = {
task: Task;
checked: boolean;
@@ -199,42 +194,20 @@ const TaskRow: FC<TaskRowProps> = ({
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const templateDisplayName = task.template_display_name ?? task.template_name;
const navigate = useNavigate();
const showPause = canPauseTask(task.status) && task.workspace_id;
const pauseDisabled = isPauseDisabled(task.status);
const showResume = canResumeTask(task.status) && task.workspace_id;
const queryClient = useQueryClient();
const showPause = pauseStatuses.includes(task.status);
const pauseDisabled = pauseDisabledStatuses.includes(task.status);
const showResume = resumeStatuses.includes(task.status);
const pauseMutation = useMutation({
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.stopWorkspace(task.workspace_id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
...pauseTask(task, queryClient),
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to pause task."));
},
});
const resumeMutation = useMutation({
mutationFn: async () => {
if (!task.workspace_id) {
throw new Error("Task has no workspace");
}
return API.startWorkspace(
task.workspace_id,
task.template_version_id,
undefined,
undefined,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
...resumeTask(task, queryClient),
onError: (error: unknown) => {
displayError(getErrorMessage(error, "Failed to resume task."));
},
@@ -21,7 +21,7 @@ export const TemplateScheduleAutostart: FC<TemplateScheduleAutostartProps> = ({
onChange,
}) => {
return (
<Stack width="100%" alignItems="start" spacing={1}>
<Stack alignItems="start" spacing={1}>
<Stack
direction="row"
spacing={0}
@@ -1,4 +1,3 @@
import Link from "@mui/material/Link";
import type { WorkspaceAgent } from "api/typesGenerated";
import {
Alert,
@@ -6,6 +5,8 @@ import {
type AlertProps,
} from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { Link } from "components/Link/Link";
import { RefreshCcwIcon } from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
@@ -205,6 +206,7 @@ const RefreshSessionButton: FC = () => {
window.location.reload();
}}
>
<RefreshCcwIcon />
{isRefreshing ? "Refreshing session..." : "Refresh session"}
</Button>
);
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react";
import visuallyHidden from "@mui/utils/visuallyHidden";
import { richParameters } from "api/queries/templates";
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
import type {
@@ -69,7 +68,7 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
className="min-w-fit"
>
<ChevronDownIcon />
<span css={{ ...visuallyHidden }}>{label}</span>
<span className="sr-only">{label}</span>
</TopbarButton>
</PopoverTrigger>
<PopoverContent
@@ -1,6 +1,5 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link, { type LinkProps } from "@mui/material/Link";
import { visuallyHidden } from "@mui/utils";
import { getErrorMessage } from "api/errors";
import {
updateDeadline,
@@ -218,7 +217,7 @@ const AutostopDisplay: FC<AutostopDisplayProps> = ({
}}
>
<MinusIcon />
<span style={visuallyHidden}>Subtract 1 hour from deadline</span>
<span className="sr-only">Subtract 1 hour from deadline</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
@@ -236,7 +235,7 @@ const AutostopDisplay: FC<AutostopDisplayProps> = ({
}}
>
<PlusIcon />
<span style={visuallyHidden}>Add 1 hour to deadline</span>
<span className="sr-only">Add 1 hour to deadline</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Add 1 hour to deadline</TooltipContent>
@@ -96,11 +96,18 @@ const WorkspaceParametersPageExperimental: FC = () => {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
// Skip stale responses. If we've already sent a newer request,
// this response contains outdated parameter values that would
// overwrite the user's more recent input.
if (response.id < wsResponseId.current) {
return;
}
setLatestResponse(response);
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
}
});
useEffect(() => {
@@ -197,7 +204,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
if (
latestBuildParametersLoading ||
!latestResponse ||
(!latestResponse && !wsError) ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
return <Loader />;
@@ -244,7 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}
diagnostics={latestResponse?.diagnostics ?? []}
isSubmitting={updateParameters.isPending}
onSubmit={handleSubmit}
onCancel={() =>
@@ -9,6 +9,7 @@ import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { useDebouncedFunction } from "hooks/debounce";
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
import {
DynamicParameter,
@@ -16,6 +17,7 @@ import {
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
@@ -67,6 +69,23 @@ export const WorkspaceParametersPageViewExperimental: FC<
workspace.template_require_active_version &&
!canChangeVersions;
// Debounce websocket sends to avoid stale responses overwriting
// the form while the user is still typing.
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
(parameter: PreviewParameter, value: string) => {
const formInputs: Record<string, string> = {};
const formParameters = form.values.rich_parameter_values ?? [];
for (const param of formParameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
},
500,
);
const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
@@ -79,29 +98,27 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendDynamicParamsRequest(parameter, value);
};
const sendDynamicParamsRequest = (
parameter: PreviewParameter,
value: string,
) => {
const formInputs: Record<string, string> = {};
const parameters = form.values.rich_parameter_values ?? [];
for (const param of parameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
// True when the form holds values the backend hasn't evaluated
// yet (debounce pending or WS round-trip in flight).
const hasUnsyncedParameters = (form.values.rich_parameter_values ?? []).some(
(formParam) => {
const responseParam = parameters.find((p) => p.name === formParam.name);
if (!responseParam) {
return true;
}
const responseValue = responseParam.value.valid
? responseParam.value.value
: "";
return formParam.value !== responseValue;
},
);
const hasIncompatibleParameters = parameters.some((parameter) => {
if (!parameter.mutable && parameter.diagnostics.length > 0) {
return true;
@@ -155,12 +172,12 @@ export const WorkspaceParametersPageViewExperimental: FC<
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={`text-xs flex flex-col rounded-md border px-4 pb-3 border-solid
${
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning"
}`}
className={cn(
"text-xs flex flex-col rounded-md border px-4 pb-3 border-solid",
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning",
)}
>
<div className="flex items-center m-0">
<p className="font-medium">{diagnostic.summary}</p>
@@ -248,6 +265,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
disabled={
isSubmitting ||
disabled ||
hasUnsyncedParameters ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||

Some files were not shown because too many files have changed in this diff Show More