Recommendation accepted
+A new version of this job will now be deployed.
+diff --git a/.github/workflows/test-ui.yml b/.github/workflows/test-ui.yml
index 19fc34aaf4c..4c0d1e4baa5 100644
--- a/.github/workflows/test-ui.yml
+++ b/.github/workflows/test-ui.yml
@@ -31,10 +31,8 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup-js
- - name: lint:js
- run: pnpm lint:js
- - name: lint:hbs
- run: pnpm lint:hbs
+ - name: Lint
+ run: pnpm lint
- id: nonce
name: nonce
run: echo "nonce=${{ github.run_id }}-$(date +%s)" >> "$GITHUB_OUTPUT"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 39b9a11b2a1..1b638064dc8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,8 +48,8 @@ importers:
specifier: ^4.0.1
version: 4.0.1
'@ember/test-helpers':
- specifier: ^5.4.1
- version: 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ specifier: ^5.4.2
+ version: 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@ember/test-waiters':
specifier: ^4.1.1
version: 4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7)
@@ -76,19 +76,19 @@ importers:
version: 2.4.0
'@hashicorp/design-system-components':
specifier: 4.13.0
- version: 4.13.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
+ version: 4.13.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
'@nullvoxpopuli/ember-composable-helpers':
- specifier: ^5.3.1
- version: 5.3.1(@babel/core@7.29.0)
+ specifier: ^5.3.2
+ version: 5.3.2(@babel/core@7.29.0)
'@nullvoxpopuli/legacy-prototype-extensions':
specifier: ^0.1.0
version: 0.1.0(@babel/core@7.29.0)
'@percy/cli':
- specifier: ^1.31.12
- version: 1.31.12(typescript@6.0.3)
+ specifier: ^1.31.13
+ version: 1.31.13(typescript@6.0.3)
'@percy/ember':
- specifier: ^5.0.0
- version: 5.0.0(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2)
+ specifier: ^5.0.1
+ version: 5.0.1(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2)
'@tsconfig/ember':
specifier: ^3.0.12
version: 3.0.12
@@ -102,8 +102,8 @@ importers:
specifier: ^2.3.5
version: 2.3.5
axe-core:
- specifier: ^4.11.3
- version: 4.11.3
+ specifier: ^4.11.4
+ version: 4.11.4
base64-js:
specifier: ^1.5.1
version: 1.5.1
@@ -153,20 +153,20 @@ importers:
specifier: ^3.0.1
version: 3.0.1(d3-selection@3.0.0)
dompurify:
- specifier: ^3.4.1
- version: 3.4.1
+ specifier: ^3.4.2
+ version: 3.4.2
duration-js:
specifier: ^4.0.0
version: 4.0.0
ember-a11y-testing:
specifier: ^8.0.0
- version: 8.0.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/test-waiters@4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7))(axe-core@4.11.3)(qunit@2.25.0)
+ version: 8.0.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/test-waiters@4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7))(axe-core@4.11.4)(qunit@2.25.0)
ember-auto-import:
specifier: ^2.13.1
version: 2.13.1(@glint/template@1.7.7)(webpack@5.106.2)
ember-basic-dropdown:
specifier: ^8.11.0
- version: 8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
+ version: 8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-can:
specifier: ^8.0.0
version: 8.0.0(@babel/core@7.29.0)(@ember/string@4.0.1)(ember-inflector@6.0.0(@babel/core@7.29.0))(ember-resolver@13.2.0)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
@@ -185,9 +185,6 @@ importers:
ember-cli-clean-css:
specifier: ^3.0.0
version: 3.0.0
- ember-cli-clipboard:
- specifier: ^1.3.0
- version: 1.3.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(webpack@5.106.2)
ember-cli-dependency-checker:
specifier: ^3.3.3
version: 3.3.3(ember-cli@6.12.0(@babel/core@7.29.0)(@types/node@24.0.14)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8))
@@ -205,13 +202,13 @@ importers:
version: 2.1.0
ember-cli-mirage:
specifier: ^3.0.4
- version: 3.0.4(@ember-data/model@4.12.8)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.106.2))(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(miragejs@0.1.48)(webpack@5.106.2)
+ version: 3.0.4(@ember-data/model@4.12.8)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.106.2))(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(miragejs@0.1.48)(webpack@5.106.2)
ember-cli-moment-shim:
specifier: ^3.8.0
version: 3.8.0(@babel/core@7.29.0)(@glint/template@1.7.7)
ember-cli-page-object:
specifier: ^2.3.2
- version: 2.3.2(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))
+ version: 2.3.2(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))
ember-cli-sass:
specifier: ^11.0.1
version: 11.0.1
@@ -244,7 +241,7 @@ importers:
version: 6.1.1
ember-exam:
specifier: 10.1.0
- version: 10.1.0(@glint/template@1.7.7)(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.25.0)(webpack@5.106.2)
+ version: 10.1.0(@glint/template@1.7.7)(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.25.0)(webpack@5.106.2)
ember-inflector:
specifier: ^6.0.0
version: 6.0.0(@babel/core@7.29.0)
@@ -268,10 +265,10 @@ importers:
version: 9.0.3
ember-power-select:
specifier: ^8.12.1
- version: 8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
+ version: 8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-qunit:
specifier: ^9.0.4
- version: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
+ version: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
ember-render-helpers:
specifier: ^2.0.0
version: 2.0.0(@babel/core@7.29.0)
@@ -313,10 +310,10 @@ importers:
version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-ember:
specifier: ^12.7.5
- version: 12.7.5(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ version: 12.7.5(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint-plugin-n:
- specifier: ^17.24.0
- version: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ specifier: ^18.0.0
+ version: 18.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint-plugin-qunit:
specifier: ^8.2.6
version: 8.2.6(eslint@9.39.4(jiti@2.6.1))
@@ -333,8 +330,8 @@ importers:
specifier: ^13.0.6
version: 13.0.6
globals:
- specifier: ^17.5.0
- version: 17.5.0
+ specifier: ^17.6.0
+ version: 17.6.0
http-proxy:
specifier: ^1.18.1
version: 1.18.1
@@ -384,11 +381,11 @@ importers:
specifier: ^1.99.0
version: 1.99.0
stylelint:
- specifier: ^17.9.0
- version: 17.9.0(typescript@6.0.3)
+ specifier: ^17.10.0
+ version: 17.10.0(typescript@6.0.3)
stylelint-config-standard-scss:
specifier: ^17.0.0
- version: 17.0.0(postcss@8.5.10)(stylelint@17.9.0(typescript@6.0.3))
+ version: 17.0.0(postcss@8.5.14)(stylelint@17.10.0(typescript@6.0.3))
testem:
specifier: ^3.20.0
version: 3.20.0(@babel/core@7.29.0)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)
@@ -411,8 +408,8 @@ importers:
specifier: ^6.0.3
version: 6.0.3
typescript-eslint:
- specifier: ^8.59.0
- version: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ specifier: ^8.59.2
+ version: 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
webpack:
specifier: ^5.106.2
version: 5.106.2
@@ -1244,8 +1241,8 @@ packages:
'@ember/string@4.0.1':
resolution: {integrity: sha512-VWeng8BSWrIsdPfffOQt/bKwNKJL7+37gPFh/6iZZ9bke+S83kKqkS30poo4bTGfRcMnvAE0ie7txom+iDu81Q==}
- '@ember/test-helpers@5.4.1':
- resolution: {integrity: sha512-BUdT91ra+QibEWAUwtZmvTGFoDHJCxDU+fkQENA8Zs0FR3pZiICxxP/fgdlNExCjjdm1letut7ENoueBuDdixQ==}
+ '@ember/test-helpers@5.4.2':
+ resolution: {integrity: sha512-ZT++x8DbixXgvxO00J064rzNcsn9WycMPisNvee6dg9u6G4Z1yx0Hc8HqUFBJP7NyxVKZCokHlRWRQuz9S6wvQ==}
'@ember/test-waiters@3.1.0':
resolution: {integrity: sha512-bb9h95ktG2wKY9+ja1sdsFBdOms2lB19VWs8wmNpzgHv1NCetonBoV5jHBV4DHt0uS1tg9z66cZqhUVlYs96KQ==}
@@ -1596,8 +1593,8 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
- '@nullvoxpopuli/ember-composable-helpers@5.3.1':
- resolution: {integrity: sha512-9K8jG/kb5TsgwoNWMEQi8KbvHKIi7lswfoyX+7qoMgPEW5YCNH/165VH99pxffbweFYAkGvwz23b1RQ7IDqNtA==}
+ '@nullvoxpopuli/ember-composable-helpers@5.3.2':
+ resolution: {integrity: sha512-ABqoeTFyh57tiTQ6X4/9agduYeY82lAGQ+Vva7RK3w9fPXeMRxtxk6AFoOF2PWzTsYrZrGZ7ZmZtVvfhCALNYg==}
'@nullvoxpopuli/legacy-prototype-extensions@0.1.0':
resolution: {integrity: sha512-Z6xhSXERPJ5STWcXRxAmZAs+NDADx2mjmr4+RQgkyMQ5OV6LIIQLRjYmNTDnlI8kYXyZthWNjNPpMK3/9ol7+Q==}
@@ -1690,81 +1687,81 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
- '@percy/cli-app@1.31.12':
- resolution: {integrity: sha512-oJVEDw7MPdn3k3OsYf0QWdIJinF2Rr7OuUccX0hZemxEQbVQYKEtlB7dA6ptrWHkhaRdGZwZ8cgpjM/eEBNkOQ==}
+ '@percy/cli-app@1.31.13':
+ resolution: {integrity: sha512-onVbiQkG2FaAnP9rw1OtCR/8OkD0lo/S8Cz56Qe3sln/kJtfEC1VAsTq9x/e3caeNFPh7aObF78o8BVvIexcNQ==}
engines: {node: '>=14'}
- '@percy/cli-build@1.31.12':
- resolution: {integrity: sha512-vQH5+TddKKfFqxxrsShqnmFFxEkrrfmM/mXR2k0OtJ5iDbMOyS8MpEPJQH1/ANKM0PWLXmVP8paMyamZi48MpA==}
+ '@percy/cli-build@1.31.13':
+ resolution: {integrity: sha512-tsAr+pzGe5LqRNJiB4CBo7YDBXqH/uXm/89iovwor8wkqDgHR3DfC8u5+GgNNbfazEG2nDx4l3QmmOvN+APARw==}
engines: {node: '>=14'}
- '@percy/cli-command@1.31.12':
- resolution: {integrity: sha512-J6gEemXcoy0rzNb0s+u1taP7mNQiZlm1AmbsMpL6bKSRQfS0KURul4gDy71jYUhDFJceNuk7s0IVpN7P3vbEcA==}
+ '@percy/cli-command@1.31.13':
+ resolution: {integrity: sha512-pGcs0yPocP3bXqpkzS1UThoQ+8ayVDXLBtpSU+/7N0Rnj6R0eUD1xWggobPT7pY1EOrWqKMEzb67scEvrSIq7Q==}
engines: {node: '>=14'}
hasBin: true
- '@percy/cli-config@1.31.12':
- resolution: {integrity: sha512-MPI6cfgO+t3vr2yFkNTsYzi4fXue6fhT9Cas8tivszOTKUp1UNR2I428LIDXYj5/ktelc+dVhsRZjzK/J9MQDA==}
+ '@percy/cli-config@1.31.13':
+ resolution: {integrity: sha512-t0K7SYCeWp+UrRMqrTmzZiidBTxWkAvNqCyrExa5yQy3GJXyYqhygqymfP8Y5+8e2SbIGBoXZQW0fIzoSc83ww==}
engines: {node: '>=14'}
- '@percy/cli-doctor@1.31.12':
- resolution: {integrity: sha512-U9pGYNP9RUdva7RjIcMG4OEbVWplwCnCMoXUSA4a3zAGGEZEr4uT7WacCBHjHmhHATqpW/U4OcKENFTK8s6Raw==}
+ '@percy/cli-doctor@1.31.13':
+ resolution: {integrity: sha512-sX+VNyyTZ3viFW75NPFYMTjD+CHraD43VJ7K5Mpt4hq2IR8LS1J0C37kAiMJnTwFHeeXpaaIsCxVrKiS+jHvOQ==}
engines: {node: '>=14'}
- '@percy/cli-exec@1.31.12':
- resolution: {integrity: sha512-e2BIbEJsDiwjK8arHxwjy+DQJPLolqqz/wapeh+1Mwk3HnRa4a0Dg11MeO4kFjC/G4euEJslLwNoiFZH2rqpQQ==}
+ '@percy/cli-exec@1.31.13':
+ resolution: {integrity: sha512-93w98Mejp2V7vuXgvEdwbJ4odHa3KHHJ6yrWkvydhKe0FmjoEnH20fYpZ1661Rh6YPTm61a/VvspoWEMD7E+5g==}
engines: {node: '>=14'}
- '@percy/cli-snapshot@1.31.12':
- resolution: {integrity: sha512-4+3NXUGxnxVQ4HhjhcnM+4tV0vKOIkYKTAZMTvw6tVG+DmAq4PpssRsAp0h2QdwtdFfadSTSXJ6A61flBrY8Lw==}
+ '@percy/cli-snapshot@1.31.13':
+ resolution: {integrity: sha512-k9gG8ImwbcLZPeLtanBETdT8qWvAIKPcZXJ0P8OnoL+onyqEbT/BuYNTY7tlezWDg5utJFwT4LczOqAnHdS0AA==}
engines: {node: '>=14'}
- '@percy/cli-upload@1.31.12':
- resolution: {integrity: sha512-vNbD/Vbiz1CVQI3TQqGA0bDZCPvhaIV3atnrpU5ft7rJMTrBcuS72oM7IEoWM1nKmzET2314nhO45NNDXPOlPA==}
+ '@percy/cli-upload@1.31.13':
+ resolution: {integrity: sha512-+/HkIGWqHH/EJmbxATXO0N8XnwtXrhnePDKFZtWx7JrpaZXxRXI96NGL7/mjKQ2sAQKPSGvbbUAB++UoFZSnYg==}
engines: {node: '>=14'}
- '@percy/cli@1.31.12':
- resolution: {integrity: sha512-sLeAdd6GPZdPtUlop0WLzXT6sEmouZkAIZZB8QwdML8s3QekdHB/L9NxJc7wLPqlrfSXZTfRWyy9NIPupElsRQ==}
+ '@percy/cli@1.31.13':
+ resolution: {integrity: sha512-QpNpqt4rpmQMOixvWzds2bMKaNT64cUNsELokM0jAP8wSbKZLAsvWddM24fZTqsKXpm4peBpzO73S3bwV0Ep5w==}
engines: {node: '>=14'}
hasBin: true
- '@percy/client@1.31.12':
- resolution: {integrity: sha512-HPyE5uDJ6+gACMwb33Kyg7CnMf/DiUd5/4lV5eT//UUq3HAo8zzmevXaGyrJ2XzLFYttZv1G+bFH0PneQsvYMw==}
+ '@percy/client@1.31.13':
+ resolution: {integrity: sha512-eFoUacW2GolCm3rL8oS1QyWD6b4UWds9HrMbtp6OJWUr6sQ8bcOemjr3M1YpkDsV2wGC0ikLISQxU8XrTxKDhA==}
engines: {node: '>=14'}
- '@percy/config@1.31.12':
- resolution: {integrity: sha512-rt7N2b7wjwSDdy43vFiYBKEdTg+enKTddoAlsL4AF+g0k1lVBW/r6lVLS/WTUs8X3JsXCTeV3LbQ6OXOclkq0g==}
+ '@percy/config@1.31.13':
+ resolution: {integrity: sha512-gxd292C8+v/psxYJNfId2gwhlIGjvRGGOTCw9gBTb/eBT8cZirVLHq+d/j2nqPxCDC7JzZqChWH4TkVkTr/Xwg==}
engines: {node: '>=14'}
- '@percy/core@1.31.12':
- resolution: {integrity: sha512-b0xZfcT/tA3T+UNDgE3RC4p1zhKEhlz6CUTZC7Up49gHRafO62rx94SK4EVzehqVTZhcf6ZYVj5QHhD1B82Jkw==}
+ '@percy/core@1.31.13':
+ resolution: {integrity: sha512-SFVw/skIpcFecn8V3emAhmqqOgsX3K9toamMhhVay9FtAoy/NmsV+WuvP3IsAvujepY8ApZe2Zi5CuzZbmDy0w==}
engines: {node: '>=14'}
- '@percy/dom@1.31.12':
- resolution: {integrity: sha512-8qrtNbmKgXBxiaHWNu6JJUM5jFjLA9IxCy7Tc1vMcW5UFbKbvqHYvMtufsRAZT7O1TK/oxbrTmtEyhPbx4BjKQ==}
+ '@percy/dom@1.31.13':
+ resolution: {integrity: sha512-5v/ybb2f4aQLUdsbQzTuHzQNAP5zcqn/aO18k2AjvnNdxNmtLC3XwMaSMznImQAXCGsSqsmrJTGtrYP3pbTPYw==}
- '@percy/ember@5.0.0':
- resolution: {integrity: sha512-Nod2k3zMUQKnAK29dO9Xp4tdIMUiLrEffntLXjHtQroEq2wqCTeS6gfLLEjV++TgPE0q2ehex/fd2QXzgKFKEA==}
+ '@percy/ember@5.0.1':
+ resolution: {integrity: sha512-f/lnUq2MmCQOOJPIX2aypxVNbvEcSEdBaAETqa9yckzKyEedTVgYhDCD6WtgZtMu+azWLXHmqyMbxti4KN6OLQ==}
engines: {node: '>= 16'}
- '@percy/env@1.31.12':
- resolution: {integrity: sha512-m5yv7J2TxwrloYJ2etgIbJKOHTqhqRRAbJFC1MqQPtVMDmioXi1zPeuYlT3R65a0A57P/z7H5JII6ng+Mfz0Nw==}
+ '@percy/env@1.31.13':
+ resolution: {integrity: sha512-oYEPbZwEtRFfuso+V1lnAZkZZC1gSk+H+//AdLU5De1+h2jxYW14qc1bpW707ZSina5DY5C2tcbsHNV4lcYI/A==}
engines: {node: '>=14'}
- '@percy/logger@1.31.12':
- resolution: {integrity: sha512-8pRXbhBLEKpcQMBAwuolf0YHozKEkyWQV9KcsEPbMvMOJubbaw3Ziv/Vtb+DRJT3LCreWwdC6ShP7hCAzh5HKg==}
+ '@percy/logger@1.31.13':
+ resolution: {integrity: sha512-ym8Viw3mAGv00afk4NwaODBXLcS7/pPtpa/RSPS0knToPbCvN4/aE9PoZ99M8UbKY02ZFqGAbBNL+uPgS1nKbA==}
engines: {node: '>=14'}
- '@percy/monitoring@1.31.12':
- resolution: {integrity: sha512-oVsa+0g203DcfFYObxwBxLrTr1lmerjhHVwmYNEck4Hw/7bxLXRbLekRoWRZI0sBQF296wVDIJ1RuLzf5vz65Q==}
+ '@percy/monitoring@1.31.13':
+ resolution: {integrity: sha512-TNHrEA8juyvSM2Egnub9gEDi3p1yYXwGfa4F+H3Ub+zNWFZ/11vxFRQuryzN9aQha/hvcBnXFQJk8JR73I+T4g==}
engines: {node: '>=14'}
- '@percy/sdk-utils@1.31.12':
- resolution: {integrity: sha512-RDAyzT/YhnBtecC2zKI9Nmc3SU4uydGioTWP9M2soHs3aLJO0bCXNxBnXUa9lZoB8/c2XUzNIOwmn4t2zBugRA==}
+ '@percy/sdk-utils@1.31.13':
+ resolution: {integrity: sha512-0JW+ngBKLjkhbsI6ZD8wnWDV1U/S66X4vBrJqHLSi8t8BygQrulAwKLrtSV27DHCxz3a+zptTMBQufwFbal5gg==}
engines: {node: '>=14'}
- '@percy/webdriver-utils@1.31.12':
- resolution: {integrity: sha512-uWQsBKrafE98gFJFvZ2zZ9lzGPBuzkgYdprLfX/LleS/EZ3/HuXPvv9c6LqrWA5Dfn171pHxwQBLRbzoUIFDUw==}
+ '@percy/webdriver-utils@1.31.13':
+ resolution: {integrity: sha512-wiCPRI42Vg6+qL/wiDMPkpauBHbpPJoiaMZB0gDyzrjHJx8cPwG85+oUOgvtMDNdvy55jAtcSdqzA5kXcvMsOw==}
engines: {node: '>=14'}
'@pkgjs/parseargs@0.11.0':
@@ -1889,63 +1886,63 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
- '@typescript-eslint/eslint-plugin@8.59.0':
- resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
+ '@typescript-eslint/eslint-plugin@8.59.2':
+ resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.59.0
+ '@typescript-eslint/parser': ^8.59.2
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.59.0':
- resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==}
+ '@typescript-eslint/parser@8.59.2':
+ resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.59.0':
- resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==}
+ '@typescript-eslint/project-service@8.59.2':
+ resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.59.0':
- resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==}
+ '@typescript-eslint/scope-manager@8.59.2':
+ resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.59.0':
- resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==}
+ '@typescript-eslint/tsconfig-utils@8.59.2':
+ resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.59.0':
- resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==}
+ '@typescript-eslint/type-utils@8.59.2':
+ resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.59.0':
- resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==}
+ '@typescript-eslint/types@8.59.2':
+ resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.59.0':
- resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==}
+ '@typescript-eslint/typescript-estree@8.59.2':
+ resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.59.0':
- resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==}
+ '@typescript-eslint/utils@8.59.2':
+ resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.59.0':
- resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==}
+ '@typescript-eslint/visitor-keys@8.59.2':
+ resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@volar/kit@2.4.28':
@@ -2219,8 +2216,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
- axe-core@4.11.3:
- resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==}
+ axe-core@4.11.4:
+ resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
engines: {node: '>=4'}
babel-import-util@1.4.1:
@@ -2681,9 +2678,6 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
- clipboard@2.0.11:
- resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
-
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -3255,9 +3249,6 @@ packages:
delaunator@5.0.1:
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
- delegate@3.2.0:
- resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
-
depd@1.1.2:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'}
@@ -3294,8 +3285,8 @@ packages:
dom-element-descriptors@0.5.1:
resolution: {integrity: sha512-DLayMRQ+yJaziF4JJX1FMjwjdr7wdTr1y9XvZ+NfHELfOMcYDnCHneAYXAS4FT1gLILh4V0juMZohhH1N5FsoQ==}
- dompurify@3.4.1:
- resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==}
+ dompurify@3.4.2:
+ resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -3349,10 +3340,6 @@ packages:
qunit:
optional: true
- ember-arg-types@1.1.0:
- resolution: {integrity: sha512-hWpUz0eiNkWzi3FgHW5QU6LyCDyUlTWwuIROHluEKZoa9m6LJVXbb/EVFgIG3FkAib6a5Ie00WvkXEZFXxh3+A==}
- engines: {node: 14.* || >= 16}
-
ember-assign-helper@0.5.1:
resolution: {integrity: sha512-dXHbwlBTJWVjG7k4dhVrT3Gh4nQt6rC2LjyltuPztIhQ+YcPYHMqAPJRJYLGZu16aPSJbaGF8K+u51i7CLzqlQ==}
@@ -3414,12 +3401,6 @@ packages:
resolution: {integrity: sha512-BbveJCyRvzzkaTH1llLW+MpHe/yzA5zpHOpMIg2vp/3JD9mban9zUm7lphaB0TSpPuMuby9rAhTI8pgXq0ifIA==}
engines: {node: 16.* || >= 18}
- ember-cli-clipboard@1.3.0:
- resolution: {integrity: sha512-GTX+zzfxhfGyDgk00PcFIEAT063QrpeB3F2UYrKQYZmJiIFFlyriSRw9LrVcXjhEROCVjrHoOVrhcqCATEDAKw==}
- engines: {node: 14.* || >= 16}
- peerDependencies:
- '@ember/test-helpers': '>= 2.9.3'
-
ember-cli-dependency-checker@3.3.3:
resolution: {integrity: sha512-mvp+HrE0M5Zhc2oW8cqs8wdhtqq0CfQXAYzaIstOzHJJn/U01NZEGu3hz7J7zl/+jxZkyygylzcS57QqmPXMuQ==}
engines: {node: '>= 6'}
@@ -3951,11 +3932,18 @@ packages:
peerDependencies:
eslint: '>=8'
- eslint-plugin-n@17.24.0:
- resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint-plugin-n@18.0.0:
+ resolution: {integrity: sha512-a1S1CiOrfOyZbH69f/n5JQoKToGvALRk7Vt2yjCNFhipy7RDhjnLNLzigOb7YxDuHpUp/IOQsz10CKAjoU/0aA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
peerDependencies:
- eslint: '>=8.23.0'
+ eslint: '>=8.57.1'
+ ts-declaration-location: ^1.0.6
+ typescript: ^5.0.0
+ peerDependenciesMeta:
+ ts-declaration-location:
+ optional: true
+ typescript:
+ optional: true
eslint-plugin-qunit@8.2.6:
resolution: {integrity: sha512-S1jC/DIW9J8VtNX4uG1vlf5FZVrfQFlcuiYmvTHR2IICUhubHqpWA5o+qS1tujh+81Gs39omKV2D4OXfbSJE5g==}
@@ -4482,8 +4470,8 @@ packages:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
- globals@17.5.0:
- resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==}
+ globals@17.6.0:
+ resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
engines: {node: '>=18'}
globalthis@1.0.4:
@@ -4503,9 +4491,6 @@ packages:
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
- good-listener@1.2.2:
- resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
-
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -5162,10 +5147,6 @@ packages:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
- loose-envify@1.4.0:
- resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
- hasBin: true
-
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@@ -5772,8 +5753,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
- postcss@8.5.10:
- resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
+ postcss@8.5.14:
+ resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -5830,9 +5811,6 @@ packages:
resolution: {integrity: sha512-KYcnXctWUWyVD3W3Ye0ZDuA1N8Szrh85cVCxpG6xYrOk/0CttRtYCmU30nWsUch0NuExQQ63QXvzRE6FLimZmg==}
engines: {node: 10.* || >= 12.*}
- prop-types@15.8.1:
- resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
-
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
@@ -5896,9 +5874,6 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
- react-is@16.13.1:
- resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
-
readable-stream@1.0.34:
resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==}
@@ -6161,9 +6136,6 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
- select@1.1.2:
- resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
-
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -6513,8 +6485,8 @@ packages:
peerDependencies:
stylelint: ^16.8.2 || ^17.0.0
- stylelint@17.9.0:
- resolution: {integrity: sha512-xO0jeY6z1/urFL5L/BZLmB1yYlbRiRMQnYH6ArZIDWJ+SZXGssOY7XoYb1JIv/L220+EBnwwJXJS4Mt/F96SvA==}
+ stylelint@17.10.0:
+ resolution: {integrity: sha512-cI7I6HHEYOHHVNVci+s92WlA3QfmNhjwFdgCgYV3TLEysilOjk+B3EFxMED1xY9GYB0Kre3OD+mSLj19VLTIvA==}
engines: {node: '>=20.19.0'}
hasBin: true
@@ -6639,9 +6611,6 @@ packages:
resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==}
engines: {node: '>=12'}
- tiny-emitter@2.1.0:
- resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
-
tiny-glob@0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
@@ -6701,11 +6670,6 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
- ts-declaration-location@1.0.7:
- resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==}
- peerDependencies:
- typescript: '>=4.0.0'
-
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
@@ -6754,8 +6718,8 @@ packages:
typescript-auto-import-cache@0.3.6:
resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==}
- typescript-eslint@8.59.0:
- resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==}
+ typescript-eslint@8.59.2:
+ resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -8263,7 +8227,7 @@ snapshots:
'@ember/string@4.0.1': {}
- '@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)':
+ '@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)':
dependencies:
'@ember/test-waiters': 4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
@@ -8523,7 +8487,7 @@ snapshots:
'@handlebars/parser@2.2.2': {}
- '@hashicorp/design-system-components@4.13.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))':
+ '@hashicorp/design-system-components@4.13.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))':
dependencies:
'@ember/render-modifiers': 2.1.0(@babel/core@7.29.0)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
'@ember/string': 3.1.1
@@ -8540,7 +8504,7 @@ snapshots:
ember-focus-trap: 1.1.1(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-get-config: 2.1.1(@babel/core@7.29.0)(@glint/template@1.7.7)
ember-modifier: 4.3.0(@babel/core@7.29.0)
- ember-power-select: 8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
+ ember-power-select: 8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-source: 6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)
ember-stargate: 0.4.3(@babel/core@7.29.0)(@ember/test-waiters@3.1.0)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-style-modifier: 4.5.1(@babel/core@7.29.0)(@ember/string@3.1.1)
@@ -8762,7 +8726,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
- '@nullvoxpopuli/ember-composable-helpers@5.3.1(@babel/core@7.29.0)':
+ '@nullvoxpopuli/ember-composable-helpers@5.3.2(@babel/core@7.29.0)':
dependencies:
'@embroider/addon-shim': 1.10.2
decorator-transforms: 2.3.1(@babel/core@7.29.0)
@@ -8839,54 +8803,48 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.1
optional: true
- '@percy/cli-app@1.31.12(typescript@6.0.3)':
+ '@percy/cli-app@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
- '@percy/cli-exec': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
+ '@percy/cli-exec': 1.31.13(typescript@6.0.3)
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
- '@percy/cli-build@1.31.12(typescript@6.0.3)':
+ '@percy/cli-build@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
transitivePeerDependencies:
- - bufferutil
- - supports-color
- typescript
- - utf-8-validate
- '@percy/cli-command@1.31.12(typescript@6.0.3)':
+ '@percy/cli-command@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/core': 1.31.12(typescript@6.0.3)
- '@percy/logger': 1.31.12
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/core': 1.31.13(typescript@6.0.3)
+ '@percy/logger': 1.31.13
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
- '@percy/cli-config@1.31.12(typescript@6.0.3)':
+ '@percy/cli-config@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
transitivePeerDependencies:
- - bufferutil
- - supports-color
- typescript
- - utf-8-validate
- '@percy/cli-doctor@1.31.12(typescript@6.0.3)':
+ '@percy/cli-doctor@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
- '@percy/client': 1.31.12(typescript@6.0.3)
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/core': 1.31.12(typescript@6.0.3)
- '@percy/env': 1.31.12
- '@percy/logger': 1.31.12
- '@percy/monitoring': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
+ '@percy/client': 1.31.13(typescript@6.0.3)
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/core': 1.31.13(typescript@6.0.3)
+ '@percy/env': 1.31.13
+ '@percy/logger': 1.31.13
+ '@percy/monitoring': 1.31.13(typescript@6.0.3)
minimatch: 9.0.9
ws: 8.18.3
transitivePeerDependencies:
@@ -8895,10 +8853,10 @@ snapshots:
- typescript
- utf-8-validate
- '@percy/cli-exec@1.31.12(typescript@6.0.3)':
+ '@percy/cli-exec@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
- '@percy/logger': 1.31.12
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
+ '@percy/logger': 1.31.13
cross-spawn: 7.0.6
which: 2.0.2
transitivePeerDependencies:
@@ -8907,73 +8865,67 @@ snapshots:
- typescript
- utf-8-validate
- '@percy/cli-snapshot@1.31.12(typescript@6.0.3)':
+ '@percy/cli-snapshot@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
yaml: 2.8.3
transitivePeerDependencies:
- - bufferutil
- - supports-color
- typescript
- - utf-8-validate
- '@percy/cli-upload@1.31.12(typescript@6.0.3)':
+ '@percy/cli-upload@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
fast-glob: 3.3.3
image-size: 1.2.1
transitivePeerDependencies:
- - bufferutil
- - supports-color
- typescript
- - utf-8-validate
- '@percy/cli@1.31.12(typescript@6.0.3)':
+ '@percy/cli@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/cli-app': 1.31.12(typescript@6.0.3)
- '@percy/cli-build': 1.31.12(typescript@6.0.3)
- '@percy/cli-command': 1.31.12(typescript@6.0.3)
- '@percy/cli-config': 1.31.12(typescript@6.0.3)
- '@percy/cli-doctor': 1.31.12(typescript@6.0.3)
- '@percy/cli-exec': 1.31.12(typescript@6.0.3)
- '@percy/cli-snapshot': 1.31.12(typescript@6.0.3)
- '@percy/cli-upload': 1.31.12(typescript@6.0.3)
- '@percy/client': 1.31.12(typescript@6.0.3)
- '@percy/logger': 1.31.12
+ '@percy/cli-app': 1.31.13(typescript@6.0.3)
+ '@percy/cli-build': 1.31.13(typescript@6.0.3)
+ '@percy/cli-command': 1.31.13(typescript@6.0.3)
+ '@percy/cli-config': 1.31.13(typescript@6.0.3)
+ '@percy/cli-doctor': 1.31.13(typescript@6.0.3)
+ '@percy/cli-exec': 1.31.13(typescript@6.0.3)
+ '@percy/cli-snapshot': 1.31.13(typescript@6.0.3)
+ '@percy/cli-upload': 1.31.13(typescript@6.0.3)
+ '@percy/client': 1.31.13(typescript@6.0.3)
+ '@percy/logger': 1.31.13
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
- '@percy/client@1.31.12(typescript@6.0.3)':
+ '@percy/client@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/env': 1.31.12
- '@percy/logger': 1.31.12
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/env': 1.31.13
+ '@percy/logger': 1.31.13
pac-proxy-agent: 7.2.0
pako: 2.1.0
transitivePeerDependencies:
- supports-color
- typescript
- '@percy/config@1.31.12(typescript@6.0.3)':
+ '@percy/config@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/logger': 1.31.12
+ '@percy/logger': 1.31.13
ajv: 8.18.0
cosmiconfig: 8.3.6(typescript@6.0.3)
yaml: 2.8.3
transitivePeerDependencies:
- typescript
- '@percy/core@1.31.12(typescript@6.0.3)':
+ '@percy/core@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/client': 1.31.12(typescript@6.0.3)
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/dom': 1.31.12
- '@percy/logger': 1.31.12
- '@percy/monitoring': 1.31.12(typescript@6.0.3)
- '@percy/webdriver-utils': 1.31.12(typescript@6.0.3)
+ '@percy/client': 1.31.13(typescript@6.0.3)
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/dom': 1.31.13
+ '@percy/logger': 1.31.13
+ '@percy/monitoring': 1.31.13(typescript@6.0.3)
+ '@percy/webdriver-utils': 1.31.13(typescript@6.0.3)
content-disposition: 0.5.4
cross-spawn: 7.0.6
extract-zip: 2.0.1
@@ -8986,18 +8938,18 @@ snapshots:
ws: 8.18.3
yaml: 2.8.3
optionalDependencies:
- '@percy/cli-doctor': 1.31.12(typescript@6.0.3)
+ '@percy/cli-doctor': 1.31.13(typescript@6.0.3)
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
- '@percy/dom@1.31.12': {}
+ '@percy/dom@1.31.13': {}
- '@percy/ember@5.0.0(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2)':
+ '@percy/ember@5.0.1(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2)':
dependencies:
- '@percy/sdk-utils': 1.31.12
+ '@percy/sdk-utils': 1.31.13
ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.106.2)
ember-cli-babel: 8.3.1(@babel/core@7.29.0)
transitivePeerDependencies:
@@ -9006,32 +8958,32 @@ snapshots:
- supports-color
- webpack
- '@percy/env@1.31.12':
+ '@percy/env@1.31.13':
dependencies:
- '@percy/logger': 1.31.12
+ '@percy/logger': 1.31.13
- '@percy/logger@1.31.12': {}
+ '@percy/logger@1.31.13': {}
- '@percy/monitoring@1.31.12(typescript@6.0.3)':
+ '@percy/monitoring@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/logger': 1.31.12
- '@percy/sdk-utils': 1.31.12
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/logger': 1.31.13
+ '@percy/sdk-utils': 1.31.13
systeminformation: 5.31.1
transitivePeerDependencies:
- supports-color
- typescript
- '@percy/sdk-utils@1.31.12':
+ '@percy/sdk-utils@1.31.13':
dependencies:
pac-proxy-agent: 7.2.0
transitivePeerDependencies:
- supports-color
- '@percy/webdriver-utils@1.31.12(typescript@6.0.3)':
+ '@percy/webdriver-utils@1.31.13(typescript@6.0.3)':
dependencies:
- '@percy/config': 1.31.12(typescript@6.0.3)
- '@percy/sdk-utils': 1.31.12
+ '@percy/config': 1.31.13(typescript@6.0.3)
+ '@percy/sdk-utils': 1.31.13
transitivePeerDependencies:
- supports-color
- typescript
@@ -9156,14 +9108,14 @@ snapshots:
'@types/node': 24.0.14
optional: true
- '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
- '@typescript-eslint/scope-manager': 8.59.0
- '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.59.0
+ '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.59.2
+ '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.59.2
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
@@ -9172,41 +9124,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.59.0
- '@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.59.0
+ '@typescript-eslint/scope-manager': 8.59.2
+ '@typescript-eslint/types': 8.59.2
+ '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.59.0(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.59.0':
+ '@typescript-eslint/scope-manager@8.59.2':
dependencies:
- '@typescript-eslint/types': 8.59.0
- '@typescript-eslint/visitor-keys': 8.59.0
+ '@typescript-eslint/types': 8.59.2
+ '@typescript-eslint/visitor-keys': 8.59.2
- '@typescript-eslint/tsconfig-utils@8.59.0(typescript@6.0.3)':
+ '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/types': 8.59.2
+ '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -9214,14 +9166,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.59.0': {}
+ '@typescript-eslint/types@8.59.2': {}
- '@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.59.0
- '@typescript-eslint/visitor-keys': 8.59.0
+ '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/types': 8.59.2
+ '@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
@@ -9231,20 +9183,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.59.0
- '@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.59.2
+ '@typescript-eslint/types': 8.59.2
+ '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.59.0':
+ '@typescript-eslint/visitor-keys@8.59.2':
dependencies:
- '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/types': 8.59.2
eslint-visitor-keys: 5.0.1
'@volar/kit@2.4.28(typescript@5.9.3)':
@@ -9571,7 +9523,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
- axe-core@4.11.3: {}
+ axe-core@4.11.4: {}
babel-import-util@1.4.1: {}
@@ -10338,12 +10290,6 @@ snapshots:
cli-width@4.1.0: {}
- clipboard@2.0.11:
- dependencies:
- good-listener: 1.2.2
- select: 1.1.2
- tiny-emitter: 2.1.0
-
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -10529,13 +10475,13 @@ snapshots:
css-loader@5.2.7(webpack@5.106.2):
dependencies:
- icss-utils: 5.1.0(postcss@8.5.10)
+ icss-utils: 5.1.0(postcss@8.5.14)
loader-utils: 2.0.4
- postcss: 8.5.10
- postcss-modules-extract-imports: 3.1.0(postcss@8.5.10)
- postcss-modules-local-by-default: 4.2.0(postcss@8.5.10)
- postcss-modules-scope: 3.2.1(postcss@8.5.10)
- postcss-modules-values: 4.0.0(postcss@8.5.10)
+ postcss: 8.5.14
+ postcss-modules-extract-imports: 3.1.0(postcss@8.5.14)
+ postcss-modules-local-by-default: 4.2.0(postcss@8.5.14)
+ postcss-modules-scope: 3.2.1(postcss@8.5.14)
+ postcss-modules-values: 4.0.0(postcss@8.5.14)
postcss-value-parser: 4.2.0
schema-utils: 3.3.0
semver: 7.7.4
@@ -10786,8 +10732,6 @@ snapshots:
dependencies:
robust-predicates: 3.0.2
- delegate@3.2.0: {}
-
depd@1.1.2: {}
depd@2.0.0: {}
@@ -10807,7 +10751,7 @@ snapshots:
dom-element-descriptors@0.5.1: {}
- dompurify@3.4.1:
+ dompurify@3.4.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10852,12 +10796,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-a11y-testing@8.0.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/test-waiters@4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7))(axe-core@4.11.3)(qunit@2.25.0):
+ ember-a11y-testing@8.0.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/test-waiters@4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7))(axe-core@4.11.4)(qunit@2.25.0):
dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@ember/test-waiters': 4.1.1(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
- axe-core: 4.11.3
+ axe-core: 4.11.4
decorator-transforms: 2.3.1(@babel/core@7.29.0)
optionalDependencies:
qunit: 2.25.0
@@ -10865,20 +10809,6 @@ snapshots:
- '@babel/core'
- supports-color
- ember-arg-types@1.1.0(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2):
- dependencies:
- '@embroider/macros': 1.20.2(@babel/core@7.29.0)(@glint/template@1.7.7)
- ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.106.2)
- ember-cli-babel: 7.26.11
- ember-cli-typescript: 5.3.0
- ember-get-config: 2.1.1(@babel/core@7.29.0)(@glint/template@1.7.7)
- prop-types: 15.8.1
- transitivePeerDependencies:
- - '@babel/core'
- - '@glint/template'
- - supports-color
- - webpack
-
ember-assign-helper@0.5.1:
dependencies:
'@embroider/addon-shim': 1.10.2
@@ -10929,9 +10859,9 @@ snapshots:
- supports-color
- webpack
- ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)):
+ ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)):
dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
'@embroider/macros': 1.20.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/util': 1.13.5(@babel/core@7.29.0)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
@@ -11082,23 +11012,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-cli-clipboard@1.3.0(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(webpack@5.106.2):
- dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
- '@embroider/macros': 1.20.2(@babel/core@7.29.0)(@glint/template@1.7.7)
- clipboard: 2.0.11
- ember-arg-types: 1.1.0(@babel/core@7.29.0)(@glint/template@1.7.7)(webpack@5.106.2)
- ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.106.2)
- ember-cli-babel: 7.26.11
- ember-cli-htmlbars: 6.3.0
- ember-modifier: 4.3.0(@babel/core@7.29.0)
- prop-types: 15.8.1
- transitivePeerDependencies:
- - '@babel/core'
- - '@glint/template'
- - supports-color
- - webpack
-
ember-cli-dependency-checker@3.3.3(ember-cli@6.12.0(@babel/core@7.29.0)(@types/node@24.0.14)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)):
dependencies:
chalk: 2.4.2
@@ -11194,7 +11107,7 @@ snapshots:
ember-cli-is-package-missing@1.0.0: {}
- ember-cli-mirage@3.0.4(@ember-data/model@4.12.8)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.106.2))(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(miragejs@0.1.48)(webpack@5.106.2):
+ ember-cli-mirage@3.0.4(@ember-data/model@4.12.8)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.106.2))(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(miragejs@0.1.48)(webpack@5.106.2):
dependencies:
'@babel/core': 7.29.0
'@embroider/macros': 1.20.2(@babel/core@7.29.0)(@glint/template@1.7.7)
@@ -11209,9 +11122,9 @@ snapshots:
miragejs: 0.1.48
optionalDependencies:
'@ember-data/model': 4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@glint/template@1.7.7))(@ember-data/json-api@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8)(@glint/template@1.7.7))(@ember-data/legacy-compat@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@4.0.1)(@glint/template@1.7.7))(@ember-data/store@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(@ember-data/tracking@4.12.8(@babel/core@7.29.0)(@glint/template@1.7.7))(@ember/string@4.0.1)(@glint/template@1.7.7)(ember-inflector@6.0.0(@babel/core@7.29.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
ember-data: 4.12.8(@babel/core@7.29.0)(@ember/string@4.0.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.106.2)
- ember-qunit: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
+ ember-qunit: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
transitivePeerDependencies:
- '@glint/template'
- supports-color
@@ -11241,9 +11154,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-cli-page-object@2.3.2(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)):
+ ember-cli-page-object@2.3.2(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)):
dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
'@ro0gr/ceibo': 2.2.0
'@types/jquery': 3.5.32
@@ -11625,24 +11538,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-eslint-parser@0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
+ ember-eslint-parser@0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@babel/core': 7.29.0
'@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@9.39.4(jiti@2.6.1))
'@glimmer/syntax': 0.95.0
- '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
content-tag: 2.0.3
eslint-scope: 7.2.2
html-tags: 3.3.1
mathml-tag-names: 2.1.3
svg-tags: 1.0.0
optionalDependencies:
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
transitivePeerDependencies:
- eslint
- typescript
- ember-exam@10.1.0(@glint/template@1.7.7)(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.25.0)(webpack@5.106.2):
+ ember-exam@10.1.0(@glint/template@1.7.7)(ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.25.0)(webpack@5.106.2):
dependencies:
'@babel/core': 7.29.0
chalk: 5.6.2
@@ -11650,7 +11563,7 @@ snapshots:
debug: 4.4.3
ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.106.2)
ember-cli-babel: 8.3.1(@babel/core@7.29.0)
- ember-qunit: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
+ ember-qunit: 9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0)
ember-source: 6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)
execa: 8.0.1
fs-extra: 11.3.4
@@ -11762,15 +11675,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-power-select@8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)):
+ ember-power-select@8.12.1(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-basic-dropdown@8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)))(ember-concurrency@4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7))(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5)):
dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
'@embroider/util': 1.13.5(@babel/core@7.29.0)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
'@glimmer/component': 2.1.1
decorator-transforms: 2.3.1(@babel/core@7.29.0)
ember-assign-helper: 0.5.1
- ember-basic-dropdown: 8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
+ ember-basic-dropdown: 8.11.0(@babel/core@7.29.0)(@ember/string@4.0.1)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glimmer/component@2.1.1)(@glint/template@1.7.7)(ember-source@6.12.0(@glimmer/component@2.1.1)(rsvp@4.8.5))
ember-concurrency: 4.0.6(@babel/core@7.29.0)(@glint/template@1.7.7)
ember-element-helper: 0.8.8
ember-modifier: 4.3.0(@babel/core@7.29.0)
@@ -11782,9 +11695,9 @@ snapshots:
- ember-source
- supports-color
- ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0):
+ ember-qunit@9.0.4(@babel/core@7.29.0)(@ember/test-helpers@5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7))(@glint/template@1.7.7)(qunit@2.25.0):
dependencies:
- '@ember/test-helpers': 5.4.1(@babel/core@7.29.0)(@glint/template@1.7.7)
+ '@ember/test-helpers': 5.4.2(@babel/core@7.29.0)(@glint/template@1.7.7)
'@embroider/addon-shim': 1.10.2
'@embroider/macros': 1.20.2(@babel/core@7.29.0)(@glint/template@1.7.7)
qunit: 2.25.0
@@ -12162,11 +12075,11 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
- eslint-plugin-ember@12.7.5(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
+ eslint-plugin-ember@12.7.5(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@ember-data/rfc395-data': 0.0.4
css-tree: 3.2.1
- ember-eslint-parser: 0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ ember-eslint-parser: 0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
ember-rfc176-data: 0.3.18
eslint: 9.39.4(jiti@2.6.1)
eslint-utils: 3.0.0(eslint@9.39.4(jiti@2.6.1))
@@ -12176,7 +12089,7 @@ snapshots:
requireindex: 1.2.0
snake-case: 3.0.4
optionalDependencies:
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
transitivePeerDependencies:
- '@babel/core'
- typescript
@@ -12188,7 +12101,7 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
+ eslint-plugin-n@18.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
enhanced-resolve: 5.20.0
@@ -12199,9 +12112,8 @@ snapshots:
globrex: 0.1.2
ignore: 5.3.2
semver: 7.7.4
- ts-declaration-location: 1.0.7(typescript@6.0.3)
- transitivePeerDependencies:
- - typescript
+ optionalDependencies:
+ typescript: 6.0.3
eslint-plugin-qunit@8.2.6(eslint@9.39.4(jiti@2.6.1)):
dependencies:
@@ -12900,7 +12812,7 @@ snapshots:
globals@15.15.0: {}
- globals@17.5.0: {}
+ globals@17.6.0: {}
globalthis@1.0.4:
dependencies:
@@ -12922,10 +12834,6 @@ snapshots:
globrex@0.1.2: {}
- good-listener@1.2.2:
- dependencies:
- delegate: 3.2.0
-
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -13086,9 +12994,9 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
- icss-utils@5.1.0(postcss@8.5.10):
+ icss-utils@5.1.0(postcss@8.5.14):
dependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
ignore@5.3.2: {}
@@ -13557,10 +13465,6 @@ snapshots:
strip-ansi: 7.2.0
wrap-ansi: 9.0.0
- loose-envify@1.4.0:
- dependencies:
- js-tokens: 4.0.0
-
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
@@ -14085,36 +13989,36 @@ snapshots:
postcss-media-query-parser@0.2.3: {}
- postcss-modules-extract-imports@3.1.0(postcss@8.5.10):
+ postcss-modules-extract-imports@3.1.0(postcss@8.5.14):
dependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
- postcss-modules-local-by-default@4.2.0(postcss@8.5.10):
+ postcss-modules-local-by-default@4.2.0(postcss@8.5.14):
dependencies:
- icss-utils: 5.1.0(postcss@8.5.10)
- postcss: 8.5.10
+ icss-utils: 5.1.0(postcss@8.5.14)
+ postcss: 8.5.14
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
- postcss-modules-scope@3.2.1(postcss@8.5.10):
+ postcss-modules-scope@3.2.1(postcss@8.5.14):
dependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
postcss-selector-parser: 7.1.1
- postcss-modules-values@4.0.0(postcss@8.5.10):
+ postcss-modules-values@4.0.0(postcss@8.5.14):
dependencies:
- icss-utils: 5.1.0(postcss@8.5.10)
- postcss: 8.5.10
+ icss-utils: 5.1.0(postcss@8.5.14)
+ postcss: 8.5.14
postcss-resolve-nested-selector@0.1.6: {}
- postcss-safe-parser@7.0.1(postcss@8.5.10):
+ postcss-safe-parser@7.0.1(postcss@8.5.14):
dependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
- postcss-scss@4.0.9(postcss@8.5.10):
+ postcss-scss@4.0.9(postcss@8.5.14):
dependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
postcss-selector-parser@7.1.1:
dependencies:
@@ -14123,7 +14027,7 @@ snapshots:
postcss-value-parser@4.2.0: {}
- postcss@8.5.10:
+ postcss@8.5.14:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -14168,12 +14072,6 @@ snapshots:
promise.hash.helper@1.0.8: {}
- prop-types@15.8.1:
- dependencies:
- loose-envify: 1.4.0
- object-assign: 4.1.1
- react-is: 16.13.1
-
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
@@ -14246,8 +14144,6 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-is@16.13.1: {}
-
readable-stream@1.0.34:
dependencies:
core-util-is: 1.0.3
@@ -14537,8 +14433,6 @@ snapshots:
ajv-formats: 2.1.1
ajv-keywords: 5.1.0(ajv@8.18.0)
- select@1.1.2: {}
-
semver@5.7.2: {}
semver@6.3.1: {}
@@ -14915,33 +14809,33 @@ snapshots:
styled_string@0.0.1: {}
- stylelint-config-recommended-scss@17.0.0(postcss@8.5.10)(stylelint@17.9.0(typescript@6.0.3)):
+ stylelint-config-recommended-scss@17.0.0(postcss@8.5.14)(stylelint@17.10.0(typescript@6.0.3)):
dependencies:
- postcss-scss: 4.0.9(postcss@8.5.10)
- stylelint: 17.9.0(typescript@6.0.3)
- stylelint-config-recommended: 18.0.0(stylelint@17.9.0(typescript@6.0.3))
- stylelint-scss: 7.0.0(stylelint@17.9.0(typescript@6.0.3))
+ postcss-scss: 4.0.9(postcss@8.5.14)
+ stylelint: 17.10.0(typescript@6.0.3)
+ stylelint-config-recommended: 18.0.0(stylelint@17.10.0(typescript@6.0.3))
+ stylelint-scss: 7.0.0(stylelint@17.10.0(typescript@6.0.3))
optionalDependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
- stylelint-config-recommended@18.0.0(stylelint@17.9.0(typescript@6.0.3)):
+ stylelint-config-recommended@18.0.0(stylelint@17.10.0(typescript@6.0.3)):
dependencies:
- stylelint: 17.9.0(typescript@6.0.3)
+ stylelint: 17.10.0(typescript@6.0.3)
- stylelint-config-standard-scss@17.0.0(postcss@8.5.10)(stylelint@17.9.0(typescript@6.0.3)):
+ stylelint-config-standard-scss@17.0.0(postcss@8.5.14)(stylelint@17.10.0(typescript@6.0.3)):
dependencies:
- stylelint: 17.9.0(typescript@6.0.3)
- stylelint-config-recommended-scss: 17.0.0(postcss@8.5.10)(stylelint@17.9.0(typescript@6.0.3))
- stylelint-config-standard: 40.0.0(stylelint@17.9.0(typescript@6.0.3))
+ stylelint: 17.10.0(typescript@6.0.3)
+ stylelint-config-recommended-scss: 17.0.0(postcss@8.5.14)(stylelint@17.10.0(typescript@6.0.3))
+ stylelint-config-standard: 40.0.0(stylelint@17.10.0(typescript@6.0.3))
optionalDependencies:
- postcss: 8.5.10
+ postcss: 8.5.14
- stylelint-config-standard@40.0.0(stylelint@17.9.0(typescript@6.0.3)):
+ stylelint-config-standard@40.0.0(stylelint@17.10.0(typescript@6.0.3)):
dependencies:
- stylelint: 17.9.0(typescript@6.0.3)
- stylelint-config-recommended: 18.0.0(stylelint@17.9.0(typescript@6.0.3))
+ stylelint: 17.10.0(typescript@6.0.3)
+ stylelint-config-recommended: 18.0.0(stylelint@17.10.0(typescript@6.0.3))
- stylelint-scss@7.0.0(stylelint@17.9.0(typescript@6.0.3)):
+ stylelint-scss@7.0.0(stylelint@17.10.0(typescript@6.0.3)):
dependencies:
css-tree: 3.2.1
is-plain-object: 5.0.0
@@ -14951,9 +14845,9 @@ snapshots:
postcss-resolve-nested-selector: 0.1.6
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
- stylelint: 17.9.0(typescript@6.0.3)
+ stylelint: 17.10.0(typescript@6.0.3)
- stylelint@17.9.0(typescript@6.0.3):
+ stylelint@17.10.0(typescript@6.0.3):
dependencies:
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
@@ -14982,8 +14876,8 @@ snapshots:
micromatch: 4.0.8
normalize-path: 3.0.0
picocolors: 1.1.1
- postcss: 8.5.10
- postcss-safe-parser: 7.0.1(postcss@8.5.10)
+ postcss: 8.5.14
+ postcss-safe-parser: 7.0.1(postcss@8.5.14)
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
string-width: 8.2.0
@@ -15184,8 +15078,6 @@ snapshots:
dependencies:
convert-hrtime: 5.0.0
- tiny-emitter@2.1.0: {}
-
tiny-glob@0.2.9:
dependencies:
globalyzer: 0.1.0
@@ -15268,11 +15160,6 @@ snapshots:
dependencies:
typescript: 6.0.3
- ts-declaration-location@1.0.7(typescript@6.0.3):
- dependencies:
- picomatch: 4.0.4
- typescript: 6.0.3
-
tslib@1.14.1: {}
tslib@2.8.1: {}
@@ -15332,12 +15219,12 @@ snapshots:
dependencies:
semver: 7.7.4
- typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
+ typescript-eslint@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
diff --git a/ui/app/components/action-card.gjs b/ui/app/components/action-card.gjs
new file mode 100644
index 00000000000..8faf5834d00
--- /dev/null
+++ b/ui/app/components/action-card.gjs
@@ -0,0 +1,242 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { capitalize } from '@ember/string';
+import { didUpdate } from '@ember/render-modifiers';
+import { fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { eq } from 'ember-truth-helpers';
+import {
+ HdsBadge,
+ HdsButton,
+ HdsButtonSet,
+ HdsCopyButton,
+ HdsPageHeader,
+ HdsReveal,
+} from '@hashicorp/design-system-components/components';
+import formatTs from 'nomad-ui/helpers/format-ts';
+
+export default class ActionCard extends Component {
+ @service nomadActions;
+
+ @tracked selectedPeer = null;
+ @tracked hasBeenAnchored = false;
+
+ get instance() {
+ return this.selectedPeer || this.args.instance;
+ }
+
+ get peers() {
+ const peerID = this.instance?.peerID;
+ if (!peerID) {
+ return [];
+ }
+ return this.nomadActions.actionsQueue.filter(
+ (peer) => peer.peerID === peerID,
+ );
+ }
+
+ get hasRunningPeers() {
+ return this.peers.some((peer) => peer.state === 'running');
+ }
+
+ get stateText() {
+ return capitalize(this.instance?.state || '');
+ }
+
+ get stateColor() {
+ const instance = this.instance;
+ switch (instance.state) {
+ case 'starting':
+ return 'neutral';
+ case 'running':
+ return 'highlight';
+ case 'complete':
+ return 'success';
+ case 'error':
+ return 'critical';
+ default:
+ return 'neutral';
+ }
+ }
+
+ get completedSecondsFloat() {
+ const started = this.instance?.createdAt;
+ const ended = this.instance?.completedAt;
+ if (!started || !ended) {
+ return null;
+ }
+ return (new Date(ended).getTime() - new Date(started).getTime()) / 1000;
+ }
+
+ get completedSecondsInt() {
+ const seconds = this.completedSecondsFloat;
+ if (seconds == null) {
+ return null;
+ }
+ return Math.trunc(seconds);
+ }
+
+ get completedLongerThanOneSecond() {
+ return (this.completedSecondsInt ?? 0) > 1;
+ }
+
+ stop = () => {
+ this.instance.socket.close();
+ };
+
+ stopAll = () => {
+ this.nomadActions.stopPeers(this.instance.peerID);
+ };
+
+ selectPeer = (peer) => {
+ this.selectedPeer = peer;
+ };
+
+ anchorToBottom = (element) => {
+ if (this.hasBeenAnchored) return;
+ const parentHeight = element.parentElement.clientHeight;
+ const elementHeight = element.clientHeight;
+ if (elementHeight > parentHeight) {
+ this.hasBeenAnchored = true;
+ element.parentElement.scroll(0, elementHeight);
+ }
+ };
+
+
+
block parent.
- * A trick here is that, if the user scrolls up from the bottom of the block,
- * we don't want to force them down to the bottom again on update, but we do
- * want to keep them there by default (so they have the latest output).
- * The hasBeenAnchored flag is used to track this state, and we do a little
- * trick when the messages get long enough to cause a scroll to start the
- * anchoring process here.
- *
- * @param {HTMLElement} element
- */
- @action anchorToBottom(element) {
- if (this.hasBeenAnchored) return;
- const parentHeight = element.parentElement.clientHeight;
- const elementHeight = element.clientHeight;
- if (elementHeight > parentHeight) {
- this.hasBeenAnchored = true;
- element.parentElement.scroll(0, elementHeight);
- }
- }
-}
diff --git a/ui/app/components/actions-dropdown.gjs b/ui/app/components/actions-dropdown.gjs
new file mode 100644
index 00000000000..0ed322d14a0
--- /dev/null
+++ b/ui/app/components/actions-dropdown.gjs
@@ -0,0 +1,122 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array, concat, fn, get, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { eq } from 'ember-truth-helpers';
+import { objectAt } from '@nullvoxpopuli/ember-composable-helpers';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import {
+ HdsDropdown,
+ HdsReveal,
+} from '@hashicorp/design-system-components/components';
+import { service } from '@ember/service';
+
+export default class ActionsDropdown extends Component {
+ @service nomadActions;
+ @service notifications;
+
+ /**
+ * @param {HTMLElement} el
+ */
+ openActionsDropdown = (el) => {
+ const dropdownTrigger = el?.getElementsByTagName('button')[0];
+ if (dropdownTrigger) {
+ dropdownTrigger.click();
+ }
+ };
+
+
+
+
+ {{#each @actions as |actionC|}}
+ {{#if @allocation}}
+
+ {{else if (eq actionC.allocations.length 1)}}
+
+ {{else}}
+
+
+
+
+
+
+ {{/if}}
+ {{/each}}
+
+
+}
diff --git a/ui/app/components/actions-dropdown.hbs b/ui/app/components/actions-dropdown.hbs
deleted file mode 100644
index fde4a2807e9..00000000000
--- a/ui/app/components/actions-dropdown.hbs
+++ /dev/null
@@ -1,88 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#each @actions as |action|}}
- {{#if @allocation}}
- {{! If an allocation was passed in, we run the action on that alloc }}
-
- {{else if (eq action.allocations.length 1)}}
- {{! If there is only one allocation on the action, we can just run it on the 0th alloc }}
-
- {{else}}
- {{! Either no allocation was passed in, or there are multiple allocatios on the action to choose from }}
-
-
-
-
-
-
- {{/if}}
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/actions-dropdown.js b/ui/app/components/actions-dropdown.js
deleted file mode 100644
index adfbbde8148..00000000000
--- a/ui/app/components/actions-dropdown.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-
-export default class ActionsDropdownComponent extends Component {
- @service nomadActions;
- @service notifications;
-
- /**
- * @param {HTMLElement} el
- */
- @action openActionsDropdown(el) {
- const dropdownTrigger = el?.getElementsByTagName('button')[0];
- if (dropdownTrigger) {
- dropdownTrigger.click();
- }
- }
-}
diff --git a/ui/app/components/actions-flyout-global-button.gjs b/ui/app/components/actions-flyout-global-button.gjs
new file mode 100644
index 00000000000..2c3f1850051
--- /dev/null
+++ b/ui/app/components/actions-flyout-global-button.gjs
@@ -0,0 +1,62 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { HdsButton } from '@hashicorp/design-system-components/components';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class ActionsFlyoutGlobalButton extends Component {
+ @service nomadActions;
+
+ shortcutPattern = ['a', 'c'];
+
+ get runningActionsCount() {
+ return this.nomadActions.runningActions.length;
+ }
+
+ get shouldShow() {
+ return (
+ this.nomadActions.actionsQueue.length > 0 &&
+ !this.nomadActions.flyoutActive
+ );
+ }
+
+ get buttonText() {
+ const count = this.runningActionsCount;
+ if (!count) return 'Actions';
+
+ const label = count === 1 ? 'Action' : 'Actions';
+ return `${count} ${label} Running`;
+ }
+
+ get buttonIcon() {
+ return this.runningActionsCount ? 'loading' : 'chevron-right';
+ }
+
+
+ {{#if this.shouldShow}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/actions-flyout-global-button.hbs b/ui/app/components/actions-flyout-global-button.hbs
deleted file mode 100644
index be24d511798..00000000000
--- a/ui/app/components/actions-flyout-global-button.hbs
+++ /dev/null
@@ -1,39 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.nomadActions.actionsQueue.length}}
- {{#unless this.nomadActions.flyoutActive}}
-
- {{/unless}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/actions-flyout-global-button.js b/ui/app/components/actions-flyout-global-button.js
deleted file mode 100644
index d49a2b8f0e9..00000000000
--- a/ui/app/components/actions-flyout-global-button.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class ActionsFlyoutGlobalButtonComponent extends Component {
- @service nomadActions;
-}
diff --git a/ui/app/components/actions-flyout.gjs b/ui/app/components/actions-flyout.gjs
new file mode 100644
index 00000000000..c5ac516a96f
--- /dev/null
+++ b/ui/app/components/actions-flyout.gjs
@@ -0,0 +1,132 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import {
+ HdsButton,
+ HdsFlyout,
+ HdsApplicationState,
+} from '@hashicorp/design-system-components/components';
+import ActionCard from 'nomad-ui/components/action-card';
+import ActionsDropdown from 'nomad-ui/components/actions-dropdown';
+
+export default class ActionsFlyout extends Component {
+ @service nomadActions;
+ @service router;
+
+ get job() {
+ if (this.task) {
+ return this.task.taskGroup.job;
+ }
+
+ return (
+ this.router.currentRouteName.startsWith('jobs.job') &&
+ this.router.currentRoute.attributes
+ );
+ }
+
+ get task() {
+ return (
+ this.router.currentRouteName.startsWith('allocations.allocation.task') &&
+ this.router.currentRoute.attributes.task
+ );
+ }
+
+ get allocation() {
+ return (
+ this.args.allocation ||
+ (this.task && this.router.currentRoute.attributes.allocation)
+ );
+ }
+
+ get contextualParent() {
+ return this.task || this.job;
+ }
+
+ get contextualActions() {
+ return this.contextualParent?.actions || [];
+ }
+
+ // Group peers together by their peerID
+ get actionInstances() {
+ const instances = this.nomadActions.actionsQueue;
+
+ const peerIDs = new Set();
+ const filteredInstances = [];
+ for (const instance of instances) {
+ if (!instance.peerID || !peerIDs.has(instance.peerID)) {
+ filteredInstances.push(instance);
+ peerIDs.add(instance.peerID);
+ }
+ }
+
+ return filteredInstances;
+ }
+
+
+ {{#if this.nomadActions.flyoutActive}}
+
+
+
+ Actions
+
+ {{#if this.contextualActions.length}}
+
+ {{/if}}
+ {{#if this.nomadActions.runningActions.length}}
+
+ {{/if}}
+ {{#if this.nomadActions.finishedActions.length}}
+
+ {{/if}}
+
+
+
+ {{#each this.actionInstances as |instance|}}
+
+ {{else}}
+
+
+
+
+
+
+
+ {{/each}}
+
+
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/actions-flyout.hbs b/ui/app/components/actions-flyout.hbs
deleted file mode 100644
index 63712cb073f..00000000000
--- a/ui/app/components/actions-flyout.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.nomadActions.flyoutActive}}
-
-
-
- Actions
-
- {{#if this.contextualActions.length}}
-
- {{/if}}
- {{#if this.nomadActions.runningActions.length}}
-
- {{/if}}
- {{#if this.nomadActions.finishedActions.length}}
-
- {{/if}}
-
-
-
- {{#each this.actionInstances as |instance|}}
-
- {{else}}
-
-
-
-
-
-
-
- {{/each}}
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/actions-flyout.js b/ui/app/components/actions-flyout.js
deleted file mode 100644
index 0efeb4c978e..00000000000
--- a/ui/app/components/actions-flyout.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-
-export default class ActionsFlyoutComponent extends Component {
- @service nomadActions;
- @service router;
-
- get job() {
- if (this.task) {
- return this.task.taskGroup.job;
- } else {
- return (
- this.router.currentRouteName.startsWith('jobs.job') &&
- this.router.currentRoute.attributes
- );
- }
- }
-
- get task() {
- return (
- this.router.currentRouteName.startsWith('allocations.allocation.task') &&
- this.router.currentRoute.attributes.task
- );
- }
-
- get allocation() {
- return (
- this.args.allocation ||
- (this.task && this.router.currentRoute.attributes.allocation)
- );
- }
-
- get contextualParent() {
- return this.task || this.job;
- }
-
- get contextualActions() {
- return this.contextualParent?.actions || [];
- }
-
- @alias('nomadActions.flyoutActive') isOpen;
-
- /**
- * Group peers together by their peerID
- */
- get actionInstances() {
- let instances = this.nomadActions.actionsQueue;
-
- // Only keep the first of any found peerID value from the list
- let peerIDs = new Set();
- let filteredInstances = [];
- for (let instance of instances) {
- if (!instance.peerID || !peerIDs.has(instance.peerID)) {
- filteredInstances.push(instance);
- peerIDs.add(instance.peerID);
- }
- }
-
- return filteredInstances;
- }
-}
diff --git a/ui/app/components/addon-copy-button.js b/ui/app/components/addon-copy-button.js
deleted file mode 100644
index 59fd043821d..00000000000
--- a/ui/app/components/addon-copy-button.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-// This lets us use copy-button to wrap ember-cli-clipboard’s component
-export { default } from 'ember-cli-clipboard/components/copy-button';
diff --git a/ui/app/components/administration-subnav.gjs b/ui/app/components/administration-subnav.gjs
new file mode 100644
index 00000000000..1c882c0f280
--- /dev/null
+++ b/ui/app/components/administration-subnav.gjs
@@ -0,0 +1,61 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+import can from 'ember-can/helpers/can';
+
+export default class AdministrationSubnav extends Component {
+ @service keyboard;
+
+
+
+
+}
diff --git a/ui/app/components/administration-subnav.hbs b/ui/app/components/administration-subnav.hbs
deleted file mode 100644
index 15bc7879cf9..00000000000
--- a/ui/app/components/administration-subnav.hbs
+++ /dev/null
@@ -1,39 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/administration-subnav.js b/ui/app/components/administration-subnav.js
deleted file mode 100644
index ea536147e14..00000000000
--- a/ui/app/components/administration-subnav.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import { service } from '@ember/service';
-
-@tagName('')
-export default class AdministrationSubnav extends Component {
- @service keyboard;
-}
diff --git a/ui/app/components/agent-monitor.gjs b/ui/app/components/agent-monitor.gjs
new file mode 100644
index 00000000000..7e3c5e77bf3
--- /dev/null
+++ b/ui/app/components/agent-monitor.gjs
@@ -0,0 +1,123 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { service } from '@ember/service';
+import { assert } from '@ember/debug';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import PowerSelect from 'ember-power-select/components/power-select';
+import StreamingFile from 'nomad-ui/components/streaming-file';
+import Log from 'nomad-ui/utils/classes/log';
+
+const LEVELS = ['error', 'warn', 'info', 'debug', 'trace'];
+
+export default class AgentMonitor extends Component {
+ @service token;
+
+ levels = LEVELS;
+ monitorUrl = '/v1/agent/monitor';
+
+ @tracked level = this.args.level ?? LEVELS[2];
+ @tracked isStreaming = this.args.isStreaming ?? true;
+ @tracked logger = null;
+
+ get monitorParams() {
+ assert(
+ 'Provide a client OR a server to AgentMonitor, not both.',
+ this.args.server != null || this.args.client != null,
+ );
+
+ const type = this.args.server ? 'server_id' : 'client_id';
+ const id = this.args.server ? this.args.server.id : this.args.client?.id;
+
+ const params = {
+ log_level: this.level,
+ [type]: id,
+ };
+
+ if (this.args.server) {
+ params.region = this.args.server.region;
+ }
+
+ return params;
+ }
+
+ capitalizeLevel = (value) => {
+ if (!value) return '';
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
+ };
+
+ initialize = () => {
+ this.updateLogger();
+ };
+
+ updateLogger = () => {
+ let currentTail = this.logger ? this.logger.tail : '';
+ if (currentTail) {
+ currentTail += `\n...changing log level to ${this.level}...\n\n`;
+ }
+
+ this.logger = Log.create({
+ logFetch: (url) => this.token.authorizedRequest(url),
+ params: this.monitorParams,
+ url: this.monitorUrl,
+ tail: currentTail,
+ });
+ };
+
+ setLevel = (level) => {
+ this.logger?.stop();
+ this.level = level;
+ this.args.onLevelChange?.(level);
+ this.updateLogger();
+ };
+
+ toggleStream = () => {
+ this.isStreaming = !this.isStreaming;
+ };
+
+
+
+
+
+ Level:
+ {{this.capitalizeLevel level}}
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/agent-monitor.hbs b/ui/app/components/agent-monitor.hbs
deleted file mode 100644
index f0e6b19b8c8..00000000000
--- a/ui/app/components/agent-monitor.hbs
+++ /dev/null
@@ -1,38 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- Level: {{capitalize level}}
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/agent-monitor.js b/ui/app/components/agent-monitor.js
deleted file mode 100644
index 60ed8c6d677..00000000000
--- a/ui/app/components/agent-monitor.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { assert } from '@ember/debug';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import Log from 'nomad-ui/utils/classes/log';
-
-const LEVELS = ['error', 'warn', 'info', 'debug', 'trace'];
-
-@classic
-@tagName('')
-export default class AgentMonitor extends Component {
- @service token;
-
- client = null;
- server = null;
- level = LEVELS[2];
- onLevelChange() {}
-
- levels = LEVELS;
- monitorUrl = '/v1/agent/monitor';
- isStreaming = true;
- logger = null;
-
- @computed('client.id', 'level', 'server.{id,region}')
- get monitorParams() {
- assert(
- 'Provide a client OR a server to AgentMonitor, not both.',
- this.server != null || this.client != null,
- );
-
- const type = this.server ? 'server_id' : 'client_id';
- const id = this.server ? this.server.id : this.client && this.client.id;
-
- const params = {
- log_level: this.level,
- [type]: id,
- };
-
- if (this.server) {
- params.region = this.server.region;
- }
-
- return params;
- }
-
- didInsertElement() {
- super.didInsertElement(...arguments);
- this.updateLogger();
- }
-
- updateLogger() {
- let currentTail = this.logger ? this.logger.tail : '';
- if (currentTail) {
- currentTail += `\n...changing log level to ${this.level}...\n\n`;
- }
- this.set(
- 'logger',
- Log.create({
- logFetch: (url) => this.token.authorizedRequest(url),
- params: this.monitorParams,
- url: this.monitorUrl,
- tail: currentTail,
- }),
- );
- }
-
- @action
- setLevel(level) {
- this.logger.stop();
- this.set('level', level);
- this.onLevelChange(level);
- this.updateLogger();
- }
-
- @action
- toggleStream() {
- this.set('streamMode', 'streaming');
- this.toggleProperty('isStreaming');
- }
-}
diff --git a/ui/app/components/allocation-row.gjs b/ui/app/components/allocation-row.gjs
new file mode 100644
index 00000000000..2f7073d6421
--- /dev/null
+++ b/ui/app/components/allocation-row.gjs
@@ -0,0 +1,272 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { eq, notEq, or } from 'ember-truth-helpers';
+import { task, timeout } from 'ember-concurrency';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import AllocationStat from 'nomad-ui/components/allocation-stat';
+import Tooltip from 'nomad-ui/components/tooltip';
+import formatJobId from 'nomad-ui/helpers/format-job-id';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import ENV from 'nomad-ui/config/environment';
+import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class AllocationRow extends Component {
+ @service store;
+ @service token;
+
+ @tracked statsError = false;
+ @tracked statsTracker = null;
+
+ get enablePolling() {
+ if (typeof this.args.enablePolling === 'boolean') {
+ return this.args.enablePolling;
+ }
+
+ return ENV.environment !== 'test';
+ }
+
+ get stats() {
+ return this.statsTracker;
+ }
+
+ buildStatsTracker(allocation) {
+ if (!allocation?.isRunning) {
+ return null;
+ }
+
+ return AllocationStatsTracker.create({
+ fetch: (url) => this.token.authorizedRequest(url),
+ allocation,
+ });
+ }
+
+ get cpu() {
+ const cpu = this.stats?.cpu;
+ return cpu?.[cpu.length - 1];
+ }
+
+ get memory() {
+ const memory = this.stats?.memory;
+ return memory?.[memory.length - 1];
+ }
+
+ get hasJobActions() {
+ return Boolean(this.args.model?.job?.actions?.length);
+ }
+
+ click = (event) => {
+ lazyClick([this.args.onClick, event]);
+ };
+
+ updateStatsTracker = () => {
+ const allocation = this.args.allocation;
+ this.statsTracker = this.buildStatsTracker(allocation);
+
+ if (allocation) {
+ this.qualifyAllocation();
+ } else {
+ this.fetchStats.cancelAll();
+ }
+ };
+
+ qualifyAllocation = async () => {
+ const allocation = this.args.allocation;
+ if (!allocation) {
+ return;
+ }
+
+ if (allocation.isPartial) {
+ await this.store.findRecord('allocation', allocation.id, {
+ backgroundReload: false,
+ });
+ }
+
+ if (allocation.get('job.isPending')) {
+ await allocation.get('job');
+ } else if (!allocation.get('taskGroup')) {
+ const job = allocation.get('job.content');
+ if (job.isPartial) await job.reload();
+ }
+
+ this.fetchStats.perform();
+ };
+
+ fetchStats = task({ drop: true }, async () => {
+ do {
+ if (this.stats) {
+ try {
+ await this.stats.poll.linked().perform();
+ this.statsError = false;
+ } catch {
+ this.statsError = true;
+ }
+ }
+
+ await timeout(500);
+ } while (this.enablePolling);
+ });
+
+
+
+
+ {{#if @allocation.unhealthyDrivers.length}}
+
+
+
+ {{/if}}
+ {{#if @allocation.nextAllocation}}
+
+
+
+ {{/if}}
+ {{#if @allocation.wasPreempted}}
+
+
+
+ {{/if}}
+
+
+
+ {{@allocation.shortId}}
+
+
+ {{#if (eq @context "job")}}
+
+
+ {{@allocation.taskGroupName}}
+
+
+ {{/if}}
+
+ {{formatMonthTs @allocation.createTime}}
+
+
+
+ {{momentFromNow @allocation.modifyTime}}
+
+
+
+
+ {{@allocation.clientStatus}}
+
+ {{#if (eq @context "volume")}}
+
+
+
+ {{@allocation.node.shortId}}
+
+
+
+ {{/if}}
+ {{#if (or (eq @context "taskGroup") (eq @context "job"))}}
+
+ {{@allocation.jobVersion}}
+
+
+
+
+ {{@allocation.node.shortId}}
+
+
+
+ {{else if (or (eq @context "node") (eq @context "volume"))}}
+
+ {{#if (or @allocation.job.isPending @allocation.job.isReloading)}}
+ ...
+ {{else}}
+
+ {{@allocation.job.name}}
+
+
+ /
+ {{@allocation.taskGroup.name}}
+
+ {{/if}}
+
+
+ {{@allocation.jobVersion}}
+
+ {{/if}}
+ {{#if (notEq @context "volume")}}
+
+ {{if @allocation.taskGroup.volumes.length "Yes"}}
+
+ {{/if}}
+
+
+
+
+
+
+ {{#if this.hasJobActions}}
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/allocation-row.hbs b/ui/app/components/allocation-row.hbs
deleted file mode 100644
index f8cdb2db102..00000000000
--- a/ui/app/components/allocation-row.hbs
+++ /dev/null
@@ -1,146 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.allocation.unhealthyDrivers.length}}
-
-
-
- {{/if}}
- {{#if this.allocation.nextAllocation}}
-
-
-
- {{/if}}
- {{#if this.allocation.wasPreempted}}
-
-
-
- {{/if}}
-
-
-
- {{this.allocation.shortId}}
-
-
-{{#if (eq this.context "job")}}
-
-
- {{this.allocation.taskGroupName}}
-
-
-{{/if}}
-
- {{format-month-ts this.allocation.createTime}}
-
-
-
- {{moment-from-now this.allocation.modifyTime}}
-
-
-
-
- {{this.allocation.clientStatus}}
-
-{{#if (eq this.context "volume")}}
-
-
-
- {{this.allocation.node.shortId}}
-
-
-
-{{/if}}
-{{#if (or (eq this.context "taskGroup") (eq this.context "job"))}}
-
- {{this.allocation.jobVersion}}
-
-
-
-
- {{this.allocation.node.shortId}}
-
-
-
-{{else if (or (eq this.context "node") (eq this.context "volume"))}}
-
- {{#if (or this.allocation.job.isPending this.allocation.job.isReloading)}}
- ...
- {{else}}
-
- {{this.allocation.job.name}}
-
-
- /
- {{this.allocation.taskGroup.name}}
-
- {{/if}}
-
-
- {{this.allocation.jobVersion}}
-
-{{/if}}
-{{#if (not-eq this.context "volume")}}
-
- {{if this.allocation.taskGroup.volumes.length "Yes"}}
-
-{{/if}}
-
-
-
-
-
-
-{{#if this.model.job.actions.length}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js
deleted file mode 100644
index 231bbc065b1..00000000000
--- a/ui/app/components/allocation-row.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import { scheduleOnce } from '@ember/runloop';
-import { task, timeout } from 'ember-concurrency';
-import { lazyClick } from '../helpers/lazy-click';
-import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker';
-import classic from 'ember-classic-decorator';
-import ENV from 'nomad-ui/config/environment';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-
-@classic
-@tagName('tr')
-@classNames('allocation-row', 'is-interactive')
-@attributeBindings(
- 'data-test-allocation',
- 'data-test-write-allocation',
- 'data-test-read-allocation',
-)
-export default class AllocationRow extends Component {
- @service store;
- @service token;
-
- allocation = null;
-
- // Used to determine whether the row should mention the node or the job
- context = null;
-
- // Internal state
- statsError = false;
-
- @overridable(() => ENV.environment !== 'test') enablePolling;
-
- @computed('allocation', 'allocation.isRunning')
- get stats() {
- if (!this.get('allocation.isRunning')) return undefined;
-
- return AllocationStatsTracker.create({
- fetch: (url) => this.token.authorizedRequest(url),
- allocation: this.allocation,
- });
- }
-
- @computed('stats.cpu.[]')
- get cpu() {
- const cpu = this.stats?.cpu;
- return cpu?.[cpu.length - 1];
- }
-
- @computed('stats.memory.[]')
- get memory() {
- const memory = this.stats?.memory;
- return memory?.[memory.length - 1];
- }
-
- onClick() {}
-
- click(event) {
- lazyClick([this.onClick, event]);
- }
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- this.updateStatsTracker();
- }
-
- updateStatsTracker() {
- const allocation = this.allocation;
-
- if (allocation) {
- scheduleOnce('afterRender', this, qualifyAllocation);
- } else {
- this.fetchStats.cancelAll();
- }
- }
-
- @(task(function* () {
- do {
- if (this.stats) {
- try {
- yield this.get('stats.poll').linked().perform();
- this.set('statsError', false);
- } catch {
- this.set('statsError', true);
- }
- }
-
- yield timeout(500);
- } while (this.enablePolling);
- }).drop())
- fetchStats;
-}
-
-async function qualifyAllocation() {
- const allocation = this.allocation;
-
- // Make sure the allocation is a complete record and not a partial so we
- // can show information such as preemptions and rescheduled allocation.
- if (allocation.isPartial) {
- await this.store.findRecord('allocation', allocation.id, {
- backgroundReload: false,
- });
- }
-
- if (allocation.get('job.isPending')) {
- // Make sure the job is loaded before starting the stats tracker
- await allocation.get('job');
- } else if (!allocation.get('taskGroup')) {
- // Make sure that the job record in the store for this allocation
- // is complete and not a partial from the list endpoint
- const job = allocation.get('job.content');
- if (job.isPartial) await job.reload();
- }
-
- this.fetchStats.perform();
-}
diff --git a/ui/app/components/allocation-service-sidebar.gjs b/ui/app/components/allocation-service-sidebar.gjs
new file mode 100644
index 00000000000..e8b8e797084
--- /dev/null
+++ b/ui/app/components/allocation-service-sidebar.gjs
@@ -0,0 +1,333 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { hash } from '@ember/helper';
+import { eq } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import onClickOutside from 'ember-click-outside/modifiers/on-click-outside';
+import keyboardCommands from 'nomad-ui/helpers/keyboard-commands';
+import Tooltip from 'nomad-ui/components/tooltip';
+import ListTable from 'nomad-ui/components/list-table';
+import ServiceStatusIndicator from 'nomad-ui/components/service-status-indicator';
+
+export default class AllocationServiceSidebar extends Component {
+ @service store;
+ @service system;
+
+ get isSideBarOpen() {
+ return !!this.args.service;
+ }
+
+ get keyCommands() {
+ return [
+ {
+ label: 'Close Service Sidebar',
+ pattern: ['Escape'],
+ action: () => this.args.fns?.closeSidebar?.(),
+ },
+ ];
+ }
+
+ get service() {
+ return this.store.query('service-fragment', { refID: this.args.serviceID });
+ }
+
+ get address() {
+ const port = this.args.allocation?.allocatedResources?.ports?.findBy(
+ 'label',
+ this.args.service?.portLabel,
+ );
+
+ if (port) {
+ return `${port.hostIp}:${port.value}`;
+ }
+
+ return null;
+ }
+
+ get aggregateStatus() {
+ if (this.args.allocation?.clientStatus !== 'running') return 'Unknown';
+ const checks = this.checks?.toArray?.() || this.checks || [];
+ return checks.some((check) => check.Status === 'failure')
+ ? 'Unhealthy'
+ : 'Healthy';
+ }
+
+ get isConsulProvider() {
+ return this.args.service?.provider === 'consul';
+ }
+
+ get showConsulLinkNotice() {
+ return this.isConsulProvider && !!this.consulRedirectLink;
+ }
+
+ get isUnhealthy() {
+ return this.aggregateStatus === 'Unhealthy';
+ }
+
+ get isUnknown() {
+ return this.aggregateStatus === 'Unknown';
+ }
+
+ get consulRedirectLink() {
+ const config =
+ this.system.agent?.config ?? this.system.agent?.get?.('config');
+ return config?.UI?.Consul?.BaseUIURL;
+ }
+
+ get checks() {
+ if (!this.args.service || !this.args.allocation) return [];
+ const allocID = this.args.allocation.id;
+
+ // Our UI checks run every 2 seconds; but a check itself may only update every, say, minute.
+ // Therefore, we'll have duplicate checks in a service's healthChecks array.
+ // Only get the most recent check for each check.
+ return (this.args.service.healthChecks || [])
+ .filterBy('Alloc', allocID)
+ .sortBy('Timestamp')
+ .reverse()
+ .uniqBy('Check')
+ .sortBy('Check');
+ }
+
+ checksForName = (checkName) => {
+ const checks = (this.args.service?.healthChecks || []).filter(
+ (check) => check.Check === checkName,
+ );
+ const seenTimestamps = new Set();
+
+ return checks.filter((check) => {
+ if (seenTimestamps.has(check.Timestamp)) {
+ return false;
+ }
+
+ seenTimestamps.add(check.Timestamp);
+ return true;
+ });
+ };
+
+
+
+
+}
diff --git a/ui/app/components/allocation-service-sidebar.hbs b/ui/app/components/allocation-service-sidebar.hbs
deleted file mode 100644
index cfdd2ae0495..00000000000
--- a/ui/app/components/allocation-service-sidebar.hbs
+++ /dev/null
@@ -1,222 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/allocation-service-sidebar.js b/ui/app/components/allocation-service-sidebar.js
deleted file mode 100644
index 7f38bde26fc..00000000000
--- a/ui/app/components/allocation-service-sidebar.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class AllocationServiceSidebarComponent extends Component {
- @service store;
- @service system;
-
- get isSideBarOpen() {
- return !!this.args.service;
- }
- keyCommands = [
- {
- label: 'Close Service Sidebar',
- pattern: ['Escape'],
- action: () => this.args.fns.closeSidebar(),
- },
- ];
-
- get service() {
- return this.store.query('service-fragment', { refID: this.args.serviceID });
- }
-
- get address() {
- const port = this.args.allocation?.allocatedResources?.ports?.findBy(
- 'label',
- this.args.service.portLabel,
- );
- if (port) {
- return `${port.hostIp}:${port.value}`;
- } else {
- return null;
- }
- }
-
- get aggregateStatus() {
- if (this.args.allocation?.clientStatus !== 'running') return 'Unknown';
- const checks = this.checks?.toArray?.() || this.checks || [];
- return checks.some((check) => check.Status === 'failure')
- ? 'Unhealthy'
- : 'Healthy';
- }
-
- get consulRedirectLink() {
- return this.system.agent.get('config')?.UI?.Consul?.BaseUIURL;
- }
-
- get checks() {
- if (!this.args.service || !this.args.allocation) return [];
- let allocID = this.args.allocation.id;
- // Our UI checks run every 2 seconds; but a check itself may only update every, say, minute.
- // Therefore, we'll have duplicate checks in a service's healthChecks array.
- // Only get the most recent check for each check.
- return (this.args.service.healthChecks || [])
- .filterBy('Alloc', allocID)
- .sortBy('Timestamp')
- .reverse()
- .uniqBy('Check')
- .sortBy('Check');
- }
-}
diff --git a/ui/app/components/allocation-stat.gjs b/ui/app/components/allocation-stat.gjs
new file mode 100644
index 00000000000..7be21c7d120
--- /dev/null
+++ b/ui/app/components/allocation-stat.gjs
@@ -0,0 +1,87 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { and, not } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
+
+export default class AllocationStat extends Component {
+ get metric() {
+ return this.args.metric || 'memory'; // Either memory or cpu
+ }
+
+ get statClass() {
+ return this.metric === 'cpu' ? 'is-info' : 'is-danger';
+ }
+
+ get cpu() {
+ const cpu = this.args.statsTracker?.cpu;
+ return cpu?.[cpu.length - 1];
+ }
+
+ get memory() {
+ const memory = this.args.statsTracker?.memory;
+ return memory?.[memory.length - 1];
+ }
+
+ get stat() {
+ const metric = this.metric;
+ if (metric === 'cpu' || metric === 'memory') {
+ return this[metric];
+ }
+
+ return undefined;
+ }
+
+ get formattedStat() {
+ if (!this.stat) return undefined;
+ if (this.metric === 'memory') return formatBytes(this.stat.used);
+ if (this.metric === 'cpu') return formatHertz(this.stat.used, 'MHz');
+ return undefined;
+ }
+
+ get formattedReserved() {
+ if (this.metric === 'memory')
+ return formatBytes(this.args.statsTracker?.reservedMemory, 'MiB');
+ if (this.metric === 'cpu')
+ return formatHertz(this.args.statsTracker?.reservedCPU, 'MHz');
+ return undefined;
+ }
+
+
+ {{#if @allocation.isRunning}}
+ {{#if (and (not this.stat) @isLoading)}}
+ …
+ {{else if @error}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/if}}
+
+}
diff --git a/ui/app/components/allocation-stat.hbs b/ui/app/components/allocation-stat.hbs
deleted file mode 100644
index 2890ca15f82..00000000000
--- a/ui/app/components/allocation-stat.hbs
+++ /dev/null
@@ -1,36 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.allocation.isRunning}}
- {{#if (and (not this.stat) this.isLoading)}}
- …
- {{else if this.error}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/allocation-stat.js b/ui/app/components/allocation-stat.js
deleted file mode 100644
index 41066869f37..00000000000
--- a/ui/app/components/allocation-stat.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class AllocationStat extends Component {
- allocation = null;
- statsTracker = null;
- isLoading = false;
- error = null;
- metric = 'memory'; // Either memory or cpu
-
- @computed('metric')
- get statClass() {
- return this.metric === 'cpu' ? 'is-info' : 'is-danger';
- }
-
- @computed('statsTracker.cpu.[]')
- get cpu() {
- const cpu = this.statsTracker?.cpu;
- return cpu?.[cpu.length - 1];
- }
-
- @computed('statsTracker.memory.[]')
- get memory() {
- const memory = this.statsTracker?.memory;
- return memory?.[memory.length - 1];
- }
-
- @computed('metric', 'cpu', 'memory')
- get stat() {
- const { metric } = this;
- if (metric === 'cpu' || metric === 'memory') {
- return this[this.metric];
- }
-
- return undefined;
- }
-
- @computed('metric', 'stat.used')
- get formattedStat() {
- if (!this.stat) return undefined;
- if (this.metric === 'memory') return formatBytes(this.stat.used);
- if (this.metric === 'cpu') return formatHertz(this.stat.used, 'MHz');
- return undefined;
- }
-
- @computed('metric', 'statsTracker.{reservedMemory,reservedCPU}')
- get formattedReserved() {
- if (this.metric === 'memory')
- return formatBytes(this.statsTracker.reservedMemory, 'MiB');
- if (this.metric === 'cpu')
- return formatHertz(this.statsTracker.reservedCPU, 'MHz');
- return undefined;
- }
-}
diff --git a/ui/app/components/allocation-status-bar.js b/ui/app/components/allocation-status-bar.gjs
similarity index 57%
rename from ui/app/components/allocation-status-bar.js
rename to ui/app/components/allocation-status-bar.gjs
index 527e0e21049..cc085276c86 100644
--- a/ui/app/components/allocation-status-bar.js
+++ b/ui/app/components/allocation-status-bar.gjs
@@ -3,22 +3,15 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { computed } from '@ember/object';
-import DistributionBar from './distribution-bar';
-import { attributeBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
+import Component from '@glimmer/component';
+import DistributionBar from 'nomad-ui/components/distribution-bar';
-@classic
-@attributeBindings('data-test-allocation-status-bar')
-export default class AllocationStatusBar extends DistributionBar {
- layoutName = 'components/distribution-bar';
-
- allocationContainer = null;
- job = null;
-
- 'data-test-allocation-status-bar' = true;
+export default class AllocationStatusBar extends Component {
+ get container() {
+ return this.args.allocationContainer;
+ }
- generateLegendLink(job, status) {
+ generateLegendLink = (job, status) => {
if (!job || status === 'queued') return null;
const namespace = job.namespaceId || job.namespace;
@@ -42,18 +35,14 @@ export default class AllocationStatusBar extends DistributionBar {
return {
queryParams,
};
- }
+ };
- @computed(
- 'allocationContainer.{queuedAllocs,completeAllocs,failedAllocs,runningAllocs,startingAllocs,lostAllocs,unknownAllocs}',
- 'job.namespace',
- )
get data() {
- if (!this.allocationContainer) {
+ if (!this.container) {
return [];
}
- const allocs = this.allocationContainer.getProperties(
+ const allocs = this.container.getProperties(
'queuedAllocs',
'completeAllocs',
'failedAllocs',
@@ -62,51 +51,66 @@ export default class AllocationStatusBar extends DistributionBar {
'lostAllocs',
'unknownAllocs',
);
+
return [
{
label: 'Queued',
value: allocs.queuedAllocs,
className: 'queued',
- legendLink: this.generateLegendLink(this.job, 'queued'),
+ legendLink: this.generateLegendLink(this.args.job, 'queued'),
},
{
label: 'Starting',
value: allocs.startingAllocs,
className: 'starting',
layers: 2,
- legendLink: this.generateLegendLink(this.job, 'pending'),
+ legendLink: this.generateLegendLink(this.args.job, 'pending'),
},
{
label: 'Running',
value: allocs.runningAllocs,
className: 'running',
- legendLink: this.generateLegendLink(this.job, 'running'),
+ legendLink: this.generateLegendLink(this.args.job, 'running'),
},
{
label: 'Complete',
value: allocs.completeAllocs,
className: 'complete',
- legendLink: this.generateLegendLink(this.job, 'complete'),
+ legendLink: this.generateLegendLink(this.args.job, 'complete'),
},
{
label: 'Unknown',
value: allocs.unknownAllocs,
className: 'unknown',
- legendLink: this.generateLegendLink(this.job, 'unknown'),
+ legendLink: this.generateLegendLink(this.args.job, 'unknown'),
help: 'Allocation is unknown since its node is disconnected.',
},
{
label: 'Failed',
value: allocs.failedAllocs,
className: 'failed',
- legendLink: this.generateLegendLink(this.job, 'failed'),
+ legendLink: this.generateLegendLink(this.args.job, 'failed'),
},
{
label: 'Lost',
value: allocs.lostAllocs,
className: 'lost',
- legendLink: this.generateLegendLink(this.job, 'lost'),
+ legendLink: this.generateLegendLink(this.args.job, 'lost'),
},
];
}
+
+
+
+ {{yield chart}}
+
+
}
diff --git a/ui/app/components/allocation-subnav.gjs b/ui/app/components/allocation-subnav.gjs
new file mode 100644
index 00000000000..4ac43f35dac
--- /dev/null
+++ b/ui/app/components/allocation-subnav.gjs
@@ -0,0 +1,51 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class AllocationSubnav extends Component {
+ @service router;
+ @service keyboard;
+
+ get filesLinkActive() {
+ return [
+ 'allocations.allocation.fs',
+ 'allocations.allocation.fs-root',
+ ].includes(this.router.currentRouteName);
+ }
+
+
+
+
+}
diff --git a/ui/app/components/allocation-subnav.hbs b/ui/app/components/allocation-subnav.hbs
deleted file mode 100644
index 5afed2c3ae1..00000000000
--- a/ui/app/components/allocation-subnav.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/allocation-subnav.js b/ui/app/components/allocation-subnav.js
deleted file mode 100644
index 0a4c8282135..00000000000
--- a/ui/app/components/allocation-subnav.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { equal, or } from '@ember/object/computed';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class AllocationSubnav extends Component {
- @service router;
- @service keyboard;
-
- @equal('router.currentRouteName', 'allocations.allocation.fs')
- fsIsActive;
-
- @equal('router.currentRouteName', 'allocations.allocation.fs-root')
- fsRootIsActive;
-
- @or('fsIsActive', 'fsRootIsActive') filesLinkActive;
-}
diff --git a/ui/app/components/app-breadcrumbs.gjs b/ui/app/components/app-breadcrumbs.gjs
new file mode 100644
index 00000000000..74dcbcb5562
--- /dev/null
+++ b/ui/app/components/app-breadcrumbs.gjs
@@ -0,0 +1,38 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import Breadcrumbs from 'nomad-ui/components/breadcrumbs';
+import BreadcrumbsDefault from 'nomad-ui/components/breadcrumbs/default';
+import BreadcrumbsJob from 'nomad-ui/components/breadcrumbs/job';
+
+const isJobType = (type) => type === 'job';
+
+export default class AppBreadcrumbs extends Component {
+ isOneCrumbUp = (iter = 0, totalNum = 0) => {
+ return iter === totalNum - 2;
+ };
+
+
+
+ {{#each breadcrumbs as |crumb iter|}}
+ {{#let crumb.args.crumb as |c|}}
+ {{#if (isJobType c.type)}}
+
+ {{else}}
+
+ {{/if}}
+ {{/let}}
+ {{/each}}
+
+
+}
diff --git a/ui/app/components/app-breadcrumbs.hbs b/ui/app/components/app-breadcrumbs.hbs
deleted file mode 100644
index 12856464724..00000000000
--- a/ui/app/components/app-breadcrumbs.hbs
+++ /dev/null
@@ -1,22 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#each breadcrumbs as |crumb iter|}}
- {{#let crumb.args.crumb as |c|}}
- {{#if (eq c.type "job")}}
-
- {{else}}
-
- {{/if}}
- {{/let}}
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/app-breadcrumbs.js b/ui/app/components/app-breadcrumbs.js
deleted file mode 100644
index fa00136752b..00000000000
--- a/ui/app/components/app-breadcrumbs.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class AppBreadcrumbsComponent extends Component {
- @action
- isOneCrumbUp(iter = 0, totalNum = 0) {
- return iter === totalNum - 2;
- }
-}
diff --git a/ui/app/components/attributes-section.gjs b/ui/app/components/attributes-section.gjs
new file mode 100644
index 00000000000..35ae7f1fcea
--- /dev/null
+++ b/ui/app/components/attributes-section.gjs
@@ -0,0 +1,31 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import MetadataKv from 'nomad-ui/components/metadata-kv';
+
+const AttributesSection =
+ {{#each @attributes.files as |file|}}
+
+ {{/each}}
+ {{#each-in @attributes.children as |key value|}}
+
+ {{/each-in}}
+;
+
+export default AttributesSection;
diff --git a/ui/app/components/attributes-section.hbs b/ui/app/components/attributes-section.hbs
deleted file mode 100644
index 3798e12e7b7..00000000000
--- a/ui/app/components/attributes-section.hbs
+++ /dev/null
@@ -1,25 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#each this.attributes.files as |file|}}
-
-{{/each}}
-{{#each-in this.attributes.children as |key value|}}
-
-{{/each-in}}
\ No newline at end of file
diff --git a/ui/app/components/attributes-section.js b/ui/app/components/attributes-section.js
deleted file mode 100644
index 6c6a83c69b3..00000000000
--- a/ui/app/components/attributes-section.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class AttributesSection extends Component {}
diff --git a/ui/app/components/attributes-table.gjs b/ui/app/components/attributes-table.gjs
new file mode 100644
index 00000000000..16acc018a7b
--- /dev/null
+++ b/ui/app/components/attributes-table.gjs
@@ -0,0 +1,31 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import AttributesSection from 'nomad-ui/components/attributes-section';
+
+export const AttributesTable =
+
+
+
+ Name
+ Value
+
+
+
+
+
+
+;
+
+export default AttributesTable;
diff --git a/ui/app/components/attributes-table.hbs b/ui/app/components/attributes-table.hbs
deleted file mode 100644
index ecd3b32da84..00000000000
--- a/ui/app/components/attributes-table.hbs
+++ /dev/null
@@ -1,25 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- Name
- Value
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/breadcrumb.js b/ui/app/components/breadcrumb.gjs
similarity index 87%
rename from ui/app/components/breadcrumb.js
rename to ui/app/components/breadcrumb.gjs
index db884bdf73e..601ad81b670 100644
--- a/ui/app/components/breadcrumb.js
+++ b/ui/app/components/breadcrumb.gjs
@@ -4,7 +4,6 @@
*/
import { assert } from '@ember/debug';
-import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
@@ -17,11 +16,11 @@ export default class Breadcrumb extends Component {
this.register();
}
- @action register() {
+ register() {
this.breadcrumbs.registerBreadcrumb(this);
}
- @action deregister() {
+ deregister() {
this.breadcrumbs.deregisterBreadcrumb(this);
}
diff --git a/ui/app/components/breadcrumbs.js b/ui/app/components/breadcrumbs.gjs
similarity index 81%
rename from ui/app/components/breadcrumbs.js
rename to ui/app/components/breadcrumbs.gjs
index 10aee1482eb..8135d05e719 100644
--- a/ui/app/components/breadcrumbs.js
+++ b/ui/app/components/breadcrumbs.gjs
@@ -9,7 +9,5 @@ import { service } from '@ember/service';
export default class Breadcrumbs extends Component {
@service breadcrumbs;
- get crumbs() {
- return this.breadcrumbs.crumbs;
- }
+ {{yield this.breadcrumbs.crumbs}}
}
diff --git a/ui/app/components/breadcrumbs.hbs b/ui/app/components/breadcrumbs.hbs
deleted file mode 100644
index ae5213e578e..00000000000
--- a/ui/app/components/breadcrumbs.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield this.crumbs}}
\ No newline at end of file
diff --git a/ui/app/components/breadcrumbs/default.gjs b/ui/app/components/breadcrumbs/default.gjs
new file mode 100644
index 00000000000..0d692c52fb4
--- /dev/null
+++ b/ui/app/components/breadcrumbs/default.gjs
@@ -0,0 +1,95 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class BreadcrumbsTemplate extends Component {
+ @service router;
+
+ shortcutPattern = ['u'];
+
+ /**
+ * The route name extracted from the crumb args array.
+ * @returns {string}
+ */
+ get route() {
+ return this.args.crumb?.args?.[0];
+ }
+
+ /**
+ * The dynamic segments (models) for the route, extracted from the crumb args array.
+ * @returns {Array}
+ */
+ get models() {
+ return this.args.crumb?.args?.slice(1) ?? [];
+ }
+
+ get isOneCrumbUp() {
+ return this.args.isOneCrumbUp?.() ?? false;
+ }
+
+ traverseUpALevel = () => {
+ this.router.transitionTo(this.route, ...this.models);
+ };
+
+
+ {{#if this.isOneCrumbUp}}
+
+
+ {{#if @crumb.title}}
+
+ -
+ {{@crumb.title}}
+
+ -
+ {{@crumb.label}}
+
+
+ {{else}}
+ {{@crumb.label}}
+ {{/if}}
+
+
+ {{else}}
+
+
+ {{#if @crumb.title}}
+
+ -
+ {{@crumb.title}}
+
+ -
+ {{@crumb.label}}
+
+
+ {{else}}
+ {{@crumb.label}}
+ {{/if}}
+
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/breadcrumbs/default.hbs b/ui/app/components/breadcrumbs/default.hbs
deleted file mode 100644
index 2eed07a83b6..00000000000
--- a/ui/app/components/breadcrumbs/default.hbs
+++ /dev/null
@@ -1,35 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#if @crumb.title}}
-
- -
- {{@crumb.title}}
-
- -
- {{@crumb.label}}
-
-
- {{else}}
- {{@crumb.label}}
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/breadcrumbs/default.js b/ui/app/components/breadcrumbs/default.js
deleted file mode 100644
index bc7349800f5..00000000000
--- a/ui/app/components/breadcrumbs/default.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
-import { service } from '@ember/service';
-
-export default class BreadcrumbsTemplate extends Component {
- @service router;
-
- /**
- * The route name extracted from the crumb args array.
- * @returns {string}
- */
- get route() {
- return this.args.crumb?.args?.[0];
- }
-
- /**
- * The dynamic segments (models) for the route, extracted from the crumb args array.
- * @returns {Array}
- */
- get models() {
- return this.args.crumb?.args?.slice(1) ?? [];
- }
-
- @action
- traverseUpALevel() {
- this.router.transitionTo(this.route, ...this.models);
- }
-
- get maybeKeyboardShortcut() {
- return this.args.isOneCrumbUp() ? KeyboardShortcutModifier : null;
- }
-}
diff --git a/ui/app/components/breadcrumbs/job.gjs b/ui/app/components/breadcrumbs/job.gjs
new file mode 100644
index 00000000000..23cae4faa1f
--- /dev/null
+++ b/ui/app/components/breadcrumbs/job.gjs
@@ -0,0 +1,121 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { assert } from '@ember/debug';
+import { LinkTo } from '@ember/routing';
+
+import { didInsert } from '@ember/render-modifiers';
+import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+import Trigger from 'nomad-ui/components/trigger';
+import BreadcrumbsTemplate from 'nomad-ui/components/breadcrumbs/default';
+
+export default class BreadcrumbsJob extends BreadcrumbsTemplate {
+ shortcutPattern = ['u'];
+
+ get job() {
+ return this.args.crumb.job;
+ }
+
+ get hasParent() {
+ return !!this.job.belongsTo('parent').id();
+ }
+
+ traverseUpALevel = () => {
+ this.router.transitionTo('jobs.job', this.job.idWithNamespace);
+ };
+
+ onError = (err) => {
+ assert(`Error: ${err.message}`);
+ };
+
+ fetchParent = () => {
+ if (this.hasParent) {
+ return this.job.get('parent');
+ }
+ };
+
+
+
+
+
+ {{#if trigger.data.isBusy}}
+
+
+ ...
+
+
+ {{/if}}
+
+ {{#if trigger.data.isSuccess}}
+ {{#if trigger.data.result}}
+ {{#if this.hasParent}}
+
+
+
+ -
+ Parent Job
+
+ -
+ {{trigger.data.result.trimmedName}}
+
+
+
+
+ {{/if}}
+ {{/if}}
+
+ {{#if this.isOneCrumbUp}}
+
+
+
+ -
+ {{if this.job.hasChildren "Parent Job" "Job"}}
+
+ -
+ {{this.job.trimmedName}}
+
+
+
+
+ {{else}}
+
+
+
+ -
+ {{if this.job.hasChildren "Parent Job" "Job"}}
+
+ -
+ {{this.job.trimmedName}}
+
+
+
+
+ {{/if}}
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/breadcrumbs/job.hbs b/ui/app/components/breadcrumbs/job.hbs
deleted file mode 100644
index 2b99b63aaa2..00000000000
--- a/ui/app/components/breadcrumbs/job.hbs
+++ /dev/null
@@ -1,61 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{did-insert-helper trigger.fns.do}}
- {{#if trigger.data.isBusy}}
-
-
- …
-
-
- {{/if}}
- {{#if trigger.data.isSuccess}}
- {{#if (and trigger.data.result this.hasParent)}}
-
-
-
- -
- Parent Job
-
- -
- {{trigger.data.result.trimmedName}}
-
-
-
-
- {{/if}}
-
-
-
- -
- {{if this.job.hasChildren "Parent Job" "Job"}}
-
- -
- {{this.job.trimmedName}}
-
-
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/breadcrumbs/job.js b/ui/app/components/breadcrumbs/job.js
deleted file mode 100644
index 4e629c3b30d..00000000000
--- a/ui/app/components/breadcrumbs/job.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { assert } from '@ember/debug';
-import { action } from '@ember/object';
-import BreadcrumbsTemplate from './default';
-
-export default class BreadcrumbsJob extends BreadcrumbsTemplate {
- get job() {
- return this.args.crumb.job;
- }
-
- get hasParent() {
- return !!this.job.belongsTo('parent').id();
- }
-
- @action
- traverseUpALevel() {
- this.router.transitionTo('jobs.job', this.job.idWithNamespace);
- }
-
- @action
- onError(err) {
- assert(`Error: ${err.message}`);
- }
-
- @action
- fetchParent() {
- if (this.hasParent) {
- return this.job.get('parent');
- }
- }
-}
diff --git a/ui/app/components/chart-primitives/area.js b/ui/app/components/chart-primitives/area.gjs
similarity index 55%
rename from ui/app/components/chart-primitives/area.js
rename to ui/app/components/chart-primitives/area.gjs
index 17ae7974e90..f52ab89da00 100644
--- a/ui/app/components/chart-primitives/area.js
+++ b/ui/app/components/chart-primitives/area.gjs
@@ -5,8 +5,9 @@
import Component from '@glimmer/component';
import { assert } from '@ember/debug';
+import { concat } from '@ember/helper';
import { default as d3Shape, area, line } from 'd3-shape';
-import uniquely from 'nomad-ui/utils/properties/uniquely';
+import { guidFor } from '@ember/object/internals';
export default class ChartPrimitiveArea extends Component {
get colorClass() {
@@ -18,8 +19,13 @@ export default class ChartPrimitiveArea extends Component {
return 'is-primary';
}
- @uniquely('area-mask') maskId;
- @uniquely('area-fill') fillId;
+ get maskId() {
+ return `area-mask-${guidFor(this)}`;
+ }
+
+ get fillId() {
+ return `area-fill-${guidFor(this)}`;
+ }
get curveMethod() {
const mappings = {
@@ -27,7 +33,7 @@ export default class ChartPrimitiveArea extends Component {
stepAfter: 'curveStepAfter',
};
assert(
- `Provided curve "${this.curve}" is not an allowed curve type`,
+ `Provided curve "${this.args.curve}" is not an allowed curve type`,
mappings[this.args.curve],
);
return mappings[this.args.curve];
@@ -57,4 +63,39 @@ export default class ChartPrimitiveArea extends Component {
return builder(this.args.data);
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
}
diff --git a/ui/app/components/chart-primitives/area.hbs b/ui/app/components/chart-primitives/area.hbs
deleted file mode 100644
index 520ffbe059f..00000000000
--- a/ui/app/components/chart-primitives/area.hbs
+++ /dev/null
@@ -1,33 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/chart-primitives/h-annotations.js b/ui/app/components/chart-primitives/h-annotations.gjs
similarity index 55%
rename from ui/app/components/chart-primitives/h-annotations.js
rename to ui/app/components/chart-primitives/h-annotations.gjs
index 921ca62bd5e..2f82e93c906 100644
--- a/ui/app/components/chart-primitives/h-annotations.js
+++ b/ui/app/components/chart-primitives/h-annotations.gjs
@@ -5,10 +5,12 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
-import { action, get } from '@ember/object';
+import { get } from '@ember/object';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
-export default class ChartPrimitiveVAnnotations extends Component {
+export default class ChartPrimitiveHAnnotations extends Component {
@styleString
get chartAnnotationsStyle() {
return {
@@ -22,7 +24,7 @@ export default class ChartPrimitiveVAnnotations extends Component {
if (!annotations || !annotations.length) return null;
- let sortedAnnotations = annotations.sortBy(prop).reverse();
+ const sortedAnnotations = annotations.sortBy(prop).reverse();
return sortedAnnotations.map((annotation) => {
const y = scale(annotation[prop]);
@@ -47,8 +49,34 @@ export default class ChartPrimitiveVAnnotations extends Component {
return annotation === activeAnnotation;
}
- @action
- selectAnnotation(annotation) {
+ selectAnnotation = (annotation) => {
if (this.args.annotationClick) this.args.annotationClick(annotation);
- }
+ };
+
+
+
+ {{#each this.processed key=@key as |annotation|}}
+
+
+
+
+ {{/each}}
+
+
}
diff --git a/ui/app/components/chart-primitives/h-annotations.hbs b/ui/app/components/chart-primitives/h-annotations.hbs
deleted file mode 100644
index bd56df272ff..00000000000
--- a/ui/app/components/chart-primitives/h-annotations.hbs
+++ /dev/null
@@ -1,29 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#each this.processed key=@key as |annotation|}}
-
-
-
-
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/chart-primitives/tooltip.gjs b/ui/app/components/chart-primitives/tooltip.gjs
new file mode 100644
index 00000000000..55c4cd43d91
--- /dev/null
+++ b/ui/app/components/chart-primitives/tooltip.gjs
@@ -0,0 +1,23 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { inc } from '@nullvoxpopuli/ember-composable-helpers';
+
+export const ChartPrimitivesTooltip =
+
+
+ {{#each @data as |props|}}
+ {{yield props.series props.datum (inc props.index)}}
+ {{/each}}
+
+
+;
+
+export default ChartPrimitivesTooltip;
diff --git a/ui/app/components/chart-primitives/tooltip.hbs b/ui/app/components/chart-primitives/tooltip.hbs
deleted file mode 100644
index cf986c0c91b..00000000000
--- a/ui/app/components/chart-primitives/tooltip.hbs
+++ /dev/null
@@ -1,17 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#each @data as |props|}}
- {{yield props.series props.datum (inc props.index)}}
- {{/each}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/chart-primitives/v-annotations.js b/ui/app/components/chart-primitives/v-annotations.gjs
similarity index 64%
rename from ui/app/components/chart-primitives/v-annotations.js
rename to ui/app/components/chart-primitives/v-annotations.gjs
index 699f2825d3a..a9900190ede 100644
--- a/ui/app/components/chart-primitives/v-annotations.js
+++ b/ui/app/components/chart-primitives/v-annotations.gjs
@@ -5,7 +5,10 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
-import { action, get } from '@ember/object';
+import { get } from '@ember/object';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
const iconFor = {
@@ -69,8 +72,36 @@ export default class ChartPrimitiveVAnnotations extends Component {
return annotation === activeAnnotation;
}
- @action
- selectAnnotation(annotation) {
+ selectAnnotation = (annotation) => {
if (this.args.annotationClick) this.args.annotationClick(annotation);
- }
+ };
+
+
+
+ {{#each this.processed key=@key as |annotation|}}
+
+
+
+
+ {{/each}}
+
+
}
diff --git a/ui/app/components/chart-primitives/v-annotations.hbs b/ui/app/components/chart-primitives/v-annotations.hbs
deleted file mode 100644
index 9326b18aa18..00000000000
--- a/ui/app/components/chart-primitives/v-annotations.hbs
+++ /dev/null
@@ -1,31 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#each this.processed key=@key as |annotation|}}
-
-
-
-
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/child-job-row.gjs b/ui/app/components/child-job-row.gjs
new file mode 100644
index 00000000000..085407646f7
--- /dev/null
+++ b/ui/app/components/child-job-row.gjs
@@ -0,0 +1,79 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { capitalize } from '@ember/string';
+import { fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import {
+ HdsBadge,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import JobStatusAllocationStatusRow from 'nomad-ui/components/job-status/allocation-status-row';
+
+export default class ChildJobRow extends Component {
+ @service router;
+
+ gotoJob = () => {
+ const { job } = this.args;
+ this.router.transitionTo('jobs.job.index', job.idWithNamespace);
+ };
+
+ get statusText() {
+ return capitalize(this.args.job?.aggregateAllocStatus?.label || '');
+ }
+
+
+
+
+
+ {{@job.name}}
+
+ {{#if @job.isPack}}
+
+
+ Pack
+
+ {{/if}}
+
+
+
+
+ {{formatMonthTs @job.submitTime}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/child-job-row.hbs b/ui/app/components/child-job-row.hbs
deleted file mode 100644
index ee8b7a9bb47..00000000000
--- a/ui/app/components/child-job-row.hbs
+++ /dev/null
@@ -1,51 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{@job.name}}
-
- {{#if @job.isPack}}
-
-
- Pack
-
- {{/if}}
-
-
-
-
- {{format-month-ts @job.submitTime}}
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/child-job-row.js b/ui/app/components/child-job-row.js
deleted file mode 100644
index 0e48440f3aa..00000000000
--- a/ui/app/components/child-job-row.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { lazyClick } from '../helpers/lazy-click';
-
-export default class ChildJobRowComponent extends Component {
- @service router;
-
- click(event) {
- lazyClick([this.gotoJob, event]);
- }
-
- @action
- gotoJob() {
- const { job } = this.args;
- this.router.transitionTo('jobs.job.index', job.idWithNamespace);
- }
-}
diff --git a/ui/app/components/children-status-bar.js b/ui/app/components/children-status-bar.gjs
similarity index 53%
rename from ui/app/components/children-status-bar.js
rename to ui/app/components/children-status-bar.gjs
index 29f4db6f9ad..364628c9810 100644
--- a/ui/app/components/children-status-bar.js
+++ b/ui/app/components/children-status-bar.gjs
@@ -3,31 +3,21 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { computed } from '@ember/object';
+import Component from '@glimmer/component';
import DistributionBar from './distribution-bar';
-import classic from 'ember-classic-decorator';
-import { attributeBindings } from '@ember-decorators/component';
-@classic
-@attributeBindings('data-test-children-status-bar')
-export default class ChildrenStatusBar extends DistributionBar {
- layoutName = 'components/distribution-bar';
-
- job = null;
-
- 'data-test-children-status-bar' = true;
-
- @computed('job.{pendingChildren,runningChildren,deadChildren}')
+export default class ChildrenStatusBar extends Component {
get data() {
- if (!this.job) {
+ if (!this.args.job) {
return [];
}
- const children = this.job.getProperties(
+ const children = this.args.job.getProperties(
'pendingChildren',
'runningChildren',
'deadChildren',
);
+
return [
{
label: 'Pending',
@@ -42,4 +32,17 @@ export default class ChildrenStatusBar extends DistributionBar {
{ label: 'Dead', value: children.deadChildren, className: 'complete' },
];
}
+
+
+
+ {{yield chart}}
+
+
}
diff --git a/ui/app/components/client-node-row.gjs b/ui/app/components/client-node-row.gjs
new file mode 100644
index 00000000000..ae1542219b2
--- /dev/null
+++ b/ui/app/components/client-node-row.gjs
@@ -0,0 +1,177 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { macroCondition, isTesting } from '@embroider/macros';
+import { capitalize } from '@ember/string';
+import { didUpdate } from '@ember/render-modifiers';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { watchRelationship } from 'nomad-ui/utils/properties/watch';
+import {
+ HdsBadge,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class ClientNodeRow extends Component {
+ @service store;
+
+ constructor() {
+ super(...arguments);
+
+ if (!macroCondition(isTesting())) {
+ this._visibilityHandler = this.visibilityHandler.bind(this);
+ document.addEventListener('visibilitychange', this._visibilityHandler);
+ }
+
+ this.handleNodeChange();
+ }
+
+ willDestroy() {
+ this.watch.cancelAll();
+ if (!macroCondition(isTesting())) {
+ document.removeEventListener('visibilitychange', this._visibilityHandler);
+ }
+ super.willDestroy(...arguments);
+ }
+
+ click = (event) => {
+ lazyClick([this.args.onClick, event]);
+ };
+
+ handleNodeChange = () => {
+ const node = this.args.node;
+ if (node) {
+ node.reload().then(() => {
+ this.watch.perform(node, 100);
+ });
+ }
+ };
+
+ visibilityHandler() {
+ if (document.hidden) {
+ this.watch.cancelAll();
+ } else {
+ const node = this.args.node;
+ if (node) {
+ this.watch.perform(node, 100);
+ }
+ }
+ }
+
+ @watchRelationship('allocations') watch;
+
+ get nodeStatusColor() {
+ const status = this.args.node?.status;
+ if (status === 'disconnected') {
+ return 'warning';
+ } else if (status === 'down') {
+ return 'critical';
+ } else if (status === 'ready') {
+ return 'success';
+ } else if (status === 'initializing') {
+ return 'neutral';
+ }
+
+ return 'neutral';
+ }
+
+ get nodeStatusIcon() {
+ const status = this.args.node?.status;
+ if (status === 'disconnected') {
+ return 'skip';
+ } else if (status === 'down') {
+ return 'x-circle';
+ } else if (status === 'ready') {
+ return 'check-circle';
+ } else if (status === 'initializing') {
+ return 'entry-point';
+ }
+
+ return '';
+ }
+
+ get nodeStatusText() {
+ return capitalize(this.args.node?.status || '');
+ }
+
+
+
+
+ {{#if @node.unhealthyDrivers.length}}
+
+
+
+ {{/if}}
+
+ {{@node.shortId}}
+ {{@node.name}}
+
+
+
+ {{#if @node.isEligible}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{#if @node.isDraining}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{@node.httpAddr}}
+
+ {{#if @node.nodePool}}{{@node.nodePool}}{{else}}-{{/if}}
+
+ {{@node.datacenter}}
+ {{@node.version}}
+ {{if
+ @node.hostVolumes.length
+ @node.hostVolumes.length
+ }}
+
+ {{#if @node.allocations.isPending}}
+ ...
+ {{else}}
+ {{@node.runningAllocations.length}}
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/client-node-row.hbs b/ui/app/components/client-node-row.hbs
deleted file mode 100644
index 2e4ff457e02..00000000000
--- a/ui/app/components/client-node-row.hbs
+++ /dev/null
@@ -1,71 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.node.unhealthyDrivers.length}}
-
-
-
- {{/if}}
-
-{{this.node.shortId}}
-{{this.node.name}}
-
-
-
- {{#if this.node.isEligible}}
-
- {{else}}
-
- {{/if}}
-
- {{#if this.node.isDraining}}
-
- {{else}}
-
- {{/if}}
-
-{{this.node.httpAddr}}
-
- {{#if this.node.nodePool}}{{this.node.nodePool}}{{else}}-{{/if}}
-
-{{this.node.datacenter}}
-{{this.node.version}}
-{{if
- this.node.hostVolumes.length
- this.node.hostVolumes.length
- }}
-
- {{#if this.node.allocations.isPending}}
- ...
- {{else}}
- {{this.node.runningAllocations.length}}
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/client-node-row.js b/ui/app/components/client-node-row.js
deleted file mode 100644
index c0758bd1a03..00000000000
--- a/ui/app/components/client-node-row.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { lazyClick } from '../helpers/lazy-click';
-import { watchRelationship } from 'nomad-ui/utils/properties/watch';
-import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection';
-import { computed } from '@ember/object';
-import { classNames, tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tr')
-@classNames('client-node-row', 'is-interactive')
-export default class ClientNodeRow extends Component.extend(
- WithVisibilityDetection,
-) {
- @service store;
-
- node = null;
-
- onClick() {}
-
- click(event) {
- lazyClick([this.onClick, event]);
- }
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- // Reload the node in order to get detail information
- const node = this.node;
- if (node) {
- node.reload().then(() => {
- this.watch.perform(node, 100);
- });
- }
- }
-
- visibilityHandler() {
- if (document.hidden) {
- this.watch.cancelAll();
- } else {
- const node = this.node;
- if (node) {
- this.watch.perform(node, 100);
- }
- }
- }
-
- willDestroy() {
- this.watch.cancelAll();
- super.willDestroy(...arguments);
- }
-
- @watchRelationship('allocations') watch;
-
- @computed('node.status')
- get nodeStatusColor() {
- let status = this.get('node.status');
- if (status === 'disconnected') {
- return 'warning';
- } else if (status === 'down') {
- return 'critical';
- } else if (status === 'ready') {
- return 'success';
- } else if (status === 'initializing') {
- return 'neutral';
- } else {
- return 'neutral';
- }
- }
- @computed('node.status')
- get nodeStatusIcon() {
- let status = this.get('node.status');
- if (status === 'disconnected') {
- return 'skip';
- } else if (status === 'down') {
- return 'x-circle';
- } else if (status === 'ready') {
- return 'check-circle';
- } else if (status === 'initializing') {
- return 'entry-point';
- } else {
- return '';
- }
- }
-}
diff --git a/ui/app/components/client-subnav.gjs b/ui/app/components/client-subnav.gjs
new file mode 100644
index 00000000000..9f508868b92
--- /dev/null
+++ b/ui/app/components/client-subnav.gjs
@@ -0,0 +1,43 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class ClientSubnav extends Component {
+ @service keyboard;
+
+
+
+
+}
diff --git a/ui/app/components/client-subnav.hbs b/ui/app/components/client-subnav.hbs
deleted file mode 100644
index 459250af68c..00000000000
--- a/ui/app/components/client-subnav.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/client-subnav.js b/ui/app/components/client-subnav.js
deleted file mode 100644
index 4f98873f69f..00000000000
--- a/ui/app/components/client-subnav.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import { service } from '@ember/service';
-
-@tagName('')
-export default class ClientSubnav extends Component {
- @service keyboard;
-}
diff --git a/ui/app/components/conditional-link-to.gjs b/ui/app/components/conditional-link-to.gjs
new file mode 100644
index 00000000000..4558a22da77
--- /dev/null
+++ b/ui/app/components/conditional-link-to.gjs
@@ -0,0 +1,61 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { LinkTo } from '@ember/routing';
+import { HdsTooltipButton } from '@hashicorp/design-system-components/components';
+import hdsTooltip from '@hashicorp/design-system-components/modifiers/hds-tooltip';
+
+export default class ConditionalLinkTo extends Component {
+ get query() {
+ return this.args.query || {};
+ }
+
+ get tooltipText() {
+ return this.args.tooltip?.text || '';
+ }
+
+
+ {{#if @condition}}
+ {{#if @tooltip}}
+
+ {{yield}}
+
+ {{else}}
+
+ {{yield}}
+
+ {{/if}}
+ {{else}}
+ {{#if @tooltip}}
+
+
+ {{yield}}
+
+
+ {{else}}
+
+ {{yield}}
+
+ {{/if}}
+ {{/if}}
+
+}
diff --git a/ui/app/components/conditional-link-to.hbs b/ui/app/components/conditional-link-to.hbs
deleted file mode 100644
index 30fc5b19d0a..00000000000
--- a/ui/app/components/conditional-link-to.hbs
+++ /dev/null
@@ -1,44 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if @condition}}
- {{#if @tooltip}}
-
- {{yield}}
-
- {{else}}
-
- {{yield}}
-
- {{/if}}
-{{else}}
- {{#if @tooltip}}
-
-
- {{yield}}
-
-
- {{else}}
-
- {{yield}}
-
- {{/if}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/conditional-link-to.js b/ui/app/components/conditional-link-to.js
deleted file mode 100644
index e1f324f3956..00000000000
--- a/ui/app/components/conditional-link-to.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-
-export default class ConditionalLinkToComponent extends Component {
- get query() {
- return this.args.query || {};
- }
-}
diff --git a/ui/app/components/copy-button.gjs b/ui/app/components/copy-button.gjs
new file mode 100644
index 00000000000..2cd17594882
--- /dev/null
+++ b/ui/app/components/copy-button.gjs
@@ -0,0 +1,117 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { eq } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import hdsClipboard from '@hashicorp/design-system-components/modifiers/hds-clipboard';
+
+export default class CopyButton extends Component {
+ @tracked state = null;
+ resetTimerId = null;
+
+ get text() {
+ if (typeof this.args.clipboardText === 'function')
+ return this.args.clipboardText;
+ if (typeof this.args.clipboardText === 'string')
+ return this.args.clipboardText;
+
+ return String(this.args.clipboardText);
+ }
+
+ indicateSuccess = () => {
+ this.state = 'success';
+
+ if (this.resetTimerId) {
+ clearTimeout(this.resetTimerId);
+ }
+
+ this.resetTimerId = setTimeout(() => {
+ this.state = null;
+ this.resetTimerId = null;
+ }, 2000);
+ };
+
+ indicateError = () => {
+ this.state = 'error';
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+
+ if (this.resetTimerId) {
+ clearTimeout(this.resetTimerId);
+ this.resetTimerId = null;
+ }
+ }
+
+
+
+
+}
diff --git a/ui/app/components/copy-button.hbs b/ui/app/components/copy-button.hbs
deleted file mode 100644
index cbeb136d194..00000000000
--- a/ui/app/components/copy-button.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/copy-button.js b/ui/app/components/copy-button.js
deleted file mode 100644
index b9b7af94721..00000000000
--- a/ui/app/components/copy-button.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { task, timeout } from 'ember-concurrency';
-
-export default class CopyButton extends Component {
- @tracked state = null;
-
- get text() {
- if (typeof this.args.clipboardText === 'function')
- return this.args.clipboardText;
- if (typeof this.args.clipboardText === 'string')
- return this.args.clipboardText;
-
- return String(this.args.clipboardText);
- }
-
- @(task(function* () {
- this.state = 'success';
-
- yield timeout(2000);
- this.state = null;
- }).restartable())
- indicateSuccess;
-}
diff --git a/ui/app/components/das/accepted.gjs b/ui/app/components/das/accepted.gjs
new file mode 100644
index 00000000000..16060384a96
--- /dev/null
+++ b/ui/app/components/das/accepted.gjs
@@ -0,0 +1,18 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+
+export const DasAccepted =
+
+
+ Recommendation accepted
+ A new version of this job will now be deployed.
+
+
+
+;
+
+export default DasAccepted;
diff --git a/ui/app/components/das/accepted.hbs b/ui/app/components/das/accepted.hbs
deleted file mode 100644
index c8643d298ff..00000000000
--- a/ui/app/components/das/accepted.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Recommendation accepted
- A new version of this job will now be deployed.
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/das/diffs-table.gjs b/ui/app/components/das/diffs-table.gjs
new file mode 100644
index 00000000000..50de4335092
--- /dev/null
+++ b/ui/app/components/das/diffs-table.gjs
@@ -0,0 +1,75 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
+
+export default class DasResourceTotals extends Component {
+ get diffs() {
+ return new ResourcesDiffs(
+ this.args.model,
+ 1,
+ this.args.recommendations,
+ this.args.excludedRecommendations,
+ );
+ }
+
+ get cpuClass() {
+ return classForDelta(this.diffs.cpu.delta);
+ }
+
+ get memoryClass() {
+ return classForDelta(this.diffs.memory.delta);
+ }
+
+
+
+
+
+ Current
+ {{@model.reservedCPU}} MHz
+ {{@model.reservedMemory}} MiB
+ Difference
+ {{this.diffs.cpu.signedDiff}}
+
+ {{this.diffs.memory.signedDiff}}
+
+
+
+ Recommended
+ {{this.diffs.cpu.recommended}}
+ MHz
+
+ {{this.diffs.memory.recommended}}
+ MiB
+
+ % Difference
+
+ {{this.diffs.cpu.percentDiff}}
+
+
+ {{this.diffs.memory.percentDiff}}
+
+
+
+
+
+}
+
+function classForDelta(delta) {
+ if (delta > 0) {
+ return 'increase';
+ } else if (delta < 0) {
+ return 'decrease';
+ }
+
+ return '';
+}
diff --git a/ui/app/components/das/diffs-table.hbs b/ui/app/components/das/diffs-table.hbs
deleted file mode 100644
index 5ff11872555..00000000000
--- a/ui/app/components/das/diffs-table.hbs
+++ /dev/null
@@ -1,41 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- Current
- {{@model.reservedCPU}} MHz
- {{@model.reservedMemory}} MiB
- Difference
- {{this.diffs.cpu.signedDiff}}
- {{this.diffs.memory.signedDiff}}
-
-
- Recommended
- {{this.diffs.cpu.recommended}}
- MHz
- {{this.diffs.memory.recommended}} MiB
- % Difference
- {{this.diffs.cpu.percentDiff}}
- {{this.diffs.memory.percentDiff}}
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/das/diffs-table.js b/ui/app/components/das/diffs-table.js
deleted file mode 100644
index 29b411b16be..00000000000
--- a/ui/app/components/das/diffs-table.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
-
-export default class DasResourceTotalsComponent extends Component {
- get diffs() {
- return new ResourcesDiffs(
- this.args.model,
- 1,
- this.args.recommendations,
- this.args.excludedRecommendations,
- );
- }
-
- get cpuClass() {
- return classForDelta(this.diffs.cpu.delta);
- }
-
- get memoryClass() {
- return classForDelta(this.diffs.memory.delta);
- }
-}
-
-function classForDelta(delta) {
- if (delta > 0) {
- return 'increase';
- } else if (delta < 0) {
- return 'decrease';
- }
-
- return '';
-}
diff --git a/ui/app/components/das/dismissed.gjs b/ui/app/components/das/dismissed.gjs
new file mode 100644
index 00000000000..e5b2306c1f5
--- /dev/null
+++ b/ui/app/components/das/dismissed.gjs
@@ -0,0 +1,66 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+
+export default class DasDismissed extends Component {
+ @localStorageProperty('nomadRecommendationDismssalUnderstood', false)
+ explanationUnderstood;
+
+ @tracked dismissInTheFuture = false;
+
+ proceedAutomatically = () => {
+ this.args.proceed({ manuallyDismissed: false });
+ };
+
+ understoodClicked = () => {
+ this.explanationUnderstood = this.dismissInTheFuture;
+ this.args.proceed({ manuallyDismissed: true });
+ };
+
+ toggleDismissInTheFuture = (event) => {
+ this.dismissInTheFuture = event.target.checked;
+ };
+
+
+
+ {{#if this.explanationUnderstood}}
+ Recommendation dismissed
+ {{else}}
+
+ Recommendation dismissed
+
+ Nomad will not apply these resource change recommendations.
+
+ To never get recommendations for this task group again, disable
+ dynamic application sizing in the job definition.
+
+
+
+
+
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/das/dismissed.hbs b/ui/app/components/das/dismissed.hbs
deleted file mode 100644
index d900c28044c..00000000000
--- a/ui/app/components/das/dismissed.hbs
+++ /dev/null
@@ -1,36 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.explanationUnderstood}}
- Recommendation dismissed
- {{else}}
-
- Recommendation dismissed
-
- Nomad will not apply these resource change recommendations.
-
- To never get recommendations for this task group again, disable dynamic
- application sizing in the job definition.
-
-
-
-
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/das/dismissed.js b/ui/app/components/das/dismissed.js
deleted file mode 100644
index 37e7f80fc56..00000000000
--- a/ui/app/components/das/dismissed.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-
-export default class DasDismissedComponent extends Component {
- @localStorageProperty('nomadRecommendationDismssalUnderstood', false)
- explanationUnderstood;
-
- @tracked dismissInTheFuture = false;
-
- @action
- proceedAutomatically() {
- this.args.proceed({ manuallyDismissed: false });
- }
-
- @action
- understoodClicked() {
- this.explanationUnderstood = this.dismissInTheFuture;
- this.args.proceed({ manuallyDismissed: true });
- }
-
- @action
- toggleDismissInTheFuture(event) {
- this.dismissInTheFuture = event.target.checked;
- }
-}
diff --git a/ui/app/components/das/error.gjs b/ui/app/components/das/error.gjs
new file mode 100644
index 00000000000..1b2f40a23cf
--- /dev/null
+++ b/ui/app/components/das/error.gjs
@@ -0,0 +1,39 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+
+export default class DasError extends Component {
+ dismissClicked = () => {
+ this.args.proceed({ manuallyDismissed: true });
+ };
+
+
+
+
+ Recommendation error
+
+
+ There were errors processing applications:
+
+
+ {{@error}}
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/das/error.hbs b/ui/app/components/das/error.hbs
deleted file mode 100644
index 2e328fb91e6..00000000000
--- a/ui/app/components/das/error.hbs
+++ /dev/null
@@ -1,27 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Recommendation error
-
-
- There were errors processing applications:
-
-
- {{@error}}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/das/error.js b/ui/app/components/das/error.js
deleted file mode 100644
index 5837100707b..00000000000
--- a/ui/app/components/das/error.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class DasErrorComponent extends Component {
- @action
- dismissClicked() {
- this.args.proceed({ manuallyDismissed: true });
- }
-}
diff --git a/ui/app/components/das/recommendation-accordion.gjs b/ui/app/components/das/recommendation-accordion.gjs
new file mode 100644
index 00000000000..3ae6b230412
--- /dev/null
+++ b/ui/app/components/das/recommendation-accordion.gjs
@@ -0,0 +1,119 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { task, timeout } from 'ember-concurrency';
+import { htmlSafe } from '@ember/template';
+import { macroCondition, isTesting } from '@embroider/macros';
+import { array } from '@ember/helper';
+import { didInsert } from '@ember/render-modifiers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import ListAccordion from 'nomad-ui/components/list-accordion';
+import DasRecommendationCard from 'nomad-ui/components/das/recommendation-card';
+import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
+
+export default class DasRecommendationAccordion extends Component {
+ @tracked waitingToProceed = false;
+ @tracked closing = false;
+ @tracked animationContainerStyle = htmlSafe('');
+
+ proceed = task({ drop: true }, async () => {
+ this.closing = true;
+ this.animationContainerStyle = htmlSafe(
+ `height: ${this.accordionElement.clientHeight}px`,
+ );
+
+ await timeout(10);
+
+ this.animationContainerStyle = htmlSafe('height: 0px');
+
+ // The 450ms for the animation to complete, set in CSS as $timing-slow
+ await timeout(macroCondition(isTesting()) ? 0 : 450);
+
+ this.waitingToProceed = false;
+ });
+
+ inserted = (element) => {
+ this.accordionElement = element;
+ this.waitingToProceed = true;
+ };
+
+ get show() {
+ return !this.args.summary.isProcessed || this.waitingToProceed;
+ }
+
+ get diffs() {
+ const summary = this.args.summary;
+ const taskGroup = summary.taskGroup;
+
+ return new ResourcesDiffs(
+ taskGroup,
+ taskGroup.count,
+ this.args.summary.recommendations,
+ this.args.summary.excludedRecommendations,
+ );
+ }
+
+
+ {{#if this.show}}
+
+
+
+
+
+ Resource Recommendation
+ {{@summary.taskGroup.name}}
+
+
+
+ {{#if this.diffs.cpu.delta}}
+
+ CPU
+ {{this.diffs.cpu.signedDiff}}
+ {{this.diffs.cpu.percentDiff}}
+
+ {{/if}}
+
+ {{#if this.diffs.memory.delta}}
+
+ Mem
+ {{this.diffs.memory.signedDiff}}
+ {{this.diffs.memory.percentDiff}}
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/das/recommendation-accordion.hbs b/ui/app/components/das/recommendation-accordion.hbs
deleted file mode 100644
index 6263bd8153b..00000000000
--- a/ui/app/components/das/recommendation-accordion.hbs
+++ /dev/null
@@ -1,55 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.show}}
-
-
-
-
-
- Resource Recommendation
- {{@summary.taskGroup.name}}
-
-
-
- {{#if this.diffs.cpu.delta}}
-
- CPU
- {{this.diffs.cpu.signedDiff}}
- {{this.diffs.cpu.percentDiff}}
-
- {{/if}}
-
- {{#if this.diffs.memory.delta}}
-
- Mem
- {{this.diffs.memory.signedDiff}}
- {{this.diffs.memory.percentDiff}}
-
- {{/if}}
-
-
-
-
-
-
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/das/recommendation-accordion.js b/ui/app/components/das/recommendation-accordion.js
deleted file mode 100644
index 07f1d8706a8..00000000000
--- a/ui/app/components/das/recommendation-accordion.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-import { task, timeout } from 'ember-concurrency';
-import { htmlSafe } from '@ember/template';
-import { macroCondition, isTesting } from '@embroider/macros';
-import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
-
-export default class DasRecommendationAccordionComponent extends Component {
- @tracked waitingToProceed = false;
- @tracked closing = false;
- @tracked animationContainerStyle = htmlSafe('');
-
- @(task(function* () {
- this.closing = true;
- this.animationContainerStyle = htmlSafe(
- `height: ${this.accordionElement.clientHeight}px`,
- );
-
- yield timeout(10);
-
- this.animationContainerStyle = htmlSafe('height: 0px');
-
- // The 450ms for the animation to complete, set in CSS as $timing-slow
- yield timeout(macroCondition(isTesting()) ? 0 : 450);
-
- this.waitingToProceed = false;
- }).drop())
- proceed;
-
- @action
- inserted(element) {
- this.accordionElement = element;
- this.waitingToProceed = true;
- }
-
- get show() {
- return !this.args.summary.isProcessed || this.waitingToProceed;
- }
-
- get diffs() {
- const summary = this.args.summary;
- const taskGroup = summary.taskGroup;
-
- return new ResourcesDiffs(
- taskGroup,
- taskGroup.count,
- this.args.summary.recommendations,
- this.args.summary.excludedRecommendations,
- );
- }
-}
diff --git a/ui/app/components/das/recommendation-card.gjs b/ui/app/components/das/recommendation-card.gjs
new file mode 100644
index 00000000000..4596375d140
--- /dev/null
+++ b/ui/app/components/das/recommendation-card.gjs
@@ -0,0 +1,495 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { htmlSafe } from '@ember/template';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import { and, not, eq } from 'ember-truth-helpers';
+import { includes } from '@nullvoxpopuli/ember-composable-helpers';
+import { didCancel, task, timeout } from 'ember-concurrency';
+import { macroCondition, isTesting } from '@embroider/macros';
+import CopyButton from 'nomad-ui/components/copy-button';
+import Toggle from 'nomad-ui/components/toggle';
+import DasAccepted from 'nomad-ui/components/das/accepted';
+import DasDismissed from 'nomad-ui/components/das/dismissed';
+import DasDiffsTable from 'nomad-ui/components/das/diffs-table';
+import DasError from 'nomad-ui/components/das/error';
+import DasRecommendationChart from 'nomad-ui/components/das/recommendation-chart';
+import DasTaskRow from 'nomad-ui/components/das/task-row';
+import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
+
+export default class DasRecommendationCard extends Component {
+ @service router;
+
+ @tracked allCpuToggleActive = true;
+ @tracked allMemoryToggleActive = true;
+
+ @tracked activeTaskToggleRowIndex = 0;
+
+ element = null;
+
+ @tracked cardHeight;
+ @tracked interstitialComponent;
+ @tracked error;
+
+ @tracked proceedPromiseResolve;
+
+ get activeTaskToggleRow() {
+ return this.taskToggleRows[this.activeTaskToggleRowIndex];
+ }
+
+ get activeTask() {
+ return this.activeTaskToggleRow.task;
+ }
+
+ get narrative() {
+ const summary = this.args.summary;
+ const taskGroup = summary.taskGroup;
+
+ const diffs = new ResourcesDiffs(
+ taskGroup,
+ taskGroup.count,
+ this.args.summary.recommendations,
+ this.args.summary.excludedRecommendations,
+ );
+
+ const cpuDelta = diffs.cpu.delta;
+ const memoryDelta = diffs.memory.delta;
+
+ const aggregate = taskGroup.count > 1;
+ const aggregateString = aggregate ? ' an aggregate' : '';
+
+ if (cpuDelta || memoryDelta) {
+ const deltasSameDirection =
+ (cpuDelta < 0 && memoryDelta < 0) || (cpuDelta > 0 && memoryDelta > 0);
+
+ let narrative = 'Applying the selected recommendations will';
+
+ if (deltasSameDirection) {
+ narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`;
+ }
+
+ if (cpuDelta) {
+ if (!deltasSameDirection) {
+ narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`;
+ }
+
+ narrative += ` ${diffs.cpu.absoluteAggregateDiff} of CPU`;
+ }
+
+ if (cpuDelta && memoryDelta) {
+ narrative += ' and';
+ }
+
+ if (memoryDelta) {
+ if (!deltasSameDirection) {
+ narrative += ` ${verbForDelta(memoryDelta)} ${aggregateString}`;
+ }
+
+ narrative += ` ${diffs.memory.absoluteAggregateDiff} of memory`;
+ }
+
+ if (taskGroup.count === 1) {
+ narrative += '.';
+ } else {
+ narrative += ` across ${taskGroup.count} allocations.`;
+ }
+
+ return htmlSafe(narrative);
+ } else {
+ return '';
+ }
+ }
+
+ get taskToggleRows() {
+ const taskNameToTaskToggles = {};
+
+ return this.args.summary.recommendations.reduce(
+ (taskToggleRows, recommendation) => {
+ let taskToggleRow = taskNameToTaskToggles[recommendation.task.name];
+
+ if (!taskToggleRow) {
+ taskToggleRow = {
+ recommendations: [],
+ task: recommendation.task,
+ };
+
+ taskNameToTaskToggles[recommendation.task.name] = taskToggleRow;
+ taskToggleRows.push(taskToggleRow);
+ }
+
+ const isCpu = recommendation.resource === 'CPU';
+ const rowResourceProperty = isCpu ? 'cpu' : 'memory';
+
+ taskToggleRow[rowResourceProperty] = {
+ recommendation,
+ isActive:
+ !this.args.summary.excludedRecommendations.includes(recommendation),
+ };
+
+ if (isCpu) {
+ taskToggleRow.recommendations.unshift(recommendation);
+ } else {
+ taskToggleRow.recommendations.push(recommendation);
+ }
+
+ return taskToggleRows;
+ },
+ [],
+ );
+ }
+
+ get showToggleAllToggles() {
+ return this.taskToggleRows.length > 1;
+ }
+
+ get allCpuToggleDisabled() {
+ return !this.args.summary.recommendations.filterBy('resource', 'CPU')
+ .length;
+ }
+
+ get allMemoryToggleDisabled() {
+ return !this.args.summary.recommendations.filterBy('resource', 'MemoryMB')
+ .length;
+ }
+
+ get cannotAccept() {
+ return (
+ this.args.summary.excludedRecommendations.length ==
+ this.args.summary.recommendations.length
+ );
+ }
+
+ get copyButtonLink() {
+ const path = this.router.urlFor(
+ 'optimize.summary',
+ this.args.summary.slug,
+ {
+ queryParams: { namespace: this.args.summary.jobNamespace },
+ },
+ );
+ const { origin } = window.location;
+
+ return `${origin}${path}`;
+ }
+
+ onApplied = task({ drop: true }, async () => {
+ this.interstitialComponent = 'accepted';
+ await timeout(macroCondition(isTesting()) ? 0 : 2000);
+
+ this.args.proceed.perform();
+ this.resetInterstitial();
+ });
+
+ onDismissed = task({ drop: true }, async () => {
+ const { manuallyDismissed } = await new Promise((resolve) => {
+ this.proceedPromiseResolve = resolve;
+ this.interstitialComponent = 'dismissed';
+ });
+
+ if (!manuallyDismissed) {
+ await timeout(macroCondition(isTesting()) ? 0 : 2000);
+ }
+
+ this.args.proceed.perform();
+ this.resetInterstitial();
+ });
+
+ onError = task({ drop: true }, async (error) => {
+ await new Promise((resolve) => {
+ this.proceedPromiseResolve = resolve;
+ this.interstitialComponent = 'error';
+ this.error = error.toString();
+ });
+
+ this.args.proceed.perform();
+ this.resetInterstitial();
+ });
+
+ get interstitialStyle() {
+ return htmlSafe(`height: ${this.cardHeight}px`);
+ }
+
+ toggleAllRecommendationsForResource = (resource) => {
+ let enabled;
+
+ if (resource === 'CPU') {
+ this.allCpuToggleActive = !this.allCpuToggleActive;
+ enabled = this.allCpuToggleActive;
+ } else {
+ this.allMemoryToggleActive = !this.allMemoryToggleActive;
+ enabled = this.allMemoryToggleActive;
+ }
+
+ this.args.summary.toggleAllRecommendationsForResource(resource, enabled);
+ };
+
+ accept = () => {
+ this.storeCardHeight();
+ this.args.summary
+ .save()
+ .then(
+ () => this.onApplied.perform(),
+ (e) => this.onError.perform(e),
+ )
+ .catch((e) => {
+ if (!didCancel(e)) {
+ throw e;
+ }
+ });
+ };
+
+ dismiss = async () => {
+ this.storeCardHeight();
+ const recommendations = await this.args.summary.recommendations;
+
+ this.args.summary.excludedRecommendations.pushObjects(recommendations);
+
+ this.args.summary
+ .save()
+ .then(
+ () => this.onDismissed.perform(),
+ (e) => this.onError.perform(e),
+ )
+ .catch((e) => {
+ if (!didCancel(e)) {
+ throw e;
+ }
+ });
+ };
+
+ setActiveTaskToggleRowIndex = (index) => {
+ this.activeTaskToggleRowIndex = index;
+ };
+
+ resetInterstitial() {
+ if (!this.args.skipReset) {
+ this.interstitialComponent = undefined;
+ this.error = undefined;
+ }
+ }
+
+ cardInserted = (element) => {
+ this.element = element;
+ };
+
+ storeCardHeight() {
+ this.cardHeight = this.element.clientHeight;
+ }
+
+
+ {{! template-lint-disable no-duplicate-landmark-elements}}
+ {{#if this.interstitialComponent}}
+
+ {{#if (eq this.interstitialComponent "accepted")}}
+
+ {{else if (eq this.interstitialComponent "dismissed")}}
+
+ {{else if (eq this.interstitialComponent "error")}}
+
+ {{/if}}
+
+ {{else if @summary.taskGroup}}
+
+
+ Resource Recommendation
+
+
+
+ {{@summary.taskGroup.job.name}}
+ {{@summary.taskGroup.name}}
+
+
+ Namespace:
+ {{@summary.jobNamespace}}
+
+
+
+
+
+
+
+
+ {{this.narrative}}
+
+
+
+
+
+
+ {{#if this.showToggleAllToggles}}
+ Task
+ Toggle All
+
+
+
+
+
+
+ {{else}}
+ Task
+ CPU
+ Mem
+ {{/if}}
+
+
+
+ {{#each
+ this.taskToggleRows key="task.name"
+ as |taskToggleRow index|
+ }}
+
+ {{/each}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{@summary.taskGroup.job.name}}
+ /
+ {{@summary.taskGroup.name}}
+
+
+ {{#if @onCollapse}}
+
+ {{/if}}
+
+
+
+ {{this.activeTask.name}} task
+
+
+
+
+
+
+
+ {{#each
+ this.activeTaskToggleRow.recommendations
+ as |recommendation|
+ }}
+ -
+
+
+ {{/each}}
+
+
+
+
+ {{/if}}
+
+}
+
+function verbForDelta(delta) {
+ if (delta > 0) {
+ return 'add';
+ } else {
+ return 'save';
+ }
+}
diff --git a/ui/app/components/das/recommendation-card.hbs b/ui/app/components/das/recommendation-card.hbs
deleted file mode 100644
index 41d5e758123..00000000000
--- a/ui/app/components/das/recommendation-card.hbs
+++ /dev/null
@@ -1,193 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! template-lint-disable no-duplicate-landmark-elements}}
-{{#if this.interstitialComponent}}
-
- {{component
- (concat "das/" this.interstitialComponent)
- proceed=this.proceedPromiseResolve
- error=this.error
- }}
-
-{{else if @summary.taskGroup}}
-
-
- Resource Recommendation
-
-
-
- {{@summary.taskGroup.job.name}}
- {{@summary.taskGroup.name}}
-
-
- Namespace:
- {{@summary.jobNamespace}}
-
-
-
-
-
-
-
-
- {{this.narrative}}
-
-
-
-
-
-
- {{#if this.showToggleAllToggles}}
- Task
- Toggle All
-
-
-
-
-
-
- {{else}}
- Task
- CPU
- Mem
- {{/if}}
-
-
-
- {{#each this.taskToggleRows key="task.name" as |taskToggleRow index|}}
-
- {{/each}}
-
-
-
-
-
-
-
-
-
-
-
-
- {{@summary.taskGroup.job.name}}
- /
- {{@summary.taskGroup.name}}
-
-
- {{#if @onCollapse}}
-
- {{/if}}
-
-
-
- {{this.activeTask.name}} task
-
-
-
-
-
-
-
- {{#each this.activeTaskToggleRow.recommendations as |recommendation|}}
- -
-
-
- {{/each}}
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/das/recommendation-card.js b/ui/app/components/das/recommendation-card.js
deleted file mode 100644
index b9105f4489d..00000000000
--- a/ui/app/components/das/recommendation-card.js
+++ /dev/null
@@ -1,284 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
-import { htmlSafe } from '@ember/template';
-import { didCancel, task, timeout } from 'ember-concurrency';
-import { macroCondition, isTesting } from '@embroider/macros';
-
-export default class DasRecommendationCardComponent extends Component {
- @service router;
-
- @tracked allCpuToggleActive = true;
- @tracked allMemoryToggleActive = true;
-
- @tracked activeTaskToggleRowIndex = 0;
-
- element = null;
-
- @tracked cardHeight;
- @tracked interstitialComponent;
- @tracked error;
-
- @tracked proceedPromiseResolve;
-
- get activeTaskToggleRow() {
- return this.taskToggleRows[this.activeTaskToggleRowIndex];
- }
-
- get activeTask() {
- return this.activeTaskToggleRow.task;
- }
-
- get narrative() {
- const summary = this.args.summary;
- const taskGroup = summary.taskGroup;
-
- const diffs = new ResourcesDiffs(
- taskGroup,
- taskGroup.count,
- this.args.summary.recommendations,
- this.args.summary.excludedRecommendations,
- );
-
- const cpuDelta = diffs.cpu.delta;
- const memoryDelta = diffs.memory.delta;
-
- const aggregate = taskGroup.count > 1;
- const aggregateString = aggregate ? ' an aggregate' : '';
-
- if (cpuDelta || memoryDelta) {
- const deltasSameDirection =
- (cpuDelta < 0 && memoryDelta < 0) || (cpuDelta > 0 && memoryDelta > 0);
-
- let narrative = 'Applying the selected recommendations will';
-
- if (deltasSameDirection) {
- narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`;
- }
-
- if (cpuDelta) {
- if (!deltasSameDirection) {
- narrative += ` ${verbForDelta(cpuDelta)} ${aggregateString}`;
- }
-
- narrative += ` ${diffs.cpu.absoluteAggregateDiff} of CPU`;
- }
-
- if (cpuDelta && memoryDelta) {
- narrative += ' and';
- }
-
- if (memoryDelta) {
- if (!deltasSameDirection) {
- narrative += ` ${verbForDelta(memoryDelta)} ${aggregateString}`;
- }
-
- narrative += ` ${diffs.memory.absoluteAggregateDiff} of memory`;
- }
-
- if (taskGroup.count === 1) {
- narrative += '.';
- } else {
- narrative += ` across ${taskGroup.count} allocations.`;
- }
-
- return htmlSafe(narrative);
- } else {
- return '';
- }
- }
-
- get taskToggleRows() {
- const taskNameToTaskToggles = {};
-
- return this.args.summary.recommendations.reduce(
- (taskToggleRows, recommendation) => {
- let taskToggleRow = taskNameToTaskToggles[recommendation.task.name];
-
- if (!taskToggleRow) {
- taskToggleRow = {
- recommendations: [],
- task: recommendation.task,
- };
-
- taskNameToTaskToggles[recommendation.task.name] = taskToggleRow;
- taskToggleRows.push(taskToggleRow);
- }
-
- const isCpu = recommendation.resource === 'CPU';
- const rowResourceProperty = isCpu ? 'cpu' : 'memory';
-
- taskToggleRow[rowResourceProperty] = {
- recommendation,
- isActive:
- !this.args.summary.excludedRecommendations.includes(recommendation),
- };
-
- if (isCpu) {
- taskToggleRow.recommendations.unshift(recommendation);
- } else {
- taskToggleRow.recommendations.push(recommendation);
- }
-
- return taskToggleRows;
- },
- [],
- );
- }
-
- get showToggleAllToggles() {
- return this.taskToggleRows.length > 1;
- }
-
- get allCpuToggleDisabled() {
- return !this.args.summary.recommendations.filterBy('resource', 'CPU')
- .length;
- }
-
- get allMemoryToggleDisabled() {
- return !this.args.summary.recommendations.filterBy('resource', 'MemoryMB')
- .length;
- }
-
- get cannotAccept() {
- return (
- this.args.summary.excludedRecommendations.length ==
- this.args.summary.recommendations.length
- );
- }
-
- get copyButtonLink() {
- const path = this.router.urlFor(
- 'optimize.summary',
- this.args.summary.slug,
- {
- queryParams: { namespace: this.args.summary.jobNamespace },
- },
- );
- const { origin } = window.location;
-
- return `${origin}${path}`;
- }
-
- @action
- toggleAllRecommendationsForResource(resource) {
- let enabled;
-
- if (resource === 'CPU') {
- this.allCpuToggleActive = !this.allCpuToggleActive;
- enabled = this.allCpuToggleActive;
- } else {
- this.allMemoryToggleActive = !this.allMemoryToggleActive;
- enabled = this.allMemoryToggleActive;
- }
-
- this.args.summary.toggleAllRecommendationsForResource(resource, enabled);
- }
-
- @action
- accept() {
- this.storeCardHeight();
- this.args.summary
- .save()
- .then(
- () => this.onApplied.perform(),
- (e) => this.onError.perform(e),
- )
- .catch((e) => {
- if (!didCancel(e)) {
- throw e;
- }
- });
- }
-
- @action
- async dismiss() {
- this.storeCardHeight();
- const recommendations = await this.args.summary.recommendations;
-
- this.args.summary.excludedRecommendations.pushObjects(recommendations);
-
- this.args.summary
- .save()
- .then(
- () => this.onDismissed.perform(),
- (e) => this.onError.perform(e),
- )
- .catch((e) => {
- if (!didCancel(e)) {
- throw e;
- }
- });
- }
-
- @(task(function* () {
- this.interstitialComponent = 'accepted';
- yield timeout(macroCondition(isTesting()) ? 0 : 2000);
-
- this.args.proceed.perform();
- this.resetInterstitial();
- }).drop())
- onApplied;
-
- @(task(function* () {
- const { manuallyDismissed } = yield new Promise((resolve) => {
- this.proceedPromiseResolve = resolve;
- this.interstitialComponent = 'dismissed';
- });
-
- if (!manuallyDismissed) {
- yield timeout(macroCondition(isTesting()) ? 0 : 2000);
- }
-
- this.args.proceed.perform();
- this.resetInterstitial();
- }).drop())
- onDismissed;
-
- @(task(function* (error) {
- yield new Promise((resolve) => {
- this.proceedPromiseResolve = resolve;
- this.interstitialComponent = 'error';
- this.error = error.toString();
- });
-
- this.args.proceed.perform();
- this.resetInterstitial();
- }).drop())
- onError;
-
- get interstitialStyle() {
- return htmlSafe(`height: ${this.cardHeight}px`);
- }
-
- resetInterstitial() {
- if (!this.args.skipReset) {
- this.interstitialComponent = undefined;
- this.error = undefined;
- }
- }
-
- @action
- cardInserted(element) {
- this.element = element;
- }
-
- storeCardHeight() {
- this.cardHeight = this.element.clientHeight;
- }
-}
-
-function verbForDelta(delta) {
- if (delta > 0) {
- return 'add';
- } else {
- return 'save';
- }
-}
diff --git a/ui/app/components/das/recommendation-chart.js b/ui/app/components/das/recommendation-chart.gjs
similarity index 55%
rename from ui/app/components/das/recommendation-chart.js
rename to ui/app/components/das/recommendation-chart.gjs
index c3b748fe983..1eb05928a2f 100644
--- a/ui/app/components/das/recommendation-chart.js
+++ b/ui/app/components/das/recommendation-chart.gjs
@@ -4,11 +4,16 @@
*/
import Component from '@glimmer/component';
-import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
import { htmlSafe } from '@ember/template';
import { get } from '@ember/object';
+import { fn, concat } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import { eq } from 'ember-truth-helpers';
+import windowResize from 'nomad-ui/modifiers/window-resize';
import { scaleLinear } from 'd3-scale';
import d3Format from 'd3-format';
@@ -24,7 +29,8 @@ const statsKeyToLabel = {
};
const formatPercent = d3Format.format('+.0%');
-export default class RecommendationChartComponent extends Component {
+
+export default class RecommendationChart extends Component {
@tracked width;
@tracked height;
@@ -126,7 +132,7 @@ export default class RecommendationChartComponent extends Component {
label,
x: tickX,
y: this.tickTextHeight - 5,
- class: '', // overridden in statsShapes to align/hide based on proximity
+ class: '',
},
line: {
x1: tickX,
@@ -239,9 +245,7 @@ export default class RecommendationChartComponent extends Component {
points: `
0,${this.center.y1}
0,${this.center.y1 - this.deltaTriangleHeight / 2}
- ${(directionXMultiplier * this.deltaTriangleHeight) / 2},${
- this.center.y1
- }
+ ${(directionXMultiplier * this.deltaTriangleHeight) / 2},${this.center.y1}
0,${this.center.y1 + this.deltaTriangleHeight / 2}
`,
};
@@ -255,9 +259,7 @@ export default class RecommendationChartComponent extends Component {
},
delta: {
style: htmlSafe(
- `transform: translateX(${
- this.shown ? this.higherValueWidth : this.lowerValueWidth
- }px)`,
+ `transform: translateX(${this.shown ? this.higherValueWidth : this.lowerValueWidth}px)`,
),
},
};
@@ -268,9 +270,7 @@ export default class RecommendationChartComponent extends Component {
},
delta: {
style: htmlSafe(
- `transform: translateX(${
- this.shown ? this.lowerValueWidth : this.higherValueWidth
- }px)`,
+ `transform: translateX(${this.shown ? this.lowerValueWidth : this.higherValueWidth}px)`,
),
},
};
@@ -350,37 +350,210 @@ export default class RecommendationChartComponent extends Component {
}
}
- @action
- isShown() {
+ isShown = () => {
next(() => {
this.shown = true;
});
- }
+ };
- @action
- onResize() {
+ onResize = () => {
this.width = this.svgElement.clientWidth;
this.height = this.svgElement.clientHeight;
- }
+ };
- @action
- storeSvgElement(element) {
+ storeSvgElement = (element) => {
this.svgElement = element;
- }
+ };
- @action
- setLegendPosition(mouseMoveEvent) {
+ setLegendPosition = (mouseMoveEvent) => {
this.showLegend = true;
this.mouseX = mouseMoveEvent.layerX;
- }
+ };
+
+ hideLegend = () => {
+ this.showLegend = false;
+ };
- @action
- setActiveLegendRow(row) {
+ setActiveLegendRow = (row) => {
this.activeLegendRow = row;
- }
+ };
- @action
- unsetActiveLegendRow() {
+ unsetActiveLegendRow = () => {
this.activeLegendRow = undefined;
- }
+ };
+
+
+
+
+
+
+
+
+ {{#each this.sortedStats as |stat|}}
+ -
+
+ {{stat.label}}
+
+ {{stat.value}}
+
+ {{/each}}
+
+
+
+
+
}
diff --git a/ui/app/components/das/recommendation-chart.hbs b/ui/app/components/das/recommendation-chart.hbs
deleted file mode 100644
index fd938220e04..00000000000
--- a/ui/app/components/das/recommendation-chart.hbs
+++ /dev/null
@@ -1,177 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
- {{this.resourceLabel.text}}
-
-
- {{#if this.center}}
-
- {{/if}}
-
- {{#each this.statsShapes as |shapes|}}
-
- {{shapes.text.label}}
-
-
-
-
-
- {{/each}}
-
- {{#unless @disabled}}
- {{#if this.deltaRect.x}}
-
-
-
-
-
-
-
-
-
- Current
-
-
-
- New
-
-
-
- {{this.deltaText.percent.text}}
-
- {{/if}}
- {{/unless}}
-
-
-
-
-
-
- {{#each this.sortedStats as |stat|}}
- -
-
- {{stat.label}}
-
- {{stat.value}}
-
- {{/each}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/das/recommendation-row.gjs b/ui/app/components/das/recommendation-row.gjs
new file mode 100644
index 00000000000..6566f985a59
--- /dev/null
+++ b/ui/app/components/das/recommendation-row.gjs
@@ -0,0 +1,120 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { didInsert } from '@ember/render-modifiers';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
+
+export default class DasRecommendationRow extends Component {
+ @tracked cpu = {};
+ @tracked memory = {};
+
+ get taskGroup() {
+ return this.args.summary.taskGroup;
+ }
+
+ get jobName() {
+ return (
+ this.taskGroup?.job?.name ||
+ this.args.summary.job?.name ||
+ this.args.summary.jobId
+ );
+ }
+
+ get allocationCount() {
+ return this.taskGroup?.count || 0;
+ }
+
+ storeDiffs = () => {
+ // Prevent resource toggling from affecting the summary diffs
+ if (!this.taskGroup) {
+ this.cpu = {};
+ this.memory = {};
+ return;
+ }
+
+ const diffs = new ResourcesDiffs(
+ this.taskGroup,
+ 1,
+ this.args.summary.recommendations,
+ this.args.summary.excludedRecommendations,
+ );
+
+ const aggregateDiffs = new ResourcesDiffs(
+ this.taskGroup,
+ this.taskGroup.count,
+ this.args.summary.recommendations,
+ this.args.summary.excludedRecommendations,
+ );
+
+ this.cpu = {
+ delta: diffs.cpu.delta,
+ signedDiff: diffs.cpu.signedDiff,
+ percentDiff: diffs.cpu.percentDiff,
+ signedAggregateDiff: aggregateDiffs.cpu.signedDiff,
+ };
+
+ this.memory = {
+ delta: diffs.memory.delta,
+ signedDiff: diffs.memory.signedDiff,
+ percentDiff: diffs.memory.percentDiff,
+ signedAggregateDiff: aggregateDiffs.memory.signedDiff,
+ };
+ };
+
+
+ {{#if @summary.taskGroup.allocations.length}}
+ {{! Prevent storing aggregate diffs until allocation count is known }}
+
+
+
+ {{@summary.taskGroup.job.name}}
+ /
+ {{@summary.taskGroup.name}}
+
+
+ Namespace:
+ {{@summary.jobNamespace}}
+
+
+
+ {{formatMonthTs @summary.submitTime}}
+
+
+ {{@summary.taskGroup.count}}
+
+
+ {{#if this.cpu.delta}}
+ {{this.cpu.signedDiff}}
+ {{this.cpu.percentDiff}}
+ {{/if}}
+
+
+ {{#if this.memory.delta}}
+ {{this.memory.signedDiff}}
+ {{this.memory.percentDiff}}
+ {{/if}}
+
+
+ {{#if this.cpu.delta}}
+ {{this.cpu.signedAggregateDiff}}
+ {{/if}}
+
+
+ {{#if this.memory.delta}}
+ {{this.memory.signedAggregateDiff}}
+ {{/if}}
+
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/das/recommendation-row.hbs b/ui/app/components/das/recommendation-row.hbs
deleted file mode 100644
index eacc00eb4b9..00000000000
--- a/ui/app/components/das/recommendation-row.hbs
+++ /dev/null
@@ -1,54 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if @summary.taskGroup.allocations.length}}
- {{! Prevent storing aggregate diffs until allocation count is known }}
-
-
-
- {{@summary.taskGroup.job.name}}
- /
- {{@summary.taskGroup.name}}
-
-
- Namespace:
- {{@summary.jobNamespace}}
-
-
-
- {{format-month-ts @summary.submitTime}}
-
-
- {{@summary.taskGroup.count}}
-
-
- {{#if this.cpu.delta}}
- {{this.cpu.signedDiff}}
- {{this.cpu.percentDiff}}
- {{/if}}
-
-
- {{#if this.memory.delta}}
- {{this.memory.signedDiff}}
- {{this.memory.percentDiff}}
- {{/if}}
-
-
- {{#if this.cpu.delta}}
- {{this.cpu.signedAggregateDiff}}
- {{/if}}
-
-
- {{#if this.memory.delta}}
- {{this.memory.signedAggregateDiff}}
- {{/if}}
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/das/recommendation-row.js b/ui/app/components/das/recommendation-row.js
deleted file mode 100644
index 14b102dbf52..00000000000
--- a/ui/app/components/das/recommendation-row.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import ResourcesDiffs from 'nomad-ui/utils/resources-diffs';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-
-export default class DasRecommendationRow extends Component {
- @tracked cpu = {};
- @tracked memory = {};
-
- get taskGroup() {
- return this.args.summary.taskGroup;
- }
-
- get jobName() {
- return (
- this.taskGroup?.job?.name ||
- this.args.summary.job?.name ||
- this.args.summary.jobId
- );
- }
-
- get allocationCount() {
- return this.taskGroup?.count || 0;
- }
-
- @action
- storeDiffs() {
- // Prevent resource toggling from affecting the summary diffs
-
- if (!this.taskGroup) {
- this.cpu = {};
- this.memory = {};
- return;
- }
-
- const diffs = new ResourcesDiffs(
- this.taskGroup,
- 1,
- this.args.summary.recommendations,
- this.args.summary.excludedRecommendations,
- );
-
- const aggregateDiffs = new ResourcesDiffs(
- this.taskGroup,
- this.taskGroup.count,
- this.args.summary.recommendations,
- this.args.summary.excludedRecommendations,
- );
-
- this.cpu = {
- delta: diffs.cpu.delta,
- signedDiff: diffs.cpu.signedDiff,
- percentDiff: diffs.cpu.percentDiff,
- signedAggregateDiff: aggregateDiffs.cpu.signedDiff,
- };
-
- this.memory = {
- delta: diffs.memory.delta,
- signedDiff: diffs.memory.signedDiff,
- percentDiff: diffs.memory.percentDiff,
- signedAggregateDiff: aggregateDiffs.memory.signedDiff,
- };
- }
-}
diff --git a/ui/app/components/das/task-row.gjs b/ui/app/components/das/task-row.gjs
new file mode 100644
index 00000000000..03762bd6490
--- /dev/null
+++ b/ui/app/components/das/task-row.gjs
@@ -0,0 +1,77 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { fn, concat } from '@ember/helper';
+import { didInsert } from '@ember/render-modifiers';
+import { and, not } from 'ember-truth-helpers';
+import Toggle from 'nomad-ui/components/toggle';
+
+export default class DasTaskRow extends Component {
+ @tracked height;
+
+ get half() {
+ return this.height / 2;
+ }
+
+ get borderCoverHeight() {
+ return this.height - 2;
+ }
+
+ calculateHeight = (element) => {
+ this.height = element.clientHeight + 1;
+ };
+
+
+
+ {{@task.name}}
+
+
+
+
+
+ {{#if (and @active this.height)}}
+
+
+
+
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/das/task-row.hbs b/ui/app/components/das/task-row.hbs
deleted file mode 100644
index f9a82afd92d..00000000000
--- a/ui/app/components/das/task-row.hbs
+++ /dev/null
@@ -1,51 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{@task.name}}
-
-
-
-
-
- {{#if (and @active this.height)}}
-
-
-
-
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/das/task-row.js b/ui/app/components/das/task-row.js
deleted file mode 100644
index 1e9fa0f6b19..00000000000
--- a/ui/app/components/das/task-row.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-
-export default class DasTaskRowComponent extends Component {
- @tracked height;
-
- get half() {
- return this.height / 2;
- }
-
- get borderCoverHeight() {
- return this.height - 2;
- }
-
- @action
- calculateHeight(element) {
- this.height = element.clientHeight + 1;
- }
-}
diff --git a/ui/app/components/distribution-bar.gjs b/ui/app/components/distribution-bar.gjs
new file mode 100644
index 00000000000..25b01bf194a
--- /dev/null
+++ b/ui/app/components/distribution-bar.gjs
@@ -0,0 +1,285 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { run, once, schedule } from '@ember/runloop';
+import { guidFor } from '@ember/object/internals';
+import { concat, hash } from '@ember/helper';
+import { eq } from 'ember-truth-helpers';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { copy } from 'ember-copy';
+import d3 from 'd3-selection';
+import 'd3-transition';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+
+const sumAggregate = (total, val) => total + val;
+
+export default class DistributionBar extends Component {
+ @tracked activeDatum = null;
+ @tracked isActive = false;
+ @tracked tooltipPosition = null;
+
+ chart = null;
+ slices = null;
+ maskId = null;
+ rootElement = null;
+ svgElement = null;
+
+ get data() {
+ return this.args.data ?? [];
+ }
+
+ get onSliceClick() {
+ return this.args.onSliceClick;
+ }
+
+ get isNarrow() {
+ return this.args.isNarrow ?? false;
+ }
+
+ get _data() {
+ const data = copy(this.data, true);
+ const sum = data.mapBy('value').reduce(sumAggregate, 0);
+
+ return data.map(
+ ({ label, value, className, layers, legendLink, help }, index) => ({
+ label,
+ value,
+ className,
+ layers,
+ legendLink,
+ help,
+ index,
+ percent: value / sum,
+ offset:
+ data.slice(0, index).mapBy('value').reduce(sumAggregate, 0) / sum,
+ }),
+ );
+ }
+
+ get tooltipStyle() {
+ if (!this.tooltipPosition) {
+ return '';
+ }
+
+ return Object.keys(this.tooltipPosition)
+ .map((key) => {
+ const value = this.tooltipPosition[key];
+ const formatted =
+ typeof value === 'number' ? `${value.toFixed(2)}px` : value;
+ return `${key}:${formatted}`;
+ })
+ .join(';');
+ }
+
+ setupChart = (svgElement) => {
+ this.svgElement = svgElement;
+ this.rootElement = svgElement.closest('.distribution-bar');
+ this.chart = d3.select(svgElement);
+ this.maskId = `dist-mask-${guidFor(this)}`;
+
+ svgElement.querySelector('clipPath').setAttribute('id', this.maskId);
+
+ this.chart.on('mouseleave', () => {
+ run(() => {
+ this.isActive = false;
+ this.activeDatum = null;
+ this.chart
+ .selectAll('g')
+ .classed('active', false)
+ .classed('inactive', false);
+ });
+ });
+
+ this.renderChart();
+ };
+
+ renderChart = () => {
+ const { chart, _data, isNarrow, svgElement } = this;
+
+ if (!chart || !svgElement) {
+ return;
+ }
+
+ const width = svgElement.clientWidth;
+ const filteredData = _data.filter((d) => d.value > 0);
+ filteredData.forEach((d, index) => {
+ d.index = index;
+ });
+
+ let slices = chart
+ .select('.bars')
+ .selectAll('g')
+ .data(filteredData, (d) => d.label);
+ const sliceCount = filteredData.length;
+
+ slices.exit().remove();
+
+ const slicesEnter = slices
+ .enter()
+ .append('g')
+ .on('mouseenter', (ev, d) => {
+ run(() => {
+ const allSlices = this.slices;
+ const slice = allSlices.filter((datum) => datum.label === d.label);
+ allSlices.classed('active', false).classed('inactive', true);
+ slice.classed('active', true).classed('inactive', false);
+ this.activeDatum = d;
+
+ const box = slice.node().getBBox();
+ const pos = box.x + box.width / 2;
+
+ // Ensure that the position is set before the tooltip is visible.
+ schedule('afterRender', this, () => {
+ this.isActive = true;
+ });
+ this.tooltipPosition = { left: pos };
+ });
+ });
+
+ slices = slices.merge(slicesEnter);
+ slices
+ .attr('class', (d) => {
+ const className = d.className || `slice-${_data.indexOf(d)}`;
+ const activeDatum = this.activeDatum;
+ const isActive = activeDatum && activeDatum.label === d.label;
+ const isInactive = activeDatum && activeDatum.label !== d.label;
+ const isClickable = !!this.onSliceClick;
+ return [
+ className,
+ isActive && 'active',
+ isInactive && 'inactive',
+ isClickable && 'clickable',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ })
+ .attr('data-test-slice-label', (d) => d.className);
+
+ this.slices = slices;
+
+ const setWidth = (d) => {
+ // Remove a pixel from either side of the slice.
+ let modifier = 2;
+ if (d.index === 0) modifier--;
+ if (d.index === sliceCount - 1) modifier--;
+
+ return `${width * d.percent - modifier}px`;
+ };
+
+ const setOffset = (d) => `${width * d.offset + (d.index === 0 ? 0 : 1)}px`;
+
+ let hoverTargets = slices.selectAll('.target').data((d) => [d]);
+ hoverTargets
+ .enter()
+ .append('rect')
+ .attr('class', 'target')
+ .attr('width', setWidth)
+ .attr('height', '100%')
+ .attr('x', setOffset)
+ .merge(hoverTargets)
+ .transition()
+ .duration(200)
+ .attr('width', setWidth)
+ .attr('x', setOffset);
+
+ let layers = slices.selectAll('.bar').data((d, i) => {
+ return new Array(d.layers || 1).fill(Object.assign({ index: i }, d));
+ });
+
+ layers
+ .enter()
+ .append('rect')
+ .attr('width', setWidth)
+ .attr('x', setOffset)
+ .attr('y', () => (isNarrow ? '50%' : 0))
+ .attr('clip-path', `url(#${this.maskId})`)
+ .attr('height', () => (isNarrow ? '6px' : '100%'))
+ .attr('transform', () => (isNarrow ? 'translate(0, -3)' : ''))
+ .merge(layers)
+ .attr('class', (d, i) => `bar layer-${i}`)
+ .transition()
+ .duration(200)
+ .attr('width', setWidth)
+ .attr('x', setOffset);
+
+ if (isNarrow && this.rootElement) {
+ d3.select(this.rootElement)
+ .select('.mask')
+ .attr('height', '6px')
+ .attr('y', '50%');
+ }
+
+ if (this.onSliceClick) {
+ slices.on('click', this.onSliceClick);
+ }
+ };
+
+ windowResizeHandler = () => {
+ once(this, this.renderChart);
+ };
+
+
+
+
+}
diff --git a/ui/app/components/distribution-bar.hbs b/ui/app/components/distribution-bar.hbs
deleted file mode 100644
index e845278d76f..00000000000
--- a/ui/app/components/distribution-bar.hbs
+++ /dev/null
@@ -1,45 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-{{#if (has-block)}}
- {{yield (hash data=this._data activeDatum=this.activeDatum)}}
-{{else}}
-
-
- {{#each this._data as |datum index|}}
- -
-
-
- {{datum.label}}
-
- {{datum.value}}
-
- {{/each}}
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js
deleted file mode 100644
index fbf2179a97e..00000000000
--- a/ui/app/components/distribution-bar.js
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-/* eslint-disable ember/no-observers */
-import Component from '@ember/component';
-import { computed, set } from '@ember/object';
-import { observes } from '@ember-decorators/object';
-import { run, once, schedule } from '@ember/runloop';
-import { guidFor } from '@ember/object/internals';
-import { copy } from 'ember-copy';
-import { computed as overridable } from 'ember-overridable-computed';
-import d3 from 'd3-selection';
-import 'd3-transition';
-import WindowResizable from '../mixins/window-resizable';
-import styleStringProperty from '../utils/properties/style-string';
-import { classNames, classNameBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-const sumAggregate = (total, val) => total + val;
-
-@classic
-@classNames('chart', 'distribution-bar')
-@classNameBindings('isNarrow:is-narrow')
-export default class DistributionBar extends Component.extend(WindowResizable) {
- chart = null;
- @overridable(() => null) data;
- onSliceClick = null;
- activeDatum = null;
- isNarrow = false;
-
- @styleStringProperty('tooltipPosition') tooltipStyle;
- maskId = null;
-
- @computed('data')
- get _data() {
- const data = copy(this.data, true);
- const sum = data.mapBy('value').reduce(sumAggregate, 0);
-
- return data.map(
- ({ label, value, className, layers, legendLink, help }, index) => ({
- label,
- value,
- className,
- layers,
- legendLink,
- help,
- index,
- percent: value / sum,
- offset:
- data.slice(0, index).mapBy('value').reduce(sumAggregate, 0) / sum,
- }),
- );
- }
-
- didInsertElement() {
- super.didInsertElement(...arguments);
- const svg = this.element.querySelector('svg');
- const chart = d3.select(svg);
- const maskId = `dist-mask-${guidFor(this)}`;
- this.setProperties({ chart, maskId });
-
- svg.querySelector('clipPath').setAttribute('id', maskId);
-
- chart.on('mouseleave', () => {
- run(() => {
- this.set('isActive', false);
- this.set('activeDatum', null);
- chart
- .selectAll('g')
- .classed('active', false)
- .classed('inactive', false);
- });
- });
-
- this.renderChart();
- }
-
- didUpdateAttrs() {
- super.didUpdateAttrs();
- this.renderChart();
- }
-
- @observes('_data.@each.{value,label,className}')
- updateChart() {
- this.renderChart();
- }
-
- // prettier-ignore
-
- renderChart() {
- const { chart, _data, isNarrow } = this;
- const width = this.element.querySelector('svg').clientWidth;
- const filteredData = _data.filter(d => d.value > 0);
- filteredData.forEach((d, index) => {
- set(d, 'index', index);
- });
-
- let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label);
- let sliceCount = filteredData.length;
-
- slices.exit().remove();
-
- let slicesEnter = slices.enter()
- .append('g')
- .on('mouseenter', (ev, d) => {
- run(() => {
- const slices = this.slices;
- const slice = slices.filter(datum => datum.label === d.label);
- slices.classed('active', false).classed('inactive', true);
- slice.classed('active', true).classed('inactive', false);
- this.set('activeDatum', d);
-
- const box = slice.node().getBBox();
- const pos = box.x + box.width / 2;
-
- // Ensure that the position is set before the tooltip is visible
- schedule('afterRender', this, () => this.set('isActive', true));
- this.set('tooltipPosition', {
- left: pos,
- });
- });
- });
-
- slices = slices.merge(slicesEnter);
- slices.attr('class', d => {
- const className = d.className || `slice-${_data.indexOf(d)}`
- const activeDatum = this.activeDatum;
- const isActive = activeDatum && activeDatum.label === d.label;
- const isInactive = activeDatum && activeDatum.label !== d.label;
- const isClickable = !!this.onSliceClick;
- return [
- className,
- isActive && 'active',
- isInactive && 'inactive',
- isClickable && 'clickable'
- ].compact().join(' ');
- }).attr('data-test-slice-label', d => d.className);
-
- this.set('slices', slices);
-
- const setWidth = d => {
- // Remove a pixel from either side of the slice
- let modifier = 2;
- if (d.index === 0) modifier--; // But not the left side
- if (d.index === sliceCount - 1) modifier--; // But not the right side
-
- return `${width * d.percent - modifier}px`;
- };
- const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px`;
-
- let hoverTargets = slices.selectAll('.target').data(d => [d]);
- hoverTargets.enter()
- .append('rect')
- .attr('class', 'target')
- .attr('width', setWidth)
- .attr('height', '100%')
- .attr('x', setOffset)
- .merge(hoverTargets)
- .transition()
- .duration(200)
- .attr('width', setWidth)
- .attr('x', setOffset)
-
- let layers = slices.selectAll('.bar').data((d, i) => {
- return new Array(d.layers || 1).fill(Object.assign({ index: i }, d));
- });
- layers.enter()
- .append('rect')
- .attr('width', setWidth)
- .attr('x', setOffset)
- .attr('y', () => isNarrow ? '50%' : 0)
- .attr('clip-path', `url(#${this.maskId})`)
- .attr('height', () => isNarrow ? '6px' : '100%')
- .attr('transform', () => isNarrow ? 'translate(0, -3)' : '')
- .merge(layers)
- .attr('class', (d, i) => `bar layer-${i}`)
- .transition()
- .duration(200)
- .attr('width', setWidth)
- .attr('x', setOffset)
-
- if (isNarrow) {
- d3.select(this.element).select('.mask')
- .attr('height', '6px')
- .attr('y', '50%');
- }
-
- if (this.onSliceClick) {
- slices.on('click', this.onSliceClick);
- }
- }
- /* eslint-enable */
-
- windowResizeHandler() {
- once(this, this.renderChart);
- }
-}
diff --git a/ui/app/components/drain-popover.gjs b/ui/app/components/drain-popover.gjs
new file mode 100644
index 00000000000..c71f230b312
--- /dev/null
+++ b/ui/app/components/drain-popover.gjs
@@ -0,0 +1,293 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { task } from 'ember-concurrency';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import Duration from 'duration-js';
+import PowerSelect from 'ember-power-select/components/power-select';
+import PopoverMenu from 'nomad-ui/components/popover-menu';
+import Toggle from 'nomad-ui/components/toggle';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+
+const noOp = () => {};
+
+export default class DrainPopover extends Component {
+ @tracked parseError = '';
+ @tracked deadlineEnabled = false;
+ @tracked forceDrain = false;
+ @tracked drainSystemJobs = true;
+ @tracked customDuration = '';
+ @tracked selectedDurationQuickOption;
+
+ @localStorageProperty('nomadDrainOptions', {}) drainOptions;
+
+ constructor() {
+ super(...arguments);
+
+ this.selectedDurationQuickOption = this.durationQuickOptions[0];
+
+ [
+ 'deadlineEnabled',
+ 'customDuration',
+ 'forceDrain',
+ 'drainSystemJobs',
+ 'selectedDurationQuickOption',
+ ].forEach((key) => {
+ if (key in this.drainOptions) {
+ this[key] = this.drainOptions[key];
+ }
+ });
+ }
+
+ get isDisabled() {
+ return this.args.isDisabled ?? false;
+ }
+
+ get onError() {
+ return this.args.onError ?? noOp;
+ }
+
+ get onDrain() {
+ return this.args.onDrain ?? noOp;
+ }
+
+ get durationQuickOptions() {
+ return [
+ { label: '1 Hour', value: '1h' },
+ { label: '4 Hours', value: '4h' },
+ { label: '8 Hours', value: '8h' },
+ { label: '12 Hours', value: '12h' },
+ { label: '1 Day', value: '1d' },
+ { label: 'Custom', value: 'custom' },
+ ];
+ }
+
+ get durationIsCustom() {
+ return this.selectedDurationQuickOption?.value === 'custom';
+ }
+
+ get deadline() {
+ if (!this.deadlineEnabled) return 0;
+ if (this.durationIsCustom) return this.customDuration;
+ return this.selectedDurationQuickOption.value;
+ }
+
+ get popoverLabel() {
+ return this.args.client?.isDraining ? 'Update Drain' : 'Drain';
+ }
+
+ get popoverTooltip() {
+ return this.isDisabled ? 'Not allowed to drain clients' : undefined;
+ }
+
+ get triggerClass() {
+ return [
+ 'is-small',
+ this.drain.isRunning ? 'is-loading' : '',
+ this.isDisabled ? 'tooltip is-right-aligned' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ }
+
+ get customDurationInputValue() {
+ return this.customDuration === 0 ? '' : this.customDuration;
+ }
+
+ drain = task({ drop: true }, async (close) => {
+ if (!this.args.client) return;
+ const isUpdating = this.args.client.isDraining;
+
+ let deadline;
+ try {
+ deadline = new Duration(this.deadline).nanoseconds();
+ } catch (err) {
+ this.parseError = err.message;
+ return;
+ }
+
+ const spec = {
+ Deadline: deadline,
+ IgnoreSystemJobs: !this.drainSystemJobs,
+ };
+
+ this.drainOptions = {
+ deadlineEnabled: this.deadlineEnabled,
+ customDuration: this.deadline,
+ selectedDurationQuickOption: this.selectedDurationQuickOption,
+ drainSystemJobs: this.drainSystemJobs,
+ forceDrain: this.forceDrain,
+ };
+
+ close();
+
+ try {
+ if (this.forceDrain) {
+ await this.args.client.forceDrain(spec);
+ } else {
+ await this.args.client.drain(spec);
+ }
+ this.onDrain(isUpdating);
+ } catch (err) {
+ this.onError(err);
+ }
+ });
+
+ onSubmit = (event, close) => {
+ event.preventDefault();
+ this.drain.perform(close);
+ };
+
+ onClickDrain = (close) => {
+ this.drain.perform(close);
+ };
+
+ updateDeadlineEnabled = ({ target: { checked } }) => {
+ this.deadlineEnabled = checked;
+ };
+
+ updateForceDrain = ({ target: { checked } }) => {
+ this.forceDrain = checked;
+ };
+
+ updateDrainSystemJobs = ({ target: { checked } }) => {
+ this.drainSystemJobs = checked;
+ };
+
+ updateSelectedDuration = (option) => {
+ this.selectedDurationQuickOption = option;
+ };
+
+ updateCustomDuration = ({ target: { value } }) => {
+ this.parseError = '';
+ this.customDuration = value;
+ };
+
+
+ {{! template-lint-disable require-input-label }}
+
+
+
+
+}
diff --git a/ui/app/components/drain-popover.hbs b/ui/app/components/drain-popover.hbs
deleted file mode 100644
index 5986505d2e2..00000000000
--- a/ui/app/components/drain-popover.hbs
+++ /dev/null
@@ -1,134 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! template-lint-disable require-input-label }}
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/drain-popover.js b/ui/app/components/drain-popover.js
deleted file mode 100644
index d965cbaec2c..00000000000
--- a/ui/app/components/drain-popover.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { equal } from '@ember/object/computed';
-import { computed as overridable } from 'ember-overridable-computed';
-import { task } from 'ember-concurrency';
-import Duration from 'duration-js';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-
-@classic
-@tagName('')
-export default class DrainPopover extends Component {
- client = null;
- isDisabled = false;
-
- onError() {}
- onDrain() {}
-
- parseError = '';
-
- deadlineEnabled = false;
- forceDrain = false;
- drainSystemJobs = true;
-
- @localStorageProperty('nomadDrainOptions', {}) drainOptions;
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- // Load drain config values from local storage if availabe.
- [
- 'deadlineEnabled',
- 'customDuration',
- 'forceDrain',
- 'drainSystemJobs',
- 'selectedDurationQuickOption',
- ].forEach((k) => {
- if (k in this.drainOptions) {
- this[k] = this.drainOptions[k];
- }
- });
- }
-
- @overridable(function () {
- return this.durationQuickOptions[0];
- })
- selectedDurationQuickOption;
-
- @equal('selectedDurationQuickOption.value', 'custom') durationIsCustom;
- customDuration = '';
-
- @computed
- get durationQuickOptions() {
- return [
- { label: '1 Hour', value: '1h' },
- { label: '4 Hours', value: '4h' },
- { label: '8 Hours', value: '8h' },
- { label: '12 Hours', value: '12h' },
- { label: '1 Day', value: '1d' },
- { label: 'Custom', value: 'custom' },
- ];
- }
-
- @computed(
- 'deadlineEnabled',
- 'durationIsCustom',
- 'customDuration',
- 'selectedDurationQuickOption.value',
- )
- get deadline() {
- if (!this.deadlineEnabled) return 0;
- if (this.durationIsCustom) return this.customDuration;
- return this.selectedDurationQuickOption.value;
- }
-
- @task(function* (close) {
- if (!this.client) return;
- const isUpdating = this.client.isDraining;
-
- let deadline;
- try {
- deadline = new Duration(this.deadline).nanoseconds();
- } catch (err) {
- this.set('parseError', err.message);
- return;
- }
-
- const spec = {
- Deadline: deadline,
- IgnoreSystemJobs: !this.drainSystemJobs,
- };
-
- this.drainOptions = {
- deadlineEnabled: this.deadlineEnabled,
- customDuration: this.deadline,
- selectedDurationQuickOption: this.selectedDurationQuickOption,
- drainSystemJobs: this.drainSystemJobs,
- forceDrain: this.forceDrain,
- };
-
- close();
-
- try {
- if (this.forceDrain) {
- yield this.client.forceDrain(spec);
- } else {
- yield this.client.drain(spec);
- }
- this.onDrain(isUpdating);
- } catch (err) {
- this.onError(err);
- }
- })
- drain;
-
- @action
- preventDefault(e) {
- e.preventDefault();
- }
-}
diff --git a/ui/app/components/editable-variable-link.gjs b/ui/app/components/editable-variable-link.gjs
new file mode 100644
index 00000000000..8c028440b83
--- /dev/null
+++ b/ui/app/components/editable-variable-link.gjs
@@ -0,0 +1,37 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import can from 'ember-can/helpers/can';
+import editableVariableLink from 'nomad-ui/helpers/editable-variable-link';
+import { HdsLinkInline } from '@hashicorp/design-system-components/components';
+
+export const EditableVariableLink =
+ {{! Either link to a new variable with a pre-filled path, or the existing variable in edit mode, depending if it exists }}
+ {{#if (can "write variable")}}
+ {{#let
+ (editableVariableLink
+ @path existingPaths=@existingPaths namespace=@namespace
+ )
+ as |link|
+ }}
+ {{#if link.model}}
+ {{@path}}
+ {{else}}
+ {{@path}}
+ {{/if}}
+ {{/let}}
+ {{else}}
+ {{@path}}
+ {{/if}}
+;
+
+export default EditableVariableLink;
diff --git a/ui/app/components/editable-variable-link.hbs b/ui/app/components/editable-variable-link.hbs
deleted file mode 100644
index ff29dbae8c8..00000000000
--- a/ui/app/components/editable-variable-link.hbs
+++ /dev/null
@@ -1,29 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! Either link to a new variable with a pre-filled path, or the existing variable in edit mode, depending if it exists }}
-{{#if (can "write variable")}}
- {{#let
- (editable-variable-link
- @path existingPaths=@existingPaths namespace=@namespace
- )
- as |link|
- }}
- {{#if link.model}}
- {{@path}}
- {{else}}
- {{@path}}
- {{/if}}
- {{/let}}
-{{else}}
- {{@path}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/evaluation-sidebar/detail.gjs b/ui/app/components/evaluation-sidebar/detail.gjs
new file mode 100644
index 00000000000..13266c912b4
--- /dev/null
+++ b/ui/app/components/evaluation-sidebar/detail.gjs
@@ -0,0 +1,472 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { concat, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import { matchesState } from 'ember-statecharts';
+import d3 from 'd3';
+import onClickOutside from 'ember-click-outside/modifiers/on-click-outside';
+import EvaluationSidebarRelatedEvaluations from 'nomad-ui/components/evaluation-sidebar/related-evaluations';
+import JsonViewer from 'nomad-ui/components/json-viewer';
+import LoadingSpinner from 'nomad-ui/components/loading-spinner';
+import PlacementFailure from 'nomad-ui/components/placement-failure';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import keyboardCommands from 'nomad-ui/helpers/keyboard-commands';
+
+export default class EvaluationSidebarDetail extends Component {
+ get statechart() {
+ return this.args.statechart;
+ }
+
+ @matchesState({ sidebar: 'open' })
+ isSideBarOpen;
+
+ @matchesState({ sidebar: { open: 'success' } })
+ isSuccess;
+
+ @matchesState({ sidebar: { open: 'busy' } })
+ isLoading;
+
+ @matchesState({ sidebar: { open: 'error' } })
+ isError;
+
+ @tracked width = null;
+ @tracked height = null;
+
+ handleResize = ({ target: { scrollWidth: width, scrollHeight: height } }) => {
+ if (width === this.width || height === this.height) return;
+
+ this.height = height;
+ this.width = width;
+ };
+
+ get currentEvalDetail() {
+ return this.args.statechart.state.context.evaluation;
+ }
+
+ get hierarchy() {
+ try {
+ const data = this.currentEvalDetail?.relatedEvals;
+
+ if (data) {
+ return d3
+ .stratify()
+ .id((detail) => detail.id)
+ .parentId((detail) => detail.previousEval)([
+ ...data.toArray(),
+ this.currentEvalDetail,
+ ]);
+ }
+ } catch (error) {
+ console.error(`\n\nRelated Evaluation Error: ${error.message}`);
+ }
+
+ return null;
+ }
+
+ get descendentsMap() {
+ return this.hierarchy
+ ?.descendants()
+ .map((detail) => detail.children)
+ .compact();
+ }
+
+ get parentEvaluation() {
+ return this.hierarchy?.data;
+ }
+
+ get portalTargetElement() {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ return document.getElementById('eval-detail-portal');
+ }
+
+ closeSidebar = () => {
+ return this.args.statechart.send('MODAL_CLOSE');
+ };
+
+ get keyCommands() {
+ return [
+ {
+ label: 'Close Evaluations Sidebar',
+ pattern: ['Escape'],
+ action: () => this.closeSidebar(),
+ },
+ ];
+ }
+
+
+ {{#let this.currentEvalDetail as |evaluation|}}
+ {{#if this.isSideBarOpen}}
+ {{keyboardCommands this.keyCommands}}
+ {{/if}}
+
+ {{#if this.portalTargetElement}}
+ {{#in-element this.portalTargetElement}}
+
+ {{/in-element}}
+ {{else}}
+
+ {{/if}}
+
+ {{/let}}
+
+}
diff --git a/ui/app/components/evaluation-sidebar/detail.hbs b/ui/app/components/evaluation-sidebar/detail.hbs
deleted file mode 100644
index 1c7c7085acb..00000000000
--- a/ui/app/components/evaluation-sidebar/detail.hbs
+++ /dev/null
@@ -1,181 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#let this.currentEvalDetail as |evaluation|}}
- {{#if this.isSideBarOpen}}
- {{keyboard-commands this.keyCommands}}
- {{/if}}
-
-
-
-{{/let}}
\ No newline at end of file
diff --git a/ui/app/components/evaluation-sidebar/detail.js b/ui/app/components/evaluation-sidebar/detail.js
deleted file mode 100644
index 0a68a9b031d..00000000000
--- a/ui/app/components/evaluation-sidebar/detail.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import d3 from 'd3';
-import { matchesState } from 'ember-statecharts';
-
-export default class Detail extends Component {
- get statechart() {
- return this.args.statechart;
- }
-
- @matchesState({ sidebar: 'open' })
- isSideBarOpen;
-
- @matchesState({ sidebar: { open: 'success' } })
- isSuccess;
-
- @matchesState({ sidebar: { open: 'busy' } })
- isLoading;
-
- @matchesState({ sidebar: { open: 'error' } })
- isError;
-
- @tracked width = null;
- @tracked height = null;
-
- @action
- handleResize({ target: { scrollWidth: width, scrollHeight: height } }) {
- if (width === this.width || height === this.height) return;
- this.height = height;
- this.width = width;
- }
-
- get currentEvalDetail() {
- return this.statechart.state.context.evaluation;
- }
-
- get hierarchy() {
- try {
- const data = this.currentEvalDetail?.relatedEvals;
-
- if (data) {
- return d3
- .stratify()
- .id((d) => {
- return d.id;
- })
- .parentId((d) => d.previousEval)([
- ...data.toArray(),
- this.currentEvalDetail,
- ]);
- }
- } catch (e) {
- console.error(`\n\nRelated Evaluation Error: ${e.message}`);
- }
- return null;
- }
-
- get descendentsMap() {
- return this.hierarchy
- ?.descendants()
- .map((d) => d.children)
- .compact();
- }
-
- get parentEvaluation() {
- return this.hierarchy?.data;
- }
-
- get error() {
- return this.statechart.state.context.error;
- }
-
- @action
- closeSidebar() {
- return this.statechart.send('MODAL_CLOSE');
- }
-
- keyCommands = [
- {
- label: 'Close Evaluations Sidebar',
- pattern: ['Escape'],
- action: () => this.closeSidebar(),
- },
- ];
-}
diff --git a/ui/app/components/evaluation-sidebar/evaluation-actor.gjs b/ui/app/components/evaluation-sidebar/evaluation-actor.gjs
new file mode 100644
index 00000000000..ad45c2f32d7
--- /dev/null
+++ b/ui/app/components/evaluation-sidebar/evaluation-actor.gjs
@@ -0,0 +1,39 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+import { eq } from 'ember-truth-helpers';
+import ProvidersActorsRelationships from 'nomad-ui/components/providers/actors-relationships';
+import StatusCell from 'nomad-ui/components/status-cell';
+
+export const EvaluationSidebarEvaluationActor =
+
+
+
+;
+
+export default EvaluationSidebarEvaluationActor;
diff --git a/ui/app/components/evaluation-sidebar/evaluation-actor.hbs b/ui/app/components/evaluation-sidebar/evaluation-actor.hbs
deleted file mode 100644
index edb7671d615..00000000000
--- a/ui/app/components/evaluation-sidebar/evaluation-actor.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/evaluation-sidebar/related-evaluations.gjs b/ui/app/components/evaluation-sidebar/related-evaluations.gjs
new file mode 100644
index 00000000000..330f76a4384
--- /dev/null
+++ b/ui/app/components/evaluation-sidebar/related-evaluations.gjs
@@ -0,0 +1,79 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { fn } from '@ember/helper';
+import { and } from 'ember-truth-helpers';
+import { didUpdate } from '@ember/render-modifiers';
+import onResize from 'ember-on-resize-modifier/modifiers/on-resize';
+import EvaluationSidebarEvaluationActor from 'nomad-ui/components/evaluation-sidebar/evaluation-actor';
+import ProvidersActorsRelationships from 'nomad-ui/components/providers/actors-relationships';
+
+export const EvaluationSidebarRelatedEvaluations =
+
+
+ Related Evaluations
+
+
+
+;
+
+export default EvaluationSidebarRelatedEvaluations;
diff --git a/ui/app/components/evaluation-sidebar/related-evaluations.hbs b/ui/app/components/evaluation-sidebar/related-evaluations.hbs
deleted file mode 100644
index 6cfe44c316c..00000000000
--- a/ui/app/components/evaluation-sidebar/related-evaluations.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Related Evaluations
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/exec-terminal.gjs b/ui/app/components/exec-terminal.gjs
new file mode 100644
index 00000000000..34cf2c50423
--- /dev/null
+++ b/ui/app/components/exec-terminal.gjs
@@ -0,0 +1,80 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { FitAddon } from 'xterm-addon-fit';
+import { didInsert } from '@ember/render-modifiers';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+
+export default class ExecTerminal extends Component {
+ fitAddon = null;
+ beforeUnloadHandler = null;
+
+ get terminal() {
+ return this.args.terminal;
+ }
+
+ get socketOpen() {
+ return this.args.socketOpen;
+ }
+
+ setupTerminal = (element) => {
+ if (!this.terminal) {
+ return;
+ }
+
+ const fitAddon = new FitAddon();
+ this.fitAddon = fitAddon;
+ this.terminal.loadAddon(fitAddon);
+ this.terminal.open(element);
+ fitAddon.fit();
+ this.addExitHandler();
+ };
+
+ addExitHandler = () => {
+ if (this.beforeUnloadHandler) {
+ return;
+ }
+
+ this.beforeUnloadHandler = (event) => this.confirmExit(event);
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
+ };
+
+ removeExitHandler = () => {
+ if (!this.beforeUnloadHandler) {
+ return;
+ }
+
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
+ this.beforeUnloadHandler = null;
+ };
+
+ confirmExit(event) {
+ if (this.socketOpen) {
+ event.preventDefault();
+ return (event.returnValue = 'Are you sure you want to exit?');
+ }
+ }
+
+ windowResizeHandler = (event) => {
+ this.fitAddon?.fit();
+ this.terminal?.resized?.(event);
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.removeExitHandler();
+ }
+
+
+
+
+
+
+}
diff --git a/ui/app/components/exec-terminal.hbs b/ui/app/components/exec-terminal.hbs
deleted file mode 100644
index 5c6435d2dc7..00000000000
--- a/ui/app/components/exec-terminal.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/exec-terminal.js b/ui/app/components/exec-terminal.js
deleted file mode 100644
index 69a9f3d0915..00000000000
--- a/ui/app/components/exec-terminal.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { FitAddon } from 'xterm-addon-fit';
-import WindowResizable from '../mixins/window-resizable';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-
-@classic
-@classNames('terminal-container')
-export default class ExecTerminal extends Component.extend(WindowResizable) {
- @service router;
-
- didInsertElement() {
- super.didInsertElement(...arguments);
- let fitAddon = new FitAddon();
- this.fitAddon = fitAddon;
- this.terminal.loadAddon(fitAddon);
-
- this.terminal.open(this.element.querySelector('.terminal'));
-
- fitAddon.fit();
- this.addExitHandler();
- }
-
- socketOpen = false;
- hasRemovedExitHandler = false;
-
- @action
- addExitHandler() {
- window.addEventListener('beforeunload', this.confirmExit.bind(this));
- }
- removeExitHandler() {
- if (!this.hasRemovedExitHandler) {
- window.removeEventListener('beforeunload', this.confirmExit.bind(this));
- this.hasRemovedExitHandler = true;
- }
- }
-
- /**
- *
- * @param {BeforeUnloadEvent} event
- * @returns {string}
- */
- confirmExit(event) {
- if (this.socketOpen) {
- event.preventDefault();
- return (event.returnValue = 'Are you sure you want to exit?');
- }
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.removeExitHandler();
- }
-
- windowResizeHandler(e) {
- this.fitAddon.fit();
- if (this.terminal.resized) {
- this.terminal.resized(e);
- }
- }
-}
diff --git a/ui/app/components/exec/open-button.gjs b/ui/app/components/exec/open-button.gjs
new file mode 100644
index 00000000000..bb959c3dc93
--- /dev/null
+++ b/ui/app/components/exec/open-button.gjs
@@ -0,0 +1,59 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { array } from '@ember/helper';
+import { or } from 'ember-truth-helpers';
+import { HdsButton } from '@hashicorp/design-system-components/components';
+import cannot from 'ember-can/helpers/cannot';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
+import openExecUrl from 'nomad-ui/utils/open-exec-url';
+
+export default class OpenButton extends Component {
+ @service router;
+
+ open = () => {
+ openExecUrl(this.generateUrl());
+ };
+
+ generateUrl() {
+ return generateExecUrl(this.router, {
+ job: this.args.job,
+ taskGroup: this.args.taskGroup,
+ task: this.args.task,
+ allocation: this.args.allocation,
+ });
+ }
+
+
+ {{#let
+ (cannot "exec allocation" namespace=(or @job.namespaceId @job.namespace))
+ as |cannotExec|
+ }}
+
+ {{/let}}
+
+}
diff --git a/ui/app/components/exec/open-button.hbs b/ui/app/components/exec/open-button.hbs
deleted file mode 100644
index 0be3378e553..00000000000
--- a/ui/app/components/exec/open-button.hbs
+++ /dev/null
@@ -1,30 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#let
- (cannot
- "exec allocation" namespace=(or this.job.namespaceId this.job.namespace)
- )
- as |cannotExec|
-}}
-
-{{/let}}
\ No newline at end of file
diff --git a/ui/app/components/exec/open-button.js b/ui/app/components/exec/open-button.js
deleted file mode 100644
index 52e4ad9d9de..00000000000
--- a/ui/app/components/exec/open-button.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
-import openExecUrl from 'nomad-ui/utils/open-exec-url';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class OpenButton extends Component {
- @service router;
-
- @action
- open() {
- openExecUrl(this.generateUrl());
- }
-
- generateUrl() {
- return generateExecUrl(this.router, {
- job: this.job,
- taskGroup: this.taskGroup,
- task: this.task,
- allocation: this.allocation,
- });
- }
-}
diff --git a/ui/app/components/exec/task-contents.gjs b/ui/app/components/exec/task-contents.gjs
new file mode 100644
index 00000000000..d02f0bc7a5b
--- /dev/null
+++ b/ui/app/components/exec/task-contents.gjs
@@ -0,0 +1,34 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+
+export const TaskContents =
+
+
+
+ {{#if @active}}
+
+ {{/if}}
+ {{@task.name}}
+
+
+ {{#if @shouldOpenInNewWindow}}
+
+
+
+ {{/if}}
+;
+
+export default TaskContents;
diff --git a/ui/app/components/exec/task-contents.hbs b/ui/app/components/exec/task-contents.hbs
deleted file mode 100644
index d4bdfb59e5f..00000000000
--- a/ui/app/components/exec/task-contents.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{#if this.active}}
-
- {{/if}}
- {{this.task.name}}
-
-
-{{#if this.shouldOpenInNewWindow}}
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/exec/task-contents.js b/ui/app/components/exec/task-contents.js
deleted file mode 100644
index 0914e2c2bfa..00000000000
--- a/ui/app/components/exec/task-contents.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class TaskContents extends Component {}
diff --git a/ui/app/components/exec/task-group-parent.gjs b/ui/app/components/exec/task-group-parent.gjs
new file mode 100644
index 00000000000..f054ad78c2e
--- /dev/null
+++ b/ui/app/components/exec/task-group-parent.gjs
@@ -0,0 +1,162 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { fn, array } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { and, eq } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import TaskContents from 'nomad-ui/components/exec/task-contents';
+import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
+import openExecUrl from 'nomad-ui/utils/open-exec-url';
+
+export default class TaskGroupParent extends Component {
+ @service router;
+
+ @tracked clickedOpen = false;
+
+ get currentRouteIsThisTaskGroup() {
+ const taskGroup = this.args.taskGroup;
+ const route = this.router.currentRoute;
+
+ if (!taskGroup || !route?.name?.includes('task-group')) {
+ return false;
+ }
+
+ const taskGroupRoute = route.parent;
+ const execRoute = taskGroupRoute?.parent;
+
+ return (
+ execRoute?.params?.job_name === taskGroup.job.name &&
+ taskGroupRoute?.params?.task_group_name === taskGroup.name
+ );
+ }
+
+ get isOpen() {
+ return this.clickedOpen || this.currentRouteIsThisTaskGroup;
+ }
+
+ get hasPendingAllocations() {
+ const allocations =
+ this.args.taskGroup?.allocations?.toArray?.() ||
+ this.args.taskGroup?.allocations;
+
+ return (allocations || []).some(
+ (allocation) => allocation.clientStatus === 'pending',
+ );
+ }
+
+ get allocationTaskStates() {
+ const allocations =
+ this.args.taskGroup?.allocations?.toArray?.() ||
+ this.args.taskGroup?.allocations;
+
+ return (allocations || []).reduce((accumulator, allocation) => {
+ const states =
+ allocation?.states?.toArray?.() || allocation?.states || [];
+ return accumulator.concat(states);
+ }, []);
+ }
+
+ get activeTaskStates() {
+ return this.allocationTaskStates.filter((taskState) => taskState?.isActive);
+ }
+
+ get tasksWithRunningStates() {
+ const taskGroup = this.args.taskGroup;
+ if (!taskGroup?.tasks) return [];
+
+ const activeTaskStateNames = this.activeTaskStates
+ .filter(
+ (taskState) => taskState?.task?.taskGroup?.name === taskGroup.name,
+ )
+ .map((taskState) => taskState.name);
+
+ return taskGroup.tasks.filter((task) =>
+ activeTaskStateNames.includes(task.name),
+ );
+ }
+
+ get sortedTasks() {
+ return this.tasksWithRunningStates
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ toggleOpen = () => {
+ this.clickedOpen = !this.clickedOpen;
+ };
+
+ openInNewWindow = (job, taskGroup, task) => {
+ const url = generateExecUrl(this.router, {
+ job,
+ taskGroup,
+ task,
+ });
+
+ openExecUrl(url);
+ };
+
+
+
+ {{#if this.isOpen}}
+
+ {{#each this.sortedTasks as |task|}}
+ {{#if @shouldOpenInNewWindow}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/each}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/exec/task-group-parent.hbs b/ui/app/components/exec/task-group-parent.hbs
deleted file mode 100644
index 6d4c4e115cf..00000000000
--- a/ui/app/components/exec/task-group-parent.hbs
+++ /dev/null
@@ -1,64 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-{{#if this.isOpen}}
-
- {{#each this.sortedTasks as |task|}}
- {{#if this.shouldOpenInNewWindow}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
- {{/each}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/exec/task-group-parent.js b/ui/app/components/exec/task-group-parent.js
deleted file mode 100644
index 0782dcad35a..00000000000
--- a/ui/app/components/exec/task-group-parent.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { action, computed } from '@ember/object';
-import { filterBy, mapBy, or, sort } from '@ember/object/computed';
-import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
-import openExecUrl from 'nomad-ui/utils/open-exec-url';
-import classic from 'ember-classic-decorator';
-
-@classic
-export default class TaskGroupParent extends Component {
- @service router;
-
- @or('clickedOpen', 'currentRouteIsThisTaskGroup') isOpen;
-
- @computed('router.currentRoute', 'taskGroup.{job.name,name}')
- get currentRouteIsThisTaskGroup() {
- const route = this.router.currentRoute;
-
- if (route.name.includes('task-group')) {
- const taskGroupRoute = route.parent;
- const execRoute = taskGroupRoute.parent;
-
- return (
- execRoute.params.job_name === this.taskGroup.job.name &&
- taskGroupRoute.params.task_group_name === this.taskGroup.name
- );
- } else {
- return false;
- }
- }
-
- @computed('taskGroup.allocations.@each.clientStatus')
- get hasPendingAllocations() {
- const allocations =
- this.taskGroup.allocations?.toArray?.() || this.taskGroup.allocations;
- return (allocations || []).some(
- (allocation) => allocation.clientStatus === 'pending',
- );
- }
-
- @mapBy('taskGroup.allocations', 'states') allocationTaskStatesRecordArrays;
- @computed('allocationTaskStatesRecordArrays.[]')
- get allocationTaskStates() {
- const flattenRecordArrays = (accumulator, recordArray) =>
- accumulator.concat(recordArray?.toArray?.() || recordArray || []);
- return this.allocationTaskStatesRecordArrays.reduce(
- flattenRecordArrays,
- [],
- );
- }
-
- @filterBy('allocationTaskStates', 'isActive') activeTaskStates;
-
- @mapBy('activeTaskStates', 'task') activeTasks;
- @mapBy('activeTasks', 'taskGroup') activeTaskGroups;
-
- @computed(
- 'activeTaskGroups.@each.name',
- 'activeTaskStates.@each.name',
- 'activeTasks.@each.name',
- 'taskGroup.{name,tasks}',
- )
- get tasksWithRunningStates() {
- const activeTaskStateNames = this.activeTaskStates
- .filter((taskState) => {
- return (
- taskState.task &&
- taskState.task.taskGroup.name === this.taskGroup.name
- );
- })
- .mapBy('name');
-
- return this.taskGroup.tasks.filter((task) =>
- activeTaskStateNames.includes(task.name),
- );
- }
-
- taskSorting = ['name'];
- @sort('tasksWithRunningStates', 'taskSorting') sortedTasks;
-
- clickedOpen = false;
-
- @action
- toggleOpen() {
- this.toggleProperty('clickedOpen');
- }
-
- @action
- openInNewWindow(job, taskGroup, task) {
- let url = generateExecUrl(this.router, {
- job,
- taskGroup,
- task,
- });
-
- openExecUrl(url);
- }
-}
diff --git a/ui/app/components/flex-masonry.js b/ui/app/components/flex-masonry.gjs
similarity index 75%
rename from ui/app/components/flex-masonry.js
rename to ui/app/components/flex-masonry.gjs
index ba1e295d3d6..4a699ac0b42 100644
--- a/ui/app/components/flex-masonry.js
+++ b/ui/app/components/flex-masonry.gjs
@@ -6,19 +6,18 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
-import { action } from '@ember/object';
import { minIndex, max } from 'd3-array';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import windowResize from 'nomad-ui/modifiers/window-resize';
export default class FlexMasonry extends Component {
@tracked element = null;
- @action
- captureElement(element) {
+ captureElement = (element) => {
this.element = element;
- }
+ };
- @action
- reflow() {
+ reflow = () => {
next(() => {
// There's nothing to do if there is no element
if (!this.element) return;
@@ -66,7 +65,7 @@ export default class FlexMasonry extends Component {
});
// Guarantee column wrapping as predicted (if the first item of a column is shorter than the difference
- // beteen the height of the column and the previous column, then flexbox will naturally place the first
+ // between the height of the column and the previous column, then flexbox will naturally place the first
// item at the end of the previous column).
columns.forEach((column, index) => {
const nextHeight =
@@ -81,5 +80,25 @@ export default class FlexMasonry extends Component {
// Set the max height of the container to the height of the tallest column
this.element.style.maxHeight = max(columns.mapBy('height')) + 1 + 'px';
});
- }
+ };
+
+
+
+ {{#each @items as |item|}}
+
+ {{yield item this.reflow}}
+
+ {{/each}}
+
+
}
diff --git a/ui/app/components/flex-masonry.hbs b/ui/app/components/flex-masonry.hbs
deleted file mode 100644
index 7e0736a34f3..00000000000
--- a/ui/app/components/flex-masonry.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#each @items as |item|}}
-
- {{yield item this.reflow}}
-
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/forbidden-message.gjs b/ui/app/components/forbidden-message.gjs
new file mode 100644
index 00000000000..d4159ba3bc2
--- /dev/null
+++ b/ui/app/components/forbidden-message.gjs
@@ -0,0 +1,102 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { not } from 'ember-truth-helpers';
+import conditionallyCapitalize from 'nomad-ui/helpers/conditionally-capitalize';
+
+export default class ForbiddenMessage extends Component {
+ @service token;
+ @service store;
+ @service router;
+
+ forbiddenOriginPath = null;
+
+ constructor() {
+ super(...arguments);
+
+ const currentURL = this.router.currentURL;
+ if (
+ !this.forbiddenOriginPath &&
+ currentURL &&
+ currentURL !== '/settings/tokens'
+ ) {
+ this.forbiddenOriginPath = currentURL;
+ }
+
+ if (currentURL && currentURL !== '/settings/tokens') {
+ if (!this.token.postExpiryPath) {
+ this.token.postExpiryPath = currentURL;
+ }
+ if (!this.token.forbiddenReturnPath) {
+ this.token.forbiddenReturnPath = currentURL;
+ }
+ }
+ }
+
+ get authMethods() {
+ return this.store.findAll('auth-method');
+ }
+
+
+
+
+}
diff --git a/ui/app/components/forbidden-message.hbs b/ui/app/components/forbidden-message.hbs
deleted file mode 100644
index fb40e89dae5..00000000000
--- a/ui/app/components/forbidden-message.hbs
+++ /dev/null
@@ -1,58 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/forbidden-message.js b/ui/app/components/forbidden-message.js
deleted file mode 100644
index d5dc7c1cb98..00000000000
--- a/ui/app/components/forbidden-message.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-
-@tagName('')
-export default class ForbiddenMessage extends Component {
- @service token;
- @service store;
- @service router;
-
- forbiddenOriginPath = null;
-
- didReceiveAttrs() {
- super.didReceiveAttrs(...arguments);
-
- const currentURL = this.router.currentURL;
- if (
- !this.forbiddenOriginPath &&
- currentURL &&
- currentURL !== '/settings/tokens'
- ) {
- this.forbiddenOriginPath = currentURL;
- }
-
- if (currentURL && currentURL !== '/settings/tokens') {
- if (!this.token.postExpiryPath) {
- this.token.postExpiryPath = currentURL;
- }
- if (!this.token.forbiddenReturnPath) {
- this.token.forbiddenReturnPath = currentURL;
- }
- }
- }
-
- @action
- rememberPostExpiryPath() {
- const currentURL = this.forbiddenOriginPath || this.router.currentURL;
-
- if (!currentURL || currentURL === '/settings/tokens') {
- return;
- }
-
- this.token.postExpiryPath = currentURL;
- this.token.forbiddenReturnPath = currentURL;
- }
-
- get authMethods() {
- return this.store.findAll('auth-method');
- }
-}
diff --git a/ui/app/components/fs/breadcrumbs.gjs b/ui/app/components/fs/breadcrumbs.gjs
new file mode 100644
index 00000000000..82b83670528
--- /dev/null
+++ b/ui/app/components/fs/breadcrumbs.gjs
@@ -0,0 +1,62 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import FsLink from 'nomad-ui/components/fs/link';
+
+export default class Breadcrumbs extends Component {
+ get breadcrumbs() {
+ const path = this.args.path ?? '';
+ const breadcrumbs = path
+ .split('/')
+ .filter(Boolean)
+ .reduce((items, pathSegment, index) => {
+ let breadcrumbPath;
+
+ if (index > 0) {
+ const lastBreadcrumb = items[index - 1];
+ breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
+ } else {
+ breadcrumbPath = pathSegment;
+ }
+
+ items.push({
+ name: pathSegment,
+ path: breadcrumbPath,
+ });
+
+ return items;
+ }, []);
+
+ if (breadcrumbs.length) {
+ breadcrumbs[breadcrumbs.length - 1].isLast = true;
+ }
+
+ return breadcrumbs;
+ }
+
+
+
+
+}
diff --git a/ui/app/components/fs/breadcrumbs.hbs b/ui/app/components/fs/breadcrumbs.hbs
deleted file mode 100644
index 5e9438c4ece..00000000000
--- a/ui/app/components/fs/breadcrumbs.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- -
-
- {{if this.taskState this.taskState.name this.allocation.shortId}}
-
-
- {{#each this.breadcrumbs as |breadcrumb|}}
- -
-
- {{breadcrumb.name}}
-
-
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/fs/breadcrumbs.js b/ui/app/components/fs/breadcrumbs.js
deleted file mode 100644
index ced90af81a7..00000000000
--- a/ui/app/components/fs/breadcrumbs.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { isEmpty } from '@ember/utils';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('nav')
-@classNames('breadcrumb')
-@attributeBindings('data-test-fs-breadcrumbs')
-export default class Breadcrumbs extends Component {
- 'data-test-fs-breadcrumbs' = true;
-
- allocation = null;
- taskState = null;
- path = null;
-
- @computed('path')
- get breadcrumbs() {
- const breadcrumbs = this.path
- .split('/')
- .reject(isEmpty)
- .reduce((breadcrumbs, pathSegment, index) => {
- let breadcrumbPath;
-
- if (index > 0) {
- const lastBreadcrumb = breadcrumbs[index - 1];
- breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
- } else {
- breadcrumbPath = pathSegment;
- }
-
- breadcrumbs.push({
- name: pathSegment,
- path: breadcrumbPath,
- });
-
- return breadcrumbs;
- }, []);
-
- if (breadcrumbs.length) {
- breadcrumbs[breadcrumbs.length - 1].isLast = true;
- }
-
- return breadcrumbs;
- }
-}
diff --git a/ui/app/components/fs/browser.gjs b/ui/app/components/fs/browser.gjs
new file mode 100644
index 00000000000..ceedc337628
--- /dev/null
+++ b/ui/app/components/fs/browser.gjs
@@ -0,0 +1,143 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import FsBreadcrumbs from 'nomad-ui/components/fs/breadcrumbs';
+import FsDirectoryEntry from 'nomad-ui/components/fs/directory-entry';
+import FsFile from 'nomad-ui/components/fs/file';
+import ListTable from 'nomad-ui/components/list-table';
+
+export default class Browser extends Component {
+ get allocation() {
+ return this.args.model?.allocation || this.args.model;
+ }
+
+ get taskState() {
+ if (this.args.model?.allocation) {
+ return this.args.model;
+ }
+
+ return undefined;
+ }
+
+ get directoryEntriesArray() {
+ return (
+ this.args.directoryEntries?.toArray?.() ||
+ this.args.directoryEntries ||
+ []
+ );
+ }
+
+ get directories() {
+ return this.directoryEntriesArray.filter((entry) => entry.IsDir);
+ }
+
+ get files() {
+ return this.directoryEntriesArray.filter((entry) => !entry.IsDir);
+ }
+
+ get sortedDirectoryEntries() {
+ const sortProperty = this.args.sortProperty;
+ const directorySortProperty =
+ sortProperty === 'Size' ? 'Name' : sortProperty;
+
+ const sortedDirectories = this.directories
+ .slice()
+ .sort((left, right) =>
+ compareEntries(left, right, directorySortProperty),
+ );
+ const sortedFiles = this.files
+ .slice()
+ .sort((left, right) => compareEntries(left, right, sortProperty));
+
+ const sortedDirectoryEntries = sortedDirectories.concat(sortedFiles);
+
+ if (this.args.sortDescending) {
+ return sortedDirectoryEntries.reverse();
+ }
+
+ return sortedDirectoryEntries;
+ }
+
+
+
+ {{#if @isFile}}
+
+
+
+ {{else}}
+
+
+
+
+ {{#if @directoryEntries}}
+
+
+ Name
+ File Size
+ Last Modified
+
+
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+
+ {{/if}}
+
+
+}
+
+function compareEntries(left, right, sortProperty) {
+ const leftValue = left?.[sortProperty];
+ const rightValue = right?.[sortProperty];
+
+ if (typeof leftValue === 'string' && typeof rightValue === 'string') {
+ return leftValue.localeCompare(rightValue);
+ }
+
+ if (leftValue === rightValue) {
+ return 0;
+ }
+
+ return leftValue > rightValue ? 1 : -1;
+}
diff --git a/ui/app/components/fs/browser.hbs b/ui/app/components/fs/browser.hbs
deleted file mode 100644
index f670772d621..00000000000
--- a/ui/app/components/fs/browser.hbs
+++ /dev/null
@@ -1,67 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.isFile}}
-
-
-
- {{else}}
-
-
-
-
- {{#if this.directoryEntries}}
-
-
- Name
- File Size
- Last Modified
-
-
-
-
-
- {{else}}
-
-
-
- {{/if}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/fs/browser.js b/ui/app/components/fs/browser.js
deleted file mode 100644
index 64a15b22226..00000000000
--- a/ui/app/components/fs/browser.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { filterBy } from '@ember/object/computed';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class Browser extends Component {
- model = null;
-
- @computed('model.allocation')
- get allocation() {
- if (this.model.allocation) {
- return this.model.allocation;
- } else {
- return this.model;
- }
- }
-
- @computed('model.allocation')
- get taskState() {
- if (this.model.allocation) {
- return this.model;
- }
-
- return undefined;
- }
-
- @computed('taskState')
- get type() {
- if (this.taskState) {
- return 'task';
- } else {
- return 'allocation';
- }
- }
-
- @filterBy('directoryEntries', 'IsDir') directories;
- @filterBy('directoryEntries', 'IsDir', false) files;
-
- @computed(
- 'directories',
- 'directoryEntries.[]',
- 'files',
- 'sortDescending',
- 'sortProperty',
- )
- get sortedDirectoryEntries() {
- const sortProperty = this.sortProperty;
-
- const directorySortProperty =
- sortProperty === 'Size' ? 'Name' : sortProperty;
-
- const sortedDirectories = this.directories.sortBy(directorySortProperty);
- const sortedFiles = this.files.sortBy(sortProperty);
-
- const sortedDirectoryEntries = sortedDirectories.concat(sortedFiles);
-
- if (this.sortDescending) {
- return sortedDirectoryEntries.reverse();
- } else {
- return sortedDirectoryEntries;
- }
- }
-}
diff --git a/ui/app/components/fs/directory-entry.gjs b/ui/app/components/fs/directory-entry.gjs
new file mode 100644
index 00000000000..49bd3c85be1
--- /dev/null
+++ b/ui/app/components/fs/directory-entry.gjs
@@ -0,0 +1,53 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import momentFrom from 'ember-moment/helpers/moment-from';
+import formatBytes from 'nomad-ui/helpers/format-bytes';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import FsLink from 'nomad-ui/components/fs/link';
+
+export default class DirectoryEntry extends Component {
+ get pathToEntry() {
+ const path = this.args.path ?? '';
+ const pathWithNoLeadingSlash = path.replace(/^\//, '');
+ const name = encodeURIComponent(this.args.entry.Name);
+
+ if (!pathWithNoLeadingSlash) {
+ return name;
+ }
+
+ return `${pathWithNoLeadingSlash}/${name}`;
+ }
+
+
+
+
+
+ {{#if @entry.IsDir}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{@entry.Name}}
+
+
+
+ {{#unless @entry.IsDir}}{{formatBytes @entry.Size}}{{/unless}}
+
+ {{momentFrom @entry.ModTime interval=1000}}
+
+
+}
diff --git a/ui/app/components/fs/directory-entry.hbs b/ui/app/components/fs/directory-entry.hbs
deleted file mode 100644
index 3465d75bc18..00000000000
--- a/ui/app/components/fs/directory-entry.hbs
+++ /dev/null
@@ -1,30 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{#if this.entry.IsDir}}
-
- {{else}}
-
- {{/if}}
-
- {{this.entry.Name}}
-
-
- {{#unless
- this.entry.IsDir
- }}{{format-bytes this.entry.Size}}{{/unless}}
- {{moment-from this.entry.ModTime interval=1000}}
-
\ No newline at end of file
diff --git a/ui/app/components/fs/directory-entry.js b/ui/app/components/fs/directory-entry.js
deleted file mode 100644
index 7d1c176b67f..00000000000
--- a/ui/app/components/fs/directory-entry.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { isEmpty } from '@ember/utils';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class DirectoryEntry extends Component {
- allocation = null;
- taskState = null;
-
- @computed('path', 'entry.Name')
- get pathToEntry() {
- const pathWithNoLeadingSlash = this.path.replace(/^\//, '');
- const name = encodeURIComponent(this.get('entry.Name'));
-
- if (isEmpty(pathWithNoLeadingSlash)) {
- return name;
- } else {
- return `${pathWithNoLeadingSlash}/${name}`;
- }
- }
-}
diff --git a/ui/app/components/fs/file.gjs b/ui/app/components/fs/file.gjs
new file mode 100644
index 00000000000..e9ece03e879
--- /dev/null
+++ b/ui/app/components/fs/file.gjs
@@ -0,0 +1,323 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { service } from '@ember/service';
+import { get } from '@ember/object';
+import { macroCondition, isTesting } from '@embroider/macros';
+import { and } from 'ember-truth-helpers';
+import { eq } from 'ember-truth-helpers';
+import { on } from '@ember/modifier';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import RSVP from 'rsvp';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import ImageFile from 'nomad-ui/components/image-file';
+import StreamingFile from 'nomad-ui/components/streaming-file';
+import Log from 'nomad-ui/utils/classes/log';
+import timeout from 'nomad-ui/utils/timeout';
+
+export default class File extends Component {
+ @service token;
+ @service system;
+
+ @tracked useServer = false;
+ @tracked noConnection = false;
+ @tracked mode = 'head';
+ @tracked isStreaming = false;
+ @tracked logger = null;
+
+ clientTimeout = 1000;
+ serverTimeout = 5000;
+
+ get fileComponent() {
+ const contentType = this.args.stat?.ContentType || '';
+
+ if (contentType.startsWith('image/')) {
+ return 'image';
+ } else if (
+ contentType.startsWith('text/') ||
+ contentType.startsWith('application/json')
+ ) {
+ return 'stream';
+ } else {
+ return 'unknown';
+ }
+ }
+
+ get isLarge() {
+ return this.args.stat?.Size > 50000;
+ }
+
+ get fileTypeIsUnknown() {
+ return this.fileComponent === 'unknown';
+ }
+
+ get isStreamable() {
+ return this.fileComponent === 'stream';
+ }
+
+ get catUrlWithoutRegion() {
+ const taskUrlPrefix = this.args.taskState
+ ? `${this.args.taskState.name}/`
+ : '';
+ const encodedPath = encodeURIComponent(`${taskUrlPrefix}${this.args.file}`);
+ return `/v1/client/fs/cat/${this.args.allocation.id}?path=${encodedPath}`;
+ }
+
+ get catUrl() {
+ let apiPath = this.catUrlWithoutRegion;
+ const activeRegion = this.system.activeRegion;
+
+ if (this.system.shouldIncludeRegion && activeRegion) {
+ apiPath += `®ion=${activeRegion}`;
+ }
+ return apiPath;
+ }
+
+ get fetchMode() {
+ if (this.mode === 'streaming') {
+ return 'stream';
+ }
+
+ if (!this.isLarge) {
+ return 'cat';
+ } else if (this.mode === 'head' || this.mode === 'tail') {
+ return 'readat';
+ }
+
+ return undefined;
+ }
+
+ get allocationId() {
+ return get(this.args.allocation, 'id');
+ }
+
+ get nodeHttpAddr() {
+ return get(this.args.allocation, 'node.httpAddr');
+ }
+
+ get fileUrl() {
+ const address = this.nodeHttpAddr;
+ const url = `/v1/client/fs/${this.fetchMode}/${this.allocationId}`;
+ return this.useServer ? url : `//${address}${url}`;
+ }
+
+ get fileParams() {
+ const taskUrlPrefix = this.args.taskState
+ ? `${this.args.taskState.name}/`
+ : '';
+ const path = `${taskUrlPrefix}${this.args.file}`;
+
+ switch (this.mode) {
+ case 'head':
+ return { path, offset: 0, limit: 50000 };
+ case 'tail':
+ return { path, offset: this.args.stat.Size - 50000, limit: 50000 };
+ case 'streaming':
+ return { path, offset: 50000, origin: 'end' };
+ default:
+ return { path };
+ }
+ }
+
+ buildLogger = () => {
+ const plainText = this.mode === 'head' || this.mode === 'tail';
+ const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
+ const logFetch = (url) =>
+ RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
+ (response) => {
+ if (!response || !response.ok) {
+ this.nextErrorState(response);
+ }
+ return response;
+ },
+ (error) => this.nextErrorState(error),
+ );
+
+ return Log.create({
+ logFetch,
+ plainText,
+ params: this.fileParams,
+ url: this.fileUrl,
+ });
+ };
+
+ refreshLogger = () => {
+ this.logger?.stop();
+ this.logger = this.buildLogger();
+ };
+
+ nextErrorState(error) {
+ if (this.useServer) {
+ this.noConnection = true;
+ } else {
+ this.failoverToServer();
+ }
+ throw error;
+ }
+
+ toggleStream = () => {
+ this.mode = 'streaming';
+ this.isStreaming = !this.isStreaming;
+ this.refreshLogger();
+ };
+
+ gotoHead = () => {
+ this.mode = 'head';
+ this.isStreaming = false;
+ this.refreshLogger();
+ };
+
+ gotoTail = () => {
+ this.mode = 'tail';
+ this.isStreaming = false;
+ this.refreshLogger();
+ };
+
+ failoverToServer = () => {
+ this.useServer = true;
+ this.refreshLogger();
+ };
+
+ downloadFile = async () => {
+ const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
+
+ try {
+ const response = await RSVP.race([
+ this.token.authorizedRequest(this.catUrl),
+ timeout(timing),
+ ]);
+
+ if (!response || !response.ok) throw new Error('file download timeout');
+
+ if (macroCondition(isTesting())) return;
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const downloadAnchor = document.createElement('a');
+
+ downloadAnchor.href = url;
+ downloadAnchor.target = '_blank';
+ downloadAnchor.rel = 'noopener noreferrer';
+ downloadAnchor.download = this.args.file;
+
+ document.body.appendChild(downloadAnchor);
+ downloadAnchor.click();
+ downloadAnchor.remove();
+
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ this.nextErrorState(err);
+ }
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.logger?.stop();
+ }
+
+
+
+ {{#if this.noConnection}}
+
+ Cannot fetch file
+ The files for this
+ {{if @taskState "task" "allocation"}}
+ are inaccessible. Check the condition of the client the allocation
+ is on.
+
+ {{/if}}
+
+ {{yield}}
+
+
+ {{#unless this.fileTypeIsUnknown}}
+
+ {{/unless}}
+ {{#if (and this.isLarge this.isStreamable)}}
+
+
+ {{/if}}
+ {{#if this.isStreamable}}
+
+ {{/if}}
+
+
+
+ {{#if (eq this.fileComponent "stream")}}
+
+ {{else if (eq this.fileComponent "image")}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/fs/file.hbs b/ui/app/components/fs/file.hbs
deleted file mode 100644
index 5739ab50984..00000000000
--- a/ui/app/components/fs/file.hbs
+++ /dev/null
@@ -1,88 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.noConnection}}
-
- Cannot fetch file
- The files for this
- {{if this.task "task" "allocation"}}
- are inaccessible. Check the condition of the client the allocation is on.
-
-{{/if}}
-
- {{yield}}
-
-
- {{#unless this.fileTypeIsUnknown}}
-
- {{/unless}}
- {{#if (and this.isLarge this.isStreamable)}}
-
-
- {{/if}}
- {{#if this.isStreamable}}
-
- {{/if}}
-
-
-
- {{#if (eq this.fileComponent "stream")}}
-
- {{else if (eq this.fileComponent "image")}}
-
- {{else}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/fs/file.js b/ui/app/components/fs/file.js
deleted file mode 100644
index 1d4f7e6f5f0..00000000000
--- a/ui/app/components/fs/file.js
+++ /dev/null
@@ -1,224 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { macroCondition, isTesting } from '@embroider/macros';
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { equal, gt } from '@ember/object/computed';
-import RSVP from 'rsvp';
-import Log from 'nomad-ui/utils/classes/log';
-import timeout from 'nomad-ui/utils/timeout';
-import { classNames, attributeBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('boxed-section', 'task-log')
-@attributeBindings('data-test-file-viewer')
-export default class File extends Component {
- @service token;
- @service system;
-
- 'data-test-file-viewer' = true;
-
- allocation = null;
- taskState = null;
- file = null;
- stat = null; // { Name, IsDir, Size, FileMode, ModTime, ContentType }
-
- // When true, request logs from the server agent
- useServer = false;
-
- // When true, logs cannot be fetched from either the client or the server
- noConnection = false;
-
- clientTimeout = 1000;
- serverTimeout = 5000;
-
- mode = 'head';
-
- @computed('stat.ContentType')
- get fileComponent() {
- const contentType = this.stat.ContentType || '';
-
- if (contentType.startsWith('image/')) {
- return 'image';
- } else if (
- contentType.startsWith('text/') ||
- contentType.startsWith('application/json')
- ) {
- return 'stream';
- } else {
- return 'unknown';
- }
- }
-
- @gt('stat.Size', 50000) isLarge;
-
- @equal('fileComponent', 'unknown') fileTypeIsUnknown;
- @equal('fileComponent', 'stream') isStreamable;
- isStreaming = false;
-
- @computed('allocation.id', 'taskState.name', 'file')
- get catUrlWithoutRegion() {
- const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : '';
- const encodedPath = encodeURIComponent(`${taskUrlPrefix}${this.file}`);
- return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`;
- }
-
- @computed('catUrlWithoutRegion', 'system.{activeRegion,shouldIncludeRegion}')
- get catUrl() {
- let apiPath = this.catUrlWithoutRegion;
- const activeRegion = this.system.activeRegion;
-
- if (this.system.shouldIncludeRegion && activeRegion) {
- apiPath += `®ion=${activeRegion}`;
- }
- return apiPath;
- }
-
- @computed('isLarge', 'mode')
- get fetchMode() {
- if (this.mode === 'streaming') {
- return 'stream';
- }
-
- if (!this.isLarge) {
- return 'cat';
- } else if (this.mode === 'head' || this.mode === 'tail') {
- return 'readat';
- }
-
- return undefined;
- }
-
- @computed('allocation.{id,node.httpAddr}', 'fetchMode', 'useServer')
- get fileUrl() {
- const address = this.get('allocation.node.httpAddr');
- const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`;
- return this.useServer ? url : `//${address}${url}`;
- }
-
- @computed('file', 'mode', 'stat.Size', 'taskState.name')
- get fileParams() {
- // The Log class handles encoding query params
- const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : '';
- const path = `${taskUrlPrefix}${this.file}`;
-
- switch (this.mode) {
- case 'head':
- return { path, offset: 0, limit: 50000 };
- case 'tail':
- return { path, offset: this.stat.Size - 50000, limit: 50000 };
- case 'streaming':
- return { path, offset: 50000, origin: 'end' };
- default:
- return { path };
- }
- }
-
- @computed(
- 'clientTimeout',
- 'fileParams',
- 'fileUrl',
- 'mode',
- 'serverTimeout',
- 'useServer',
- )
- get logger() {
- // The cat and readat APIs are in plainText while the stream API is always encoded.
- const plainText = this.mode === 'head' || this.mode === 'tail';
-
- // If the file request can't settle in one second, the client
- // must be unavailable and the server should be used instead
- const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
- const logFetch = (url) =>
- RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
- (response) => {
- if (!response || !response.ok) {
- this.nextErrorState(response);
- }
- return response;
- },
- (error) => this.nextErrorState(error),
- );
-
- return Log.create({
- logFetch,
- plainText,
- params: this.fileParams,
- url: this.fileUrl,
- });
- }
-
- nextErrorState(error) {
- if (this.useServer) {
- this.set('noConnection', true);
- } else {
- this.send('failoverToServer');
- }
- throw error;
- }
-
- @action
- toggleStream() {
- this.set('mode', 'streaming');
- this.toggleProperty('isStreaming');
- }
-
- @action
- gotoHead() {
- this.set('mode', 'head');
- this.set('isStreaming', false);
- }
-
- @action
- gotoTail() {
- this.set('mode', 'tail');
- this.set('isStreaming', false);
- }
-
- @action
- failoverToServer() {
- this.set('useServer', true);
- }
-
- @action
- async downloadFile() {
- const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
-
- try {
- const response = await RSVP.race([
- this.token.authorizedRequest(this.catUrl),
- timeout(timing),
- ]);
-
- if (!response || !response.ok) throw new Error('file download timeout');
-
- // Don't download in tests. Unfortunately, since the download is triggered
- // by the download attribute of the ephemeral anchor element, there's no
- // way to stub this in tests.
- if (macroCondition(isTesting())) return;
-
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const downloadAnchor = document.createElement('a');
-
- downloadAnchor.href = url;
- downloadAnchor.target = '_blank';
- downloadAnchor.rel = 'noopener noreferrer';
- downloadAnchor.download = this.file;
-
- // Appending the element to the DOM is required for Firefox support
- document.body.appendChild(downloadAnchor);
- downloadAnchor.click();
- downloadAnchor.remove();
-
- window.URL.revokeObjectURL(url);
- } catch (err) {
- this.nextErrorState(err);
- }
- }
-}
diff --git a/ui/app/components/fs/link.gjs b/ui/app/components/fs/link.gjs
new file mode 100644
index 00000000000..6ac2278b8a8
--- /dev/null
+++ b/ui/app/components/fs/link.gjs
@@ -0,0 +1,49 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+
+export const FsLink =
+ {{#if @taskState}}
+ {{#if @path}}
+
+ {{yield}}
+
+ {{else}}
+
+ {{yield}}
+
+ {{/if}}
+ {{else}}
+ {{#if @path}}
+
+ {{yield}}
+
+ {{else}}
+
+ {{yield}}
+
+ {{/if}}
+ {{/if}}
+;
+
+export default FsLink;
diff --git a/ui/app/components/fs/link.hbs b/ui/app/components/fs/link.hbs
deleted file mode 100644
index 328a3dd6a70..00000000000
--- a/ui/app/components/fs/link.hbs
+++ /dev/null
@@ -1,42 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.taskState}}
- {{#if this.path}}
-
- {{yield}}
-
- {{else}}
-
- {{yield}}
-
- {{/if}}
-{{else}}
- {{#if this.path}}
-
- {{yield}}
-
- {{else}}
-
- {{yield}}
-
- {{/if}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/fs/link.js b/ui/app/components/fs/link.js
deleted file mode 100644
index de9b1627230..00000000000
--- a/ui/app/components/fs/link.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class Link extends Component {
- allocation = null;
- taskState = null;
-}
diff --git a/ui/app/components/gauge-chart.gjs b/ui/app/components/gauge-chart.gjs
new file mode 100644
index 00000000000..386dc1d07e6
--- /dev/null
+++ b/ui/app/components/gauge-chart.gjs
@@ -0,0 +1,162 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { assert } from '@ember/debug';
+import { guidFor } from '@ember/object/internals';
+import { once } from '@ember/runloop';
+import { concat } from '@ember/helper';
+import { didInsert } from '@ember/render-modifiers';
+import d3Shape from 'd3-shape';
+import formatPercentage from 'nomad-ui/helpers/format-percentage';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+
+export default class GaugeChart extends Component {
+ @tracked width = 0;
+ @tracked height = 0;
+
+ svgElement = null;
+ weight = 4;
+
+ get value() {
+ return this.args.value;
+ }
+
+ get complement() {
+ return this.args.complement;
+ }
+
+ get total() {
+ return this.args.total;
+ }
+
+ get label() {
+ return this.args.label;
+ }
+
+ get chartClass() {
+ return this.args.chartClass || 'is-info';
+ }
+
+ get percent() {
+ assert(
+ 'Provide complement OR total to GaugeChart, not both.',
+ this.complement != null || this.total != null,
+ );
+
+ if (this.complement != null) {
+ return this.value / (this.value + this.complement);
+ }
+
+ return this.value / this.total;
+ }
+
+ get fillId() {
+ return `gauge-chart-fill-${guidFor(this)}`;
+ }
+
+ get maskId() {
+ return `gauge-chart-mask-${guidFor(this)}`;
+ }
+
+ get radius() {
+ return this.width / 2;
+ }
+
+ get backgroundArc() {
+ const { radius, weight } = this;
+ const arc = d3Shape
+ .arc()
+ .outerRadius(radius)
+ .innerRadius(radius - weight)
+ .cornerRadius(weight)
+ .startAngle(-Math.PI / 2)
+ .endAngle(Math.PI / 2);
+
+ return arc();
+ }
+
+ get valueArc() {
+ const { radius, weight, percent } = this;
+ const arc = d3Shape
+ .arc()
+ .outerRadius(radius)
+ .innerRadius(radius - weight)
+ .cornerRadius(weight)
+ .startAngle(-Math.PI / 2)
+ .endAngle(-Math.PI / 2 + Math.PI * percent);
+
+ return arc();
+ }
+
+ setSvgElement = (element) => {
+ this.svgElement = element;
+ this.updateDimensions();
+ };
+
+ updateDimensions = () => {
+ const width = this.svgElement?.clientWidth || 0;
+ this.width = width;
+ this.height = width / 2;
+ };
+
+ handleResize = () => {
+ once(this, this.updateDimensions);
+ };
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{this.label}}
+ {{formatPercentage
+ this.value
+ total=this.total
+ complement=this.complement
+ }}
+
+
+
+}
diff --git a/ui/app/components/gauge-chart.hbs b/ui/app/components/gauge-chart.hbs
deleted file mode 100644
index 48b652f6b73..00000000000
--- a/ui/app/components/gauge-chart.hbs
+++ /dev/null
@@ -1,43 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{this.label}}
- {{format-percentage
- this.value
- total=this.total
- complement=this.complement
- }}
-
\ No newline at end of file
diff --git a/ui/app/components/gauge-chart.js b/ui/app/components/gauge-chart.js
deleted file mode 100644
index 28457c380cb..00000000000
--- a/ui/app/components/gauge-chart.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { assert } from '@ember/debug';
-import { guidFor } from '@ember/object/internals';
-import { once } from '@ember/runloop';
-import d3Shape from 'd3-shape';
-import WindowResizable from 'nomad-ui/mixins/window-resizable';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('chart', 'gauge-chart')
-export default class GaugeChart extends Component.extend(WindowResizable) {
- value = null;
- complement = null;
- total = null;
- chartClass = 'is-info';
-
- width = 0;
- height = 0;
-
- @computed('value', 'complement', 'total')
- get percent() {
- assert(
- 'Provide complement OR total to GaugeChart, not both.',
- this.complement != null || this.total != null,
- );
-
- if (this.complement != null) {
- return this.value / (this.value + this.complement);
- }
-
- return this.value / this.total;
- }
-
- @computed
- get fillId() {
- return `gauge-chart-fill-${guidFor(this)}`;
- }
-
- @computed
- get maskId() {
- return `gauge-chart-mask-${guidFor(this)}`;
- }
-
- @computed('width')
- get radius() {
- return this.width / 2;
- }
-
- weight = 4;
-
- @computed('radius', 'weight')
- get backgroundArc() {
- const { radius, weight } = this;
- const arc = d3Shape
- .arc()
- .outerRadius(radius)
- .innerRadius(radius - weight)
- .cornerRadius(weight)
- .startAngle(-Math.PI / 2)
- .endAngle(Math.PI / 2);
- return arc();
- }
-
- @computed('radius', 'weight', 'percent')
- get valueArc() {
- const { radius, weight, percent } = this;
-
- const arc = d3Shape
- .arc()
- .outerRadius(radius)
- .innerRadius(radius - weight)
- .cornerRadius(weight)
- .startAngle(-Math.PI / 2)
- .endAngle(-Math.PI / 2 + Math.PI * percent);
- return arc();
- }
-
- didInsertElement() {
- super.didInsertElement(...arguments);
- this.updateDimensions();
- }
-
- updateDimensions() {
- const width = this.element.querySelector('svg').clientWidth;
- this.setProperties({ width, height: width / 2 });
- }
-
- windowResizeHandler() {
- once(this, this.updateDimensions);
- }
-}
diff --git a/ui/app/components/global-header.gjs b/ui/app/components/global-header.gjs
new file mode 100644
index 00000000000..b0983b44273
--- /dev/null
+++ b/ui/app/components/global-header.gjs
@@ -0,0 +1,111 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { htmlSafe } from '@ember/template';
+import { service } from '@ember/service';
+import media from 'ember-responsive/helpers/media';
+import GlobalSearchControl from 'nomad-ui/components/global-search/control';
+import HamburgerMenu from 'nomad-ui/components/hamburger-menu';
+import NomadLogo from 'nomad-ui/components/nomad-logo';
+import ProfileNavbarItem from 'nomad-ui/components/profile-navbar-item';
+import RegionSwitcher from 'nomad-ui/components/region-switcher';
+
+export default class GlobalHeader extends Component {
+ @service system;
+
+ get onHamburgerClick() {
+ return this.args.onHamburgerClick ?? (() => {});
+ }
+
+ get labelStyles() {
+ return htmlSafe(
+ `
+ color: ${this.system.agent.get('config')?.UI?.Label?.TextColor};
+ background-color: ${
+ this.system.agent.get('config')?.UI?.Label?.BackgroundColor
+ };
+ `,
+ );
+ }
+
+
+
+ {{! template-lint-disable no-duplicate-landmark-elements }}
+
+
+
+
+}
diff --git a/ui/app/components/global-header.hbs b/ui/app/components/global-header.hbs
deleted file mode 100644
index 701c3edb55f..00000000000
--- a/ui/app/components/global-header.hbs
+++ /dev/null
@@ -1,76 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! template-lint-disable no-duplicate-landmark-elements }}
-
-
\ No newline at end of file
diff --git a/ui/app/components/global-header.js b/ui/app/components/global-header.js
deleted file mode 100644
index 0e66d229600..00000000000
--- a/ui/app/components/global-header.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import classic from 'ember-classic-decorator';
-import { service } from '@ember/service';
-import { attributeBindings } from '@ember-decorators/component';
-import { htmlSafe } from '@ember/template';
-
-@classic
-@attributeBindings('data-test-global-header')
-export default class GlobalHeader extends Component {
- @service config;
- @service system;
-
- 'data-test-global-header' = true;
- onHamburgerClick() {}
-
- get labelStyles() {
- return htmlSafe(
- `
- color: ${this.system.agent.get('config')?.UI?.Label?.TextColor};
- background-color: ${
- this.system.agent.get('config')?.UI?.Label?.BackgroundColor
- };
- `,
- );
- }
-}
diff --git a/ui/app/components/global-search/trigger.gjs b/ui/app/components/global-search/trigger.gjs
index 00803d3bf28..d0de4373bc2 100644
--- a/ui/app/components/global-search/trigger.gjs
+++ b/ui/app/components/global-search/trigger.gjs
@@ -3,24 +3,19 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import Component from '@glimmer/component';
import { or, not } from 'ember-truth-helpers';
import { HdsIcon } from '@hashicorp/design-system-components/components';
-export default class GlobalSearchTrigger extends Component {
- get select() {
- return this.args.select;
- }
+export const GlobalSearchTrigger =
+
-
-
+ {{#unless @select.isOpen}}
+ Jump to
+ {{/unless}}
- {{#unless this.select.isOpen}}
- Jump to
- {{/unless}}
+ {{#if (not (or @select.isActive @select.isOpen))}}
+ /
+ {{/if}}
+;
- {{#if (not (or this.select.isActive this.select.isOpen))}}
- /
- {{/if}}
-
-}
+export default GlobalSearchTrigger;
diff --git a/ui/app/components/gutter-menu.gjs b/ui/app/components/gutter-menu.gjs
new file mode 100644
index 00000000000..6ad6cd9da25
--- /dev/null
+++ b/ui/app/components/gutter-menu.gjs
@@ -0,0 +1,275 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import { didInsert } from '@ember/render-modifiers';
+import can from 'ember-can/helpers/can';
+import HamburgerMenu from 'nomad-ui/components/hamburger-menu';
+import NomadLogo from 'nomad-ui/components/nomad-logo';
+import RegionSwitcher from 'nomad-ui/components/region-switcher';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class GutterMenu extends Component {
+ @service system;
+ @service router;
+ @service keyboard;
+
+ mainMenuJobsShortcut = ['g', 'j'];
+ mainMenuOptimizeShortcut = ['g', 'o'];
+ mainMenuStorageShortcut = ['g', 'r'];
+ mainMenuVariablesShortcut = ['g', 'v'];
+ mainMenuClientsShortcut = ['g', 'c'];
+ mainMenuServersShortcut = ['g', 's'];
+ mainMenuTopologyShortcut = ['g', 't'];
+ mainMenuEvaluationsShortcut = ['g', 'e'];
+ mainMenuAdministrationShortcut = ['g', 'a'];
+
+ get isOpen() {
+ return this.args.isOpen;
+ }
+
+ get onHamburgerClick() {
+ return this.args.onHamburgerClick ?? (() => {});
+ }
+
+ get sortedNamespaces() {
+ const namespaces = this.system.namespaces?.toArray?.() || [];
+
+ return namespaces.sort((a, b) => {
+ const aName = a.get('name');
+ const bName = b.get('name');
+
+ // Keep default namespace first for parity with prior behavior.
+ if (aName === 'default') {
+ return -1;
+ }
+ if (bName === 'default') {
+ return 1;
+ }
+
+ if (aName < bName) {
+ return -1;
+ }
+ if (aName > bName) {
+ return 1;
+ }
+
+ return 0;
+ });
+ }
+
+ transitionTo = (destination) => {
+ return this.router.transitionTo(destination);
+ };
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if this.system.agent.version}}
+
+ {{/if}}
+
+
+
+ {{yield}}
+
+
+
+
+}
diff --git a/ui/app/components/gutter-menu.hbs b/ui/app/components/gutter-menu.hbs
deleted file mode 100644
index 194a596b5a3..00000000000
--- a/ui/app/components/gutter-menu.hbs
+++ /dev/null
@@ -1,170 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
- {{#if this.system.agent.version}}
-
- {{/if}}
-
-
-
- {{yield}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/gutter-menu.js b/ui/app/components/gutter-menu.js
deleted file mode 100644
index 94ee2189220..00000000000
--- a/ui/app/components/gutter-menu.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import classic from 'ember-classic-decorator';
-
-@classic
-export default class GutterMenu extends Component {
- @service system;
- @service router;
- @service keyboard;
-
- @computed('system.namespaces.@each.name')
- get sortedNamespaces() {
- const namespaces = this.get('system.namespaces').toArray() || [];
-
- return namespaces.sort((a, b) => {
- const aName = a.get('name');
- const bName = b.get('name');
-
- // Make sure the default namespace is always first in the list
- if (aName === 'default') {
- return -1;
- }
- if (bName === 'default') {
- return 1;
- }
-
- if (aName < bName) {
- return -1;
- }
- if (aName > bName) {
- return 1;
- }
-
- return 0;
- });
- }
-
- onHamburgerClick() {}
-
- // Seemingly redundant, but serves to ensure the action is passed to the keyboard service correctly
- @action
- transitionTo(destination) {
- return this.router.transitionTo(destination);
- }
-}
diff --git a/ui/app/components/hamburger-menu.gjs b/ui/app/components/hamburger-menu.gjs
new file mode 100644
index 00000000000..9149aabf988
--- /dev/null
+++ b/ui/app/components/hamburger-menu.gjs
@@ -0,0 +1,19 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export const HamburgerMenu =
+
+
+
+
+
+;
+
+export default HamburgerMenu;
diff --git a/ui/app/components/hamburger-menu.hbs b/ui/app/components/hamburger-menu.hbs
deleted file mode 100644
index ed5c5520f5b..00000000000
--- a/ui/app/components/hamburger-menu.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/hamburger-menu.js b/ui/app/components/hamburger-menu.js
deleted file mode 100644
index d1298f63001..00000000000
--- a/ui/app/components/hamburger-menu.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-
-@tagName('')
-export default class HamburgerMenu extends Component {}
diff --git a/ui/app/components/image-file.gjs b/ui/app/components/image-file.gjs
new file mode 100644
index 00000000000..14a2d50304a
--- /dev/null
+++ b/ui/app/components/image-file.gjs
@@ -0,0 +1,68 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import formatBytes from 'nomad-ui/helpers/format-bytes';
+
+export default class ImageFile extends Component {
+ @tracked width = 0;
+ @tracked height = 0;
+
+ get fileName() {
+ const src = this.args.src;
+ if (!src) return undefined;
+ return src.includes('/') ? src.match(/^.*\/(.*)$/)[1] : src;
+ }
+
+ get altText() {
+ return this.args.alt || this.fileName;
+ }
+
+ get hasDimensions() {
+ return this.width && this.height;
+ }
+
+ handleImageLoad = (event) => {
+ const img = event.target;
+ this.width = img.naturalWidth;
+ this.height = img.naturalHeight;
+
+ if (typeof this.args.updateImageMeta === 'function') {
+ this.args.updateImageMeta(event);
+ }
+ };
+
+
+
+
+
+
+
+
+ {{this.fileName}}
+ {{#if this.hasDimensions}}
+ ({{this.width}}px ×
+ {{this.height}}px{{#if @size}},
+ {{formatBytes @size}}{{/if}})
+ {{/if}}
+
+
+
+
+}
diff --git a/ui/app/components/image-file.hbs b/ui/app/components/image-file.hbs
deleted file mode 100644
index d39ab2530b5..00000000000
--- a/ui/app/components/image-file.hbs
+++ /dev/null
@@ -1,30 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- {{this.fileName}}
- {{#if (and this.width this.height)}}
- ({{this.width}}px ×
- {{this.height}}px{{#if this.size}},
- {{format-bytes this.size}}{{/if}})
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/image-file.js b/ui/app/components/image-file.js
deleted file mode 100644
index edcd19b5790..00000000000
--- a/ui/app/components/image-file.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('figure')
-@classNames('image-file')
-@attributeBindings('data-test-image-file')
-export default class ImageFile extends Component {
- 'data-test-image-file' = true;
-
- src = null;
- alt = null;
- size = null;
-
- // Set by updateImageMeta
- width = 0;
- height = 0;
-
- @computed('src')
- get fileName() {
- if (!this.src) return undefined;
- return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src;
- }
-
- @action
- handleImageLoad(event) {
- this.updateImageMeta(event);
- }
-
- updateImageMeta(event) {
- const img = event.target;
- this.setProperties({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- }
-}
diff --git a/ui/app/components/job-client-status-bar.js b/ui/app/components/job-client-status-bar.gjs
similarity index 80%
rename from ui/app/components/job-client-status-bar.js
rename to ui/app/components/job-client-status-bar.gjs
index a924ca85a4f..ba5ea008ab7 100644
--- a/ui/app/components/job-client-status-bar.js
+++ b/ui/app/components/job-client-status-bar.gjs
@@ -3,22 +3,13 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { computed } from '@ember/object';
+import Component from '@glimmer/component';
import DistributionBar from './distribution-bar';
-import { attributeBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-@classic
-@attributeBindings('data-test-job-client-status-bar')
-export default class JobClientStatusBar extends DistributionBar {
- layoutName = 'components/distribution-bar';
-
- 'data-test-job-client-status-bar' = true;
- job = null;
- jobClientStatus = null;
-
- legendQueryParamsForStatus(status) {
- const namespace = this.job?.namespaceId || this.job?.namespace?.get?.('id');
+export default class JobClientStatusBar extends Component {
+ legendQueryParamsForStatus = (status) => {
+ const namespace =
+ this.args.job?.namespaceId || this.args.job?.namespace?.get?.('id');
const queryParams = {
status: JSON.stringify([status]),
page: 1,
@@ -32,10 +23,14 @@ export default class JobClientStatusBar extends DistributionBar {
}
return queryParams;
- }
+ };
- @computed('job.namespace', 'jobClientStatus.byStatus')
get data() {
+ const byStatus = this.args.jobClientStatus?.byStatus;
+ if (!byStatus) {
+ return [];
+ }
+
const {
queued,
starting,
@@ -46,7 +41,7 @@ export default class JobClientStatusBar extends DistributionBar {
lost,
notScheduled,
unknown,
- } = this.jobClientStatus.byStatus;
+ } = byStatus;
return [
{
@@ -127,4 +122,17 @@ export default class JobClientStatusBar extends DistributionBar {
},
];
}
+
+
+
+ {{yield chart}}
+
+
}
diff --git a/ui/app/components/job-client-status-row.js b/ui/app/components/job-client-status-row.gjs
similarity index 51%
rename from ui/app/components/job-client-status-row.js
rename to ui/app/components/job-client-status-row.gjs
index 12f0c9dc098..14c8dde1d11 100644
--- a/ui/app/components/job-client-status-row.js
+++ b/ui/app/components/job-client-status-row.gjs
@@ -5,6 +5,12 @@
import EmberObject from '@ember/object';
import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import AllocationStatusBar from 'nomad-ui/components/allocation-status-bar';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
export default class ClientRow extends Component {
// Attribute set in the template as @onClick.
@@ -14,6 +20,10 @@ export default class ClientRow extends Component {
return this.args.row.model;
}
+ get allocation() {
+ return this.args.allocation;
+ }
+
get shouldDisplayAllocationSummary() {
return this.args.row.model.jobStatus !== 'notScheduled';
}
@@ -95,4 +105,68 @@ export default class ClientRow extends Component {
});
return Allocations.create();
}
+
+
+
+
+
+ {{this.row.node.shortId}}
+
+
+
+ {{this.row.node.name}}
+
+
+ {{#if this.row.createTime}}
+
+ {{momentFromNow this.row.createTime}}
+
+ {{else}}
+ -
+ {{/if}}
+
+
+ {{#if this.row.modifyTime}}
+
+ {{momentFromNow this.row.modifyTime}}
+
+ {{else}}
+ -
+ {{/if}}
+
+
+
+ {{this.humanizedJobStatus}}
+
+
+ {{#if this.shouldDisplayAllocationSummary}}
+
+
+
+ {{else}}
+ {{this.allocationSummaryPlaceholder}}
+ {{/if}}
+
+
+
}
diff --git a/ui/app/components/job-client-status-row.hbs b/ui/app/components/job-client-status-row.hbs
deleted file mode 100644
index 966623496d9..00000000000
--- a/ui/app/components/job-client-status-row.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{this.row.node.shortId}}
-
-
-
- {{this.row.node.name}}
-
-
- {{#if this.row.createTime}}
-
- {{moment-from-now this.row.createTime}}
-
- {{else}}
- -
- {{/if}}
-
-
- {{#if this.row.modifyTime}}
-
- {{moment-from-now this.row.modifyTime}}
-
- {{else}}
- -
- {{/if}}
-
-
-
- {{this.humanizedJobStatus}}
-
-
- {{#if this.shouldDisplayAllocationSummary}}
-
-
-
- {{else}}
- {{this.allocationSummaryPlaceholder}}
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-deployment-details.gjs b/ui/app/components/job-deployment-details.gjs
new file mode 100644
index 00000000000..a9f36d3c895
--- /dev/null
+++ b/ui/app/components/job-deployment-details.gjs
@@ -0,0 +1,23 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { hash } from '@ember/helper';
+import JobDeploymentDeploymentAllocations from 'nomad-ui/components/job-deployment/deployment-allocations';
+import JobDeploymentDeploymentMetrics from 'nomad-ui/components/job-deployment/deployment-metrics';
+import JobDeploymentTaskGroups from 'nomad-ui/components/job-deployment/task-groups';
+
+export const JobDeploymentDetails =
+ {{yield
+ (hash
+ metrics=(component JobDeploymentDeploymentMetrics deployment=@deployment)
+ taskGroups=(component JobDeploymentTaskGroups deployment=@deployment)
+ allocations=(component
+ JobDeploymentDeploymentAllocations deployment=@deployment
+ )
+ )
+ }}
+;
+
+export default JobDeploymentDetails;
diff --git a/ui/app/components/job-deployment-details.hbs b/ui/app/components/job-deployment-details.hbs
deleted file mode 100644
index dcb0b8e6b1c..00000000000
--- a/ui/app/components/job-deployment-details.hbs
+++ /dev/null
@@ -1,16 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield
- (hash
- metrics=(component
- "job-deployment/deployment-metrics" deployment=@deployment
- )
- taskGroups=(component "job-deployment/task-groups" deployment=@deployment)
- allocations=(component
- "job-deployment/deployment-allocations" deployment=@deployment
- )
- )
-}}
\ No newline at end of file
diff --git a/ui/app/components/job-deployment.gjs b/ui/app/components/job-deployment.gjs
new file mode 100644
index 00000000000..27b9a84a7ea
--- /dev/null
+++ b/ui/app/components/job-deployment.gjs
@@ -0,0 +1,69 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { tracked } from '@glimmer/tracking';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import JobDeploymentDetails from 'nomad-ui/components/job-deployment-details';
+import formatTs from 'nomad-ui/helpers/format-ts';
+
+export default class JobDeployment extends Component {
+ @tracked isOpen = false;
+
+ toggleDetails = () => {
+ this.isOpen = !this.isOpen;
+ };
+
+
+
+
+ {{@deployment.shortId}}
+ {{@deployment.status}}
+ {{#if @deployment.requiresPromotion}}
+ Requires Promotion
+ {{/if}}
+
+
+ Version
+ #{{@deployment.version.number}}
+ |
+
+ {{momentFromNow @deployment.version.submitTime}}
+
+
+
+
+
+
+ {{#if this.isOpen}}
+
+
+
+
+
+
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-deployment.hbs b/ui/app/components/job-deployment.hbs
deleted file mode 100644
index c9e39e860d1..00000000000
--- a/ui/app/components/job-deployment.hbs
+++ /dev/null
@@ -1,50 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{this.deployment.shortId}}
- {{this.deployment.status}}
- {{#if this.deployment.requiresPromotion}}
- Requires Promotion
- {{/if}}
-
-
- Version
- #{{this.deployment.version.number}}
- |
-
- {{moment-from-now this.deployment.version.submitTime}}
-
-
-
-
-
-
-{{#if this.isOpen}}
-
-
-
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-deployment.js b/ui/app/components/job-deployment.js
deleted file mode 100644
index fa82be6ee11..00000000000
--- a/ui/app/components/job-deployment.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { classNames } from '@ember-decorators/component';
-import { action } from '@ember/object';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('job-deployment', 'boxed-section')
-export default class JobDeployment extends Component {
- deployment = null;
- isOpen = false;
-
- @action
- toggleDetails() {
- this.set('isOpen', !this.isOpen);
- }
-}
diff --git a/ui/app/components/job-deployment/deployment-allocations.gjs b/ui/app/components/job-deployment/deployment-allocations.gjs
new file mode 100644
index 00000000000..066ed9b195c
--- /dev/null
+++ b/ui/app/components/job-deployment/deployment-allocations.gjs
@@ -0,0 +1,66 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import AllocationRow from 'nomad-ui/components/allocation-row';
+import ListTable from 'nomad-ui/components/list-table';
+
+export const JobDeploymentDeploymentAllocations =
+
+
+ Allocations
+
+
+ {{#if @deployment.allocations.length}}
+
+
+ Driver Health,
+ Scheduling, and Preemption
+ ID
+ Task Group
+ Created
+ Modified
+ Status
+ Version
+ Node
+ Volume
+ CPU
+ Memory
+
+
+
+
+
+ {{else}}
+
+ {{/if}}
+
+
+;
+
+export default JobDeploymentDeploymentAllocations;
diff --git a/ui/app/components/job-deployment/deployment-allocations.hbs b/ui/app/components/job-deployment/deployment-allocations.hbs
deleted file mode 100644
index a2d2d689ea1..00000000000
--- a/ui/app/components/job-deployment/deployment-allocations.hbs
+++ /dev/null
@@ -1,59 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Allocations
-
-
- {{#if @deployment.allocations.length}}
-
-
- Driver Health,
- Scheduling, and Preemption
- ID
- Task Group
- Created
- Modified
- Status
- Version
- Node
- Volume
- CPU
- Memory
-
-
-
-
-
- {{else}}
-
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-deployment/deployment-metrics.gjs b/ui/app/components/job-deployment/deployment-metrics.gjs
new file mode 100644
index 00000000000..8da97705224
--- /dev/null
+++ b/ui/app/components/job-deployment/deployment-metrics.gjs
@@ -0,0 +1,84 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { eq, gt } from 'ember-truth-helpers';
+
+export const JobDeploymentDeploymentMetrics =
+
+
+
+
+ Canaries
+ {{@deployment.placedCanaries}}
+ /
+ {{@deployment.desiredCanaries}}
+
+
+
+
+
+ Placed
+ {{@deployment.placedAllocs}}
+
+
+ Desired
+ {{@deployment.desiredTotal}}
+
+
+
+
+
+ Healthy
+ {{@deployment.healthyAllocs}}
+
+
+
+
+
+ Unhealthy
+ {{@deployment.unhealthyAllocs}}
+
+
+
+
+
+ {{@deployment.statusDescription}}
+
+
+
+;
+
+export default JobDeploymentDeploymentMetrics;
diff --git a/ui/app/components/job-deployment/deployment-metrics.hbs b/ui/app/components/job-deployment/deployment-metrics.hbs
deleted file mode 100644
index efe4e278a32..00000000000
--- a/ui/app/components/job-deployment/deployment-metrics.hbs
+++ /dev/null
@@ -1,78 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
- Canaries
- {{this.deployment.placedCanaries}}
- /
- {{this.deployment.desiredCanaries}}
-
-
-
-
-
- Placed
- {{this.deployment.placedAllocs}}
-
-
- Desired
- {{this.deployment.desiredTotal}}
-
-
-
-
-
- Healthy
- {{this.deployment.healthyAllocs}}
-
-
-
-
-
- Unhealthy
- {{this.deployment.unhealthyAllocs}}
-
-
-
-
-
- {{this.deployment.statusDescription}}
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-deployment/deployment-metrics.js b/ui/app/components/job-deployment/deployment-metrics.js
deleted file mode 100644
index a376ba2dcf8..00000000000
--- a/ui/app/components/job-deployment/deployment-metrics.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class DeploymentMetrics extends Component {}
diff --git a/ui/app/components/job-deployment/task-groups.gjs b/ui/app/components/job-deployment/task-groups.gjs
new file mode 100644
index 00000000000..37465b3b404
--- /dev/null
+++ b/ui/app/components/job-deployment/task-groups.gjs
@@ -0,0 +1,75 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { or } from 'ember-truth-helpers';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import ListTable from 'nomad-ui/components/list-table';
+
+export const JobDeploymentTaskGroups =
+
+
+ Task Groups
+
+
+
+
+ Name
+ Needs Promotion?
+ Auto Revert?
+ Canaries
+ Allocs
+ Healthy Allocs
+ Unhealthy Allocs
+ Progress Deadline
+
+
+
+ {{row.model.name}}
+
+ {{#if row.model.requiresPromotion}}
+ {{if row.model.promoted "No" "Yes"}}
+ {{else}}
+ N/A
+ {{/if}}
+
+ {{if
+ row.model.autoRevert
+ "Yes"
+ "No"
+ }}
+ {{or
+ row.model.placedCanaries
+ 0
+ }}
+ /
+ {{row.model.desiredCanaries}}
+ {{row.model.placedAllocs}}
+ /
+ {{row.model.desiredTotal}}
+ {{row.model.healthyAllocs}}
+ {{row.model.unhealthyAllocs}}
+
+ {{formatTs
+ row.model.requireProgressBy
+ }}
+
+
+
+
+
+
+;
+
+export default JobDeploymentTaskGroups;
diff --git a/ui/app/components/job-deployment/task-groups.hbs b/ui/app/components/job-deployment/task-groups.hbs
deleted file mode 100644
index e5b32cc7171..00000000000
--- a/ui/app/components/job-deployment/task-groups.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Task Groups
-
-
-
-
- Name
- Needs Promotion?
- Auto Revert?
- Canaries
- Allocs
- Healthy Allocs
- Unhealthy Allocs
- Progress Deadline
-
-
-
- {{row.model.name}}
-
- {{#if row.model.requiresPromotion}}
- {{if row.model.promoted "No" "Yes"}}
- {{else}}
- N/A
- {{/if}}
-
- {{if
- row.model.autoRevert
- "Yes"
- "No"
- }}
- {{or
- row.model.placedCanaries
- 0
- }}
- /
- {{row.model.desiredCanaries}}
- {{row.model.placedAllocs}}
- /
- {{row.model.desiredTotal}}
- {{row.model.healthyAllocs}}
- {{row.model.unhealthyAllocs}}
-
- {{format-ts
- row.model.requireProgressBy
- }}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-deployments-stream.gjs b/ui/app/components/job-deployments-stream.gjs
new file mode 100644
index 00000000000..0ea2bfa21ca
--- /dev/null
+++ b/ui/app/components/job-deployments-stream.gjs
@@ -0,0 +1,86 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import moment from 'moment';
+import formatDate from 'nomad-ui/helpers/format-date';
+import JobDeployment from 'nomad-ui/components/job-deployment';
+
+export default class JobDeploymentsStream extends Component {
+ get deployments() {
+ return normalizeCollection(this.args.deployments);
+ }
+
+ get sortedDeployments() {
+ return [...this.deployments].sort((a, b) => {
+ return (b.versionSubmitTime ?? 0) - (a.versionSubmitTime ?? 0);
+ });
+ }
+
+ get annotatedDeployments() {
+ const deployments = this.sortedDeployments;
+ return deployments.map((deployment, index) => {
+ const meta = {};
+
+ if (index === 0) {
+ meta.showDate = true;
+ } else {
+ const previousDeployment = deployments[index - 1];
+ const previousSubmitTime = previousDeployment.get('version.submitTime');
+ const submitTime = deployment.get('submitTime');
+ if (
+ submitTime &&
+ previousSubmitTime &&
+ moment(previousSubmitTime)
+ .startOf('day')
+ .diff(moment(submitTime).startOf('day'), 'days') > 0
+ ) {
+ meta.showDate = true;
+ }
+ }
+
+ return { deployment, meta };
+ });
+ }
+
+
+
+ {{#each this.annotatedDeployments key="deployment.id" as |record|}}
+ {{#if record.meta.showDate}}
+ -
+ {{#if record.deployment.version.submitTime}}
+ {{formatDate record.deployment.version.submitTime}}
+ {{else}}
+ Unknown time
+ {{/if}}
+
+ {{/if}}
+ -
+
+
+ {{/each}}
+
+
+}
+
+function normalizeCollection(value) {
+ if (!value) {
+ return [];
+ }
+
+ if (Array.isArray(value)) {
+ return [...value];
+ }
+
+ if (typeof value.toArray === 'function') {
+ return value.toArray();
+ }
+
+ if (typeof value[Symbol.iterator] === 'function') {
+ return Array.from(value);
+ }
+
+ return [];
+}
diff --git a/ui/app/components/job-deployments-stream.hbs b/ui/app/components/job-deployments-stream.hbs
deleted file mode 100644
index ab46e2f8971..00000000000
--- a/ui/app/components/job-deployments-stream.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#each this.annotatedDeployments key="deployment.id" as |record|}}
- {{#if record.meta.showDate}}
-
- {{#if record.deployment.version.submitTime}}
- {{format-date record.deployment.version.submitTime}}
- {{else}}
- Unknown time
- {{/if}}
-
- {{/if}}
-
-
-
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/job-deployments-stream.js b/ui/app/components/job-deployments-stream.js
deleted file mode 100644
index cb4c3cde0f3..00000000000
--- a/ui/app/components/job-deployments-stream.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import moment from 'moment';
-import { classNames, tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('ol')
-@classNames('timeline')
-export default class JobDeploymentsStream extends Component {
- @overridable(() => []) deployments;
-
- @computed('deployments.@each.versionSubmitTime')
- get sortedDeployments() {
- return this.deployments.sortBy('versionSubmitTime').reverse();
- }
-
- @computed('sortedDeployments.@each.version')
- get annotatedDeployments() {
- const deployments = this.sortedDeployments;
- return deployments.map((deployment, index) => {
- const meta = {};
-
- if (index === 0) {
- meta.showDate = true;
- } else {
- const previousDeployment = deployments[index - 1];
- const previousSubmitTime = previousDeployment.get('version.submitTime');
- const submitTime = deployment.get('submitTime');
- if (
- submitTime &&
- previousSubmitTime &&
- moment(previousSubmitTime)
- .startOf('day')
- .diff(moment(submitTime).startOf('day'), 'days') > 0
- ) {
- meta.showDate = true;
- }
- }
-
- return { deployment, meta };
- });
- }
-}
diff --git a/ui/app/components/job-diff-fields-and-objects.gjs b/ui/app/components/job-diff-fields-and-objects.gjs
new file mode 100644
index 00000000000..fea957b8720
--- /dev/null
+++ b/ui/app/components/job-diff-fields-and-objects.gjs
@@ -0,0 +1,91 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+
+export default class JobDiffFieldsAndObjects extends Component {
+ lowerType = (item) => (item?.Type || '').toLowerCase();
+
+ objectTestField = (object) =>
+ this.args.nested ? null : this.lowerType(object);
+
+ get fields() {
+ return this.args.fields || [];
+ }
+
+ get objects() {
+ return this.args.objects || [];
+ }
+
+ fieldsFor = (object) => object?.Fields || object?.fields || [];
+
+ objectsFor = (object) => object?.Objects || object?.objects || [];
+
+ marker = (item) => {
+ const type = this.lowerType(item);
+ if (type === 'added') return '+';
+ if (type === 'deleted') return '-';
+ if (type === 'edited') return '+/-';
+ return '';
+ };
+
+ sectionClass = (item) => `diff-section-label is-${this.lowerType(item)}`;
+
+ markerClass = (item) => `is-${this.lowerType(item)}`;
+
+ isType = (item, type) => this.lowerType(item) === type;
+
+
+ {{#each this.fields as |field|}}
+
+
+
+ {{this.marker field}}
+
+ {{field.Name}}:
+
+ {{#if (this.isType field "added")}}
+ "{{field.New}}"
+ {{else if (this.isType field "deleted")}}
+ "{{field.Old}}"
+ {{else if (this.isType field "edited")}}
+ "{{field.Old}}" => "{{field.New}}"
+ {{else}}
+ "{{field.New}}"
+ {{/if}}
+
+ {{/each}}
+
+ {{#each this.objects as |object|}}
+
+
+ {{this.marker object}}
+
+ {{object.Name}}
+ {
+
+ }
+
+ {{/each}}
+
+}
diff --git a/ui/app/components/job-diff-fields-and-objects.hbs b/ui/app/components/job-diff-fields-and-objects.hbs
deleted file mode 100644
index f0fbcdb9710..00000000000
--- a/ui/app/components/job-diff-fields-and-objects.hbs
+++ /dev/null
@@ -1,73 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#each this.fields as |field|}}
-
-
-
- {{#if (eq (lowercase field.Type) "added")}}
- +
- {{else if (eq (lowercase field.Type) "deleted")}}
- -
- {{else if (eq (lowercase field.Type) "edited")}}
- +/-
- {{/if}}
-
- {{field.Name}}:
-
- {{#if (eq (lowercase field.Type) "added")}}
- "{{field.New}}"
- {{else if (eq (lowercase field.Type) "deleted")}}
- "{{field.Old}}"
- {{else if (eq (lowercase field.Type) "edited")}}
- "{{field.Old}}" => "{{field.New}}"
- {{else}}
- "{{field.New}}"
- {{/if}}
-
- {{/each}}
-
-
-{{#each this.objects as |object|}}
-
-
- {{#if (eq (lowercase object.Type) "added")}}
- +
- {{else if (eq (lowercase object.Type) "deleted")}}
- -
- {{else if (eq (lowercase object.Type) "edited")}}
- +/-
- {{/if}}
-
- {{object.Name}}
- {
-
-
-
- }
-
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/job-diff-fields-and-objects.js b/ui/app/components/job-diff-fields-and-objects.js
deleted file mode 100644
index 153f2f07ba1..00000000000
--- a/ui/app/components/job-diff-fields-and-objects.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class JobDiffFieldsAndObjects extends Component {}
diff --git a/ui/app/components/job-diff.gjs b/ui/app/components/job-diff.gjs
new file mode 100644
index 00000000000..53489ab6fe8
--- /dev/null
+++ b/ui/app/components/job-diff.gjs
@@ -0,0 +1,154 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { dasherize } from '@ember/string';
+import JobDiffFieldsAndObjects from 'nomad-ui/components/job-diff-fields-and-objects';
+
+export default class JobDiff extends Component {
+ get verbose() {
+ return this.args.verbose ?? true;
+ }
+
+ get diff() {
+ return this.args.diff || {};
+ }
+
+ get fields() {
+ return this.diff.Fields || this.diff.fields || [];
+ }
+
+ get objects() {
+ return this.diff.Objects || this.diff.objects || [];
+ }
+
+ get taskGroups() {
+ return this.diff.TaskGroups || this.diff.taskGroups || [];
+ }
+
+ fieldsFor = (item) => item?.Fields || item?.fields || [];
+
+ objectsFor = (item) => item?.Objects || item?.objects || [];
+
+ tasksFor = (group) => group?.Tasks || group?.tasks || [];
+
+ lowerType = (item) => (item?.Type || '').toLowerCase();
+
+ marker = (item) => {
+ const type = this.lowerType(item);
+ if (type === 'added') return '+';
+ if (type === 'deleted') return '-';
+ if (type === 'edited') return '+/-';
+ return '';
+ };
+
+ markerClass = (item) => {
+ const type = this.lowerType(item);
+ return type ? `is-${type}` : '';
+ };
+
+ sectionClass = (item) => {
+ const type = this.lowerType(item);
+ return type ? `diff-section-label is-${type}` : 'diff-section-label';
+ };
+
+ isType = (item, type) => this.lowerType(item) === type;
+
+ shouldShowDiff = (item) => this.verbose || this.isType(item, 'edited');
+
+ cssClass = (value) => dasherize(String(value || '').replace(/\//g, '-'));
+
+ isLastAnnotation = (task, index) =>
+ index === (task?.Annotations?.length || 0) - 1;
+
+ get rootClass() {
+ const classes = ['job-diff'];
+ if (this.isType(this.diff, 'edited')) classes.push('is-edited');
+ if (this.isType(this.diff, 'added')) classes.push('is-added');
+ if (this.isType(this.diff, 'deleted')) classes.push('is-deleted');
+ return classes.join(' ');
+ }
+
+
+
+
+
+ {{this.marker this.diff}}
+
+ Job: "{{this.diff.ID}}"
+
+
+ {{#if (this.shouldShowDiff this.diff)}}
+
+
+
+ {{/if}}
+
+ {{#each this.taskGroups as |group|}}
+
+
+ {{this.marker group}}
+
+ Task Group: "{{group.Name}}"
+ {{#if group.Updates}}
+ ({{#each-in group.Updates as |updateType count|}}
+ {{count}}
+ {{updateType}}
+ {{/each-in}})
+ {{/if}}
+
+ {{#if (this.shouldShowDiff group)}}
+
+
+
+ {{/if}}
+
+ {{#each (this.tasksFor group) as |task|}}
+
+
+ {{this.marker task}}
+
+ Task: "{{task.Name}}"
+ {{#if task.Annotations}}
+ ({{~#each task.Annotations as |annotation index|}}
+ {{annotation}}
+ {{#unless (this.isLastAnnotation task index)}},{{/unless}}
+ {{/each~}})
+ {{/if}}
+ {{#if (this.shouldShowDiff task)}}
+
+ {{/if}}
+
+ {{/each}}
+
+ {{/each}}
+
+
+}
diff --git a/ui/app/components/job-diff.hbs b/ui/app/components/job-diff.hbs
deleted file mode 100644
index 86f2eb916e4..00000000000
--- a/ui/app/components/job-diff.hbs
+++ /dev/null
@@ -1,112 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! Job heading }}
-{{! template-lint-disable simple-unless }}
-
-
- {{#if (eq (lowercase this.diff.Type) "added")}}
- +
- {{else if (eq (lowercase this.diff.Type) "deleted")}}
- -
- {{else if (eq (lowercase this.diff.Type) "edited")}}
- +/-
- {{/if}}
-
- Job: "{{this.diff.ID}}"
-
-
-{{! Show job field and object diffs if the job is edited }}
-{{#if (or this.verbose (eq (lowercase this.diff.Type) "edited"))}}
-
-
-
-{{/if}}
-
-{{! Each task group }}
-{{#each this.diff.TaskGroups as |group|}}
-
-
- {{#if (eq (lowercase group.Type) "added")}}
- +
- {{else if (eq (lowercase group.Type) "deleted")}}
- -
- {{else if (eq (lowercase group.Type) "edited")}}
- +/-
- {{/if}}
-
- Task Group: "{{group.Name}}"
- {{#if group.Updates}}
- ({{#each-in group.Updates as |updateType count|}}
- {{count}}
- {{updateType}}
- {{/each-in}})
- {{/if}}
-
- {{! Show task group field and object diffs if the task group is edited }}
- {{#if (or (eq (lowercase group.Type) "edited") this.verbose)}}
-
-
-
- {{/if}}
-
- {{! Each task }}
- {{#each group.Tasks as |task|}}
-
-
- {{#if (eq (lowercase task.Type) "added")}}
- +
- {{else if (eq (lowercase task.Type) "deleted")}}
- -
- {{else if (eq (lowercase task.Type) "edited")}}
- +/-
- {{/if}}
-
- Task: "{{task.Name}}"
- {{#if task.Annotations}}
- ({{~#each task.Annotations as |annotation index|}}
- {{annotation}}
- {{#unless (eq index (dec task.Annotations.length))}},{{/unless}}
- {{/each~}})
- {{/if}}
- {{#if (or this.verbose (eq (lowercase task.Type) "edited"))}}
-
- {{/if}}
-
- {{/each}}
-
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/job-diff.js b/ui/app/components/job-diff.js
deleted file mode 100644
index c5b8ddcd4f1..00000000000
--- a/ui/app/components/job-diff.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { equal } from '@ember/object/computed';
-import Component from '@ember/component';
-import { classNames, classNameBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('job-diff')
-@classNameBindings(
- 'isEdited:is-edited',
- 'isAdded:is-added',
- 'isDeleted:is-deleted',
-)
-export default class JobDiff extends Component {
- diff = null;
-
- verbose = true;
-
- @equal('diff.Type', 'Edited') isEdited;
- @equal('diff.Type', 'Added') isAdded;
- @equal('diff.Type', 'Deleted') isDeleted;
-}
diff --git a/ui/app/components/job-dispatch.gjs b/ui/app/components/job-dispatch.gjs
new file mode 100644
index 00000000000..875bd3a0717
--- /dev/null
+++ b/ui/app/components/job-dispatch.gjs
@@ -0,0 +1,253 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { trackedArray } from '@ember/reactive/collections';
+import { task } from 'ember-concurrency';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import { noCase } from 'change-case';
+import { titleCase } from 'title-case';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+class MetaField {
+ @tracked value;
+ @tracked error;
+
+ name;
+ required;
+ title;
+
+ constructor(meta) {
+ this.name = meta.name;
+ this.required = meta.required;
+ this.title = meta.title;
+ this.value = meta.value;
+ this.error = meta.error;
+ }
+
+ validate() {
+ this.error = '';
+
+ if (this.required && !this.value) {
+ this.error = `Missing required meta parameter "${this.name}".`;
+ }
+ }
+}
+
+export default class JobDispatch extends Component {
+ @service router;
+ @service config;
+
+ @tracked metaFields = [];
+ @tracked payload = '';
+ @tracked payloadHasError = false;
+ errors = trackedArray([]);
+
+ constructor() {
+ super(...arguments);
+
+ const mapper = (values, required) =>
+ values.map(
+ (value) =>
+ new MetaField({
+ name: value,
+ required,
+ title: titleCase(noCase(value)),
+ value: this.args.job.meta ? this.args.job.meta.get(value) : '',
+ }),
+ );
+
+ const required = mapper(
+ this.args.job.parameterizedDetails.MetaRequired || [],
+ true,
+ );
+ const optional = mapper(
+ this.args.job.parameterizedDetails.MetaOptional || [],
+ false,
+ );
+
+ this.metaFields = required.concat(optional);
+ }
+
+ get hasPayload() {
+ return this.args.job.parameterizedDetails.Payload !== 'forbidden';
+ }
+
+ get payloadRequired() {
+ return this.args.job.parameterizedDetails.Payload === 'required';
+ }
+
+ updateMetaField = (field, event) => {
+ field.value = event.target.value;
+ };
+
+ updatePayload = (value) => {
+ this.payload = value;
+ };
+
+ dispatch = () => {
+ this.validateForm();
+ if (this.errors.length > 0) {
+ this.scrollToError();
+ return;
+ }
+
+ this.onDispatched.perform();
+ };
+
+ cancel = () => {
+ this.router.transitionTo('jobs.job');
+ };
+
+ onDispatched = task({ drop: true }, async () => {
+ try {
+ const paramValues = {};
+ this.metaFields.forEach((field) => {
+ paramValues[field.name] = field.value;
+ });
+ const dispatch = await this.args.job.dispatch(paramValues, this.payload);
+
+ const namespaceId = this.args.job.belongsTo('namespace').id();
+ const jobId = namespaceId
+ ? `${dispatch.DispatchedJobID}@${namespaceId}`
+ : dispatch.DispatchedJobID;
+
+ this.router.transitionTo('jobs.job', jobId);
+ } catch (err) {
+ const error = messageFromAdapterError(err) || 'Could not dispatch job';
+ this.errors.push(error);
+ this.scrollToError();
+ }
+ });
+
+ scrollToError() {
+ if (!this.config.isTest) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ resetErrors() {
+ this.payloadHasError = false;
+ this.errors.splice(0, this.errors.length);
+ }
+
+ validateForm() {
+ this.resetErrors();
+
+ this.metaFields.forEach((field) => {
+ field.validate();
+ if (field.error) {
+ this.errors.push(field.error);
+ }
+ });
+
+ if (this.payloadRequired && !this.payload) {
+ this.errors.push('Missing required payload.');
+ this.payloadHasError = true;
+ }
+ }
+
+
+ {{#if this.errors}}
+
+ Dispatch Error
+
+ {{#each this.errors as |error|}}
+ - {{error}}
+ {{/each}}
+
+
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/job-dispatch.hbs b/ui/app/components/job-dispatch.hbs
deleted file mode 100644
index 215f19df18b..00000000000
--- a/ui/app/components/job-dispatch.hbs
+++ /dev/null
@@ -1,100 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.errors}}
-
- Dispatch Error
-
- {{#each this.errors as |error|}}
- - {{error}}
- {{/each}}
-
-
-{{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-dispatch.js b/ui/app/components/job-dispatch.js
deleted file mode 100644
index d33e95b57bb..00000000000
--- a/ui/app/components/job-dispatch.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-import { A } from '@ember/array';
-import { task } from 'ember-concurrency';
-import { noCase } from 'change-case';
-import { titleCase } from 'title-case';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-class MetaField {
- @tracked value;
- @tracked error;
-
- name;
- required;
- title;
-
- constructor(meta) {
- this.name = meta.name;
- this.required = meta.required;
- this.title = meta.title;
- this.value = meta.value;
- this.error = meta.error;
- }
-
- validate() {
- this.error = '';
-
- if (this.required && !this.value) {
- this.error = `Missing required meta parameter "${this.name}".`;
- }
- }
-}
-
-export default class JobDispatch extends Component {
- @service router;
- @service config;
-
- @tracked metaFields = [];
- @tracked payload = '';
- @tracked payloadHasError = false;
-
- errors = A([]);
-
- constructor() {
- super(...arguments);
-
- // Helper for mapping the params into a useable form.
- const mapper = (values, required) =>
- values.map(
- (x) =>
- new MetaField({
- name: x,
- required,
- title: titleCase(noCase(x)),
- value: this.args.job.meta ? this.args.job.meta.get(x) : '',
- }),
- );
-
- // Fetch the different types of parameters.
- const required = mapper(
- this.args.job.parameterizedDetails.MetaRequired || [],
- true,
- );
- const optional = mapper(
- this.args.job.parameterizedDetails.MetaOptional || [],
- false,
- );
-
- // Merge them, required before optional.
- this.metaFields = required.concat(optional);
- }
-
- get hasPayload() {
- return this.args.job.parameterizedDetails.Payload !== 'forbidden';
- }
-
- get payloadRequired() {
- return this.args.job.parameterizedDetails.Payload === 'required';
- }
-
- @action
- dispatch() {
- this.validateForm();
- if (this.errors.length > 0) {
- this.scrollToError();
- return;
- }
-
- this.onDispatched.perform();
- }
-
- @action
- cancel() {
- this.router.transitionTo('jobs.job');
- }
-
- @task({ drop: true }) *onDispatched() {
- // Try to create the dispatch.
- try {
- let paramValues = {};
- this.metaFields.forEach((m) => (paramValues[m.name] = m.value));
- const dispatch = yield this.args.job.dispatch(paramValues, this.payload);
-
- // Navigate to the newly created instance.
- const namespaceId = this.args.job.belongsTo('namespace').id();
- const jobId = namespaceId
- ? `${dispatch.DispatchedJobID}@${namespaceId}`
- : dispatch.DispatchedJobID;
-
- this.router.transitionTo('jobs.job', jobId);
- } catch (err) {
- const error = messageFromAdapterError(err) || 'Could not dispatch job';
- this.errors.pushObject(error);
- this.scrollToError();
- }
- }
-
- scrollToError() {
- if (!this.config.isTest) {
- window.scrollTo(0, 0);
- }
- }
-
- resetErrors() {
- this.payloadHasError = false;
- this.errors.clear();
- }
-
- validateForm() {
- this.resetErrors();
-
- // Make sure that we have all of the meta fields that we need.
- this.metaFields.forEach((f) => {
- f.validate();
- if (f.error) {
- this.errors.pushObject(f.error);
- }
- });
-
- // Validate payload.
- if (this.payloadRequired && !this.payload) {
- this.errors.pushObject('Missing required payload.');
- this.payloadHasError = true;
- }
- }
-}
diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.gjs
similarity index 67%
rename from ui/app/components/job-editor.js
rename to ui/app/components/job-editor.gjs
index 2f560583a23..df8e5663354 100644
--- a/ui/app/components/job-editor.js
+++ b/ui/app/components/job-editor.gjs
@@ -4,21 +4,25 @@
*/
import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
import { service } from '@ember/service';
-import { action } from '@ember/object';
import { scheduleOnce } from '@ember/runloop';
import { task } from 'ember-concurrency';
+import can from 'ember-can/helpers/can';
+import { eq } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsButtonSet,
+} from '@hashicorp/design-system-components/components';
+import JobEditorAlert from 'nomad-ui/components/job-editor/alert';
+import JobEditorEdit from 'nomad-ui/components/job-editor/edit';
+import JobEditorRead from 'nomad-ui/components/job-editor/read';
+import JobEditorReview from 'nomad-ui/components/job-editor/review';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-import { tracked } from '@glimmer/tracking';
import jsonToHcl from 'nomad-ui/utils/json-to-hcl';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-/**
- * JobEditor component that provides an interface for editing and managing Nomad jobs.
- *
- * @class JobEditor
- * @extends Component
- */
export default class JobEditor extends Component {
@service config;
@service store;
@@ -27,9 +31,6 @@ export default class JobEditor extends Component {
@tracked error = null;
@tracked planOutput = null;
- /**
- * Initialize the component, setting the definition and definition variables on the model if available.
- */
constructor() {
super(...arguments);
@@ -65,17 +66,11 @@ export default class JobEditor extends Component {
}
}
- /**
- * Check if the component is in editing mode.
- *
- * @returns {boolean} True if the component is in 'new' or 'edit' context, otherwise false.
- */
get isEditing() {
return ['new', 'edit'].includes(this.args.context);
}
- @action
- setDefinitionOnModel() {
+ setDefinitionOnModel = () => {
if (
!this.args.job ||
this.args.job.isDestroying ||
@@ -89,9 +84,9 @@ export default class JobEditor extends Component {
if (this.args.job._newDefinition !== definition) {
this.args.job.set('_newDefinition', definition);
}
- }
+ };
- setDefinitionVariablesOnModel(variables) {
+ setDefinitionVariablesOnModel = (variables) => {
if (
!this.args.job ||
this.args.job.isDestroying ||
@@ -103,108 +98,77 @@ export default class JobEditor extends Component {
if (this.args.job._newDefinitionVariables !== variables) {
this.args.job.set('_newDefinitionVariables', variables);
}
- }
+ };
- /**
- * Enter the edit mode and defensively set the definition on the model.
- */
- @action
- edit() {
+ edit = () => {
this.setDefinitionOnModel();
this.args.onToggleEdit(true);
- }
+ };
- @action
- onCancel() {
+ onCancel = () => {
this.args.onToggleEdit(false);
- }
+ };
- /**
- * Determine the current stage of the component based on the plan output and editing state.
- *
- * @returns {"review"|"edit"|"read"} The current stage, either 'review', 'edit', or 'read'.
- */
get stage() {
if (this.planOutput) return 'review';
if (this.isEditing) return 'edit';
- else return 'read';
+ return 'read';
}
@localStorageProperty('nomadMessageJobPlan', true) shouldShowPlanMessage;
@localStorageProperty('nomadShouldWrapCode', false) shouldWrapCode;
- @action
- dismissPlanMessage() {
+ dismissPlanMessage = () => {
this.shouldShowPlanMessage = false;
- }
+ };
- /**
- * A task that performs the job parsing and planning.
- * On error, it calls the onError method.
- */
- @(task(function* () {
+ plan = task({ drop: true }, async () => {
this.reset();
try {
- yield this.args.job.parse();
+ await this.args.job.parse();
} catch (err) {
this.onError(err, 'parse', 'parse jobs');
return;
}
try {
- const plan = yield this.args.job.plan();
+ const plan = await this.args.job.plan();
this.planOutput = plan;
} catch (err) {
this.onError(err, 'plan', 'plan jobs');
}
- }).drop())
- plan;
-
- /**
- * A task that submits the job, either running a new job or updating an existing one.
- * On error, it calls the onError method and resets our planOutput state.
- */
- @task(function* () {
+ });
+
+ submit = task(async () => {
try {
if (this.args.context === 'new') {
- yield this.args.job.run();
+ await this.args.job.run();
} else {
- yield this.args.job.update(this.args.format);
+ await this.args.job.update(this.args.format);
}
const id = this.args.job.plainId;
const namespace = this.args.job.belongsTo('namespace').id() || 'default';
this.reset();
-
- // Treat the job as ephemeral and only provide ID parts.
this.args.onSubmit(id, namespace);
} catch (err) {
this.onError(err, 'run', 'submit jobs');
this.planOutput = null;
}
- })
- submit;
-
- /**
- * Handle errors, setting the error object and scrolling to the error message.
- *
- * @param {Error} err - The error object.
- * @param {"parse"|"plan"|"run"} type - The type of error (e.g., 'parse', 'plan', 'run').
- * @param {string} actionMsg - A message describing the action that caused the error.
- */
+ });
+
onError(err, type, actionMsg) {
const error = messageFromAdapterError(err, actionMsg);
this.error = { message: error, type };
this.scrollToError();
}
- @action
- reset() {
+ reset = () => {
this.planOutput = null;
this.error = null;
- }
+ };
scrollToError() {
if (!this.config.get('isTest')) {
@@ -212,15 +176,7 @@ export default class JobEditor extends Component {
}
}
- /**
- * Update the job's definition or definition variables based on the provided type.
- *
- * @param {string} value - The new value for the job's definition or definition variables.
- * @param {_codemirror} _codemirror - The CodeMirror instance (not used in this action).
- * @param {"hclVariables"|"job"} [type='job'] - The type of code being updated ('job' or 'hclVariables').
- */
- @action
- updateCode(value, _codemirror, type = 'job') {
+ updateCode = (value, _codemirror, type = 'job') => {
if (!this.args.job.isDestroying && !this.args.job.isDestroyed) {
if (type === 'hclVariables') {
this.args.job.set('_newDefinitionVariables', value);
@@ -228,23 +184,13 @@ export default class JobEditor extends Component {
this.args.job.set('_newDefinition', value);
}
}
- }
+ };
- /**
- * Toggle the wrapping of the job's definition or definition variables.
- */
- @action
- toggleWrap() {
+ toggleWrap = () => {
this.shouldWrapCode = !this.shouldWrapCode;
- }
+ };
- /**
- * Read the content of an uploaded job specification file and update the job's definition.
- *
- * @param {Event} event - The input change event containing the selected file.
- */
- @action
- uploadJobSpec(event) {
+ uploadJobSpec = (event) => {
const reader = new FileReader();
reader.onload = () => {
this.updateCode(reader.result);
@@ -252,13 +198,9 @@ export default class JobEditor extends Component {
const [file] = event.target.files;
reader.readAsText(file);
- }
+ };
- /**
- * Download the job's definition or specification as .nomad.hcl file locally
- */
- @action
- async handleSaveAsFile() {
+ handleSaveAsFile = async () => {
try {
const blob = new Blob([this.args.job._newDefinition], {
type: 'text/plain',
@@ -288,19 +230,14 @@ export default class JobEditor extends Component {
sticky: true,
});
}
- }
+ };
- /**
- * Get the definition or specification based on the view type.
- *
- * @returns {string} The definition or specification in JSON or HCL format.
- */
get definition() {
if (this.args.view === 'full-definition') {
return JSON.stringify(this.args.definition, null, 2);
- } else {
- return this.args.specification;
}
+
+ return this.args.specification;
}
get definitionVariables() {
@@ -330,6 +267,14 @@ export default class JobEditor extends Component {
};
}
+ get alertData() {
+ return {
+ ...this.data,
+ error: this.error,
+ stage: this.stage,
+ };
+ }
+
get fns() {
return {
onCancel: this.onCancel,
@@ -346,4 +291,51 @@ export default class JobEditor extends Component {
onToggleWrap: this.toggleWrap,
};
}
+
+
+
+
+
+ {{#if (eq @context "new")}}
+
+ Run a job
+
+ Paste or author HCL or JSON to submit to your cluster, or select
+ from a list of templates. A plan will be requested before the job is
+ submitted. You can also attach a job spec by uploading a job file or
+ dragging & dropping a file to the editor.
+
+
+
+ {{#if
+ (can "read variable" path="nomad/job-templates/*" namespace="*")
+ }}
+
+ {{/if}}
+
+
+ {{/if}}
+ {{#if (eq this.stage "review")}}
+
+ {{else if (eq this.stage "edit")}}
+
+ {{else}}
+
+ {{/if}}
+
+
}
diff --git a/ui/app/components/job-editor.hbs b/ui/app/components/job-editor.hbs
deleted file mode 100644
index 86fb105cefd..00000000000
--- a/ui/app/components/job-editor.hbs
+++ /dev/null
@@ -1,50 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{#if (eq @context "new")}}
-
- Run a job
-
- Paste or author HCL or JSON to submit to your cluster, or select from a
- list of templates. A plan will be requested before the job is submitted.
- You can also attach a job spec by uploading a job file or dragging &
- dropping a file to the editor.
-
-
-
- {{#if (can "read variable" path="nomad/job-templates/*" namespace="*")}}
-
- {{/if}}
-
-
- {{/if}}
- {{#if (eq this.stage "review")}}
-
- {{else if (eq this.stage "edit")}}
-
- {{else}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-editor/alert.gjs b/ui/app/components/job-editor/alert.gjs
new file mode 100644
index 00000000000..109d6e168b7
--- /dev/null
+++ b/ui/app/components/job-editor/alert.gjs
@@ -0,0 +1,88 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { hash } from '@ember/helper';
+import { and, eq } from 'ember-truth-helpers';
+import { HdsAlert } from '@hashicorp/design-system-components/components';
+import conditionallyCapitalize from 'nomad-ui/helpers/conditionally-capitalize';
+
+export default class Alert extends Component {
+ @tracked shouldShowAlert = true;
+
+ dismissAlert = () => {
+ this.shouldShowAlert = false;
+ };
+
+
+
+ {{#if @data.error}}
+
+ {{conditionallyCapitalize
+ @data.error.type
+ true
+ }}
+ {{@data.error.message}}
+ {{#if (eq @data.error.message "Job ID does not match")}}
+
+ {{/if}}
+
+ {{/if}}
+ {{#if
+ (and
+ (eq @data.stage "read") @data.hasVariables (eq @data.view "job-spec")
+ )
+ }}
+ {{#if this.shouldShowAlert}}
+
+ HCL Variables values may be incomplete
+ Nomad cannot ensure that all variable values provided
+ below match those provided on job submit. Ensure the proper values
+ are provided before re-submitting the job.
+
+ {{/if}}
+ {{/if}}
+ {{#if (and (eq @data.stage "edit") (eq @data.view "full-definition"))}}
+
+ Edit JSON
+ If you edit the JSON formation in the full definition,
+ you will no longer be able to see job spec in HCL.
+
+ {{/if}}
+ {{#if (and (eq @data.stage "review") @data.shouldShowPlanMessage)}}
+
+ Job Plan
+ This is the impact running
+ this job will have on your cluster
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-editor/alert.hbs b/ui/app/components/job-editor/alert.hbs
deleted file mode 100644
index 0a3516691ff..00000000000
--- a/ui/app/components/job-editor/alert.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if @data.error}}
-
- {{conditionally-capitalize
- @data.error.type
- true
- }}
- {{@data.error.message}}
- {{#if (eq @data.error.message "Job ID does not match")}}
-
- {{/if}}
-
- {{/if}}
- {{#if
- (and (eq @data.stage "read") @data.hasVariables (eq @data.view "job-spec"))
- }}
- {{#if this.shouldShowAlert}}
-
- HCL Variables values may be incomplete
- Nomad cannot ensure that all variable values provided
- below match those provided on job submit. Ensure the proper values are
- provided before re-submitting the job.
-
- {{/if}}
- {{/if}}
- {{#if (and (eq @data.stage "edit") (eq @data.view "full-definition"))}}
-
- Edit JSON
- If you edit the JSON formation in the full definition, you
- will no longer be able to see job spec in HCL.
-
- {{/if}}
- {{#if (and (eq @data.stage "review") @data.shouldShowPlanMessage)}}
-
- Job Plan
- This is the impact running this
- job will have on your cluster
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-editor/alert.js b/ui/app/components/job-editor/alert.js
deleted file mode 100644
index f423d9d71a8..00000000000
--- a/ui/app/components/job-editor/alert.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-
-export default class Alert extends Component {
- @tracked shouldShowAlert = true;
-
- @action
- dismissAlert() {
- this.shouldShowAlert = false;
- }
-}
diff --git a/ui/app/components/job-editor/edit.gjs b/ui/app/components/job-editor/edit.gjs
new file mode 100644
index 00000000000..e3eeef09cac
--- /dev/null
+++ b/ui/app/components/job-editor/edit.gjs
@@ -0,0 +1,154 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array, fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import can from 'ember-can/helpers/can';
+import { eq, not, or } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsButtonSet,
+ HdsFormToggleField,
+} from '@hashicorp/design-system-components/components';
+import Tooltip from 'nomad-ui/components/tooltip';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+
+const performTask = (task) => {
+ if (typeof task?.perform === 'function') {
+ task.perform();
+ }
+};
+
+export const JobEditorEdit =
+
+
+ Job Definition
+ {{#if @data.cancelable}}
+
+
+
+ Word Wrap
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+
+
+
+
+ {{#if (or (eq @data.view "job-spec") @data.job.isNew)}}
+
+
+ {{#if @data.job.isNew}}
+ HCL Variable Values
+ {{else}}
+ Edit HCL Variable Values
+ {{/if}}
+
+
+
+
+
+ {{/if}}
+
+
+
+ {{#if (can "write variable" path="nomad/job-templates/*" namespace="*")}}
+ {{#if @data.job.isNew}}
+
+ {{/if}}
+ {{/if}}
+
+
+;
+
+export default JobEditorEdit;
diff --git a/ui/app/components/job-editor/edit.hbs b/ui/app/components/job-editor/edit.hbs
deleted file mode 100644
index eeb58faca5d..00000000000
--- a/ui/app/components/job-editor/edit.hbs
+++ /dev/null
@@ -1,131 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Job Definition
- {{#if @data.cancelable}}
-
-
-
- Word Wrap
-
-
-
-
-
-
-
-
-
-
- {{/if}}
-
-
-
-
- {{#if (or (eq @data.view "job-spec") @data.job.isNew)}}
-
-
- {{#if @data.job.isNew}}
- HCL Variable Values
- {{else}}
- Edit HCL Variable Values
- {{/if}}
-
-
-
-
-
- {{/if}}
-
-
-
- {{#if (can "write variable" path="nomad/job-templates/*" namespace="*")}}
- {{#if @data.job.isNew}}
-
- {{/if}}
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-editor/read.gjs b/ui/app/components/job-editor/read.gjs
new file mode 100644
index 00000000000..e94a08a3e6f
--- /dev/null
+++ b/ui/app/components/job-editor/read.gjs
@@ -0,0 +1,126 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array, fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { and, eq } from 'ember-truth-helpers';
+import { HdsFormToggleField } from '@hashicorp/design-system-components/components';
+import Tooltip from 'nomad-ui/components/tooltip';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export const JobEditorRead =
+
+
+ Job Definition
+
+
+
+ Word Wrap
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if (eq @data.view "job-spec")}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{#if (and (eq @data.view "job-spec") @data.hasVariables)}}
+
+
+ HCL Variable Values
+
+
+
+
+
+ {{/if}}
+
+;
+
+export default JobEditorRead;
diff --git a/ui/app/components/job-editor/read.hbs b/ui/app/components/job-editor/read.hbs
deleted file mode 100644
index f0544220b24..00000000000
--- a/ui/app/components/job-editor/read.hbs
+++ /dev/null
@@ -1,114 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Job Definition
-
-
-
- Word Wrap
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{#if (eq @data.view "job-spec")}}
-
- {{else}}
-
- {{/if}}
-
- {{#if (and (eq @data.view "job-spec") @data.hasVariables)}}
-
-
- HCL Variable Values
-
-
-
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-editor/review.gjs b/ui/app/components/job-editor/review.gjs
new file mode 100644
index 00000000000..2a9fda25250
--- /dev/null
+++ b/ui/app/components/job-editor/review.gjs
@@ -0,0 +1,141 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { and } from 'ember-truth-helpers';
+import {
+ HdsAlert,
+ HdsButton,
+ HdsButtonSet,
+} from '@hashicorp/design-system-components/components';
+import { htmlSafe } from '@ember/template';
+import AllocationRow from 'nomad-ui/components/allocation-row';
+import JobDiff from 'nomad-ui/components/job-diff';
+import ListTable from 'nomad-ui/components/list-table';
+import PlacementFailure from 'nomad-ui/components/placement-failure';
+
+export default class JobEditorReview extends Component {
+ // Slightly formats the warning string to be more readable
+ get warnings() {
+ return htmlSafe(
+ (this.args.data.planOutput.warnings || '')
+ .replace(/\n/g, '
')
+ .replace(/\t/g, ' '),
+ );
+ }
+
+ run = () => {
+ const submitTask = this.args.fns?.onSubmit;
+ if (typeof submitTask?.perform === 'function') {
+ submitTask.perform();
+ }
+ };
+
+
+
+ Job Plan
+
+
+
+
+
+
+ Scheduler dry-run
+
+ {{#if @data.planOutput.failedTGAllocs}}
+ {{#each @data.planOutput.failedTGAllocs as |placementFailure|}}
+
+ {{/each}}
+ {{else}}
+ All tasks successfully allocated.
+ {{/if}}
+
+
+
+
+ {{#if this.warnings}}
+
+
+
+ {{this.warnings}}
+
+
+
+
+ {{/if}}
+
+ {{#if
+ (and
+ @data.planOutput.preemptions.isFulfilled
+ @data.planOutput.preemptions.length
+ )
+ }}
+
+
+ Preemptions (if you choose to run this job, these allocations will be
+ stopped)
+
+
+
+
+
+
+ Driver Health, Scheduling, and Preemption
+
+
+ ID
+ Task Group
+ Created
+ Modified
+ Status
+ Version
+ Node
+ Volume
+ CPU
+ Memory
+
+
+
+
+
+
+
+ {{/if}}
+
+
+
+
+
+}
diff --git a/ui/app/components/job-editor/review.hbs b/ui/app/components/job-editor/review.hbs
deleted file mode 100644
index 8c67b01d0b4..00000000000
--- a/ui/app/components/job-editor/review.hbs
+++ /dev/null
@@ -1,98 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- Job Plan
-
-
-
-
-
-
- Scheduler dry-run
-
- {{#if @data.planOutput.failedTGAllocs}}
- {{#each @data.planOutput.failedTGAllocs as |placementFailure|}}
-
- {{/each}}
- {{else}}
- All tasks successfully allocated.
- {{/if}}
-
-
-
-
-{{#if this.warnings}}
-
-
-
- {{this.warnings}}
-
-
-
-
-{{/if}}
-
-{{#if
- (and
- @data.planOutput.preemptions.isFulfilled @data.planOutput.preemptions.length
- )
-}}
-
-
- Preemptions (if you choose to run this job, these allocations will be
- stopped)
-
-
-
-
- Driver Health,
- Scheduling, and Preemption
- ID
- Task Group
- Created
- Modified
- Status
- Version
- Node
- Volume
- CPU
- Memory
-
-
-
-
-
-
-
-{{/if}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-editor/review.js b/ui/app/components/job-editor/review.js
deleted file mode 100644
index 8299da226f7..00000000000
--- a/ui/app/components/job-editor/review.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { htmlSafe } from '@ember/template';
-
-export default class JobEditorReviewComponent extends Component {
- // Slightly formats the warning string to be more readable
- get warnings() {
- return htmlSafe(
- (this.args.data.planOutput.warnings || '')
- .replace(/\n/g, '
')
- .replace(/\t/g, ' '),
- );
- }
-}
diff --git a/ui/app/components/job-page.gjs b/ui/app/components/job-page.gjs
new file mode 100644
index 00000000000..fd03f5bdcf0
--- /dev/null
+++ b/ui/app/components/job-page.gjs
@@ -0,0 +1,76 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { hash } from '@ember/helper';
+import JobStatusPanel from 'nomad-ui/components/job-status/panel';
+import JobPagePartsBody from 'nomad-ui/components/job-page/parts/body';
+import JobPagePartsChildren from 'nomad-ui/components/job-page/parts/children';
+import JobPagePartsDasRecommendations from 'nomad-ui/components/job-page/parts/das-recommendations';
+import JobPagePartsError from 'nomad-ui/components/job-page/parts/error';
+import JobPagePartsMeta from 'nomad-ui/components/job-page/parts/meta';
+import JobPagePartsPlacementFailures from 'nomad-ui/components/job-page/parts/placement-failures';
+import JobPagePartsRecentAllocations from 'nomad-ui/components/job-page/parts/recent-allocations';
+import JobPagePartsStatsBox from 'nomad-ui/components/job-page/parts/stats-box';
+import JobPagePartsSummary from 'nomad-ui/components/job-page/parts/summary';
+import JobPagePartsTaskGroups from 'nomad-ui/components/job-page/parts/task-groups';
+import JobPagePartsTitle from 'nomad-ui/components/job-page/parts/title';
+import messageForError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class JobPage extends Component {
+ @tracked errorMessage = null;
+
+ clearErrorMessage = () => {
+ this.errorMessage = null;
+ };
+
+ handleError = (errorObject) => {
+ this.errorMessage = errorObject;
+ };
+
+ setError = (err) => {
+ this.errorMessage = {
+ title: 'Could Not Force Launch',
+ description: messageForError(err, 'submit jobs'),
+ };
+ };
+
+
+ {{yield
+ (hash
+ data=(hash)
+ fns=(hash setError=this.setError)
+ ui=(hash
+ Body=(component JobPagePartsBody job=@job)
+ Error=(component
+ JobPagePartsError
+ errorMessage=this.errorMessage
+ onDismiss=this.clearErrorMessage
+ )
+ Title=(component
+ JobPagePartsTitle job=@job handleError=this.handleError
+ )
+ StatsBox=(component JobPagePartsStatsBox job=@job)
+ Summary=(component JobPagePartsSummary job=@job)
+ PlacementFailures=(component JobPagePartsPlacementFailures job=@job)
+ TaskGroups=(component JobPagePartsTaskGroups job=@job)
+ RecentAllocations=(component
+ JobPagePartsRecentAllocations
+ job=@job
+ activeTask=@activeTask
+ setActiveTaskQueryParam=@setActiveTaskQueryParam
+ )
+ Meta=(component JobPagePartsMeta meta=@job.meta)
+ DasRecommendations=(component JobPagePartsDasRecommendations job=@job)
+ Children=(component JobPagePartsChildren job=@job)
+ StatusPanel=(component
+ JobStatusPanel job=@job handleError=this.handleError
+ )
+ )
+ )
+ }}
+
+}
diff --git a/ui/app/components/job-page.hbs b/ui/app/components/job-page.hbs
deleted file mode 100644
index 43453e85825..00000000000
--- a/ui/app/components/job-page.hbs
+++ /dev/null
@@ -1,40 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield
- (hash
- data=(hash)
- fns=(hash setError=this.setError)
- ui=(hash
- Body=(component "job-page/parts/body" job=@job)
- Error=(component
- "job-page/parts/error"
- errorMessage=this.errorMessage
- onDismiss=this.clearErrorMessage
- )
- Title=(component
- "job-page/parts/title" job=@job handleError=this.handleError
- )
- StatsBox=(component "job-page/parts/stats-box" job=@job)
- Summary=(component "job-page/parts/summary" job=@job)
- PlacementFailures=(component "job-page/parts/placement-failures" job=@job)
- TaskGroups=(component "job-page/parts/task-groups" job=@job)
- RecentAllocations=(component
- "job-page/parts/recent-allocations"
- job=@job
- activeTask=@activeTask
- setActiveTaskQueryParam=@setActiveTaskQueryParam
- )
- Meta=(component "job-page/parts/meta" meta=@job.meta)
- DasRecommendations=(component
- "job-page/parts/das-recommendations" job=@job
- )
- Children=(component "job-page/parts/children" job=@job)
- StatusPanel=(component
- "job-status/panel" job=@job handleError=this.handleError
- )
- )
- )
-}}
\ No newline at end of file
diff --git a/ui/app/components/job-page.js b/ui/app/components/job-page.js
deleted file mode 100644
index 6286f867ee0..00000000000
--- a/ui/app/components/job-page.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import messageForError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class JobPage extends Component {
- @tracked errorMessage = null;
-
- @action
- clearErrorMessage() {
- this.errorMessage = null;
- }
-
- @action
- handleError(errorObject) {
- this.errorMessage = errorObject;
- }
-
- @action
- setError(err) {
- this.errorMessage = {
- title: 'Could Not Force Launch',
- description: messageForError(err, 'submit jobs'),
- };
- }
-}
diff --git a/ui/app/components/job-page/batch.gjs b/ui/app/components/job-page/batch.gjs
new file mode 100644
index 00000000000..f8dc3835ffe
--- /dev/null
+++ b/ui/app/components/job-page/batch.gjs
@@ -0,0 +1,32 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPageBatch =
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default JobPageBatch;
diff --git a/ui/app/components/job-page/batch.hbs b/ui/app/components/job-page/batch.hbs
deleted file mode 100644
index c856969732b..00000000000
--- a/ui/app/components/job-page/batch.hbs
+++ /dev/null
@@ -1,26 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parameterized-child.gjs b/ui/app/components/job-page/parameterized-child.gjs
new file mode 100644
index 00000000000..7590dd891a1
--- /dev/null
+++ b/ui/app/components/job-page/parameterized-child.gjs
@@ -0,0 +1,96 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import Component from '@glimmer/component';
+import JobPage from 'nomad-ui/components/job-page';
+import JsonViewer from 'nomad-ui/components/json-viewer';
+
+export default class ParameterizedChild extends Component {
+ get payload() {
+ return this.args.job?.decodedPayload;
+ }
+
+ get payloadJSON() {
+ let json;
+
+ try {
+ json = JSON.parse(this.payload);
+ } catch {
+ // Swallow error and fall back to plain text rendering.
+ }
+
+ return json;
+ }
+
+
+
+
+
+
+
+ <:beforeNamespace>
+
+
+ Parent
+
+
+ {{@job.parent.name}}
+
+
+
+
+
+
+
+
+
+ {{#if @job.meta}}
+
+ {{else}}
+
+ Meta
+
+
+
+
+ {{/if}}
+
+
+
+ Payload
+
+
+ {{#if this.payloadJSON}}
+
+ {{else}}
+
+
+ {{this.payload}}
+
+
+ {{/if}}
+
+
+
+
+
+}
diff --git a/ui/app/components/job-page/parameterized-child.hbs b/ui/app/components/job-page/parameterized-child.hbs
deleted file mode 100644
index db3ef453547..00000000000
--- a/ui/app/components/job-page/parameterized-child.hbs
+++ /dev/null
@@ -1,71 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- <:beforeNamespace>
-
-
- Parent
-
-
- {{@job.parent.name}}
-
-
-
-
-
-
-
-
-
- {{#if @job.meta}}
-
- {{else}}
-
- Meta
-
-
-
-
- {{/if}}
-
-
-
- Payload
-
-
- {{#if this.payloadJSON}}
-
- {{else}}
-
-
- {{this.payload}}
-
-
- {{/if}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js
deleted file mode 100644
index 6586494efa7..00000000000
--- a/ui/app/components/job-page/parameterized-child.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { computed } from '@ember/object';
-import { alias } from '@ember/object/computed';
-import Component from '@glimmer/component';
-
-export default class ParameterizedChild extends Component {
- @alias('args.job.decodedPayload') payload;
-
- @computed('payload')
- get payloadJSON() {
- let json;
- try {
- json = JSON.parse(this.payload);
- } catch {
- // Swallow error and fall back to plain text rendering
- }
- return json;
- }
-}
diff --git a/ui/app/components/job-page/parameterized.gjs b/ui/app/components/job-page/parameterized.gjs
new file mode 100644
index 00000000000..4a128954905
--- /dev/null
+++ b/ui/app/components/job-page/parameterized.gjs
@@ -0,0 +1,30 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPageParameterized =
+
+
+
+
+
+ Parameterized
+
+
+
+
+
+
+
+
+;
+
+export default JobPageParameterized;
diff --git a/ui/app/components/job-page/parameterized.hbs b/ui/app/components/job-page/parameterized.hbs
deleted file mode 100644
index 20b698953d1..00000000000
--- a/ui/app/components/job-page/parameterized.hbs
+++ /dev/null
@@ -1,24 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- Parameterized
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/body.gjs b/ui/app/components/job-page/parts/body.gjs
new file mode 100644
index 00000000000..c35f01c8a54
--- /dev/null
+++ b/ui/app/components/job-page/parts/body.gjs
@@ -0,0 +1,15 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobSubnav from 'nomad-ui/components/job-subnav';
+
+export const JobPagePartsBody =
+
+
+ {{yield}}
+
+;
+
+export default JobPagePartsBody;
diff --git a/ui/app/components/job-page/parts/body.hbs b/ui/app/components/job-page/parts/body.hbs
deleted file mode 100644
index 571e36108d3..00000000000
--- a/ui/app/components/job-page/parts/body.hbs
+++ /dev/null
@@ -1,9 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{yield}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/children.gjs b/ui/app/components/job-page/parts/children.gjs
new file mode 100644
index 00000000000..59c59d3eafd
--- /dev/null
+++ b/ui/app/components/job-page/parts/children.gjs
@@ -0,0 +1,175 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import can from 'ember-can/helpers/can';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import ChildJobRow from 'nomad-ui/components/child-job-row';
+import ListPagination from 'nomad-ui/components/list-pagination';
+import ListTable from 'nomad-ui/components/list-table';
+import PageSizeSelect from 'nomad-ui/components/page-size-select';
+
+export default class Children extends Component {
+ @service router;
+ @service system;
+ @service userSettings;
+
+ get pageSize() {
+ return this.userSettings.pageSize;
+ }
+
+ get sortedChildren() {
+ return sortItems(
+ this.args.jobs,
+ this.args.sortProperty,
+ this.args.sortDescending,
+ );
+ }
+
+ resetPagination = () => {
+ if (this.args.currentPage != null && this.router.currentRouteName) {
+ this.router.transitionTo({ queryParams: { page: 1 } });
+ }
+ };
+
+
+
+
+ Job Launches
+ {{#if @job.parameterized}}
+ {{#if (can "dispatch job" namespace=@job.namespaceId)}}
+
+ Dispatch Job
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+
+
+ {{#if this.sortedChildren}}
+
+
+
+
+ Name
+
+
+ Submitted At
+
+
+ Status
+
+
+ Completed Allocations
+
+
+
+
+
+
+
+
+
+
+
+ {{else}}
+
+ {{/if}}
+
+
+
+}
+
+function sortItems(items, sortProperty, sortDescending = true) {
+ const normalizedItems = (items?.toArray?.() || items || []).filter(Boolean);
+
+ if (!sortProperty) {
+ return normalizedItems;
+ }
+
+ const sortedItems = normalizedItems
+ .slice()
+ .sort((left, right) => compareValues(left, right, sortProperty));
+
+ return sortDescending ? sortedItems.reverse() : sortedItems;
+}
+
+function compareValues(left, right, sortProperty) {
+ const leftValue = getPathValue(left, sortProperty);
+ const rightValue = getPathValue(right, sortProperty);
+
+ if (typeof leftValue === 'string' && typeof rightValue === 'string') {
+ return leftValue.localeCompare(rightValue);
+ }
+
+ if (leftValue === rightValue) {
+ return 0;
+ }
+
+ if (leftValue == null) {
+ return -1;
+ }
+
+ if (rightValue == null) {
+ return 1;
+ }
+
+ return leftValue > rightValue ? 1 : -1;
+}
+
+function getPathValue(item, sortProperty) {
+ return sortProperty.split('.').reduce((value, key) => value?.[key], item);
+}
diff --git a/ui/app/components/job-page/parts/children.hbs b/ui/app/components/job-page/parts/children.hbs
deleted file mode 100644
index df9f1fe26f0..00000000000
--- a/ui/app/components/job-page/parts/children.hbs
+++ /dev/null
@@ -1,95 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- Job Launches
- {{#if this.job.parameterized}}
- {{#if (can "dispatch job" namespace=this.job.namespaceId)}}
-
- Dispatch Job
-
- {{else}}
-
- {{/if}}
- {{/if}}
-
-
- {{#if this.sortedChildren}}
-
-
-
-
- Name
-
-
- Submitted At
-
-
- Status
-
-
- Completed Allocations
-
-
-
-
-
-
-
-
-
-
-
- {{else}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/children.js b/ui/app/components/job-page/parts/children.js
deleted file mode 100644
index b40082dadf2..00000000000
--- a/ui/app/components/job-page/parts/children.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { action, computed } from '@ember/object';
-import { alias, readOnly } from '@ember/object/computed';
-import Sortable from 'nomad-ui/mixins/sortable';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('boxed-section')
-export default class Children extends Component.extend(Sortable) {
- @service system;
- @service userSettings;
-
- job = null;
-
- // Provide a value that is bound to a query param
- sortProperty = null;
- sortDescending = null;
- currentPage = null;
-
- @readOnly('userSettings.pageSize') pageSize;
-
- @computed('job.taskGroups.[]')
- get taskGroups() {
- return this.get('job.taskGroups') || [];
- }
-
- @computed('jobs.[]')
- get children() {
- return this.jobs || [];
- }
-
- @alias('children') listToSort;
- @alias('listSorted') sortedChildren;
-
- @action
- resetPagination() {
- if (this.currentPage != null) {
- this.set('currentPage', 1);
- }
- }
-}
diff --git a/ui/app/components/job-page/parts/das-recommendations.gjs b/ui/app/components/job-page/parts/das-recommendations.gjs
new file mode 100644
index 00000000000..1ecd0d60a2f
--- /dev/null
+++ b/ui/app/components/job-page/parts/das-recommendations.gjs
@@ -0,0 +1,17 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import can from 'ember-can/helpers/can';
+import DasRecommendationAccordion from 'nomad-ui/components/das/recommendation-accordion';
+
+export const JobPagePartsDasRecommendations =
+ {{#if (can "accept recommendations")}}
+ {{#each @job.recommendationSummaries as |summary|}}
+
+ {{/each}}
+ {{/if}}
+;
+
+export default JobPagePartsDasRecommendations;
diff --git a/ui/app/components/job-page/parts/das-recommendations.hbs b/ui/app/components/job-page/parts/das-recommendations.hbs
deleted file mode 100644
index f8e32b96c76..00000000000
--- a/ui/app/components/job-page/parts/das-recommendations.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if (can "accept recommendations")}}
- {{#each @job.recommendationSummaries as |summary|}}
-
- {{/each}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/error.gjs b/ui/app/components/job-page/parts/error.gjs
new file mode 100644
index 00000000000..4e5546fae91
--- /dev/null
+++ b/ui/app/components/job-page/parts/error.gjs
@@ -0,0 +1,32 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { on } from '@ember/modifier';
+
+const JobPagePartsError =
+ {{#if @errorMessage}}
+
+
+
+ {{@errorMessage.title}}
+ {{@errorMessage.description}}
+
+
+
+
+
+
+ {{/if}}
+;
+
+export default JobPagePartsError;
diff --git a/ui/app/components/job-page/parts/error.hbs b/ui/app/components/job-page/parts/error.hbs
deleted file mode 100644
index dc0166710ce..00000000000
--- a/ui/app/components/job-page/parts/error.hbs
+++ /dev/null
@@ -1,26 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.errorMessage}}
-
-
-
- {{this.errorMessage.title}}
- {{this.errorMessage.description}}
-
-
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/error.js b/ui/app/components/job-page/parts/error.js
deleted file mode 100644
index f543626b097..00000000000
--- a/ui/app/components/job-page/parts/error.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class Error extends Component {
- errorMessage = null;
- onDismiss() {}
-}
diff --git a/ui/app/components/job-page/parts/meta.gjs b/ui/app/components/job-page/parts/meta.gjs
new file mode 100644
index 00000000000..bec3dd23db3
--- /dev/null
+++ b/ui/app/components/job-page/parts/meta.gjs
@@ -0,0 +1,25 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import AttributesTable from 'nomad-ui/components/attributes-table';
+
+export const JobPagePartsMeta =
+ {{#if @meta.structured}}
+
+
+ Meta
+
+
+
+
+
+ {{/if}}
+;
+
+export default JobPagePartsMeta;
diff --git a/ui/app/components/job-page/parts/meta.hbs b/ui/app/components/job-page/parts/meta.hbs
deleted file mode 100644
index f1f68818aa4..00000000000
--- a/ui/app/components/job-page/parts/meta.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if @meta.structured}}
-
-
- Meta
-
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/placement-failures.gjs b/ui/app/components/job-page/parts/placement-failures.gjs
new file mode 100644
index 00000000000..99b2bc89330
--- /dev/null
+++ b/ui/app/components/job-page/parts/placement-failures.gjs
@@ -0,0 +1,28 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { HdsAlert } from '@hashicorp/design-system-components/components';
+import PlacementFailure from 'nomad-ui/components/placement-failure';
+
+const PlacementFailures =
+ {{#if @job.hasPlacementFailures}}
+
+ Placement Failures
+
+ {{#each @job.taskGroups as |taskGroup|}}
+
+ {{/each}}
+
+
+ {{/if}}
+;
+
+export default PlacementFailures;
diff --git a/ui/app/components/job-page/parts/placement-failures.hbs b/ui/app/components/job-page/parts/placement-failures.hbs
deleted file mode 100644
index 5d687f990af..00000000000
--- a/ui/app/components/job-page/parts/placement-failures.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.job.hasPlacementFailures}}
-
- Placement Failures
-
- {{#each this.job.taskGroups as |taskGroup|}}
-
- {{/each}}
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/placement-failures.js b/ui/app/components/job-page/parts/placement-failures.js
deleted file mode 100644
index 6f6558da2e2..00000000000
--- a/ui/app/components/job-page/parts/placement-failures.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class PlacementFailures extends Component {
- job = null;
-}
diff --git a/ui/app/components/job-page/parts/recent-allocations.gjs b/ui/app/components/job-page/parts/recent-allocations.gjs
new file mode 100644
index 00000000000..269d87d4dc6
--- /dev/null
+++ b/ui/app/components/job-page/parts/recent-allocations.gjs
@@ -0,0 +1,165 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { concat, fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { eq } from 'ember-truth-helpers';
+import PromiseArray from 'nomad-ui/utils/classes/promise-array';
+import AllocationRow from 'nomad-ui/components/allocation-row';
+import ListTable from 'nomad-ui/components/list-table';
+import TaskSubRow from 'nomad-ui/components/task-sub-row';
+import Toggle from 'nomad-ui/components/toggle';
+import pluralize from 'nomad-ui/helpers/pluralize';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+
+export default class RecentAllocations extends Component {
+ @service router;
+
+ sortProperty = 'modifyIndex';
+ sortDescending = true;
+
+ @localStorageProperty('nomadShowSubTasks', true) showSubTasks;
+
+ get sortedAllocations() {
+ return PromiseArray.create({
+ promise: this.args.job.allocations.then((allocations) =>
+ allocations.sortBy('modifyIndex').reverse().slice(0, 5),
+ ),
+ });
+ }
+
+ toggleShowSubTasks = (event) => {
+ event.preventDefault();
+ this.showSubTasks = !this.showSubTasks;
+ };
+
+ gotoAllocation = (allocation) => {
+ this.router.transitionTo('allocations.allocation', allocation.id);
+ };
+
+
+
+
+ Recent Allocations
+
+ Show Tasks
+
+
+
+ {{#if @job.allocations.length}}
+
+
+ Driver Health,
+ Scheduling, and Preemption
+
+ ID
+
+
+ Task Group
+
+
+ Created
+
+
+ Modified
+
+
+ Status
+
+
+ Version
+
+
+ Client
+
+
+ Volume
+
+
+ CPU
+
+
+ Memory
+
+ {{#if @job.actions.length}}
+ Actions
+ {{/if}}
+
+
+
+
+ {{#if this.showSubTasks}}
+ {{#each row.model.states as |task|}}
+
+ {{/each}}
+ {{/if}}
+
+
+ {{else}}
+
+ {{/if}}
+
+ {{#if @job.allocations.length}}
+
+
+
+ View all
+ {{@job.allocations.length}}
+ {{pluralize "allocation" @job.allocations.length}}
+
+
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-page/parts/recent-allocations.hbs b/ui/app/components/job-page/parts/recent-allocations.hbs
deleted file mode 100644
index c8d267ee2d0..00000000000
--- a/ui/app/components/job-page/parts/recent-allocations.hbs
+++ /dev/null
@@ -1,123 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Recent Allocations
-
- Show Tasks
-
-
-
- {{#if this.job.allocations.length}}
-
-
- Driver Health,
- Scheduling, and Preemption
-
- ID
-
-
- Task Group
-
-
- Created
-
-
- Modified
-
-
- Status
-
-
- Version
-
-
- Client
-
-
- Volume
-
-
- CPU
-
-
- Memory
-
- {{#if this.job.actions.length}}
- Actions
- {{/if}}
-
-
-
-
- {{#if this.showSubTasks}}
- {{#each row.model.states as |task|}}
-
- {{/each}}
- {{/if}}
-
-
- {{else}}
-
- {{/if}}
-
- {{#if this.job.allocations.length}}
-
-
-
- View all
- {{this.job.allocations.length}}
- {{pluralize "allocation" this.job.allocations.length}}
-
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/recent-allocations.js b/ui/app/components/job-page/parts/recent-allocations.js
deleted file mode 100644
index 60c878aa54e..00000000000
--- a/ui/app/components/job-page/parts/recent-allocations.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { service } from '@ember/service';
-import PromiseArray from 'nomad-ui/utils/classes/promise-array';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-
-@classic
-@classNames('boxed-section')
-export default class RecentAllocations extends Component {
- @service router;
-
- sortProperty = 'modifyIndex';
- sortDescending = true;
-
- @localStorageProperty('nomadShowSubTasks', true) showSubTasks;
-
- @action
- toggleShowSubTasks(e) {
- e.preventDefault();
- this.set('showSubTasks', !this.get('showSubTasks'));
- }
-
- @computed('job.allocations.@each.modifyIndex')
- get sortedAllocations() {
- return PromiseArray.create({
- promise: this.get('job.allocations').then((allocations) =>
- allocations.sortBy('modifyIndex').reverse().slice(0, 5),
- ),
- });
- }
-
- @action
- gotoAllocation(allocation) {
- this.router.transitionTo('allocations.allocation', allocation.id);
- }
-}
diff --git a/ui/app/components/job-page/parts/stats-box.gjs b/ui/app/components/job-page/parts/stats-box.gjs
new file mode 100644
index 00000000000..637681c417c
--- /dev/null
+++ b/ui/app/components/job-page/parts/stats-box.gjs
@@ -0,0 +1,102 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { and } from 'ember-truth-helpers';
+import can from 'ember-can/helpers/can';
+
+export default class StatsBox extends Component {
+ @service system;
+
+ get packDetails() {
+ let packMeta = this.args.job?.meta?.structured.root.children.pack;
+
+ if (!packMeta) {
+ return null;
+ }
+
+ return packMeta.files
+ .map((file) => ({
+ key: file.name,
+ value: file.variable.value,
+ }))
+ .reduce((accumulator, file) => {
+ accumulator[file.key] = file.value;
+ return accumulator;
+ }, {});
+ }
+
+
+ {{! template-lint-disable no-inline-styles }}
+
+
+ Job Details
+
+ Type
+ {{@job.type}}
+
+
+ Priority
+ {{@job.priority}}
+
+
+ Version
+ {{@job.version}}
+
+ {{#if (and (can "list variables") @job.pathLinkedVariable)}}
+
+ Variables
+
+ {{/if}}
+ {{yield to="beforeNamespace"}}
+ {{#if (and @job.namespaceId this.system.shouldShowNamespaces)}}
+
+ Namespace
+ {{@job.namespaceId}}
+
+ {{/if}}
+
+ Node Pool
+ {{#if @job.nodePool}}{{@job.nodePool}}{{else}}-{{/if}}
+
+ {{yield to="afterNamespace"}}
+
+
+ {{#if this.packDetails.name}}
+
+ Pack Details
+
+ Name
+ {{this.packDetails.name}}
+
+ {{#if this.packDetails.registry}}
+
+ Registry
+ {{this.packDetails.registry}}
+
+ {{/if}}
+ {{#if this.packDetails.version}}
+
+ Version
+ {{this.packDetails.version}}
+
+ {{/if}}
+ {{#if this.packDetails.revision}}
+
+ Revision
+ {{this.packDetails.revision}}
+
+ {{/if}}
+ {{yield to="pack"}}
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-page/parts/stats-box.hbs b/ui/app/components/job-page/parts/stats-box.hbs
deleted file mode 100644
index 493defab131..00000000000
--- a/ui/app/components/job-page/parts/stats-box.hbs
+++ /dev/null
@@ -1,72 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! template-lint-disable no-inline-styles }}
-
-
- Job Details
-
- Type
- {{@job.type}}
-
-
- Priority
- {{@job.priority}}
-
-
- Version
- {{@job.version}}
-
- {{#if (and (can "list variables") @job.pathLinkedVariable)}}
-
- Variables
-
- {{/if}}
- {{yield to="beforeNamespace"}}
- {{#if (and @job.namespaceId this.system.shouldShowNamespaces)}}
-
- Namespace
- {{@job.namespaceId}}
-
- {{/if}}
-
- Node Pool
- {{#if @job.nodePool}}{{@job.nodePool}}{{else}}-{{/if}}
-
- {{yield to="afterNamespace"}}
-
-
- {{#if this.packDetails.name}}
-
- Pack Details
-
- Name
- {{this.packDetails.name}}
-
- {{#if this.packDetails.registry}}
-
- Registry
- {{this.packDetails.registry}}
-
- {{/if}}
- {{#if this.packDetails.version}}
-
- Version
- {{this.packDetails.version}}
-
- {{/if}}
- {{#if this.packDetails.revision}}
-
- Revision
- {{this.packDetails.revision}}
-
- {{/if}}
- {{yield to="pack"}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/stats-box.js b/ui/app/components/job-page/parts/stats-box.js
deleted file mode 100644
index 428951d3612..00000000000
--- a/ui/app/components/job-page/parts/stats-box.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class StatsBox extends Component {
- @service system;
-
- get packDetails() {
- let packMeta = this.args.job?.meta?.structured.root.children.pack;
- if (!packMeta) {
- return null;
- } else {
- return packMeta.files
- .map((file) => {
- return {
- key: file.name,
- value: file.variable.value,
- };
- })
- .reduce((acc, file) => {
- acc[file.key] = file.value;
- return acc;
- }, {});
- }
- }
-}
diff --git a/ui/app/components/job-page/parts/summary-chart.gjs b/ui/app/components/job-page/parts/summary-chart.gjs
new file mode 100644
index 00000000000..2952e09627e
--- /dev/null
+++ b/ui/app/components/job-page/parts/summary-chart.gjs
@@ -0,0 +1,110 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import Component from '@glimmer/component';
+import { camelize } from '@ember/string';
+import { service } from '@ember/service';
+import { and, eq, gt } from 'ember-truth-helpers';
+import AllocationStatusBar from 'nomad-ui/components/allocation-status-bar';
+import ChildrenStatusBar from 'nomad-ui/components/children-status-bar';
+import JobPagePartsSummaryLegendItem from 'nomad-ui/components/job-page/parts/summary-legend-item';
+
+export default class JobPagePartsSummaryChart extends Component {
+ @service router;
+
+ gotoAllocations = (status) => {
+ const namespace = this.args.job.namespaceId || this.args.job.namespace;
+ const queryParams = {
+ status: JSON.stringify(status),
+ page: 1,
+ search: '',
+ sort: 'modifyIndex',
+ desc: true,
+ client: '',
+ taskGroup: '',
+ version: '',
+ scheduling: '',
+ activeTask: null,
+ };
+
+ if (namespace && namespace !== 'default') {
+ queryParams.namespace = namespace;
+ }
+
+ this.router.transitionTo('jobs.job.allocations', this.args.job, {
+ queryParams,
+ });
+ };
+
+ onSliceClick = (event, slice) => {
+ this.gotoAllocations([camelize(slice.label)]);
+ };
+
+
+ {{#if @job.hasChildren}}
+
+
+ {{#each chart.data as |datum index|}}
+ -
+
+
+ {{/each}}
+
+
+ {{else}}
+
+
+ {{#each chart.data as |datum index|}}
+ -
+ {{#if (and (gt datum.value 0) datum.legendLink)}}
+
+
+
+ {{else}}
+
+ {{/if}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/job-page/parts/summary-chart.hbs b/ui/app/components/job-page/parts/summary-chart.hbs
deleted file mode 100644
index 2dcd5798e61..00000000000
--- a/ui/app/components/job-page/parts/summary-chart.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if @job.hasChildren}}
-
-
- {{#each chart.data as |datum index|}}
- -
-
-
- {{/each}}
-
-
-{{else}}
-
-
- {{#each chart.data as |datum index|}}
- -
- {{#if (and (gt datum.value 0) datum.legendLink)}}
-
-
-
- {{else}}
-
- {{/if}}
-
- {{/each}}
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/summary-chart.js b/ui/app/components/job-page/parts/summary-chart.js
deleted file mode 100644
index 94bb8625ead..00000000000
--- a/ui/app/components/job-page/parts/summary-chart.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { camelize } from '@ember/string';
-import { service } from '@ember/service';
-
-export default class JobPagePartsSummaryChartComponent extends Component {
- @service router;
-
- @action
- gotoAllocations(status) {
- const namespace = this.args.job.namespaceId || this.args.job.namespace;
- const queryParams = {
- status: JSON.stringify(status),
- page: 1,
- search: '',
- sort: 'modifyIndex',
- desc: true,
- client: '',
- taskGroup: '',
- version: '',
- scheduling: '',
- activeTask: null,
- };
-
- if (namespace && namespace !== 'default') {
- queryParams.namespace = namespace;
- }
-
- this.router.transitionTo('jobs.job.allocations', this.args.job, {
- queryParams,
- });
- }
-
- @action
- onSliceClick(ev, slice) {
- this.gotoAllocations([camelize(slice.label)]);
- }
-}
diff --git a/ui/app/components/job-page/parts/summary-legend-item.gjs b/ui/app/components/job-page/parts/summary-legend-item.gjs
new file mode 100644
index 00000000000..ff7b75c5866
--- /dev/null
+++ b/ui/app/components/job-page/parts/summary-legend-item.gjs
@@ -0,0 +1,35 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { concat } from '@ember/helper';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+
+export const JobPagePartsSummaryLegendItem =
+
+
+
+
+ {{@datum.value}}
+
+
+ {{@datum.label}}
+
+
+ {{#if @datum.help}}
+
+
+
+ {{/if}}
+
+;
+
+export default JobPagePartsSummaryLegendItem;
diff --git a/ui/app/components/job-page/parts/summary-legend-item.hbs b/ui/app/components/job-page/parts/summary-legend-item.hbs
deleted file mode 100644
index 1189bae383e..00000000000
--- a/ui/app/components/job-page/parts/summary-legend-item.hbs
+++ /dev/null
@@ -1,24 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
- {{@datum.value}}
-
-
- {{@datum.label}}
-
-
- {{#if @datum.help}}
-
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/summary.gjs b/ui/app/components/job-page/parts/summary.gjs
new file mode 100644
index 00000000000..585c51bfaab
--- /dev/null
+++ b/ui/app/components/job-page/parts/summary.gjs
@@ -0,0 +1,101 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array } from '@ember/helper';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { gt } from 'ember-truth-helpers';
+import AllocationStatusBar from 'nomad-ui/components/allocation-status-bar';
+import ChildrenStatusBar from 'nomad-ui/components/children-status-bar';
+import JobPagePartsSummaryChart from 'nomad-ui/components/job-page/parts/summary-chart';
+import ListAccordion from 'nomad-ui/components/list-accordion';
+
+export default class Summary extends Component {
+ @tracked persistedStateVersion = 0;
+
+ get forceCollapsed() {
+ return this.args.forceCollapsed ?? false;
+ }
+
+ get isExpanded() {
+ this.persistedStateVersion;
+
+ if (this.forceCollapsed) {
+ return false;
+ }
+
+ const storageValue = window.localStorage.nomadExpandJobSummary;
+ return storageValue != null ? JSON.parse(storageValue) : true;
+ }
+
+ persist = (item, isOpen) => {
+ window.localStorage.nomadExpandJobSummary = isOpen;
+ this.persistedStateVersion++;
+ };
+
+
+
+
+
+
+ {{#if a.item.hasChildren}}
+ Children Status
+
+ {{a.item.summary.totalChildren}}
+
+ {{else}}
+ Allocation Status
+
+ {{a.item.summary.totalAllocs}}
+
+ {{/if}}
+
+ {{#unless a.isOpen}}
+
+
+ {{#if a.item.hasChildren}}
+ {{#if (gt a.item.totalChildren 0)}}
+
+ {{else}}
+
+ No Children
+
+ {{/if}}
+ {{else}}
+
+ {{/if}}
+
+
+ {{/unless}}
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/job-page/parts/summary.hbs b/ui/app/components/job-page/parts/summary.hbs
deleted file mode 100644
index 61ad63b93e4..00000000000
--- a/ui/app/components/job-page/parts/summary.hbs
+++ /dev/null
@@ -1,57 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
- {{#if a.item.hasChildren}}
- Children Status
-
- {{a.item.summary.totalChildren}}
-
- {{else}}
- Allocation Status
-
- {{a.item.summary.totalAllocs}}
-
- {{/if}}
-
- {{#unless a.isOpen}}
-
-
- {{#if a.item.hasChildren}}
- {{#if (gt a.item.totalChildren 0)}}
-
- {{else}}
-
- No Children
-
- {{/if}}
- {{else}}
-
- {{/if}}
-
-
- {{/unless}}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js
deleted file mode 100644
index 2aba6ab8491..00000000000
--- a/ui/app/components/job-page/parts/summary.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { service } from '@ember/service';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-@classic
-@classNames('boxed-section')
-export default class Summary extends Component {
- @service router;
-
- job = null;
- forceCollapsed = false;
-
- @computed('forceCollapsed')
- get isExpanded() {
- if (this.forceCollapsed) return false;
-
- const storageValue = window.localStorage.nomadExpandJobSummary;
- return storageValue != null ? JSON.parse(storageValue) : true;
- }
-
- @action
- persist(item, isOpen) {
- window.localStorage.nomadExpandJobSummary = isOpen;
- this.notifyPropertyChange('isExpanded');
- }
-}
diff --git a/ui/app/components/job-page/parts/task-groups.gjs b/ui/app/components/job-page/parts/task-groups.gjs
new file mode 100644
index 00000000000..17c78295786
--- /dev/null
+++ b/ui/app/components/job-page/parts/task-groups.gjs
@@ -0,0 +1,123 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { fn } from '@ember/helper';
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import ListTable from 'nomad-ui/components/list-table';
+import TaskGroupRow from 'nomad-ui/components/task-group-row';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class TaskGroups extends Component {
+ @service router;
+
+ get sortedTaskGroups() {
+ return sortItems(
+ this.args.job?.taskGroups,
+ this.args.sortProperty,
+ this.args.sortDescending,
+ );
+ }
+
+ gotoTaskGroup = (taskGroup) => {
+ this.router.transitionTo(
+ 'jobs.job.task-group',
+ this.args.job,
+ taskGroup.name,
+ );
+ };
+
+
+
+
+ Task Groups
+
+
+
+
+
+ Name
+
+
+ Count
+
+
+ Allocation Status
+
+
+ Volume
+
+
+ Reserved CPU
+
+
+ Reserved Memory
+
+
+ Reserved Disk
+
+
+
+
+
+
+
+
+
+}
+
+function sortItems(items, sortProperty, sortDescending = true) {
+ const normalizedItems = (items?.toArray?.() || items || []).filter(Boolean);
+
+ if (!sortProperty) {
+ return normalizedItems;
+ }
+
+ const sortedItems = normalizedItems
+ .slice()
+ .sort((left, right) => compareValues(left, right, sortProperty));
+
+ return sortDescending ? sortedItems.reverse() : sortedItems;
+}
+
+function compareValues(left, right, sortProperty) {
+ const leftValue = getPathValue(left, sortProperty);
+ const rightValue = getPathValue(right, sortProperty);
+
+ if (typeof leftValue === 'string' && typeof rightValue === 'string') {
+ return leftValue.localeCompare(rightValue);
+ }
+
+ if (leftValue === rightValue) {
+ return 0;
+ }
+
+ if (leftValue == null) {
+ return -1;
+ }
+
+ if (rightValue == null) {
+ return 1;
+ }
+
+ return leftValue > rightValue ? 1 : -1;
+}
+
+function getPathValue(item, sortProperty) {
+ return sortProperty.split('.').reduce((value, key) => value?.[key], item);
+}
diff --git a/ui/app/components/job-page/parts/task-groups.hbs b/ui/app/components/job-page/parts/task-groups.hbs
deleted file mode 100644
index ea7e244f6bd..00000000000
--- a/ui/app/components/job-page/parts/task-groups.hbs
+++ /dev/null
@@ -1,53 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Task Groups
-
-
-
-
-
- Name
-
-
- Count
-
-
- Allocation Status
-
-
- Volume
-
-
- Reserved CPU
-
-
- Reserved Memory
-
-
- Reserved Disk
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/task-groups.js b/ui/app/components/job-page/parts/task-groups.js
deleted file mode 100644
index 8428ac11bec..00000000000
--- a/ui/app/components/job-page/parts/task-groups.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import Sortable from 'nomad-ui/mixins/sortable';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('boxed-section')
-export default class TaskGroups extends Component.extend(Sortable) {
- @service router;
-
- job = null;
-
- // Provide a value that is bound to a query param
- sortProperty = null;
- sortDescending = null;
-
- @action
- gotoTaskGroup(taskGroup) {
- this.router.transitionTo('jobs.job.task-group', this.job, taskGroup.name);
- }
-
- @computed('job.taskGroups.[]')
- get taskGroups() {
- return this.get('job.taskGroups') || [];
- }
-
- @alias('taskGroups') listToSort;
- @alias('listSorted') sortedTaskGroups;
-}
diff --git a/ui/app/components/job-page/parts/title.gjs b/ui/app/components/job-page/parts/title.gjs
new file mode 100644
index 00000000000..d5e113a6be0
--- /dev/null
+++ b/ui/app/components/job-page/parts/title.gjs
@@ -0,0 +1,340 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array, fn, hash } from '@ember/helper';
+import Component from '@glimmer/component';
+import { htmlSafe } from '@ember/template';
+import { not } from 'ember-truth-helpers';
+import { service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import can from 'ember-can/helpers/can';
+import {
+ HdsButton,
+ HdsIcon,
+ HdsPageHeader,
+} from '@hashicorp/design-system-components/components';
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+import ActionsDropdown from 'nomad-ui/components/actions-dropdown';
+import ExecOpenButton from 'nomad-ui/components/exec/open-button';
+import TwoStepButton from 'nomad-ui/components/two-step-button';
+import hdsTooltip from '@hashicorp/design-system-components/modifiers/hds-tooltip';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+import jsonToHcl from 'nomad-ui/utils/json-to-hcl';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class Title extends Component {
+ @service router;
+ @service notifications;
+
+ get displayTitle() {
+ return this.args.title || this.args.job.name;
+ }
+
+ get hasPack() {
+ return !!this.args.job.meta?.structured?.root.children.pack;
+ }
+
+ get showRunningActions() {
+ return this.args.job.status !== 'dead';
+ }
+
+ get showActionsDropdown() {
+ return this.args.job.actions.length && this.args.job.allocations.length;
+ }
+
+ get showStableVersionRevert() {
+ return this.args.job.hasStableNonCurrentVersion;
+ }
+
+ get showLatestVersionRevert() {
+ return !this.args.job.hasVersionStability && this.args.job.latestVersion;
+ }
+
+ stopJob = task(async (withNotifications = false) => {
+ try {
+ const job = this.args.job;
+ await job.stop();
+ job.set('status', 'dead');
+
+ if (withNotifications) {
+ this.notifications.add({
+ title: 'Job Stopped',
+ message: `${job.name} has been stopped`,
+ color: 'success',
+ });
+ }
+ } catch (err) {
+ this.args.handleError?.({
+ title: 'Could Not Stop Job',
+ description: messageFromAdapterError(err, 'stop jobs'),
+ });
+ }
+ });
+
+ purgeJob = task(async () => {
+ try {
+ const job = this.args.job;
+ await job.purge();
+ this.notifications.add({
+ title: 'Job Purged',
+ message: `You have purged ${job.name}`,
+ color: 'success',
+ });
+ this.router.transitionTo('jobs');
+ } catch (err) {
+ this.args.handleError?.({
+ title: 'Error purging job',
+ description: messageFromAdapterError(err, 'purge jobs'),
+ });
+ }
+ });
+
+ startJob = task(async (withNotifications = false) => {
+ const job = this.args.job;
+
+ try {
+ const specification = await job.fetchRawSpecification();
+
+ let newDefinitionVariables = job.get('_newDefinitionVariables') || '';
+
+ if (specification.VariableFlags) {
+ newDefinitionVariables += jsonToHcl(specification.VariableFlags);
+ }
+
+ if (specification.Variables) {
+ newDefinitionVariables += specification.Variables;
+ }
+
+ job.set('_newDefinitionVariables', newDefinitionVariables);
+ job.set('_newDefinition', specification.Source);
+ } catch {
+ const definition = await job.fetchRawDefinition();
+ delete definition.Stop;
+ job.set('_newDefinition', JSON.stringify(definition));
+ }
+
+ try {
+ await job.parse();
+ await job.update();
+ job.set('status', 'running');
+
+ if (withNotifications) {
+ this.notifications.add({
+ title: 'Job Started',
+ message: `${job.name} has started`,
+ color: 'success',
+ });
+ }
+ } catch (err) {
+ this.args.handleError?.({
+ title: 'Could Not Start Job',
+ description: messageFromAdapterError(err, 'start jobs'),
+ });
+ }
+ });
+
+ revertTo = task(async (version) => {
+ if (!version) {
+ return;
+ }
+
+ await version.revertTo();
+ });
+
+ get description() {
+ if (!this.args.job.ui?.Description) {
+ return null;
+ }
+
+ marked.use({
+ gfm: true,
+ breaks: true,
+ });
+
+ const purifyConfig = {
+ FORBID_TAGS: ['script', 'style'],
+ FORBID_ATTR: ['onerror', 'onload'],
+ };
+ const rawDescription = marked.parse(this.args.job.ui.Description);
+
+ if (typeof rawDescription !== 'string') {
+ console.error(
+ 'Expected a string from marked.parse(), received:',
+ typeof rawDescription,
+ );
+ return null;
+ }
+
+ const cleanDescription = DOMPurify.sanitize(rawDescription, purifyConfig);
+ return htmlSafe(cleanDescription);
+ }
+
+ get links() {
+ return this.args.job.ui?.Links;
+ }
+
+
+
+
+ {{this.displayTitle}}
+ {{#if this.hasPack}}
+
+
+ Pack
+
+ {{/if}}
+ {{yield}}
+
+ {{#if this.description}}
+
+ {{this.description}}
+
+ {{/if}}
+ {{#if this.links}}
+
+
+ {{#each this.links as |link|}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if this.showRunningActions}}
+ {{#if (can "exec allocation" namespace=@job.namespaceId)}}
+ {{#if this.showActionsDropdown}}
+
+ {{/if}}
+ {{/if}}
+
+
+ {{else}}
+
+
+ {{#if @job.stopped}}
+
+ {{else if this.showStableVersionRevert}}
+
+ {{else if this.showLatestVersionRevert}}
+
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/job-page/parts/title.hbs b/ui/app/components/job-page/parts/title.hbs
deleted file mode 100644
index 1c6a50069b3..00000000000
--- a/ui/app/components/job-page/parts/title.hbs
+++ /dev/null
@@ -1,174 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{or this.title this.job.name}}
- {{#if @job.meta.structured.root.children.pack}}
-
-
- Pack
-
- {{/if}}
- {{yield}}
-
- {{#if this.description}}
-
- {{this.description}}
-
- {{/if}}
- {{#if this.links}}
-
-
- {{#each this.links as |link|}}
-
- {{/each}}
-
-
- {{/if}}
-
- {{#if (not-eq this.job.status "dead")}}
- {{#if (can "exec allocation" namespace=this.job.namespaceId)}}
- {{#if (and this.job.actions.length this.job.allocations.length)}}
-
- {{/if}}
- {{/if}}
-
-
- {{else}}
-
- {{!
- 1. If job.stopped is true, that means the job was manually stopped and can be restared. So we should show the "start" button.
- 2. If job.stopped is false, but if job.status is "dead", that means the job has failed and can't be restarted necessarily. We should should check to see that there's a stable verison of the job to fall back to.
- 2a. If there is a stable version, we should show a "Revert to last stable version" button
- 2b. If there is no stable version, we should show an "Edit and resubmit" button
- }}
-
- {{#if this.job.stopped}}
-
- {{else}}
- {{#if this.job.hasStableNonCurrentVersion}}
-
- {{else if
- (and (not this.job.hasVersionStability) this.job.latestVersion)
- }}
-
-
- {{else}}
-
- {{/if}}
- {{/if}}
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js
deleted file mode 100644
index 842c322568f..00000000000
--- a/ui/app/components/job-page/parts/title.js
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { task } from 'ember-concurrency';
-import { service } from '@ember/service';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import jsonToHcl from 'nomad-ui/utils/json-to-hcl';
-import { marked } from 'marked';
-import { htmlSafe } from '@ember/template';
-import DOMPurify from 'dompurify';
-
-@classic
-@tagName('')
-export default class Title extends Component {
- @service router;
- @service notifications;
-
- job = null;
- title = null;
-
- handleError() {}
-
- /**
- * @param {boolean} withNotifications - Whether to show a toast on success, as when triggered by keyboard shortcut
- */
- @task(function* (withNotifications = false) {
- try {
- const job = this.job;
- yield job.stop();
- // Eagerly update the job status to avoid flickering
- job.set('status', 'dead');
- if (withNotifications) {
- this.notifications.add({
- title: 'Job Stopped',
- message: `${job.name} has been stopped`,
- color: 'success',
- });
- }
- } catch (err) {
- this.handleError({
- title: 'Could Not Stop Job',
- description: messageFromAdapterError(err, 'stop jobs'),
- });
- }
- })
- stopJob;
-
- @task(function* () {
- try {
- const job = this.job;
- yield job.purge();
- this.notifications.add({
- title: 'Job Purged',
- message: `You have purged ${job.name}`,
- color: 'success',
- });
- this.router.transitionTo('jobs');
- } catch (err) {
- this.handleError({
- title: 'Error purging job',
- description: messageFromAdapterError(err, 'purge jobs'),
- });
- }
- })
- purgeJob;
-
- /**
- * @param {boolean} withNotifications - Whether to show a toast on success, as when triggered by keyboard shortcut
- */
- @task(function* (withNotifications = false) {
- const job = this.job;
-
- // Try to get the submission/hcl sourced specification first.
- // In the event that this fails, fall back to the raw definition.
- try {
- const specification = yield job.fetchRawSpecification();
-
- let _newDefinitionVariables = job.get('_newDefinitionVariables') || '';
- if (specification.VariableFlags) {
- _newDefinitionVariables += jsonToHcl(specification.VariableFlags);
- }
- if (specification.Variables) {
- _newDefinitionVariables += specification.Variables;
- }
- job.set('_newDefinitionVariables', _newDefinitionVariables);
-
- job.set('_newDefinition', specification.Source);
- } catch {
- const definition = yield job.fetchRawDefinition();
- delete definition.Stop;
- job.set('_newDefinition', JSON.stringify(definition));
- }
-
- try {
- yield job.parse();
- yield job.update();
- // Eagerly update the job status to avoid flickering
- job.set('status', 'running');
- if (withNotifications) {
- this.notifications.add({
- title: 'Job Started',
- message: `${job.name} has started`,
- color: 'success',
- });
- }
- } catch (err) {
- this.handleError({
- title: 'Could Not Start Job',
- description: messageFromAdapterError(err, 'start jobs'),
- });
- }
- })
- startJob;
-
- @task(function* (version) {
- if (!version) {
- return;
- }
- yield version.revertTo();
- })
- revertTo;
-
- get description() {
- if (!this.job.ui?.Description) {
- return null;
- }
-
- // Put
on newlines, use github-flavoured-markdown.
- marked.use({
- gfm: true,
- breaks: true,
- });
-
- const purifyConfig = {
- FORBID_TAGS: ['script', 'style'],
- FORBID_ATTR: ['onerror', 'onload'],
- };
- const rawDescription = marked.parse(this.job.ui.Description);
- if (typeof rawDescription !== 'string') {
- console.error(
- 'Expected a string from marked.parse(), received:',
- typeof rawDescription,
- );
- return null;
- }
- const cleanDescription = DOMPurify.sanitize(rawDescription, purifyConfig);
- return htmlSafe(cleanDescription);
- }
-
- get links() {
- return this.job.ui?.Links;
- }
-}
diff --git a/ui/app/components/job-page/periodic-child.gjs b/ui/app/components/job-page/periodic-child.gjs
new file mode 100644
index 00000000000..11f556deba6
--- /dev/null
+++ b/ui/app/components/job-page/periodic-child.gjs
@@ -0,0 +1,44 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPagePeriodicChild =
+
+
+
+
+
+ <:beforeNamespace>
+
+
+ Parent
+
+
+ {{@job.parent.name}}
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default JobPagePeriodicChild;
diff --git a/ui/app/components/job-page/periodic-child.hbs b/ui/app/components/job-page/periodic-child.hbs
deleted file mode 100644
index 8d2590389fd..00000000000
--- a/ui/app/components/job-page/periodic-child.hbs
+++ /dev/null
@@ -1,37 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- <:beforeNamespace>
-
-
- Parent
-
-
- {{@job.parent.name}}
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/periodic.gjs b/ui/app/components/job-page/periodic.gjs
new file mode 100644
index 00000000000..d3d7a787f9d
--- /dev/null
+++ b/ui/app/components/job-page/periodic.gjs
@@ -0,0 +1,69 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import Component from '@glimmer/component';
+import JobPage from 'nomad-ui/components/job-page';
+import pluralize from 'nomad-ui/helpers/pluralize';
+
+export default class Periodic extends Component {
+ get cronSpecs() {
+ return this.args.job?.periodicDetails?.Specs ?? [];
+ }
+
+ get cronSpecCount() {
+ return this.cronSpecs.length || 1;
+ }
+
+ forceLaunch = (setError) => {
+ this.args.job.forcePeriodic().catch((err) => {
+ setError?.(err);
+ });
+ };
+
+
+
+
+
+
+
+ periodic
+
+
+
+
+ <:afterNamespace>
+
+
+ {{pluralize "Cron" this.cronSpecCount}}
+
+ {{#each this.cronSpecs as |spec|}}
+ {{spec}}
+ {{else}}
+ {{@job.periodicDetails.Spec}}
+ {{/each}}
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/job-page/periodic.hbs b/ui/app/components/job-page/periodic.hbs
deleted file mode 100644
index 6e5bdfd4d5a..00000000000
--- a/ui/app/components/job-page/periodic.hbs
+++ /dev/null
@@ -1,45 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- periodic
-
-
-
-
- <:afterNamespace>
-
-
- {{pluralize "Cron" (or @job.periodicDetails.Specs.length 1)}}
-
- {{#each @job.periodicDetails.Specs as |spec|}}
- {{spec}}
- {{else}}
- {{@job.periodicDetails.Spec}}
- {{/each}}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/periodic.js b/ui/app/components/job-page/periodic.js
deleted file mode 100644
index c13a28c5063..00000000000
--- a/ui/app/components/job-page/periodic.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-
-export default class Periodic extends Component {
- @action
- forceLaunch(setError) {
- this.args.job.forcePeriodic().catch((err) => {
- setError(err);
- });
- }
-}
diff --git a/ui/app/components/job-page/service.gjs b/ui/app/components/job-page/service.gjs
new file mode 100644
index 00000000000..3591b1ab04a
--- /dev/null
+++ b/ui/app/components/job-page/service.gjs
@@ -0,0 +1,33 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPageService =
+
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default JobPageService;
diff --git a/ui/app/components/job-page/service.hbs b/ui/app/components/job-page/service.hbs
deleted file mode 100644
index af479da8c3e..00000000000
--- a/ui/app/components/job-page/service.hbs
+++ /dev/null
@@ -1,27 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/sysbatch.gjs b/ui/app/components/job-page/sysbatch.gjs
new file mode 100644
index 00000000000..967ad618dcd
--- /dev/null
+++ b/ui/app/components/job-page/sysbatch.gjs
@@ -0,0 +1,32 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPageSysbatch =
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default JobPageSysbatch;
diff --git a/ui/app/components/job-page/sysbatch.hbs b/ui/app/components/job-page/sysbatch.hbs
deleted file mode 100644
index c856969732b..00000000000
--- a/ui/app/components/job-page/sysbatch.hbs
+++ /dev/null
@@ -1,26 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-page/system.gjs b/ui/app/components/job-page/system.gjs
new file mode 100644
index 00000000000..8811706b50f
--- /dev/null
+++ b/ui/app/components/job-page/system.gjs
@@ -0,0 +1,33 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import JobPage from 'nomad-ui/components/job-page';
+
+export const JobPageSystem =
+
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default JobPageSystem;
diff --git a/ui/app/components/job-page/system.hbs b/ui/app/components/job-page/system.hbs
deleted file mode 100644
index af479da8c3e..00000000000
--- a/ui/app/components/job-page/system.hbs
+++ /dev/null
@@ -1,27 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-row.gjs b/ui/app/components/job-row.gjs
new file mode 100644
index 00000000000..4dca946bbcd
--- /dev/null
+++ b/ui/app/components/job-row.gjs
@@ -0,0 +1,105 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { eq, gt, notEq } from 'ember-truth-helpers';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import AllocationStatusBar from 'nomad-ui/components/allocation-status-bar';
+import ChildrenStatusBar from 'nomad-ui/components/children-status-bar';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class JobRow extends Component {
+ @service router;
+ @service system;
+
+ click = (event) => {
+ lazyClick([this.gotoJob, event]);
+ };
+
+ gotoJob = () => {
+ const { job } = this.args;
+ this.router.transitionTo('jobs.job.index', job.idWithNamespace);
+ };
+
+
+
+
+
+ {{@job.name}}
+
+ {{#if @job.meta.structured.pack}}
+
+ Pack
+
+ {{/if}}
+
+
+
+ {{#if (notEq @context "child")}}
+ {{#if this.system.shouldShowNamespaces}}
+
+ {{@job.namespace.name}}
+
+ {{/if}}
+ {{/if}}
+ {{#if (eq @context "child")}}
+
+ {{formatMonthTs @job.submitTime}}
+
+ {{/if}}
+
+
+ {{@job.status}}
+
+
+ {{#if (notEq @context "child")}}
+
+ {{@job.displayType.type}}
+
+
+ {{#if @job.nodePool}}{{@job.nodePool}}{{else}}-{{/if}}
+
+
+ {{@job.priority}}
+
+ {{/if}}
+
+
+ {{#if @job.hasChildren}}
+ {{#if (gt @job.totalChildren 0)}}
+
+ {{else}}
+
+ No Children
+
+ {{/if}}
+ {{else}}
+
+ {{/if}}
+
+
+
+
+}
diff --git a/ui/app/components/job-row.hbs b/ui/app/components/job-row.hbs
deleted file mode 100644
index 5fed4e5c97a..00000000000
--- a/ui/app/components/job-row.hbs
+++ /dev/null
@@ -1,70 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-{{! TODO: is not currently used in the UI. It should be re-implemented in jobs/index.hbs }}
-
-
- {{this.job.name}}
-
- {{#if this.job.meta.structured.pack}}
-
- Pack
-
- {{/if}}
-
-
-
-{{#if (not-eq @context "child")}}
- {{#if this.system.shouldShowNamespaces}}
-
- {{this.job.namespace.name}}
-
- {{/if}}
-{{/if}}
-{{#if (eq @context "child")}}
-
- {{format-month-ts this.job.submitTime}}
-
-{{/if}}
-
-
- {{this.job.status}}
-
-
-{{#if (not-eq @context "child")}}
-
- {{this.job.displayType.type}}
-
-
- {{#if this.job.nodePool}}{{this.job.nodePool}}{{else}}-{{/if}}
-
-
- {{this.job.priority}}
-
-{{/if}}
-
-
- {{#if this.job.hasChildren}}
- {{#if (gt this.job.totalChildren 0)}}
-
- {{else}}
-
- No Children
-
- {{/if}}
- {{else}}
-
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js
deleted file mode 100644
index 465b63a12a7..00000000000
--- a/ui/app/components/job-row.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { lazyClick } from '../helpers/lazy-click';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tr')
-@classNames('job-row', 'is-interactive')
-@attributeBindings('data-test-job-row')
-export default class JobRow extends Component {
- @service router;
- @service store;
- @service system;
-
- job = null;
-
- // One of independent, parent, or child. Used to customize the template
- // based on the relationship of this job to others.
- context = 'independent';
-
- click(event) {
- lazyClick([this.gotoJob, event]);
- }
-
- @action
- gotoJob() {
- const { job } = this;
- this.router.transitionTo('jobs.job.index', job.idWithNamespace);
- }
-}
diff --git a/ui/app/components/job-search-box.gjs b/ui/app/components/job-search-box.gjs
new file mode 100644
index 00000000000..cb4c32a7558
--- /dev/null
+++ b/ui/app/components/job-search-box.gjs
@@ -0,0 +1,59 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { debounce } from '@ember/runloop';
+import { on } from '@ember/modifier';
+import { HdsFormTextInputBase } from '@hashicorp/design-system-components/components';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+
+const DEBOUNCE_MS = 500;
+
+export default class JobSearchBox extends Component {
+ @service keyboard;
+
+ shortcutPattern = ['Shift+F'];
+
+ updateSearchText = (event) => {
+ debounce(this, this.sendUpdate, event.target.value, DEBOUNCE_MS);
+ };
+
+ sendUpdate = (value) => {
+ this.args.onSearchTextChange(value);
+ };
+
+ get textInputComponent() {
+ return this.args.s?.TextInput || HdsFormTextInputBase;
+ }
+
+ focus = (element) => {
+ element.focus();
+ // Because the element is an input,
+ // and the "hide hints" part of our keynav implementation is on keyUp,
+ // but the focus action happens on keyDown,
+ // and the keynav explicitly ignores key input while focused in a text input,
+ // we need to manually hide the hints here.
+ this.keyboard.displayHints = false;
+ };
+
+
+
+
+}
diff --git a/ui/app/components/job-search-box.hbs b/ui/app/components/job-search-box.hbs
deleted file mode 100644
index c24bcb178e4..00000000000
--- a/ui/app/components/job-search-box.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-<@s.TextInput
- @type="search"
- @value={{@searchText}}
- aria-label="Job Search"
- placeholder="Name contains myJob"
- @icon="search"
- @width="300px"
- {{on "input" this.updateSearchText}}
- {{keyboard-shortcut
- label="Search Jobs"
- pattern=(array "Shift+F")
- action=this.focus
- }}
- data-test-jobs-search
-/>
\ No newline at end of file
diff --git a/ui/app/components/job-search-box.js b/ui/app/components/job-search-box.js
deleted file mode 100644
index 0ba2e811643..00000000000
--- a/ui/app/components/job-search-box.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { debounce } from '@ember/runloop';
-
-const DEBOUNCE_MS = 500;
-
-export default class JobSearchBoxComponent extends Component {
- @service keyboard;
-
- element = null;
-
- @action
- updateSearchText(event) {
- debounce(this, this.sendUpdate, event.target.value, DEBOUNCE_MS);
- }
-
- sendUpdate(value) {
- this.args.onSearchTextChange(value);
- }
-
- @action
- focus(element) {
- element.focus();
- // Because the element is an input,
- // and the "hide hints" part of our keynav implementation is on keyUp,
- // but the focus action happens on keyDown,
- // and the keynav explicitly ignores key input while focused in a text input,
- // we need to manually hide the hints here.
- this.keyboard.displayHints = false;
- }
-}
diff --git a/ui/app/components/job-service-row.gjs b/ui/app/components/job-service-row.gjs
new file mode 100644
index 00000000000..021ae0d6e90
--- /dev/null
+++ b/ui/app/components/job-service-row.gjs
@@ -0,0 +1,105 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { fn, hash } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { and, eq } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class JobServiceRow extends Component {
+ @service router;
+ @service system;
+
+ gotoService = (service) => {
+ if (service.provider === 'nomad') {
+ this.router.transitionTo('jobs.job.services.service', service.name, {
+ queryParams: { level: service.level },
+ instances: service.instances,
+ });
+ }
+ };
+
+ get consulRedirectLink() {
+ return this.system.agent.get('config')?.UI?.Consul?.BaseUIURL;
+ }
+
+ get instancesCount() {
+ return this.args.service?.instances?.length ?? 0;
+ }
+
+ get allocationLabel() {
+ return this.instancesCount === 1 ? 'allocation' : 'allocations';
+ }
+
+
+
+
+ {{#if (eq @service.provider "nomad")}}
+
+ {{@service.name}}
+ {{else}}
+
+ {{#if (and (eq @service.provider "consul") this.consulRedirectLink)}}
+
+ {{@service.name}}
+
+ {{else}}
+ {{@service.name}}
+ {{/if}}
+ {{#if @service.connect}}
+
+ {{/if}}
+ {{/if}}
+
+
+ {{@service.level}}
+
+
+ {{#each @service.tags as |tag|}}
+ {{tag}}
+ {{/each}}
+ {{#each @service.canary_tags as |tag|}}
+ {{tag}}
+ {{/each}}
+
+
+ {{#if (eq @service.provider "nomad")}}
+ {{this.instancesCount}}
+ {{this.allocationLabel}}
+ {{else}}
+ --
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/job-service-row.hbs b/ui/app/components/job-service-row.hbs
deleted file mode 100644
index fa3ba8305bd..00000000000
--- a/ui/app/components/job-service-row.hbs
+++ /dev/null
@@ -1,64 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#if (eq @service.provider "nomad")}}
-
- {{@service.name}}
- {{else}}
-
- {{#if (and (eq @service.provider "consul") this.consulRedirectLink)}}
-
- {{@service.name}}
-
- {{else}}
- {{@service.name}}
- {{/if}}
- {{#if @service.connect}}
-
- {{/if}}
- {{/if}}
-
-
- {{@service.level}}
-
-
- {{#each @service.tags as |tag|}}
- {{tag}}
- {{/each}}
- {{#each @service.canary_tags as |tag|}}
- {{tag}}
- {{/each}}
-
-
- {{#if (eq @service.provider "nomad")}}
- {{@service.instances.length}}
- {{pluralize "allocation" @service.instances.length}}
- {{else}}
- --
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-service-row.js b/ui/app/components/job-service-row.js
deleted file mode 100644
index 5b3e7701ffb..00000000000
--- a/ui/app/components/job-service-row.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-
-export default class JobServiceRowComponent extends Component {
- @service router;
- @service system;
-
- @action
- gotoService(service) {
- if (service.provider === 'nomad') {
- this.router.transitionTo('jobs.job.services.service', service.name, {
- queryParams: { level: service.level },
- instances: service.instances,
- });
- }
- }
-
- get consulRedirectLink() {
- return this.system.agent.get('config')?.UI?.Consul?.BaseUIURL;
- }
-}
diff --git a/ui/app/components/job-status/allocation-status-block.gjs b/ui/app/components/job-status/allocation-status-block.gjs
new file mode 100644
index 00000000000..cf9b35f0e65
--- /dev/null
+++ b/ui/app/components/job-status/allocation-status-block.gjs
@@ -0,0 +1,107 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { htmlSafe } from '@ember/template';
+import { eq, not } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import ConditionalLinkTo from 'nomad-ui/components/conditional-link-to';
+import JobStatusIndividualAllocation from 'nomad-ui/components/job-status/individual-allocation';
+
+export default class JobStatusAllocationStatusBlock extends Component {
+ get countToShow() {
+ if (this.args.compact) {
+ return 0;
+ }
+
+ const restWidth = 50;
+ const restGap = 10;
+ const countToShow = Math.floor(
+ (this.args.width - (restWidth + restGap)) / 42,
+ );
+
+ return countToShow > 3 ? countToShow : 0;
+ }
+
+ get remaining() {
+ return (this.args.count ?? 0) - this.countToShow;
+ }
+
+ get visibleAllocs() {
+ return (this.args.allocs || []).slice(0, this.countToShow);
+ }
+
+ get firstAllocation() {
+ return this.args.allocs?.[0];
+ }
+
+ get blockStyle() {
+ return htmlSafe(`width: ${this.args.width}px`);
+ }
+
+ get representedAllocationClass() {
+ return `represented-allocation rest ${this.args.status} ${this.args.health} ${this.args.canary}`;
+ }
+
+ get restQuery() {
+ return {
+ status: `["${this.args.status}"]`,
+ version: `[${this.firstAllocation?.jobVersion}]`,
+ };
+ }
+
+
+
+ {{#if this.countToShow}}
+
+ {{#each this.visibleAllocs as |allocation|}}
+
+ {{/each}}
+
+ {{/if}}
+ {{#if this.remaining}}
+
+
+ {{#if
+ this.countToShow
+ }}+{{/if}}{{this.remaining}}
+ {{#unless @steady}}
+ {{#if (eq @canary "canary")}}
+
+ {{/if}}
+ {{#if (eq @status "running")}}
+
+ {{#if (eq @health "healthy")}}
+
+ {{else if (eq @health "unhealthy")}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{/if}}
+ {{/unless}}
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-status/allocation-status-block.hbs b/ui/app/components/job-status/allocation-status-block.hbs
deleted file mode 100644
index 7127d7d824e..00000000000
--- a/ui/app/components/job-status/allocation-status-block.hbs
+++ /dev/null
@@ -1,64 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{!
- Exists as a "middleman" between AllocationStatusRow and IndividualAllocation
- only when showSummaries in AllocationStatusRow is true (i.e. when the math of how
- many allocations to show in each block is done x minWidth of each alloc exceeds
- available space)
-}}
-
-
- {{#if this.countToShow}}
-
- {{#each (range 0 this.countToShow) as |i|}}
-
- {{/each}}
-
- {{/if}}
- {{#if this.remaining}}
-
-
- {{#if
- this.countToShow
- }}+{{/if}}{{this.remaining}}
- {{#unless @steady}}
- {{#if (eq @canary "canary")}}
-
- {{/if}}
- {{#if (eq @status "running")}}
-
- {{#if (eq @health "healthy")}}
-
- {{else if (eq @health "unhealthy")}}
-
- {{else}}
-
- {{/if}}
-
- {{/if}}
- {{/unless}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/allocation-status-block.js b/ui/app/components/job-status/allocation-status-block.js
deleted file mode 100644
index e122ee09972..00000000000
--- a/ui/app/components/job-status/allocation-status-block.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-
-export default class JobStatusAllocationStatusBlockComponent extends Component {
- get countToShow() {
- if (this.args.compact) {
- return 0;
- }
- const restWidth = 50;
- const restGap = 10;
- let cts = Math.floor((this.args.width - (restWidth + restGap)) / 42);
- // Either show 3+ or show only a single/remaining box
- return cts > 3 ? cts : 0;
- }
-
- get remaining() {
- return this.args.count - this.countToShow;
- }
-}
diff --git a/ui/app/components/job-status/allocation-status-row.gjs b/ui/app/components/job-status/allocation-status-row.gjs
new file mode 100644
index 00000000000..5c8b9601df5
--- /dev/null
+++ b/ui/app/components/job-status/allocation-status-row.gjs
@@ -0,0 +1,152 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { didInsert } from '@ember/render-modifiers';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+import JobStatusAllocationStatusBlock from 'nomad-ui/components/job-status/allocation-status-block';
+import JobStatusIndividualAllocation from 'nomad-ui/components/job-status/individual-allocation';
+
+const ALLOC_BLOCK_WIDTH = 32;
+const ALLOC_BLOCK_GAP = 10;
+const COMPACT_INTER_SUMMARY_GAP = 7;
+
+export default class JobStatusAllocationStatusRow extends Component {
+ @tracked width = 0;
+
+ get allocationGroups() {
+ return Object.entries(this.args.allocBlocks || {}).flatMap(
+ ([status, allocsByStatus]) =>
+ Object.entries(allocsByStatus || {}).flatMap(
+ ([health, allocsByHealth]) =>
+ Object.entries(allocsByHealth || {}).map(
+ ([canary, allocsByCanary]) => ({
+ status,
+ health,
+ canary,
+ allocs: allocsByCanary || [],
+ }),
+ ),
+ ),
+ );
+ }
+
+ get allocBlockSlots() {
+ return this.allocationGroups.reduce(
+ (totalSlots, group) => totalSlots + group.allocs.length,
+ 0,
+ );
+ }
+
+ get showSummaries() {
+ return (
+ this.args.compact ||
+ this.allocBlockSlots * (ALLOC_BLOCK_WIDTH + ALLOC_BLOCK_GAP) -
+ ALLOC_BLOCK_GAP >
+ this.width
+ );
+ }
+
+ get numberOfSummariesShown() {
+ return this.allocationGroups.filter((group) => group.allocs.length > 0)
+ .length;
+ }
+
+ get summaryGroups() {
+ return this.allocationGroups
+ .filter((group) => group.allocs.length > 0)
+ .map((group) => ({
+ ...group,
+ width: this.calcWidth(group.allocs.length),
+ }));
+ }
+
+ get individualAllocs() {
+ return this.allocationGroups.flatMap((group) =>
+ group.allocs.map((allocation) => ({
+ allocation,
+ status: group.status,
+ health: group.health,
+ canary: group.canary,
+ })),
+ );
+ }
+
+ get compactTally() {
+ if (!this.args.compact) {
+ return null;
+ }
+
+ if (this.args.allocationTallyMode === 'complete') {
+ return `${this.args.completeAllocs}/${this.args.groupCountSum}`;
+ }
+
+ return `${this.args.runningAllocs}/${this.args.groupCountSum}`;
+ }
+
+ calcWidth(count) {
+ if (this.args.compact) {
+ const totalGaps =
+ (this.numberOfSummariesShown - 1) * COMPACT_INTER_SUMMARY_GAP;
+ const usableWidth = this.width - totalGaps;
+ return (count / this.allocBlockSlots) * usableWidth;
+ }
+
+ return (count / this.allocBlockSlots) * this.width;
+ }
+
+ reflow = (element) => {
+ this.width = element.clientWidth;
+ };
+
+ captureElement = (element) => {
+ this.width = element.clientWidth;
+ };
+
+
+
+ {{#if this.showSummaries}}
+
+ {{#each this.summaryGroups as |group|}}
+
+ {{/each}}
+
+ {{else}}
+
+ {{#each this.individualAllocs as |item|}}
+
+ {{/each}}
+
+ {{/if}}
+ {{#if @compact}}
+ {{this.compactTally}}
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/job-status/allocation-status-row.hbs b/ui/app/components/job-status/allocation-status-row.hbs
deleted file mode 100644
index 5086d89a2a1..00000000000
--- a/ui/app/components/job-status/allocation-status-row.hbs
+++ /dev/null
@@ -1,64 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.showSummaries}}
-
- {{#each-in @allocBlocks as |status allocsByStatus|}}
- {{#each-in allocsByStatus as |health allocsByHealth|}}
- {{#each-in allocsByHealth as |canary allocsByCanary|}}
- {{#if (gt allocsByCanary.length 0)}}
-
- {{/if}}
- {{/each-in}}
- {{/each-in}}
- {{/each-in}}
-
- {{else}}
-
- {{#each-in @allocBlocks as |status allocsByStatus|}}
- {{#each-in allocsByStatus as |health allocsByHealth|}}
- {{#each-in allocsByHealth as |canary allocsByCanary|}}
- {{#if (gt allocsByCanary.length 0)}}
- {{#each (range 0 allocsByCanary.length) as |i|}}
-
- {{/each}}
- {{/if}}
- {{/each-in}}
- {{/each-in}}
- {{/each-in}}
-
- {{/if}}
- {{#if @compact}}
- {{#if (eq @allocationTallyMode "complete")}}
- {{@completeAllocs}}/{{@groupCountSum}}
- {{else}}
- {{@runningAllocs}}/{{@groupCountSum}}
- {{/if}}
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/allocation-status-row.js b/ui/app/components/job-status/allocation-status-row.js
deleted file mode 100644
index a5e59fc2d59..00000000000
--- a/ui/app/components/job-status/allocation-status-row.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-
-const ALLOC_BLOCK_WIDTH = 32;
-const ALLOC_BLOCK_GAP = 10;
-const COMPACT_INTER_SUMMARY_GAP = 7;
-
-export default class JobStatusAllocationStatusRowComponent extends Component {
- @tracked width = 0;
-
- get allocBlockSlots() {
- return Object.values(this.args.allocBlocks)
- .flatMap((statusObj) => Object.values(statusObj))
- .flatMap((healthObj) => Object.values(healthObj))
- .reduce(
- (totalSlots, allocsByCanary) =>
- totalSlots + (allocsByCanary ? allocsByCanary.length : 0),
- 0,
- );
- }
-
- get showSummaries() {
- return (
- this.args.compact ||
- this.allocBlockSlots * (ALLOC_BLOCK_WIDTH + ALLOC_BLOCK_GAP) -
- ALLOC_BLOCK_GAP >
- this.width
- );
- }
-
- // When we calculate how much width to give to a row in our viz,
- // we want to also offset the gap BETWEEN summaries. The way that css grid
- // works, a gap only appears between 2 elements, not at the start or end of a row.
- // Thus, we need to calculate total gap space using the number of summaries shown.
- get numberOfSummariesShown() {
- return Object.values(this.args.allocBlocks)
- .flatMap((statusObj) => Object.values(statusObj))
- .flatMap((healthObj) => Object.values(healthObj))
- .filter((allocs) => allocs.length > 0).length;
- }
-
- @action
- calcPerc(count) {
- if (this.args.compact) {
- const totalGaps =
- (this.numberOfSummariesShown - 1) * COMPACT_INTER_SUMMARY_GAP;
- const usableWidth = this.width - totalGaps;
- return (count / this.allocBlockSlots) * usableWidth;
- } else {
- return (count / this.allocBlockSlots) * this.width;
- }
- }
-
- @action reflow(element) {
- this.width = element.clientWidth;
- }
-
- @action
- captureElement(element) {
- this.width = element.clientWidth;
- }
-}
diff --git a/ui/app/components/job-status/deployment-history.gjs b/ui/app/components/job-status/deployment-history.gjs
new file mode 100644
index 00000000000..9998420ab44
--- /dev/null
+++ b/ui/app/components/job-status/deployment-history.gjs
@@ -0,0 +1,202 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { service } from '@ember/service';
+import { get } from '@ember/object';
+import { scheduleOnce } from '@ember/runloop';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { eq, or } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import SearchBox from 'nomad-ui/components/search-box';
+import formatTs from 'nomad-ui/helpers/format-ts';
+
+const MAX_NUMBER_OF_EVENTS = 500;
+
+export default class JobStatusDeploymentHistory extends Component {
+ @service notifications;
+
+ @tracked isHidden = false;
+ @tracked errorState = null;
+ @tracked searchTerm = '';
+
+ constructor() {
+ super(...arguments);
+
+ this.isHidden = this.args.isHidden ?? false;
+ }
+
+ get job() {
+ return get(this.args.deployment, 'job');
+ }
+
+ get deploymentVersion() {
+ return get(this.args.deployment, 'versionNumber');
+ }
+
+ get jobAllocations() {
+ return this.job?.get?.('allocations') || [];
+ }
+
+ get deploymentAllocations() {
+ return (
+ this.args.allocations ||
+ this.jobAllocations.filter(
+ (alloc) => alloc.jobVersion === this.deploymentVersion,
+ )
+ );
+ }
+
+ get history() {
+ try {
+ return this.deploymentAllocations
+ .map((allocation) => {
+ const states =
+ allocation?.get?.('states') || allocation?.states || [];
+ const stateList = states?.toArray?.() || states || [];
+
+ return stateList
+ .map((state) => state?.events?.toArray?.() || state?.events || [])
+ .flat();
+ })
+ .flat()
+ .filter(Boolean)
+ .filter((taskEvent) => this.containsSearchTerm(taskEvent))
+ .sort((left, right) => {
+ const leftTime = left?.time?.valueOf?.() || left?.get?.('time') || 0;
+ const rightTime =
+ right?.time?.valueOf?.() || right?.get?.('time') || 0;
+ return leftTime - rightTime;
+ })
+ .reverse()
+ .slice(0, MAX_NUMBER_OF_EVENTS);
+ } catch (error) {
+ this.triggerError(error);
+ return [];
+ }
+ }
+
+ triggerError(error) {
+ // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions
+ scheduleOnce('actions', this, () => {
+ if (this.errorState === error) {
+ return;
+ }
+
+ this.errorState = error;
+ this.notifications.add({
+ title: 'Could not fetch deployment history',
+ message: error?.message || String(error),
+ color: 'critical',
+ });
+ });
+ }
+
+ containsSearchTerm(taskEvent) {
+ if (!taskEvent) {
+ return false;
+ }
+
+ const lowerSearchTerm = this.searchTerm.toLowerCase();
+ const message = (taskEvent.message || '').toLowerCase();
+ const type = (taskEvent.type || '').toLowerCase();
+ const allocationShortId =
+ taskEvent.state?.allocation?.shortId?.toLowerCase?.() || '';
+
+ return (
+ message.includes(lowerSearchTerm) ||
+ type.includes(lowerSearchTerm) ||
+ allocationShortId.includes(lowerSearchTerm)
+ );
+ }
+
+ toggleHidden = () => {
+ this.isHidden = !this.isHidden;
+ };
+
+ setSearchTerm = (searchTerm) => {
+ this.searchTerm = searchTerm;
+ };
+
+
+
+
+
+
+
+ {{#unless this.isHidden}}
+
+ {{/unless}}
+
+ {{#unless this.isHidden}}
+
+
+ {{#each this.history as |deploymentLog|}}
+ -
+
+
{{deploymentLog.state.allocation.shortId}}
+ {{deploymentLog.type}}:
+ {{deploymentLog.message}}
+
+ {{formatTs deploymentLog.time}}
+
+
+
+ {{else}}
+ {{#if this.errorState}}
+ -
+
+ Error loading deployment history
+
+
+ {{else if this.deploymentAllocations.length}}
+ {{#if this.searchTerm}}
+ -
+
+ No events match {{this.searchTerm}}
+
+
+ {{else}}
+ -
+
+ No deployment events yet
+
+
+ {{/if}}
+ {{else}}
+ -
+
+ Loading deployment events
+
+
+ {{/if}}
+ {{/each}}
+
+
+ {{/unless}}
+
+
+}
diff --git a/ui/app/components/job-status/deployment-history.hbs b/ui/app/components/job-status/deployment-history.hbs
deleted file mode 100644
index 854c7e0c45f..00000000000
--- a/ui/app/components/job-status/deployment-history.hbs
+++ /dev/null
@@ -1,85 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
- {{#unless this.isHidden}}
-
- {{/unless}}
-
- {{#unless this.isHidden}}
-
-
- {{#each this.history as |deployment-log|}}
- -
-
-
{{deployment-log.state.allocation.shortId}}
- {{deployment-log.type}}:
- {{deployment-log.message}}
-
- {{format-ts deployment-log.time}}
-
-
-
- {{else}}
- {{#if this.errorState}}
- -
-
- Error loading deployment history
-
-
- {{else}}
- {{#if this.deploymentAllocations.length}}
- {{#if this.searchTerm}}
- -
-
- No events match {{this.searchTerm}}
-
-
- {{else}}
- -
-
- No deployment events yet
-
-
- {{/if}}
- {{else}}
- -
-
- Loading deployment events
-
-
- {{/if}}
- {{/if}}
- {{/each}}
-
-
- {{/unless}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/deployment-history.js b/ui/app/components/job-status/deployment-history.js
deleted file mode 100644
index bf70ea1a4cf..00000000000
--- a/ui/app/components/job-status/deployment-history.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { alias } from '@ember/object/computed';
-import { tracked } from '@glimmer/tracking';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-import { scheduleOnce } from '@ember/runloop';
-
-const MAX_NUMBER_OF_EVENTS = 500;
-
-export default class JobStatusDeploymentHistoryComponent extends Component {
- @service notifications;
-
- // eslint-disable-next-line ember/no-tracked-properties-from-args
- @tracked isHidden = this.args.isHidden;
-
- /**
- * @type { Error }
- */
- @tracked errorState = null;
-
- /**
- * @type { import('../../models/job').default }
- */
- @alias('args.deployment.job') job;
-
- /**
- * @type { number }
- */
- @alias('args.deployment.versionNumber') deploymentVersion;
-
- /**
- * Get all allocations for the job
- * @type { import('../../models/allocation').default[] }
- */
- get jobAllocations() {
- return this.job.get('allocations');
- }
-
- /**
- * Filter the job's allocations to only those that are part of the deployment
- * @type { import('../../models/allocation').default[] }
- */
- get deploymentAllocations() {
- return (
- this.args.allocations ||
- this.jobAllocations.filter(
- (alloc) => alloc.jobVersion === this.deploymentVersion,
- )
- );
- }
-
- /**
- * Map the deployment's allocations to their task events, in reverse-chronological order
- * @type { import('../../models/task-event').default[] }
- */
- get history() {
- try {
- return this.deploymentAllocations
- .map((allocation) => {
- const states =
- allocation?.get?.('states') || allocation?.states || [];
- const stateList = states?.toArray?.() || states || [];
-
- return stateList
- .map((state) => state?.events?.toArray?.() || state?.events || [])
- .flat();
- })
- .flat()
- .filter(Boolean)
- .filter((taskEvent) => this.containsSearchTerm(taskEvent))
- .sort((a, b) => {
- const aTime = a?.time?.valueOf?.() || a?.get?.('time') || 0;
- const bTime = b?.time?.valueOf?.() || b?.get?.('time') || 0;
- return aTime - bTime;
- })
- .reverse()
- .slice(0, MAX_NUMBER_OF_EVENTS);
- } catch (e) {
- this.triggerError(e);
- return [];
- }
- }
-
- @action triggerError(error) {
- // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions
- scheduleOnce('actions', this, () => {
- if (this.errorState === error) {
- return;
- }
-
- this.errorState = error;
- this.notifications.add({
- title: 'Could not fetch deployment history',
- message: error?.message || String(error),
- color: 'critical',
- });
- });
- }
-
- // #region search
-
- /**
- * @type { string }
- */
- @tracked searchTerm = '';
-
- /**
- * @param { import('../../models/task-event').default } taskEvent
- * @returns { boolean }
- */
- containsSearchTerm(taskEvent) {
- if (!taskEvent) {
- return false;
- }
-
- const message = (taskEvent.message || '').toLowerCase();
- const type = (taskEvent.type || '').toLowerCase();
- const allocationShortId =
- taskEvent.state?.allocation?.shortId?.toLowerCase?.() || '';
-
- return (
- message.includes(this.searchTerm.toLowerCase()) ||
- type.includes(this.searchTerm.toLowerCase()) ||
- allocationShortId.includes(this.searchTerm.toLowerCase())
- );
- }
-
- // #endregion search
-}
diff --git a/ui/app/components/job-status/failed-or-lost.gjs b/ui/app/components/job-status/failed-or-lost.gjs
new file mode 100644
index 00000000000..152df601c62
--- /dev/null
+++ b/ui/app/components/job-status/failed-or-lost.gjs
@@ -0,0 +1,72 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { concat, hash } from '@ember/helper';
+import {
+ HdsIcon,
+ HdsTooltipButton,
+} from '@hashicorp/design-system-components/components';
+import ConditionalLinkTo from 'nomad-ui/components/conditional-link-to';
+
+export default class JobStatusFailedOrLost extends Component {
+ get latestDeploymentVersion() {
+ return get(this.args.job, 'latestDeployment.versionNumber');
+ }
+
+
+
+ Replaced Allocations
+
+ {{#if @supportsRescheduling}}
+
+
+
+
+
+ {{@rescheduledAllocs.length}}
+ Rescheduled
+
+
+ {{/if}}
+
+
+
+
+
+
+ {{@restartedAllocs.length}}
+ Restarted
+
+
+
+
+
+}
diff --git a/ui/app/components/job-status/failed-or-lost.hbs b/ui/app/components/job-status/failed-or-lost.hbs
deleted file mode 100644
index f0280091925..00000000000
--- a/ui/app/components/job-status/failed-or-lost.hbs
+++ /dev/null
@@ -1,55 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- Replaced Allocations
-
- {{#if @supportsRescheduling}}
-
-
-
-
-
- {{@rescheduledAllocs.length}}
- Rescheduled
-
-
- {{/if}}
-
-
-
-
-
-
- {{@restartedAllocs.length}}
- Restarted
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/individual-allocation.gjs b/ui/app/components/job-status/individual-allocation.gjs
new file mode 100644
index 00000000000..34b6e60e0d1
--- /dev/null
+++ b/ui/app/components/job-status/individual-allocation.gjs
@@ -0,0 +1,91 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { eq, not } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import ConditionalLinkTo from 'nomad-ui/components/conditional-link-to';
+
+export default class JobStatusIndividualAllocation extends Component {
+ get allocationId() {
+ return get(this.args.allocation, 'id');
+ }
+
+ get jobType() {
+ return get(this.args.allocation, 'job.type');
+ }
+
+ get nodeName() {
+ return get(this.args.allocation, 'node.name');
+ }
+
+ get groupName() {
+ return get(this.args.allocation, 'taskGroup.name');
+ }
+
+ get taskGroups() {
+ return get(this.args.allocation, 'job.taskGroups');
+ }
+
+ get shortId() {
+ return get(this.args.allocation, 'shortId');
+ }
+
+ get showClient() {
+ return this.jobType === 'system' || this.jobType === 'sysbatch';
+ }
+
+ get tooltipText() {
+ if (this.showClient) {
+ return `${this.nodeName} - ${this.shortId}`;
+ } else if (this.groupName && this.taskGroups?.length > 1) {
+ return `${this.groupName} - ${this.shortId}`;
+ }
+
+ return this.shortId;
+ }
+
+ get tooltip() {
+ if (!this.tooltipText) {
+ return null;
+ }
+
+ return {
+ text: this.tooltipText,
+ extraTippyOptions: {
+ trigger: this.args.status === 'unplaced' ? 'manual' : undefined,
+ },
+ };
+ }
+
+
+
+ {{#unless @steady}}
+ {{#if (eq @canary "canary")}}
+
+ {{/if}}
+ {{#if (eq @status "running")}}
+
+ {{#if (eq @health "healthy")}}
+
+ {{else if (eq @health "unhealthy")}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{/if}}
+ {{/unless}}
+
+
+}
diff --git a/ui/app/components/job-status/individual-allocation.hbs b/ui/app/components/job-status/individual-allocation.hbs
deleted file mode 100644
index fd6cc7c56aa..00000000000
--- a/ui/app/components/job-status/individual-allocation.hbs
+++ /dev/null
@@ -1,36 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
- {{#unless @steady}}
- {{#if (eq @canary "canary")}}
-
- {{/if}}
- {{#if (eq @status "running")}}
-
- {{#if (eq @health "healthy")}}
-
- {{else if (eq @health "unhealthy")}}
-
- {{else}}
-
- {{/if}}
-
- {{/if}}
- {{/unless}}
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/individual-allocation.js b/ui/app/components/job-status/individual-allocation.js
deleted file mode 100644
index 72493f064b7..00000000000
--- a/ui/app/components/job-status/individual-allocation.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { alias } from '@ember/object/computed';
-
-export default class JobStatusIndividualAllocationComponent extends Component {
- @alias('args.allocation.job.type') jobType;
- @alias('args.allocation.node.name') nodeName;
- @alias('args.allocation.taskGroup.name') groupName;
- @alias('args.allocation.job.taskGroups') taskGroups;
- @alias('args.allocation.shortId') shortId;
-
- get showClient() {
- return this.jobType === 'system' || this.jobType === 'sysbatch';
- }
-
- get tooltipText() {
- if (this.showClient) {
- return `${this.nodeName} - ${this.shortId}`;
- } else if (this.groupName && this.taskGroups?.length > 1) {
- return `${this.groupName} - ${this.shortId}`;
- } else {
- return this.shortId;
- }
- }
-}
diff --git a/ui/app/components/job-status/latest-deployment.gjs b/ui/app/components/job-status/latest-deployment.gjs
new file mode 100644
index 00000000000..59dac71ec2e
--- /dev/null
+++ b/ui/app/components/job-status/latest-deployment.gjs
@@ -0,0 +1,73 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { LinkTo } from '@ember/routing';
+import {
+ HdsBadge,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+
+export default class JobStatusLatestDeployment extends Component {
+ get deployment() {
+ return get(this.args.job, 'latestDeployment');
+ }
+
+ get status() {
+ return get(this.deployment, 'status');
+ }
+
+ get healthyAllocs() {
+ const summaries = get(this.deployment, 'taskGroupSummaries') || [];
+ return summaries
+ .mapBy('healthyAllocs')
+ .reduce((sum, count) => sum + count, 0);
+ }
+
+ get desiredTotal() {
+ const summaries = get(this.deployment, 'taskGroupSummaries') || [];
+ return summaries
+ .mapBy('desiredTotal')
+ .reduce((sum, count) => sum + count, 0);
+ }
+
+ get statusColor() {
+ switch (this.status) {
+ case 'successful':
+ return 'success';
+ case 'failed':
+ return 'critical';
+ default:
+ return 'neutral';
+ }
+ }
+
+ get statusLabel() {
+ if (!this.status) {
+ return '';
+ }
+
+ return `${this.status.charAt(0).toUpperCase()}${this.status.slice(1)}`;
+ }
+
+
+
+
+
+ Latest Deployment
+
+
+
+
+ {{this.healthyAllocs}}/{{this.desiredTotal}} Healthy
+
+
+}
diff --git a/ui/app/components/job-status/latest-deployment.hbs b/ui/app/components/job-status/latest-deployment.hbs
deleted file mode 100644
index 8ce835c179e..00000000000
--- a/ui/app/components/job-status/latest-deployment.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- Latest Deployment
-
-
-
-
- {{this.healthyAllocs}}/{{this.desiredTotal}} Healthy
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/latest-deployment.js b/ui/app/components/job-status/latest-deployment.js
deleted file mode 100644
index 91d21996086..00000000000
--- a/ui/app/components/job-status/latest-deployment.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { alias } from '@ember/object/computed';
-
-export default class JobStatusLatestDeploymentComponent extends Component {
- @alias('args.job.latestDeployment') deployment;
- @alias('deployment.status') status;
-
- get healthyAllocs() {
- return this.deployment
- .get('taskGroupSummaries')
- .mapBy('healthyAllocs')
- .reduce((sum, count) => sum + count, 0);
- }
- get desiredTotal() {
- return this.deployment
- .get('taskGroupSummaries')
- .mapBy('desiredTotal')
- .reduce((sum, count) => sum + count, 0);
- }
-
- get statusColor() {
- switch (this.status) {
- case 'successful':
- return 'success';
- case 'failed':
- return 'critical';
- default:
- return 'neutral';
- }
- }
-}
diff --git a/ui/app/components/job-status/panel.gjs b/ui/app/components/job-status/panel.gjs
new file mode 100644
index 00000000000..9d34d5bc069
--- /dev/null
+++ b/ui/app/components/job-status/panel.gjs
@@ -0,0 +1,38 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import JobStatusPanelDeploying from 'nomad-ui/components/job-status/panel/deploying';
+import JobStatusPanelSteady from 'nomad-ui/components/job-status/panel/steady';
+
+export default class JobStatusPanel extends Component {
+ @service store;
+
+ get isActivelyDeploying() {
+ return this.args.job.get('latestDeployment.isRunning');
+ }
+
+ get nodes() {
+ if (!this.args.job.get('hasClientStatus')) {
+ return [];
+ }
+
+ return this.store.peekAll('node');
+ }
+
+
+ {{#if this.isActivelyDeploying}}
+
+ {{else}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/job-status/panel.hbs b/ui/app/components/job-status/panel.hbs
deleted file mode 100644
index d9d0e797be6..00000000000
--- a/ui/app/components/job-status/panel.hbs
+++ /dev/null
@@ -1,15 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.isActivelyDeploying}}
-
-{{else}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/job-status/panel.js b/ui/app/components/job-status/panel.js
deleted file mode 100644
index 58e698d24ea..00000000000
--- a/ui/app/components/job-status/panel.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class JobStatusPanelComponent extends Component {
- @service store;
-
- get isActivelyDeploying() {
- return this.args.job.get('latestDeployment.isRunning');
- }
-
- get nodes() {
- if (!this.args.job.get('hasClientStatus')) {
- return [];
- }
- return this.store.peekAll('node');
- }
-}
diff --git a/ui/app/components/job-status/panel/deploying.gjs b/ui/app/components/job-status/panel/deploying.gjs
new file mode 100644
index 00000000000..c2453ed11b0
--- /dev/null
+++ b/ui/app/components/job-status/panel/deploying.gjs
@@ -0,0 +1,641 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { get } from '@ember/object';
+import { task } from 'ember-concurrency';
+import can from 'ember-can/helpers/can';
+import { array } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import {
+ HdsAlert,
+ HdsBadge,
+ HdsButton,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import { and, eq, not } from 'ember-truth-helpers';
+import { hash, concat } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import ConditionalLinkTo from 'nomad-ui/components/conditional-link-to';
+import JobStatusAllocationStatusRow from 'nomad-ui/components/job-status/allocation-status-row';
+import JobStatusDeploymentHistory from 'nomad-ui/components/job-status/deployment-history';
+import JobStatusFailedOrLost from 'nomad-ui/components/job-status/failed-or-lost';
+import JobStatusUpdateParams from 'nomad-ui/components/job-status/update-params';
+import keyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+import { jobAllocStatuses } from 'nomad-ui/utils/allocation-client-statuses';
+
+export default class JobStatusPanelDeploying extends Component {
+ @tracked oldVersionAllocBlockIDs = [];
+
+ get deployment() {
+ return get(this.args.job, 'latestDeployment');
+ }
+
+ get deploymentVersionNumber() {
+ return get(this.args.job, 'latestDeployment.versionNumber');
+ }
+
+ get allocTypes() {
+ return jobAllocStatuses[this.args.job.type].map((type) => ({
+ label: type,
+ }));
+ }
+
+ get allocations() {
+ const relationship = this.args.job?.hasMany?.('allocations');
+ const ids = relationship?.ids?.() || [];
+ const store = this.args.job?.store;
+
+ if (!store || !ids.length) {
+ return [];
+ }
+
+ return ids.map((id) => store.peekRecord('allocation', id)).filter(Boolean);
+ }
+
+ establishOldAllocBlockIDs = () => {
+ this.oldVersionAllocBlockIDs = this.allocations.filter(
+ (allocation) => allocation.clientStatus === 'running' && allocation.isOld,
+ );
+ };
+
+ get canariesHealthy() {
+ const relevantAllocs = this.allocations.filter(
+ (allocation) =>
+ !allocation.isOld &&
+ allocation.isCanary &&
+ !allocation.hasBeenRescheduled,
+ );
+
+ return (
+ relevantAllocs.length &&
+ relevantAllocs.every(
+ (allocation) =>
+ allocation.clientStatus === 'running' && allocation.isHealthy,
+ )
+ );
+ }
+
+ get someCanariesHaveFailed() {
+ const relevantAllocs = this.allocations.filter(
+ (allocation) =>
+ !allocation.isOld &&
+ allocation.isCanary &&
+ !allocation.hasBeenRescheduled,
+ );
+
+ return relevantAllocs.some(
+ (allocation) =>
+ allocation.clientStatus === 'failed' ||
+ allocation.clientStatus === 'lost' ||
+ allocation.isUnhealthy,
+ );
+ }
+
+ promote = task(async () => {
+ try {
+ await this.args.job.latestDeployment.content.promote();
+ } catch (error) {
+ this.args.handleError?.({
+ title: 'Could Not Promote Deployment',
+ description: messageFromAdapterError(error, 'promote deployments'),
+ });
+ }
+ });
+
+ fail = task(async () => {
+ try {
+ await this.args.job.latestDeployment.content.fail();
+ } catch (error) {
+ this.args.handleError?.({
+ title: 'Could Not Fail Deployment',
+ description: messageFromAdapterError(error, 'fail deployments'),
+ });
+ }
+ });
+
+ get desiredTotal() {
+ return this.totalAllocs;
+ }
+
+ get oldVersionAllocBlocks() {
+ return this.allocations
+ .filter((allocation) => this.oldVersionAllocBlockIDs.includes(allocation))
+ .reduce((allocationGroups, currentAlloc) => {
+ const status = currentAlloc.clientStatus;
+
+ if (!allocationGroups[status]) {
+ allocationGroups[status] = {
+ healthy: { nonCanary: [] },
+ unhealthy: { nonCanary: [] },
+ health_unknown: { nonCanary: [] },
+ };
+ }
+
+ allocationGroups[status].healthy.nonCanary.push(currentAlloc);
+ return allocationGroups;
+ }, {});
+ }
+
+ get newVersionAllocBlocks() {
+ let availableSlotsToFill = this.desiredTotal;
+ const allocationsOfDeploymentVersion = this.allocations.filter(
+ (allocation) => !allocation.isOld,
+ );
+
+ const allocationCategories = this.allocTypes.reduce((categories, type) => {
+ categories[type.label] = {
+ healthy: { canary: [], nonCanary: [] },
+ unhealthy: { canary: [], nonCanary: [] },
+ health_unknown: { canary: [], nonCanary: [] },
+ };
+ return categories;
+ }, {});
+
+ for (const alloc of allocationsOfDeploymentVersion) {
+ if (availableSlotsToFill <= 0) {
+ break;
+ }
+
+ const status = alloc.clientStatus;
+ const canary = alloc.isCanary ? 'canary' : 'nonCanary';
+ const health =
+ status === 'running'
+ ? alloc.isHealthy
+ ? 'healthy'
+ : alloc.isUnhealthy
+ ? 'unhealthy'
+ : 'health_unknown'
+ : 'health_unknown';
+
+ if (allocationCategories[status]) {
+ if (alloc.willNotRestart) {
+ if (!alloc.willNotReschedule) {
+ continue;
+ }
+ }
+
+ allocationCategories[status][health][canary].push(alloc);
+ availableSlotsToFill--;
+ }
+ }
+
+ if (availableSlotsToFill > 0) {
+ allocationCategories.unplaced = {
+ healthy: { canary: [], nonCanary: [] },
+ unhealthy: { canary: [], nonCanary: [] },
+ health_unknown: { canary: [], nonCanary: [] },
+ };
+ allocationCategories.unplaced.healthy.nonCanary = Array(
+ availableSlotsToFill,
+ )
+ .fill()
+ .map(() => ({ clientStatus: 'unplaced' }));
+ }
+
+ return allocationCategories;
+ }
+
+ get newRunningHealthyAllocBlocks() {
+ return [
+ ...this.newVersionAllocBlocks.running.healthy.canary,
+ ...this.newVersionAllocBlocks.running.healthy.nonCanary,
+ ];
+ }
+
+ get newRunningUnhealthyAllocBlocks() {
+ return [
+ ...this.newVersionAllocBlocks.running.unhealthy.canary,
+ ...this.newVersionAllocBlocks.running.unhealthy.nonCanary,
+ ];
+ }
+
+ get newRunningHealthUnknownAllocBlocks() {
+ return [
+ ...this.newVersionAllocBlocks.running.health_unknown.canary,
+ ...this.newVersionAllocBlocks.running.health_unknown.nonCanary,
+ ];
+ }
+
+ get rescheduledAllocs() {
+ return this.allocations.filter(
+ (allocation) => !allocation.isOld && allocation.hasBeenRescheduled,
+ );
+ }
+
+ get restartedAllocs() {
+ return this.allocations.filter(
+ (allocation) => !allocation.isOld && allocation.hasBeenRestarted,
+ );
+ }
+
+ get newAllocsByStatus() {
+ return Object.entries(this.newVersionAllocBlocks).reduce(
+ (counts, [status, healthStatusObj]) => {
+ counts[status] = Object.values(healthStatusObj)
+ .flatMap((canaryStatusObj) => Object.values(canaryStatusObj))
+ .flatMap((canaryStatusArray) => canaryStatusArray).length;
+ return counts;
+ },
+ {},
+ );
+ }
+
+ get newAllocsByCanary() {
+ const counts = Object.values(this.newVersionAllocBlocks)
+ .flatMap((healthStatusObj) => Object.values(healthStatusObj))
+ .flatMap((canaryStatusObj) => Object.entries(canaryStatusObj))
+ .reduce((accumulator, [canaryStatus, items]) => {
+ accumulator[canaryStatus] =
+ (accumulator[canaryStatus] || 0) + items.length;
+ return accumulator;
+ }, {});
+
+ return {
+ canary: counts.canary || 0,
+ nonCanary: counts.nonCanary || 0,
+ };
+ }
+
+ get newAllocsByHealth() {
+ return {
+ healthy: this.newRunningHealthyAllocBlocks.length,
+ unhealthy: this.newRunningUnhealthyAllocBlocks.length,
+ health_unknown: this.newRunningHealthUnknownAllocBlocks.length,
+ };
+ }
+
+ get oldRunningHealthyAllocBlocks() {
+ return this.oldVersionAllocBlocks.running?.healthy?.nonCanary || [];
+ }
+
+ get oldCompleteHealthyAllocBlocks() {
+ return this.oldVersionAllocBlocks.complete?.healthy?.nonCanary || [];
+ }
+
+ get totalAllocs() {
+ return this.args.job.taskGroups.reduce(
+ (sum, taskGroup) => sum + taskGroup.count,
+ 0,
+ );
+ }
+
+ get deploymentIsAutoPromoted() {
+ return get(this.args.job, 'latestDeployment.isAutoPromoted');
+ }
+
+ buildVersionEntries(versions) {
+ return versions
+ .map((allocation) =>
+ !isNaN(allocation?.jobVersion) ? allocation.jobVersion : 'unknown',
+ )
+ .sort((left, right) => left - right)
+ .reduce((result, item) => {
+ const existingVersion = result.find(
+ (version) => version.version === item,
+ );
+ if (existingVersion) {
+ existingVersion.allocations.push(item);
+ } else {
+ result.push({
+ version: item,
+ allocations: [item],
+ query: {
+ version: `[${item}]`,
+ status: '["running", "pending", "failed"]',
+ },
+ });
+ }
+ return result;
+ }, []);
+ }
+
+ get oldVersions() {
+ return this.buildVersionEntries(
+ Object.values(this.oldRunningHealthyAllocBlocks),
+ );
+ }
+
+ get newVersions() {
+ const newVersionAllocs = Object.values(this.newVersionAllocBlocks)
+ .flatMap((allocType) => Object.values(allocType))
+ .flatMap((allocHealth) => Object.values(allocHealth))
+ .flatMap((allocCanary) => Object.values(allocCanary))
+ .filter(
+ (allocation) =>
+ allocation.jobVersion && allocation.jobVersion !== 'unknown',
+ );
+
+ return this.buildVersionEntries(newVersionAllocs);
+ }
+
+ get versions() {
+ return [...this.oldVersions, ...this.newVersions];
+ }
+
+ get oldVersionLegend() {
+ return [
+ {
+ count: this.oldRunningHealthyAllocBlocks.length,
+ label: 'Running',
+ status: 'running',
+ },
+ {
+ count: this.oldCompleteHealthyAllocBlocks.length,
+ label: 'Complete',
+ status: 'complete',
+ },
+ ];
+ }
+
+ get newStatusLegend() {
+ return Object.entries(this.newAllocsByStatus).map(([status, count]) => ({
+ status,
+ count,
+ query: {
+ status: `["${status}"]`,
+ version: `[${this.deploymentVersionNumber}]`,
+ },
+ label: this.capitalize(status),
+ }));
+ }
+
+ get newHealthLegend() {
+ return Object.entries(this.newAllocsByHealth).map(([health, count]) => ({
+ health,
+ count,
+ label: this.humanize(health),
+ }));
+ }
+
+ capitalize(value) {
+ if (!value) return '';
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
+ }
+
+ humanize(value) {
+ if (!value) return '';
+ return value
+ .split('_')
+ .map((word) => this.capitalize(word))
+ .join(' ');
+ }
+
+
+
+
+
+ Status:
+
+
+
+ {{#if @job.latestDeployment.isRunning}}
+ {{#if (can "fail deployment" namespace=@job.namespace)}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
+ {{#if @job.latestDeployment.requiresPromotion}}
+
+ {{#if this.canariesHealthy}}
+
+ Deployment requires promotion
+ Your deployment requires manual promotion — all
+ canary allocations have passed their health checks.
+ {{#if (can "promote deployment" namespace=@job.namespace)}}
+
+ {{/if}}
+
+ {{else if this.someCanariesHaveFailed}}
+
+ Some Canaries have failed
+ Your canary allocations have failed their health
+ checks. Please have a look at the error logs and task events
+ for the allocations in question.
+
+ {{else}}
+
+ Checking Canary health
+ {{#if this.deploymentIsAutoPromoted}}
+ Your canary allocations are being placed and
+ health-checked. If they pass, they will be automatically
+ promoted and your deployment will continue.
+ {{else}}
+ Your job requires manual promotion, and your
+ canary allocations are being placed and health-checked.
+ {{/if}}
+
+ {{/if}}
+
+ {{/if}}
+
+
+ {{#if this.oldVersionAllocBlockIDs.length}}
+
+
+ Previous allocations:
+ {{#if
+ this.oldVersionAllocBlocks.running
+ }}{{this.oldRunningHealthyAllocBlocks.length}} running{{/if}}
+
+
+
+
+ {{#each this.oldVersions as |versionObj|}}
+ -
+
+ {{#if (eq versionObj.version "unknown")}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+ {{/each}}
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+
+ New
+ allocations:
+ {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}}
+ running and healthy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/job-status/panel/deploying.hbs b/ui/app/components/job-status/panel/deploying.hbs
deleted file mode 100644
index 121468be8fa..00000000000
--- a/ui/app/components/job-status/panel/deploying.hbs
+++ /dev/null
@@ -1,281 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- Status:
-
-
-
- {{#if @job.latestDeployment.isRunning}}
- {{#if (can "fail deployment" namespace=@job.namespace)}}
-
- {{/if}}
- {{/if}}
-
-
-
-
- {{#if @job.latestDeployment.requiresPromotion}}
-
- {{#if this.canariesHealthy}}
-
- Deployment requires promotion
- Your deployment requires manual promotion — all
- canary allocations have passed their health checks.
- {{#if (can "promote deployment" namespace=@job.namespace)}}
-
- {{/if}}
-
- {{else}}
- {{#if this.someCanariesHaveFailed}}
-
- Some Canaries have failed
- Your canary allocations have failed their health
- checks. Please have a look at the error logs and task events for
- the allocations in question.
-
- {{else}}
-
- Checking Canary health
- {{#if this.deploymentIsAutoPromoted}}
- Your canary allocations are being placed and
- health-checked. If they pass, they will be automatically
- promoted and your deployment will continue.
- {{else}}
- Your job requires manual promotion, and your
- canary allocations are being placed and health-checked.
- {{/if}}
-
- {{/if}}
- {{/if}}
-
- {{/if}}
-
-
- {{#if this.oldVersionAllocBlockIDs.length}}
-
-
- Previous allocations:
- {{#if
- this.oldVersionAllocBlocks.running
- }}{{this.oldRunningHealthyAllocBlocks.length}} running{{/if}}
-
-
-
-
- {{#each this.oldVersions as |versionObj|}}
- -
-
- {{#if (eq versionObj.version "unknown")}}
-
- {{else}}
-
- {{/if}}
-
-
-
- {{/each}}
-
-
-
-
-
-
-
-
-
-
- {{/if}}
-
- New
- allocations:
- {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}}
- running and healthy
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{! Legend by Status, then by Health, then by Canary }}
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/panel/deploying.js b/ui/app/components/job-status/panel/deploying.js
deleted file mode 100644
index a4584609a0e..00000000000
--- a/ui/app/components/job-status/panel/deploying.js
+++ /dev/null
@@ -1,315 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { task } from 'ember-concurrency';
-import { tracked } from '@glimmer/tracking';
-import { alias } from '@ember/object/computed';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-import { jobAllocStatuses } from '../../../utils/allocation-client-statuses';
-
-export default class JobStatusPanelDeployingComponent extends Component {
- @alias('args.job') job;
- @alias('args.handleError') handleError = () => {};
-
- get allocTypes() {
- return jobAllocStatuses[this.args.job.type].map((type) => {
- return {
- label: type,
- };
- });
- }
-
- get allocations() {
- const relationship = this.job?.hasMany?.('allocations');
- const ids = relationship?.ids?.() || [];
- const store = this.job?.store;
-
- if (!store || !ids.length) {
- return [];
- }
-
- return ids.map((id) => store.peekRecord('allocation', id)).filter(Boolean);
- }
-
- @tracked oldVersionAllocBlockIDs = [];
-
- // Called via did-insert; sets a static array of "outgoing"
- // allocations we can track throughout a deployment
- @action
- establishOldAllocBlockIDs() {
- this.oldVersionAllocBlockIDs = this.allocations.filter(
- (a) => a.clientStatus === 'running' && a.isOld,
- );
- }
-
- /**
- * Promotion of a deployment will error if the canary allocations are not of status "Healthy";
- * this function will check for that and disable the promote button if necessary.
- * @returns {boolean}
- */
- get canariesHealthy() {
- const relevantAllocs = this.allocations.filter(
- (a) => !a.isOld && a.isCanary && !a.hasBeenRescheduled,
- );
- return (
- relevantAllocs.length &&
- relevantAllocs.every((a) => a.clientStatus === 'running' && a.isHealthy)
- );
- }
-
- get someCanariesHaveFailed() {
- const relevantAllocs = this.allocations.filter(
- (a) => !a.isOld && a.isCanary && !a.hasBeenRescheduled,
- );
- return relevantAllocs.some(
- (a) =>
- a.clientStatus === 'failed' ||
- a.clientStatus === 'lost' ||
- a.isUnhealthy,
- );
- }
-
- @task(function* () {
- try {
- yield this.job.latestDeployment.content.promote();
- } catch (err) {
- this.handleError({
- title: 'Could Not Promote Deployment',
- description: messageFromAdapterError(err, 'promote deployments'),
- });
- }
- })
- promote;
-
- @task(function* () {
- try {
- yield this.job.latestDeployment.content.fail();
- } catch (err) {
- this.handleError({
- title: 'Could Not Fail Deployment',
- description: messageFromAdapterError(err, 'fail deployments'),
- });
- }
- })
- fail;
-
- @alias('job.latestDeployment') deployment;
- @alias('totalAllocs') desiredTotal;
-
- get oldVersionAllocBlocks() {
- return this.allocations
- .filter((allocation) => this.oldVersionAllocBlockIDs.includes(allocation))
- .reduce((alloGroups, currentAlloc) => {
- const status = currentAlloc.clientStatus;
-
- if (!alloGroups[status]) {
- alloGroups[status] = {
- healthy: { nonCanary: [] },
- unhealthy: { nonCanary: [] },
- health_unknown: { nonCanary: [] },
- };
- }
- alloGroups[status].healthy.nonCanary.push(currentAlloc);
-
- return alloGroups;
- }, {});
- }
-
- get newVersionAllocBlocks() {
- let availableSlotsToFill = this.desiredTotal;
- let allocationsOfDeploymentVersion = this.allocations.filter(
- (a) => !a.isOld,
- );
-
- let allocationCategories = this.allocTypes.reduce((categories, type) => {
- categories[type.label] = {
- healthy: { canary: [], nonCanary: [] },
- unhealthy: { canary: [], nonCanary: [] },
- health_unknown: { canary: [], nonCanary: [] },
- };
- return categories;
- }, {});
-
- for (let alloc of allocationsOfDeploymentVersion) {
- if (availableSlotsToFill <= 0) {
- break;
- }
- let status = alloc.clientStatus;
- let canary = alloc.isCanary ? 'canary' : 'nonCanary';
-
- // Health status only matters in the context of a "running" allocation.
- // However, healthy/unhealthy is never purged when an allocation moves to a different clientStatus
- // Thus, we should only show something as "healthy" in the event that it is running.
- // Otherwise, we'd have arbitrary groupings based on previous health status.
- let health =
- status === 'running'
- ? alloc.isHealthy
- ? 'healthy'
- : alloc.isUnhealthy
- ? 'unhealthy'
- : 'health_unknown'
- : 'health_unknown';
-
- if (allocationCategories[status]) {
- // If status is failed or lost, we only want to show it IF it's used up its restarts/rescheds.
- // Otherwise, we'd be showing an alloc that had been replaced.
- if (alloc.willNotRestart) {
- if (!alloc.willNotReschedule) {
- // Dont count it
- continue;
- }
- }
- allocationCategories[status][health][canary].push(alloc);
- availableSlotsToFill--;
- }
- }
-
- // Fill unplaced slots if availableSlotsToFill > 0
- if (availableSlotsToFill > 0) {
- allocationCategories['unplaced'] = {
- healthy: { canary: [], nonCanary: [] },
- unhealthy: { canary: [], nonCanary: [] },
- health_unknown: { canary: [], nonCanary: [] },
- };
- allocationCategories['unplaced']['healthy']['nonCanary'] = Array(
- availableSlotsToFill,
- )
- .fill()
- .map(() => {
- return { clientStatus: 'unplaced' };
- });
- }
-
- return allocationCategories;
- }
-
- get newRunningHealthyAllocBlocks() {
- return [
- ...this.newVersionAllocBlocks['running']['healthy']['canary'],
- ...this.newVersionAllocBlocks['running']['healthy']['nonCanary'],
- ];
- }
-
- get newRunningUnhealthyAllocBlocks() {
- return [
- ...this.newVersionAllocBlocks['running']['unhealthy']['canary'],
- ...this.newVersionAllocBlocks['running']['unhealthy']['nonCanary'],
- ];
- }
-
- get newRunningHealthUnknownAllocBlocks() {
- return [
- ...this.newVersionAllocBlocks['running']['health_unknown']['canary'],
- ...this.newVersionAllocBlocks['running']['health_unknown']['nonCanary'],
- ];
- }
-
- get rescheduledAllocs() {
- return this.allocations.filter((a) => !a.isOld && a.hasBeenRescheduled);
- }
-
- get restartedAllocs() {
- return this.allocations.filter((a) => !a.isOld && a.hasBeenRestarted);
- }
-
- // #region legend
- get newAllocsByStatus() {
- return Object.entries(this.newVersionAllocBlocks).reduce(
- (counts, [status, healthStatusObj]) => {
- counts[status] = Object.values(healthStatusObj)
- .flatMap((canaryStatusObj) => Object.values(canaryStatusObj))
- .flatMap((canaryStatusArray) => canaryStatusArray).length;
- return counts;
- },
- {},
- );
- }
-
- get newAllocsByCanary() {
- return Object.values(this.newVersionAllocBlocks)
- .flatMap((healthStatusObj) => Object.values(healthStatusObj))
- .flatMap((canaryStatusObj) => Object.entries(canaryStatusObj))
- .reduce((counts, [canaryStatus, items]) => {
- counts[canaryStatus] = (counts[canaryStatus] || 0) + items.length;
- return counts;
- }, {});
- }
-
- get newAllocsByHealth() {
- return {
- healthy: this.newRunningHealthyAllocBlocks.length,
- unhealthy: this.newRunningUnhealthyAllocBlocks.length,
- health_unknown: this.newRunningHealthUnknownAllocBlocks.length,
- };
- }
- // #endregion legend
-
- get oldRunningHealthyAllocBlocks() {
- return this.oldVersionAllocBlocks.running?.healthy?.nonCanary || [];
- }
- get oldCompleteHealthyAllocBlocks() {
- return this.oldVersionAllocBlocks.complete?.healthy?.nonCanary || [];
- }
-
- // TODO: eventually we will want this from a new property on a job.
- // TODO: consolidate w/ the one in steady.js
- get totalAllocs() {
- // v----- Experimental method: Count all allocs. Good for testing but not a realistic representation of "Desired"
- // return this.allocTypes.reduce((sum, type) => sum + this.args.job[type.property], 0);
-
- // v----- Realistic method: Tally a job's task groups' "count" property
- return this.args.job.taskGroups.reduce((sum, tg) => sum + tg.count, 0);
- }
-
- get deploymentIsAutoPromoted() {
- return this.job.latestDeployment?.get('isAutoPromoted');
- }
-
- get oldVersions() {
- const oldVersions = Object.values(this.oldRunningHealthyAllocBlocks)
- .map((a) => (!isNaN(a?.jobVersion) ? a.jobVersion : 'unknown')) // "starting" allocs, GC'd allocs, etc. do not have a jobVersion
- .sort((a, b) => a - b)
- .reduce((result, item) => {
- const existingVersion = result.find((v) => v.version === item);
- if (existingVersion) {
- existingVersion.allocations.push(item);
- } else {
- result.push({ version: item, allocations: [item] });
- }
- return result;
- }, []);
-
- return oldVersions;
- }
-
- get newVersions() {
- // Note: it's probably safe to assume all new allocs have the latest job version, but
- // let's map just in case there's ever a situation with multiple job versions going out
- // in a deployment for some reason
- const newVersions = Object.values(this.newVersionAllocBlocks)
- .flatMap((allocType) => Object.values(allocType))
- .flatMap((allocHealth) => Object.values(allocHealth))
- .flatMap((allocCanary) => Object.values(allocCanary))
- .filter((a) => a.jobVersion && a.jobVersion !== 'unknown')
- .map((a) => a.jobVersion)
- .sort((a, b) => a - b)
- .reduce((result, item) => {
- const existingVersion = result.find((v) => v.version === item);
- if (existingVersion) {
- existingVersion.allocations.push(item);
- } else {
- result.push({ version: item, allocations: [item] });
- }
- return result;
- }, []);
- return newVersions;
- }
-
- get versions() {
- return [...this.oldVersions, ...this.newVersions];
- }
-}
diff --git a/ui/app/components/job-status/panel/steady.gjs b/ui/app/components/job-status/panel/steady.gjs
new file mode 100644
index 00000000000..174bcef1147
--- /dev/null
+++ b/ui/app/components/job-status/panel/steady.gjs
@@ -0,0 +1,432 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { LinkTo } from '@ember/routing';
+import { HdsBadge } from '@hashicorp/design-system-components/components';
+import { and, eq, notEq } from 'ember-truth-helpers';
+import JobPagePartsSummaryChart from 'nomad-ui/components/job-page/parts/summary-chart';
+import ConditionalLinkTo from 'nomad-ui/components/conditional-link-to';
+import JobStatusAllocationStatusRow from 'nomad-ui/components/job-status/allocation-status-row';
+import JobStatusDeploymentHistory from 'nomad-ui/components/job-status/deployment-history';
+import JobStatusFailedOrLost from 'nomad-ui/components/job-status/failed-or-lost';
+import JobStatusLatestDeployment from 'nomad-ui/components/job-status/latest-deployment';
+import { jobAllocStatuses } from 'nomad-ui/utils/allocation-client-statuses';
+import { on } from '@ember/modifier';
+
+export default class JobStatusPanelSteady extends Component {
+ get allocations() {
+ const relationship = this.args.job?.hasMany?.('allocations');
+ const ids = relationship?.ids?.() || [];
+ const store = this.args.job?.store;
+
+ if (!store || !ids.length) {
+ return [];
+ }
+
+ return ids.map((id) => store.peekRecord('allocation', id)).filter(Boolean);
+ }
+
+ get allocTypes() {
+ return this.args.job.allocTypes;
+ }
+
+ get allocBlocks() {
+ let availableSlotsToFill = this.totalAllocs;
+
+ const allocationsOfShowableType = this.allocTypes.reduce(
+ (accumulator, type) => {
+ accumulator[type.label] = { healthy: { nonCanary: [] } };
+ return accumulator;
+ },
+ {},
+ );
+
+ for (const alloc of this.allocations.filter(
+ (allocation) =>
+ allocation.clientStatus === 'running' ||
+ allocation.clientStatus === 'pending',
+ )) {
+ if (availableSlotsToFill === 0) {
+ break;
+ }
+
+ const status = alloc.clientStatus;
+ allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
+ availableSlotsToFill--;
+ }
+
+ const sortedAllocs = this.allocations
+ .filter(
+ (allocation) =>
+ allocation.clientStatus !== 'running' &&
+ allocation.clientStatus !== 'pending',
+ )
+ .sort((left, right) => {
+ if (left.jobVersion > right.jobVersion) return 1;
+ if (left.jobVersion < right.jobVersion) return -1;
+
+ if (left.jobVersion === right.jobVersion) {
+ return (
+ jobAllocStatuses[this.args.job.type].indexOf(right.clientStatus) -
+ jobAllocStatuses[this.args.job.type].indexOf(left.clientStatus)
+ );
+ }
+
+ return 0;
+ })
+ .reverse();
+
+ for (const alloc of sortedAllocs) {
+ if (availableSlotsToFill === 0) {
+ break;
+ }
+
+ const status = alloc.clientStatus;
+ if (
+ this.allocTypes.map(({ label }) => label).includes(status) &&
+ allocationsOfShowableType[status].healthy.nonCanary.length <
+ this.totalAllocs
+ ) {
+ allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
+ availableSlotsToFill--;
+ }
+ }
+
+ if (availableSlotsToFill > 0) {
+ allocationsOfShowableType.unplaced = {
+ healthy: {
+ nonCanary: Array(availableSlotsToFill)
+ .fill()
+ .map(() => ({ clientStatus: 'unplaced' })),
+ },
+ };
+ }
+
+ return allocationsOfShowableType;
+ }
+
+ get nodes() {
+ return this.args.nodes;
+ }
+
+ get totalAllocs() {
+ if (this.args.job.type === 'service' || this.args.job.type === 'batch') {
+ return this.args.job.taskGroups.reduce(
+ (sum, taskGroup) => sum + taskGroup.count,
+ 0,
+ );
+ } else if (this.atMostOneAllocPerNode) {
+ return new Set(
+ this.allocations
+ .map((allocation) => allocation?.nodeID)
+ .filter(Boolean),
+ ).size;
+ }
+
+ return this.args.job.count;
+ }
+
+ get totalNonCompletedAllocs() {
+ return this.totalAllocs - this.completedAllocs.length;
+ }
+
+ get allAllocsComplete() {
+ return this.completedAllocs.length && this.totalNonCompletedAllocs === 0;
+ }
+
+ get atMostOneAllocPerNode() {
+ return this.args.job.type === 'system' || this.args.job.type === 'sysbatch';
+ }
+
+ get versions() {
+ return Object.values(this.allocBlocks)
+ .flatMap((allocType) => Object.values(allocType))
+ .flatMap((allocHealth) => Object.values(allocHealth))
+ .flatMap((allocCanary) => Object.values(allocCanary))
+ .map((allocation) =>
+ !isNaN(allocation?.jobVersion) ? allocation.jobVersion : 'unknown',
+ )
+ .sort((left, right) => left - right)
+ .reduce((result, item) => {
+ const existingVersion = result.find(
+ (version) => version.version === item,
+ );
+ if (existingVersion) {
+ existingVersion.allocations.push(item);
+ } else {
+ result.push({
+ version: item,
+ allocations: [item],
+ query: {
+ version: `[${item}]`,
+ status: '["running", "pending", "failed"]',
+ },
+ });
+ }
+ return result;
+ }, []);
+ }
+
+ get versionsQueryString() {
+ return `[${this.versions.map((version) => version.version).join(',')}]`;
+ }
+
+ get rescheduledAllocs() {
+ return this.allocations.filter(
+ (allocation) => !allocation.isOld && allocation.hasBeenRescheduled,
+ );
+ }
+
+ get restartedAllocs() {
+ return this.allocations.filter(
+ (allocation) => !allocation.isOld && allocation.hasBeenRestarted,
+ );
+ }
+
+ get runningAllocs() {
+ return this.allocations.filter(
+ (allocation) => allocation.clientStatus === 'running',
+ );
+ }
+
+ get completedAllocs() {
+ return this.allocations.filter(
+ (allocation) =>
+ !allocation.isOld && allocation.clientStatus === 'complete',
+ );
+ }
+
+ get supportsRescheduling() {
+ return this.args.job.type !== 'system';
+ }
+
+ get latestVersionAllocations() {
+ return this.allocations.filter((allocation) => !allocation.isOld);
+ }
+
+ get currentStatus() {
+ const totalAllocs = this.totalAllocs;
+
+ if (this.args.job.status === 'dead' && this.args.job.stopped) {
+ return { label: 'Stopped', state: 'neutral' };
+ }
+
+ if (this.totalAllocs === 0 && !this.args.job.hasClientStatus) {
+ return { label: 'Scaled Down', state: 'neutral' };
+ }
+
+ if (this.args.job.type === 'batch' || this.args.job.type === 'sysbatch') {
+ const completeAllocs = this.allocBlocks.complete?.healthy?.nonCanary;
+ if (completeAllocs?.length === totalAllocs) {
+ return { label: 'Complete', state: 'success' };
+ }
+
+ const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
+ if (healthyAllocs?.length + completeAllocs?.length === totalAllocs) {
+ return { label: 'Running', state: 'success' };
+ }
+ }
+
+ const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
+ if (healthyAllocs?.length && healthyAllocs?.length === totalAllocs) {
+ return { label: 'Healthy', state: 'success' };
+ }
+
+ const pendingAllocs = this.allocBlocks.pending?.healthy?.nonCanary;
+ if (pendingAllocs?.length > 0) {
+ return { label: 'Recovering', state: 'highlight' };
+ }
+
+ const failedOrLostAllocs = [
+ ...(this.allocBlocks.failed?.healthy?.nonCanary || []),
+ ...(this.allocBlocks.lost?.healthy?.nonCanary || []),
+ ...(this.allocBlocks.unplaced?.healthy?.nonCanary || []),
+ ];
+
+ if (failedOrLostAllocs.length === totalAllocs) {
+ return { label: 'Failed', state: 'critical' };
+ }
+
+ return { label: 'Degraded', state: 'warning' };
+ }
+
+ get legendEntries() {
+ return this.allocTypes.map((type) => {
+ const count =
+ this.allocBlocks[type.label]?.healthy?.nonCanary?.length || 0;
+ return {
+ type: type.label,
+ count,
+ label: this.capitalize(type.label),
+ query: {
+ status: `["${type.label}"]`,
+ version: this.versionsQueryString,
+ },
+ };
+ });
+ }
+
+ get runningSummary() {
+ if (this.allAllocsComplete) {
+ return 'All allocations have completed successfully';
+ }
+
+ const total = this.atMostOneAllocPerNode
+ ? ''
+ : this.args.job.type === 'batch'
+ ? `/${this.totalNonCompletedAllocs}`
+ : `/${this.totalAllocs}`;
+ const remaining = this.args.job.type === 'batch' ? 'Remaining ' : '';
+ const allocationLabel =
+ this.runningAllocs.length === 1 ? 'Allocation' : 'Allocations';
+
+ return `${this.runningAllocs.length}${total} ${remaining}${allocationLabel} Running`;
+ }
+
+ capitalize(value) {
+ if (!value) return '';
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
+ }
+
+ setCurrentStatusMode = () => {
+ this.args.setStatusMode?.('current');
+ };
+
+ setHistoricalStatusMode = () => {
+ this.args.setStatusMode?.('historical');
+ };
+
+
+
+
+ Status:
+
+
+
+
+
+
+
+
+ {{#if (eq @statusMode "historical")}}
+
+ {{else}}
+ {{this.runningSummary}}
+
+
+
+
+
+
+
+
+ Versions
+
+ {{#each this.versions as |versionObj|}}
+ -
+
+ {{#if (eq versionObj.version "unknown")}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+ {{/each}}
+
+
+
+ {{#if @job.latestDeployment}}
+
+ {{/if}}
+
+
+
+
+ {{#if this.latestVersionAllocations.length}}
+
+ {{/if}}
+
+
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/job-status/panel/steady.hbs b/ui/app/components/job-status/panel/steady.hbs
deleted file mode 100644
index 7aee98a8b37..00000000000
--- a/ui/app/components/job-status/panel/steady.hbs
+++ /dev/null
@@ -1,183 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Status:
-
-
-
-
-
-
-
-
- {{#if (eq @statusMode "historical")}}
-
- {{else}}
-
- {{#if this.allAllocsComplete}}
- All allocations have completed successfully
- {{else}}
-
- {{this.runningAllocs.length~}}
- {{#unless this.atMostOneAllocPerNode~}}
- {{#if (eq @job.type "batch")~}}
- /{{this.totalNonCompletedAllocs}}
- {{else~}}
- /{{this.totalAllocs}}
- {{/if}}
- {{/unless}}
-
- {{#if (eq @job.type "batch")~}}Remaining{{/if}}
- {{pluralize "Allocation" this.runningAllocs.length}}
- Running
- {{/if}}
-
-
-
-
-
-
-
-
-
- Versions
-
- {{#each this.versions as |versionObj|}}
- -
-
- {{#if (eq versionObj.version "unknown")}}
-
- {{else}}
-
- {{/if}}
-
-
-
- {{/each}}
-
-
-
- {{#if @job.latestDeployment}}
-
- {{/if}}
-
-
-
-
- {{#if this.latestVersionAllocations.length}}
-
- {{/if}}
-
-
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/panel/steady.js b/ui/app/components/job-status/panel/steady.js
deleted file mode 100644
index d3383dec6ea..00000000000
--- a/ui/app/components/job-status/panel/steady.js
+++ /dev/null
@@ -1,284 +0,0 @@
-/* eslint-disable no-unsafe-optional-chaining */
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { alias } from '@ember/object/computed';
-import { jobAllocStatuses } from '../../../utils/allocation-client-statuses';
-
-export default class JobStatusPanelSteadyComponent extends Component {
- @alias('args.job') job;
-
- get allocations() {
- const relationship = this.job?.hasMany?.('allocations');
- const ids = relationship?.ids?.() || [];
- const store = this.job?.store;
-
- if (!store || !ids.length) {
- return [];
- }
-
- return ids.map((id) => store.peekRecord('allocation', id)).filter(Boolean);
- }
-
- get allocTypes() {
- return this.args.job.allocTypes;
- }
-
- /**
- * @typedef {Object} HealthStatus
- * @property {Array} nonCanary
- * @property {Array} canary
- */
-
- /**
- * @typedef {Object} AllocationStatus
- * @property {HealthStatus} healthy
- * @property {HealthStatus} unhealthy
- * @property {HealthStatus} health unknown
- */
-
- /**
- * @typedef {Object} AllocationBlock
- * @property {AllocationStatus} [running]
- * @property {AllocationStatus} [pending]
- * @property {AllocationStatus} [failed]
- * @property {AllocationStatus} [lost]
- * @property {AllocationStatus} [unplaced]
- * @property {AllocationStatus} [complete]
- */
-
- /**
- * Looks through running/pending allocations with the aim of filling up your desired number of allocations.
- * If any desired remain, it will walk backwards through job versions and other allocation types to build
- * a picture of the job's overall status.
- *
- * @returns {AllocationBlock} An object containing healthy non-canary allocations
- * for each clientStatus.
- */
- get allocBlocks() {
- let availableSlotsToFill = this.totalAllocs;
-
- // Initialize allocationsOfShowableType with empty arrays for each clientStatus
- /**
- * @type {AllocationBlock}
- */
- let allocationsOfShowableType = this.allocTypes.reduce(
- (accumulator, type) => {
- accumulator[type.label] = { healthy: { nonCanary: [] } };
- return accumulator;
- },
- {},
- );
-
- // First accumulate the Running/Pending allocations
- for (const alloc of this.allocations.filter(
- (a) => a.clientStatus === 'running' || a.clientStatus === 'pending',
- )) {
- if (availableSlotsToFill === 0) {
- break;
- }
-
- const status = alloc.clientStatus;
- allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
- availableSlotsToFill--;
- }
-
- // Sort all allocs by jobVersion in descending order
- const sortedAllocs = this.allocations
- .filter(
- (a) => a.clientStatus !== 'running' && a.clientStatus !== 'pending',
- )
- .sort((a, b) => {
- // First sort by jobVersion
- if (a.jobVersion > b.jobVersion) return 1;
- if (a.jobVersion < b.jobVersion) return -1;
-
- // If jobVersion is the same, sort by status order
- if (a.jobVersion === b.jobVersion) {
- return (
- jobAllocStatuses[this.args.job.type].indexOf(b.clientStatus) -
- jobAllocStatuses[this.args.job.type].indexOf(a.clientStatus)
- );
- } else {
- return 0;
- }
- })
- .reverse();
-
- // Iterate over the sorted allocs
- for (const alloc of sortedAllocs) {
- if (availableSlotsToFill === 0) {
- break;
- }
-
- const status = alloc.clientStatus;
- // If the alloc has another clientStatus, add it to the corresponding list
- // as long as we haven't reached the totalAllocs limit for that clientStatus
- if (
- this.allocTypes.map(({ label }) => label).includes(status) &&
- allocationsOfShowableType[status].healthy.nonCanary.length <
- this.totalAllocs
- ) {
- allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
- availableSlotsToFill--;
- }
- }
-
- // Handle unplaced allocs
- if (availableSlotsToFill > 0) {
- allocationsOfShowableType['unplaced'] = {
- healthy: {
- nonCanary: Array(availableSlotsToFill)
- .fill()
- .map(() => {
- return { clientStatus: 'unplaced' };
- }),
- },
- };
- }
-
- return allocationsOfShowableType;
- }
-
- get nodes() {
- return this.args.nodes;
- }
-
- get totalAllocs() {
- if (this.args.job.type === 'service' || this.args.job.type === 'batch') {
- return this.args.job.taskGroups.reduce((sum, tg) => sum + tg.count, 0);
- } else if (this.atMostOneAllocPerNode) {
- return new Set(this.allocations.map((a) => a?.nodeID).filter(Boolean))
- .size;
- } else {
- return this.args.job.count; // TODO: this is probably not the correct totalAllocs count for any type.
- }
- }
-
- get totalNonCompletedAllocs() {
- return this.totalAllocs - this.completedAllocs.length;
- }
-
- get allAllocsComplete() {
- return this.completedAllocs.length && this.totalNonCompletedAllocs === 0;
- }
-
- get atMostOneAllocPerNode() {
- return this.args.job.type === 'system' || this.args.job.type === 'sysbatch';
- }
-
- get versions() {
- const versions = Object.values(this.allocBlocks)
- .flatMap((allocType) => Object.values(allocType))
- .flatMap((allocHealth) => Object.values(allocHealth))
- .flatMap((allocCanary) => Object.values(allocCanary))
- .map((a) => (!isNaN(a?.jobVersion) ? a.jobVersion : 'unknown')) // "starting" allocs, GC'd allocs, etc. do not have a jobVersion
- .sort((a, b) => a - b)
- .reduce((result, item) => {
- const existingVersion = result.find((v) => v.version === item);
- if (existingVersion) {
- existingVersion.allocations.push(item);
- } else {
- result.push({ version: item, allocations: [item] });
- }
- return result;
- }, []);
- return versions;
- }
-
- get rescheduledAllocs() {
- return this.allocations.filter((a) => !a.isOld && a.hasBeenRescheduled);
- }
-
- get restartedAllocs() {
- return this.allocations.filter((a) => !a.isOld && a.hasBeenRestarted);
- }
-
- get runningAllocs() {
- return this.allocations.filter((a) => a.clientStatus === 'running');
- }
-
- get completedAllocs() {
- return this.allocations.filter(
- (a) => !a.isOld && a.clientStatus === 'complete',
- );
- }
-
- get supportsRescheduling() {
- return this.job.type !== 'system';
- }
-
- get latestVersionAllocations() {
- return this.allocations.filter((a) => !a.isOld);
- }
-
- /**
- * @typedef {Object} CurrentStatus
- * @property {"Healthy"|"Failed"|"Degraded"|"Recovering"|"Complete"|"Running"|"Stopped"|"Scaled Down"} label - The current status of the job
- * @property {"highlight"|"success"|"warning"|"critical"|"neutral"} state -
- */
-
- /**
- * A general assessment for how a job is going, in a non-deployment state
- * @returns {CurrentStatus}
- */
- get currentStatus() {
- // If all allocs are running, the job is Healthy
- const totalAllocs = this.totalAllocs;
-
- if (this.job.status === 'dead' && this.job.stopped) {
- return {
- label: 'Stopped',
- state: 'neutral',
- };
- }
-
- if (this.totalAllocs === 0 && !this.job.hasClientStatus) {
- return {
- label: 'Scaled Down',
- state: 'neutral',
- };
- }
-
- if (this.job.type === 'batch' || this.job.type === 'sysbatch') {
- // If all the allocs are complete, the job is Complete
- const completeAllocs = this.allocBlocks.complete?.healthy?.nonCanary;
- if (completeAllocs?.length === totalAllocs) {
- return { label: 'Complete', state: 'success' };
- }
-
- // If any allocations are running the job is "Running"
- const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
- if (healthyAllocs?.length + completeAllocs?.length === totalAllocs) {
- return { label: 'Running', state: 'success' };
- }
- }
-
- const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
- if (healthyAllocs?.length && healthyAllocs?.length === totalAllocs) {
- return { label: 'Healthy', state: 'success' };
- }
-
- // If any allocations are pending the job is "Recovering"
- const pendingAllocs = this.allocBlocks.pending?.healthy?.nonCanary;
- if (pendingAllocs?.length > 0) {
- return { label: 'Recovering', state: 'highlight' };
- }
-
- // If any allocations are failed, lost, or unplaced in a steady state, the job is "Degraded"
- const failedOrLostAllocs = [
- ...this.allocBlocks.failed?.healthy?.nonCanary,
- ...this.allocBlocks.lost?.healthy?.nonCanary,
- ...this.allocBlocks.unplaced?.healthy?.nonCanary,
- ];
-
- if (failedOrLostAllocs.length === totalAllocs) {
- return { label: 'Failed', state: 'critical' };
- } else {
- return { label: 'Degraded', state: 'warning' };
- }
- }
-}
diff --git a/ui/app/components/job-status/update-params.gjs b/ui/app/components/job-status/update-params.gjs
new file mode 100644
index 00000000000..7740db71b38
--- /dev/null
+++ b/ui/app/components/job-status/update-params.gjs
@@ -0,0 +1,99 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { service } from '@ember/service';
+import { didInsert } from '@ember/render-modifiers';
+import Trigger from 'nomad-ui/components/trigger';
+import formatDuration from 'nomad-ui/utils/format-duration';
+
+const PARAMS_REQUIRING_CONVERSION = [
+ 'HealthyDeadline',
+ 'MinHealthyTime',
+ 'ProgressDeadline',
+ 'Stagger',
+];
+
+export default class JobStatusUpdateParams extends Component {
+ @service notifications;
+
+ @tracked rawDefinition = null;
+
+ get updateParamGroups() {
+ if (!this.rawDefinition) {
+ return null;
+ }
+
+ return this.rawDefinition.TaskGroups.map((taskGroup) => ({
+ name: taskGroup.Name,
+ update: Object.keys(taskGroup.Update || {}).reduce(
+ (newUpdateObj, key) => {
+ newUpdateObj[key] = PARAMS_REQUIRING_CONVERSION.includes(key)
+ ? formatDuration(taskGroup.Update[key])
+ : taskGroup.Update[key];
+ return newUpdateObj;
+ },
+ {},
+ ),
+ }));
+ }
+
+ onError = ({ Error }) => {
+ const error = Error.errors[0].title || 'Error fetching job parameters';
+ this.notifications.add({
+ title: 'Could not fetch job definition',
+ message: error,
+ color: 'critical',
+ });
+ };
+
+ fetchJobDefinition = async () => {
+ this.rawDefinition = await this.args.job.fetchRawDefinition();
+ };
+
+
+
+
+
+
+ Update Params
+
+
+ {{#if trigger.data.isSuccess}}
+
+ {{#each this.updateParamGroups as |group|}}
+ -
+ Group "{{group.name}}"
+
+ {{#each-in group.update as |key value|}}
+ -
+ {{key}}
+ {{value}}
+
+ {{/each-in}}
+
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if trigger.data.isBusy}}
+ Loading Parameters
+ {{/if}}
+
+ {{#if trigger.data.isError}}
+ Error loading parameters
+ {{/if}}
+
+
+
+
+
+}
diff --git a/ui/app/components/job-status/update-params.hbs b/ui/app/components/job-status/update-params.hbs
deleted file mode 100644
index a27b72bd276..00000000000
--- a/ui/app/components/job-status/update-params.hbs
+++ /dev/null
@@ -1,41 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{did-insert-helper trigger.fns.do}}
-
-
- Update Params
-
-
- {{#if (and trigger.data.isSuccess (not trigger.data.isError))}}
-
- {{#each this.updateParamGroups as |group|}}
- -
- Group "{{group.name}}"
-
- {{#each-in group.update as |k v|}}
- -
- {{k}}
- {{v}}
-
- {{/each-in}}
-
-
- {{/each}}
-
- {{/if}}
-
- {{#if trigger.data.isBusy}}
- Loading Parameters
- {{/if}}
-
- {{#if trigger.data.isError}}
- Error loading parameters
- {{/if}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-status/update-params.js b/ui/app/components/job-status/update-params.js
deleted file mode 100644
index ab77e002cc8..00000000000
--- a/ui/app/components/job-status/update-params.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-import { service } from '@ember/service';
-import formatDuration from 'nomad-ui/utils/format-duration';
-
-/**
- * @typedef {Object} DefinitionUpdateStrategy
- * @property {boolean} AutoPromote
- * @property {boolean} AutoRevert
- * @property {number} Canary
- * @property {number} MaxParallel
- * @property {string} HealthCheck
- * @property {number} MinHealthyTime
- * @property {number} HealthyDeadline
- * @property {number} ProgressDeadline
- * @property {number} Stagger
- */
-
-/**
- * @typedef {Object} DefinitionTaskGroup
- * @property {string} Name
- * @property {number} Count
- * @property {DefinitionUpdateStrategy} Update
- */
-
-/**
- * @typedef {Object} JobDefinition
- * @property {string} ID
- * @property {DefinitionUpdateStrategy} Update
- * @property {DefinitionTaskGroup[]} TaskGroups
- */
-
-const PARAMS_REQUIRING_CONVERSION = [
- 'HealthyDeadline',
- 'MinHealthyTime',
- 'ProgressDeadline',
- 'Stagger',
-];
-
-export default class JobStatusUpdateParamsComponent extends Component {
- @service notifications;
-
- /**
- * @type {JobDefinition}
- */
- @tracked rawDefinition = null;
-
- get updateParamGroups() {
- if (!this.rawDefinition) {
- return null;
- }
- return this.rawDefinition.TaskGroups.map((tg) => ({
- name: tg.Name,
- update: Object.keys(tg.Update || {}).reduce((newUpdateObj, key) => {
- newUpdateObj[key] = PARAMS_REQUIRING_CONVERSION.includes(key)
- ? formatDuration(tg.Update[key])
- : tg.Update[key];
- return newUpdateObj;
- }, {}),
- }));
- }
-
- @action onError({ Error }) {
- const error = Error.errors[0].title || 'Error fetching job parameters';
- this.notifications.add({
- title: 'Could not fetch job definition',
- message: error,
- color: 'critical',
- });
- }
-
- @action async fetchJobDefinition() {
- this.rawDefinition = await this.args.job.fetchRawDefinition();
- }
-}
diff --git a/ui/app/components/job-subnav.gjs b/ui/app/components/job-subnav.gjs
new file mode 100644
index 00000000000..5db834d0645
--- /dev/null
+++ b/ui/app/components/job-subnav.gjs
@@ -0,0 +1,136 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { service } from '@ember/service';
+import Component from '@glimmer/component';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+import formatJobId from 'nomad-ui/helpers/format-job-id';
+
+export default class JobSubnav extends Component {
+ @service abilities;
+ @service keyboard;
+
+ get shouldRenderClientsTab() {
+ const { job } = this.args;
+ return (
+ job?.hasClientStatus &&
+ !job?.hasChildren &&
+ this.abilities.can('read client')
+ );
+ }
+
+ get shouldHideNonParentTabs() {
+ return this.args.job?.hasChildren;
+ }
+
+ get canListVariables() {
+ return this.abilities.can('list variables');
+ }
+
+
+
+
+}
diff --git a/ui/app/components/job-subnav.hbs b/ui/app/components/job-subnav.hbs
deleted file mode 100644
index 19e137a33cd..00000000000
--- a/ui/app/components/job-subnav.hbs
+++ /dev/null
@@ -1,105 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-subnav.js b/ui/app/components/job-subnav.js
deleted file mode 100644
index 1511314e991..00000000000
--- a/ui/app/components/job-subnav.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@glimmer/component';
-
-export default class JobSubnav extends Component {
- @service abilities;
- @service keyboard;
-
- get shouldRenderClientsTab() {
- const { job } = this.args;
- return (
- job?.hasClientStatus &&
- !job?.hasChildren &&
- this.abilities.can('read client')
- );
- }
-
- // Periodic and Parameterized jobs "parents" are not jobs unto themselves, but more like summaries.
- // They should not have tabs for allocations, evaluations, etc.
- // but their child jobs, and other job types generally, should.
- get shouldHideNonParentTabs() {
- return this.args.job?.hasChildren;
- }
-}
diff --git a/ui/app/components/job-version.gjs b/ui/app/components/job-version.gjs
new file mode 100644
index 00000000000..22e444e1619
--- /dev/null
+++ b/ui/app/components/job-version.gjs
@@ -0,0 +1,517 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { fn, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didUpdate } from '@ember/render-modifiers';
+import { service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import can from 'ember-can/helpers/can';
+import { eq, not } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsFormTextInputField,
+} from '@hashicorp/design-system-components/components';
+import JobDiff from 'nomad-ui/components/job-diff';
+import TwoStepButton from 'nomad-ui/components/two-step-button';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import pluralize from 'nomad-ui/helpers/pluralize';
+import messageForError from 'nomad-ui/utils/message-from-adapter-error';
+
+const changeTypes = ['Added', 'Deleted', 'Edited'];
+
+export default class JobVersion extends Component {
+ @service store;
+ @service notifications;
+ @service router;
+
+ @tracked isOpen = false;
+ @tracked isEditing = false;
+ @tracked editableTag;
+ @tracked cloneButtonStatus = 'idle';
+
+ verbose = true;
+
+ constructor() {
+ super(...arguments);
+ this.isOpen = Boolean(this.args.diffsExpanded && this.diff);
+ }
+
+ get version() {
+ return this.args.version;
+ }
+
+ get diff() {
+ return this.args.diff;
+ }
+
+ versionsDidUpdate = () => {
+ if (this.args.diffsExpanded && this.diff) {
+ this.isOpen = true;
+ }
+ };
+
+ initializeEditableTag() {
+ const job = this.version.get('job');
+ const namespaceId =
+ this.version.get('job.namespaceId') ||
+ job.belongsTo('namespace').id() ||
+ 'default';
+ const jobName = this.version.get('job.plainId');
+
+ if (this.version.versionTag) {
+ this.editableTag = this.store.createRecord('versionTag', {
+ name: this.version.versionTag.name,
+ description: this.version.versionTag.description,
+ });
+ } else {
+ this.editableTag = this.store.createRecord('versionTag');
+ }
+ this.editableTag.versionNumber = this.version.number;
+ this.editableTag.jobNamespace = namespaceId;
+ this.editableTag.jobName = jobName;
+ }
+
+ get changeCount() {
+ const diff = this.diff;
+ const taskGroups = diff?.TaskGroups || [];
+
+ if (!diff) {
+ return 0;
+ }
+
+ return (
+ fieldChanges(diff) +
+ taskGroups.reduce(arrayOfFieldChanges, 0) +
+ (taskGroups.map((taskGroup) => taskGroup?.Tasks) || [])
+ .reduce(flatten, [])
+ .reduce(arrayOfFieldChanges, 0)
+ );
+ }
+
+ get isCurrent() {
+ return this.version.number === this.version.get('job.version');
+ }
+
+ toggleDiff = () => {
+ this.isOpen = !this.isOpen;
+ };
+
+ revertTo = task(async () => {
+ try {
+ const versionBeforeReversion = this.version.get('job.version');
+ await this.version.revertTo();
+ await this.version.get('job').reload();
+
+ const versionAfterReversion = this.version.get('job.version');
+ if (versionBeforeReversion === versionAfterReversion) {
+ this.args.handleError({
+ level: 'warn',
+ title: 'Reversion Had No Effect',
+ description:
+ 'Reverting to an identical older version doesn’t produce a new version',
+ });
+ } else {
+ const job = this.version.get('job');
+ this.router.transitionTo('jobs.job.index', job.get('idWithNamespace'));
+ }
+ } catch (error) {
+ this.args.handleError({
+ level: 'danger',
+ title: 'Could Not Revert',
+ description: messageForError(error, 'revert'),
+ });
+ }
+ });
+
+ performRevert = () => {
+ this.revertTo.perform();
+ };
+
+ cloneAsNewVersion = async () => {
+ try {
+ this.router.transitionTo(
+ 'jobs.job.definition',
+ this.version.get('job.idWithNamespace'),
+ {
+ queryParams: {
+ isEditing: true,
+ version: this.version.number,
+ },
+ },
+ );
+ } catch {
+ this.args.handleError({
+ level: 'danger',
+ title: 'Could Not Edit from Version',
+ });
+ }
+ };
+
+ cloneAsNewJob = async () => {
+ const job = await this.version.get('job');
+ try {
+ const specification = await job.fetchRawSpecification(
+ this.version.number,
+ );
+ this.router.transitionTo('jobs.run', {
+ queryParams: {
+ sourceString: specification.Source,
+ },
+ });
+ return;
+ } catch {
+ try {
+ const definition = await job.fetchRawDefinition(this.version.number);
+ this.router.transitionTo('jobs.run', {
+ queryParams: {
+ sourceString: JSON.stringify(definition, null, 2),
+ },
+ });
+ } catch (definitionError) {
+ this.args.handleError({
+ level: 'danger',
+ title: 'Could Not Clone as New Job',
+ description: messageForError(definitionError),
+ });
+ }
+ }
+ };
+
+ handleKeydown = (event) => {
+ if (event.key === 'Escape') {
+ this.cancelEditTag();
+ }
+ };
+
+ updateEditableTagName = ({ target: { value } }) => {
+ this.editableTag.name = value;
+ };
+
+ updateEditableTagDescription = ({ target: { value } }) => {
+ this.editableTag.description = value;
+ };
+
+ toggleEditTag = () => {
+ if (!this.isEditing) {
+ this.initializeEditableTag();
+ }
+
+ this.isEditing = !this.isEditing;
+ };
+
+ saveTag = async (event) => {
+ event.preventDefault();
+ try {
+ if (!this.editableTag.name) {
+ this.notifications.add({
+ title: 'Error Tagging Job Version',
+ message: 'Tag name is required',
+ color: 'critical',
+ });
+ return;
+ }
+
+ const savedTag = await this.editableTag.save();
+ const effectiveTag = savedTag || this.editableTag;
+ const tagData =
+ typeof effectiveTag.toJSON === 'function'
+ ? effectiveTag.toJSON()
+ : {
+ name: this.editableTag.name,
+ description: this.editableTag.description,
+ versionNumber: this.editableTag.versionNumber,
+ };
+
+ this.version.versionTag = effectiveTag;
+ if (typeof this.version.versionTag?.setProperties === 'function') {
+ this.version.versionTag.setProperties({
+ ...tagData,
+ });
+ }
+
+ this.initializeEditableTag();
+ this.isEditing = false;
+
+ this.notifications.add({
+ title: 'Job Version Tagged',
+ color: 'success',
+ });
+ } catch (error) {
+ this.notifications.add({
+ title: 'Error Tagging Job Version',
+ message: messageForError(error),
+ color: 'critical',
+ });
+ }
+ };
+
+ cancelEditTag = () => {
+ this.isEditing = false;
+ this.initializeEditableTag();
+ };
+
+ deleteTag = async () => {
+ try {
+ await this.store
+ .adapterFor('version-tag')
+ .deleteTag(
+ this.editableTag.jobNamespace,
+ this.editableTag.jobName,
+ this.editableTag.name,
+ );
+ this.notifications.add({
+ title: 'Job Version Un-Tagged',
+ color: 'success',
+ });
+ this.version.versionTag = null;
+ this.initializeEditableTag();
+ this.isEditing = false;
+ } catch (error) {
+ this.notifications.add({
+ title: 'Error Un-Tagging Job Version',
+ message: messageForError(error),
+ color: 'critical',
+ });
+ }
+ };
+
+
+
+
+
+
+ Version #{{this.version.number}}
+
+ {{#if this.version.job.hasVersionStability}}
+
+ Stable
+ {{this.version.stable}}
+
+ {{else}}
+
+ {{/if}}
+
+ Submitted
+ {{formatTs
+ this.version.submitTime
+ }}
+
+
+ {{#if this.diff}}
+
+ {{else}}
+ No Changes
+ {{/if}}
+
+
+ {{#if this.isOpen}}
+
+
+
+ {{/if}}
+
+
+
+
+}
+
+const flatten = (accumulator, array) => accumulator.concat(array);
+const countChanges = (total, field) =>
+ changeTypes.includes(field.Type) ? total + 1 : total;
+
+function fieldChanges(diff) {
+ return (
+ (diff.Fields || []).reduce(countChanges, 0) +
+ (diff.Objects || []).reduce(arrayOfFieldChanges, 0)
+ );
+}
+
+function arrayOfFieldChanges(count, diff) {
+ if (!diff) {
+ return count;
+ }
+
+ return count + fieldChanges(diff);
+}
diff --git a/ui/app/components/job-version.hbs b/ui/app/components/job-version.hbs
deleted file mode 100644
index 1ac989f9aa5..00000000000
--- a/ui/app/components/job-version.hbs
+++ /dev/null
@@ -1,220 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{did-update-helper this.versionsDidUpdate this.diff}}
-
-
-
- Version #{{this.version.number}}
-
- {{#if this.version.job.hasVersionStability}}
-
- Stable
- {{this.version.stable}}
-
- {{else}}
-
- {{/if}}
-
- Submitted
- {{format-ts
- this.version.submitTime
- }}
-
-
- {{#if this.diff}}
-
- {{else}}
- No Changes
- {{/if}}
-
-
- {{#if this.isOpen}}
-
-
-
- {{/if}}
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/job-version.js b/ui/app/components/job-version.js
deleted file mode 100644
index 46fbb678fca..00000000000
--- a/ui/app/components/job-version.js
+++ /dev/null
@@ -1,289 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action, computed } from '@ember/object';
-import { alias } from '@ember/object/computed';
-import { service } from '@ember/service';
-import { tracked } from '@glimmer/tracking';
-import { task } from 'ember-concurrency';
-import messageForError from 'nomad-ui/utils/message-from-adapter-error';
-
-const changeTypes = ['Added', 'Deleted', 'Edited'];
-
-export default class JobVersion extends Component {
- @service store;
- @service notifications;
- @service router;
-
- @alias('args.version') version;
- @alias('args.diff') diff;
- @tracked isOpen = false;
- @tracked isEditing = false;
- @tracked editableTag;
-
- // Passes through to the job-diff component
- verbose = true;
-
- constructor() {
- super(...arguments);
- this.isOpen = Boolean(this.args.diffsExpanded && this.diff);
- }
-
- @action
- versionsDidUpdate() {
- if (this.args.diffsExpanded && this.diff) {
- this.isOpen = true;
- }
- }
-
- initializeEditableTag() {
- const job = this.version.get('job');
- const namespaceId =
- this.version.get('job.namespaceId') ||
- job.belongsTo('namespace').id() ||
- 'default';
- const jobName = this.version.get('job.plainId');
-
- if (this.version.versionTag) {
- this.editableTag = this.store.createRecord('versionTag', {
- name: this.version.versionTag.name,
- description: this.version.versionTag.description,
- });
- } else {
- this.editableTag = this.store.createRecord('versionTag');
- }
- this.editableTag.versionNumber = this.version.number;
- this.editableTag.jobNamespace = namespaceId;
- this.editableTag.jobName = jobName;
- }
-
- @computed('diff')
- get changeCount() {
- const diff = this.diff;
- const taskGroups = diff.TaskGroups || [];
-
- if (!diff) {
- return 0;
- }
-
- return (
- fieldChanges(diff) +
- taskGroups.reduce(arrayOfFieldChanges, 0) +
- (taskGroups.mapBy('Tasks') || [])
- .reduce(flatten, [])
- .reduce(arrayOfFieldChanges, 0)
- );
- }
-
- @computed('version.{number,job.version}')
- get isCurrent() {
- return this.version.number === this.version.get('job.version');
- }
-
- @action
- toggleDiff() {
- this.isOpen = !this.isOpen;
- }
-
- /**
- * @type {'idle' | 'confirming'}
- */
- @tracked cloneButtonStatus = 'idle';
-
- @task(function* () {
- try {
- const versionBeforeReversion = this.version.get('job.version');
- yield this.version.revertTo();
- yield this.version.get('job').reload();
-
- const versionAfterReversion = this.version.get('job.version');
- if (versionBeforeReversion === versionAfterReversion) {
- this.args.handleError({
- level: 'warn',
- title: 'Reversion Had No Effect',
- description:
- 'Reverting to an identical older version doesn’t produce a new version',
- });
- } else {
- const job = this.version.get('job');
- this.router.transitionTo('jobs.job.index', job.get('idWithNamespace'));
- }
- } catch (e) {
- this.args.handleError({
- level: 'danger',
- title: 'Could Not Revert',
- description: messageForError(e, 'revert'),
- });
- }
- })
- revertTo;
-
- @action async cloneAsNewVersion() {
- try {
- this.router.transitionTo(
- 'jobs.job.definition',
- this.version.get('job.idWithNamespace'),
- {
- queryParams: {
- isEditing: true,
- version: this.version.number,
- },
- },
- );
- } catch {
- this.args.handleError({
- level: 'danger',
- title: 'Could Not Edit from Version',
- });
- }
- }
-
- @action async cloneAsNewJob() {
- const job = await this.version.get('job');
- try {
- const specification = await job.fetchRawSpecification(
- this.version.number,
- );
- this.router.transitionTo('jobs.run', {
- queryParams: {
- sourceString: specification.Source,
- },
- });
- return;
- } catch {
- try {
- // If submission info is not available, try to fetch the raw definition
- const definition = await job.fetchRawDefinition(this.version.number);
- this.router.transitionTo('jobs.run', {
- queryParams: {
- sourceString: JSON.stringify(definition, null, 2),
- },
- });
- } catch (defError) {
- // Both methods failed, show error
- this.args.handleError({
- level: 'danger',
- title: 'Could Not Clone as New Job',
- description: messageForError(defError),
- });
- }
- }
- }
-
- @action
- handleKeydown(event) {
- if (event.key === 'Escape') {
- this.cancelEditTag();
- }
- }
-
- @action
- toggleEditTag() {
- if (!this.isEditing) {
- this.initializeEditableTag();
- }
-
- this.isEditing = !this.isEditing;
- }
-
- @action
- async saveTag(event) {
- event.preventDefault();
- try {
- if (!this.editableTag.name) {
- this.notifications.add({
- title: 'Error Tagging Job Version',
- message: 'Tag name is required',
- color: 'critical',
- });
- return;
- }
- const savedTag = await this.editableTag.save();
- const effectiveTag = savedTag || this.editableTag;
- const tagData =
- typeof effectiveTag.toJSON === 'function'
- ? effectiveTag.toJSON()
- : {
- name: this.editableTag.name,
- description: this.editableTag.description,
- versionNumber: this.editableTag.versionNumber,
- };
-
- this.version.versionTag = effectiveTag;
- if (typeof this.version.versionTag?.setProperties === 'function') {
- this.version.versionTag.setProperties({
- ...tagData,
- });
- }
-
- this.initializeEditableTag();
- this.isEditing = false;
-
- this.notifications.add({
- title: 'Job Version Tagged',
- color: 'success',
- });
- } catch (error) {
- console.log('error tagging job version', error);
- this.notifications.add({
- title: 'Error Tagging Job Version',
- message: messageForError(error),
- color: 'critical',
- });
- }
- }
-
- @action
- cancelEditTag() {
- this.isEditing = false;
- this.initializeEditableTag();
- }
-
- @action
- async deleteTag() {
- try {
- await this.store
- .adapterFor('version-tag')
- .deleteTag(
- this.editableTag.jobNamespace,
- this.editableTag.jobName,
- this.editableTag.name,
- );
- this.notifications.add({
- title: 'Job Version Un-Tagged',
- color: 'success',
- });
- this.version.versionTag = null;
- this.initializeEditableTag();
- this.isEditing = false;
- } catch (error) {
- this.notifications.add({
- title: 'Error Un-Tagging Job Version',
- message: messageForError(error),
- color: 'critical',
- });
- }
- }
-}
-
-const flatten = (accumulator, array) => accumulator.concat(array);
-const countChanges = (total, field) =>
- changeTypes.includes(field.Type) ? total + 1 : total;
-
-function fieldChanges(diff) {
- return (
- (diff.Fields || []).reduce(countChanges, 0) +
- (diff.Objects || []).reduce(arrayOfFieldChanges, 0)
- );
-}
-
-function arrayOfFieldChanges(count, diff) {
- if (!diff) {
- return count;
- }
-
- return count + fieldChanges(diff);
-}
diff --git a/ui/app/components/job-versions-stream.gjs b/ui/app/components/job-versions-stream.gjs
new file mode 100644
index 00000000000..7cc06157a9e
--- /dev/null
+++ b/ui/app/components/job-versions-stream.gjs
@@ -0,0 +1,90 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import moment from 'moment';
+import formatDate from 'nomad-ui/helpers/format-date';
+import JobVersion from 'nomad-ui/components/job-version';
+
+export default class JobVersionsStream extends Component {
+ get versions() {
+ return normalizeCollection(this.args.versions);
+ }
+
+ get diffs() {
+ return normalizeCollection(this.args.diffs);
+ }
+
+ get verbose() {
+ return this.args.verbose ?? true;
+ }
+
+ get annotatedVersions() {
+ const versions = [...this.versions].sort((a, b) => {
+ return (b.submitTime ?? 0) - (a.submitTime ?? 0);
+ });
+
+ return versions.map((version, index) => {
+ const meta = {};
+
+ if (index === 0) {
+ meta.showDate = true;
+ } else {
+ const previousVersion = versions[index - 1];
+ const previousStart = moment(previousVersion.get('submitTime')).startOf(
+ 'day',
+ );
+ const currentStart = moment(version.get('submitTime')).startOf('day');
+ if (previousStart.diff(currentStart, 'days') > 0) {
+ meta.showDate = true;
+ }
+ }
+
+ const diff = this.diffs[index];
+ return { version, meta, diff };
+ });
+ }
+
+
+
+ {{#each this.annotatedVersions key="version.id" as |record|}}
+ {{#if record.meta.showDate}}
+ -
+ {{formatDate record.version.submitTime}}
+
+ {{/if}}
+ -
+
+
+ {{/each}}
+
+
+}
+
+function normalizeCollection(value) {
+ if (!value) {
+ return [];
+ }
+
+ if (Array.isArray(value)) {
+ return [...value];
+ }
+
+ if (typeof value.toArray === 'function') {
+ return value.toArray();
+ }
+
+ if (typeof value[Symbol.iterator] === 'function') {
+ return Array.from(value);
+ }
+
+ return [];
+}
diff --git a/ui/app/components/job-versions-stream.hbs b/ui/app/components/job-versions-stream.hbs
deleted file mode 100644
index d781b14af79..00000000000
--- a/ui/app/components/job-versions-stream.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#each this.annotatedVersions key="version.id" as |record|}}
- {{#if record.meta.showDate}}
-
- {{format-date record.version.submitTime}}
-
- {{/if}}
-
-
-
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/job-versions-stream.js b/ui/app/components/job-versions-stream.js
deleted file mode 100644
index 1477f7e9ab3..00000000000
--- a/ui/app/components/job-versions-stream.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import moment from 'moment';
-import { classNames, tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('ol')
-@classNames('timeline')
-export default class JobVersionsStream extends Component {
- @overridable(() => []) versions;
-
- // Passes through to the job-diff component
- verbose = true;
-
- diffs = [];
-
- @computed('versions.[]', 'diffs.[]')
- get annotatedVersions() {
- const versions = this.versions.sortBy('submitTime').reverse();
- return versions.map((version, index) => {
- const meta = {};
-
- if (index === 0) {
- meta.showDate = true;
- } else {
- const previousVersion = versions[index - 1];
- const previousStart = moment(previousVersion.get('submitTime')).startOf(
- 'day',
- );
- const currentStart = moment(version.get('submitTime')).startOf('day');
- if (previousStart.diff(currentStart, 'days') > 0) {
- meta.showDate = true;
- }
- }
-
- const diff = this.diffs[index];
- return { version, meta, diff };
- });
- }
-}
diff --git a/ui/app/components/json-viewer.gjs b/ui/app/components/json-viewer.gjs
new file mode 100644
index 00000000000..c572a49e79c
--- /dev/null
+++ b/ui/app/components/json-viewer.gjs
@@ -0,0 +1,30 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import stringifyObject from 'nomad-ui/helpers/stringify-object';
+
+export default class JsonViewer extends Component {
+ get rootClass() {
+ return this.args.fluidHeight
+ ? 'json-viewer has-fluid-height'
+ : 'json-viewer';
+ }
+
+
+
+
+
+
+}
diff --git a/ui/app/components/json-viewer.hbs b/ui/app/components/json-viewer.hbs
deleted file mode 100644
index ef85e263cb2..00000000000
--- a/ui/app/components/json-viewer.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/json-viewer.js b/ui/app/components/json-viewer.js
deleted file mode 100644
index 6401040b733..00000000000
--- a/ui/app/components/json-viewer.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { classNames, classNameBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('json-viewer')
-@classNameBindings('fluidHeight:has-fluid-height')
-export default class JsonViewer extends Component {}
diff --git a/ui/app/components/keyboard-shortcuts-modal.gjs b/ui/app/components/keyboard-shortcuts-modal.gjs
new file mode 100644
index 00000000000..60f060b0423
--- /dev/null
+++ b/ui/app/components/keyboard-shortcuts-modal.gjs
@@ -0,0 +1,211 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array, fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+import onClickOutside from 'ember-click-outside/modifiers/on-click-outside';
+import { and, not, or } from 'ember-truth-helpers';
+import {
+ HdsFormToggleField,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import cleanKeycommand from 'nomad-ui/helpers/clean-keycommand';
+import keyboardCommands from 'nomad-ui/helpers/keyboard-commands';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import Tether from 'tether';
+
+export default class KeyboardShortcutsModal extends Component {
+ @service keyboard;
+ @service config;
+
+ blurHandler = () => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ const keyboard = this.keyboard;
+ if (!keyboard || keyboard.isDestroying || keyboard.isDestroyed) {
+ return;
+ }
+
+ keyboard.displayHints = false;
+ };
+
+ constructor() {
+ super(...arguments);
+ window.addEventListener('blur', this.blurHandler);
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ window.removeEventListener('blur', this.blurHandler);
+ }
+
+ escapeCommand = {
+ label: 'Hide Keyboard Shortcuts',
+ pattern: ['Escape'],
+ action: () => {
+ this.keyboard.shortcutsVisible = false;
+ },
+ };
+
+ get commands() {
+ return this.keyboard.keyCommands.reduce((memo, command) => {
+ if (
+ command.label &&
+ command.action &&
+ !memo.find((existing) => existing.label === command.label)
+ ) {
+ memo.push(command);
+ }
+ return memo;
+ }, []);
+ }
+
+ get hints() {
+ if (!this.keyboard.displayHints) {
+ return [];
+ }
+
+ const elementBoundKeyCommands = this.keyboard.keyCommands.filter(
+ (command) => command.element,
+ );
+
+ return elementBoundKeyCommands.map((command) => {
+ const pair = this.keyboard.keyCommands.find(
+ (candidate) =>
+ JSON.stringify(candidate.defaultPattern) ===
+ JSON.stringify(command.pattern),
+ );
+
+ if (!pair) {
+ return command;
+ }
+
+ return {
+ ...command,
+ pattern: pair.pattern,
+ };
+ });
+ }
+
+ tetherToElement = (element, hint, self) => {
+ if (!this.config.isTest) {
+ hint.binder = new Tether({
+ element: self,
+ target: element,
+ attachment: 'top left',
+ targetAttachment: 'top left',
+ targetModifier: 'visible',
+ });
+ }
+ };
+
+ untetherFromElement = (hint) => {
+ if (!this.config.isTest) {
+ hint.binder.destroy();
+ }
+ };
+
+ closeShortcuts = () => {
+ this.keyboard.shortcutsVisible = false;
+ };
+
+ toggleListener = () => {
+ this.keyboard.enabled = !this.keyboard.enabled;
+ };
+
+
+ {{#if this.keyboard.shortcutsVisible}}
+ {{keyboardCommands (array this.escapeCommand)}}
+
+
+
+ Keyboard Shortcuts
+ Click a key pattern to re-bind it to a shortcut of your choosing.
+
+
+ {{#each this.commands as |command|}}
+ -
+ {{command.label}}
+
+ {{#if command.recording}}
+ Recording; ESC to cancel.
+ {{else}}
+ {{#if command.custom}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
+ {{/each}}
+
+
+
+ {{/if}}
+
+ {{#if (and this.keyboard.enabled this.keyboard.displayHints)}}
+ {{#each this.hints as |hint|}}
+
+ {{/each}}
+ {{/if}}
+
+}
diff --git a/ui/app/components/keyboard-shortcuts-modal.hbs b/ui/app/components/keyboard-shortcuts-modal.hbs
deleted file mode 100644
index e38bbb1452f..00000000000
--- a/ui/app/components/keyboard-shortcuts-modal.hbs
+++ /dev/null
@@ -1,86 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.keyboard.shortcutsVisible}}
- {{keyboard-commands (array this.escapeCommand)}}
-
-
-
- Keyboard Shortcuts
- Click a key pattern to re-bind it to a shortcut of your choosing.
-
-
- {{#each this.commands as |command|}}
- -
- {{command.label}}
-
- {{#if command.recording}}
- Recording; ESC to cancel.
- {{else}}
- {{#if command.custom}}
-
- {{/if}}
- {{/if}}
-
-
-
-
- {{/each}}
-
-
-
-{{/if}}
-
-{{#if (and this.keyboard.enabled this.keyboard.displayHints)}}
- {{#each this.hints as |hint|}}
-
- {{/each}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/keyboard-shortcuts-modal.js b/ui/app/components/keyboard-shortcuts-modal.js
deleted file mode 100644
index 6a83b9da72b..00000000000
--- a/ui/app/components/keyboard-shortcuts-modal.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { set } from '@ember/object';
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { computed } from '@ember/object';
-import { action } from '@ember/object';
-import Tether from 'tether';
-
-export default class KeyboardShortcutsModalComponent extends Component {
- @service keyboard;
- @service config;
-
- blurHandler() {
- set(this, 'keyboard.displayHints', false);
- }
-
- constructor() {
- super(...arguments);
- set(this, '_blurHandler', this.blurHandler.bind(this));
- window.addEventListener('blur', this._blurHandler);
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- window.removeEventListener('blur', this._blurHandler);
- }
-
- escapeCommand = {
- label: 'Hide Keyboard Shortcuts',
- pattern: ['Escape'],
- action: () => {
- this.keyboard.shortcutsVisible = false;
- },
- };
-
- /**
- * commands: filter keyCommands to those that have an action and a label,
- * to distinguish between those that are just visual hints of existing commands
- */
- @computed('keyboard.keyCommands.[]')
- get commands() {
- return this.keyboard.keyCommands.reduce((memo, c) => {
- if (c.label && c.action && !memo.find((m) => m.label === c.label)) {
- memo.push(c);
- }
- return memo;
- }, []);
- }
-
- /**
- * hints: filter keyCommands to those that have an element property,
- * and then compute a position on screen to place the hint.
- */
- @computed('keyboard.{keyCommands.length,displayHints}')
- get hints() {
- if (this.keyboard.displayHints) {
- let elementBoundKeyCommands = this.keyboard.keyCommands.filter(
- (c) => c.element,
- );
- // Some element-bound key commands have pairs can be re-bound by the user.
- // For each of them, check to see if any other key command has its pattern
- // as a defaultPattern. If so, use that key command's pattern instead.
- let elementBoundKeyCommandsWithRebinds = [];
- elementBoundKeyCommands.forEach((c) => {
- let pair = this.keyboard.keyCommands.find(
- (kc) =>
- JSON.stringify(kc.defaultPattern) === JSON.stringify(c.pattern),
- );
- if (pair) {
- elementBoundKeyCommandsWithRebinds.push({
- ...c,
- pattern: pair.pattern,
- });
- } else {
- elementBoundKeyCommandsWithRebinds.push(c);
- }
- });
- return elementBoundKeyCommandsWithRebinds;
- } else {
- return [];
- }
- }
-
- @action
- tetherToElement(element, hint, self) {
- if (!this.config.isTest) {
- let binder = new Tether({
- element: self,
- target: element,
- attachment: 'top left',
- targetAttachment: 'top left',
- targetModifier: 'visible',
- });
- hint.binder = binder;
- }
- }
-
- @action
- untetherFromElement(hint) {
- if (!this.config.isTest) {
- hint.binder.destroy();
- }
- }
-
- @action
- closeShortcuts() {
- this.keyboard.shortcutsVisible = false;
- }
-
- @action toggleListener() {
- this.keyboard.enabled = !this.keyboard.enabled;
- }
-}
diff --git a/ui/app/components/lifecycle-chart-row.gjs b/ui/app/components/lifecycle-chart-row.gjs
new file mode 100644
index 00000000000..86db7405e18
--- /dev/null
+++ b/ui/app/components/lifecycle-chart-row.gjs
@@ -0,0 +1,140 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { capitalize } from '@ember/string';
+import { array } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { HdsAlert } from '@hashicorp/design-system-components/components';
+
+const safeGet = (obj, key) => (obj ? get(obj, key) : undefined);
+
+export default class LifecycleChartRow extends Component {
+ get taskColor() {
+ const state = safeGet(this.args.taskState, 'state');
+ const failed = safeGet(this.args.taskState, 'failed');
+ let color = 'neutral';
+ if (state === 'running') {
+ color = 'success';
+ }
+ if (state === 'pending') {
+ color = 'neutral';
+ }
+ if (state === 'dead') {
+ if (failed) {
+ color = 'critical';
+ } else {
+ color = 'neutral';
+ }
+ }
+ return color;
+ }
+
+ get taskIcon() {
+ const state = safeGet(this.args.taskState, 'state');
+ const failed = safeGet(this.args.taskState, 'failed');
+ const startedAt = safeGet(this.args.taskState, 'startedAt');
+ let icon;
+ if (state === 'running') {
+ icon = 'running';
+ }
+ if (state === 'pending') {
+ icon = 'test';
+ }
+ if (state === 'dead') {
+ if (failed) {
+ icon = 'alert-circle';
+ } else {
+ if (startedAt) {
+ icon = 'check-circle';
+ } else {
+ icon = 'minus-circle';
+ }
+ }
+ }
+
+ return icon;
+ }
+
+ get activeClass() {
+ if (
+ this.args.taskState &&
+ get(this.args.taskState, 'state') === 'running'
+ ) {
+ return 'is-active';
+ }
+
+ return undefined;
+ }
+
+ get finishedClass() {
+ if (this.args.taskState && get(this.args.taskState, 'state') === 'dead') {
+ return 'is-finished';
+ }
+
+ return undefined;
+ }
+
+ get pendingClass() {
+ return safeGet(this.args.taskState, 'state') === 'pending' ? 'pending' : '';
+ }
+
+ get lifecycleLabel() {
+ if (!this.args.task) {
+ return '';
+ }
+
+ const name = get(this.args.task, 'lifecycleName');
+
+ if (name.includes('sidecar')) {
+ return 'sidecar';
+ } else if (name.includes('ephemeral')) {
+ return name.substr(0, name.indexOf('-'));
+ } else {
+ return name;
+ }
+ }
+
+ get lifecycleTitle() {
+ return capitalize(this.lifecycleLabel);
+ }
+
+
+
+
+
+ {{#if @taskState}}
+
+ {{@task.name}}
+
+ {{else}}
+ {{@task.name}}
+ {{/if}}
+
+
+ {{this.lifecycleTitle}}
+ Task
+
+
+
+
+}
diff --git a/ui/app/components/lifecycle-chart-row.hbs b/ui/app/components/lifecycle-chart-row.hbs
deleted file mode 100644
index 857201738d9..00000000000
--- a/ui/app/components/lifecycle-chart-row.hbs
+++ /dev/null
@@ -1,39 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{#if this.taskState}}
-
- {{this.task.name}}
-
- {{else}}
- {{this.task.name}}
- {{/if}}
-
-
- {{capitalize
- this.lifecycleLabel
- }}
- Task
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/lifecycle-chart-row.js b/ui/app/components/lifecycle-chart-row.js
deleted file mode 100644
index a1534ac7512..00000000000
--- a/ui/app/components/lifecycle-chart-row.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class LifecycleChartRow extends Component {
- @computed('taskState.{failed,state}')
- get taskColor() {
- let color = 'neutral';
- if (this.taskState?.state === 'running') {
- color = 'success';
- }
- if (this.taskState?.state === 'pending') {
- color = 'neutral';
- }
- if (this.taskState?.state === 'dead') {
- if (this.taskState?.failed) {
- color = 'critical';
- } else {
- color = 'neutral';
- }
- }
- return color;
- }
-
- get taskIcon() {
- let icon;
- if (this.taskState?.state === 'running') {
- icon = 'running';
- }
- if (this.taskState?.state === 'pending') {
- icon = 'test';
- }
- if (this.taskState?.state === 'dead') {
- if (this.taskState?.failed) {
- icon = 'alert-circle';
- } else {
- if (this.taskState?.startedAt) {
- icon = 'check-circle';
- } else {
- icon = 'minus-circle';
- }
- }
- }
-
- return icon;
- }
-
- @computed('taskState.state')
- get activeClass() {
- if (this.taskState && this.taskState.state === 'running') {
- return 'is-active';
- }
-
- return undefined;
- }
-
- @computed('taskState.state')
- get finishedClass() {
- if (this.taskState && this.taskState.state === 'dead') {
- return 'is-finished';
- }
-
- return undefined;
- }
-
- @computed('task.lifecycleName')
- get lifecycleLabel() {
- if (!this.task) {
- return '';
- }
-
- const name = this.task.lifecycleName;
-
- if (name.includes('sidecar')) {
- return 'sidecar';
- } else if (name.includes('ephemeral')) {
- return name.substr(0, name.indexOf('-'));
- } else {
- return name;
- }
- }
-}
diff --git a/ui/app/components/lifecycle-chart.gjs b/ui/app/components/lifecycle-chart.gjs
new file mode 100644
index 00000000000..ce543573727
--- /dev/null
+++ b/ui/app/components/lifecycle-chart.gjs
@@ -0,0 +1,156 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { gt } from 'ember-truth-helpers';
+import LifecycleChartRow from 'nomad-ui/components/lifecycle-chart-row';
+
+export default class LifecycleChart extends Component {
+ get lifecyclePhases() {
+ const taskStates = normalizeCollection(this.args.taskStates);
+ const tasks = normalizeCollection(this.args.tasks);
+ const tasksOrStates = taskStates.length ? taskStates : tasks;
+ const lifecycles = {
+ 'prestart-ephemerals': [],
+ 'prestart-sidecars': [],
+ 'poststart-ephemerals': [],
+ 'poststart-sidecars': [],
+ poststops: [],
+ mains: [],
+ };
+
+ tasksOrStates.forEach((taskOrState) => {
+ const task = get(taskOrState, 'task') || taskOrState;
+
+ const lifecycleName = get(task, 'lifecycleName');
+ if (lifecycleName) {
+ lifecycles[`${lifecycleName}s`].push(taskOrState);
+ }
+ });
+
+ const phases = [];
+ const stateActiveIterator = (state) => get(state, 'state') === 'running';
+
+ if (lifecycles.mains.length < tasksOrStates.length) {
+ phases.push({
+ name: 'Prestart',
+ cssClass: 'prestart',
+ isActive: lifecycles['prestart-ephemerals'].some(stateActiveIterator),
+ });
+
+ phases.push({
+ name: 'Main',
+ cssClass: 'main',
+ isActive:
+ lifecycles.mains.some(stateActiveIterator) ||
+ lifecycles['poststart-ephemerals'].some(stateActiveIterator),
+ });
+
+ phases.push({
+ name: 'Poststart',
+ cssClass: 'poststart',
+ });
+
+ phases.push({
+ name: 'Poststop',
+ cssClass: 'poststop',
+ isActive: lifecycles.poststops.some(stateActiveIterator),
+ });
+ }
+
+ return phases;
+ }
+
+ get sortedLifecycleTaskStates() {
+ return normalizeCollection(this.args.taskStates).sort((a, b) => {
+ return getTaskSortPrefix(a.task).localeCompare(getTaskSortPrefix(b.task));
+ });
+ }
+
+ get sortedLifecycleTasks() {
+ return normalizeCollection(this.args.tasks).sort((a, b) => {
+ return getTaskSortPrefix(a).localeCompare(getTaskSortPrefix(b));
+ });
+ }
+
+
+ {{#if (gt this.lifecyclePhases.length 1)}}
+
+
+ Task Lifecycle
+ {{if @taskStates "Status" "Configuration"}}
+
+
+
+
+ {{#each this.lifecyclePhases as |phase|}}
+
+ {{phase.name}}
+
+ {{/each}}
+
+
+
+
+
+
+
+
+
+ {{#if @tasks}}
+ {{#each this.sortedLifecycleTasks as |task|}}
+
+ {{/each}}
+ {{else}}
+ {{#each this.sortedLifecycleTaskStates as |state|}}
+
+ {{/each}}
+ {{/if}}
+
+
+
+
+ {{/if}}
+
+}
+
+const lifecycleNameSortPrefix = {
+ 'prestart-ephemeral': 0,
+ 'prestart-sidecar': 1,
+ main: 2,
+ 'poststart-sidecar': 3,
+ 'poststart-ephemeral': 4,
+ poststop: 5,
+};
+
+function getTaskSortPrefix(task) {
+ return `${lifecycleNameSortPrefix[task.lifecycleName]}-${task.name}`;
+}
+
+function normalizeCollection(value) {
+ if (!value) {
+ return [];
+ }
+
+ if (Array.isArray(value)) {
+ return [...value];
+ }
+
+ if (typeof value.toArray === 'function') {
+ return value.toArray();
+ }
+
+ if (typeof value[Symbol.iterator] === 'function') {
+ return Array.from(value);
+ }
+
+ return [];
+}
diff --git a/ui/app/components/lifecycle-chart.hbs b/ui/app/components/lifecycle-chart.hbs
deleted file mode 100644
index 4eba74cfb5b..00000000000
--- a/ui/app/components/lifecycle-chart.hbs
+++ /dev/null
@@ -1,47 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if (gt this.lifecyclePhases.length 1)}}
-
-
- Task Lifecycle
- {{if this.taskStates "Status" "Configuration"}}
-
-
-
-
- {{#each this.lifecyclePhases as |phase|}}
-
- {{phase.name}}
-
- {{/each}}
-
-
-
-
-
-
-
-
-
- {{#if this.tasks}}
- {{#each this.sortedLifecycleTasks as |task|}}
-
- {{/each}}
- {{else}}
- {{#each this.sortedLifecycleTaskStates as |state|}}
-
- {{/each}}
- {{/if}}
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/lifecycle-chart.js b/ui/app/components/lifecycle-chart.js
deleted file mode 100644
index 5c8407ea545..00000000000
--- a/ui/app/components/lifecycle-chart.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { sort } from '@ember/object/computed';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class LifecycleChart extends Component {
- tasks = null;
- taskStates = null;
-
- @computed('tasks.@each.lifecycle', 'taskStates.@each.state')
- get lifecyclePhases() {
- const tasksOrStates = this.taskStates || this.tasks;
- const lifecycles = {
- 'prestart-ephemerals': [],
- 'prestart-sidecars': [],
- 'poststart-ephemerals': [],
- 'poststart-sidecars': [],
- poststops: [],
- mains: [],
- };
-
- tasksOrStates.forEach((taskOrState) => {
- const task = taskOrState.task || taskOrState;
-
- if (task.lifecycleName) {
- lifecycles[`${task.lifecycleName}s`].push(taskOrState);
- }
- });
-
- const phases = [];
- const stateActiveIterator = (state) => state.state === 'running';
-
- if (lifecycles.mains.length < tasksOrStates.length) {
- phases.push({
- name: 'Prestart',
- isActive: lifecycles['prestart-ephemerals'].some(stateActiveIterator),
- });
-
- phases.push({
- name: 'Main',
- isActive:
- lifecycles.mains.some(stateActiveIterator) ||
- lifecycles['poststart-ephemerals'].some(stateActiveIterator),
- });
-
- // Poststart is rendered as a subphase of main and therefore has no independent active state
- phases.push({
- name: 'Poststart',
- });
-
- phases.push({
- name: 'Poststop',
- isActive: lifecycles.poststops.some(stateActiveIterator),
- });
- }
-
- return phases;
- }
-
- @sort('taskStates', function (a, b) {
- return getTaskSortPrefix(a.task).localeCompare(getTaskSortPrefix(b.task));
- })
- sortedLifecycleTaskStates;
-
- @sort('tasks', function (a, b) {
- return getTaskSortPrefix(a).localeCompare(getTaskSortPrefix(b));
- })
- sortedLifecycleTasks;
-}
-
-const lifecycleNameSortPrefix = {
- 'prestart-ephemeral': 0,
- 'prestart-sidecar': 1,
- main: 2,
- 'poststart-sidecar': 3,
- 'poststart-ephemeral': 4,
- poststop: 5,
-};
-
-function getTaskSortPrefix(task) {
- return `${lifecycleNameSortPrefix[task.lifecycleName]}-${task.name}`;
-}
diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.gjs
similarity index 54%
rename from ui/app/components/line-chart.js
rename to ui/app/components/line-chart.gjs
index 8b0e501b51e..8fc61cc0aeb 100644
--- a/ui/app/components/line-chart.js
+++ b/ui/app/components/line-chart.gjs
@@ -5,7 +5,8 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
+import { hash } from '@ember/helper';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
import { schedule, next } from '@ember/runloop';
import d3 from 'd3-selection';
import d3Scale from 'd3-scale';
@@ -13,26 +14,31 @@ import d3Axis from 'd3-axis';
import d3Array from 'd3-array';
import d3Format from 'd3-format';
import d3TimeFormat from 'd3-time-format';
+import Area from 'nomad-ui/components/chart-primitives/area';
+import HAnnotations from 'nomad-ui/components/chart-primitives/h-annotations';
+import Tooltip from 'nomad-ui/components/chart-primitives/tooltip';
+import VAnnotations from 'nomad-ui/components/chart-primitives/v-annotations';
+import windowResize from 'nomad-ui/modifiers/window-resize';
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
import uniquely from 'nomad-ui/utils/properties/uniquely';
-// Returns a new array with the specified number of points linearly
-// distributed across the bounds
const lerp = ([low, high], numPoints) => {
const step = (high - low) / (numPoints - 1);
- const arr = [];
- for (var i = 0; i < numPoints; i++) {
- arr.push(low + step * i);
+ const values = [];
+ for (let index = 0; index < numPoints; index++) {
+ values.push(low + step * index);
}
- return arr;
+ return values;
};
-// Round a number or an array of numbers
-const nice = (val) => (val instanceof Array ? val.map(nice) : Math.round(val));
+const nice = (value) =>
+ value instanceof Array ? value.map(nice) : Math.round(value);
const defaultXScale = (data, yAxisOffset, xProp, timeseries) => {
const scale = timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
- const domain = data.length ? d3Array.extent(data, (d) => d[xProp]) : [0, 1];
+ const domain = data.length
+ ? d3Array.extent(data, (datum) => datum[xProp])
+ : [0, 1];
scale.rangeRound([10, yAxisOffset]).domain(domain);
@@ -40,7 +46,7 @@ const defaultXScale = (data, yAxisOffset, xProp, timeseries) => {
};
const defaultYScale = (data, xAxisOffset, yProp) => {
- let max = d3Array.max(data, (d) => d[yProp]) || 1;
+ let max = d3Array.max(data, (datum) => datum[yProp]) || 1;
if (max > 1) {
max = nice(max);
}
@@ -49,22 +55,6 @@ const defaultYScale = (data, xAxisOffset, yProp) => {
};
export default class LineChart extends Component {
- /** Args
- data = null;
- xProp = null;
- yProp = null;
- curve = 'linear';
- title = 'Line Chart';
- description = null;
- timeseries = false;
- activeAnnotation = null;
- onAnnotationClick() {}
- xFormat;
- yFormat;
- xScale;
- yScale;
- */
-
@tracked width = 0;
@tracked height = 0;
@tracked isActive = false;
@@ -77,35 +67,46 @@ export default class LineChart extends Component {
@uniquely('title') titleId;
@uniquely('desc') descriptionId;
+ latestMouseX = 0;
+
get xProp() {
return this.args.xProp || 'time';
}
+
get yProp() {
return this.args.yProp || 'value';
}
+
get data() {
if (!this.args.data) return [];
if (this.args.dataProp) {
- return this.args.data.mapBy(this.args.dataProp).flat();
+ return this.args.data.map((item) => item?.[this.args.dataProp]).flat();
}
return this.args.data;
}
+
get curve() {
return this.args.curve || 'linear';
}
- @action
- xFormat(timeseries) {
+ xFormat = (timeseries) => {
if (this.args.xFormat) return this.args.xFormat;
return timeseries
? d3TimeFormat.timeFormat('%b %d, %H:%M')
: d3Format.format(',');
- }
+ };
- @action
- yFormat() {
+ yFormat = () => {
if (this.args.yFormat) return this.args.yFormat;
return d3Format.format(',.2~r');
+ };
+
+ get title() {
+ return this.args.title || 'Line Chart';
+ }
+
+ get description() {
+ return this.args.description;
}
get activeDatumLabel() {
@@ -113,8 +114,7 @@ export default class LineChart extends Component {
if (!datum) return undefined;
- const x = datum[this.xProp];
- return this.xFormat(this.args.timeseries)(x);
+ return this.xFormat(this.args.timeseries)(datum[this.xProp]);
}
get activeDatumValue() {
@@ -122,8 +122,7 @@ export default class LineChart extends Component {
if (!datum) return undefined;
- const y = datum[this.yProp];
- return this.yFormat()(y);
+ return this.yFormat()(datum[this.yProp]);
}
@styleString
@@ -137,19 +136,33 @@ export default class LineChart extends Component {
}
get xRange() {
- const { xProp, data } = this;
- const range = d3Array.extent(data, (d) => d[xProp]);
const formatter = this.xFormat(this.args.timeseries);
-
- return range.map(formatter);
+ return d3Array
+ .extent(this.data, (datum) => datum[this.xProp])
+ .map(formatter);
}
get yRange() {
- const yProp = this.yProp;
- const range = d3Array.extent(this.data, (d) => d[yProp]);
const formatter = this.yFormat();
+ return d3Array
+ .extent(this.data, (datum) => datum[this.yProp])
+ .map(formatter);
+ }
+
+ get xRangeStart() {
+ return this.xRange[0];
+ }
+
+ get xRangeEnd() {
+ return this.xRange[this.xRange.length - 1];
+ }
- return range.map(formatter);
+ get yRangeStart() {
+ return this.yRange[0];
+ }
+
+ get yRangeEnd() {
+ return this.yRange[this.yRange.length - 1];
}
get yScale() {
@@ -158,35 +171,29 @@ export default class LineChart extends Component {
}
get xAxis() {
- const formatter = this.xFormat(this.args.timeseries);
-
return d3Axis
.axisBottom()
.scale(this.xScale)
.ticks(5)
- .tickFormat(formatter);
+ .tickFormat(this.xFormat(this.args.timeseries));
}
get yTicks() {
- const height = this.xAxisOffset;
- const tickCount = Math.ceil(height / 120) * 2 + 1;
+ const tickCount = Math.ceil(this.xAxisOffset / 120) * 2 + 1;
const domain = this.yScale.domain();
const ticks = lerp(domain, tickCount);
return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
}
get yAxis() {
- const formatter = this.yFormat();
-
return d3Axis
.axisRight()
.scale(this.yScale)
.tickValues(this.yTicks)
- .tickFormat(formatter);
+ .tickFormat(this.yFormat());
}
get yGridlines() {
- // The first gridline overlaps the x-axis, so remove it
const [, ...ticks] = this.yTicks;
return d3Axis
@@ -198,7 +205,6 @@ export default class LineChart extends Component {
}
get xAxisHeight() {
- // Avoid divide by zero errors by always having a height
if (!this.element) return 1;
const axis = this.element.querySelector('.x-axis');
@@ -206,7 +212,6 @@ export default class LineChart extends Component {
}
get yAxisWidth() {
- // Avoid divide by zero errors by always having a width
if (!this.element) return 1;
const axis = this.element.querySelector('.y-axis');
@@ -227,25 +232,23 @@ export default class LineChart extends Component {
return { left, width: right - left, top, height: bottom - top };
}
- @action
- onInsert(element) {
+ onInsert = (element) => {
this.element = element;
this.updateDimensions();
const canvas = d3.select(this.element.querySelector('.hover-target'));
const updateActiveDatum = this.updateActiveDatum.bind(this);
- const chart = this;
- canvas.on('mouseenter', function (ev) {
- const mouseX = d3.pointer(ev, this)[0];
- chart.latestMouseX = mouseX;
+ canvas.on('mouseenter', (event) => {
+ const mouseX = d3.pointer(event, canvas.node())[0];
+ this.latestMouseX = mouseX;
updateActiveDatum(mouseX);
- schedule('afterRender', chart, () => (chart.isActive = true));
+ schedule('afterRender', this, () => (this.isActive = true));
});
- canvas.on('mousemove', function (ev) {
- const mouseX = d3.pointer(ev, this)[0];
- chart.latestMouseX = mouseX;
+ canvas.on('mousemove', (event) => {
+ const mouseX = d3.pointer(event, canvas.node())[0];
+ this.latestMouseX = mouseX;
updateActiveDatum(mouseX);
});
@@ -254,10 +257,10 @@ export default class LineChart extends Component {
this.activeDatum = null;
this.activeData = [];
});
- }
+ };
updateActiveDatum(mouseX) {
- if (!this.data || !this.data.length) return;
+ if (!this.data?.length) return;
const { xScale, xProp, yScale, yProp } = this;
let { dataProp, data } = this.args;
@@ -267,33 +270,22 @@ export default class LineChart extends Component {
data = [{ data: this.data }];
}
- // Map screen coordinates to data domain
- const bisector = d3Array.bisector((d) => d[xProp]).left;
+ const bisector = d3Array.bisector((datum) => datum[xProp]).left;
const x = xScale.invert(mouseX);
- // Find the closest datum to the cursor for each series
const activeData = data
.map((series, seriesIndex) => {
const dataset = series[dataProp];
-
- // If the dataset is empty, there can't be an activeData.
- // This must be done here instead of preemptively in a filter to
- // preserve the seriesIndex value.
if (!dataset.length) return null;
const index = bisector(dataset, x, 1);
-
- // The data point on either side of the cursor
const dLeft = dataset[index - 1];
const dRight = dataset[index];
let datum;
-
- // If there is only one point, it's the activeDatum
if (dLeft && !dRight) {
datum = dLeft;
} else {
- // Pick the closer point
datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
}
@@ -307,9 +299,8 @@ export default class LineChart extends Component {
index: data.length - seriesIndex - 1,
};
})
- .compact();
+ .filter(Boolean);
- // Of the selected data, determine which is closest
const closestDatum = activeData
.slice()
.sort(
@@ -318,11 +309,10 @@ export default class LineChart extends Component {
Math.abs(b.datum.datum[xProp] - x),
)[0];
- // If any other selected data are beyond a distance threshold, drop them from the list
- // xScale is used here to measure distance in screen-space rather than data-space.
const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX);
const filteredData = activeData.filter(
- (d) => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10,
+ (entry) =>
+ Math.abs(xScale(entry.datum.datum[xProp]) - mouseX) < dist + 10,
);
this.activeData = filteredData;
@@ -333,41 +323,31 @@ export default class LineChart extends Component {
};
}
- // The renderChart method should only ever be responsible for runtime calculations
- // and appending d3 created elements to the DOM (such as axes).
- renderChart() {
- // There is nothing to do if the element hasn't been inserted yet
+ renderChart = () => {
if (!this.element) return;
- // Create the axes to get the dimensions of the resulting
- // svg elements
this.mountD3Elements();
next(() => {
- // Since each axis depends on the dimension of the other
- // axis, the axes themselves are recomputed and need to
- // be re-rendered.
this.mountD3Elements();
this.ready = true;
if (this.isActive) {
this.updateActiveDatum(this.latestMouseX);
}
});
- }
+ };
- @action
- recomputeXAxis(el) {
+ recomputeXAxis = (element) => {
if (!this.isDestroyed && !this.isDestroying) {
- d3.select(el.querySelector('.x-axis')).call(this.xAxis);
+ d3.select(element.querySelector('.x-axis')).call(this.xAxis);
}
- }
+ };
- @action
- recomputeYAxis(el) {
+ recomputeYAxis = (element) => {
if (!this.isDestroyed && !this.isDestroying) {
- d3.select(el.querySelector('.y-axis')).call(this.yAxis);
+ d3.select(element.querySelector('.y-axis')).call(this.yAxis);
}
- }
+ };
mountD3Elements() {
if (!this.isDestroyed && !this.isDestroying) {
@@ -379,16 +359,116 @@ export default class LineChart extends Component {
}
}
- annotationClick(annotation) {
- this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
- }
+ annotationClick = (annotation) => {
+ this.args.onAnnotationClick?.(annotation);
+ };
- @action
- updateDimensions() {
- const $svg = this.element.querySelector('svg');
+ updateDimensions = () => {
+ const svg = this.element.querySelector('svg');
- this.height = $svg.clientHeight;
- this.width = $svg.clientWidth;
+ this.height = svg.clientHeight;
+ this.width = svg.clientWidth;
this.renderChart();
- }
+ };
+
+
+
+
+ {{this.title}}
+
+ {{#if this.description}}
+ {{this.description}}
+ {{else}}
+ X-axis values range from
+ {{this.xRangeStart}}
+ to
+ {{this.xRangeEnd}}, and Y-axis values range from
+ {{this.yRangeStart}}
+ to
+ {{this.yRangeEnd}}.
+ {{/if}}
+
+
+ {{#if this.ready}}
+ {{yield
+ (hash
+ Area=(component
+ Area
+ curve="linear"
+ xScale=this.xScale
+ yScale=this.yScale
+ xProp=this.xProp
+ yProp=this.yProp
+ width=this.yAxisOffset
+ height=this.xAxisOffset
+ )
+ )
+ to="svg"
+ }}
+ {{/if}}
+
+
+
+
+ {{#if this.ready}}
+ {{yield
+ (hash
+ VAnnotations=(component
+ VAnnotations
+ timeseries=@timeseries
+ format=this.xFormat
+ scale=this.xScale
+ prop=this.xProp
+ height=this.xAxisOffset
+ )
+ HAnnotations=(component
+ HAnnotations
+ format=this.yFormat
+ scale=this.yScale
+ prop=this.yProp
+ left=this.canvasDimensions.left
+ width=this.canvasDimensions.width
+ )
+ Tooltip=(component
+ Tooltip
+ active=this.activeData.length
+ style=this.tooltipStyle
+ data=this.activeData
+ )
+ )
+ to="after"
+ }}
+ {{/if}}
+
+
}
diff --git a/ui/app/components/line-chart.hbs b/ui/app/components/line-chart.hbs
deleted file mode 100644
index ec99dadd79b..00000000000
--- a/ui/app/components/line-chart.hbs
+++ /dev/null
@@ -1,103 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{this.title}}
-
- {{#if this.description}}
- {{this.description}}
- {{else}}
- X-axis values range from
- {{get this.xRange "0"}}
- to
- {{this.xRange.lastObject}}, and Y-axis values range from
- {{get this.yRange "0"}}
- to
- {{this.yRange.lastObject}}.
- {{/if}}
-
-
- {{#if this.ready}}
- {{yield
- (hash
- Area=(component
- "chart-primitives/area"
- curve="linear"
- xScale=this.xScale
- yScale=this.yScale
- xProp=this.xProp
- yProp=this.yProp
- width=this.yAxisOffset
- height=this.xAxisOffset
- )
- )
- to="svg"
- }}
- {{/if}}
-
-
-
-
- {{#if this.ready}}
- {{yield
- (hash
- VAnnotations=(component
- "chart-primitives/v-annotations"
- timeseries=@timeseries
- format=this.xFormat
- scale=this.xScale
- prop=this.xProp
- height=this.xAxisOffset
- )
- HAnnotations=(component
- "chart-primitives/h-annotations"
- format=this.yFormat
- scale=this.yScale
- prop=this.yProp
- left=this.canvasDimensions.left
- width=this.canvasDimensions.width
- )
- Tooltip=(component
- "chart-primitives/tooltip"
- active=this.activeData.length
- style=this.tooltipStyle
- data=this.activeData
- )
- )
- to="after"
- }}
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/list-accordion.gjs b/ui/app/components/list-accordion.gjs
new file mode 100644
index 00000000000..c0a645e4803
--- /dev/null
+++ b/ui/app/components/list-accordion.gjs
@@ -0,0 +1,94 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { fn, hash } from '@ember/helper';
+import { get } from '@ember/object';
+import ListAccordionAccordionBody from 'nomad-ui/components/list-accordion/accordion-body';
+import ListAccordionAccordionHead from 'nomad-ui/components/list-accordion/accordion-head';
+
+export default class ListAccordion extends Component {
+ @tracked stateCache = [];
+
+ get key() {
+ return this.args.key ?? 'id';
+ }
+
+ get source() {
+ return this.args.source ?? [];
+ }
+
+ get startExpanded() {
+ return this.args.startExpanded ?? false;
+ }
+
+ get decoratedSource() {
+ const stateCache = this.stateCache;
+ const key = this.key;
+ const startExpanded = this.startExpanded;
+
+ return this.source.map((item) => {
+ const itemKey = get(item, key);
+ const cacheItem = stateCache.find(
+ (candidate) => candidate.key === itemKey,
+ );
+
+ return {
+ item,
+ isOpen: cacheItem ? !!cacheItem.isOpen : startExpanded,
+ };
+ });
+ }
+
+ setItemOpenState = (item, isOpen) => {
+ const key = this.key;
+ const itemKey = get(item, key);
+ const nextState = this.stateCache.slice();
+ const existingIndex = nextState.findIndex(
+ (candidate) => candidate.key === itemKey,
+ );
+
+ if (existingIndex === -1) {
+ nextState.push({ key: itemKey, isOpen });
+ } else {
+ nextState[existingIndex] = { key: itemKey, isOpen };
+ }
+
+ this.stateCache = nextState;
+ this.args.onToggle?.(item, isOpen);
+ };
+
+ openItem = (item) => {
+ this.setItemOpenState(item, true);
+ };
+
+ closeItem = (item) => {
+ this.setItemOpenState(item, false);
+ };
+
+
+
+ {{#each this.decoratedSource as |item|}}
+ {{yield
+ (hash
+ head=(component
+ ListAccordionAccordionHead
+ isOpen=item.isOpen
+ onOpen=(fn this.openItem item.item)
+ onClose=(fn this.closeItem item.item)
+ item=item.item
+ )
+ body=(component ListAccordionAccordionBody isOpen=item.isOpen)
+ item=item.item
+ isOpen=item.isOpen
+ onOpen=(fn this.openItem item.item)
+ onClose=(fn this.closeItem item.item)
+ )
+ }}
+ {{/each}}
+
+
+}
diff --git a/ui/app/components/list-accordion.hbs b/ui/app/components/list-accordion.hbs
deleted file mode 100644
index da5b5fb8726..00000000000
--- a/ui/app/components/list-accordion.hbs
+++ /dev/null
@@ -1,30 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#each this.decoratedSource as |item|}}
- {{yield
- (hash
- head=(component
- "list-accordion/accordion-head"
- isOpen=item.isOpen
- onOpen=(queue
- (fn (mut item.isOpen) true) (fn this.onToggle item.item item.isOpen)
- )
- onClose=(queue
- (fn (mut item.isOpen) false) (fn this.onToggle item.item item.isOpen)
- )
- )
- body=(component "list-accordion/accordion-body" isOpen=item.isOpen)
- item=item.item
- isOpen=item.isOpen
- onOpen=(queue
- (fn (mut item.isOpen) true) (fn this.onToggle item.item item.isOpen)
- )
- onClose=(queue
- (fn (mut item.isOpen) false) (fn this.onToggle item.item item.isOpen)
- )
- )
- }}
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/list-accordion.js b/ui/app/components/list-accordion.js
deleted file mode 100644
index 7de97f8cbf8..00000000000
--- a/ui/app/components/list-accordion.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed, get } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('accordion')
-export default class ListAccordion extends Component {
- key = 'id';
- @overridable(() => []) source;
-
- onToggle /* item, isOpen */() {}
- startExpanded = false;
-
- @computed('key', 'source.[]', 'startExpanded', 'stateCache')
- get decoratedSource() {
- const stateCache = this.stateCache;
- const key = this.key;
- const deepKey = `item.${key}`;
- const startExpanded = this.startExpanded;
-
- const decoratedSource = this.source.map((item) => {
- const cacheItem = stateCache.findBy(deepKey, get(item, key));
- return {
- item,
- isOpen: cacheItem ? !!cacheItem.isOpen : startExpanded,
- };
- });
-
- // eslint-disable-next-line ember/no-side-effects
- this.set('stateCache', decoratedSource);
- return decoratedSource;
- }
-
- // When source updates come in, the state cache is used to preserve
- // open/close state.
- @overridable(() => []) stateCache;
-}
diff --git a/ui/app/components/list-accordion/accordion-body.gjs b/ui/app/components/list-accordion/accordion-body.gjs
new file mode 100644
index 00000000000..4529ffccbf4
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-body.gjs
@@ -0,0 +1,17 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export const ListAccordionAccordionBody =
+ {{#if @isOpen}}
+
+ {{yield}}
+
+ {{/if}}
+;
+
+export default ListAccordionAccordionBody;
diff --git a/ui/app/components/list-accordion/accordion-body.hbs b/ui/app/components/list-accordion/accordion-body.hbs
deleted file mode 100644
index 4ea9a50786f..00000000000
--- a/ui/app/components/list-accordion/accordion-body.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.isOpen}}
-
- {{yield}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/list-accordion/accordion-body.js b/ui/app/components/list-accordion/accordion-body.js
deleted file mode 100644
index 002e4565da5..00000000000
--- a/ui/app/components/list-accordion/accordion-body.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class AccordionBody extends Component {
- isOpen = false;
-}
diff --git a/ui/app/components/list-accordion/accordion-head.gjs b/ui/app/components/list-accordion/accordion-head.gjs
new file mode 100644
index 00000000000..11e9fea4cc2
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-head.gjs
@@ -0,0 +1,33 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { or } from 'ember-truth-helpers';
+
+export const ListAccordionAccordionHead =
+
+
+ {{yield}}
+
+
+
+;
+
+export default ListAccordionAccordionHead;
diff --git a/ui/app/components/list-accordion/accordion-head.hbs b/ui/app/components/list-accordion/accordion-head.hbs
deleted file mode 100644
index acf0ab3d09b..00000000000
--- a/ui/app/components/list-accordion/accordion-head.hbs
+++ /dev/null
@@ -1,18 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{yield}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/list-accordion/accordion-head.js b/ui/app/components/list-accordion/accordion-head.js
deleted file mode 100644
index b52de76e028..00000000000
--- a/ui/app/components/list-accordion/accordion-head.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import {
- classNames,
- classNameBindings,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('accordion-head')
-@classNameBindings('isOpen::is-light', 'isExpandable::is-inactive')
-@attributeBindings('data-test-accordion-head')
-export default class AccordionHead extends Component {
- 'data-test-accordion-head' = true;
-
- buttonLabel = 'toggle';
- isOpen = false;
- isExpandable = true;
- item = null;
-
- onClose() {}
- onOpen() {}
-}
diff --git a/ui/app/components/list-pagination.gjs b/ui/app/components/list-pagination.gjs
new file mode 100644
index 00000000000..53cd19c2685
--- /dev/null
+++ b/ui/app/components/list-pagination.gjs
@@ -0,0 +1,121 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { hash } from '@ember/helper';
+import ListPager from 'nomad-ui/components/list-pagination/list-pager';
+
+export default class ListPagination extends Component {
+ get source() {
+ return this.args.source ?? [];
+ }
+
+ get size() {
+ return this.args.size ?? 25;
+ }
+
+ get page() {
+ return this.args.page ?? 1;
+ }
+
+ get spread() {
+ return this.args.spread ?? 2;
+ }
+
+ get startsAt() {
+ return (this.page - 1) * this.size + 1;
+ }
+
+ get endsAt() {
+ return Math.min(this.page * this.size, this.source.length);
+ }
+
+ get lastPage() {
+ return Math.ceil(this.source.length / this.size);
+ }
+
+ get pageLinks() {
+ const { spread, page, lastPage } = this;
+
+ // When there is only one page, don't bother with page links
+ if (lastPage === 1) {
+ return [];
+ }
+
+ const lowerBound = Math.max(1, page - spread);
+ const upperBound = Math.min(lastPage, page + spread) + 1;
+
+ return Array(upperBound - lowerBound)
+ .fill(null)
+ .map((_, index) => ({
+ pageNumber: lowerBound + index,
+ }));
+ }
+
+ get list() {
+ const size = this.size;
+ const start = (this.page - 1) * size;
+ return this.source.slice(start, start + size);
+ }
+
+ get firstOrPrevVisible() {
+ return this.page !== 1;
+ }
+
+ get nextOrLastVisible() {
+ return this.page !== this.lastPage;
+ }
+
+ get prevPage() {
+ return this.page - 1;
+ }
+
+ get nextPage() {
+ return this.page + 1;
+ }
+
+
+ {{#if this.source.length}}
+ {{yield
+ (hash
+ first=(component
+ ListPager
+ test="first"
+ label="First page"
+ page=1
+ visible=this.firstOrPrevVisible
+ )
+ prev=(component
+ ListPager
+ test="prev"
+ label="Previous page"
+ page=this.prevPage
+ visible=this.firstOrPrevVisible
+ )
+ next=(component
+ ListPager
+ test="next"
+ label="Next page"
+ page=this.nextPage
+ visible=this.nextOrLastVisible
+ )
+ last=(component
+ ListPager
+ test="last"
+ label="Last page"
+ page=this.lastPage
+ visible=this.nextOrLastVisible
+ )
+ pageLinks=this.pageLinks
+ currentPage=this.page
+ totalPages=this.lastPage
+ startsAt=this.startsAt
+ endsAt=this.endsAt
+ list=this.list
+ )
+ }}
+ {{/if}}
+
+}
diff --git a/ui/app/components/list-pagination.hbs b/ui/app/components/list-pagination.hbs
deleted file mode 100644
index 03a91151d6c..00000000000
--- a/ui/app/components/list-pagination.hbs
+++ /dev/null
@@ -1,45 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.source.length}}
- {{yield
- (hash
- first=(component
- "list-pagination/list-pager"
- test="first"
- label="First page"
- page=1
- visible=(not-eq this.page 1)
- )
- prev=(component
- "list-pagination/list-pager"
- test="prev"
- label="Previous page"
- page=(dec this.page)
- visible=(not-eq this.page 1)
- )
- next=(component
- "list-pagination/list-pager"
- test="next"
- label="Next page"
- page=(inc this.page)
- visible=(not-eq this.page this.lastPage)
- )
- last=(component
- "list-pagination/list-pager"
- test="last"
- label="Last page"
- page=this.lastPage
- visible=(not-eq this.page this.lastPage)
- )
- pageLinks=this.pageLinks
- currentPage=this.page
- totalPages=this.lastPage
- startsAt=this.startsAt
- endsAt=this.endsAt
- list=this.list
- )
- }}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/list-pagination.js b/ui/app/components/list-pagination.js
deleted file mode 100644
index 3edc66ffb5c..00000000000
--- a/ui/app/components/list-pagination.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import classic from 'ember-classic-decorator';
-
-@classic
-export default class ListPagination extends Component {
- @overridable(() => []) source;
- size = 25;
- page = 1;
- spread = 2;
-
- @computed('size', 'page')
- get startsAt() {
- return (this.page - 1) * this.size + 1;
- }
-
- @computed('source.[]', 'size', 'page')
- get endsAt() {
- return Math.min(this.page * this.size, this.get('source.length'));
- }
-
- @computed('source.[]', 'size')
- get lastPage() {
- return Math.ceil(this.get('source.length') / this.size);
- }
-
- @computed('source.[]', 'page', 'spread')
- get pageLinks() {
- const { spread, page, lastPage } = this;
-
- // When there is only one page, don't bother with page links
- if (lastPage === 1) {
- return [];
- }
-
- const lowerBound = Math.max(1, page - spread);
- const upperBound = Math.min(lastPage, page + spread) + 1;
-
- return Array(upperBound - lowerBound)
- .fill(null)
- .map((_, index) => ({
- pageNumber: lowerBound + index,
- }));
- }
-
- @computed('source.[]', 'page', 'size')
- get list() {
- const size = this.size;
- const start = (this.page - 1) * size;
- return this.source.slice(start, start + size);
- }
-}
diff --git a/ui/app/components/list-pagination/list-pager.gjs b/ui/app/components/list-pagination/list-pager.gjs
new file mode 100644
index 00000000000..674d10364d0
--- /dev/null
+++ b/ui/app/components/list-pagination/list-pager.gjs
@@ -0,0 +1,59 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { hash } from '@ember/helper';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
+
+export default class ListPager extends Component {
+ @service router;
+
+ // Even though we don't currently use "first" / "last" pagination in the app,
+ // the option is there at a component level, so let's make sure that we
+ // only append keyNav to the "next" and "prev" links.
+ // We use this to make the modifier conditional, per https://v5.chriskrycho.com/journal/conditional-modifiers-and-helpers-in-emberjs/
+ get includeKeyboardNav() {
+ return this.args.label === 'Next page' ||
+ this.args.label === 'Previous page'
+ ? KeyboardShortcutModifier
+ : null;
+ }
+
+ get keyboardLabel() {
+ return this.args.label === 'Next page' ? 'Next Page' : 'Previous Page';
+ }
+
+ get keyboardPattern() {
+ return this.args.label === 'Next page' ? [']', ']'] : ['[', '['];
+ }
+
+ gotoRoute = () => {
+ this.router.transitionTo({
+ queryParams: { page: this.args.page },
+ });
+ };
+
+
+ {{#if @visible}}
+
+ {{yield}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/list-pagination/list-pager.hbs b/ui/app/components/list-pagination/list-pager.hbs
deleted file mode 100644
index eabfd33d1b5..00000000000
--- a/ui/app/components/list-pagination/list-pager.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.visible}}
-
- {{yield}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/list-pagination/list-pager.js b/ui/app/components/list-pagination/list-pager.js
deleted file mode 100644
index 79910a4b77c..00000000000
--- a/ui/app/components/list-pagination/list-pager.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
-
-@classic
-@tagName('')
-export default class ListPager extends Component {
- @service router;
-
- // Even though we don't currently use "first" / "last" pagination in the app,
- // the option is there at a component level, so let's make sure that we
- // only append keyNav to the "next" and "prev" links.
- // We use this to make the modifier conditional, per https://v5.chriskrycho.com/journal/conditional-modifiers-and-helpers-in-emberjs/
- get includeKeyboardNav() {
- return this.label === 'Next page' || this.label === 'Previous page'
- ? KeyboardShortcutModifier
- : null;
- }
-
- @action
- gotoRoute() {
- this.router.transitionTo({
- queryParams: { page: this.page },
- });
- }
-}
diff --git a/ui/app/components/list-table.gjs b/ui/app/components/list-table.gjs
new file mode 100644
index 00000000000..75a0f1ac265
--- /dev/null
+++ b/ui/app/components/list-table.gjs
@@ -0,0 +1,36 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { hash, concat } from '@ember/helper';
+import { or } from 'ember-truth-helpers';
+import ListTableSortBy from 'nomad-ui/components/list-table/sort-by';
+import ListTableTableBody from 'nomad-ui/components/list-table/table-body';
+import ListTableTableHead from 'nomad-ui/components/list-table/table-head';
+
+export default class ListTable extends Component {
+ // Plan for a future with metadata (e.g., isSelected)
+ get decoratedSource() {
+ return (this.args.source || []).map((row) => ({
+ model: row,
+ }));
+ }
+
+
+
+ {{yield
+ (hash
+ head=ListTableTableHead
+ body=(component ListTableTableBody rows=this.decoratedSource)
+ sortBy=(component
+ ListTableSortBy
+ currentProp=@sortProperty
+ sortDescending=@sortDescending
+ )
+ )
+ }}
+
+
+}
diff --git a/ui/app/components/list-table.hbs b/ui/app/components/list-table.hbs
deleted file mode 100644
index 0de2cd8e08b..00000000000
--- a/ui/app/components/list-table.hbs
+++ /dev/null
@@ -1,16 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield
- (hash
- head=(component "list-table/table-head")
- body=(component "list-table/table-body" rows=this.decoratedSource)
- sort-by=(component
- "list-table/sort-by"
- currentProp=this.sortProperty
- sortDescending=this.sortDescending
- )
- )
-}}
\ No newline at end of file
diff --git a/ui/app/components/list-table.js b/ui/app/components/list-table.js
deleted file mode 100644
index 6de4703901b..00000000000
--- a/ui/app/components/list-table.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import { classNames, tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('table')
-@classNames('table')
-export default class ListTable extends Component {
- @overridable(() => []) source;
-
- // Plan for a future with metadata (e.g., isSelected)
- @computed('source.{[],isFulfilled}')
- get decoratedSource() {
- return (this.source || []).map((row) => ({
- model: row,
- }));
- }
-}
diff --git a/ui/app/components/list-table/sort-by.gjs b/ui/app/components/list-table/sort-by.gjs
new file mode 100644
index 00000000000..cbc03ea5a85
--- /dev/null
+++ b/ui/app/components/list-table/sort-by.gjs
@@ -0,0 +1,41 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { LinkTo } from '@ember/routing';
+import { concat, hash } from '@ember/helper';
+
+export default class SortBy extends Component {
+ get isActive() {
+ return this.args.currentProp === this.args.prop;
+ }
+
+ get shouldSortDescending() {
+ return !this.isActive || !this.args.sortDescending;
+ }
+
+
+
+
+ {{yield}}
+
+
+
+}
diff --git a/ui/app/components/list-table/sort-by.hbs b/ui/app/components/list-table/sort-by.hbs
deleted file mode 100644
index 4263b74f7f2..00000000000
--- a/ui/app/components/list-table/sort-by.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{yield}}
-
\ No newline at end of file
diff --git a/ui/app/components/list-table/sort-by.js b/ui/app/components/list-table/sort-by.js
deleted file mode 100644
index f75b09a4373..00000000000
--- a/ui/app/components/list-table/sort-by.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed } from '@ember/object';
-import {
- classNames,
- attributeBindings,
- classNameBindings,
- tagName,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('th')
-@attributeBindings('title')
-@classNames('is-selectable')
-@classNameBindings('isActive:is-active', 'sortDescending:desc:asc')
-export default class SortBy extends Component {
- // The prop that the table is currently sorted by
- currentProp = '';
-
- // The prop this sorter controls
- prop = '';
-
- @computed('currentProp', 'prop')
- get isActive() {
- return this.currentProp === this.prop;
- }
-
- @computed('sortDescending', 'isActive')
- get shouldSortDescending() {
- return !this.isActive || !this.sortDescending;
- }
-}
diff --git a/ui/app/components/list-table/table-body.gjs b/ui/app/components/list-table/table-body.gjs
new file mode 100644
index 00000000000..b42760a85f3
--- /dev/null
+++ b/ui/app/components/list-table/table-body.gjs
@@ -0,0 +1,14 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+const TableBody =
+
+ {{#each @rows as |row index|}}
+ {{yield row index}}
+ {{/each}}
+
+;
+
+export default TableBody;
diff --git a/ui/app/components/list-table/table-body.hbs b/ui/app/components/list-table/table-body.hbs
deleted file mode 100644
index c4133e85307..00000000000
--- a/ui/app/components/list-table/table-body.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#each this.rows key=this.key as |row index|}}
- {{yield row index}}
-{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/list-table/table-body.js b/ui/app/components/list-table/table-body.js
deleted file mode 100644
index 9d7fa3ea810..00000000000
--- a/ui/app/components/list-table/table-body.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tbody')
-export default class TableBody extends Component {}
diff --git a/ui/app/components/list-table/table-head.gjs b/ui/app/components/list-table/table-head.gjs
new file mode 100644
index 00000000000..7b64f5c6b6e
--- /dev/null
+++ b/ui/app/components/list-table/table-head.gjs
@@ -0,0 +1,14 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+const TableHead =
+
+
+ {{yield}}
+
+
+;
+
+export default TableHead;
diff --git a/ui/app/components/list-table/table-head.hbs b/ui/app/components/list-table/table-head.hbs
deleted file mode 100644
index 907e018f3d7..00000000000
--- a/ui/app/components/list-table/table-head.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{yield}}
-
\ No newline at end of file
diff --git a/ui/app/components/list-table/table-head.js b/ui/app/components/list-table/table-head.js
deleted file mode 100644
index 8e7edc854da..00000000000
--- a/ui/app/components/list-table/table-head.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('thead')
-export default class TableHead extends Component {}
diff --git a/ui/app/components/loading-spinner.gjs b/ui/app/components/loading-spinner.gjs
new file mode 100644
index 00000000000..31e92a2c515
--- /dev/null
+++ b/ui/app/components/loading-spinner.gjs
@@ -0,0 +1,37 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+
+export default class LoadingSpinner extends Component {
+ @tracked paused = false;
+
+ togglePaused = () => {
+ this.paused = !this.paused;
+ };
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/loading-spinner.hbs b/ui/app/components/loading-spinner.hbs
deleted file mode 100644
index 7367a377923..00000000000
--- a/ui/app/components/loading-spinner.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/loading-spinner.js b/ui/app/components/loading-spinner.js
deleted file mode 100644
index 4b1e5f25572..00000000000
--- a/ui/app/components/loading-spinner.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-
-export default class LoadingSpinner extends Component {
- @tracked paused = false;
-
- @action
- togglePaused() {
- this.paused = !this.paused;
- }
-}
diff --git a/ui/app/components/metadata-editor.gjs b/ui/app/components/metadata-editor.gjs
new file mode 100644
index 00000000000..9ff3387135a
--- /dev/null
+++ b/ui/app/components/metadata-editor.gjs
@@ -0,0 +1,79 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+
+const updateKVKey = (kv, event) => {
+ if (kv) kv.key = event.target.value;
+};
+
+const updateKVValue = (kv, event) => {
+ if (!kv) return;
+
+ const value = event.target.value;
+ if (typeof kv.setValue === 'function') {
+ kv.setValue(value);
+ }
+
+ kv.value = value;
+};
+
+const preventSubmit = (event) => {
+ event?.preventDefault?.();
+};
+
+export const MetadataEditor =
+
+;
+
+export default MetadataEditor;
diff --git a/ui/app/components/metadata-editor.hbs b/ui/app/components/metadata-editor.hbs
deleted file mode 100644
index cbffd173c4f..00000000000
--- a/ui/app/components/metadata-editor.hbs
+++ /dev/null
@@ -1,46 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/metadata-kv.gjs b/ui/app/components/metadata-kv.gjs
new file mode 100644
index 00000000000..e78e7034415
--- /dev/null
+++ b/ui/app/components/metadata-kv.gjs
@@ -0,0 +1,122 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import CopyButton from 'nomad-ui/components/copy-button';
+import MetadataEditor from 'nomad-ui/components/metadata-editor';
+import { hash } from '@ember/helper';
+import { not, or } from 'ember-truth-helpers';
+
+export default class MetadataKv extends Component {
+ @tracked editing = false;
+ // eslint-disable-next-line ember/no-tracked-properties-from-args
+ @tracked value = this.args.value;
+
+ get prefixedKey() {
+ return this.args.prefix
+ ? `${this.args.prefix}.${this.args.key}`
+ : this.args.key;
+ }
+
+ onEdit = (event) => {
+ if (event.key === 'Escape') {
+ this.cancelEditing();
+ }
+ };
+
+ setValue = (value) => {
+ this.value = value;
+ };
+
+ startEditing = () => {
+ this.editing = true;
+ };
+
+ cancelEditing = () => {
+ this.editing = false;
+ this.value = this.args.value;
+ };
+
+ saveMetadata = () => {
+ this.args.onKVSave?.({ key: this.prefixedKey, value: this.value });
+ this.editing = false;
+ };
+
+ deleteMetadata = () => {
+ this.args.onKVSave?.({ key: this.prefixedKey, value: null });
+ this.editing = false;
+ };
+
+
+
+ {{#if this.editing}}
+
+
+
+
+
+
+
+ {{else}}
+
+ {{#if @prefix}}{{@prefix}}.{{/if}}
+ {{~@key}}
+
+
+
+ {{@value}}
+ {{#if @editable}}
+
+ {{/if}}
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/metadata-kv.hbs b/ui/app/components/metadata-kv.hbs
deleted file mode 100644
index 48c55b5995c..00000000000
--- a/ui/app/components/metadata-kv.hbs
+++ /dev/null
@@ -1,78 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.editing}}
-
-
-
-
-
-
-
- {{else}}
-
- {{#if @prefix}}{{@prefix}}.{{/if}}
- {{~@key}}
-
-
-
- {{@value}}
- {{#if @editable}}
-
- {{/if}}
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/metadata-kv.js b/ui/app/components/metadata-kv.js
deleted file mode 100644
index 57459dca617..00000000000
--- a/ui/app/components/metadata-kv.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-
-export default class MetadataKvComponent extends Component {
- @tracked editing = false;
- // eslint-disable-next-line ember/no-tracked-properties-from-args
- @tracked value = this.args.value;
-
- get prefixedKey() {
- return this.args.prefix
- ? `${this.args.prefix}.${this.args.key}`
- : this.args.key;
- }
-
- @action onEdit(event) {
- if (event.key === 'Escape') {
- this.editing = false;
- }
- }
-}
diff --git a/ui/app/components/multi-select-dropdown.gjs b/ui/app/components/multi-select-dropdown.gjs
new file mode 100644
index 00000000000..816ffef77bc
--- /dev/null
+++ b/ui/app/components/multi-select-dropdown.gjs
@@ -0,0 +1,205 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { scheduleOnce } from '@ember/runloop';
+import { on } from '@ember/modifier';
+import { includes } from '@nullvoxpopuli/ember-composable-helpers';
+import { didUpdate } from '@ember/render-modifiers';
+import BasicDropdown from 'ember-basic-dropdown/components/basic-dropdown';
+
+const TAB = 9;
+const ESC = 27;
+const SPACE = 32;
+const ARROW_UP = 38;
+const ARROW_DOWN = 40;
+
+let dropdownInstanceCounter = 0;
+
+export default class MultiSelectDropdown extends Component {
+ @tracked isOpen = false;
+
+ dropdown = null;
+ triggerElement = null;
+
+ labelElementId = `multi-select-dropdown-${dropdownInstanceCounter++}-label`;
+
+ get options() {
+ return this.args.options ?? [];
+ }
+
+ get selection() {
+ return this.args.selection ?? [];
+ }
+
+ capture = (dropdown) => {
+ this.dropdown = dropdown;
+ };
+
+ handleOpen = (dropdown) => {
+ this.isOpen = true;
+ this.capture(dropdown);
+ };
+
+ handleClose = () => {
+ this.isOpen = false;
+ this.dropdown = null;
+ };
+
+ captureTrigger = (element) => {
+ this.triggerElement = element;
+ };
+
+ repositionDropdown = () => {
+ if (this.isOpen && this.dropdown) {
+ scheduleOnce('afterRender', this, this.repositionNow);
+ }
+ };
+
+ repositionNow = () => {
+ this.dropdown?.actions?.reposition?.();
+ };
+
+ toggle = ({ key }) => {
+ const newSelection = [...this.selection];
+ const index = newSelection.indexOf(key);
+
+ if (index >= 0) {
+ newSelection.splice(index, 1);
+ } else {
+ newSelection.push(key);
+ }
+
+ this.args.onSelect?.(newSelection);
+ };
+
+ openOnArrowDown = (dropdown, event) => {
+ this.capture(dropdown);
+
+ if (!this.isOpen && event.keyCode === ARROW_DOWN) {
+ dropdown.actions.open(event);
+ event.preventDefault();
+ } else if (
+ this.isOpen &&
+ (event.keyCode === TAB || event.keyCode === ARROW_DOWN)
+ ) {
+ const optionsId = event.currentTarget?.getAttribute('aria-owns');
+ const firstElement = optionsId
+ ? document.querySelector(`#${optionsId} .dropdown-option`)
+ : null;
+
+ if (firstElement) {
+ firstElement.focus();
+ event.preventDefault();
+ }
+ }
+ };
+
+ traverseList = (option, event) => {
+ if (event.keyCode === ESC) {
+ const dropdown = this.dropdown;
+ if (dropdown) {
+ dropdown.actions.close(event);
+ this.triggerElement?.focus?.();
+ event.preventDefault();
+ this.dropdown = null;
+ }
+ } else if (event.keyCode === ARROW_UP) {
+ const prev = event.target.previousElementSibling;
+ if (prev) {
+ prev.focus();
+ event.preventDefault();
+ }
+ } else if (event.keyCode === ARROW_DOWN) {
+ const next = event.target.nextElementSibling;
+ if (next) {
+ next.focus();
+ event.preventDefault();
+ }
+ } else if (event.keyCode === SPACE) {
+ this.toggle(option);
+ event.preventDefault();
+ }
+ };
+
+
+
+
+
+
+ {{@label}}
+ {{#if this.selection.length}}
+
+ {{this.selection.length}}
+
+ {{/if}}
+
+
+
+
+ {{#if this.options}}
+
+ {{#each this.options key="key" as |option|}}
+
+
+
+ {{/each}}
+
+ {{else}}
+
+ -
+ No options
+
+
+ {{/if}}
+
+
+
+
+}
diff --git a/ui/app/components/multi-select-dropdown.hbs b/ui/app/components/multi-select-dropdown.hbs
deleted file mode 100644
index 793e2b5df67..00000000000
--- a/ui/app/components/multi-select-dropdown.hbs
+++ /dev/null
@@ -1,67 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{this.label}}
- {{#if this.selection.length}}
-
- {{this.selection.length}}
-
- {{/if}}
-
-
-
-
- {{#if this.options}}
-
- {{#each this.options key="key" as |option|}}
-
-
-
- {{/each}}
-
- {{else}}
-
- -
- No options
-
-
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/multi-select-dropdown.js b/ui/app/components/multi-select-dropdown.js
deleted file mode 100644
index eedbcb99768..00000000000
--- a/ui/app/components/multi-select-dropdown.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { computed as overridable } from 'ember-overridable-computed';
-import { scheduleOnce } from '@ember/runloop';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-const TAB = 9;
-const ESC = 27;
-const SPACE = 32;
-const ARROW_UP = 38;
-const ARROW_DOWN = 40;
-
-@classic
-@classNames('dropdown')
-export default class MultiSelectDropdown extends Component {
- @overridable(() => []) options;
- @overridable(() => []) selection;
-
- onSelect() {}
-
- isOpen = false;
- dropdown = null;
-
- @action
- capture(dropdown) {
- // It's not a good idea to grab a dropdown reference like this, but it's necessary
- // in order to invoke dropdown.actions.close in traverseList as well as
- // dropdown.actions.reposition when the label or selection length changes.
- this.set('dropdown', dropdown);
- }
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- const dropdown = this.dropdown;
- if (this.isOpen && dropdown) {
- scheduleOnce('afterRender', this, this.repositionDropdown);
- }
- }
-
- repositionDropdown() {
- this.dropdown.actions.reposition();
- }
-
- @action
- toggle({ key }) {
- const newSelection = this.selection.slice();
- if (newSelection.includes(key)) {
- newSelection.removeObject(key);
- } else {
- newSelection.addObject(key);
- }
- this.onSelect(newSelection);
- }
-
- @action
- openOnArrowDown(dropdown, e) {
- this.capture(dropdown);
-
- if (!this.isOpen && e.keyCode === ARROW_DOWN) {
- dropdown.actions.open(e);
- e.preventDefault();
- } else if (this.isOpen && (e.keyCode === TAB || e.keyCode === ARROW_DOWN)) {
- const optionsId = this.element
- .querySelector('.dropdown-trigger')
- .getAttribute('aria-owns');
- const firstElement = document.querySelector(
- `#${optionsId} .dropdown-option`,
- );
-
- if (firstElement) {
- firstElement.focus();
- e.preventDefault();
- }
- }
- }
-
- @action
- traverseList(option, e) {
- if (e.keyCode === ESC) {
- // Close the dropdown
- const dropdown = this.dropdown;
- if (dropdown) {
- dropdown.actions.close(e);
- // Return focus to the trigger so tab works as expected
- const trigger = this.element.querySelector('.dropdown-trigger');
- if (trigger) trigger.focus();
- e.preventDefault();
- this.set('dropdown', null);
- }
- } else if (e.keyCode === ARROW_UP) {
- // previous item
- const prev = e.target.previousElementSibling;
- if (prev) {
- prev.focus();
- e.preventDefault();
- }
- } else if (e.keyCode === ARROW_DOWN) {
- // next item
- const next = e.target.nextElementSibling;
- if (next) {
- next.focus();
- e.preventDefault();
- }
- } else if (e.keyCode === SPACE) {
- this.send('toggle', option);
- e.preventDefault();
- }
- }
-}
diff --git a/ui/app/components/namespace-editor.gjs b/ui/app/components/namespace-editor.gjs
new file mode 100644
index 00000000000..28634be6f8f
--- /dev/null
+++ b/ui/app/components/namespace-editor.gjs
@@ -0,0 +1,252 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { hash } from '@ember/helper';
+import { service } from '@ember/service';
+import can from 'ember-can/helpers/can';
+import {
+ HdsButton,
+ HdsFormTextInputField,
+} from '@hashicorp/design-system-components/components';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+import { not } from 'ember-truth-helpers';
+
+export default class NamespaceEditor extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+ @service abilities;
+
+ @tracked JSONError = null;
+ @tracked definitionString = this.definitionStringFromNamespace(
+ this.args.namespace,
+ );
+
+ updateNamespaceName = ({ target: { value } }) => {
+ this.args.namespace.set('name', value);
+ };
+
+ updateNamespaceDefinition = (value) => {
+ this.JSONError = null;
+ this.definitionString = value;
+
+ try {
+ JSON.parse(this.definitionString);
+ } catch {
+ this.JSONError = 'Invalid JSON';
+ }
+ };
+
+ save = async (event) => {
+ event?.preventDefault?.();
+
+ const namespace = this.args.namespace;
+
+ try {
+ this.deserializeDefinitionJson(JSON.parse(this.definitionString));
+
+ const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
+ if (!namespace.name?.match(nameRegex)) {
+ throw new Error(
+ 'Namespace name must be 1-128 characters long and can only contain letters, numbers, and dashes.',
+ );
+ }
+
+ const shouldRedirectAfterSave = namespace.isNew;
+
+ if (
+ namespace.isNew &&
+ this.store
+ .peekAll('namespace')
+ .filter((existingNamespace) => existingNamespace !== namespace)
+ .findBy('name', namespace.name)
+ ) {
+ throw new Error(
+ `A namespace with name ${namespace.name} already exists.`,
+ );
+ }
+
+ namespace.set('id', namespace.name);
+ await namespace.save();
+
+ this.notifications.add({
+ title: 'Namespace Saved',
+ color: 'success',
+ });
+
+ if (shouldRedirectAfterSave) {
+ this.router.transitionTo(
+ 'administration.namespaces.acl-namespace',
+ namespace.name,
+ );
+ }
+ } catch (err) {
+ const title = `Error ${
+ namespace.isNew ? 'creating' : 'updating'
+ } Namespace ${namespace.name}`;
+
+ const message = err.errors?.length
+ ? messageFromAdapterError(err)
+ : err.message || 'Unknown Error';
+
+ this.notifications.add({
+ title,
+ message,
+ color: 'critical',
+ sticky: true,
+ });
+ }
+ };
+
+ definitionStringFromNamespace(namespace) {
+ const capabilities = namespace.capabilities
+ ? {
+ DisabledTaskDrivers:
+ namespace.capabilities.DisabledTaskDrivers?.toArray?.() ||
+ namespace.capabilities.DisabledTaskDrivers ||
+ [],
+ EnabledTaskDrivers:
+ namespace.capabilities.EnabledTaskDrivers?.toArray?.() ||
+ namespace.capabilities.EnabledTaskDrivers ||
+ [],
+ }
+ : undefined;
+
+ const nodePoolConfiguration = namespace.nodePoolConfiguration
+ ? {
+ Default: namespace.nodePoolConfiguration.Default,
+ Allowed:
+ namespace.nodePoolConfiguration.Allowed?.toArray?.() ||
+ namespace.nodePoolConfiguration.Allowed ||
+ null,
+ Disallowed:
+ namespace.nodePoolConfiguration.Disallowed?.toArray?.() ||
+ namespace.nodePoolConfiguration.Disallowed ||
+ null,
+ }
+ : null;
+
+ const definitionHash = {};
+ definitionHash.Description = namespace.description;
+ definitionHash.Capabilities = capabilities;
+ definitionHash.Meta = namespace.meta;
+
+ if (this.abilities.can('configure-in-namespace node-pool')) {
+ definitionHash.NodePoolConfiguration = nodePoolConfiguration;
+ }
+
+ if (this.abilities.can('configure-in-namespace quota')) {
+ definitionHash.Quota = namespace.quota;
+ }
+
+ return JSON.stringify(definitionHash, null, 4);
+ }
+
+ deserializeDefinitionJson(definitionHash) {
+ const namespace = this.args.namespace;
+
+ namespace.set('description', definitionHash.Description);
+ namespace.set('meta', definitionHash.Meta);
+
+ const capabilities = this.store.createFragment(
+ 'ns-capabilities',
+ definitionHash.Capabilities,
+ );
+ namespace.set('capabilities', capabilities);
+
+ if (this.abilities.can('configure-in-namespace node-pool')) {
+ const npConfig = definitionHash.NodePoolConfiguration || {};
+
+ if (!('Allowed' in npConfig)) {
+ npConfig.Allowed = null;
+ }
+
+ if (!('Disallowed' in npConfig)) {
+ npConfig.Disallowed = null;
+ }
+
+ const nodePoolConfiguration = this.store.createFragment(
+ 'ns-node-pool-configuration',
+ npConfig,
+ );
+
+ namespace.set('nodePoolConfiguration', nodePoolConfiguration);
+ }
+
+ if (this.abilities.can('configure-in-namespace quota')) {
+ namespace.set('quota', definitionHash.Quota);
+ }
+ }
+
+
+
+
+}
diff --git a/ui/app/components/namespace-editor.hbs b/ui/app/components/namespace-editor.hbs
deleted file mode 100644
index a736a1279d7..00000000000
--- a/ui/app/components/namespace-editor.hbs
+++ /dev/null
@@ -1,66 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/namespace-editor.js b/ui/app/components/namespace-editor.js
deleted file mode 100644
index 7be59b9c7a4..00000000000
--- a/ui/app/components/namespace-editor.js
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import { tracked } from '@glimmer/tracking';
-import Component from '@glimmer/component';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class NamespaceEditorComponent extends Component {
- @service notifications;
- @service router;
- @service store;
- @service abilities;
-
- @alias('args.namespace') namespace;
-
- @tracked JSONError = null;
- @tracked definitionString = this.definitionStringFromNamespace(
- this.args.namespace,
- );
-
- @action updateNamespaceName({ target: { value } }) {
- this.namespace.set('name', value);
- }
-
- @action updateNamespaceDefinition(value) {
- this.JSONError = null;
- this.definitionString = value;
-
- try {
- JSON.parse(this.definitionString);
- } catch {
- this.JSONError = 'Invalid JSON';
- }
- }
-
- @action async save(e) {
- if (e instanceof Event) {
- e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault()
- }
- try {
- this.deserializeDefinitionJson(JSON.parse(this.definitionString));
-
- const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
- if (!this.namespace.name?.match(nameRegex)) {
- throw new Error(
- `Namespace name must be 1-128 characters long and can only contain letters, numbers, and dashes.`,
- );
- }
-
- const shouldRedirectAfterSave = this.namespace.isNew;
-
- if (
- this.namespace.isNew &&
- this.store
- .peekAll('namespace')
- .filter((namespace) => namespace !== this.namespace)
- .findBy('name', this.namespace.name)
- ) {
- throw new Error(
- `A namespace with name ${this.namespace.name} already exists.`,
- );
- }
-
- this.namespace.set('id', this.namespace.name);
- await this.namespace.save();
-
- this.notifications.add({
- title: 'Namespace Saved',
- color: 'success',
- });
-
- if (shouldRedirectAfterSave) {
- this.router.transitionTo(
- 'administration.namespaces.acl-namespace',
- this.namespace.name,
- );
- }
- } catch (err) {
- let title = `Error ${
- this.namespace.isNew ? 'creating' : 'updating'
- } Namespace ${this.namespace.name}`;
-
- let message = err.errors?.length
- ? messageFromAdapterError(err)
- : err.message || 'Unknown Error';
-
- this.notifications.add({
- title,
- message,
- color: 'critical',
- sticky: true,
- });
- }
- }
-
- definitionStringFromNamespace(namespace) {
- const capabilities = namespace.capabilities
- ? {
- DisabledTaskDrivers:
- namespace.capabilities.DisabledTaskDrivers?.toArray?.() ||
- namespace.capabilities.DisabledTaskDrivers ||
- [],
- EnabledTaskDrivers:
- namespace.capabilities.EnabledTaskDrivers?.toArray?.() ||
- namespace.capabilities.EnabledTaskDrivers ||
- [],
- }
- : undefined;
-
- const nodePoolConfiguration = namespace.nodePoolConfiguration
- ? {
- Default: namespace.nodePoolConfiguration.Default,
- Allowed:
- namespace.nodePoolConfiguration.Allowed?.toArray?.() ||
- namespace.nodePoolConfiguration.Allowed ||
- null,
- Disallowed:
- namespace.nodePoolConfiguration.Disallowed?.toArray?.() ||
- namespace.nodePoolConfiguration.Disallowed ||
- null,
- }
- : null;
-
- let definitionHash = {};
- definitionHash['Description'] = namespace.description;
- definitionHash['Capabilities'] = capabilities;
- definitionHash['Meta'] = namespace.meta;
-
- if (this.abilities.can('configure-in-namespace node-pool')) {
- definitionHash['NodePoolConfiguration'] = nodePoolConfiguration;
- }
-
- if (this.abilities.can('configure-in-namespace quota')) {
- definitionHash['Quota'] = namespace.quota;
- }
-
- return JSON.stringify(definitionHash, null, 4);
- }
-
- deserializeDefinitionJson(definitionHash) {
- this.namespace.set('description', definitionHash['Description']);
- this.namespace.set('meta', definitionHash['Meta']);
-
- let capabilities = this.store.createFragment(
- 'ns-capabilities',
- definitionHash['Capabilities'],
- );
- this.namespace.set('capabilities', capabilities);
-
- if (this.abilities.can('configure-in-namespace node-pool')) {
- let npConfig = definitionHash['NodePoolConfiguration'] || {};
- this.store.create;
-
- // If we don't manually set this to null, removing
- // the keys wont update the data framgment, which we want
- if (!('Allowed' in npConfig)) {
- npConfig['Allowed'] = null;
- }
-
- if (!('Disallowed' in npConfig)) {
- npConfig['Disallowed'] = null;
- }
-
- // Create node pool config fragment
- let nodePoolConfiguration = this.store.createFragment(
- 'ns-node-pool-configuration',
- npConfig,
- );
-
- this.namespace.set('nodePoolConfiguration', nodePoolConfiguration);
- }
-
- if (this.abilities.can('configure-in-namespace quota')) {
- this.namespace.set('quota', definitionHash['Quota']);
- }
- }
-}
diff --git a/ui/app/components/nomad-logo.gjs b/ui/app/components/nomad-logo.gjs
new file mode 100644
index 00000000000..b391cd731f2
--- /dev/null
+++ b/ui/app/components/nomad-logo.gjs
@@ -0,0 +1,45 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export const NomadLogo =
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default NomadLogo;
diff --git a/ui/app/components/nomad-logo.hbs b/ui/app/components/nomad-logo.hbs
deleted file mode 100644
index 8888dd22e20..00000000000
--- a/ui/app/components/nomad-logo.hbs
+++ /dev/null
@@ -1,40 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/page-layout.gjs b/ui/app/components/page-layout.gjs
new file mode 100644
index 00000000000..b31111ff822
--- /dev/null
+++ b/ui/app/components/page-layout.gjs
@@ -0,0 +1,37 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import AppBreadcrumbs from 'nomad-ui/components/app-breadcrumbs';
+import GlobalHeader from 'nomad-ui/components/global-header';
+import GutterMenu from 'nomad-ui/components/gutter-menu';
+
+export default class PageLayout extends Component {
+ @tracked isGutterOpen = false;
+
+ openGutter = () => {
+ this.isGutterOpen = true;
+ };
+
+ closeGutter = () => {
+ this.isGutterOpen = false;
+ };
+
+
+
+
+
+
+
+ {{yield}}
+
+
+
+}
diff --git a/ui/app/components/page-layout.hbs b/ui/app/components/page-layout.hbs
deleted file mode 100644
index b42d3f32c2a..00000000000
--- a/ui/app/components/page-layout.hbs
+++ /dev/null
@@ -1,18 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
- {{yield}}
-
\ No newline at end of file
diff --git a/ui/app/components/page-layout.js b/ui/app/components/page-layout.js
deleted file mode 100644
index 5a0ba2589a3..00000000000
--- a/ui/app/components/page-layout.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('page-layout')
-export default class PageLayout extends Component {
- isGutterOpen = false;
-}
diff --git a/ui/app/components/page-size-select.gjs b/ui/app/components/page-size-select.gjs
new file mode 100644
index 00000000000..9bfc346825f
--- /dev/null
+++ b/ui/app/components/page-size-select.gjs
@@ -0,0 +1,48 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import PowerSelect from 'ember-power-select/components/power-select';
+
+export default class PageSizeSelect extends Component {
+ @service userSettings;
+
+ pageSizeOptions = [10, 25, 50];
+
+ get onChange() {
+ return this.args.onChange ?? (() => {});
+ }
+
+ handlePageSizeChange = (pageSize) => {
+ this.userSettings.set('pageSize', pageSize);
+ this.onChange(pageSize);
+ };
+
+
+
+
+ Per page
+
+
+ {{option}}
+
+
+
+}
diff --git a/ui/app/components/page-size-select.hbs b/ui/app/components/page-size-select.hbs
deleted file mode 100644
index 642e85dccf4..00000000000
--- a/ui/app/components/page-size-select.hbs
+++ /dev/null
@@ -1,27 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- Per page
-
-
- {{option}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/page-size-select.js b/ui/app/components/page-size-select.js
deleted file mode 100644
index 6d35c398e64..00000000000
--- a/ui/app/components/page-size-select.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-
-export default class PageSizeSelect extends Component {
- @service userSettings;
-
- tagName = '';
- pageSizeOptions = [10, 25, 50];
-
- onChange() {}
-}
diff --git a/ui/app/components/placement-failure.gjs b/ui/app/components/placement-failure.gjs
new file mode 100644
index 00000000000..6a3494d8018
--- /dev/null
+++ b/ui/app/components/placement-failure.gjs
@@ -0,0 +1,119 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { HdsBadge } from '@hashicorp/design-system-components/components';
+
+const isZero = (value) => value === 0;
+const plusOne = (value) => value + 1;
+const pluralizedNode = (count) => (count === 1 ? 'node' : 'nodes');
+
+export default class PlacementFailure extends Component {
+ get placementFailures() {
+ return this.args.taskGroup?.placementFailures ?? this.args.failedTGAlloc;
+ }
+
+
+ {{#if this.placementFailures}}
+ {{#let this.placementFailures as |failures|}}
+
+ {{failures.name}}
+
+
+
+
+ {{#if (isZero failures.nodesEvaluated)}}
+ -
+ No nodes were eligible for evaluation
+
+ {{/if}}
+
+ {{#each-in failures.nodesAvailable as |datacenter available|}}
+ {{#if (isZero available)}}
+ -
+ No nodes are available in datacenter
+ {{datacenter}}
+
+ {{/if}}
+ {{/each-in}}
+
+ {{#each-in failures.classFiltered as |class count|}}
+ -
+ Class
+ {{class}}
+ filtered
+ {{count}}
+ {{pluralizedNode count}}
+
+ {{/each-in}}
+
+ {{#each-in failures.constraintFiltered as |constraint count|}}
+ -
+ Constraint
+
{{constraint}}
+ filtered
+ {{count}}
+ {{pluralizedNode count}}
+
+ {{/each-in}}
+
+ {{#if failures.nodesExhausted}}
+ -
+ Resources exhausted on
+ {{failures.nodesExhausted}}
+ {{pluralizedNode failures.nodesExhausted}}
+
+ {{/if}}
+
+ {{#each-in failures.classExhausted as |class count|}}
+ -
+ Class
+ {{class}}
+ exhausted on
+ {{count}}
+ {{pluralizedNode count}}
+
+ {{/each-in}}
+
+ {{#each-in failures.dimensionExhausted as |dimension count|}}
+ -
+ Dimension
+ {{dimension}}
+ exhausted on
+ {{count}}
+ {{pluralizedNode count}}
+
+ {{/each-in}}
+
+ {{#each-in failures.quotaExhausted as |quota dimension|}}
+ -
+ Quota limit hit
+ {{dimension}}
+
+ {{/each-in}}
+
+ {{#each-in failures.scores as |name score|}}
+ -
+ Score
+ {{name}}
+ =
+ {{score}}
+
+ {{/each-in}}
+
+ {{/let}}
+ {{/if}}
+
+}
diff --git a/ui/app/components/placement-failure.hbs b/ui/app/components/placement-failure.hbs
deleted file mode 100644
index c2ecaaf883a..00000000000
--- a/ui/app/components/placement-failure.hbs
+++ /dev/null
@@ -1,80 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.placementFailures}}
- {{#let this.placementFailures as |failures|}}
-
- {{this.placementFailures.name}}
-
-
-
- {{#if (eq failures.nodesEvaluated 0)}}
- - No nodes were
- eligible for evaluation
- {{/if}}
- {{#each-in failures.nodesAvailable as |datacenter available|}}
- {{#if (eq available 0)}}
- - No
- nodes are available in datacenter
- {{datacenter}}
- {{/if}}
- {{/each-in}}
- {{#each-in failures.classFiltered as |class count|}}
- - Class
- {{class}}
- filtered
- {{count}}
- {{pluralize "node" count}}
- {{/each-in}}
- {{#each-in failures.constraintFiltered as |constraint count|}}
- - Constraint
-
{{constraint}}
- filtered
- {{count}}
- {{pluralize "node" count}}
- {{/each-in}}
- {{#if failures.nodesExhausted}}
- - Resources exhausted on
- {{failures.nodesExhausted}}
- {{pluralize "node" failures.nodesExhausted}}
- {{/if}}
- {{#each-in failures.classExhausted as |class count|}}
- - Class
- {{class}}
- exhausted on
- {{count}}
- {{pluralize "node" count}}
- {{/each-in}}
- {{#each-in failures.dimensionExhausted as |dimension count|}}
- - Dimension
- {{dimension}}
- exhausted on
- {{count}}
- {{pluralize "node" count}}
- {{/each-in}}
- {{#each-in failures.quotaExhausted as |quota dimension|}}
- - Quota limit
- hit
- {{dimension}}
- {{/each-in}}
- {{#each-in failures.scores as |name score|}}
- - Score
- {{name}}
- =
- {{score}}
- {{/each-in}}
-
- {{/let}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/placement-failure.js b/ui/app/components/placement-failure.js
deleted file mode 100644
index 8f69c9aab53..00000000000
--- a/ui/app/components/placement-failure.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { or } from '@ember/object/computed';
-import classic from 'ember-classic-decorator';
-
-@classic
-export default class PlacementFailure extends Component {
- // Either provide a taskGroup or a failedTGAlloc
- taskGroup = null;
- failedTGAlloc = null;
-
- @or('taskGroup.placementFailures', 'failedTGAlloc') placementFailures;
-}
diff --git a/ui/app/components/plugin-allocation-row.gjs b/ui/app/components/plugin-allocation-row.gjs
new file mode 100644
index 00000000000..6250ef392b2
--- /dev/null
+++ b/ui/app/components/plugin-allocation-row.gjs
@@ -0,0 +1,231 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { tracked } from '@glimmer/tracking';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { LinkTo } from '@ember/routing';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import { or } from 'ember-truth-helpers';
+import AllocationRow from 'nomad-ui/components/allocation-row';
+import AllocationStat from 'nomad-ui/components/allocation-stat';
+import Tooltip from 'nomad-ui/components/tooltip';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker';
+
+export default class PluginAllocationRow extends AllocationRow {
+ @tracked allocation = null;
+
+ buildStatsTracker(allocation) {
+ if (!allocation?.isRunning) {
+ return null;
+ }
+
+ return AllocationStatsTracker.create({
+ fetch: (url) => this.token.authorizedRequest(url),
+ allocation,
+ });
+ }
+
+ syncPluginAllocation = () => {
+ this.allocation = null;
+ this.setAllocation();
+ };
+
+ updateStatsTracker = () => {
+ this.statsTracker = this.buildStatsTracker(this.allocation);
+
+ if (this.allocation) {
+ this.qualifyAllocation();
+ } else {
+ this.fetchStats.cancelAll();
+ }
+ };
+
+ qualifyAllocation = async () => {
+ const allocation = this.allocation;
+ if (!allocation) {
+ return;
+ }
+
+ if (allocation.isPartial) {
+ await this.store.findRecord('allocation', allocation.id, {
+ backgroundReload: false,
+ });
+ }
+
+ if (allocation.get('job.isPending')) {
+ await allocation.get('job');
+ } else if (!allocation.get('taskGroup')) {
+ const job = allocation.get('job.content');
+ if (job.isPartial) {
+ await job.reload();
+ }
+ }
+
+ this.fetchStats.perform();
+ };
+
+ setAllocation = async () => {
+ if (this.args.pluginAllocation && !this.allocation) {
+ const allocation = await this.args.pluginAllocation.getAllocation();
+ if (!this.isDestroyed) {
+ this.allocation = allocation;
+ this.updateStatsTracker();
+ }
+ }
+ };
+
+
+
+ {{#if this.allocation}}
+
+ {{#if this.allocation.unhealthyDrivers.length}}
+
+
+
+ {{/if}}
+ {{#if this.allocation.nextAllocation}}
+
+
+
+ {{/if}}
+ {{#if this.allocation.wasPreempted}}
+
+
+
+ {{/if}}
+
+
+
+
+ {{this.allocation.shortId}}
+
+
+
+
+
+ {{formatMonthTs this.allocation.createTime short=true}}
+
+
+
+
+
+ {{momentFromNow this.allocation.modifyTime}}
+
+
+
+
+
+
+ {{if @pluginAllocation.healthy "Healthy" "Unhealthy"}}
+
+
+
+
+
+
+ {{this.allocation.node.shortId}}
+
+
+
+
+ {{#if
+ (or this.allocation.job.isPending this.allocation.job.isReloading)
+ }}
+ ...
+ {{else}}
+ {{this.allocation.job.name}}
+ /
+ {{this.allocation.taskGroup.name}}
+ {{/if}}
+
+ {{this.allocation.jobVersion}}
+ {{if
+ this.allocation.taskGroup.volumes.length
+ "Yes"
+ }}
+
+
+
+
+
+
+
+ {{else}}
+ …
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/plugin-allocation-row.hbs b/ui/app/components/plugin-allocation-row.hbs
deleted file mode 100644
index 29723a2d135..00000000000
--- a/ui/app/components/plugin-allocation-row.hbs
+++ /dev/null
@@ -1,137 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.allocation}}
-
- {{#if this.allocation.unhealthyDrivers.length}}
-
-
-
- {{/if}}
- {{#if this.allocation.nextAllocation}}
-
-
-
- {{/if}}
- {{#if this.allocation.wasPreempted}}
-
-
-
- {{/if}}
-
-
-
-
- {{this.allocation.shortId}}
-
-
-
-
-
- {{format-month-ts this.allocation.createTime short=true}}
-
-
-
-
-
- {{moment-from-now this.allocation.modifyTime}}
-
-
-
-
-
-
- {{if this.pluginAllocation.healthy "Healthy" "Unhealthy"}}
-
-
-
-
-
-
- {{this.allocation.node.shortId}}
-
-
-
-
- {{#if (or this.allocation.job.isPending this.allocation.job.isReloading)}}
- ...
- {{else}}
- {{this.allocation.job.name}}
- /
- {{this.allocation.taskGroup.name}}
- {{/if}}
-
- {{this.allocation.jobVersion}}
- {{if
- this.allocation.taskGroup.volumes.length
- "Yes"
- }}
-
-
-
-
-
-
-
-{{else}}
- …
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/plugin-allocation-row.js b/ui/app/components/plugin-allocation-row.js
deleted file mode 100644
index 426e09195ea..00000000000
--- a/ui/app/components/plugin-allocation-row.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import AllocationRow from 'nomad-ui/components/allocation-row';
-import classic from 'ember-classic-decorator';
-import { attributeBindings } from '@ember-decorators/component';
-
-@classic
-@attributeBindings(
- 'data-test-controller-allocation',
- 'data-test-node-allocation',
-)
-export default class PluginAllocationRow extends AllocationRow {
- pluginAllocation = null;
- allocation = null;
-
- didReceiveAttrs() {
- // Allocation is always set through pluginAllocation
- this.set('allocation', null);
- this.setAllocation();
- }
-
- // The allocation for the plugin's controller or storage plugin needs
- // to be imperatively fetched since these plugins are Fragments which
- // can't have relationships.
- async setAllocation() {
- if (this.pluginAllocation && !this.allocation) {
- const allocation = await this.pluginAllocation.getAllocation();
- if (!this.isDestroyed) {
- this.set('allocation', allocation);
- this.updateStatsTracker();
- }
- }
- }
-}
diff --git a/ui/app/components/plugin-subnav.gjs b/ui/app/components/plugin-subnav.gjs
new file mode 100644
index 00000000000..46ddcb49c1c
--- /dev/null
+++ b/ui/app/components/plugin-subnav.gjs
@@ -0,0 +1,44 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class PluginSubnav extends Component {
+ @service keyboard;
+
+
+
+
+}
diff --git a/ui/app/components/plugin-subnav.hbs b/ui/app/components/plugin-subnav.hbs
deleted file mode 100644
index fd0992e017e..00000000000
--- a/ui/app/components/plugin-subnav.hbs
+++ /dev/null
@@ -1,24 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/plugin-subnav.js b/ui/app/components/plugin-subnav.js
deleted file mode 100644
index 6836c7f143f..00000000000
--- a/ui/app/components/plugin-subnav.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class PluginSubnavComponent extends Component {
- @service keyboard;
-}
diff --git a/ui/app/components/policy-editor.gjs b/ui/app/components/policy-editor.gjs
new file mode 100644
index 00000000000..d212487d8a7
--- /dev/null
+++ b/ui/app/components/policy-editor.gjs
@@ -0,0 +1,158 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+import { hash } from '@ember/helper';
+import { service } from '@ember/service';
+import can from 'ember-can/helpers/can';
+import {
+ HdsButton,
+ HdsFormTextInputField,
+} from '@hashicorp/design-system-components/components';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class PolicyEditor extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+
+ updatePolicyRules = (value) => {
+ this.args.policy?.set?.('rules', value);
+ };
+
+ updatePolicyName = ({ target: { value } }) => {
+ this.args.policy?.set?.('name', value);
+ };
+
+ updatePolicyDescription = ({ target: { value } }) => {
+ this.args.policy?.set?.('description', value);
+ };
+
+ save = async (event) => {
+ event?.preventDefault?.();
+
+ const policy = this.args.policy;
+
+ try {
+ const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
+ if (!policy?.name?.match(nameRegex)) {
+ throw new Error(
+ 'Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.',
+ );
+ }
+
+ const shouldRedirectAfterSave = policy.isNew;
+
+ // Because we set the ID for adapter/serialization reasons just before save here,
+ // that becomes a barrier to our Unique Name validation. So we explicitly exclude
+ // the current policy when checking for uniqueness.
+ if (
+ policy.isNew &&
+ this.store
+ .peekAll('policy')
+ .filter((existingPolicy) => existingPolicy !== policy)
+ .findBy('name', policy.name)
+ ) {
+ throw new Error(`A policy with name ${policy.name} already exists.`);
+ }
+
+ policy.set('id', policy.name);
+ await policy.save();
+
+ this.notifications.add({
+ title: 'Policy Saved',
+ color: 'success',
+ });
+
+ if (shouldRedirectAfterSave) {
+ this.router.transitionTo('administration.policies.policy', policy.id);
+ }
+ } catch (err) {
+ const message = err.errors?.length
+ ? messageFromAdapterError(err)
+ : err.message || 'Unknown Error';
+
+ this.notifications.add({
+ title: `Error creating Policy ${policy?.name}`,
+ message,
+ color: 'critical',
+ sticky: true,
+ });
+ }
+ };
+
+
+
+
+}
diff --git a/ui/app/components/policy-editor.hbs b/ui/app/components/policy-editor.hbs
deleted file mode 100644
index 7cc0e03e390..00000000000
--- a/ui/app/components/policy-editor.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js
deleted file mode 100644
index 557aa8ed935..00000000000
--- a/ui/app/components/policy-editor.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class PolicyEditorComponent extends Component {
- @service notifications;
- @service router;
- @service store;
-
- @alias('args.policy') policy;
-
- @action updatePolicyRules(value) {
- this.policy.set('rules', value);
- }
-
- @action updatePolicyName({ target: { value } }) {
- this.policy.set('name', value);
- }
-
- @action async save(e) {
- if (e instanceof Event) {
- e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault()
- }
- try {
- const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
- if (!this.policy.name?.match(nameRegex)) {
- throw new Error(
- `Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.`,
- );
- }
- const shouldRedirectAfterSave = this.policy.isNew;
- // Because we set the ID for adapter/serialization reasons just before save here,
- // that becomes a barrier to our Unique Name validation. So we explicltly exclude
- // the current policy when checking for uniqueness.
- if (
- this.policy.isNew &&
- this.store
- .peekAll('policy')
- .filter((policy) => policy !== this.policy)
- .findBy('name', this.policy.name)
- ) {
- throw new Error(
- `A policy with name ${this.policy.name} already exists.`,
- );
- }
- this.policy.set('id', this.policy.name);
- await this.policy.save();
-
- this.notifications.add({
- title: 'Policy Saved',
- color: 'success',
- });
-
- if (shouldRedirectAfterSave) {
- this.router.transitionTo(
- 'administration.policies.policy',
- this.policy.id,
- );
- }
- } catch (err) {
- let message = err.errors?.length
- ? messageFromAdapterError(err)
- : err.message || 'Unknown Error';
-
- this.notifications.add({
- title: `Error creating Policy ${this.policy.name}`,
- message,
- color: 'critical',
- sticky: true,
- });
- }
- }
-}
diff --git a/ui/app/components/popover-menu.gjs b/ui/app/components/popover-menu.gjs
new file mode 100644
index 00000000000..7291b29cb41
--- /dev/null
+++ b/ui/app/components/popover-menu.gjs
@@ -0,0 +1,100 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { fn, concat } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import BasicDropdown from 'ember-basic-dropdown/components/basic-dropdown';
+
+const TAB = 9;
+const ARROW_DOWN = 40;
+const FOCUSABLE = [
+ 'a:not([disabled])',
+ 'button:not([disabled])',
+ 'input:not([disabled]):not([type="hidden"])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([disabled]):not([tabindex="-1"])',
+].join(', ');
+
+export default class PopoverMenu extends Component {
+ @tracked isOpen = false;
+
+ dropdown = null;
+
+ capture = (dropdown) => {
+ // A direct dropdown reference is required for close/reposition controls.
+ this.dropdown = dropdown;
+ };
+
+ handleOpen = (dropdown) => {
+ this.isOpen = true;
+ this.capture(dropdown);
+ };
+
+ handleClose = () => {
+ this.isOpen = false;
+ };
+
+ openOnArrowDown = (dropdown, event) => {
+ if (!this.isOpen && event.keyCode === ARROW_DOWN) {
+ dropdown.actions.open(event);
+ event.preventDefault();
+ return;
+ }
+
+ if (
+ !this.isOpen ||
+ (event.keyCode !== TAB && event.keyCode !== ARROW_DOWN)
+ ) {
+ return;
+ }
+
+ const optionsId = event.currentTarget?.getAttribute('aria-owns');
+ if (!optionsId) return;
+
+ const popoverContentEl = document.querySelector(`#${optionsId}`);
+ const firstFocusableElement = popoverContentEl?.querySelector(FOCUSABLE);
+
+ if (firstFocusableElement) {
+ firstFocusableElement.focus();
+ event.preventDefault();
+ }
+ };
+
+
+
+
+ {{#let dd.Trigger dd.Content as |Trigger Content|}}
+
+ {{@label}}
+
+
+
+ {{yield dd}}
+
+ {{/let}}
+
+
+
+}
diff --git a/ui/app/components/popover-menu.hbs b/ui/app/components/popover-menu.hbs
deleted file mode 100644
index a6cd1a30b22..00000000000
--- a/ui/app/components/popover-menu.hbs
+++ /dev/null
@@ -1,31 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{this.label}}
-
-
-
- {{yield dd}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/popover-menu.js b/ui/app/components/popover-menu.js
deleted file mode 100644
index 9a51dfcdae1..00000000000
--- a/ui/app/components/popover-menu.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { scheduleOnce } from '@ember/runloop';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-const TAB = 9;
-const ARROW_DOWN = 40;
-const FOCUSABLE = [
- 'a:not([disabled])',
- 'button:not([disabled])',
- 'input:not([disabled]):not([type="hidden"])',
- 'textarea:not([disabled])',
- '[tabindex]:not([disabled]):not([tabindex="-1"])',
-].join(', ');
-
-@classic
-@classNames('popover')
-export default class PopoverMenu extends Component {
- triggerClass = '';
- isOpen = false;
- isDisabled = false;
- label = '';
-
- dropdown = null;
-
- @action
- capture(dropdown) {
- // It's not a good idea to grab a dropdown reference like this, but it's necessary
- // in order to invoke dropdown.actions.close in traverseList as well as
- // dropdown.actions.reposition when the label or selection length changes.
- this.set('dropdown', dropdown);
- }
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- const dropdown = this.dropdown;
- if (this.isOpen && dropdown) {
- scheduleOnce('afterRender', this, this.repositionDropdown);
- }
- }
-
- repositionDropdown() {
- this.dropdown.actions.reposition();
- }
-
- @action
- openOnArrowDown(dropdown, e) {
- if (!this.isOpen && e.keyCode === ARROW_DOWN) {
- dropdown.actions.open(e);
- e.preventDefault();
- } else if (this.isOpen && (e.keyCode === TAB || e.keyCode === ARROW_DOWN)) {
- const optionsId = this.element
- .querySelector('.popover-trigger')
- .getAttribute('aria-owns');
- const popoverContentEl = document.querySelector(`#${optionsId}`);
- const firstFocusableElement = popoverContentEl.querySelector(FOCUSABLE);
-
- if (firstFocusableElement) {
- firstFocusableElement.focus();
- e.preventDefault();
- }
- }
- }
-}
diff --git a/ui/app/components/primary-metric/allocation.gjs b/ui/app/components/primary-metric/allocation.gjs
new file mode 100644
index 00000000000..9b6752cabe1
--- /dev/null
+++ b/ui/app/components/primary-metric/allocation.gjs
@@ -0,0 +1,165 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { assert } from '@ember/debug';
+import { service } from '@ember/service';
+import { task, timeout } from 'ember-concurrency';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { eq } from 'ember-truth-helpers';
+import ENV from 'nomad-ui/config/environment';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+import reverse from '@nullvoxpopuli/ember-composable-helpers/helpers/reverse';
+import PrimaryMetricCurrentValue from 'nomad-ui/components/primary-metric/current-value';
+import StatsTimeSeries from 'nomad-ui/components/stats-time-series';
+
+export default class AllocationPrimaryMetric extends Component {
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ get metric() {
+ assert('metric is a required argument', this.args.metric);
+ return this.args.metric;
+ }
+
+ get allocation() {
+ return this.args.allocation;
+ }
+
+ get tracker() {
+ return this.statsTrackersRegistry.getTracker(this.allocation);
+ }
+
+ get data() {
+ if (!this.tracker) return [];
+ return this.tracker[this.metric];
+ }
+
+ get series() {
+ if (!this.tracker?.tasks) {
+ return [];
+ }
+
+ return this.tracker.tasks
+ .map((task) => ({
+ name: task.task,
+ data: task[this.metric],
+ }))
+ .reverse();
+ }
+
+ get reservedAmount() {
+ if (!this.tracker) return null;
+ if (this.metric === 'cpu') return this.tracker.reservedCPU;
+ if (this.metric === 'memory') return this.tracker.reservedMemory;
+ return null;
+ }
+
+ get chartClass() {
+ if (this.metric === 'cpu') return 'is-info';
+ if (this.metric === 'memory') return 'is-danger';
+ return 'is-primary';
+ }
+
+ get colorScale() {
+ if (this.metric === 'cpu') return 'blues';
+ if (this.metric === 'memory') return 'reds';
+ return 'ordinal';
+ }
+
+ poller = task(async () => {
+ do {
+ this.tracker?.poll?.perform?.();
+ await timeout(100);
+ } while (ENV.environment !== 'test');
+ });
+
+ start = () => {
+ if (this.tracker) this.poller.perform();
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.poller.cancelAll();
+ this.tracker?.signalPause?.perform?.();
+ }
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ CPU
+ {{else if (eq this.metric "memory")}}
+ Memory
+ {{else}}
+ {{this.metric}}
+ {{/if}}
+
+
+
+ <:svg as |c|>
+ {{#each (reverse this.series) as |series idx|}}
+
+ {{/each}}
+
+ <:after as |c|>
+
+
+ {{series.name}}
+ {{#if (eq this.metric "cpu")}}
+ {{formatScheduledHertz
+ datum.datum.used
+ }}
+ {{else if (eq this.metric "memory")}}
+ {{formatScheduledBytes
+ datum.datum.used
+ }}
+ {{else}}
+ {{datum.formatttedY}}
+ {{/if}}
+
+
+
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ {{formatScheduledHertz this.data.lastObject.used}}
+ /
+ {{formatScheduledHertz this.reservedAmount}}
+ Total
+ {{else if (eq this.metric "memory")}}
+ {{formatScheduledBytes this.data.lastObject.used}}
+ /
+ {{formatScheduledBytes this.reservedAmount start="MiB"}}
+ Total
+ {{else}}
+ {{this.data.lastObject.used}}
+ /
+ {{this.reservedAmount}}
+ Total
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/primary-metric/allocation.hbs b/ui/app/components/primary-metric/allocation.hbs
deleted file mode 100644
index 6c2bc3b14b3..00000000000
--- a/ui/app/components/primary-metric/allocation.hbs
+++ /dev/null
@@ -1,77 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#if (eq this.metric "cpu")}}
- CPU
- {{else if (eq this.metric "memory")}}
- Memory
- {{else}} {{this.metric}} {{/if}}
-
-
-
- <:svg as |c|>
- {{#each (reverse this.series) as |series idx|}}
-
- {{/each}}
-
- <:after as |c|>
-
-
- {{series.name}}
- {{#if (eq this.metric "cpu")}}
- {{format-scheduled-hertz
- datum.datum.used
- }}
- {{else if (eq this.metric "memory")}}
- {{format-scheduled-bytes
- datum.datum.used
- }}
- {{else}}
- {{datum.formatttedY}}
- {{/if}}
-
-
-
-
-
-
-
- {{#if (eq this.metric "cpu")}}
- {{format-scheduled-hertz this.data.lastObject.used}}
- /
- {{format-scheduled-hertz this.reservedAmount}}
- Total
- {{else if (eq this.metric "memory")}}
- {{format-scheduled-bytes this.data.lastObject.used}}
- /
- {{format-scheduled-bytes this.reservedAmount start="MiB"}}
- Total
- {{else}}
- {{this.data.lastObject.used}}
- /
- {{this.reservedAmount}}
- Total
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/primary-metric/allocation.js b/ui/app/components/primary-metric/allocation.js
deleted file mode 100644
index 9021e3c4a5f..00000000000
--- a/ui/app/components/primary-metric/allocation.js
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { task, timeout } from 'ember-concurrency';
-import { assert } from '@ember/debug';
-import { service } from '@ember/service';
-import { action, get, computed } from '@ember/object';
-import { dependentKeyCompat } from '@ember/object/compat';
-import ENV from 'nomad-ui/config/environment';
-
-export default class AllocationPrimaryMetric extends Component {
- @service('stats-trackers-registry') statsTrackersRegistry;
-
- /** Args
- allocation = null;
- metric null; (one of 'cpu' or 'memory'
- */
-
- get metric() {
- assert('metric is a required argument', this.args.metric);
- return this.args.metric;
- }
-
- @dependentKeyCompat
- get allocation() {
- return this.args.allocation;
- }
-
- @computed('allocation')
- get tracker() {
- return this.statsTrackersRegistry.getTracker(this.allocation);
- }
-
- get data() {
- if (!this.tracker) return [];
- return get(this, `tracker.${this.metric}`);
- }
-
- @computed('tracker.tasks.[]', 'metric')
- get series() {
- const ret = this.tracker.tasks
- .map((task) => ({
- name: task.task,
- data: task[this.metric],
- }))
- .reverse();
-
- return ret;
- }
-
- get reservedAmount() {
- if (this.metric === 'cpu') return this.tracker.reservedCPU;
- if (this.metric === 'memory') return this.tracker.reservedMemory;
- return null;
- }
-
- get chartClass() {
- if (this.metric === 'cpu') return 'is-info';
- if (this.metric === 'memory') return 'is-danger';
- return 'is-primary';
- }
-
- get colorScale() {
- if (this.metric === 'cpu') return 'blues';
- if (this.metric === 'memory') return 'reds';
- return 'ordinal';
- }
-
- @task(function* () {
- do {
- this.tracker.poll.perform();
- yield timeout(100);
- } while (ENV.environment !== 'test');
- })
- poller;
-
- @action
- start() {
- if (this.tracker) this.poller.perform();
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.poller.cancelAll();
- this.tracker.signalPause.perform();
- }
-}
diff --git a/ui/app/components/primary-metric/current-value.gjs b/ui/app/components/primary-metric/current-value.gjs
new file mode 100644
index 00000000000..77a0073304d
--- /dev/null
+++ b/ui/app/components/primary-metric/current-value.gjs
@@ -0,0 +1,32 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { or } from 'ember-truth-helpers';
+import formatPercentage from 'nomad-ui/helpers/format-percentage';
+
+export const PrimaryMetricCurrentValue =
+
+
+
+
+
+
+
+ {{formatPercentage
+ @percent
+ total=1
+ }}
+
+
+;
+
+export default PrimaryMetricCurrentValue;
diff --git a/ui/app/components/primary-metric/current-value.hbs b/ui/app/components/primary-metric/current-value.hbs
deleted file mode 100644
index db9aef7573f..00000000000
--- a/ui/app/components/primary-metric/current-value.hbs
+++ /dev/null
@@ -1,25 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
-
- {{format-percentage
- @percent
- total=1
- }}
-
-
\ No newline at end of file
diff --git a/ui/app/components/primary-metric/node.gjs b/ui/app/components/primary-metric/node.gjs
new file mode 100644
index 00000000000..af8a0180311
--- /dev/null
+++ b/ui/app/components/primary-metric/node.gjs
@@ -0,0 +1,145 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { assert } from '@ember/debug';
+import { service } from '@ember/service';
+import { task, timeout } from 'ember-concurrency';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { eq } from 'ember-truth-helpers';
+import ENV from 'nomad-ui/config/environment';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+import { formatScheduledHertz as formatScheduledHertzValue } from 'nomad-ui/utils/units';
+import PrimaryMetricCurrentValue from 'nomad-ui/components/primary-metric/current-value';
+import StatsTimeSeries from 'nomad-ui/components/stats-time-series';
+
+export default class NodePrimaryMetric extends Component {
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ get metric() {
+ assert('metric is a required argument', this.args.metric);
+ return this.args.metric;
+ }
+
+ get tracker() {
+ return this.statsTrackersRegistry.getTracker(this.args.node);
+ }
+
+ get data() {
+ if (!this.tracker) return [];
+ return this.tracker[this.metric];
+ }
+
+ get reservedAmount() {
+ if (!this.tracker) return null;
+ if (this.metric === 'cpu') return this.tracker.reservedCPU;
+ if (this.metric === 'memory') return this.tracker.reservedMemory;
+ return null;
+ }
+
+ get chartClass() {
+ if (this.metric === 'cpu') return 'is-info';
+ if (this.metric === 'memory') return 'is-danger';
+ return 'is-primary';
+ }
+
+ get reservedAnnotations() {
+ const reserved = this.args.node?.reserved;
+
+ if (this.metric === 'cpu' && reserved?.cpu) {
+ const cpu = reserved.cpu;
+ return [
+ {
+ label: `${formatScheduledHertzValue(cpu, 'MHz')} reserved`,
+ percent: cpu / this.reservedAmount,
+ },
+ ];
+ }
+
+ if (this.metric === 'memory' && reserved?.memory) {
+ const memory = reserved.memory;
+ return [
+ {
+ label: `${formatScheduledBytes(memory, 'MiB')} reserved`,
+ percent: memory / this.reservedAmount,
+ },
+ ];
+ }
+
+ return [];
+ }
+
+ poller = task(async () => {
+ do {
+ this.tracker?.poll?.perform?.();
+ await timeout(100);
+ } while (ENV.environment !== 'test');
+ });
+
+ start = () => {
+ if (this.tracker) this.poller.perform();
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.poller.cancelAll();
+ this.tracker?.signalPause?.perform?.();
+ }
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ CPU
+ {{else if (eq this.metric "memory")}}
+ Memory
+ {{else}}
+ {{this.metric}}
+ {{/if}}
+
+
+
+ <:after as |c|>
+ {{#if this.reservedAnnotations}}
+
+ {{/if}}
+
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ {{formatScheduledHertz this.data.lastObject.used}}
+ /
+ {{formatScheduledHertz this.reservedAmount}}
+ Total
+ {{else if (eq this.metric "memory")}}
+ {{formatScheduledBytes this.data.lastObject.used}}
+ /
+ {{formatScheduledBytes this.reservedAmount start="MiB"}}
+ Total
+ {{else}}
+ {{this.data.lastObject.used}}
+ /
+ {{this.reservedAmount}}
+ Total
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/primary-metric/node.hbs b/ui/app/components/primary-metric/node.hbs
deleted file mode 100644
index c07c9ce52e5..00000000000
--- a/ui/app/components/primary-metric/node.hbs
+++ /dev/null
@@ -1,54 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#if (eq this.metric "cpu")}}
- CPU
- {{else if (eq this.metric "memory")}}
- Memory
- {{else}} {{this.metric}} {{/if}}
-
-
-
- <:after as |c|>
- {{#if this.reservedAnnotations}}
-
- {{/if}}
-
-
-
-
-
- {{#if (eq this.metric "cpu")}}
- {{format-scheduled-hertz this.data.lastObject.used}}
- /
- {{format-scheduled-hertz this.reservedAmount}}
- Total
- {{else if (eq this.metric "memory")}}
- {{format-scheduled-bytes this.data.lastObject.used}}
- /
- {{format-scheduled-bytes this.reservedAmount start="MiB"}}
- Total
- {{else}}
- {{this.data.lastObject.used}}
- /
- {{this.reservedAmount}}
- Total
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/primary-metric/node.js b/ui/app/components/primary-metric/node.js
deleted file mode 100644
index f2b698fe0e9..00000000000
--- a/ui/app/components/primary-metric/node.js
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { task, timeout } from 'ember-concurrency';
-import { assert } from '@ember/debug';
-import { service } from '@ember/service';
-import { action, get } from '@ember/object';
-import {
- formatScheduledBytes,
- formatScheduledHertz,
-} from 'nomad-ui/utils/units';
-import ENV from 'nomad-ui/config/environment';
-
-export default class NodePrimaryMetric extends Component {
- @service('stats-trackers-registry') statsTrackersRegistry;
-
- /** Args
- node = null;
- metric null; (one of 'cpu' or 'memory')
- */
-
- get metric() {
- assert('metric is a required argument', this.args.metric);
- return this.args.metric;
- }
-
- get tracker() {
- return this.statsTrackersRegistry.getTracker(this.args.node);
- }
-
- get data() {
- if (!this.tracker) return [];
- return get(this, `tracker.${this.metric}`);
- }
-
- get reservedAmount() {
- if (this.metric === 'cpu') return this.tracker.reservedCPU;
- if (this.metric === 'memory') return this.tracker.reservedMemory;
- return null;
- }
-
- get chartClass() {
- if (this.metric === 'cpu') return 'is-info';
- if (this.metric === 'memory') return 'is-danger';
- return 'is-primary';
- }
-
- get reservedAnnotations() {
- if (this.metric === 'cpu' && get(this.args.node, 'reserved.cpu')) {
- const cpu = this.args.node.reserved.cpu;
- return [
- {
- label: `${formatScheduledHertz(cpu, 'MHz')} reserved`,
- percent: cpu / this.reservedAmount,
- },
- ];
- }
-
- if (this.metric === 'memory' && get(this.args.node, 'reserved.memory')) {
- const memory = this.args.node.reserved.memory;
- return [
- {
- label: `${formatScheduledBytes(memory, 'MiB')} reserved`,
- percent: memory / this.reservedAmount,
- },
- ];
- }
-
- return [];
- }
-
- @task(function* () {
- do {
- this.tracker.poll.perform();
- yield timeout(100);
- } while (ENV.environment !== 'test');
- })
- poller;
-
- @action
- start() {
- if (this.tracker) this.poller.perform();
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.poller.cancelAll();
- this.tracker.signalPause.perform();
- }
-}
diff --git a/ui/app/components/primary-metric/task.gjs b/ui/app/components/primary-metric/task.gjs
new file mode 100644
index 00000000000..4a8a81db585
--- /dev/null
+++ b/ui/app/components/primary-metric/task.gjs
@@ -0,0 +1,115 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { assert } from '@ember/debug';
+import { service } from '@ember/service';
+import { task, timeout } from 'ember-concurrency';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { eq } from 'ember-truth-helpers';
+import ENV from 'nomad-ui/config/environment';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+import PrimaryMetricCurrentValue from 'nomad-ui/components/primary-metric/current-value';
+import StatsTimeSeries from 'nomad-ui/components/stats-time-series';
+
+export default class TaskPrimaryMetric extends Component {
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ @tracked tracker = null;
+ @tracked taskState = null;
+
+ get metric() {
+ assert('metric is a required argument', this.args.metric);
+ return this.args.metric;
+ }
+
+ get data() {
+ if (!this.tracker) return [];
+ const task = this.tracker.tasks.findBy('task', this.taskState.name);
+ return task && task[this.metric];
+ }
+
+ get reservedAmount() {
+ if (!this.tracker) return null;
+ const task = this.tracker.tasks.findBy('task', this.taskState.name);
+ if (this.metric === 'cpu') return task.reservedCPU;
+ if (this.metric === 'memory') return task.reservedMemory;
+ return null;
+ }
+
+ get chartClass() {
+ if (this.metric === 'cpu') return 'is-info';
+ if (this.metric === 'memory') return 'is-danger';
+ return 'is-primary';
+ }
+
+ poller = task(async () => {
+ do {
+ this.tracker?.poll?.perform?.();
+ await timeout(100);
+ } while (ENV.environment !== 'test');
+ });
+
+ start = () => {
+ this.taskState = this.args.taskState;
+ this.tracker = this.statsTrackersRegistry.getTracker(
+ this.args.taskState.allocation,
+ );
+ this.poller.perform();
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.poller.cancelAll();
+ this.tracker?.signalPause?.perform?.();
+ }
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ CPU
+ {{else if (eq this.metric "memory")}}
+ Memory
+ {{else}}
+ {{this.metric}}
+ {{/if}}
+
+
+
+
+
+
+ {{#if (eq this.metric "cpu")}}
+ {{formatScheduledHertz this.data.lastObject.used}}
+ /
+ {{formatScheduledHertz this.reservedAmount}}
+ Total
+ {{else if (eq this.metric "memory")}}
+ {{formatScheduledBytes this.data.lastObject.used}}
+ /
+ {{formatScheduledBytes this.reservedAmount start="MiB"}}
+ Total
+ {{else}}
+ {{this.data.lastObject.used}}
+ /
+ {{this.reservedAmount}}
+ Total
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/primary-metric/task.hbs b/ui/app/components/primary-metric/task.hbs
deleted file mode 100644
index 83c7905ea89..00000000000
--- a/ui/app/components/primary-metric/task.hbs
+++ /dev/null
@@ -1,45 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{#if (eq this.metric "cpu")}}
- CPU
- {{else if (eq this.metric "memory")}}
- Memory
- {{else}} {{this.metric}} {{/if}}
-
-
-
-
-
-
- {{#if (eq this.metric "cpu")}}
- {{format-scheduled-hertz this.data.lastObject.used}}
- /
- {{format-scheduled-hertz this.reservedAmount}}
- Total
- {{else if (eq this.metric "memory")}}
- {{format-scheduled-bytes this.data.lastObject.used}}
- /
- {{format-scheduled-bytes this.reservedAmount start="MiB"}}
- Total
- {{else}}
- {{this.data.lastObject.used}}
- /
- {{this.reservedAmount}}
- Total
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/primary-metric/task.js b/ui/app/components/primary-metric/task.js
deleted file mode 100644
index ffe6a3e7197..00000000000
--- a/ui/app/components/primary-metric/task.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { task, timeout } from 'ember-concurrency';
-import { assert } from '@ember/debug';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-import ENV from 'nomad-ui/config/environment';
-
-export default class TaskPrimaryMetric extends Component {
- @service('stats-trackers-registry') statsTrackersRegistry;
-
- /** Args
- taskState = null;
- metric null; (one of 'cpu' or 'memory'
- */
-
- @tracked tracker = null;
- @tracked taskState = null;
-
- get metric() {
- assert('metric is a required argument', this.args.metric);
- return this.args.metric;
- }
-
- get data() {
- if (!this.tracker) return [];
- const task = this.tracker.tasks.findBy('task', this.taskState.name);
- return task && task[this.metric];
- }
-
- get reservedAmount() {
- if (!this.tracker) return null;
- const task = this.tracker.tasks.findBy('task', this.taskState.name);
- if (this.metric === 'cpu') return task.reservedCPU;
- if (this.metric === 'memory') return task.reservedMemory;
- return null;
- }
-
- get chartClass() {
- if (this.metric === 'cpu') return 'is-info';
- if (this.metric === 'memory') return 'is-danger';
- return 'is-primary';
- }
-
- @task(function* () {
- do {
- this.tracker.poll.perform();
- yield timeout(100);
- } while (ENV.environment !== 'test');
- })
- poller;
-
- @action
- start() {
- this.taskState = this.args.taskState;
- this.tracker = this.statsTrackersRegistry.getTracker(
- this.args.taskState.allocation,
- );
- this.poller.perform();
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.poller.cancelAll();
- this.tracker.signalPause.perform();
- }
-}
diff --git a/ui/app/components/profile-navbar-item.gjs b/ui/app/components/profile-navbar-item.gjs
new file mode 100644
index 00000000000..1f7d184a88e
--- /dev/null
+++ b/ui/app/components/profile-navbar-item.gjs
@@ -0,0 +1,85 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { on } from '@ember/modifier';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import {
+ HdsButton,
+ HdsDropdown,
+} from '@hashicorp/design-system-components/components';
+
+export default class ProfileNavbarItem extends Component {
+ @service token;
+ @service router;
+ @service store;
+
+ profileShortcut = ['g', 'p'];
+
+ get profileName() {
+ return this.token.selfToken?.name || 'Profile';
+ }
+
+ signOut = () => {
+ this.token.setProperties({
+ secret: undefined,
+ });
+
+ // Clear out all data to ensure only data the anonymous token is privileged to see is shown.
+ this.store.unloadAll();
+ this.token.reset();
+ this.router.transitionTo('jobs.index');
+ };
+
+
+ {{#if this.token.selfToken}}
+
+
+
+
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+
+ {{yield}}
+
+}
diff --git a/ui/app/components/profile-navbar-item.hbs b/ui/app/components/profile-navbar-item.hbs
deleted file mode 100644
index a4b85d01997..00000000000
--- a/ui/app/components/profile-navbar-item.hbs
+++ /dev/null
@@ -1,51 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.token.selfToken}}
-
-
-
-
-
-
-
-{{else}}
-
-
-
-{{/if}}
-
-{{yield}}
\ No newline at end of file
diff --git a/ui/app/components/profile-navbar-item.js b/ui/app/components/profile-navbar-item.js
deleted file mode 100644
index cac22ed0f0f..00000000000
--- a/ui/app/components/profile-navbar-item.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-
-export default class ProfileNavbarItemComponent extends Component {
- @service token;
- @service router;
- @service store;
-
- @action
- signOut() {
- this.token.setProperties({
- secret: undefined,
- });
-
- // Clear out all data to ensure only data the anonymous token is privileged to see is shown
- this.store.unloadAll();
- this.token.reset();
- this.router.transitionTo('jobs.index');
- }
-}
diff --git a/ui/app/components/providers/actors-relationships.js b/ui/app/components/providers/actors-relationships.gjs
similarity index 61%
rename from ui/app/components/providers/actors-relationships.js
rename to ui/app/components/providers/actors-relationships.gjs
index c11f23bc2fa..19a673085f5 100644
--- a/ui/app/components/providers/actors-relationships.js
+++ b/ui/app/components/providers/actors-relationships.gjs
@@ -5,7 +5,14 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
+import { hash } from '@ember/helper';
export default class ActorsRelationships extends Component {
@service actorsRelationships;
+
+
+ {{yield
+ (hash fns=this.actorsRelationships.fns data=this.actorsRelationships.data)
+ }}
+
}
diff --git a/ui/app/components/providers/actors-relationships.hbs b/ui/app/components/providers/actors-relationships.hbs
deleted file mode 100644
index 052adfd4c32..00000000000
--- a/ui/app/components/providers/actors-relationships.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield
- (hash fns=this.actorsRelationships.fns data=this.actorsRelationships.data)
-}}
\ No newline at end of file
diff --git a/ui/app/components/proxy-tag.gjs b/ui/app/components/proxy-tag.gjs
new file mode 100644
index 00000000000..49ab17cc20a
--- /dev/null
+++ b/ui/app/components/proxy-tag.gjs
@@ -0,0 +1,18 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+const ProxyTag =
+
+ Proxy
+
+;
+
+export default ProxyTag;
diff --git a/ui/app/components/proxy-tag.hbs b/ui/app/components/proxy-tag.hbs
deleted file mode 100644
index ab2d6fe0431..00000000000
--- a/ui/app/components/proxy-tag.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- Proxy
-
\ No newline at end of file
diff --git a/ui/app/components/proxy-tag.js b/ui/app/components/proxy-tag.js
deleted file mode 100644
index b830b687d83..00000000000
--- a/ui/app/components/proxy-tag.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class ProxyTag extends Component {}
diff --git a/ui/app/components/region-switcher.gjs b/ui/app/components/region-switcher.gjs
new file mode 100644
index 00000000000..95e6711b4c9
--- /dev/null
+++ b/ui/app/components/region-switcher.gjs
@@ -0,0 +1,73 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { or } from 'ember-truth-helpers';
+import PowerSelect from 'ember-power-select/components/power-select';
+import keyboardCommands from 'nomad-ui/helpers/keyboard-commands';
+
+export default class RegionSwitcher extends Component {
+ @service system;
+ @service router;
+ @service store;
+ @service token;
+
+ get sortedRegions() {
+ return this.system.regions.toArray().sort();
+ }
+
+ gotoRegion = async (region) => {
+ // Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion')
+ // is not something we can await, so we do it explicitly here.
+ this.system.set('activeRegion', region);
+ await this.token.fetchSelfTokenAndPolicies.perform().catch(() => {});
+
+ this.router.transitionTo({ queryParams: { region } });
+ };
+
+ get keyCommands() {
+ if (this.sortedRegions.length <= 1) {
+ return [];
+ }
+ return this.sortedRegions.map((region, iter) => {
+ return {
+ label: `Switch to ${region} region`,
+ pattern: ['r', `${iter + 1}`],
+ action: () => this.gotoRegion(region),
+ };
+ });
+ }
+
+
+ {{keyboardCommands this.keyCommands}}
+
+ {{#if this.system.shouldShowRegions}}
+
+
+ {{#if this.system.activeRegion}}
+ Region:
+ {{/if}}
+ {{region}}
+
+
+ {{else if this.system.hasNonDefaultRegion}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/region-switcher.hbs b/ui/app/components/region-switcher.hbs
deleted file mode 100644
index 79a431db835..00000000000
--- a/ui/app/components/region-switcher.hbs
+++ /dev/null
@@ -1,32 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{keyboard-commands this.keyCommands}}
-
-{{#if this.system.shouldShowRegions}}
-
-
- {{#if this.system.activeRegion}}
- Region:
- {{/if}}
- {{region}}
-
-
-{{else if this.system.hasNonDefaultRegion}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/region-switcher.js b/ui/app/components/region-switcher.js
deleted file mode 100644
index 7df4e002244..00000000000
--- a/ui/app/components/region-switcher.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { service } from '@ember/service';
-import classic from 'ember-classic-decorator';
-
-@classic
-export default class RegionSwitcher extends Component {
- @service system;
- @service router;
- @service store;
- @service token;
-
- @computed('system.regions')
- get sortedRegions() {
- return this.get('system.regions').toArray().sort();
- }
-
- @action
- async gotoRegion(region) {
- // Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion')
- // is not something we can await, so we do it explicitly here.
- this.system.set('activeRegion', region);
- await this.get('token.fetchSelfTokenAndPolicies').perform().catch();
-
- this.router.transitionTo({ queryParams: { region } });
- }
-
- get keyCommands() {
- if (this.sortedRegions.length <= 1) {
- return [];
- }
- return this.sortedRegions.map((region, iter) => {
- return {
- label: `Switch to ${region} region`,
- pattern: ['r', `${iter + 1}`],
- action: () => this.gotoRegion(region),
- };
- });
- }
-}
diff --git a/ui/app/components/reschedule-event-row.gjs b/ui/app/components/reschedule-event-row.gjs
new file mode 100644
index 00000000000..e7fdf23dcee
--- /dev/null
+++ b/ui/app/components/reschedule-event-row.gjs
@@ -0,0 +1,70 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { LinkTo } from '@ember/routing';
+import formatTs from 'nomad-ui/helpers/format-ts';
+
+const RescheduleEventRow =
+
+ {{#if @label}}
+ {{@label}}
+ {{/if}}
+ {{formatTs @time}}
+
+
+
+ {{#unless @linkToAllocation}}
+
+ This Allocation
+
+ {{/unless}}
+
+
+
+
+ {{@allocation.clientStatus}}
+
+
+
+
+
+ Allocation
+ {{#if @linkToAllocation}}
+
+ {{@allocation.shortId}}
+
+ {{else}}
+ {{@allocation.shortId}}
+ {{/if}}
+
+
+ Client
+
+
+ {{@allocation.node.id}}
+
+
+
+
+
+
+
+
+
+;
+
+export default RescheduleEventRow;
diff --git a/ui/app/components/reschedule-event-row.hbs b/ui/app/components/reschedule-event-row.hbs
deleted file mode 100644
index d94d7a892b9..00000000000
--- a/ui/app/components/reschedule-event-row.hbs
+++ /dev/null
@@ -1,65 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if this.label}}
- {{this.label}}
- {{/if}}
- {{format-ts this.time}}
-
-
-
- {{#unless this.linkToAllocation}}
-
- This Allocation
-
- {{/unless}}
-
-
-
-
- {{this.allocation.clientStatus}}
-
-
-
-
-
- Allocation
- {{#if this.linkToAllocation}}
-
- {{this.allocation.shortId}}
-
- {{else}}
- {{this.allocation.shortId}}
- {{/if}}
-
-
- Client
-
-
- {{this.allocation.node.id}}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/reschedule-event-row.js b/ui/app/components/reschedule-event-row.js
deleted file mode 100644
index b18e3c6d977..00000000000
--- a/ui/app/components/reschedule-event-row.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { computed as overridable } from 'ember-overridable-computed';
-import { service } from '@ember/service';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class RescheduleEventRow extends Component {
- @service store;
-
- // When given a string, the component will fetch the allocation
- allocationId = null;
-
- // An allocation can also be provided directly
- @overridable('allocationId', function () {
- if (this.allocationId) {
- return this.store.findRecord('allocation', this.allocationId);
- }
-
- return null;
- })
- allocation;
-
- time = null;
- linkToAllocation = true;
- label = '';
-}
diff --git a/ui/app/components/reschedule-event-timeline.gjs b/ui/app/components/reschedule-event-timeline.gjs
new file mode 100644
index 00000000000..1ae4da10ede
--- /dev/null
+++ b/ui/app/components/reschedule-event-timeline.gjs
@@ -0,0 +1,91 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { and, not } from 'ember-truth-helpers';
+import { service } from '@ember/service';
+import { reverse } from '@nullvoxpopuli/ember-composable-helpers';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import RescheduleEventRow from 'nomad-ui/components/reschedule-event-row';
+
+export default class RescheduleEventTimeline extends Component {
+ @service store;
+
+ allocationCache = new Map();
+
+ allocationForEvent = (event) => {
+ const id = event?.previousAllocationId;
+ if (!id) {
+ return null;
+ }
+
+ if (this.allocationCache.has(id)) {
+ return this.allocationCache.get(id);
+ }
+
+ const allocation =
+ this.store.peekRecord('allocation', id) ||
+ this.store.findRecord('allocation', id);
+ this.allocationCache.set(id, allocation);
+ return allocation;
+ };
+
+
+
+ {{#if @allocation.nextAllocation}}
+
+ {{/if}}
+ {{#if @allocation.hasStoppedRescheduling}}
+ -
+
+ Nomad has stopped attempting to reschedule this allocation.
+
+ {{/if}}
+ {{#if
+ (and
+ @allocation.followUpEvaluation.waitUntil
+ (not @allocation.nextAllocation)
+ )
+ }}
+ -
+
+ Nomad will attempt to reschedule
+
+ {{momentFromNow
+ @allocation.followUpEvaluation.waitUntil
+ interval=1000
+ }}
+
+
+ {{/if}}
+
+
+ {{#each (reverse @allocation.rescheduleEvents) as |event|}}
+
+ {{/each}}
+
+
+}
diff --git a/ui/app/components/reschedule-event-timeline.hbs b/ui/app/components/reschedule-event-timeline.hbs
deleted file mode 100644
index 88089fdd45d..00000000000
--- a/ui/app/components/reschedule-event-timeline.hbs
+++ /dev/null
@@ -1,56 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if @allocation.nextAllocation}}
-
- {{/if}}
- {{#if @allocation.hasStoppedRescheduling}}
- -
-
- Nomad has stopped attempting to reschedule this allocation.
-
- {{/if}}
- {{#if
- (and
- @allocation.followUpEvaluation.waitUntil (not @allocation.nextAllocation)
- )
- }}
- -
-
- Nomad will attempt to reschedule
-
- {{moment-from-now
- @allocation.followUpEvaluation.waitUntil
- interval=1000
- }}
-
-
- {{/if}}
-
-
- {{#each (reverse @allocation.rescheduleEvents) as |event|}}
-
- {{/each}}
-
\ No newline at end of file
diff --git a/ui/app/components/role-editor.gjs b/ui/app/components/role-editor.gjs
new file mode 100644
index 00000000000..4a556f89b41
--- /dev/null
+++ b/ui/app/components/role-editor.gjs
@@ -0,0 +1,195 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { array, fn, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import { not } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsFormTextInputField,
+ HdsTable,
+} from '@hashicorp/design-system-components/components';
+import can from 'ember-can/helpers/can';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class RoleEditor extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+
+ @tracked rolePolicies = [];
+
+ constructor() {
+ super(...arguments);
+ this.rolePolicies = this.args.role.policies.toArray() || [];
+ }
+
+ updateRoleName = ({ target: { value } }) => {
+ this.args.role.set('name', value);
+ };
+
+ updateRoleDescription = ({ target: { value } }) => {
+ this.args.role.set('description', value);
+ };
+
+ updateRolePolicies = (policy, event) => {
+ const { checked } = event.target;
+ if (checked) {
+ if (!this.rolePolicies.some((item) => item?.name === policy?.name)) {
+ this.rolePolicies = [...this.rolePolicies, policy];
+ }
+ } else {
+ this.rolePolicies = this.rolePolicies.filter(
+ (item) => item?.name !== policy?.name,
+ );
+ }
+ };
+
+ hasPolicySelected = (policyName) =>
+ this.rolePolicies.some((policy) => policy?.name === policyName);
+
+ save = async (event) => {
+ event?.preventDefault?.();
+
+ const role = this.args.role;
+
+ try {
+ const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
+ if (!role.name?.match(nameRegex)) {
+ throw new Error(
+ 'Role name must be 1-128 characters long and can only contain letters, numbers, and dashes.',
+ );
+ }
+
+ const shouldRedirectAfterSave = role.isNew;
+
+ if (role.isNew && this.store.peekRecord('role', role.name)) {
+ throw new Error(`A role with name ${role.name} already exists.`);
+ }
+
+ if (this.rolePolicies.length === 0) {
+ throw new Error('You must select at least one policy.');
+ }
+
+ role.policies = this.rolePolicies;
+
+ await role.save();
+
+ this.notifications.add({
+ title: 'Role Saved',
+ color: 'success',
+ });
+
+ if (shouldRedirectAfterSave) {
+ this.router.transitionTo('administration.roles.role', role.id);
+ }
+ } catch (err) {
+ const message = err.errors?.length
+ ? messageFromAdapterError(err)
+ : err.message || 'Unknown Error';
+
+ this.notifications.add({
+ title: `Error creating Role ${role.name}`,
+ message,
+ color: 'critical',
+ sticky: true,
+ });
+ }
+ };
+
+
+
+
+}
diff --git a/ui/app/components/role-editor.hbs b/ui/app/components/role-editor.hbs
deleted file mode 100644
index cedb2a81bac..00000000000
--- a/ui/app/components/role-editor.hbs
+++ /dev/null
@@ -1,84 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/role-editor.js b/ui/app/components/role-editor.js
deleted file mode 100644
index cb35a78db82..00000000000
--- a/ui/app/components/role-editor.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class RoleEditorComponent extends Component {
- @service notifications;
- @service router;
- @service store;
-
- @alias('args.role') role;
-
- @tracked rolePolicies = [];
-
- // when this renders, set up rolePOlicies
- constructor() {
- super(...arguments);
- this.rolePolicies = this.role.policies.toArray() || [];
- }
-
- @action updateRoleName({ target: { value } }) {
- this.role.set('name', value);
- }
-
- @action updateRolePolicies(policy, event) {
- let { checked } = event.target;
- if (checked) {
- this.rolePolicies.push(policy);
- } else {
- this.rolePolicies = this.rolePolicies.filter((p) => p !== policy);
- }
- }
-
- @action async save(e) {
- if (e instanceof Event) {
- e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault()
- }
- try {
- const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
- if (!this.role.name?.match(nameRegex)) {
- throw new Error(
- `Role name must be 1-128 characters long and can only contain letters, numbers, and dashes.`,
- );
- }
-
- const shouldRedirectAfterSave = this.role.isNew;
-
- if (this.role.isNew && this.store.peekRecord('role', this.role.name)) {
- throw new Error(`A role with name ${this.role.name} already exists.`);
- }
-
- // If no policies are selected, throw an error
- if (this.rolePolicies.length === 0) {
- throw new Error(`You must select at least one policy.`);
- }
-
- this.role.policies = this.rolePolicies;
-
- await this.role.save();
-
- this.notifications.add({
- title: 'Role Saved',
- color: 'success',
- });
-
- if (shouldRedirectAfterSave) {
- this.router.transitionTo('administration.roles.role', this.role.id);
- }
- } catch (err) {
- let message = err.errors?.length
- ? messageFromAdapterError(err)
- : err.message || 'Unknown Error';
-
- this.notifications.add({
- title: `Error creating Role ${this.role.name}`,
- message,
- color: 'critical',
- sticky: true,
- });
- }
- }
-}
diff --git a/ui/app/components/safe-link-to.js b/ui/app/components/safe-link-to.js
deleted file mode 100644
index 1f9fc59abad..00000000000
--- a/ui/app/components/safe-link-to.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { LinkComponent } from '@ember/legacy-built-in-components';
-import classic from 'ember-classic-decorator';
-
-// Necessary for programmatic routing away pages with s that contain @query properties.
-// (There's an issue with query param calculations in the new component that uses the router service)
-// https://github.com/emberjs/ember.js/issues/20051
-
-@classic
-export default class SafeLinkToComponent extends LinkComponent {}
diff --git a/ui/app/components/scale-events-accordion.gjs b/ui/app/components/scale-events-accordion.gjs
new file mode 100644
index 00000000000..c51d79d7daf
--- /dev/null
+++ b/ui/app/components/scale-events-accordion.gjs
@@ -0,0 +1,78 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { concat } from '@ember/helper';
+import {
+ HdsIcon,
+ HdsTooltipButton,
+} from '@hashicorp/design-system-components/components';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import JsonViewer from 'nomad-ui/components/json-viewer';
+import ListAccordion from 'nomad-ui/components/list-accordion';
+
+export const ScaleEventsAccordion =
+
+
+
+
+
+
+ {{#if a.item.error}}
+
+ {{/if}}
+
+
+ {{formatMonthTs a.item.time}}
+
+
+
+
+ {{#if a.item.hasCount}}
+
+ {{#if a.item.increased}}
+
+ {{else}}
+
+ {{/if}}
+
+
+ {{a.item.count}}
+
+ {{/if}}
+
+
+ {{a.item.message}}
+
+
+
+
+
+
+
+;
+
+export default ScaleEventsAccordion;
diff --git a/ui/app/components/scale-events-accordion.hbs b/ui/app/components/scale-events-accordion.hbs
deleted file mode 100644
index c4d6acf2e4f..00000000000
--- a/ui/app/components/scale-events-accordion.hbs
+++ /dev/null
@@ -1,62 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
- {{#if a.item.error}}
-
- {{/if}}
-
-
- {{format-month-ts a.item.time}}
-
-
-
-
- {{#if a.item.hasCount}}
-
- {{#if a.item.increased}}
-
- {{else}}
-
- {{/if}}
-
-
- {{a.item.count}}
-
- {{/if}}
-
-
- {{a.item.message}}
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/scale-events-chart.gjs b/ui/app/components/scale-events-chart.gjs
new file mode 100644
index 00000000000..0ad81706da1
--- /dev/null
+++ b/ui/app/components/scale-events-chart.gjs
@@ -0,0 +1,131 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import formatMonthTs from 'nomad-ui/helpers/format-month-ts';
+import JsonViewer from 'nomad-ui/components/json-viewer';
+import LineChart from 'nomad-ui/components/line-chart';
+
+export default class ScaleEventsChart extends Component {
+ @tracked activeEvent = null;
+
+ get data() {
+ const data = this.args.events.filterBy('hasCount').sortBy('time');
+
+ // Extend the domain of the chart to the current time.
+ data.push({
+ time: new Date(),
+ count: data[data.length - 1].count,
+ });
+
+ // Make sure the domain of the chart includes the first annotation.
+ const firstAnnotation = this.annotations.sortBy('time')[0];
+ if (firstAnnotation && firstAnnotation.time < data[0].time) {
+ data.unshift({
+ time: firstAnnotation.time,
+ count: data[0].count,
+ });
+ }
+
+ return data;
+ }
+
+ get annotations() {
+ return this.args.events.rejectBy('hasCount').map((ev, index) => ({
+ type: ev.error ? 'error' : 'info',
+ time: ev.time,
+ event: cloneScaleEvent(ev, index),
+ }));
+ }
+
+ toggleEvent = (ev) => {
+ if (this.activeEvent?.event?.uid === ev?.event?.uid) {
+ this.closeEventDetails();
+ } else {
+ this.activeEvent = ev;
+ }
+ };
+
+ closeEventDetails = () => {
+ this.activeEvent = null;
+ };
+
+
+
+ <:svg as |c|>
+
+
+ <:after as |c|>
+
+
+ {{datum.formattedX}}
+ {{datum.formattedY}}
+
+
+
+
+
+ {{#if this.activeEvent}}
+
+
+
+ {{#if this.activeEvent.event.error}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+
+
+
+
+ {{/if}}
+
+}
+
+function cloneValue(value) {
+ if (typeof structuredClone === 'function') {
+ return structuredClone(value);
+ }
+
+ return value == null ? value : JSON.parse(JSON.stringify(value));
+}
+
+function cloneScaleEvent(event, index) {
+ const fallbackUid = `${+event?.time || 0}:${event?.message || ''}:${index}`;
+
+ return {
+ uid: event?.uid ?? fallbackUid,
+ error: event?.error,
+ time: event?.time,
+ message: event?.message,
+ meta: cloneValue(event?.meta),
+ };
+}
diff --git a/ui/app/components/scale-events-chart.hbs b/ui/app/components/scale-events-chart.hbs
deleted file mode 100644
index 1bad301134a..00000000000
--- a/ui/app/components/scale-events-chart.hbs
+++ /dev/null
@@ -1,54 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- <:svg as |c|>
-
-
- <:after as |c|>
-
-
- {{datum.formattedX}}
- {{datum.formattedY}}
-
-
-
-
-
-{{#if this.activeEvent}}
-
-
-
- {{#if this.activeEvent.event.error}}
-
- {{else}}
-
- {{/if}}
-
-
-
-
-
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/scale-events-chart.js b/ui/app/components/scale-events-chart.js
deleted file mode 100644
index 18a233173f8..00000000000
--- a/ui/app/components/scale-events-chart.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { action, get } from '@ember/object';
-
-export default class ScaleEventsChart extends Component {
- /** Args
- events = []
- */
-
- @tracked activeEvent = null;
-
- get data() {
- const data = this.args.events.filterBy('hasCount').sortBy('time');
-
- // Extend the domain of the chart to the current time
- data.push({
- time: new Date(),
- count: data[data.length - 1].count,
- });
-
- // Make sure the domain of the chart includes the first annotation
- const firstAnnotation = this.annotations.sortBy('time')[0];
- if (firstAnnotation && firstAnnotation.time < data[0].time) {
- data.unshift({
- time: firstAnnotation.time,
- count: data[0].count,
- });
- }
-
- return data;
- }
-
- get annotations() {
- return this.args.events.rejectBy('hasCount').map((ev) => ({
- type: ev.error ? 'error' : 'info',
- time: ev.time,
- event: cloneScaleEvent(ev),
- }));
- }
-
- @action
- toggleEvent(ev) {
- if (
- this.activeEvent &&
- get(this.activeEvent, 'event.uid') === get(ev, 'event.uid')
- ) {
- this.closeEventDetails();
- } else {
- this.activeEvent = ev;
- }
- }
-
- closeEventDetails() {
- this.activeEvent = null;
- }
-}
-
-function cloneValue(value) {
- if (typeof structuredClone === 'function') {
- return structuredClone(value);
- }
-
- return value == null ? value : JSON.parse(JSON.stringify(value));
-}
-
-function cloneScaleEvent(event) {
- return {
- uid: get(event, 'uid'),
- error: get(event, 'error'),
- time: get(event, 'time'),
- message: get(event, 'message'),
- meta: cloneValue(get(event, 'meta')),
- };
-}
diff --git a/ui/app/components/search-box.gjs b/ui/app/components/search-box.gjs
new file mode 100644
index 00000000000..34b6389d937
--- /dev/null
+++ b/ui/app/components/search-box.gjs
@@ -0,0 +1,68 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { debounce } from '@ember/runloop';
+import { on } from '@ember/modifier';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import { didUpdate } from '@ember/render-modifiers';
+
+export default class SearchBox extends Component {
+ @tracked _searchTerm = null;
+
+ debounce = 150;
+
+ constructor() {
+ super(...arguments);
+ this.syncSearchTerm();
+ }
+
+ syncSearchTerm = () => {
+ this._searchTerm = this.args.searchTerm;
+ };
+
+ setSearchTerm = (e) => {
+ this._searchTerm = e.target.value;
+ debounce(this, this.updateSearch, this.debounce);
+ };
+
+ clear = () => {
+ this._searchTerm = '';
+ debounce(this, this.updateSearch, this.debounce);
+ };
+
+ updateSearch = () => {
+ const newTerm = this._searchTerm;
+ this.args.onChange?.(newTerm);
+ };
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/search-box.hbs b/ui/app/components/search-box.hbs
deleted file mode 100644
index 62af7d70275..00000000000
--- a/ui/app/components/search-box.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/search-box.js b/ui/app/components/search-box.js
deleted file mode 100644
index 6982b6af280..00000000000
--- a/ui/app/components/search-box.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { reads } from '@ember/object/computed';
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { debounce } from '@ember/runloop';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('search-box', 'field', 'has-addons')
-export default class SearchBox extends Component {
- // Passed to the component (mutable)
- searchTerm = null;
-
- // Used as a debounce buffer
- @reads('searchTerm') _searchTerm;
-
- // Used to throttle sets to searchTerm
- debounce = 150;
-
- // A hook that's called when the search value changes
- onChange() {}
-
- @action
- setSearchTerm(e) {
- this.set('_searchTerm', e.target.value);
- debounce(this, updateSearch, this.debounce);
- }
-
- @action
- clear() {
- this.set('_searchTerm', '');
- debounce(this, updateSearch, this.debounce);
- }
-}
-
-function updateSearch() {
- const newTerm = this._searchTerm;
- this.onChange(newTerm);
- this.set('searchTerm', newTerm);
-}
diff --git a/ui/app/components/sentinel-policy-editor.gjs b/ui/app/components/sentinel-policy-editor.gjs
new file mode 100644
index 00000000000..1a78c32d348
--- /dev/null
+++ b/ui/app/components/sentinel-policy-editor.gjs
@@ -0,0 +1,250 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import can from 'ember-can/helpers/can';
+import { eq } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsFormRadioGroup,
+ HdsFormTextInputField,
+ HdsLinkInline,
+} from '@hashicorp/design-system-components/components';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class SentinelPolicyEditor extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+
+ updatePolicy = (value) => {
+ this.args.policy.set('policy', value);
+ };
+
+ updatePolicyName = ({ target: { value } }) => {
+ this.args.policy.set('name', value);
+ };
+
+ updatePolicyDescription = ({ target: { value } }) => {
+ this.args.policy.set('description', value);
+ };
+
+ updatePolicyEnforcementLevel = ({ target: { id } }) => {
+ this.args.policy.set('enforcementLevel', id);
+ };
+
+ updatePolicyScope = ({ target: { id } }) => {
+ this.args.policy.set('scope', id);
+ };
+
+ save = async (event) => {
+ event?.preventDefault?.();
+
+ const policy = this.args.policy;
+
+ try {
+ const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
+ if (!policy.name?.match(nameRegex)) {
+ throw new Error(
+ 'Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.',
+ );
+ }
+ if (policy.description?.length > 256) {
+ throw new Error(
+ 'Policy description must be under 256 characters long.',
+ );
+ }
+
+ const shouldRedirectAfterSave = policy.isNew;
+
+ if (
+ policy.isNew &&
+ this.store
+ .peekAll('sentinel-policy')
+ .filter((existingPolicy) => existingPolicy !== policy)
+ .findBy('name', policy.name)
+ ) {
+ throw new Error(
+ `A sentinel policy with name ${policy.name} already exists.`,
+ );
+ }
+
+ policy.set('id', policy.name);
+ await policy.save();
+
+ this.notifications.add({
+ title: 'Sentinel Policy Saved',
+ color: 'success',
+ });
+
+ if (shouldRedirectAfterSave) {
+ this.router.transitionTo(
+ 'administration.sentinel-policies.policy',
+ policy.name,
+ );
+ }
+ } catch (err) {
+ const message = err.errors?.length
+ ? messageFromAdapterError(err)
+ : err.message || 'Unknown Error';
+
+ this.notifications.add({
+ title: `Error creating Sentinel Policy ${policy.name}`,
+ message,
+ color: 'critical',
+ sticky: true,
+ });
+ }
+ };
+
+
+
+
+}
diff --git a/ui/app/components/sentinel-policy-editor.hbs b/ui/app/components/sentinel-policy-editor.hbs
deleted file mode 100644
index 13956bac97a..00000000000
--- a/ui/app/components/sentinel-policy-editor.hbs
+++ /dev/null
@@ -1,139 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/sentinel-policy-editor.js b/ui/app/components/sentinel-policy-editor.js
deleted file mode 100644
index 89220c1f0e7..00000000000
--- a/ui/app/components/sentinel-policy-editor.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class SentinelPolicyEditorComponent extends Component {
- @service notifications;
- @service router;
- @service store;
-
- @alias('args.policy') policy;
-
- @action updatePolicy(value) {
- this.policy.set('policy', value);
- }
-
- @action updatePolicyName({ target: { value } }) {
- this.policy.set('name', value);
- }
-
- @action updatePolicyEnforcementLevel({ target: { id } }) {
- this.policy.set('enforcementLevel', id);
- }
-
- @action updatePolicyScope({ target: { id } }) {
- this.policy.set('scope', id);
- }
-
- @action async save(e) {
- if (e instanceof Event) {
- e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault()
- }
- try {
- const nameRegex = '^[a-zA-Z0-9-]{1,128}$';
- if (!this.policy.name?.match(nameRegex)) {
- throw new Error(
- `Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.`,
- );
- }
- if (this.policy.description?.length > 256) {
- throw new Error(
- `Policy description must be under 256 characters long.`,
- );
- }
-
- const shouldRedirectAfterSave = this.policy.isNew;
- // Because we set the ID for adapter/serialization reasons just before save here,
- // that becomes a barrier to our Unique Name validation. So we explicltly exclude
- // the current policy when checking for uniqueness.
- if (
- this.policy.isNew &&
- this.store
- .peekAll('sentinel-policy')
- .filter((policy) => policy !== this.policy)
- .findBy('name', this.policy.name)
- ) {
- throw new Error(
- `A sentinel policy with name ${this.policy.name} already exists.`,
- );
- }
- this.policy.set('id', this.policy.name);
- await this.policy.save();
-
- this.notifications.add({
- title: 'Sentinel Policy Saved',
- color: 'success',
- });
-
- if (shouldRedirectAfterSave) {
- this.router.transitionTo(
- 'administration.sentinel-policies.policy',
- this.policy.name,
- );
- }
- } catch (err) {
- let message = err.errors?.length
- ? messageFromAdapterError(err)
- : err.message || 'Unknown Error';
-
- this.notifications.add({
- title: `Error creating Sentinel Policy ${this.policy.name}`,
- message,
- color: 'critical',
- sticky: true,
- });
- }
- }
-}
diff --git a/ui/app/components/server-agent-row.gjs b/ui/app/components/server-agent-row.gjs
new file mode 100644
index 00000000000..e9220ee7adc
--- /dev/null
+++ b/ui/app/components/server-agent-row.gjs
@@ -0,0 +1,106 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { concat } from '@ember/helper';
+import { capitalize } from '@ember/string';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { HdsBadge } from '@hashicorp/design-system-components/components';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class ServerAgentRow extends Component {
+ // eslint-disable-next-line ember/no-private-routing-service
+ @service('-routing') _router;
+
+ get router() {
+ return this._router.router;
+ }
+
+ get isActive() {
+ const router = this.router;
+ const targetURL = router.generate('servers.server', this.args.agent);
+ const currentURL = `${router.get('rootURL').slice(0, -1)}${
+ router.get('currentURL').split('?')[0]
+ }`;
+
+ return currentURL.replace(/%40/g, '@') === targetURL.replace(/%40/g, '@');
+ }
+
+ goToAgent = (event) => {
+ const transition = () =>
+ this.router.transitionTo('servers.server', this.args.agent);
+ lazyClick([transition, event]);
+ };
+
+ get agentStatusColor() {
+ const agentStatus = this.args.agent?.status;
+ if (agentStatus === 'alive') {
+ return 'success';
+ } else if (agentStatus === 'failed') {
+ return 'critical';
+ } else if (agentStatus === 'leaving') {
+ return 'neutral';
+ } else if (agentStatus === 'left') {
+ return 'neutral';
+ } else {
+ return '';
+ }
+ }
+
+ get agentStatusText() {
+ return capitalize(this.args.agent?.status || '');
+ }
+
+
+
+ {{@agent.name}}
+
+
+
+
+
+
+ {{@agent.address}}
+ {{@agent.serfPort}}
+ {{@agent.datacenter}}
+ {{@agent.version}}
+
+
+}
diff --git a/ui/app/components/server-agent-row.hbs b/ui/app/components/server-agent-row.hbs
deleted file mode 100644
index 89d58505220..00000000000
--- a/ui/app/components/server-agent-row.hbs
+++ /dev/null
@@ -1,44 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{this.agent.name}}
-
-
-
-
-
-
-{{this.agent.address}}
-{{this.agent.serfPort}}
-{{this.agent.datacenter}}
-{{this.agent.version}}
\ No newline at end of file
diff --git a/ui/app/components/server-agent-row.js b/ui/app/components/server-agent-row.js
deleted file mode 100644
index 2f383d09a25..00000000000
--- a/ui/app/components/server-agent-row.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { lazyClick } from '../helpers/lazy-click';
-import {
- classNames,
- classNameBindings,
- tagName,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tr')
-@classNames('server-agent-row', 'is-interactive')
-@classNameBindings('isActive:is-active')
-export default class ServerAgentRow extends Component {
- // TODO Switch back to the router service once the service behaves more like Route
- // https://github.com/emberjs/ember.js/issues/15801
- // router: inject.service('router'),
- // eslint-disable-next-line ember/no-private-routing-service
- @service('-routing') _router;
- @alias('_router.router') router;
-
- agent = null;
-
- @computed('agent', 'router.currentURL')
- get isActive() {
- // TODO Switch back to the router service once the service behaves more like Route
- // https://github.com/emberjs/ember.js/issues/15801
- // const targetURL = this.get('router').urlFor('servers.server', this.get('agent'));
- // const currentURL = `${this.get('router.rootURL').slice(0, -1)}${this.get('router.currentURL')}`;
-
- const router = this.router;
- const targetURL = router.generate('servers.server', this.agent);
- const currentURL = `${router.get('rootURL').slice(0, -1)}${
- router.get('currentURL').split('?')[0]
- }`;
-
- // Account for potential URI encoding
- return currentURL.replace(/%40/g, '@') === targetURL.replace(/%40/g, '@');
- }
-
- @action
- goToAgent() {
- const transition = () =>
- this.router.transitionTo('servers.server', this.agent);
- lazyClick([transition, event]);
- }
-
- click() {
- this.goToAgent();
- }
-
- @computed('agent.status')
- get agentStatusColor() {
- let agentStatus = this.get('agent.status');
- if (agentStatus === 'alive') {
- return 'success';
- } else if (agentStatus === 'failed') {
- return 'critical';
- } else if (agentStatus === 'leaving') {
- return 'neutral';
- } else if (agentStatus === 'left') {
- return 'neutral';
- } else {
- return '';
- }
- }
-}
diff --git a/ui/app/components/server-subnav.gjs b/ui/app/components/server-subnav.gjs
new file mode 100644
index 00000000000..723c1e53a67
--- /dev/null
+++ b/ui/app/components/server-subnav.gjs
@@ -0,0 +1,43 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class ServerSubnav extends Component {
+ @service keyboard;
+
+
+
+
+}
diff --git a/ui/app/components/server-subnav.hbs b/ui/app/components/server-subnav.hbs
deleted file mode 100644
index 3cec559a142..00000000000
--- a/ui/app/components/server-subnav.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/server-subnav.js b/ui/app/components/server-subnav.js
deleted file mode 100644
index 66a4108feae..00000000000
--- a/ui/app/components/server-subnav.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { tagName } from '@ember-decorators/component';
-import { service } from '@ember/service';
-
-@tagName('')
-export default class ServerSubnav extends Component {
- @service keyboard;
-}
diff --git a/ui/app/components/service-status-bar.gjs b/ui/app/components/service-status-bar.gjs
new file mode 100644
index 00000000000..f666b990e2a
--- /dev/null
+++ b/ui/app/components/service-status-bar.gjs
@@ -0,0 +1,49 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import DistributionBar from 'nomad-ui/components/distribution-bar';
+
+export default class ServiceStatusBar extends Component {
+ get data() {
+ if (!this.args.status) {
+ return [];
+ }
+
+ const pending = this.args.status.pending || 0;
+ const failing = this.args.status.failure || 0;
+ const success = this.args.status.success || 0;
+
+ const [grey, red, green] = ['queued', 'failed', 'running'];
+
+ return [
+ {
+ label: 'Pending',
+ value: pending,
+ className: grey,
+ },
+ {
+ label: 'Failing',
+ value: failing,
+ className: red,
+ },
+ {
+ label: 'Success',
+ value: success,
+ className: green,
+ },
+ ];
+ }
+
+
+
+
+}
diff --git a/ui/app/components/service-status-bar.js b/ui/app/components/service-status-bar.js
deleted file mode 100644
index 33d5f1a0ac7..00000000000
--- a/ui/app/components/service-status-bar.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { computed } from '@ember/object';
-import DistributionBar from './distribution-bar';
-import { attributeBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@attributeBindings('data-test-service-status-bar')
-export default class ServiceStatusBar extends DistributionBar {
- layoutName = 'components/distribution-bar';
-
- status = null;
-
- 'data-test-service-status-bar' = true;
-
- @computed('status.{failure,pending,success}')
- get data() {
- if (!this.status) {
- return [];
- }
-
- const pending = this.status.pending || 0;
- const failing = this.status.failure || 0;
- const success = this.status.success || 0;
-
- const [grey, red, green] = ['queued', 'failed', 'running'];
-
- return [
- {
- label: 'Pending',
- value: pending,
- className: grey,
- },
- {
- label: 'Failing',
- value: failing,
- className: red,
- },
- {
- label: 'Success',
- value: success,
- className: green,
- },
- ];
- }
-}
diff --git a/ui/app/components/service-status-indicator.gjs b/ui/app/components/service-status-indicator.gjs
new file mode 100644
index 00000000000..3173f57134e
--- /dev/null
+++ b/ui/app/components/service-status-indicator.gjs
@@ -0,0 +1,28 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { eq } from 'ember-truth-helpers';
+import formatTs from 'nomad-ui/helpers/format-ts';
+
+export const ServiceStatusIndicator =
+
+ {{#if (eq @check.Status "failure")}}
+ ×
+ {{/if}}
+
+
+
+;
+
+export default ServiceStatusIndicator;
diff --git a/ui/app/components/service-status-indicator.hbs b/ui/app/components/service-status-indicator.hbs
deleted file mode 100644
index 94e3655b025..00000000000
--- a/ui/app/components/service-status-indicator.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#if (eq @check.Status "failure")}}
- ×
- {{/if}}
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/single-select-dropdown.gjs b/ui/app/components/single-select-dropdown.gjs
new file mode 100644
index 00000000000..6882eb0edc5
--- /dev/null
+++ b/ui/app/components/single-select-dropdown.gjs
@@ -0,0 +1,49 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import PowerSelect from 'ember-power-select/components/power-select';
+
+export default class SingleSelectDropdown extends Component {
+ get activeOption() {
+ return this.args.options?.find?.(
+ (option) => option.key === this.args.selection,
+ );
+ }
+
+ get ariaLabel() {
+ return `label-single-select-dropdown-${this.args.label}`;
+ }
+
+ get searchEnabled() {
+ return (this.args.options?.length ?? 0) > 10;
+ }
+
+ setSelection = ({ key }) => {
+ this.args.onSelect?.(key);
+ };
+
+
+
+
+ {{@label}}:
+
+ {{option.label}}
+
+
+
+
+}
diff --git a/ui/app/components/single-select-dropdown/index.hbs b/ui/app/components/single-select-dropdown/index.hbs
deleted file mode 100644
index 99fd4f588f3..00000000000
--- a/ui/app/components/single-select-dropdown/index.hbs
+++ /dev/null
@@ -1,25 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{@label}}:
- {{option.label}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/single-select-dropdown/index.js b/ui/app/components/single-select-dropdown/index.js
deleted file mode 100644
index 7ea79ad20d9..00000000000
--- a/ui/app/components/single-select-dropdown/index.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class SingleSelectDropdown extends Component {
- get activeOption() {
- return this.args.options.findBy('key', this.args.selection);
- }
-
- @action
- setSelection({ key }) {
- this.args.onSelect && this.args.onSelect(key);
- }
-}
diff --git a/ui/app/components/stats-time-series.gjs b/ui/app/components/stats-time-series.gjs
new file mode 100644
index 00000000000..ebc6c936459
--- /dev/null
+++ b/ui/app/components/stats-time-series.gjs
@@ -0,0 +1,101 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import moment from 'moment';
+import d3TimeFormat from 'd3-time-format';
+import d3Format from 'd3-format';
+import { scaleTime, scaleLinear } from 'd3-scale';
+import d3Array from 'd3-array';
+import bind from 'nomad-ui/helpers/bind';
+import LineChart from 'nomad-ui/components/line-chart';
+import formatDuration from 'nomad-ui/utils/format-duration';
+
+export default class StatsTimeSeries extends Component {
+ xFormat = d3TimeFormat.timeFormat('%H:%M:%S');
+
+ yFormat = d3Format.format('.1~%');
+
+ get useDefaults() {
+ return !this.args.dataProp;
+ }
+
+ get description() {
+ const data = this.args.data;
+ const yRange = d3Array.extent(data, (d) => d.percent);
+ const xRange = d3Array.extent(data, (d) => d.timestamp);
+
+ const duration = formatDuration(xRange[1] - xRange[0], 'ms', true);
+
+ return `Time series data for the last ${duration}, with values ranging from ${this.yFormat(
+ yRange[0],
+ )} to ${this.yFormat(yRange[1])}`;
+ }
+
+ xScale(data, yAxisOffset) {
+ const scale = scaleTime();
+
+ const [low, high] = d3Array.extent(data, (d) => d.timestamp);
+ const minLow = moment(high).subtract(5, 'minutes').toDate();
+
+ const extent = data.length
+ ? [Math.min(low, minLow), high]
+ : [minLow, new Date()];
+ scale.rangeRound([10, yAxisOffset]).domain(extent);
+
+ return scale;
+ }
+
+ yScale(data, xAxisOffset) {
+ const yValueKey = this.args.dataProp ? 'percentStack' : 'percent';
+ const yValues = (data || []).map((datum) => datum?.[yValueKey]);
+
+ let [low, high] = [0, 1];
+ if (yValues.filter((value) => value != null).length) {
+ [low, high] = d3Array.extent(yValues);
+ }
+
+ return scaleLinear()
+ .rangeRound([xAxisOffset, 10])
+ .domain([Math.min(0, low), Math.max(1, high)]);
+ }
+
+
+
+ <:svg as |c|>
+ {{#if this.useDefaults}}
+
+ {{/if}}
+ {{yield c to="svg"}}
+
+ <:after as |c|>
+ {{#if this.useDefaults}}
+
+
+ {{datum.formattedX}}
+ {{datum.formattedY}}
+
+
+ {{/if}}
+ {{yield c to="after"}}
+
+
+
+}
diff --git a/ui/app/components/stats-time-series.hbs b/ui/app/components/stats-time-series.hbs
deleted file mode 100644
index 2b3ebbf0c01..00000000000
--- a/ui/app/components/stats-time-series.hbs
+++ /dev/null
@@ -1,39 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- <:svg as |c|>
- {{#if this.useDefaults}}
-
- {{/if}}
- {{yield c to="svg"}}
-
- <:after as |c|>
- {{#if this.useDefaults}}
-
-
- {{datum.formattedX}}
- {{datum.formattedY}}
-
-
- {{/if}}
- {{yield c to="after"}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js
deleted file mode 100644
index a5f3f5e99d6..00000000000
--- a/ui/app/components/stats-time-series.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import moment from 'moment';
-import d3TimeFormat from 'd3-time-format';
-import d3Format from 'd3-format';
-import { scaleTime, scaleLinear } from 'd3-scale';
-import d3Array from 'd3-array';
-import formatDuration from 'nomad-ui/utils/format-duration';
-
-export default class StatsTimeSeries extends Component {
- get xFormat() {
- return d3TimeFormat.timeFormat('%H:%M:%S');
- }
-
- get yFormat() {
- return d3Format.format('.1~%');
- }
-
- get useDefaults() {
- return !this.args.dataProp;
- }
-
- // Specific a11y descriptors
- get description() {
- const data = this.args.data;
- const yRange = d3Array.extent(data, (d) => d.percent);
- const xRange = d3Array.extent(data, (d) => d.timestamp);
- const yFormatter = this.yFormat;
-
- const duration = formatDuration(xRange[1] - xRange[0], 'ms', true);
-
- return `Time series data for the last ${duration}, with values ranging from ${yFormatter(
- yRange[0],
- )} to ${yFormatter(yRange[1])}`;
- }
-
- xScale(data, yAxisOffset) {
- const scale = scaleTime();
-
- const [low, high] = d3Array.extent(data, (d) => d.timestamp);
- const minLow = moment(high).subtract(5, 'minutes').toDate();
-
- const extent = data.length
- ? [Math.min(low, minLow), high]
- : [minLow, new Date()];
- scale.rangeRound([10, yAxisOffset]).domain(extent);
-
- return scale;
- }
-
- yScale(data, xAxisOffset) {
- const yValues = (data || []).mapBy(
- this.args.dataProp ? 'percentStack' : 'percent',
- );
-
- let [low, high] = [0, 1];
- if (yValues.compact().length) {
- [low, high] = d3Array.extent(yValues);
- }
-
- return scaleLinear()
- .rangeRound([xAxisOffset, 10])
- .domain([Math.min(0, low), Math.max(1, high)]);
- }
-}
diff --git a/ui/app/components/status-cell.gjs b/ui/app/components/status-cell.gjs
new file mode 100644
index 00000000000..4cedb77f5ff
--- /dev/null
+++ b/ui/app/components/status-cell.gjs
@@ -0,0 +1,10 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export const StatusCell =
+ {{@status}}
+;
+
+export default StatusCell;
diff --git a/ui/app/components/status-cell.hbs b/ui/app/components/status-cell.hbs
deleted file mode 100644
index 041741ededf..00000000000
--- a/ui/app/components/status-cell.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{@status}}
\ No newline at end of file
diff --git a/ui/app/components/stepper-input.gjs b/ui/app/components/stepper-input.gjs
new file mode 100644
index 00000000000..5176e8e87fa
--- /dev/null
+++ b/ui/app/components/stepper-input.gjs
@@ -0,0 +1,176 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { debounce } from '@ember/runloop';
+import { guidFor } from '@ember/object/internals';
+import { on } from '@ember/modifier';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+
+const ESC = 27;
+
+export default class StepperInput extends Component {
+ @tracked internalValue = Number(this.args.value ?? 0);
+
+ get inputId() {
+ return `stepper-input-${guidFor(this)}`;
+ }
+
+ get min() {
+ return this.args.min ?? 0;
+ }
+
+ get max() {
+ return this.args.max ?? 10;
+ }
+
+ get disabled() {
+ return this.args.disabled ?? false;
+ }
+
+ get classVariant() {
+ return this.args.class ?? '';
+ }
+
+ get rootClass() {
+ const classes = ['stepper-input'];
+
+ if (this.classVariant) {
+ classes.push(this.classVariant);
+ }
+
+ if (this.disabled) {
+ classes.push('is-disabled', 'tooltip', 'multiline');
+ }
+
+ return classes.join(' ');
+ }
+
+ get isIncrementDisabled() {
+ return this.disabled || this.internalValue >= this.max;
+ }
+
+ get isDecrementDisabled() {
+ return this.disabled || this.internalValue <= this.min;
+ }
+
+ syncValueFromArgs = () => {
+ this.internalValue = Number(this.args.value ?? 0);
+ this.syncInputElementValue(this.internalValue);
+ };
+
+ syncInputElementValue(value) {
+ const input = document.getElementById(this.inputId);
+ if (input) {
+ input.value = value;
+ }
+ }
+
+ increment = () => {
+ if (this.internalValue < this.max) {
+ const nextValue = this.internalValue + 1;
+ this.internalValue = nextValue;
+ this.syncInputElementValue(nextValue);
+ this.update(nextValue);
+ }
+ };
+
+ decrement = () => {
+ if (this.internalValue > this.min) {
+ const nextValue = this.internalValue - 1;
+ this.internalValue = nextValue;
+ this.syncInputElementValue(nextValue);
+ this.update(nextValue);
+ }
+ };
+
+ setValue = (event) => {
+ if (event.target.value !== '') {
+ const rawValue = Number(event.target.value);
+ const boundedValue = Math.min(this.max, Math.max(this.min, rawValue));
+ const newValue = Math.floor(boundedValue);
+
+ if (Number.isFinite(newValue)) {
+ this.internalValue = newValue;
+ this.syncInputElementValue(newValue);
+ this.update(newValue);
+ } else {
+ event.target.value = this.internalValue;
+ }
+ } else {
+ event.target.value = this.internalValue;
+ }
+ };
+
+ resetTextInput = (event) => {
+ if (event.keyCode === ESC) {
+ event.target.value = this.internalValue;
+ }
+ };
+
+ selectValue = (event) => {
+ event.target.select();
+ };
+
+ update(value) {
+ debounce(this, this.sendUpdateAction, value, this.args.debounce ?? 500);
+ }
+
+ sendUpdateAction = (value) => {
+ if (this.args.onChange) {
+ return this.args.onChange(value);
+ }
+ };
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/stepper-input.hbs b/ui/app/components/stepper-input.hbs
deleted file mode 100644
index 960e665af81..00000000000
--- a/ui/app/components/stepper-input.hbs
+++ /dev/null
@@ -1,43 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/stepper-input.js b/ui/app/components/stepper-input.js
deleted file mode 100644
index 9923083ca39..00000000000
--- a/ui/app/components/stepper-input.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { debounce, join } from '@ember/runloop';
-import { classNames, classNameBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-const ESC = 27;
-
-@classic
-@classNames('stepper-input')
-@classNameBindings(
- 'class',
- 'disabled:is-disabled',
- 'disabled:tooltip',
- 'disabled:multiline',
-)
-export default class StepperInput extends Component {
- min = 0;
- max = 10;
- value = 0;
- internalValue = 0;
- debounce = 500;
- onChange() {}
-
- didReceiveAttrs() {
- super.didReceiveAttrs(...arguments);
- this.set('internalValue', Number(this.value ?? 0));
- }
-
- @action
- increment() {
- join(this, () => {
- if (this.internalValue < this.max) {
- const nextValue = this.internalValue + 1;
- this.set('internalValue', nextValue);
- this.update(nextValue);
- }
- });
- }
-
- @action
- decrement() {
- join(this, () => {
- if (this.internalValue > this.min) {
- const nextValue = this.internalValue - 1;
- this.set('internalValue', nextValue);
- this.update(nextValue);
- }
- });
- }
-
- @action
- setValue(e) {
- join(this, () => {
- if (e.target.value !== '') {
- const newValue = Math.floor(
- Math.min(this.max, Math.max(this.min, e.target.value)),
- );
- this.set('internalValue', newValue);
- this.update(newValue);
- } else {
- e.target.value = this.internalValue;
- }
- });
- }
-
- @action
- resetTextInput(e) {
- if (e.keyCode === ESC) {
- e.target.value = this.internalValue;
- }
- }
-
- @action
- selectValue(e) {
- e.target.select();
- }
-
- update(value) {
- debounce(this, sendUpdateAction, value, this.debounce);
- }
-}
-
-function sendUpdateAction(value) {
- return this.onChange(value);
-}
diff --git a/ui/app/components/storage-subnav.gjs b/ui/app/components/storage-subnav.gjs
new file mode 100644
index 00000000000..496e840d66f
--- /dev/null
+++ b/ui/app/components/storage-subnav.gjs
@@ -0,0 +1,35 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class StorageSubnav extends Component {
+ @service keyboard;
+
+
+
+
+}
diff --git a/ui/app/components/storage-subnav.hbs b/ui/app/components/storage-subnav.hbs
deleted file mode 100644
index 5696d7c1369..00000000000
--- a/ui/app/components/storage-subnav.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/storage-subnav.js b/ui/app/components/storage-subnav.js
deleted file mode 100644
index 68027514958..00000000000
--- a/ui/app/components/storage-subnav.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-
-export default class StorageSubnavComponent extends Component {
- @service keyboard;
-}
diff --git a/ui/app/components/streaming-file.gjs b/ui/app/components/streaming-file.gjs
new file mode 100644
index 00000000000..0660b967681
--- /dev/null
+++ b/ui/app/components/streaming-file.gjs
@@ -0,0 +1,193 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { scheduleOnce, once } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+
+const A_KEY = 65;
+
+export default class StreamingFile extends Component {
+ @tracked follow = true;
+
+ requestFrame = true;
+ cliElement = null;
+ scrollHandlerBound = null;
+ keyDownHandlerBound = null;
+
+ get mode() {
+ return this.args.mode ?? 'streaming';
+ }
+
+ get isStreaming() {
+ return this.args.isStreaming ?? true;
+ }
+
+ get shouldFillHeight() {
+ return this.args.shouldFillHeight ?? true;
+ }
+
+ setupElement = (element) => {
+ this.cliElement = element;
+
+ if (this.shouldFillHeight) {
+ this.fillAvailableHeight();
+ }
+
+ this.scrollHandlerBound = this.scrollHandler.bind(this);
+ this.cliElement.addEventListener('scroll', this.scrollHandlerBound);
+
+ this.keyDownHandlerBound = this.keyDownHandler.bind(this);
+ document.addEventListener('keydown', this.keyDownHandlerBound);
+ };
+
+ onArgsChange = () => {
+ if (!this.args.logger) {
+ return;
+ }
+
+ // Defer task start/stop so task state doesn't mutate during render.
+ scheduleOnce('actions', this, this.performTask);
+ };
+
+ performTask = () => {
+ switch (this.mode) {
+ case 'head':
+ this.follow = false;
+ this.head.perform();
+ break;
+ case 'tail':
+ this.follow = true;
+ this.tail.perform();
+ break;
+ case 'streaming':
+ this.follow = true;
+ if (this.isStreaming) {
+ this.stream.perform();
+ } else {
+ this.args.logger.stop();
+ }
+ break;
+ }
+ };
+
+ scrollHandler() {
+ const cli = this.cliElement;
+
+ if (!cli) {
+ return;
+ }
+
+ // Scroll events can fire multiple times per frame, this eliminates
+ // redundant computation.
+ if (this.requestFrame) {
+ window.requestAnimationFrame(() => {
+ // If the scroll position is close enough to the bottom, autoscroll to the bottom.
+ this.follow = cli.scrollHeight - cli.scrollTop - cli.clientHeight < 20;
+ this.requestFrame = true;
+ });
+ }
+
+ this.requestFrame = false;
+ }
+
+ keyDownHandler(event) {
+ // Rebind select-all shortcut to only select the text in the streaming file output.
+ if ((event.metaKey || event.ctrlKey) && event.keyCode === A_KEY) {
+ event.preventDefault();
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ const range = document.createRange();
+ range.selectNode(this.cliElement);
+ selection.addRange(range);
+ }
+ }
+
+ windowResizeHandler = () => {
+ if (this.shouldFillHeight) {
+ once(this, this.fillAvailableHeight);
+ }
+ };
+
+ fillAvailableHeight = () => {
+ if (!this.cliElement) {
+ return;
+ }
+
+ // This math is arbitrary and far from bulletproof, but the UX of having
+ // the log window fill available height is worth the hack.
+ const margins = 30;
+ this.cliElement.style.height = `${
+ window.innerHeight - this.cliElement.offsetTop - margins
+ }px`;
+ };
+
+ head = task(async () => {
+ await this.args.logger.gotoHead.perform();
+ scheduleOnce('afterRender', this, this.scrollToTop);
+ });
+
+ scrollToTop = () => {
+ if (this.cliElement) {
+ this.cliElement.scrollTop = 0;
+ }
+ };
+
+ tail = task(async () => {
+ await this.args.logger.gotoTail.perform();
+ });
+
+ synchronizeScrollPosition = () => {
+ if (this.follow && this.cliElement) {
+ this.cliElement.scrollTop = this.cliElement.scrollHeight;
+ }
+ };
+
+ stream = task(async () => {
+ // Follow the log if the scroll position is near the bottom of the cli window.
+ this.args.logger.on('tick', this, 'scheduleScrollSynchronization');
+
+ await this.args.logger.startStreaming();
+ this.args.logger.off('tick', this, 'scheduleScrollSynchronization');
+ });
+
+ scheduleScrollSynchronization() {
+ scheduleOnce('afterRender', this, this.synchronizeScrollPosition);
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+
+ if (this.cliElement && this.scrollHandlerBound) {
+ this.cliElement.removeEventListener('scroll', this.scrollHandlerBound);
+ }
+
+ if (this.keyDownHandlerBound) {
+ document.removeEventListener('keydown', this.keyDownHandlerBound);
+ }
+
+ this.args.logger?.stop();
+ }
+
+
+
+ {{@logger.output}}
+
+
+}
diff --git a/ui/app/components/streaming-file.hbs b/ui/app/components/streaming-file.hbs
deleted file mode 100644
index 312ff46c4e2..00000000000
--- a/ui/app/components/streaming-file.hbs
+++ /dev/null
@@ -1,9 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{this.logger.output}}
\ No newline at end of file
diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js
deleted file mode 100644
index 5e1d5def3d2..00000000000
--- a/ui/app/components/streaming-file.js
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { scheduleOnce, once } from '@ember/runloop';
-import { task } from 'ember-concurrency';
-import WindowResizable from 'nomad-ui/mixins/window-resizable';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-const A_KEY = 65;
-
-@classic
-@tagName('pre')
-@classNames('cli-window')
-@attributeBindings('data-test-log-cli')
-export default class StreamingFile extends Component.extend(WindowResizable) {
- 'data-test-log-cli' = true;
-
- mode = 'streaming'; // head, tail, streaming
- isStreaming = true;
- logger = null;
- follow = true;
- shouldFillHeight = true;
-
- // Internal bookkeeping to avoid multiple scroll events on one frame
- requestFrame = true;
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- if (!this.logger) {
- return;
- }
-
- // Defer task start/stop so task state doesn't mutate during render.
- scheduleOnce('actions', this, this.performTask);
- }
-
- performTask() {
- switch (this.mode) {
- case 'head':
- this.set('follow', false);
- this.head.perform();
- break;
- case 'tail':
- this.set('follow', true);
- this.tail.perform();
- break;
- case 'streaming':
- this.set('follow', true);
- if (this.isStreaming) {
- this.stream.perform();
- } else {
- this.logger.stop();
- }
- break;
- }
- }
-
- scrollHandler() {
- const cli = this.element;
-
- // Scroll events can fire multiple times per frame, this eliminates
- // redundant computation.
- if (this.requestFrame) {
- window.requestAnimationFrame(() => {
- // If the scroll position is close enough to the bottom, autoscroll to the bottom
- this.set(
- 'follow',
- cli.scrollHeight - cli.scrollTop - cli.clientHeight < 20,
- );
- this.requestFrame = true;
- });
- }
- this.requestFrame = false;
- }
-
- keyDownHandler(e) {
- // Rebind select-all shortcut to only select the text in the
- // streaming file output.
- if ((e.metaKey || e.ctrlKey) && e.keyCode === A_KEY) {
- e.preventDefault();
- const selection = window.getSelection();
- selection.removeAllRanges();
- const range = document.createRange();
- range.selectNode(this.element);
- selection.addRange(range);
- }
- }
-
- didInsertElement() {
- super.didInsertElement(...arguments);
- if (this.shouldFillHeight) {
- this.fillAvailableHeight();
- }
-
- this.set('_scrollHandler', this.scrollHandler.bind(this));
- this.element.addEventListener('scroll', this._scrollHandler);
-
- this.set('_keyDownHandler', this.keyDownHandler.bind(this));
- document.addEventListener('keydown', this._keyDownHandler);
- }
-
- willDestroyElement() {
- super.willDestroyElement(...arguments);
- this.element.removeEventListener('scroll', this._scrollHandler);
- document.removeEventListener('keydown', this._keyDownHandler);
- }
-
- windowResizeHandler() {
- if (this.shouldFillHeight) {
- once(this, this.fillAvailableHeight);
- }
- }
-
- fillAvailableHeight() {
- // This math is arbitrary and far from bulletproof, but the UX
- // of having the log window fill available height is worth the hack.
- const margins = 30; // Account for padding and margin on either side of the CLI
- const cliWindow = this.element;
- cliWindow.style.height = `${
- window.innerHeight - cliWindow.offsetTop - margins
- }px`;
- }
-
- @task(function* () {
- yield this.get('logger.gotoHead').perform();
- scheduleOnce('afterRender', this, this.scrollToTop);
- })
- head;
-
- scrollToTop() {
- this.element.scrollTop = 0;
- }
-
- @task(function* () {
- yield this.get('logger.gotoTail').perform();
- })
- tail;
-
- synchronizeScrollPosition() {
- if (this.follow) {
- this.element.scrollTop = this.element.scrollHeight;
- }
- }
-
- @task(function* () {
- // Follow the log if the scroll position is near the bottom of the cli window
- this.logger.on('tick', this, 'scheduleScrollSynchronization');
-
- yield this.logger.startStreaming();
- this.logger.off('tick', this, 'scheduleScrollSynchronization');
- })
- stream;
-
- scheduleScrollSynchronization() {
- scheduleOnce('afterRender', this, this.synchronizeScrollPosition);
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.logger.stop();
- }
-}
diff --git a/ui/app/components/svg-patterns.gjs b/ui/app/components/svg-patterns.gjs
new file mode 100644
index 00000000000..2eb148da98b
--- /dev/null
+++ b/ui/app/components/svg-patterns.gjs
@@ -0,0 +1,55 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export const SvgPatterns =
+
+
+
+ {{! Evenly sized diagonal stripes}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+;
+
+export default SvgPatterns;
diff --git a/ui/app/components/svg-patterns.hbs b/ui/app/components/svg-patterns.hbs
deleted file mode 100644
index e5269f95e08..00000000000
--- a/ui/app/components/svg-patterns.hbs
+++ /dev/null
@@ -1,51 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{! Evenly sized diagonal stripes}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/task-context-sidebar.gjs b/ui/app/components/task-context-sidebar.gjs
new file mode 100644
index 00000000000..26ab51ffbc0
--- /dev/null
+++ b/ui/app/components/task-context-sidebar.gjs
@@ -0,0 +1,163 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { array } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import onClickOutside from 'ember-click-outside/modifiers/on-click-outside';
+import { reverse } from '@nullvoxpopuli/ember-composable-helpers';
+import ListTable from 'nomad-ui/components/list-table';
+import TaskLog from 'nomad-ui/components/task-log';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import keyboardCommands from 'nomad-ui/helpers/keyboard-commands';
+
+export default class TaskContextSidebar extends Component {
+ @tracked wide = false;
+
+ get isSideBarOpen() {
+ return !!this.args.task;
+ }
+
+ get portalTargetElement() {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ return document.getElementById('log-sidebar-portal');
+ }
+
+ keyCommands = [
+ {
+ label: 'Close Task Logs Sidebar',
+ pattern: ['Escape'],
+ action: () => this.args.fns.closeSidebar(),
+ },
+ ];
+
+ narrowCommand = {
+ label: 'Narrow Sidebar',
+ pattern: ['ArrowRight', 'ArrowRight'],
+ action: () => this.toggleWide(),
+ };
+
+ widenCommand = {
+ label: 'Widen Sidebar',
+ pattern: ['ArrowLeft', 'ArrowLeft'],
+ action: () => this.toggleWide(),
+ };
+
+ toggleWide = () => {
+ this.wide = !this.wide;
+ };
+
+
+ {{#if this.portalTargetElement}}
+ {{#in-element this.portalTargetElement}}
+
+ {{/in-element}}
+ {{/if}}
+
+}
diff --git a/ui/app/components/task-context-sidebar.hbs b/ui/app/components/task-context-sidebar.hbs
deleted file mode 100644
index 7bbd226d212..00000000000
--- a/ui/app/components/task-context-sidebar.hbs
+++ /dev/null
@@ -1,105 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/task-context-sidebar.js b/ui/app/components/task-context-sidebar.js
deleted file mode 100644
index 6648f0ec2b9..00000000000
--- a/ui/app/components/task-context-sidebar.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-
-export default class TaskContextSidebarComponent extends Component {
- get isSideBarOpen() {
- return !!this.args.task;
- }
-
- keyCommands = [
- {
- label: 'Close Task Logs Sidebar',
- pattern: ['Escape'],
- action: () => this.args.fns.closeSidebar(),
- },
- ];
-
- narrowCommand = {
- label: 'Narrow Sidebar',
- pattern: ['ArrowRight', 'ArrowRight'],
- action: () => this.toggleWide(),
- };
-
- widenCommand = {
- label: 'Widen Sidebar',
- pattern: ['ArrowLeft', 'ArrowLeft'],
- action: () => this.toggleWide(),
- };
-
- @tracked wide = false;
- @action toggleWide() {
- this.wide = !this.wide;
- }
-}
diff --git a/ui/app/components/task-group-row.gjs b/ui/app/components/task-group-row.gjs
new file mode 100644
index 00000000000..7fd1cf50bfd
--- /dev/null
+++ b/ui/app/components/task-group-row.gjs
@@ -0,0 +1,220 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { debounce, join } from '@ember/runloop';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { or } from 'ember-truth-helpers';
+import { HdsIcon } from '@hashicorp/design-system-components/components';
+import cannot from 'ember-can/helpers/cannot';
+import { didUpdate } from '@ember/render-modifiers';
+import AllocationStatusBar from 'nomad-ui/components/allocation-status-bar';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class TaskGroupRow extends Component {
+ @service abilities;
+
+ @tracked count = 0;
+ debounce = 500;
+
+ constructor() {
+ super(...arguments);
+ this.syncCount();
+ }
+
+ syncCount = () => {
+ this.count = Number(this.args.taskGroup?.count ?? 0);
+ };
+
+ handleClick = (event) => {
+ lazyClick([this.args.onClick, event]);
+ };
+
+ get runningDeployment() {
+ return this.args.taskGroup?.job?.runningDeployment;
+ }
+
+ get namespace() {
+ const job = this.args.taskGroup?.job;
+
+ const namespaceId =
+ (typeof job?.get === 'function' ? job.get('namespaceId') : undefined) ||
+ job?.namespaceId;
+ if (namespaceId) {
+ return namespaceId;
+ }
+
+ const jobId =
+ (typeof job?.get === 'function' ? job.get('id') : undefined) || job?.id;
+ if (jobId) {
+ try {
+ const [, parsedNamespace] = JSON.parse(jobId);
+ return parsedNamespace || 'default';
+ } catch {
+ // Fall through to final default.
+ }
+ }
+
+ if (typeof job?.namespace === 'string') {
+ return job.namespace;
+ }
+
+ return 'default';
+ }
+
+ get tooltipText() {
+ if (this.abilities.cannot('scale job', null, { namespace: this.namespace }))
+ return "You aren't allowed to scale task groups";
+ if (this.runningDeployment)
+ return 'You cannot scale task groups during a deployment';
+ return undefined;
+ }
+
+ get isMinimum() {
+ const scaling = this.args.taskGroup.scaling;
+ if (!scaling || scaling.min == null) return false;
+ return this.count <= scaling.min;
+ }
+
+ get isMaximum() {
+ const scaling = this.args.taskGroup.scaling;
+ if (!scaling || scaling.max == null) return false;
+ return this.count >= scaling.max;
+ }
+
+ countUp = () => {
+ join(this, () => {
+ const scaling = this.args.taskGroup.scaling;
+ if (!scaling || scaling.max == null || this.count < scaling.max) {
+ const nextCount = this.count + 1;
+ this.count = nextCount;
+ if (typeof this.args.taskGroup?.set === 'function') {
+ this.args.taskGroup.set('count', nextCount);
+ } else if (this.args.taskGroup) {
+ this.args.taskGroup.count = nextCount;
+ }
+ this.scale(nextCount);
+ }
+ });
+ };
+
+ countDown = () => {
+ join(this, () => {
+ const scaling = this.args.taskGroup.scaling;
+ if (!scaling || scaling.min == null || this.count > scaling.min) {
+ const nextCount = this.count - 1;
+ this.count = nextCount;
+ if (typeof this.args.taskGroup?.set === 'function') {
+ this.args.taskGroup.set('count', nextCount);
+ } else if (this.args.taskGroup) {
+ this.args.taskGroup.count = nextCount;
+ }
+ this.scale(nextCount);
+ }
+ });
+ };
+
+ scale(count) {
+ debounce(this, this.sendCountAction, count, this.debounce);
+ }
+
+ sendCountAction = (count) => {
+ return this.args.taskGroup.scale(count);
+ };
+
+
+
+
+
+ {{@taskGroup.name}}
+
+
+
+ {{this.count}}
+ {{#if @taskGroup.scaling}}
+
+ {{/if}}
+
+
+
+
+ {{if
+ @taskGroup.volumes.length
+ "Yes"
+ }}
+ {{formatScheduledHertz
+ @taskGroup.reservedCPU
+ }}
+ {{formatScheduledBytes
+ @taskGroup.reservedMemory
+ start="MiB"
+ }}
+ {{formatScheduledBytes
+ @taskGroup.reservedEphemeralDisk
+ start="MiB"
+ }}
+
+
+}
diff --git a/ui/app/components/task-group-row.hbs b/ui/app/components/task-group-row.hbs
deleted file mode 100644
index b1e24015d33..00000000000
--- a/ui/app/components/task-group-row.hbs
+++ /dev/null
@@ -1,78 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{this.taskGroup.name}}
-
-
-
- {{this.count}}
- {{#if this.taskGroup.scaling}}
-
- {{/if}}
-
-
-
-
-{{if this.taskGroup.volumes.length "Yes"}}
-{{format-scheduled-hertz
- this.taskGroup.reservedCPU
- }}
-{{format-scheduled-bytes
- this.taskGroup.reservedMemory
- start="MiB"
- }}
-{{format-scheduled-bytes
- this.taskGroup.reservedEphemeralDisk
- start="MiB"
- }}
\ No newline at end of file
diff --git a/ui/app/components/task-group-row.js b/ui/app/components/task-group-row.js
deleted file mode 100644
index 17a257436ff..00000000000
--- a/ui/app/components/task-group-row.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-import { debounce, join } from '@ember/runloop';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import { lazyClick } from '../helpers/lazy-click';
-
-@classic
-@tagName('tr')
-@classNames('task-group-row', 'is-interactive')
-@attributeBindings('data-test-task-group')
-export default class TaskGroupRow extends Component {
- @service abilities;
-
- taskGroup = null;
- count = 0;
- debounce = 500;
-
- didReceiveAttrs() {
- super.didReceiveAttrs(...arguments);
- this.set('count', Number(this.taskGroup?.count ?? 0));
- }
-
- get runningDeployment() {
- return this.taskGroup?.job?.runningDeployment;
- }
-
- get namespace() {
- const job = this.taskGroup?.job;
-
- const namespaceId =
- (typeof job?.get === 'function' ? job.get('namespaceId') : undefined) ||
- job?.namespaceId;
- if (namespaceId) {
- return namespaceId;
- }
-
- const jobId =
- (typeof job?.get === 'function' ? job.get('id') : undefined) || job?.id;
- if (jobId) {
- try {
- const [, parsedNamespace] = JSON.parse(jobId);
- return parsedNamespace || 'default';
- } catch {
- // Fall through to final default.
- }
- }
-
- if (typeof job?.namespace === 'string') {
- return job.namespace;
- }
-
- return 'default';
- }
-
- get tooltipText() {
- if (this.abilities.cannot('scale job', null, { namespace: this.namespace }))
- return "You aren't allowed to scale task groups";
- if (this.runningDeployment)
- return 'You cannot scale task groups during a deployment';
- return undefined;
- }
-
- onClick() {}
-
- click(event) {
- lazyClick([this.onClick, event]);
- }
-
- get isMinimum() {
- const scaling = this.taskGroup.scaling;
- if (!scaling || scaling.min == null) return false;
- return this.count <= scaling.min;
- }
-
- get isMaximum() {
- const scaling = this.taskGroup.scaling;
- if (!scaling || scaling.max == null) return false;
- return this.count >= scaling.max;
- }
-
- @action
- countUp() {
- join(this, () => {
- const scaling = this.taskGroup.scaling;
- if (!scaling || scaling.max == null || this.count < scaling.max) {
- const nextCount = this.count + 1;
- this.set('count', nextCount);
- this.taskGroup.set('count', nextCount);
- this.scale(nextCount);
- }
- });
- }
-
- @action
- countDown() {
- join(this, () => {
- const scaling = this.taskGroup.scaling;
- if (!scaling || scaling.min == null || this.count > scaling.min) {
- const nextCount = this.count - 1;
- this.set('count', nextCount);
- this.taskGroup.set('count', nextCount);
- this.scale(nextCount);
- }
- });
- }
-
- scale(count) {
- debounce(this, sendCountAction, count, this.debounce);
- }
-}
-
-function sendCountAction(count) {
- return this.taskGroup.scale(count);
-}
diff --git a/ui/app/components/task-log.gjs b/ui/app/components/task-log.gjs
new file mode 100644
index 00000000000..5f5cc4427fe
--- /dev/null
+++ b/ui/app/components/task-log.gjs
@@ -0,0 +1,239 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn, array } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { eq } from 'ember-truth-helpers';
+import {
+ HdsFormToggleField,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import RSVP from 'rsvp';
+import { logger } from 'nomad-ui/utils/classes/log';
+import timeout from 'nomad-ui/utils/timeout';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import StreamingFile from 'nomad-ui/components/streaming-file';
+
+export default class TaskLog extends Component {
+ @service token;
+ @service userSettings;
+ @service abilities;
+
+ @tracked useServer = false;
+ @tracked noConnection = false;
+ @tracked logsDisabled = false;
+
+ clientTimeout = 1000;
+ serverTimeout = 5000;
+
+ @tracked isStreaming = true;
+ @tracked streamMode = 'streaming';
+
+ shouldFillHeight = true;
+
+ @localStorageProperty('nomadShouldWrapCode', false) wrapped;
+
+ get mode() {
+ return this.userSettings.logMode;
+ }
+
+ set mode(value) {
+ this.userSettings.logMode = value;
+ }
+
+ get logUrl() {
+ let address;
+ const allocation = this.args.allocation?.id;
+ if (this.abilities.can('read client')) {
+ address = this.args.allocation?.node?.httpAddr;
+ }
+ const url = `/v1/client/fs/logs/${allocation}`;
+ return this.useServer ? url : address ? `//${address}${url}` : url;
+ }
+
+ get logParams() {
+ return {
+ task: this.args.task,
+ type: this.mode,
+ };
+ }
+
+ @logger('logUrl', 'logParams', function logFetch() {
+ const aborter = new AbortController();
+ const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
+ const useServer = this.useServer;
+
+ return (url) =>
+ RSVP.race([
+ this.token.authorizedRequest(url, { signal: aborter.signal }),
+ timeout(timing),
+ ]).then(
+ (response) => {
+ if (response.status === 404) {
+ this.logsDisabled = true;
+ }
+ return response;
+ },
+ (error) => {
+ aborter.abort();
+ if (useServer) {
+ this.noConnection = true;
+ } else {
+ this.failoverToServer();
+ }
+ throw error;
+ },
+ );
+ })
+ logger;
+
+ setMode = (mode) => {
+ if (this.mode === mode) return;
+ this.logger.stop();
+ this.mode = mode;
+ };
+
+ toggleStream = () => {
+ this.streamMode = 'streaming';
+ this.isStreaming = !this.isStreaming;
+ };
+
+ gotoHead = () => {
+ this.streamMode = 'head';
+ this.isStreaming = false;
+ };
+
+ gotoTail = () => {
+ this.streamMode = 'tail';
+ this.isStreaming = false;
+ };
+
+ failoverToServer = () => {
+ this.useServer = true;
+ };
+
+ toggleWrap = () => {
+ this.wrapped = !this.wrapped;
+ return false;
+ };
+
+ dismissNoConnection = () => {
+ this.noConnection = false;
+ };
+
+ dismissLogsDisabled = () => {
+ this.logsDisabled = false;
+ };
+
+
+
+ {{#if this.noConnection}}
+
+
+
+ Cannot fetch logs
+ The logs for this task are inaccessible. Check the condition of
+ the node the allocation is on.
+
+
+
+
+
+
+ {{/if}}
+ {{#if this.logsDisabled}}
+
+
+
+ Cannot fetch logs
+ Logs unavailable. Log collection may be disabled.
+
+
+
+
+
+
+ {{/if}}
+
+
+
+
+
+
+
+
+ Word Wrap
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/task-log.hbs b/ui/app/components/task-log.hbs
deleted file mode 100644
index e8499a8db69..00000000000
--- a/ui/app/components/task-log.hbs
+++ /dev/null
@@ -1,106 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.noConnection}}
-
-
-
- Cannot fetch logs
- The logs for this task are inaccessible. Check the condition of the
- node the allocation is on.
-
-
-
-
-
-
-{{/if}}
-{{#if this.logsDisabled}}
-
-
-
- Cannot fetch logs
- Logs unavailable. Log collection may be disabled.
-
-
-
-
-
-
-{{/if}}
-
-
-
-
-
-
-
-
- Word Wrap
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js
deleted file mode 100644
index 18087b968cc..00000000000
--- a/ui/app/components/task-log.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { service } from '@ember/service';
-import Component from '@ember/component';
-import { action, computed } from '@ember/object';
-import { alias } from '@ember/object/computed';
-import RSVP from 'rsvp';
-import { logger } from 'nomad-ui/utils/classes/log';
-import timeout from 'nomad-ui/utils/timeout';
-import { classNames } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
-
-@classic
-@classNames('boxed-section', 'task-log')
-export default class TaskLog extends Component {
- @service token;
- @service userSettings;
- @service abilities;
- allocation = null;
- task = null;
-
- // When true, request logs from the server agent
- useServer = false;
-
- // When true, logs cannot be fetched from either the client or the server
- noConnection = false;
- logsDisabled = false;
-
- clientTimeout = 1000;
- serverTimeout = 5000;
-
- isStreaming = true;
- streamMode = 'streaming';
-
- shouldFillHeight = true;
-
- @localStorageProperty('nomadShouldWrapCode', false) wrapped;
-
- @alias('userSettings.logMode') mode;
-
- @computed('allocation.{id,node.httpAddr}', 'useServer')
- get logUrl() {
- let address;
- const allocation = this.get('allocation.id');
- if (this.abilities.can('read client')) {
- address = this.get('allocation.node.httpAddr');
- }
- const url = `/v1/client/fs/logs/${allocation}`;
- return this.useServer ? url : address ? `//${address}${url}` : url;
- }
-
- @computed('task', 'mode')
- get logParams() {
- return {
- task: this.task,
- type: this.mode,
- };
- }
-
- @logger('logUrl', 'logParams', function logFetch() {
- // If the log request can't settle in one second, the client
- // must be unavailable and the server should be used instead
-
- const aborter = new AbortController();
- const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
-
- // Capture the state of useServer at logger create time to avoid a race
- // between the stdout logger and stderr logger running at once.
- const useServer = this.useServer;
- return (url) =>
- RSVP.race([
- this.token.authorizedRequest(url, { signal: aborter.signal }),
- timeout(timing),
- ]).then(
- (response) => {
- // whenever the log files 404, it is due to log collection
- // being disabled.
- if (response.status === 404) {
- this.set('logsDisabled', true);
- }
- return response;
- },
- (error) => {
- aborter.abort();
- if (useServer) {
- this.set('noConnection', true);
- } else {
- this.send('failoverToServer');
- }
- throw error;
- },
- );
- })
- logger;
-
- @action
- setMode(mode) {
- if (this.mode === mode) return;
- this.logger.stop();
- this.set('mode', mode);
- }
-
- @action
- toggleStream() {
- this.set('streamMode', 'streaming');
- this.toggleProperty('isStreaming');
- }
-
- @action
- gotoHead() {
- this.set('streamMode', 'head');
- this.set('isStreaming', false);
- }
-
- @action
- gotoTail() {
- this.set('streamMode', 'tail');
- this.set('isStreaming', false);
- }
-
- @action
- failoverToServer() {
- this.set('useServer', true);
- }
-
- @action toggleWrap() {
- this.toggleProperty('wrapped');
- return false;
- }
-}
diff --git a/ui/app/components/task-row.gjs b/ui/app/components/task-row.gjs
new file mode 100644
index 00000000000..d6f936a8a45
--- /dev/null
+++ b/ui/app/components/task-row.gjs
@@ -0,0 +1,239 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array, concat } from '@ember/helper';
+import { tracked } from '@glimmer/tracking';
+import { didUpdate } from '@ember/render-modifiers';
+import { LinkTo } from '@ember/routing';
+import { on } from '@ember/modifier';
+import { service } from '@ember/service';
+import { and, not } from 'ember-truth-helpers';
+import { task, timeout } from 'ember-concurrency';
+import {
+ HdsIcon,
+ HdsTooltipButton,
+} from '@hashicorp/design-system-components/components';
+import formatBytes from 'nomad-ui/helpers/format-bytes';
+import formatHertz from 'nomad-ui/helpers/format-hertz';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import formatVolumeName from 'nomad-ui/helpers/format-volume-name';
+import ProxyTag from 'nomad-ui/components/proxy-tag';
+import ENV from 'nomad-ui/config/environment';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default class TaskRow extends Component {
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ @tracked statsError = false;
+
+ get enablePolling() {
+ return ENV.environment !== 'test';
+ }
+
+ get stats() {
+ if (!this.args.task?.isRunning) return undefined;
+ return this.statsTrackersRegistry.getTracker(this.args.task.allocation);
+ }
+
+ get taskStats() {
+ if (!this.stats) return undefined;
+ return this.stats.tasks?.find?.(
+ (entry) => entry.task === this.args.task?.name,
+ );
+ }
+
+ get cpu() {
+ const cpu = this.taskStats?.cpu;
+ return cpu?.[cpu.length - 1];
+ }
+
+ get memory() {
+ const memory = this.taskStats?.memory;
+ return memory?.[memory.length - 1];
+ }
+
+ click = (event) => {
+ lazyClick([this.args.onClick, event]);
+ };
+
+ handleTaskChange = () => {
+ const allocation = this.args.task?.allocation;
+
+ if (allocation) {
+ this.fetchStats.perform();
+ } else {
+ this.fetchStats.cancelAll();
+ }
+ };
+
+ fetchStats = task({ drop: true }, async () => {
+ do {
+ if (this.stats) {
+ try {
+ await this.stats.poll.linked().perform();
+ this.statsError = false;
+ } catch {
+ this.statsError = true;
+ }
+ }
+
+ await timeout(500);
+ } while (this.enablePolling);
+ });
+
+
+
+
+ {{#unless @task.driverStatus.healthy}}
+
+
+
+ {{/unless}}
+
+
+
+ {{@task.name}}
+ {{#if @task.isConnectProxy}}
+
+ {{/if}}
+
+
+
+ {{@task.state}}
+
+
+ {{#if @task.events.lastObject.message}}
+ {{@task.events.lastObject.message}}
+ {{else}}
+
+ No message
+
+ {{/if}}
+
+
+ {{formatTs @task.events.lastObject.time}}
+
+
+
+ {{#each @task.task.volumeMounts as |volume|}}
+ -
+
+ {{volume.volume}}
+ :
+
+ {{#if volume.isCSI}}
+
+ {{formatVolumeName
+ source=volume.source
+ isPerAlloc=volume.volumeDeclaration.perAlloc
+ volumeExtension=@task.allocation.volumeExtension
+ }}
+
+ {{else}}
+ {{volume.source}}
+ {{/if}}
+
+ {{/each}}
+
+
+
+ {{#if @task.isRunning}}
+ {{#if (and (not this.cpu) this.fetchStats.isRunning)}}
+ ...
+ {{else if this.statsError}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/if}}
+
+
+ {{#if @task.isRunning}}
+ {{#if (and (not this.memory) this.fetchStats.isRunning)}}
+ ...
+ {{else if this.statsError}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/task-row.hbs b/ui/app/components/task-row.hbs
deleted file mode 100644
index 0341d55f3a2..00000000000
--- a/ui/app/components/task-row.hbs
+++ /dev/null
@@ -1,147 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#unless this.task.driverStatus.healthy}}
-
-
-
- {{/unless}}
-
-
-
- {{this.task.name}}
- {{#if this.task.isConnectProxy}}
-
- {{/if}}
-
-
-
- {{this.task.state}}
-
-
- {{#if this.task.events.lastObject.message}}
- {{this.task.events.lastObject.message}}
- {{else}}
-
- No message
-
- {{/if}}
-
-
- {{format-ts this.task.events.lastObject.time}}
-
-
-
- {{#each this.task.task.volumeMounts as |volume|}}
- -
-
- {{volume.volume}}
- :
-
- {{#if volume.isCSI}}
-
- {{format-volume-name
- source=volume.source
- isPerAlloc=volume.volumeDeclaration.perAlloc
- volumeExtension=this.task.allocation.volumeExtension
- }}
-
- {{else}}
- {{volume.source}}
- {{/if}}
-
- {{/each}}
-
-
-
- {{#if this.task.isRunning}}
- {{#if (and (not this.cpu) this.fetchStats.isRunning)}}
- ...
- {{else if this.statsError}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
- {{/if}}
-
-
- {{#if this.task.isRunning}}
- {{#if (and (not this.memory) this.fetchStats.isRunning)}}
- ...
- {{else if this.statsError}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/task-row.js b/ui/app/components/task-row.js
deleted file mode 100644
index 86f6568883f..00000000000
--- a/ui/app/components/task-row.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { computed } from '@ember/object';
-import { task, timeout } from 'ember-concurrency';
-import { lazyClick } from '../helpers/lazy-click';
-import ENV from 'nomad-ui/config/environment';
-
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tr')
-@classNames('task-row', 'is-interactive')
-@attributeBindings('data-test-task-row')
-export default class TaskRow extends Component {
- @service store;
- @service token;
- @service('stats-trackers-registry') statsTrackersRegistry;
-
- task = null;
-
- // Internal state
- statsError = false;
-
- @computed
- get enablePolling() {
- return ENV.environment !== 'test';
- }
-
- // Since all tasks for an allocation share the same tracker, use the registry
- @computed('task.{allocation,isRunning}')
- get stats() {
- if (!this.get('task.isRunning')) return undefined;
-
- return this.statsTrackersRegistry.getTracker(this.get('task.allocation'));
- }
-
- @computed('task.name', 'stats.tasks.[]')
- get taskStats() {
- if (!this.stats) return undefined;
-
- return this.get('stats.tasks').findBy('task', this.get('task.name'));
- }
-
- @computed('taskStats.cpu.[]')
- get cpu() {
- const cpu = this.taskStats?.cpu;
- return cpu?.[cpu.length - 1];
- }
-
- @computed('taskStats.memory.[]')
- get memory() {
- const memory = this.taskStats?.memory;
- return memory?.[memory.length - 1];
- }
-
- onClick() {}
-
- click(event) {
- lazyClick([this.onClick, event]);
- }
-
- @(task(function* () {
- do {
- if (this.stats) {
- try {
- yield this.get('stats.poll').linked().perform();
- this.set('statsError', false);
- } catch {
- this.set('statsError', true);
- }
- }
-
- yield timeout(500);
- } while (this.enablePolling);
- }).drop())
- fetchStats;
-
- didReceiveAttrs() {
- super.didReceiveAttrs();
- const allocation = this.get('task.allocation');
-
- if (allocation) {
- this.fetchStats.perform();
- } else {
- this.fetchStats.cancelAll();
- }
- }
-}
diff --git a/ui/app/components/task-sub-row.gjs b/ui/app/components/task-sub-row.gjs
new file mode 100644
index 00000000000..f8bd2c3a20c
--- /dev/null
+++ b/ui/app/components/task-sub-row.gjs
@@ -0,0 +1,266 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { array, fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import {
+ HdsDropdown,
+ HdsIcon,
+} from '@hashicorp/design-system-components/components';
+import can from 'ember-can/helpers/can';
+import { task, timeout } from 'ember-concurrency';
+import formatBytes from 'nomad-ui/helpers/format-bytes';
+import formatHertz from 'nomad-ui/helpers/format-hertz';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import TaskContextSidebar from 'nomad-ui/components/task-context-sidebar';
+import ENV from 'nomad-ui/config/environment';
+
+export default class TaskSubRow extends Component {
+ @service store;
+ @service router;
+ @service notifications;
+ @service nomadActions;
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ @tracked statsError = false;
+
+ constructor() {
+ super(...arguments);
+
+ const allocation = this.task?.allocation;
+ if (allocation) {
+ this.fetchStats.perform();
+ } else {
+ this.fetchStats.cancelAll();
+ }
+ }
+
+ get task() {
+ return this.args.taskState;
+ }
+
+ get stats() {
+ if (!this.task?.isRunning) return undefined;
+ return this.statsTrackersRegistry.getTracker(this.task.allocation);
+ }
+
+ get enablePolling() {
+ return ENV.environment !== 'test';
+ }
+
+ get taskStats() {
+ if (!this.stats) return undefined;
+ return this.stats.tasks.findBy('task', this.task.name);
+ }
+
+ get cpu() {
+ const cpu = this.taskStats?.cpu;
+ return cpu?.[cpu.length - 1];
+ }
+
+ get memory() {
+ const memory = this.taskStats?.memory;
+ return memory?.[memory.length - 1];
+ }
+
+ get isCpuLoading() {
+ return !this.cpu && this.fetchStats.isRunning;
+ }
+
+ get isMemoryLoading() {
+ return !this.memory && this.fetchStats.isRunning;
+ }
+
+ fetchStats = task({ drop: true }, async () => {
+ do {
+ if (this.stats) {
+ try {
+ await this.stats.poll.linked().perform();
+ this.statsError = false;
+ } catch {
+ this.statsError = true;
+ }
+ }
+
+ await timeout(500);
+ } while (this.enablePolling);
+ });
+
+ get shouldShowLogs() {
+ return this.args.active;
+ }
+
+ get namespace() {
+ return this.task.task?.taskGroup.job.namespace;
+ }
+
+ gotoTask = (allocation, task) => {
+ const taskName =
+ (typeof task?.get === 'function' ? task.get('name') : undefined) ||
+ task?.name ||
+ task;
+
+ this.router.transitionTo(
+ 'allocations.allocation.task',
+ allocation,
+ taskName,
+ );
+ };
+
+ handleTaskLogsClick = (task) => {
+ this.args.onSetActiveTask?.(task);
+ };
+
+ closeSidebar = () => {
+ this.args.onSetActiveTask?.(null);
+ };
+
+ get sidebarFns() {
+ return {
+ closeSidebar: this.closeSidebar,
+ };
+ }
+
+ runAction = task(async (action, allocID) => {
+ try {
+ await this.nomadActions.runAction({ action, allocID });
+ } catch (err) {
+ this.notifications.add({
+ title: `Error starting ${action.name}`,
+ message: err,
+ sticky: true,
+ color: 'critical',
+ });
+ }
+ });
+
+
+
+
+
+ {{this.task.name}}
+
+
+
+
+ {{#if this.task.isRunning}}
+ {{#if this.isCpuLoading}}
+ ...
+ {{else if this.statsError}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/if}}
+
+
+ {{#if this.task.isRunning}}
+ {{#if this.isMemoryLoading}}
+ ...
+ {{else if this.statsError}}
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{/if}}
+
+ {{#if @jobHasActions}}
+
+ {{#if (can "exec allocation" namespace=this.namespace)}}
+ {{#if this.task.task.actions.length}}
+
+
+ {{#each this.task.task.actions as |actionC|}}
+
+ {{/each}}
+
+ {{/if}}
+ {{/if}}
+
+ {{/if}}
+
+
+ {{yield}}
+
+ {{#if this.shouldShowLogs}}
+
+ {{/if}}
+
+}
diff --git a/ui/app/components/task-sub-row.hbs b/ui/app/components/task-sub-row.hbs
deleted file mode 100644
index 99c6d1532cc..00000000000
--- a/ui/app/components/task-sub-row.hbs
+++ /dev/null
@@ -1,126 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
- {{this.task.name}}
-
-
-
-
- {{#if this.task.isRunning}}
- {{#if (and (not this.cpu) this.fetchStats.isRunning)}}
- ...
- {{else if this.statsError}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
- {{/if}}
-
-
- {{#if this.task.isRunning}}
- {{#if (and (not this.memory) this.fetchStats.isRunning)}}
- ...
- {{else if this.statsError}}
-
-
-
- {{else}}
-
-
-
- {{/if}}
- {{/if}}
-
- {{#if @jobHasActions}}
-
- {{#if (can "exec allocation" namespace=this.namespace)}}
- {{#if this.task.task.actions.length}}
-
-
- {{#each this.task.task.actions as |action|}}
-
- {{/each}}
-
- {{/if}}
- {{/if}}
-
- {{/if}}
-
-
-{{yield}}
-
-{{#if this.shouldShowLogs}}
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/task-sub-row.js b/ui/app/components/task-sub-row.js
deleted file mode 100644
index 25dcf993f0d..00000000000
--- a/ui/app/components/task-sub-row.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { service } from '@ember/service';
-import { action } from '@ember/object';
-import { computed } from '@ember/object';
-import { alias } from '@ember/object/computed';
-import { task, timeout } from 'ember-concurrency';
-import { tracked } from '@glimmer/tracking';
-import ENV from 'nomad-ui/config/environment';
-
-export default class TaskSubRowComponent extends Component {
- @service store;
- @service router;
- @service notifications;
- @service nomadActions;
- @service('stats-trackers-registry') statsTrackersRegistry;
-
- constructor() {
- super(...arguments);
- // Kick off stats polling
- const allocation = this.task.allocation;
- if (allocation) {
- this.fetchStats.perform();
- } else {
- this.fetchStats.cancelAll();
- }
- }
-
- @alias('args.taskState') task;
-
- @action
- gotoTask(allocation, task) {
- const taskName =
- (typeof task?.get === 'function' ? task.get('name') : undefined) ||
- task?.name ||
- task;
- this.router.transitionTo(
- 'allocations.allocation.task',
- allocation,
- taskName,
- );
- }
-
- // Since all tasks for an allocation share the same tracker, use the registry
- @computed('task.{allocation,isRunning}')
- get stats() {
- if (!this.task.isRunning) return undefined;
-
- return this.statsTrackersRegistry.getTracker(this.task.allocation);
- }
-
- // Internal state
- @tracked statsError = false;
-
- @computed
- get enablePolling() {
- return ENV.environment !== 'test';
- }
-
- @computed('task.name', 'stats.tasks.[]')
- get taskStats() {
- if (!this.stats) return undefined;
-
- return this.stats.tasks.findBy('task', this.task.name);
- }
-
- @computed('taskStats.cpu.[]')
- get cpu() {
- const cpu = this.taskStats?.cpu;
- return cpu?.[cpu.length - 1];
- }
-
- @computed('taskStats.memory.[]')
- get memory() {
- const memory = this.taskStats?.memory;
- return memory?.[memory.length - 1];
- }
-
- @(task(function* () {
- do {
- if (this.stats) {
- try {
- yield this.stats.poll.linked().perform();
- this.statsError = false;
- } catch {
- this.statsError = true;
- }
- }
-
- yield timeout(500);
- } while (this.enablePolling);
- }).drop())
- fetchStats;
-
- //#region Logs Sidebar
-
- @alias('args.active') shouldShowLogs;
-
- @action handleTaskLogsClick(task) {
- if (this.args.onSetActiveTask) {
- this.args.onSetActiveTask(task);
- }
- }
-
- @action closeSidebar() {
- if (this.args.onSetActiveTask) {
- this.args.onSetActiveTask(null);
- }
- }
-
- //#endregion Logs Sidebar
-
- get namespace() {
- return this.task.task?.taskGroup.job.namespace;
- }
-
- /**
- * @param {string} action - The action to run
- * @param {string} allocID - The allocation ID to run the action on
- * @param {Event} ev - The event that triggered the action
- */
- @task(function* (action, allocID) {
- try {
- yield this.nomadActions.runAction({ action, allocID });
- } catch (err) {
- this.notifications.add({
- title: `Error starting ${action.name}`,
- message: err,
- sticky: true,
- color: 'critical',
- });
- }
- })
- runAction;
-}
diff --git a/ui/app/components/task-subnav.gjs b/ui/app/components/task-subnav.gjs
new file mode 100644
index 00000000000..2427aaa87c4
--- /dev/null
+++ b/ui/app/components/task-subnav.gjs
@@ -0,0 +1,73 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { LinkTo } from '@ember/routing';
+import { didInsert, willDestroy } from '@ember/render-modifiers';
+
+export default class TaskSubnav extends Component {
+ @service router;
+ @service keyboard;
+
+ get fsIsActive() {
+ return this.router.currentRouteName === 'allocations.allocation.task.fs';
+ }
+
+ get fsRootIsActive() {
+ return (
+ this.router.currentRouteName === 'allocations.allocation.task.fs-root'
+ );
+ }
+
+ get filesLinkActive() {
+ return this.fsIsActive || this.fsRootIsActive;
+ }
+
+ get taskModels() {
+ const task = this.args.task;
+ if (!task) return [];
+ return [task.allocation, task.name];
+ }
+
+
+
+
+}
diff --git a/ui/app/components/task-subnav.hbs b/ui/app/components/task-subnav.hbs
deleted file mode 100644
index ef4358077d1..00000000000
--- a/ui/app/components/task-subnav.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/task-subnav.js b/ui/app/components/task-subnav.js
deleted file mode 100644
index 317e56222a6..00000000000
--- a/ui/app/components/task-subnav.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { service } from '@ember/service';
-import { equal, or } from '@ember/object/computed';
-import { tagName } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('')
-export default class TaskSubnav extends Component {
- @service router;
- @service keyboard;
-
- @equal('router.currentRouteName', 'allocations.allocation.task.fs')
- fsIsActive;
-
- @equal('router.currentRouteName', 'allocations.allocation.task.fs-root')
- fsRootIsActive;
-
- @or('fsIsActive', 'fsRootIsActive')
- filesLinkActive;
-}
diff --git a/ui/app/components/toggle.gjs b/ui/app/components/toggle.gjs
new file mode 100644
index 00000000000..4cec43d50fb
--- /dev/null
+++ b/ui/app/components/toggle.gjs
@@ -0,0 +1,53 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { on } from '@ember/modifier';
+
+const noOp = () => {};
+
+export default class Toggle extends Component {
+ get isActive() {
+ return this.args.isActive ?? false;
+ }
+
+ get isDisabled() {
+ return this.args.isDisabled ?? false;
+ }
+
+ get onToggle() {
+ return this.args.onToggle ?? noOp;
+ }
+
+ get rootClass() {
+ const classes = ['toggle'];
+
+ if (this.isDisabled) {
+ classes.push('is-disabled');
+ }
+
+ if (this.isActive) {
+ classes.push('is-active');
+ }
+
+ return classes.join(' ');
+ }
+
+
+ {{! template-lint-disable }}
+
+
+}
diff --git a/ui/app/components/toggle.hbs b/ui/app/components/toggle.hbs
deleted file mode 100644
index f1297dea8e5..00000000000
--- a/ui/app/components/toggle.hbs
+++ /dev/null
@@ -1,16 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{! template-lint-disable }}
-
-
-{{yield}}
\ No newline at end of file
diff --git a/ui/app/components/toggle.js b/ui/app/components/toggle.js
deleted file mode 100644
index c9c9366d81d..00000000000
--- a/ui/app/components/toggle.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import {
- classNames,
- classNameBindings,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('label')
-@classNames('toggle')
-@classNameBindings('isDisabled:is-disabled', 'isActive:is-active')
-@attributeBindings('data-test-label')
-export default class Toggle extends Component {
- 'data-test-label' = true;
-
- isActive = false;
- isDisabled = false;
- onToggle() {}
-}
diff --git a/ui/app/components/token-editor.gjs b/ui/app/components/token-editor.gjs
new file mode 100644
index 00000000000..f846326d90b
--- /dev/null
+++ b/ui/app/components/token-editor.gjs
@@ -0,0 +1,535 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { array, fn, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import can from 'ember-can/helpers/can';
+import { findBy } from '@nullvoxpopuli/ember-composable-helpers';
+import { eq, not } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsFormMaskedInputField,
+ HdsFormRadioGroup,
+ HdsLinkInline,
+ HdsTable,
+ HdsTag,
+} from '@hashicorp/design-system-components/components';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import Tooltip from 'nomad-ui/components/tooltip';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
+
+export default class TokenEditor extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+ @service system;
+
+ @tracked tokenPolicies = [];
+ @tracked tokenRoles = [];
+ @tracked tokenRegion = '';
+
+ constructor() {
+ super(...arguments);
+ this.tokenPolicies = this.args.token.policies.toArray() || [];
+ this.tokenRoles = this.args.token.roles.toArray() || [];
+ if (this.args.token.isNew) {
+ this.args.token.expirationTTL = 'never';
+ }
+ this.tokenRegion = this.system.activeRegion;
+ }
+
+ policyKey(policy) {
+ return policy?.name;
+ }
+
+ roleKey(role) {
+ return role?.id || role?.name;
+ }
+
+ updateTokenName = ({ target: { value } }) => {
+ this.args.token.set('name', value);
+ };
+
+ updateTokenPolicies = (policy, event) => {
+ const { checked } = event.target;
+ const key = this.policyKey(policy);
+
+ if (checked) {
+ if (!this.tokenPolicies.some((item) => this.policyKey(item) === key)) {
+ this.tokenPolicies = [...this.tokenPolicies, policy];
+ }
+ } else {
+ this.tokenPolicies = this.tokenPolicies.filter(
+ (item) => this.policyKey(item) !== key,
+ );
+ }
+ };
+
+ updateTokenRoles = (role, event) => {
+ const { checked } = event.target;
+ const key = this.roleKey(role);
+
+ if (checked) {
+ if (!this.tokenRoles.some((item) => this.roleKey(item) === key)) {
+ this.tokenRoles = [...this.tokenRoles, role];
+ }
+ } else {
+ this.tokenRoles = this.tokenRoles.filter(
+ (item) => this.roleKey(item) !== key,
+ );
+ }
+ };
+
+ updateTokenType = (event) => {
+ this.args.token.type = event.target.id;
+ };
+
+ updateTokenExpirationTime = (event) => {
+ const rawValue = event?.target?.value;
+ if (!rawValue) {
+ return;
+ }
+
+ const normalizedValue = rawValue.includes('.')
+ ? rawValue.split('.')[0]
+ : rawValue;
+ const parsed = new Date(normalizedValue);
+
+ if (Number.isNaN(parsed.getTime())) {
+ return;
+ }
+
+ this.args.token.expirationTTL = null;
+ this.args.token.expirationTime = parsed;
+ };
+
+ updateTokenExpirationTTL = (event) => {
+ this.args.token.expirationTime = null;
+ if (event.target.value === 'never') {
+ this.args.token.expirationTTL = null;
+ } else if (event.target.value === 'custom') {
+ this.args.token.expirationTime = new Date();
+ } else {
+ this.args.token.expirationTTL = event.target.value;
+ }
+ };
+
+ updateTokenLocality = (event) => {
+ this.tokenRegion = event.target.id;
+ };
+
+ save = task({ drop: true }, async (event) => {
+ event?.preventDefault?.();
+
+ const activeToken = this.args.token;
+
+ try {
+ const shouldRedirectAfterSave = activeToken.isNew;
+
+ const policyIDs = this.tokenPolicies
+ .map((policy) => policy?.id || policy?.name)
+ .filter(Boolean);
+ const roleIDs = this.tokenRoles
+ .map((role) => role?.id || role?.name)
+ .filter(Boolean);
+
+ activeToken.policies = this.tokenPolicies;
+ activeToken.roles = this.tokenRoles;
+ activeToken.policyIDs = policyIDs;
+ activeToken.policyNames = policyIDs;
+ activeToken.roleIDs = roleIDs;
+
+ if (activeToken.type === 'management') {
+ activeToken.policyIDs = [];
+ activeToken.policyNames = [];
+ activeToken.roleIDs = [];
+ activeToken.policies = [];
+ activeToken.roles = [];
+ }
+
+ activeToken.global = this.tokenRegion === 'global';
+
+ if (activeToken.expirationTTL === 'never') {
+ activeToken.expirationTTL = null;
+ }
+
+ const adapterRegion = activeToken.global
+ ? this.system.get('defaultRegion.region')
+ : this.tokenRegion;
+
+ await activeToken.save({
+ adapterOptions: adapterRegion ? { region: adapterRegion } : {},
+ });
+
+ this.notifications.add({
+ title: 'Token Saved',
+ color: 'success',
+ });
+
+ if (shouldRedirectAfterSave) {
+ this.router.transitionTo('administration.tokens.token', activeToken.id);
+ }
+ } catch (err) {
+ const message = err.errors?.length
+ ? messageFromAdapterError(err)
+ : err.message;
+
+ this.notifications.add({
+ title: `Error creating Token ${activeToken.name}`,
+ message,
+ color: 'critical',
+ sticky: true,
+ });
+ }
+ });
+
+
+
+
+}
diff --git a/ui/app/components/token-editor.hbs b/ui/app/components/token-editor.hbs
deleted file mode 100644
index 3b9e2a5455f..00000000000
--- a/ui/app/components/token-editor.hbs
+++ /dev/null
@@ -1,333 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/token-editor.js b/ui/app/components/token-editor.js
deleted file mode 100644
index 27e9189b581..00000000000
--- a/ui/app/components/token-editor.js
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import { alias } from '@ember/object/computed';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { task } from 'ember-concurrency';
-import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
-
-export default class TokenEditorComponent extends Component {
- @service notifications;
- @service router;
- @service store;
- @service system;
-
- @alias('args.roles') roles;
- @alias('args.token') activeToken;
- @alias('args.policies') policies;
-
- @tracked tokenPolicies = [];
- @tracked tokenRoles = [];
-
- /**
- * When creating a token, it can be made global (has access to all regions),
- * or non-global. If it's non-global, it can be scoped to a specific region.
- * By default, the token is created in the active region of the UI.
- * @type {string}
- */
- @tracked tokenRegion = '';
-
- // when this renders, set up tokenPolicies
- constructor() {
- super(...arguments);
- this.tokenPolicies = this.activeToken.policies.toArray() || [];
- this.tokenRoles = this.activeToken.roles.toArray() || [];
- if (this.activeToken.isNew) {
- this.activeToken.expirationTTL = 'never';
- }
- this.tokenRegion = this.system.activeRegion;
- }
-
- policyKey(policy) {
- return policy?.name;
- }
-
- roleKey(role) {
- return role?.id || role?.name;
- }
-
- @action updateTokenPolicies(policy, event) {
- let { checked } = event.target;
- const key = this.policyKey(policy);
-
- if (checked) {
- if (!this.tokenPolicies.some((p) => this.policyKey(p) === key)) {
- this.tokenPolicies = [...this.tokenPolicies, policy];
- }
- } else {
- this.tokenPolicies = this.tokenPolicies.filter(
- (p) => this.policyKey(p) !== key,
- );
- }
- }
-
- @action updateTokenRoles(role, event) {
- let { checked } = event.target;
- const key = this.roleKey(role);
-
- if (checked) {
- if (!this.tokenRoles.some((r) => this.roleKey(r) === key)) {
- this.tokenRoles = [...this.tokenRoles, role];
- }
- } else {
- this.tokenRoles = this.tokenRoles.filter((r) => this.roleKey(r) !== key);
- }
- }
-
- @action updateTokenType(event) {
- let tokenType = event.target.id;
- this.activeToken.type = tokenType;
- }
-
- @action updateTokenExpirationTime(event) {
- const rawValue = event?.target?.value;
- if (!rawValue) {
- return;
- }
-
- // datetime-local values can include seconds/fractions; normalize before parsing.
- const normalizedValue = rawValue.includes('.')
- ? rawValue.split('.')[0]
- : rawValue;
- const parsed = new Date(normalizedValue);
-
- if (Number.isNaN(parsed.getTime())) {
- return;
- }
-
- // Override expirationTTL if user selects a valid time.
- this.activeToken.expirationTTL = null;
- this.activeToken.expirationTime = parsed;
- }
- @action updateTokenExpirationTTL(event) {
- // Override expirationTime if user selects a TTL
- this.activeToken.expirationTime = null;
- if (event.target.value === 'never') {
- this.activeToken.expirationTTL = null;
- } else if (event.target.value === 'custom') {
- this.activeToken.expirationTime = new Date();
- } else {
- this.activeToken.expirationTTL = event.target.value;
- }
- }
-
- @action updateTokenLocality(event) {
- this.tokenRegion = event.target.id;
- }
-
- save = task({ drop: true }, async (event) => {
- event?.preventDefault?.();
-
- try {
- const shouldRedirectAfterSave = this.activeToken.isNew;
-
- const policyIDs = this.tokenPolicies
- .map((policy) => policy?.id || policy?.name)
- .filter(Boolean);
- const roleIDs = this.tokenRoles
- .map((role) => role?.id || role?.name)
- .filter(Boolean);
-
- this.activeToken.policies = this.tokenPolicies;
- this.activeToken.roles = this.tokenRoles;
- this.activeToken.policyIDs = policyIDs;
- this.activeToken.policyNames = policyIDs;
- this.activeToken.roleIDs = roleIDs;
-
- if (this.activeToken.type === 'management') {
- // Management tokens cannot have policies or roles
- this.activeToken.policyIDs = [];
- this.activeToken.policyNames = [];
- this.activeToken.roleIDs = [];
- this.activeToken.policies = [];
- this.activeToken.roles = [];
- }
-
- if (this.tokenRegion === 'global') {
- this.activeToken.global = true;
- } else {
- this.activeToken.global = false;
- }
-
- // Sets to "never" for auto-selecting the radio button;
- // if it gets updated by the user, will fall back to "" to represent
- // no expiration. However, if the user never updates it,
- // it stays as the string "never", where the API expects a null value.
- if (this.activeToken.expirationTTL === 'never') {
- this.activeToken.expirationTTL = null;
- }
-
- const adapterRegion = this.activeToken.global
- ? this.system.get('defaultRegion.region')
- : this.tokenRegion;
-
- await this.activeToken.save({
- adapterOptions: adapterRegion ? { region: adapterRegion } : {},
- });
-
- this.notifications.add({
- title: 'Token Saved',
- color: 'success',
- });
-
- if (shouldRedirectAfterSave) {
- this.router.transitionTo(
- 'administration.tokens.token',
- this.activeToken.id,
- );
- }
- } catch (err) {
- let message = err.errors?.length
- ? messageFromAdapterError(err)
- : err.message;
-
- this.notifications.add({
- title: `Error creating Token ${this.activeToken.name}`,
- message,
- color: 'critical',
- sticky: true,
- });
- }
- });
-}
diff --git a/ui/app/components/tooltip.js b/ui/app/components/tooltip.gjs
similarity index 60%
rename from ui/app/components/tooltip.js
rename to ui/app/components/tooltip.gjs
index 0c74066ec0f..5e84493ffee 100644
--- a/ui/app/components/tooltip.js
+++ b/ui/app/components/tooltip.gjs
@@ -13,14 +13,6 @@ import Component from '@glimmer/component';
* @extends Component
*/
export default class Tooltip extends Component {
- /**
- * Determines if the tooltip should be displayed.
- * Defaults to `true` if the `condition` argument is not provided.
- *
- * @property condition
- * @type {boolean}
- * @readonly
- */
get condition() {
if (this.args.condition === undefined) return true;
@@ -29,16 +21,6 @@ export default class Tooltip extends Component {
return this.args.condition;
}
- /**
- * Returns the truncated text to be displayed in the tooltip.
- * If the input text length is less than 30 characters, the input text is returned as-is.
- * Otherwise, the text is truncated to include the first 15 characters, followed by an ellipsis,
- * and then the last 10 characters.
- *
- * @property text
- * @type {string}
- * @readonly
- */
get text() {
const inputText = this.args.text?.toString();
if (!inputText || inputText.length < 30) {
@@ -51,4 +33,21 @@ export default class Tooltip extends Component {
.trim();
return `${prefix}...${suffix}`;
}
+
+ get tooltipClass() {
+ return this.args.isFullText ? 'tooltip multiline' : 'tooltip';
+ }
+
+
+ {{#if this.condition}}
+
+ {{yield}}
+
+ {{else}}
+ {{yield}}
+ {{/if}}
+
}
diff --git a/ui/app/components/tooltip.hbs b/ui/app/components/tooltip.hbs
deleted file mode 100644
index 8e4e29ae4bc..00000000000
--- a/ui/app/components/tooltip.hbs
+++ /dev/null
@@ -1,15 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.condition}}
-
- {{yield}}
-
-{{else}}
- {{yield}}
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/topo-viz.js b/ui/app/components/topo-viz.gjs
similarity index 72%
rename from ui/app/components/topo-viz.js
rename to ui/app/components/topo-viz.gjs
index a84b1666fc0..702c29e2aa5 100644
--- a/ui/app/components/topo-viz.js
+++ b/ui/app/components/topo-viz.gjs
@@ -5,13 +5,19 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
-import { action, set } from '@ember/object';
+import { set } from '@ember/object';
import { service } from '@ember/service';
import { next } from '@ember/runloop';
+import { htmlSafe } from '@ember/template';
import { scaleLinear } from 'd3-scale';
import { extent, deviation, mean } from 'd3-array';
import { line, curveBasis } from 'd3-shape';
-import styleStringProperty from '../utils/properties/style-string';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+import windowResize from 'nomad-ui/modifiers/window-resize';
+import FlexMasonry from 'nomad-ui/components/flex-masonry';
+import TopoVizDatacenter from 'nomad-ui/components/topo-viz/datacenter';
export default class TopoViz extends Component {
@service system;
@@ -28,7 +34,23 @@ export default class TopoViz extends Component {
@tracked highlightAllocation = null;
@tracked tooltipProps = {};
- @styleStringProperty('tooltipProps') tooltipStyle;
+ get tooltipStyle() {
+ const styles = this.tooltipProps;
+ if (!styles) return htmlSafe('');
+
+ const value = Object.keys(styles)
+ .map((key) => {
+ const styleValue = styles[key];
+ const formattedValue =
+ typeof styleValue === 'number'
+ ? `${styleValue.toFixed(2)}px`
+ : styleValue;
+ return `${key}:${formattedValue}`;
+ })
+ .join(';');
+
+ return htmlSafe(value);
+ }
get isSingleColumn() {
if (this.topology.datacenters.length <= 1 || this.viewportColumns === 1)
@@ -41,7 +63,7 @@ export default class TopoViz extends Component {
);
const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);
- // The point at which the varation is too extreme for a two column layout
+ // The point at which the variation is too extreme for a two column layout
const threshold = 0.5;
if (variationCoefficient > threshold) return true;
return false;
@@ -89,8 +111,7 @@ export default class TopoViz extends Component {
};
}
- @action
- buildTopology() {
+ buildTopology = () => {
const nodes = this.args.nodes;
const allocations = this.args.allocations;
@@ -166,16 +187,14 @@ export default class TopoViz extends Component {
},
]);
}
- }
+ };
- @action
- captureElement(element) {
+ captureElement = (element) => {
this.element = element;
this.determineViewportColumns();
- }
+ };
- @action
- showNodeDetails(node) {
+ showNodeDetails = (node) => {
if (this.activeNode) {
set(this.activeNode, 'isSelected', false);
}
@@ -187,23 +206,23 @@ export default class TopoViz extends Component {
}
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
- }
+ };
- @action showTooltip(allocation, element) {
+ showTooltip = (allocation, element) => {
const bbox = element.getBoundingClientRect();
this.highlightAllocation = allocation;
this.tooltipProps = {
- left: window.scrollX + bbox.left + bbox.width / 2,
- top: window.scrollY + bbox.top,
+ position: 'fixed',
+ left: bbox.left + bbox.width / 2,
+ top: bbox.top,
};
- }
+ };
- @action hideTooltip() {
+ hideTooltip = () => {
this.highlightAllocation = null;
- }
+ };
- @action
- associateAllocations(allocation) {
+ associateAllocations = (allocation) => {
if (this.activeAllocation === allocation) {
this.activeAllocation = null;
this.activeEdges = [];
@@ -256,22 +275,19 @@ export default class TopoViz extends Component {
this.activeAllocation && this.activeAllocation.allocation,
);
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
- }
+ };
- @action
- determineViewportColumns() {
+ determineViewportColumns = () => {
this.viewportColumns = this.element.clientWidth < 900 ? 1 : 2;
- }
+ };
- @action
- resizeEdges() {
+ resizeEdges = () => {
if (this.activeEdges.length > 0) {
this.computedActiveEdges();
}
- }
+ };
- @action
- computedActiveEdges() {
+ computedActiveEdges = () => {
// Wait a render cycle
next(() => {
const path = line().curve(curveBasis);
@@ -327,7 +343,87 @@ export default class TopoViz extends Component {
this.activeEdges = curves.map((curve) => path(curve));
this.edgeOffset = { x: window.scrollX, y: window.scrollY };
});
- }
+ };
+
+
+
+
+
+
+
+
+ {{#let this.highlightAllocation as |allocation|}}
+
+ -
+ Job
+ {{allocation.allocation.job.name}}/{{allocation.allocation.taskGroupName}}
+
+ {{#if this.system.shouldShowNamespaces}}
+ -
+ Namespace
+ {{allocation.allocation.job.namespace.name}}
+
+ {{/if}}
+ -
+ Memory
+ {{formatScheduledBytes
+ allocation.memory
+ start="MiB"
+ }}
+
+ -
+ CPU
+ {{formatScheduledHertz allocation.cpu}}
+
+
+ {{/let}}
+
+
+ {{#if this.activeAllocation}}
+
+
+ {{#each this.activeEdges as |edge|}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+
}
function centerOfBBox(bbox) {
diff --git a/ui/app/components/topo-viz.hbs b/ui/app/components/topo-viz.hbs
deleted file mode 100644
index 5da6d94438b..00000000000
--- a/ui/app/components/topo-viz.hbs
+++ /dev/null
@@ -1,81 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
-
-
-
-
- {{#let this.highlightAllocation as |allocation|}}
-
- -
- Job
- {{allocation.allocation.job.name}}/{{allocation.allocation.taskGroupName}}
-
- {{#if this.system.shouldShowNamespaces}}
- -
- Namespace
- {{allocation.allocation.job.namespace.name}}
-
- {{/if}}
- -
- Memory
- {{format-scheduled-bytes
- allocation.memory
- start="MiB"
- }}
-
- -
- CPU
- {{format-scheduled-hertz allocation.cpu}}
-
-
- {{/let}}
-
-
- {{#if this.activeAllocation}}
-
-
- {{#each this.activeEdges as |edge|}}
-
- {{/each}}
-
-
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/topo-viz/datacenter.gjs b/ui/app/components/topo-viz/datacenter.gjs
new file mode 100644
index 00000000000..3d3d14e553f
--- /dev/null
+++ b/ui/app/components/topo-viz/datacenter.gjs
@@ -0,0 +1,104 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import formatBytes from 'nomad-ui/helpers/format-bytes';
+import formatHertz from 'nomad-ui/helpers/format-hertz';
+import FlexMasonry from 'nomad-ui/components/flex-masonry';
+import TopoVizNode from 'nomad-ui/components/topo-viz/node';
+
+export default class TopoVizDatacenter extends Component {
+ get scheduledAllocations() {
+ return this.args.datacenter.nodes.reduce(
+ (all, node) =>
+ all.concat(node.allocations.filterBy('allocation.isScheduled')),
+ [],
+ );
+ }
+
+ get aggregatedAllocationResources() {
+ return this.scheduledAllocations.reduce(
+ (totals, allocation) => {
+ totals.cpu += allocation.cpu;
+ totals.memory += allocation.memory;
+ return totals;
+ },
+ { cpu: 0, memory: 0 },
+ );
+ }
+
+ get aggregatedNodeResources() {
+ return this.args.datacenter.nodes.reduce(
+ (totals, node) => {
+ totals.cpu += node.cpu;
+ totals.memory += node.memory;
+ return totals;
+ },
+ { cpu: 0, memory: 0 },
+ );
+ }
+
+
+
+
+ {{@datacenter.name}}
+ {{this.scheduledAllocations.length}} Allocs
+ {{@datacenter.nodes.length}} Nodes
+
+ {{formatBytes
+ this.aggregatedAllocationResources.memory
+ start="MiB"
+ }}
+ /
+ {{formatBytes
+ this.aggregatedNodeResources.memory
+ start="MiB"
+ }},
+ {{formatHertz
+ this.aggregatedAllocationResources.cpu
+ }}
+ /
+ {{formatHertz
+ this.aggregatedNodeResources.cpu
+ }}
+
+
+
+ {{#let (if @isSingleColumn 1 2) as |layoutColumns|}}
+
+
+
+ {{/let}}
+
+
+
+}
diff --git a/ui/app/components/topo-viz/datacenter.hbs b/ui/app/components/topo-viz/datacenter.hbs
deleted file mode 100644
index c0bce0c6a90..00000000000
--- a/ui/app/components/topo-viz/datacenter.hbs
+++ /dev/null
@@ -1,54 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
-
- {{@datacenter.name}}
- {{this.scheduledAllocations.length}} Allocs
- {{@datacenter.nodes.length}} Nodes
-
- {{format-bytes
- this.aggregatedAllocationResources.memory
- start="MiB"
- }}
- /
- {{format-bytes
- this.aggregatedNodeResources.memory
- start="MiB"
- }},
- {{format-hertz
- this.aggregatedAllocationResources.cpu
- }}
- /
- {{format-hertz
- this.aggregatedNodeResources.cpu
- }}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/topo-viz/datacenter.js b/ui/app/components/topo-viz/datacenter.js
deleted file mode 100644
index 8fd64035c6a..00000000000
--- a/ui/app/components/topo-viz/datacenter.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-
-export default class TopoVizDatacenter extends Component {
- get scheduledAllocations() {
- return this.args.datacenter.nodes.reduce(
- (all, node) =>
- all.concat(node.allocations.filterBy('allocation.isScheduled')),
- [],
- );
- }
-
- get aggregatedAllocationResources() {
- return this.scheduledAllocations.reduce(
- (totals, allocation) => {
- totals.cpu += allocation.cpu;
- totals.memory += allocation.memory;
- return totals;
- },
- { cpu: 0, memory: 0 },
- );
- }
-
- get aggregatedNodeResources() {
- return this.args.datacenter.nodes.reduce(
- (totals, node) => {
- totals.cpu += node.cpu;
- totals.memory += node.memory;
- return totals;
- },
- { cpu: 0, memory: 0 },
- );
- }
-}
diff --git a/ui/app/components/topo-viz/node.gjs b/ui/app/components/topo-viz/node.gjs
new file mode 100644
index 00000000000..fa828622d4d
--- /dev/null
+++ b/ui/app/components/topo-viz/node.gjs
@@ -0,0 +1,460 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { guidFor } from '@ember/object/internals';
+import { LinkTo } from '@ember/routing';
+import {
+ HdsIcon,
+ HdsTooltipButton,
+} from '@hashicorp/design-system-components/components';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+import { eq, not, or } from 'ember-truth-helpers';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import hdsTooltip from '@hashicorp/design-system-components/modifiers/hds-tooltip';
+
+import windowResize from 'nomad-ui/modifiers/window-resize';
+import formatScheduledBytes from 'nomad-ui/helpers/format-scheduled-bytes';
+import formatScheduledHertz from 'nomad-ui/helpers/format-scheduled-hertz';
+
+export default class TopoVizNode extends Component {
+ @tracked data = { cpu: [], memory: [] };
+ @tracked dimensionsWidth = 0;
+ @tracked padding = 5;
+ @tracked activeAllocation = null;
+
+ get height() {
+ return this.args.heightScale
+ ? this.args.heightScale(this.args.node.memory)
+ : 15;
+ }
+
+ get labelHeight() {
+ return this.height / 2;
+ }
+
+ get paddingLeft() {
+ const labelWidth = 20;
+ return this.padding + labelWidth;
+ }
+
+ // Since strokes are placed centered on the perimeter of fills, The width of the stroke needs to be removed from
+ // the height of the fill to match unstroked height and avoid clipping.
+ get selectedHeight() {
+ return this.height - 1;
+ }
+
+ // Since strokes are placed centered on the perimeter of fills, half the width of the stroke needs to be added to
+ // the yOffset to match heights with unstroked shapes.
+ get selectedYOffset() {
+ return this.height + 2.5;
+ }
+
+ get yOffset() {
+ return this.height + 2;
+ }
+
+ get maskHeight() {
+ return this.height + this.yOffset;
+ }
+
+ get totalHeight() {
+ return this.maskHeight + this.padding * 2;
+ }
+
+ get maskId() {
+ return `topo-viz-node-mask-${guidFor(this)}`;
+ }
+
+ get count() {
+ return this.allocations.length;
+ }
+
+ get allocations() {
+ // Sort by the delta between memory and cpu percent. This creates the least amount of
+ // drift between the positional alignment of an alloc's cpu and memory representations.
+ return this.args.node.allocations
+ .filterBy('allocation.isScheduled')
+ .sort((a, b) => {
+ const deltaA = Math.abs(a.memoryPercent - a.cpuPercent);
+ const deltaB = Math.abs(b.memoryPercent - b.cpuPercent);
+ return deltaA - deltaB;
+ });
+ }
+
+ reloadNode = async () => {
+ if (this.args.node.isPartial) {
+ await this.args.node.reload();
+ this.data = this.computeData(this.dimensionsWidth);
+ }
+ };
+
+ render = (svg) => {
+ this.dimensionsWidth = svg.clientWidth - this.padding - this.paddingLeft;
+ this.data = this.computeData(this.dimensionsWidth);
+ };
+
+ updateRender = (svg) => {
+ // Only update all data when the width changes
+ const newWidth = svg.clientWidth - this.padding - this.paddingLeft;
+ if (newWidth !== this.dimensionsWidth) {
+ this.dimensionsWidth = newWidth;
+ this.data = this.computeData(this.dimensionsWidth);
+ }
+ };
+
+ highlightAllocation = (allocation, { target }) => {
+ this.activeAllocation = allocation;
+ this.args.onAllocationFocus &&
+ this.args.onAllocationFocus(allocation, target);
+ };
+
+ allocationBlur = () => {
+ this.args.onAllocationBlur && this.args.onAllocationBlur();
+ };
+
+ clearHighlight = () => {
+ this.activeAllocation = null;
+ };
+
+ selectNode = () => {
+ if (this.args.isDense && this.args.onNodeSelect) {
+ this.args.onNodeSelect(this.args.node.isSelected ? null : this.args.node);
+ }
+ };
+
+ selectAllocation = (allocation) => {
+ if (this.args.onAllocationSelect) this.args.onAllocationSelect(allocation);
+ };
+
+ containsActiveTaskGroup() {
+ return this.args.node.allocations.some(
+ (allocation) =>
+ allocation.taskGroupName === this.args.activeTaskGroup &&
+ allocation.belongsTo('job').id() === this.args.activeJobId,
+ );
+ }
+
+ computeData(width) {
+ const allocations = this.allocations;
+ let cpuOffset = 0;
+ let memoryOffset = 0;
+
+ const cpu = [];
+ const memory = [];
+ for (const allocation of allocations) {
+ const { cpuPercent, memoryPercent, isSelected } = allocation;
+ const isFirst = allocation === allocations[0];
+
+ let cpuWidth = cpuPercent * width - 1;
+ let memoryWidth = memoryPercent * width - 1;
+ if (isFirst) {
+ cpuWidth += 0.5;
+ memoryWidth += 0.5;
+ }
+ if (isSelected) {
+ cpuWidth--;
+ memoryWidth--;
+ }
+
+ cpu.push({
+ allocation,
+ offset: cpuOffset * 100,
+ percent: cpuPercent * 100,
+ width: Math.max(cpuWidth, 0),
+ x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
+ className: allocation.allocation.clientStatus,
+ });
+ memory.push({
+ allocation,
+ offset: memoryOffset * 100,
+ percent: memoryPercent * 100,
+ width: Math.max(memoryWidth, 0),
+ x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
+ className: allocation.allocation.clientStatus,
+ });
+
+ cpuOffset += cpuPercent;
+ memoryOffset += memoryPercent;
+ }
+
+ const cpuRemainder = {
+ x: cpuOffset * width + 0.5,
+ width: Math.max(width - cpuOffset * width, 0),
+ };
+ const memoryRemainder = {
+ x: memoryOffset * width + 0.5,
+ width: Math.max(width - memoryOffset * width, 0),
+ };
+
+ return {
+ cpu,
+ memory,
+ cpuRemainder,
+ memoryRemainder,
+ cpuLabel: { x: -this.paddingLeft / 2, y: this.height / 2 + this.yOffset },
+ memoryLabel: { x: -this.paddingLeft / 2, y: this.height / 2 },
+ };
+ }
+
+
+
+ {{#unless @isDense}}
+
+ {{#if @node.node.isDraining}}
+
+
+
+ {{else if (not @node.node.isEligible)}}
+
+
+
+ {{/if}}
+ {{@node.node.name}}
+ {{this.count}} Allocs
+ {{#if @node.node.nodePool}}
+ {{@node.node.nodePool}}
+ {{/if}}
+ {{#if @node.memory}}
+ {{formatScheduledBytes
+ @node.memory
+ start="MiB"
+ }}
+ {{/if}}
+ {{#if @node.cpu}}
+ {{formatScheduledHertz @node.cpu}}
+ {{/if}}
+ {{#if @node.node.status}}
+ {{@node.node.status}}
+ {{/if}}
+ {{#if @node.node.version}}
+ {{@node.node.version}}
+ {{/if}}
+
+ {{/unless}}
+
+
+
+
+
+
+
+ {{#if this.allocations.length}}
+
+
+ {{#if this.data.memoryLabel}}
+ M
+ {{/if}}
+ {{#if this.data.memoryRemainder}}
+
+ {{/if}}
+ {{#each this.data.memory key="allocation.id" as |memory|}}
+
+
+ {{#if
+ (or
+ (eq memory.className "starting")
+ (eq memory.className "pending")
+ )
+ }}
+
+ {{/if}}
+
+ {{/each}}
+
+
+ {{#if this.data.cpuLabel}}
+ C
+ {{/if}}
+ {{#if this.data.cpuRemainder}}
+
+ {{/if}}
+ {{#each this.data.cpu key="allocation.id" as |cpu|}}
+
+
+ {{#if
+ (or
+ (eq cpu.className "starting") (eq cpu.className "pending")
+ )
+ }}
+
+ {{/if}}
+
+ {{/each}}
+
+
+ {{else}}
+ Empty Client
+ {{/if}}
+
+
+
+}
diff --git a/ui/app/components/topo-viz/node.hbs b/ui/app/components/topo-viz/node.hbs
deleted file mode 100644
index d9094426315..00000000000
--- a/ui/app/components/topo-viz/node.hbs
+++ /dev/null
@@ -1,250 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{#unless @isDense}}
-
- {{#if @node.node.isDraining}}
-
-
-
- {{else if (not @node.node.isEligible)}}
-
-
-
- {{/if}}
- {{@node.node.name}}
- {{this.count}} Allocs
- {{#if @node.node.nodePool}}
- {{@node.node.nodePool}}
- {{/if}}
- {{#if @node.memory}}
- {{format-scheduled-bytes
- @node.memory
- start="MiB"
- }}
- {{/if}}
- {{#if @node.cpu}}
- {{format-scheduled-hertz @node.cpu}}
- {{/if}}
- {{#if @node.node.status}}
- {{@node.node.status}}
- {{/if}}
- {{#if @node.node.version}}
- {{@node.node.version}}
- {{/if}}
-
- {{/unless}}
-
-
-
-
-
-
-
- {{#if this.allocations.length}}
-
-
- {{#if this.data.memoryLabel}}
- M
- {{/if}}
- {{#if this.data.memoryRemainder}}
-
- {{/if}}
- {{#each this.data.memory key="allocation.id" as |memory|}}
-
-
- {{#if
- (or
- (eq memory.className "starting")
- (eq memory.className "pending")
- )
- }}
-
- {{/if}}
-
- {{/each}}
-
-
- {{#if this.data.cpuLabel}}
- C
- {{/if}}
- {{#if this.data.cpuRemainder}}
-
- {{/if}}
- {{#each this.data.cpu key="allocation.id" as |cpu|}}
-
-
- {{#if
- (or (eq cpu.className "starting") (eq cpu.className "pending"))
- }}
-
- {{/if}}
-
- {{/each}}
-
-
- {{else}}
- Empty Client
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/topo-viz/node.js b/ui/app/components/topo-viz/node.js
deleted file mode 100644
index 7d94144e080..00000000000
--- a/ui/app/components/topo-viz/node.js
+++ /dev/null
@@ -1,198 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-import { guidFor } from '@ember/object/internals';
-
-export default class TopoVizNode extends Component {
- @tracked data = { cpu: [], memory: [] };
- @tracked dimensionsWidth = 0;
- @tracked padding = 5;
- @tracked activeAllocation = null;
-
- get height() {
- return this.args.heightScale
- ? this.args.heightScale(this.args.node.memory)
- : 15;
- }
-
- get labelHeight() {
- return this.height / 2;
- }
-
- get paddingLeft() {
- const labelWidth = 20;
- return this.padding + labelWidth;
- }
-
- // Since strokes are placed centered on the perimeter of fills, The width of the stroke needs to be removed from
- // the height of the fill to match unstroked height and avoid clipping.
- get selectedHeight() {
- return this.height - 1;
- }
-
- // Since strokes are placed centered on the perimeter of fills, half the width of the stroke needs to be added to
- // the yOffset to match heights with unstroked shapes.
- get selectedYOffset() {
- return this.height + 2.5;
- }
-
- get yOffset() {
- return this.height + 2;
- }
-
- get maskHeight() {
- return this.height + this.yOffset;
- }
-
- get totalHeight() {
- return this.maskHeight + this.padding * 2;
- }
-
- get maskId() {
- return `topo-viz-node-mask-${guidFor(this)}`;
- }
-
- get count() {
- return this.allocations.length;
- }
-
- get allocations() {
- // Sort by the delta between memory and cpu percent. This creates the least amount of
- // drift between the positional alignment of an alloc's cpu and memory representations.
- return this.args.node.allocations
- .filterBy('allocation.isScheduled')
- .sort((a, b) => {
- const deltaA = Math.abs(a.memoryPercent - a.cpuPercent);
- const deltaB = Math.abs(b.memoryPercent - b.cpuPercent);
- return deltaA - deltaB;
- });
- }
-
- @action
- async reloadNode() {
- if (this.args.node.isPartial) {
- await this.args.node.reload();
- this.data = this.computeData(this.dimensionsWidth);
- }
- }
-
- @action
- render(svg) {
- this.dimensionsWidth = svg.clientWidth - this.padding - this.paddingLeft;
- this.data = this.computeData(this.dimensionsWidth);
- }
-
- @action
- updateRender(svg) {
- // Only update all data when the width changes
- const newWidth = svg.clientWidth - this.padding - this.paddingLeft;
- if (newWidth !== this.dimensionsWidth) {
- this.dimensionsWidth = newWidth;
- this.data = this.computeData(this.dimensionsWidth);
- }
- }
-
- @action
- highlightAllocation(allocation, { target }) {
- this.activeAllocation = allocation;
- this.args.onAllocationFocus &&
- this.args.onAllocationFocus(allocation, target);
- }
-
- @action
- allocationBlur() {
- this.args.onAllocationBlur && this.args.onAllocationBlur();
- }
-
- @action
- clearHighlight() {
- this.activeAllocation = null;
- }
-
- @action
- selectNode() {
- if (this.args.isDense && this.args.onNodeSelect) {
- this.args.onNodeSelect(this.args.node.isSelected ? null : this.args.node);
- }
- }
-
- @action
- selectAllocation(allocation) {
- if (this.args.onAllocationSelect) this.args.onAllocationSelect(allocation);
- }
-
- containsActiveTaskGroup() {
- return this.args.node.allocations.some(
- (allocation) =>
- allocation.taskGroupName === this.args.activeTaskGroup &&
- allocation.belongsTo('job').id() === this.args.activeJobId,
- );
- }
-
- computeData(width) {
- const allocations = this.allocations;
- let cpuOffset = 0;
- let memoryOffset = 0;
-
- const cpu = [];
- const memory = [];
- for (const allocation of allocations) {
- const { cpuPercent, memoryPercent, isSelected } = allocation;
- const isFirst = allocation === allocations[0];
-
- let cpuWidth = cpuPercent * width - 1;
- let memoryWidth = memoryPercent * width - 1;
- if (isFirst) {
- cpuWidth += 0.5;
- memoryWidth += 0.5;
- }
- if (isSelected) {
- cpuWidth--;
- memoryWidth--;
- }
-
- cpu.push({
- allocation,
- offset: cpuOffset * 100,
- percent: cpuPercent * 100,
- width: Math.max(cpuWidth, 0),
- x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
- className: allocation.allocation.clientStatus,
- });
- memory.push({
- allocation,
- offset: memoryOffset * 100,
- percent: memoryPercent * 100,
- width: Math.max(memoryWidth, 0),
- x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
- className: allocation.allocation.clientStatus,
- });
-
- cpuOffset += cpuPercent;
- memoryOffset += memoryPercent;
- }
-
- const cpuRemainder = {
- x: cpuOffset * width + 0.5,
- width: Math.max(width - cpuOffset * width, 0),
- };
- const memoryRemainder = {
- x: memoryOffset * width + 0.5,
- width: Math.max(width - memoryOffset * width, 0),
- };
-
- return {
- cpu,
- memory,
- cpuRemainder,
- memoryRemainder,
- cpuLabel: { x: -this.paddingLeft / 2, y: this.height / 2 + this.yOffset },
- memoryLabel: { x: -this.paddingLeft / 2, y: this.height / 2 },
- };
- }
-}
diff --git a/ui/app/components/trigger.js b/ui/app/components/trigger.gjs
similarity index 84%
rename from ui/app/components/trigger.js
rename to ui/app/components/trigger.gjs
index 5185b2b9aa1..1567933c00f 100644
--- a/ui/app/components/trigger.js
+++ b/ui/app/components/trigger.gjs
@@ -3,9 +3,9 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
+import { hash } from '@ember/helper';
import { task } from 'ember-concurrency';
const noOp = () => undefined;
@@ -54,20 +54,20 @@ export default class Trigger extends Component {
this.error = null;
}
- @task(function* () {
+ triggerTask = task(async () => {
this._reset();
try {
- this.result = yield this.args.do();
+ this.result = await this.args.do();
this.onSuccess(this.result);
} catch (e) {
this.error = { Error: e };
this.onError(this.error);
}
- })
- triggerTask;
+ });
- @action
- onTrigger() {
+ onTrigger = () => {
this.triggerTask.perform();
- }
+ };
+
+ {{yield (hash data=this.data fns=this.fns)}}
}
diff --git a/ui/app/components/trigger.hbs b/ui/app/components/trigger.hbs
deleted file mode 100644
index 0df99147831..00000000000
--- a/ui/app/components/trigger.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{yield (hash data=this.data fns=this.fns)}}
\ No newline at end of file
diff --git a/ui/app/components/two-step-button.gjs b/ui/app/components/two-step-button.gjs
new file mode 100644
index 00000000000..ceaf033aef7
--- /dev/null
+++ b/ui/app/components/two-step-button.gjs
@@ -0,0 +1,159 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { HdsButton } from '@hashicorp/design-system-components/components';
+import onClickOutside from 'ember-click-outside/modifiers/on-click-outside';
+
+const noOp = () => {};
+
+export default class TwoStepButton extends Component {
+ @tracked state = 'idle';
+
+ get isIdle() {
+ return this.state === 'idle';
+ }
+
+ get isPendingConfirmation() {
+ return this.state === 'prompt';
+ }
+
+ get idleText() {
+ return this.args.idleText ?? '';
+ }
+
+ get cancelText() {
+ return this.args.cancelText ?? '';
+ }
+
+ get confirmText() {
+ return this.args.confirmText ?? '';
+ }
+
+ get confirmationMessage() {
+ return this.args.confirmationMessage ?? '';
+ }
+
+ get awaitingConfirmation() {
+ return this.args.awaitingConfirmation ?? false;
+ }
+
+ get disabled() {
+ return this.args.disabled ?? false;
+ }
+
+ get alignRight() {
+ return this.args.alignRight ?? false;
+ }
+
+ get inlineText() {
+ return this.args.inlineText ?? false;
+ }
+
+ get title() {
+ return this.args.title ?? '';
+ }
+
+ get onConfirm() {
+ return this.args.onConfirm ?? noOp;
+ }
+
+ get onCancel() {
+ return this.args.onCancel ?? noOp;
+ }
+
+ get onPrompt() {
+ return this.args.onPrompt ?? noOp;
+ }
+
+ get rootClass() {
+ const classes = ['two-step-button'];
+
+ if (this.inlineText) {
+ classes.push('has-inline-text');
+ }
+
+ if (this.args.fadingBackground) {
+ classes.push('has-fading-background');
+ }
+
+ return classes.join(' ');
+ }
+
+ setToIdle = () => {
+ this.state = 'idle';
+ };
+
+ promptForConfirmation = () => {
+ this.onPrompt();
+ this.state = 'prompt';
+ };
+
+ cancel = () => {
+ this.setToIdle();
+ this.onCancel();
+ };
+
+ confirm = () => {
+ this.setToIdle();
+ this.onConfirm();
+ };
+
+ handleOutsideClick = () => {
+ if (this.isPendingConfirmation && !this.awaitingConfirmation) {
+ this.onCancel();
+ this.setToIdle();
+ }
+ };
+
+
+
+ {{#if this.isIdle}}
+
+ {{else if this.isPendingConfirmation}}
+
+ {{this.confirmationMessage}}
+
+
+
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/two-step-button.hbs b/ui/app/components/two-step-button.hbs
deleted file mode 100644
index 5d5193a6754..00000000000
--- a/ui/app/components/two-step-button.hbs
+++ /dev/null
@@ -1,44 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{#if this.isIdle}}
-
-{{else if this.isPendingConfirmation}}
-
- {{this.confirmationMessage}}
-
-
-
-{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/two-step-button.js b/ui/app/components/two-step-button.js
deleted file mode 100644
index 6ed487a0e84..00000000000
--- a/ui/app/components/two-step-button.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@ember/component';
-import { action } from '@ember/object';
-import { next } from '@ember/runloop';
-import { equal } from '@ember/object/computed';
-import { task, waitForEvent } from 'ember-concurrency';
-import RSVP from 'rsvp';
-import { classNames, classNameBindings } from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@classNames('two-step-button')
-@classNameBindings(
- 'inlineText:has-inline-text',
- 'fadingBackground:has-fading-background',
-)
-export default class TwoStepButton extends Component {
- idleText = '';
- cancelText = '';
- confirmText = '';
- confirmationMessage = '';
- awaitingConfirmation = false;
- disabled = false;
- alignRight = false;
- inlineText = false;
- title = '';
- onConfirm() {}
- onCancel() {}
- onPrompt() {}
-
- state = 'idle';
- @equal('state', 'idle') isIdle;
- @equal('state', 'prompt') isPendingConfirmation;
-
- @task(function* () {
- while (true) {
- let ev = yield waitForEvent(document.body, 'click');
- if (!this.element.contains(ev.target) && !this.awaitingConfirmation) {
- if (this.onCancel) {
- this.onCancel();
- }
- this.send('setToIdle');
- }
- }
- })
- cancelOnClickOutside;
-
- @action
- setToIdle() {
- this.set('state', 'idle');
- this.cancelOnClickOutside.cancelAll();
- }
-
- @action
- promptForConfirmation() {
- if (this.onPrompt) {
- this.onPrompt();
- }
- this.set('state', 'prompt');
- next(() => {
- this.cancelOnClickOutside.perform();
- });
- }
-
- @action
- confirm() {
- RSVP.resolve(this.onConfirm()).then(() => {
- this.send('setToIdle');
- });
- }
-}
diff --git a/ui/app/components/variable-form.gjs b/ui/app/components/variable-form.gjs
new file mode 100644
index 00000000000..4625d3b098b
--- /dev/null
+++ b/ui/app/components/variable-form.gjs
@@ -0,0 +1,793 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { concat, fn, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { trackedArray } from '@ember/reactive/collections';
+import { service } from '@ember/service';
+import { didInsert, didUpdate } from '@ember/render-modifiers';
+import { copy } from 'ember-copy';
+import EmberObject from '@ember/object';
+import { and, eq, not, or } from 'ember-truth-helpers';
+import {
+ HdsButton,
+ HdsCopySnippet,
+ HdsFormTextInputField,
+} from '@hashicorp/design-system-components/components';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import autofocus from 'nomad-ui/modifiers/autofocus';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+import { stringifyObject as stringifyObjectValue } from 'nomad-ui/helpers/stringify-object';
+import stringifyObject from 'nomad-ui/helpers/stringify-object';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import { trimPath } from 'nomad-ui/helpers/trim-path';
+import VariableFormInputGroup from 'nomad-ui/components/variable-form/input-group';
+import VariableFormJobTemplateEditor from 'nomad-ui/components/variable-form/job-template-editor';
+import VariableFormNamespaceFilter from 'nomad-ui/components/variable-form/namespace-filter';
+import VariableFormRelatedEntities from 'nomad-ui/components/variable-form/related-entities';
+import notifyConflict from 'nomad-ui/utils/notify-conflict';
+import pluralize from 'nomad-ui/helpers/pluralize';
+import isEqual from 'fast-deep-equal';
+
+const EMPTY_KV = {
+ key: '',
+ value: '',
+ warnings: EmberObject.create(),
+};
+
+const invalidKeyCharactersRegex = new RegExp(/[^_\p{Letter}\p{Number}]/gu);
+
+export default class VariableForm extends Component {
+ @service notifications;
+ @service router;
+ @service store;
+ @service abilities;
+
+ @tracked variableNamespace = null;
+ @tracked namespaceOptions = null;
+ @tracked hasConflict = false;
+ @tracked conflictingVariable = null;
+ @tracked path = '';
+ @tracked JSONError = null;
+ @tracked keyValues = trackedArray([]);
+ @tracked view = 'table';
+ @tracked JSONItems = '{}';
+
+ hasRemovedExitHandler = false;
+
+ constructor() {
+ super(...arguments);
+ this.path = this.args.model.path;
+ this.view = this.args.view;
+ this.addExitHandler();
+ }
+
+ setNamespace = (namespace) => {
+ this.variableNamespace = namespace;
+ };
+
+ setNamespaceOptions = (options) => {
+ this.namespaceOptions = options;
+
+ if (options.length) {
+ this.variableNamespace = this.args.model.namespace;
+ }
+ };
+
+ get shouldDisableSave() {
+ const disallowedPath =
+ this.path?.startsWith('nomad/') &&
+ !(
+ this.path?.startsWith('nomad/jobs') ||
+ (this.path?.startsWith('nomad/job-templates') &&
+ trimPath([this.path]) !== 'nomad/job-templates')
+ );
+ return !!this.JSONError || !this.path || disallowedPath;
+ }
+
+ get isJobTemplateVariable() {
+ return this.path?.startsWith('nomad/job-templates/');
+ }
+
+ get jobTemplateName() {
+ return this.path.split('nomad/job-templates/').slice(-1);
+ }
+
+ establishKeyValues = () => {
+ const keyValues = (copy(this.args.model?.keyValues || []) || []).map(
+ (kv) => ({
+ key: kv.key,
+ value: kv.value,
+ warnings: EmberObject.create(),
+ }),
+ );
+
+ if (!this.args.model?.isNew) {
+ keyValues.push(copy(EMPTY_KV));
+ }
+ this.keyValues = trackedArray(keyValues);
+
+ this.JSONItems = stringifyObjectValue([
+ this.keyValues.reduce((accumulator, { key, value }) => {
+ accumulator[key] = value;
+ return accumulator;
+ }, {}),
+ ]);
+ };
+
+ get duplicatePathWarning() {
+ const existingVariables = normalizeCollection(this.args.existingVariables);
+ const pathValue = trimPath([this.path]);
+ const existingVariable = existingVariables
+ .filter((variable) => variable !== this.args.model)
+ .find(
+ (variable) =>
+ variable.path === pathValue &&
+ (variable.namespace === this.variableNamespace ||
+ !this.variableNamespace),
+ );
+
+ if (existingVariable) {
+ return {
+ path: existingVariable.path,
+ };
+ }
+
+ return null;
+ }
+
+ get hasInvalidPath() {
+ return !new RegExp('^[a-zA-Z0-9-_~/]{1,128}$').test(trimPath([this.path]));
+ }
+
+ validateKey = (entry, event) => {
+ const value = event.target.value;
+ const invalidChars = value.match(invalidKeyCharactersRegex);
+ if (invalidChars) {
+ const invalidCharsOutput = [...new Set(invalidChars)]
+ .sort()
+ .map((character) => `'${character}'`);
+ entry.warnings.set(
+ 'dottedKeyError',
+ `${value} contains characters [${invalidCharsOutput}] that require the "index" function for direct access in templates.`,
+ );
+ } else {
+ delete entry.warnings.dottedKeyError;
+ entry.warnings.notifyPropertyChange('dottedKeyError');
+ }
+
+ const existingKeys = this.keyValues.map((kv) => kv.key);
+ if (existingKeys.includes(value)) {
+ entry.warnings.set('duplicateKeyError', 'Key already exists.');
+ } else {
+ delete entry.warnings.duplicateKeyError;
+ entry.warnings.notifyPropertyChange('duplicateKeyError');
+ }
+
+ entry.key = value;
+ this.keyValues = trackedArray([...this.keyValues]);
+ };
+
+ setEntryValue = (entry, valueOrEvent) => {
+ const nextValue =
+ typeof valueOrEvent === 'string'
+ ? valueOrEvent
+ : (valueOrEvent?.target?.value ??
+ valueOrEvent?.detail?.value ??
+ (typeof valueOrEvent?.detail === 'string'
+ ? valueOrEvent.detail
+ : ''));
+
+ entry.value = nextValue;
+ this.keyValues = trackedArray([...this.keyValues]);
+ };
+
+ appendRow = () => {
+ const newRow = copy(EMPTY_KV);
+ newRow.warnings = EmberObject.create();
+ this.keyValues.push(newRow);
+ };
+
+ deleteRow = (row) => {
+ const index = this.keyValues.indexOf(row);
+ if (index > -1) {
+ this.keyValues.splice(index, 1);
+ }
+ };
+
+ refresh = () => {
+ window.location.reload();
+ };
+
+ saveWithOverwrite = (event) => {
+ this.conflictingVariable = null;
+ this.save(event, true);
+ };
+
+ setModelProperty = (key, value) => {
+ const model = this.args.model;
+
+ if (typeof model?.set === 'function') {
+ model.set(key, value);
+ return;
+ }
+
+ if (model) {
+ model[key] = value;
+ }
+ };
+
+ setModelPath = (event) => {
+ this.path = event.target.value;
+ this.setModelProperty('path', event.target.value);
+ };
+
+ updateKeyValue = (key, value) => {
+ const existing = this.keyValues.find((kv) => kv.key === key);
+ if (existing) {
+ existing.value = value;
+ } else {
+ this.keyValues.push({ key, value, warnings: EmberObject.create() });
+ }
+ };
+
+ save = async (event, overwrite = false) => {
+ event?.preventDefault?.();
+
+ if (this.view === 'json') {
+ this.translateAndValidateItems('table');
+ }
+ try {
+ const nonEmptyItems = this.keyValues.filter(
+ (item) => item.key.trim() && item.value,
+ );
+ if (!nonEmptyItems.length) {
+ throw new Error('Please provide at least one key/value pair.');
+ } else {
+ this.keyValues = trackedArray(nonEmptyItems);
+ }
+
+ if (this.args.model?.isNew) {
+ if (this.namespaceOptions) {
+ this.setModelProperty('namespace', this.variableNamespace);
+ } else {
+ const [namespace] = this.store.peekAll('namespace').toArray();
+ this.setModelProperty('namespace', namespace.id);
+ }
+ }
+
+ this.setModelProperty('keyValues', this.keyValues);
+ this.setModelProperty('path', this.path);
+
+ if (typeof this.args.model?.setAndTrimPath === 'function') {
+ this.args.model.setAndTrimPath();
+ } else {
+ this.setModelProperty('path', trimPath([this.path]));
+ }
+
+ await this.args.model.save({ adapterOptions: { overwrite } });
+
+ this.notifications.add({
+ title: 'Variable saved',
+ message: `${this.path} successfully saved`,
+ color: 'success',
+ });
+
+ if (
+ this.abilities.can('read job', null, {
+ namespace: this.variableNamespace || 'default',
+ })
+ ) {
+ this.updateJobVariables(this.args.model.pathLinkedEntities.job);
+ }
+
+ this.removeExitHandler();
+ this.router.transitionTo('variables.variable', this.args.model.id);
+ } catch (error) {
+ notifyConflict(this)(error);
+ if (!this.hasConflict) {
+ let errorMessage = error;
+ if (error.errors && error.errors.length > 0) {
+ const nameInvalidError = error.errors.find(
+ (err) => err.status === 400,
+ );
+ if (nameInvalidError) {
+ errorMessage = nameInvalidError.detail;
+ }
+ }
+
+ this.notifications.add({
+ title: `Error saving ${this.path}`,
+ message: errorMessage,
+ color: 'critical',
+ sticky: true,
+ });
+ } else {
+ if (error.errors[0]?.detail) {
+ this.conflictingVariable = error.errors[0].detail;
+ }
+ window.scrollTo(0, 0);
+ }
+ }
+ };
+
+ async updateJobVariables(jobName) {
+ if (!jobName) {
+ return;
+ }
+ const fullJobId = JSON.stringify([
+ jobName,
+ this.variableNamespace || 'default',
+ ]);
+ const job = await this.store.findRecord('job', fullJobId, { reload: true });
+ if (job) {
+ if (typeof job.variables?.pushObject === 'function') {
+ job.variables.pushObject(this.args.model);
+ } else {
+ const variables = normalizeCollection(job.variables);
+ job.variables = [...variables, this.args.model];
+ }
+ }
+ }
+
+ get lastKeyValue() {
+ return this.keyValues[this.keyValues.length - 1];
+ }
+
+ get isJSONView() {
+ return this.args.view === 'json';
+ }
+
+ onViewChange = () => {
+ if (this.args.view !== this.view) {
+ this.translateAndValidateItems(this.args.view);
+ this.view = this.args.view;
+ }
+ };
+
+ translateAndValidateItems = (view) => {
+ if (view === 'json') {
+ const items = this.liveTableItemsForJSON();
+
+ this.JSONItems = stringifyObjectValue([
+ items
+ .filter((item) => item.key.trim() && item.value)
+ .reduce((accumulator, { key, value }) => {
+ const normalizedKey = key.trim();
+ accumulator[normalizedKey] = value;
+ return accumulator;
+ }, {}),
+ ]);
+
+ if (!Object.keys(JSON.parse(this.JSONItems)).length) {
+ this.JSONItems = stringifyObjectValue([{ '': '' }]);
+ }
+ } else if (view === 'table') {
+ this.keyValues = trackedArray(
+ Object.entries(JSON.parse(this.JSONItems)).map(([key, value]) => ({
+ key,
+ value: typeof value === 'string' ? value : JSON.stringify(value),
+ warnings: EmberObject.create(),
+ })),
+ );
+
+ if (!Object.keys(JSON.parse(this.JSONItems)).length) {
+ this.appendRow();
+ }
+ }
+
+ this.JSONError = null;
+ };
+
+ liveTableItemsForJSON() {
+ // Masked input can buffer value updates; read current controls at conversion time.
+ if (typeof document === 'undefined') {
+ return this.keyValues;
+ }
+
+ const rows = Array.from(document.querySelectorAll('.key-value'));
+ if (!rows.length) {
+ return this.keyValues;
+ }
+
+ const pickControlValue = (row, selectors, { excludeWithin } = {}) => {
+ const controls = selectors.flatMap((selector) =>
+ Array.from(row.querySelectorAll(selector)),
+ );
+
+ const candidates = controls
+ .filter((control) => control && typeof control.value === 'string')
+ .filter((control) => control.type !== 'hidden')
+ .filter((control) => control.getAttribute('aria-hidden') !== 'true')
+ .filter((control) => {
+ if (!excludeWithin) {
+ return true;
+ }
+
+ return !excludeWithin.contains(control);
+ });
+
+ const visibleCandidates = candidates.filter(
+ (control) =>
+ control.offsetParent !== null || control.getClientRects().length > 0,
+ );
+
+ const preferred = visibleCandidates.length
+ ? visibleCandidates
+ : candidates;
+
+ if (!preferred.length) {
+ return undefined;
+ }
+
+ // Prefer controls that currently hold text (avoids empty mirror inputs).
+ const withValue = preferred.find((control) => control.value.length > 0);
+ return (withValue || preferred[0]).value;
+ };
+
+ return rows.map((row, index) => {
+ const trackedItem = this.keyValues[index] || { key: '', value: '' };
+
+ const keyValueFromControl = pickControlValue(row, [
+ 'input[data-test-var-key]',
+ 'textarea[data-test-var-key]',
+ '[data-test-var-key] input',
+ '[data-test-var-key] textarea',
+ ]);
+ const keyWrapper = row.querySelector('[data-test-var-key]');
+
+ const valueFromControl =
+ pickControlValue(row, [
+ 'textarea[data-test-var-value]',
+ '[data-test-var-value] textarea',
+ 'input[data-test-var-value]',
+ '[data-test-var-value] input',
+ ]) ??
+ // Fallback for masked inputs that may not expose stable data-test hooks
+ // on the active internal control.
+ pickControlValue(row, ['textarea', 'input:not([type="hidden"])'], {
+ excludeWithin: keyWrapper,
+ });
+ const valueWrapper = row.querySelector('[data-test-var-value]');
+
+ const keyValue =
+ keyValueFromControl ??
+ (typeof keyWrapper?.value === 'string'
+ ? keyWrapper.value
+ : trackedItem.key);
+ const fieldValue =
+ valueFromControl ??
+ (typeof valueWrapper?.value === 'string'
+ ? valueWrapper.value
+ : trackedItem.value);
+
+ return {
+ key: keyValue,
+ value: fieldValue,
+ };
+ });
+ }
+
+ updateCode = (value) => {
+ try {
+ const parsedValue = JSON.parse(value);
+ const hasFormatErrors =
+ parsedValue instanceof Array || typeof parsedValue !== 'object';
+ if (hasFormatErrors) {
+ throw new Error('A Variable must be formatted as a single JSON object');
+ }
+
+ this.JSONError = null;
+ this.JSONItems = value;
+ } catch (error) {
+ this.JSONError = error;
+ }
+ };
+
+ get shouldShowLinkedEntities() {
+ return (
+ this.args.model.pathLinkedEntities?.job ||
+ this.args.model.pathLinkedEntities?.group ||
+ this.args.model.pathLinkedEntities?.task ||
+ trimPath([this.path]) === 'nomad/jobs'
+ );
+ }
+
+ get hasUserModifiedAttributes() {
+ const compactedBasicKVs = this.keyValues
+ .map((kv) => ({ key: kv.key, value: kv.value }))
+ .filter((kv) => kv.key || kv.value);
+ const compactedPassedKVs = this.args.model.keyValues.filter(
+ (kv) => kv.key || kv.value,
+ );
+ return (
+ !isEqual(compactedBasicKVs, compactedPassedKVs) ||
+ !isEqual(this.path, this.args.model.path)
+ );
+ }
+
+ addExitHandler() {
+ this.router.on('routeWillChange', this, this.confirmExit);
+ }
+
+ removeExitHandler() {
+ if (!this.hasRemovedExitHandler) {
+ this.router.off('routeWillChange', this, this.confirmExit);
+ this.hasRemovedExitHandler = true;
+ }
+ }
+
+ confirmExit = (transition) => {
+ if (transition.isAborted || transition.queryParamsOnly) return;
+
+ if (this.hasUserModifiedAttributes) {
+ if (
+ !confirm(
+ 'Your variable has unsaved changes. Are you sure you want to leave?',
+ )
+ ) {
+ transition.abort();
+ } else {
+ this.removeExitHandler();
+ }
+ }
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.removeExitHandler();
+ }
+
+
+
+
+
+
+}
+
+function normalizeCollection(value) {
+ if (!value) {
+ return [];
+ }
+
+ if (Array.isArray(value)) {
+ return value;
+ }
+
+ if (typeof value.toArray === 'function') {
+ return value.toArray();
+ }
+
+ if (typeof value[Symbol.iterator] === 'function') {
+ return Array.from(value);
+ }
+
+ return [];
+}
diff --git a/ui/app/components/variable-form.hbs b/ui/app/components/variable-form.hbs
deleted file mode 100644
index c6fdb7ba2ab..00000000000
--- a/ui/app/components/variable-form.hbs
+++ /dev/null
@@ -1,228 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{did-update-helper this.onViewChange @view}}
-{{did-insert-helper this.establishKeyValues}}
-
\ No newline at end of file
diff --git a/ui/app/components/variable-form.js b/ui/app/components/variable-form.js
deleted file mode 100644
index 60753167251..00000000000
--- a/ui/app/components/variable-form.js
+++ /dev/null
@@ -1,478 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action, computed } from '@ember/object';
-import { tracked } from '@glimmer/tracking';
-import { service } from '@ember/service';
-import { trimPath } from '../helpers/trim-path';
-import { copy } from 'ember-copy';
-import EmberObject, { set } from '@ember/object';
-// eslint-disable-next-line no-unused-vars
-import MutableArray from '@ember/array/mutable';
-import { A } from '@ember/array';
-import { stringifyObject } from 'nomad-ui/helpers/stringify-object';
-import notifyConflict from 'nomad-ui/utils/notify-conflict';
-import isEqual from 'fast-deep-equal';
-
-const EMPTY_KV = {
- key: '',
- value: '',
- warnings: EmberObject.create(),
-};
-
-// Capture characters that are not _, letters, or numbers using Unicode.
-const invalidKeyCharactersRegex = new RegExp(/[^_\p{Letter}\p{Number}]/gu);
-
-export default class VariableFormComponent extends Component {
- @service notifications;
- @service router;
- @service store;
- @service abilities;
-
- @tracked variableNamespace = null;
- @tracked namespaceOptions = null;
- @tracked hasConflict = false;
-
- /**
- * @typedef {Object} conflictingVariable
- * @property {string} ModifyTime
- * @property {Object} Items
- */
-
- /**
- * @type {conflictingVariable}
- */
- @tracked conflictingVariable = null;
-
- @tracked path = '';
- constructor() {
- super(...arguments);
- set(this, 'path', this.args.model.path);
- this.addExitHandler();
- }
-
- @action
- setNamespace(namespace) {
- this.variableNamespace = namespace;
- }
-
- @action
- setNamespaceOptions(options) {
- this.namespaceOptions = options;
-
- // Set first namespace option
- if (options.length) {
- this.variableNamespace = this.args.model.namespace;
- }
- }
-
- get shouldDisableSave() {
- const disallowedPath =
- this.path?.startsWith('nomad/') &&
- !(
- this.path?.startsWith('nomad/jobs') ||
- (this.path?.startsWith('nomad/job-templates') &&
- trimPath([this.path]) !== 'nomad/job-templates')
- );
- return !!this.JSONError || !this.path || disallowedPath;
- }
-
- get isJobTemplateVariable() {
- return this.path?.startsWith('nomad/job-templates/');
- }
-
- get jobTemplateName() {
- return this.path.split('nomad/job-templates/').slice(-1);
- }
-
- /**
- * @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
- */
- keyValues = A([]);
-
- /**
- * @type {string}
- */
- JSONItems = '{}';
-
- @action
- establishKeyValues() {
- const keyValues = copy(this.args.model?.keyValues || [])?.map((kv) => {
- return {
- key: kv.key,
- value: kv.value,
- warnings: EmberObject.create(),
- };
- });
-
- /**
- * Appends a row to the end of the Items list if you're editing an existing variable.
- * This will allow it to auto-focus and make all other rows deletable
- */
- if (!this.args.model?.isNew) {
- keyValues.pushObject(copy(EMPTY_KV));
- }
- set(this, 'keyValues', keyValues);
-
- this.JSONItems = stringifyObject([
- this.keyValues.reduce((acc, { key, value }) => {
- acc[key] = value;
- return acc;
- }, {}),
- ]);
- }
-
- /**
- * @typedef {Object} DuplicatePathWarning
- * @property {string} path
- */
-
- /**
- * @type {DuplicatePathWarning}
- */
- get duplicatePathWarning() {
- const existingVariables = this.args.existingVariables || [];
- const pathValue = trimPath([this.path]);
- let existingVariable = existingVariables
- .without(this.args.model)
- .find(
- (v) =>
- v.path === pathValue &&
- (v.namespace === this.variableNamespace || !this.variableNamespace),
- );
- if (existingVariable) {
- return {
- path: existingVariable.path,
- };
- } else {
- return null;
- }
- }
-
- get hasInvalidPath() {
- let pathNameRegex = new RegExp('^[a-zA-Z0-9-_~/]{1,128}$');
- return !pathNameRegex.test(trimPath([this.path]));
- }
-
- @action
- validateKey(entry, e) {
- const value = e.target.value;
- // Only letters, numbers, and _ are allowed in keys
- const invalidChars = value.match(invalidKeyCharactersRegex);
- if (invalidChars) {
- const invalidCharsOuput = [...new Set(invalidChars)]
- .sort()
- .map((c) => `'${c}'`);
- entry.warnings.set(
- 'dottedKeyError',
- `${value} contains characters [${invalidCharsOuput}] that require the "index" function for direct access in templates.`,
- );
- } else {
- delete entry.warnings.dottedKeyError;
- entry.warnings.notifyPropertyChange('dottedKeyError');
- }
-
- // no duplicate keys
- const existingKeys = this.keyValues.map((kv) => kv.key);
- if (existingKeys.includes(value)) {
- entry.warnings.set('duplicateKeyError', 'Key already exists.');
- } else {
- delete entry.warnings.duplicateKeyError;
- entry.warnings.notifyPropertyChange('duplicateKeyError');
- }
- set(entry, 'key', value);
- }
-
- @action appendRow() {
- // Clear our any entity errors
- let newRow = copy(EMPTY_KV);
- newRow.warnings = EmberObject.create();
- this.keyValues.pushObject(newRow);
- }
-
- @action deleteRow(row) {
- this.keyValues.removeObject(row);
- }
-
- @action refresh() {
- window.location.reload();
- }
-
- @action saveWithOverwrite(e) {
- set(this, 'conflictingVariable', null);
- this.save(e, true);
- }
-
- /**
- *
- * @param {KeyboardEvent} e
- */
- @action setModelPath(e) {
- set(this, 'path', e.target.value);
- set(this.args.model, 'path', e.target.value);
- }
-
- @action updateKeyValue(key, value) {
- if (this.keyValues.find((kv) => kv.key === key)) {
- this.keyValues.find((kv) => kv.key === key).value = value;
- } else {
- this.keyValues.pushObject({ key, value, warnings: EmberObject.create() });
- }
- }
-
- @action
- async save(e, overwrite = false) {
- if (e.type === 'submit') {
- e.preventDefault();
- }
-
- if (this.view === 'json') {
- this.translateAndValidateItems('table');
- }
- try {
- const nonEmptyItems = A(
- this.keyValues.filter((item) => item.key.trim() && item.value),
- );
- if (!nonEmptyItems.length) {
- throw new Error('Please provide at least one key/value pair.');
- } else {
- set(this, 'keyValues', nonEmptyItems);
- }
-
- if (this.args.model?.isNew) {
- if (this.namespaceOptions) {
- this.args.model.set('namespace', this.variableNamespace);
- } else {
- const [namespace] = this.store.peekAll('namespace').toArray();
- this.args.model.set('namespace', namespace.id);
- }
- }
-
- this.args.model.set('keyValues', this.keyValues);
- this.args.model.set('path', this.path);
- this.args.model.setAndTrimPath();
- await this.args.model.save({ adapterOptions: { overwrite } });
-
- this.notifications.add({
- title: 'Variable saved',
- message: `${this.path} successfully saved`,
- color: 'success',
- });
-
- if (
- this.abilities.can('read job', null, {
- namespace: this.variableNamespace || 'default',
- })
- ) {
- this.updateJobVariables(this.args.model.pathLinkedEntities.job);
- }
-
- this.removeExitHandler();
- this.router.transitionTo('variables.variable', this.args.model.id);
- } catch (e) {
- notifyConflict(this)(e);
- if (!this.hasConflict) {
- let errorMessage = e;
- if (e.errors && e.errors.length > 0) {
- const nameInvalidError = e.errors.find((err) => err.status === 400);
- if (nameInvalidError) {
- errorMessage = nameInvalidError.detail;
- }
- }
-
- console.log('caught an error', e);
- this.notifications.add({
- title: `Error saving ${this.path}`,
- message: errorMessage,
- color: 'critical',
- sticky: true,
- });
- } else {
- if (e.errors[0]?.detail) {
- set(this, 'conflictingVariable', e.errors[0].detail);
- }
- window.scrollTo(0, 0); // because the k/v list may be long, ensure the user is snapped to top to read error
- }
- }
- }
-
- /**
- * A job, its task groups, and tasks, all have a getter called pathLinkedVariable.
- * These are dependent on a variables list that may already be established. If a variable
- * is added or removed, this function will update job.variables[] list to reflect the change.
- * and force an update to the job's pathLinkedVariable getter.
- */
- async updateJobVariables(jobName) {
- if (!jobName) {
- return;
- }
- const fullJobId = JSON.stringify([
- jobName,
- this.variableNamespace || 'default',
- ]);
- let job = await this.store.findRecord('job', fullJobId, { reload: true });
- if (job) {
- job.variables.pushObject(this.args.model);
- }
- }
-
- //#region JSON Editing
-
- view = this.args.view;
-
- get isJSONView() {
- return this.args.view === 'json';
- }
-
- // Prevent duplicate onUpdate events when @view is set to its already-existing value,
- // which happens because parent's queryParams and toggle button both resolve independently.
- @action onViewChange([view]) {
- if (view !== this.view) {
- set(this, 'view', view);
- this.translateAndValidateItems(view);
- }
- }
-
- @action
- translateAndValidateItems(view) {
- // TODO: move the translation functions in serializers/variable.js to generic importable functions.
- if (view === 'json') {
- // Translate table to JSON
- set(
- this,
- 'JSONItems',
- stringifyObject([
- this.keyValues
- .filter((item) => item.key.trim() && item.value) // remove empty items when translating to JSON
- .reduce((acc, { key, value }) => {
- acc[key] = value;
- return acc;
- }, {}),
- ]),
- );
-
- // Give the user a foothold if they're transitioning an empty K/V form into JSON
- if (!Object.keys(JSON.parse(this.JSONItems)).length) {
- set(this, 'JSONItems', stringifyObject([{ '': '' }]));
- }
- } else if (view === 'table') {
- // Translate JSON to table
- set(
- this,
- 'keyValues',
- A(
- Object.entries(JSON.parse(this.JSONItems)).map(([key, value]) => {
- return {
- key,
- value: typeof value === 'string' ? value : JSON.stringify(value),
- warnings: EmberObject.create(),
- };
- }),
- ),
- );
-
- // If the JSON object is empty at switch time, add an empty KV in to give the user a foothold
- if (!Object.keys(JSON.parse(this.JSONItems)).length) {
- this.appendRow();
- }
- }
-
- // Reset any error state, since the errorring json will not persist
- set(this, 'JSONError', null);
- }
-
- /**
- * @type {string}
- */
- @tracked JSONError = null;
- /**
- *
- * @param {string} value
- */
- @action updateCode(value) {
- try {
- // "myString" is valid JSON, but it's not a valid Variable.
- // Ditto for an array of objects. We expect a single object to be a Variable.
- const hasFormatErrors =
- JSON.parse(value) instanceof Array ||
- typeof JSON.parse(value) !== 'object';
- if (hasFormatErrors) {
- throw new Error('A Variable must be formatted as a single JSON object');
- }
-
- set(this, 'JSONError', null);
- set(this, 'JSONItems', value);
- } catch (error) {
- set(this, 'JSONError', error);
- }
- }
- //#endregion JSON Editing
-
- get shouldShowLinkedEntities() {
- return (
- this.args.model.pathLinkedEntities?.job ||
- this.args.model.pathLinkedEntities?.group ||
- this.args.model.pathLinkedEntities?.task ||
- trimPath([this.path]) === 'nomad/jobs'
- );
- }
-
- //#region Unsaved Changes Confirmation
-
- hasRemovedExitHandler = false;
-
- @computed(
- 'args.model.{keyValues,path}',
- 'keyValues.@each.{key,value}',
- 'path',
- )
- get hasUserModifiedAttributes() {
- const compactedBasicKVs = this.keyValues
- .map((kv) => ({ key: kv.key, value: kv.value }))
- .filter((kv) => kv.key || kv.value);
- const compactedPassedKVs = this.args.model.keyValues.filter(
- (kv) => kv.key || kv.value,
- );
- const unequal =
- !isEqual(compactedBasicKVs, compactedPassedKVs) ||
- !isEqual(this.path, this.args.model.path);
- return unequal;
- }
-
- addExitHandler() {
- this.router.on('routeWillChange', this, this.confirmExit);
- }
-
- removeExitHandler() {
- if (!this.hasRemovedExitHandler) {
- this.router.off('routeWillChange', this, this.confirmExit);
- this.hasRemovedExitHandler = true;
- }
- }
-
- confirmExit(transition) {
- if (transition.isAborted || transition.queryParamsOnly) return;
-
- if (this.hasUserModifiedAttributes) {
- if (
- !confirm(
- 'Your variable has unsaved changes. Are you sure you want to leave?',
- )
- ) {
- transition.abort();
- } else {
- this.removeExitHandler();
- }
- }
- }
-
- willDestroy() {
- super.willDestroy(...arguments);
- this.removeExitHandler();
- }
-
- //#endregion Unsaved Changes Confirmation
-}
diff --git a/ui/app/components/variable-form/input-group.gjs b/ui/app/components/variable-form/input-group.gjs
new file mode 100644
index 00000000000..23a921680c4
--- /dev/null
+++ b/ui/app/components/variable-form/input-group.gjs
@@ -0,0 +1,26 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { HdsFormMaskedInputField } from '@hashicorp/design-system-components/components';
+
+export const VariableFormInputGroup =
+
+;
+
+export default VariableFormInputGroup;
diff --git a/ui/app/components/variable-form/input-group.hbs b/ui/app/components/variable-form/input-group.hbs
deleted file mode 100644
index 8d7e3742f8e..00000000000
--- a/ui/app/components/variable-form/input-group.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/variable-form/input-group.js b/ui/app/components/variable-form/input-group.js
deleted file mode 100644
index 55915ec6f40..00000000000
--- a/ui/app/components/variable-form/input-group.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-
-export default class InputGroup extends Component {
- @tracked isObscured = true;
-
- get inputType() {
- return this.isObscured ? 'password' : 'text';
- }
-
- @action
- toggleInputType() {
- this.isObscured = !this.isObscured;
- }
-}
diff --git a/ui/app/components/variable-form/job-template-editor.gjs b/ui/app/components/variable-form/job-template-editor.gjs
new file mode 100644
index 00000000000..66e4924a098
--- /dev/null
+++ b/ui/app/components/variable-form/job-template-editor.gjs
@@ -0,0 +1,67 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import codeMirror from 'nomad-ui/modifiers/code-mirror';
+
+export default class JobTemplateEditor extends Component {
+ @tracked description;
+ @tracked template;
+
+ establishKeyValues = () => {
+ this.description = this.args.keyValues?.find?.(
+ (entry) => entry.key === 'description',
+ )?.value;
+ this.template = this.args.keyValues?.find?.(
+ (entry) => entry.key === 'template',
+ )?.value;
+ };
+
+ updateDescription = (event) => {
+ this.args.updateKeyValue('description', event.target.value);
+ };
+
+ updateTemplate = (value) => {
+ this.args.updateKeyValue('template', value);
+ };
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/ui/app/components/variable-form/job-template-editor.hbs b/ui/app/components/variable-form/job-template-editor.hbs
deleted file mode 100644
index 91b6f575d27..00000000000
--- a/ui/app/components/variable-form/job-template-editor.hbs
+++ /dev/null
@@ -1,37 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-{{did-insert-helper this.establishKeyValues}}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ui/app/components/variable-form/job-template-editor.js b/ui/app/components/variable-form/job-template-editor.js
deleted file mode 100644
index 96652fa2454..00000000000
--- a/ui/app/components/variable-form/job-template-editor.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-
-export default class JobTemplateEditor extends Component {
- @tracked description;
- @tracked template;
- @action
- establishKeyValues() {
- this.description = this.args.keyValues.findBy('key', 'description')?.value;
- this.template = this.args.keyValues.findBy('key', 'template')?.value;
- }
-
- @action
- updateDescription(event) {
- this.args.updateKeyValue('description', event.target.value);
- }
- @action
- updateTemplate(value) {
- this.args.updateKeyValue('template', value);
- }
-}
diff --git a/ui/app/components/variable-form/namespace-filter.gjs b/ui/app/components/variable-form/namespace-filter.gjs
new file mode 100644
index 00000000000..b0d61a08a8d
--- /dev/null
+++ b/ui/app/components/variable-form/namespace-filter.gjs
@@ -0,0 +1,68 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { didInsert } from '@ember/render-modifiers';
+import { eq } from 'ember-truth-helpers';
+import { HdsDropdown } from '@hashicorp/design-system-components/components';
+import Trigger from 'nomad-ui/components/trigger';
+
+export default class NamespaceFilter extends Component {
+ @service store;
+
+ fetchNamespaces = async () => {
+ return this.store.findAll('namespace');
+ };
+
+ formatAndSetNamespaces = () => {
+ const namespaces = this.store
+ .peekAll('namespace')
+ .map(({ name }) => ({ key: name, label: name }));
+
+ if (namespaces.length <= 1) return null;
+
+ this.args.fns.setNamespaceOptions(namespaces);
+ };
+
+
+
+
+ {{#if trigger.data.isSuccess}}
+ {{#if trigger.data.result}}
+ {{#if @data.namespaceOptions}}
+
+
+ {{#each @data.namespaceOptions as |option|}}
+
+ {{option.label}}
+
+ {{/each}}
+
+ {{/if}}
+ {{/if}}
+ {{/if}}
+
+
+}
diff --git a/ui/app/components/variable-form/namespace-filter.hbs b/ui/app/components/variable-form/namespace-filter.hbs
deleted file mode 100644
index f5c94a4bf5d..00000000000
--- a/ui/app/components/variable-form/namespace-filter.hbs
+++ /dev/null
@@ -1,41 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- {{did-insert-helper trigger.fns.do}}
- {{! No-op on Error}}
- {{! No Loading Behavior }}
- {{#if trigger.data.isSuccess}}
- {{#if trigger.data.result}}
- {{#if @data.namespaceOptions}}
-
-
- {{#each @data.namespaceOptions as |option|}}
-
- {{option.label}}
-
- {{/each}}
-
- {{/if}}
- {{/if}}
- {{/if}}
-
\ No newline at end of file
diff --git a/ui/app/components/variable-form/namespace-filter.js b/ui/app/components/variable-form/namespace-filter.js
deleted file mode 100644
index 29f32fc9fbb..00000000000
--- a/ui/app/components/variable-form/namespace-filter.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import Component from '@glimmer/component';
-
-export default class NamespaceFilter extends Component {
- @service store;
-
- @action
- async fetchNamespaces() {
- return this.store.findAll('namespace');
- }
-
- @action
- formatAndSetNamespaces() {
- // Triggered on the promise in fetchNamespaces resolving
- const namespaces = this.store
- .peekAll('namespace')
- .map(({ name }) => ({ key: name, label: name }));
-
- if (namespaces.length <= 1) return null;
-
- this.args.fns.setNamespaceOptions(namespaces);
- }
-}
diff --git a/ui/app/components/variable-form/related-entities.gjs b/ui/app/components/variable-form/related-entities.gjs
new file mode 100644
index 00000000000..aa8b6edfbaf
--- /dev/null
+++ b/ui/app/components/variable-form/related-entities.gjs
@@ -0,0 +1,56 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { array } from '@ember/helper';
+import { concat } from '@ember/helper';
+import {
+ HdsAlert,
+ HdsLinkInline,
+} from '@hashicorp/design-system-components/components';
+
+export const VariableFormRelatedEntities =
+
+ Automatically-accessible variable
+
+ This variable
+ {{#if @new}}will be{{else}}is{{/if}}
+ accessible by
+ {{#if @task}}
+ task
+ {{@task}}
+ in group
+ {{@group}}
+ {{else if @group}}
+ group
+ {{@group}}
+ {{else if @job}}
+ job
+ {{@job}}
+ {{else}}
+ all nomad jobs in this namespace
+ {{/if}}
+
+
+;
+
+export default VariableFormRelatedEntities;
diff --git a/ui/app/components/variable-form/related-entities.hbs b/ui/app/components/variable-form/related-entities.hbs
deleted file mode 100644
index 2137e7f0a53..00000000000
--- a/ui/app/components/variable-form/related-entities.hbs
+++ /dev/null
@@ -1,45 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- Automatically-accessible variable
-
- This variable
- {{#if @new}}will be{{else}}is{{/if}}
- accessible by
- {{#if @task}}
- task
- {{@task}}
- in group
- {{@group}}
- {{else if @group}}
- group
- {{@group}}
- {{else if @job}}
- job
- {{@job}}
- {{else}}
- all nomad jobs in this namespace
- {{/if}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/variable-paths.gjs b/ui/app/components/variable-paths.gjs
new file mode 100644
index 00000000000..1746e36a295
--- /dev/null
+++ b/ui/app/components/variable-paths.gjs
@@ -0,0 +1,159 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { fn, hash } from '@ember/helper';
+import { on } from '@ember/modifier';
+import { LinkTo } from '@ember/routing';
+import { service } from '@ember/service';
+import {
+ HdsIcon,
+ HdsTable,
+} from '@hashicorp/design-system-components/components';
+import can from 'ember-can/helpers/can';
+import momentFromNow from 'ember-moment/helpers/moment-from-now';
+import formatTs from 'nomad-ui/helpers/format-ts';
+import trimPath from 'nomad-ui/helpers/trim-path';
+import keyboardShortcut from 'nomad-ui/modifiers/keyboard-shortcut';
+import compactPath from '../utils/compact-path';
+
+export default class VariablePaths extends Component {
+ @service router;
+ @service abilities;
+
+ get folders() {
+ return Object.entries(this.args.branch.children).map(([name]) => {
+ return compactPath(this.args.branch.children[name], name);
+ });
+ }
+
+ get files() {
+ return this.args.branch.files;
+ }
+
+ handleFolderClick = async (path, trigger) => {
+ // Don't navigate if the user clicked on a link; this happens with cmd/ctrl-click on the link itself.
+ if (
+ trigger instanceof PointerEvent &&
+ /** @type {HTMLElement} */ (trigger.target).tagName === 'A'
+ ) {
+ return;
+ }
+ this.router.transitionTo('variables.path', path);
+ };
+
+ handleFileClick = async ({ path, variable: { id, namespace } }, trigger) => {
+ if (this.abilities.can('read variable', null, { path, namespace })) {
+ // Don't navigate if the user clicked on a link; this happens with cmd/ctrl-click on the link itself.
+ if (
+ trigger instanceof PointerEvent &&
+ /** @type {HTMLElement} */ (trigger.target).tagName === 'A'
+ ) {
+ return;
+ }
+ this.router.transitionTo('variables.variable', id);
+ }
+ };
+
+
+
+ <:head as |H|>
+
+
+ Path
+
+
+ Namespace
+
+
+ Last Modified
+
+
+
+ <:body as |B|>
+ {{#each this.folders as |folder|}}
+
+
+
+
+
+ {{trimPath folder.name}}
+
+
+
+
+
+ {{/each}}
+
+ {{#each this.files as |file|}}
+
+
+
+ {{#if
+ (can
+ "read variable"
+ path=file.absoluteFilePath
+ namespace=file.variable.namespace
+ )
+ }}
+
+ {{file.name}}
+
+ {{else}}
+ {{file.name}}
+ {{/if}}
+
+
+ {{file.variable.namespace}}
+
+
+
+ {{momentFromNow file.variable.modifyTime}}
+
+
+
+ {{/each}}
+
+
+
+}
diff --git a/ui/app/components/variable-paths.hbs b/ui/app/components/variable-paths.hbs
deleted file mode 100644
index 5061c613dbc..00000000000
--- a/ui/app/components/variable-paths.hbs
+++ /dev/null
@@ -1,102 +0,0 @@
-{{!
- Copyright IBM Corp. 2015, 2025
- SPDX-License-Identifier: BUSL-1.1
-~}}
-
-
- <:head as |H|>
-
-
- Path
-
-
- Namespace
-
-
- Last Modified
-
-
-
- <:body as |B|>
- {{#each this.folders as |folder|}}
-
-
-
-
-
- {{trim-path folder.name}}
-
-
-
-
-
- {{/each}}
-
- {{#each this.files as |file|}}
-
-
-
- {{#if
- (can
- "read variable"
- path=file.absoluteFilePath
- namespace=file.variable.namespace
- )
- }}
-
- {{file.name}}
-
- {{else}}
- {{file.name}}
- {{/if}}
-
-
- {{file.variable.namespace}}
-
-
-
- {{moment-from-now file.variable.modifyTime}}
-
-
-
- {{/each}}
-
-
\ No newline at end of file
diff --git a/ui/app/components/variable-paths.js b/ui/app/components/variable-paths.js
deleted file mode 100644
index 6ed6b8821b9..00000000000
--- a/ui/app/components/variable-paths.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-import { service } from '@ember/service';
-import compactPath from '../utils/compact-path';
-export default class VariablePathsComponent extends Component {
- @service router;
- @service abilities;
-
- /**
- * @returns {Array>}
- */
- get folders() {
- return Object.entries(this.args.branch.children).map(([name]) => {
- return compactPath(this.args.branch.children[name], name);
- });
- }
-
- get files() {
- return this.args.branch.files;
- }
-
- @action
- async handleFolderClick(path, trigger) {
- // Don't navigate if the user clicked on a link; this will happen with modifier keys like cmd/ctrl on the link itself
- if (
- trigger instanceof PointerEvent &&
- /** @type {HTMLElement} */ (trigger.target).tagName === 'A'
- ) {
- return;
- }
- this.router.transitionTo('variables.path', path);
- }
-
- @action
- async handleFileClick({ path, variable: { id, namespace } }, trigger) {
- if (this.abilities.can('read variable', null, { path, namespace })) {
- // Don't navigate if the user clicked on a link; this will happen with modifier keys like cmd/ctrl on the link itself
- if (
- trigger instanceof PointerEvent &&
- /** @type {HTMLElement} */ (trigger.target).tagName === 'A'
- ) {
- return;
- }
- this.router.transitionTo('variables.variable', id);
- }
- }
-}
diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js
index ca1cf49a8a3..58d761a3a7c 100644
--- a/ui/app/controllers/clients/client/index.js
+++ b/ui/app/controllers/clients/client/index.js
@@ -322,12 +322,16 @@ export default class ClientController extends Controller.extend(
if (event.key === 'Escape') {
this.resetNewMetaData();
this.editingMetadata = false;
+ return;
}
+
+ // Keep tracked state reactive when nested key/value fields are mutated.
+ this.newMetaData = { ...this.newMetaData };
}
@action async addDynamicMetaData({ key, value }, e) {
try {
- e.preventDefault();
+ e?.preventDefault?.();
await this.model.addMeta({ [key]: value });
this.notifications.add({
diff --git a/ui/app/mixins/window-resizable.js b/ui/app/mixins/window-resizable.js
deleted file mode 100644
index ba69162534a..00000000000
--- a/ui/app/mixins/window-resizable.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import Mixin from '@ember/object/mixin';
-import { assert } from '@ember/debug';
-import { on } from '@ember/object/evented';
-
-// eslint-disable-next-line ember/no-new-mixins
-export default Mixin.create({
- windowResizeHandler() {
- assert(
- 'windowResizeHandler needs to be overridden in the Component',
- false,
- );
- },
-
- setupWindowResize: on('didInsertElement', function () {
- this.addResizeListener();
- }),
-
- addResizeListener() {
- if (this._windowResizeHandler) {
- return;
- }
-
- this.set('_windowResizeHandler', this.windowResizeHandler.bind(this));
- window.addEventListener('resize', this._windowResizeHandler);
- },
-
- removeWindowResize: on('willDestroyElement', function () {
- if (!this._windowResizeHandler) {
- return;
- }
-
- window.removeEventListener('resize', this._windowResizeHandler);
- this.set('_windowResizeHandler', null);
- }),
-});
diff --git a/ui/app/mixins/with-component-visibility-detection.js b/ui/app/mixins/with-component-visibility-detection.js
deleted file mode 100644
index 92c9633b1ed..00000000000
--- a/ui/app/mixins/with-component-visibility-detection.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { macroCondition, isTesting } from '@embroider/macros';
-import Mixin from '@ember/object/mixin';
-import { assert } from '@ember/debug';
-import { on } from '@ember/object/evented';
-
-// eslint-disable-next-line ember/no-new-mixins
-export default Mixin.create({
- visibilityHandler() {
- assert('visibilityHandler needs to be overridden in the Component', false);
- },
-
- setupDocumentVisibility: on('init', function () {
- if (!macroCondition(isTesting())) {
- this.set('_visibilityHandler', this.visibilityHandler.bind(this));
- document.addEventListener('visibilitychange', this._visibilityHandler);
- }
- }),
-
- removeDocumentVisibility: on('init', function () {
- if (!macroCondition(isTesting())) {
- document.removeEventListener('visibilitychange', this._visibilityHandler);
- }
- }),
-});
diff --git a/ui/app/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js
index 4a7a43334c0..dd6a021b380 100644
--- a/ui/app/services/stats-trackers-registry.js
+++ b/ui/app/services/stats-trackers-registry.js
@@ -21,6 +21,12 @@ const exists = (tracker, prop) =>
!tracker.get(prop).isDestroyed &&
!tracker.get(prop).isDestroying;
+const createTracker = ({ Constructor, resource, resourceProp, token }) =>
+ Constructor.create({
+ fetch: (url) => token.authorizedRequest(url),
+ [resourceProp]: resource,
+ });
+
export default class StatsTrackersRegistryService extends Service {
@service token;
@@ -51,16 +57,27 @@ export default class StatsTrackersRegistryService extends Service {
const cachedTracker = registry.get(key);
if (cachedTracker) {
- // It's possible for the resource on a cachedTracker to have been
- // deleted. Rebind it if that's the case.
- if (!exists(cachedTracker, resourceProp))
- cachedTracker.set(resourceProp, resource);
+ // Avoid mutating a cached tracker during render-time computations.
+ // If the bound resource is gone or destroyed, replace the tracker.
+ if (!exists(cachedTracker, resourceProp)) {
+ const tracker = createTracker({
+ Constructor,
+ resource,
+ resourceProp,
+ token: this.token,
+ });
+ registry.set(key, tracker);
+ return tracker;
+ }
+
return cachedTracker;
}
- const tracker = Constructor.create({
- fetch: (url) => this.token.authorizedRequest(url),
- [resourceProp]: resource,
+ const tracker = createTracker({
+ Constructor,
+ resource,
+ resourceProp,
+ token: this.token,
});
registry.set(key, tracker);
diff --git a/ui/app/styles/charts/topo-viz.scss b/ui/app/styles/charts/topo-viz.scss
index 909dc872bba..328d6fa3383 100644
--- a/ui/app/styles/charts/topo-viz.scss
+++ b/ui/app/styles/charts/topo-viz.scss
@@ -4,6 +4,24 @@
*/
.topo-viz {
+ .topo-viz-tooltip {
+ max-width: min(40rem, calc(100vw - 2rem));
+
+ ol > li {
+ min-width: 0;
+ }
+
+ .label {
+ flex: 0 0 auto;
+ }
+
+ .value {
+ max-width: 28rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
.topo-viz-datacenters {
display: flex;
flex-flow: column wrap;
diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs
index 354241b10da..300851f3850 100644
--- a/ui/app/templates/allocations/allocation/index.hbs
+++ b/ui/app/templates/allocations/allocation/index.hbs
@@ -189,18 +189,18 @@
>
Driver Health
-
+
Name
-
-
+
+
State
-
+
Last Event
-
+
Time
-
+
Volumes
@@ -217,7 +217,7 @@
enumerated=true
action=(fn this.taskClick row.model.allocation row.model)
}}
- @data-test-task-row={{row.model.name}}
+ data-test-task-row={{row.model.name}}
@task={{row.model}}
@onClick={{fn this.taskClick row.model.allocation row.model}}
/>
@@ -516,7 +516,7 @@
diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs
index b50745ca078..d88d3f8a691 100644
--- a/ui/app/templates/application.hbs
+++ b/ui/app/templates/application.hbs
@@ -53,7 +53,7 @@
-
+
{{#if this.error}}
diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs
index 7315f080e88..977b9ccb08f 100644
--- a/ui/app/templates/clients/client/index.hbs
+++ b/ui/app/templates/clients/client/index.hbs
@@ -522,7 +522,7 @@
/>
Driver Health,
Scheduling, and Preemption
-
+
ID
-
-
+
+
Created
-
-
+
+
Modified
-
-
+
+
Status
-
-
+
+
Job
-
-
+
+
Version
-
+
Volume
@@ -598,7 +598,7 @@
@allocation={{row.model}}
@context="node"
@onClick={{fn this.gotoAllocation row.model}}
- @data-test-allocation={{row.model.id}}
+ data-test-allocation={{row.model.id}}
/>
{{#if this.showSubTasks}}
{{#each row.model.states as |task|}}
diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs
index 79ce278d534..3fadde2a527 100644
--- a/ui/app/templates/clients/index.hbs
+++ b/ui/app/templates/clients/index.hbs
@@ -13,7 +13,7 @@
{{#if this.nodes.length}}
{{/if}}
@@ -258,16 +258,16 @@
>
Driver Health
- ID
- ID
+ Name
- State
+ >Name
+ State
Address
- Node Pool
- Datacenter
- Version
+ Node Pool
+ Datacenter
+ Version
# Volumes
# Allocs
diff --git a/ui/app/templates/evaluations.hbs b/ui/app/templates/evaluations.hbs
index 53e14b9687e..1703ecb681b 100644
--- a/ui/app/templates/evaluations.hbs
+++ b/ui/app/templates/evaluations.hbs
@@ -7,6 +7,6 @@
@crumb={{hash label="Evaluations" args=(array "evaluations.index")}}
/>
-
+
{{outlet}}
\ No newline at end of file
diff --git a/ui/app/templates/evaluations/index.hbs b/ui/app/templates/evaluations/index.hbs
index c9d5fd39096..9ff4bdce891 100644
--- a/ui/app/templates/evaluations/index.hbs
+++ b/ui/app/templates/evaluations/index.hbs
@@ -11,7 +11,7 @@
diff --git a/ui/app/templates/jobs/job/allocations.hbs b/ui/app/templates/jobs/job/allocations.hbs
index 15f2d53f493..340dbb8016b 100644
--- a/ui/app/templates/jobs/job/allocations.hbs
+++ b/ui/app/templates/jobs/job/allocations.hbs
@@ -12,7 +12,7 @@
@@ -73,19 +73,19 @@
Driver Health,
Scheduling, and Preemption
- ID
- Task Group
- ID
+ Task Group
+ Created
- Created
+ Modified
- Status
- Version
- Client
+ >Modified
+ Status
+ Version
+ Client
Volume
CPU
Memory
@@ -99,7 +99,7 @@
enumerated=true
action=(fn this.gotoAllocation row.model)
}}
- @data-test-allocation={{row.model.id}}
+ data-test-allocation={{row.model.id}}
@allocation={{row.model}}
@context="job"
@onClick={{fn this.gotoAllocation row.model}}
diff --git a/ui/app/templates/jobs/job/clients.hbs b/ui/app/templates/jobs/job/clients.hbs
index 9a0a568e4b8..e3ef112ff18 100644
--- a/ui/app/templates/jobs/job/clients.hbs
+++ b/ui/app/templates/jobs/job/clients.hbs
@@ -12,7 +12,7 @@
@@ -58,18 +58,15 @@
as |t|
>
- Client ID
- Client
- Name
- Created
- Client ID
+ Client
+ Name
+ Created
+ Modified
- Job Status
+ >Modified
+ Job Status
Allocation Summary
diff --git a/ui/app/templates/jobs/job/definition.hbs b/ui/app/templates/jobs/job/definition.hbs
index 5c17815a556..28e46babaeb 100644
--- a/ui/app/templates/jobs/job/definition.hbs
+++ b/ui/app/templates/jobs/job/definition.hbs
@@ -18,6 +18,5 @@
@onSubmit={{this.onSubmit}}
@onSelect={{this.selectView}}
@onToggleEdit={{this.toggleEdit}}
- data-test-job-editor
/>
\ No newline at end of file
diff --git a/ui/app/templates/jobs/job/evaluations.hbs b/ui/app/templates/jobs/job/evaluations.hbs
index a8c83d58b7c..367283d5957 100644
--- a/ui/app/templates/jobs/job/evaluations.hbs
+++ b/ui/app/templates/jobs/job/evaluations.hbs
@@ -15,11 +15,11 @@
>
ID
- Priority
- Created
- Triggered By
- Status
- Placement Failures
+ Priority
+ Created
+ Triggered By
+ Status
+ Placement Failures
diff --git a/ui/app/templates/jobs/job/services/index.hbs b/ui/app/templates/jobs/job/services/index.hbs
index 4b2f123c347..dbe4f4f9351 100644
--- a/ui/app/templates/jobs/job/services/index.hbs
+++ b/ui/app/templates/jobs/job/services/index.hbs
@@ -12,10 +12,10 @@
as |t|
>
- Name
- Level
+ Name
+ Level
Tags
- Number of Allocations
+ Number of Allocations
@@ -198,24 +198,24 @@
Driver Health,
Scheduling, and Preemption
-
+
ID
-
-
+
+
Created
-
-
+
+
Modified
-
-
+
+
Status
-
-
+
+
Version
-
-
+
+
Client
-
+
Volume
@@ -235,7 +235,7 @@
enumerated=true
action=(fn this.gotoAllocation row.model)
}}
- @data-test-allocation={{row.model.id}}
+ data-test-allocation={{row.model.id}}
@allocation={{row.model}}
@context="taskGroup"
@onClick={{fn this.gotoAllocation row.model}}
@@ -322,7 +322,7 @@
{{/if}}
-
+
Recent Scaling Events
diff --git a/ui/app/templates/optimize.hbs b/ui/app/templates/optimize.hbs
index 7938479bb9d..452987f2011 100644
--- a/ui/app/templates/optimize.hbs
+++ b/ui/app/templates/optimize.hbs
@@ -12,7 +12,10 @@
{{#if this.summaries}}
- Name
- Status
- Leader
- Name
+ Status
+ Leader
+ Address
- port
- Datacenter
- Version
+ >Address
+ port
+ Datacenter
+ Version
diff --git a/ui/app/templates/storage/plugins/index.hbs b/ui/app/templates/storage/plugins/index.hbs
index 5b07ff86ab2..32f01811d92 100644
--- a/ui/app/templates/storage/plugins/index.hbs
+++ b/ui/app/templates/storage/plugins/index.hbs
@@ -15,7 +15,7 @@
{{/if}}
@@ -36,10 +36,10 @@
as |t|
>
- ID
- Controller Health
- Node Health
- Provider
+ ID
+ Controller Health
+ Node Health
+ Provider
ID
Created
- Modified
- Health
+ Modified
+ Health
Client
Job
Version
@@ -59,7 +59,7 @@
diff --git a/ui/app/templates/storage/plugins/plugin/index.hbs b/ui/app/templates/storage/plugins/plugin/index.hbs
index a45bf5e10fc..b97ef033221 100644
--- a/ui/app/templates/storage/plugins/plugin/index.hbs
+++ b/ui/app/templates/storage/plugins/plugin/index.hbs
@@ -130,7 +130,7 @@
@@ -195,7 +195,7 @@
diff --git a/ui/app/templates/storage/volumes/dynamic-host-volume.hbs b/ui/app/templates/storage/volumes/dynamic-host-volume.hbs
index da0b5188144..16c6ae7c219 100644
--- a/ui/app/templates/storage/volumes/dynamic-host-volume.hbs
+++ b/ui/app/templates/storage/volumes/dynamic-host-volume.hbs
@@ -87,7 +87,7 @@
enumerated=true
action=(fn this.gotoAllocation row.model)
}}
- @data-test-allocation={{row.model.id}}
+ data-test-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{fn this.gotoAllocation row.model}}
diff --git a/ui/app/templates/storage/volumes/volume.hbs b/ui/app/templates/storage/volumes/volume.hbs
index 9e7a55b4061..75ecf110de2 100644
--- a/ui/app/templates/storage/volumes/volume.hbs
+++ b/ui/app/templates/storage/volumes/volume.hbs
@@ -68,7 +68,7 @@
enumerated=true
action=(fn this.gotoAllocation row.model)
}}
- @data-test-write-allocation={{row.model.id}}
+ data-test-write-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{fn this.gotoAllocation row.model}}
@@ -123,7 +123,7 @@
enumerated=true
action=(fn this.gotoAllocation row.model)
}}
- @data-test-read-allocation={{row.model.id}}
+ data-test-read-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{fn this.gotoAllocation row.model}}
diff --git a/ui/app/templates/topology.hbs b/ui/app/templates/topology.hbs
index ba62d349d32..fd18755b9fe 100644
--- a/ui/app/templates/topology.hbs
+++ b/ui/app/templates/topology.hbs
@@ -511,6 +511,7 @@
{{/if}}
diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js
index b8ca6422ef7..61e184e0bc0 100644
--- a/ui/app/utils/classes/log.js
+++ b/ui/app/utils/classes/log.js
@@ -159,12 +159,20 @@ class Log extends EmberObject.extend(Evented) {
export default Log;
+function getByPath(context, path) {
+ if (!context || !path) {
+ return undefined;
+ }
+
+ return path.split('.').reduce((value, segment) => value?.[segment], context);
+}
+
export function logger(urlProp, params, logFetch) {
return computed(urlProp, params, function () {
return Log.create({
logFetch: logFetch.call(this),
- params: this.get(params),
- url: this.get(urlProp),
+ params: getByPath(this, params),
+ url: getByPath(this, urlProp),
});
});
}
diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs
index 6749d973725..cd3160ac8af 100644
--- a/ui/eslint.config.mjs
+++ b/ui/eslint.config.mjs
@@ -92,6 +92,7 @@ export default defineConfig([
},
},
rules: {
+ 'ember/no-at-ember-render-modifiers': 'off',
'ember/no-runloop': 'off',
'ember/no-mixins': 'off',
'ember/avoid-leaking-state-in-ember-objects': 'off',
diff --git a/ui/package.json b/ui/package.json
index 1327acb3736..8a1f83c4434 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -23,7 +23,7 @@
"lint:hbs:fix": "ember-template-lint . --fix",
"lint:js": "eslint . --cache",
"lint:js:fix": "eslint . --fix",
- "lint:types": "tsc --noEmit",
+ "lint:types": "ember-tsc --noEmit",
"local:exam": "ember exam --server --load-balance --parallel=4",
"local:test": "ember test --server",
"percy": "percy",
@@ -55,7 +55,7 @@
"@ember/optional-features": "^3.0.0",
"@ember/render-modifiers": "^4.0.0",
"@ember/string": "^4.0.1",
- "@ember/test-helpers": "^5.4.1",
+ "@ember/test-helpers": "^5.4.2",
"@ember/test-waiters": "^4.1.1",
"@embroider/macros": "^1.20.2",
"@eslint/js": "^9.39.4",
@@ -65,18 +65,19 @@
"@glint/template": "^1.7.7",
"@glint/tsserver-plugin": "^2.4.0",
"@hashicorp/design-system-components": "4.13.0",
- "@nullvoxpopuli/ember-composable-helpers": "^5.3.1",
+ "@nullvoxpopuli/ember-composable-helpers": "^5.3.2",
"@nullvoxpopuli/legacy-prototype-extensions": "^0.1.0",
- "@percy/cli": "^1.31.12",
- "@percy/ember": "^5.0.0",
+ "@percy/cli": "^1.31.13",
+ "@percy/ember": "^5.0.1",
"@tsconfig/ember": "^3.0.12",
"@types/qunit": "^2.19.13",
"@types/rsvp": "^4.0.9",
"anser": "^2.3.5",
- "axe-core": "^4.11.3",
+ "axe-core": "^4.11.4",
"base64-js": "^1.5.1",
"broccoli-asset-rev": "^3.0.0",
"bulma": "0.9.3",
+ "change-case": "^5.4.4",
"codemirror": "^5.65.21",
"concurrently": "^9.2.1",
"curved-arrows": "^0.3.0",
@@ -89,7 +90,7 @@
"d3-shape": "^3.2.0",
"d3-time-format": "^4.1.0",
"d3-transition": "^3.0.1",
- "dompurify": "^3.4.1",
+ "dompurify": "^3.4.2",
"duration-js": "^4.0.0",
"ember-a11y-testing": "^8.0.0",
"ember-auto-import": "^2.13.1",
@@ -100,7 +101,6 @@
"ember-cli-app-version": "^7.0.0",
"ember-cli-babel": "^8.3.1",
"ember-cli-clean-css": "^3.0.0",
- "ember-cli-clipboard": "^1.3.0",
"ember-cli-dependency-checker": "^3.3.3",
"ember-cli-deprecation-workflow": "^4.0.1",
"ember-cli-flash": "^7.0.0",
@@ -143,13 +143,13 @@
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-ember": "^12.7.5",
- "eslint-plugin-n": "^17.24.0",
+ "eslint-plugin-n": "^18.0.0",
"eslint-plugin-qunit": "^8.2.6",
"faker": "^4.1.0",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.3.0",
"glob": "^13.0.6",
- "globals": "^17.5.0",
+ "globals": "^17.6.0",
"http-proxy": "^1.18.1",
"is-ip": "^5.0.1",
"lint-staged": "^16.4.0",
@@ -159,7 +159,6 @@
"marked": "^17.0.4",
"miragejs": "^0.1.48",
"morgan": "^1.10.1",
- "change-case": "^5.4.4",
"pretender": "^3.4.7",
"prettier": "^3.8.3",
"prettier-plugin-ember-template-tag": "^2.1.5",
@@ -167,7 +166,7 @@
"qunit": "^2.25.0",
"qunit-dom": "^3.5.1",
"sass": "^1.99.0",
- "stylelint": "^17.9.0",
+ "stylelint": "^17.10.0",
"stylelint-config-standard-scss": "^17.0.0",
"testem": "^3.20.0",
"testem-multi-reporter": "^1.2.0",
@@ -176,7 +175,7 @@
"title-case": "^4.3.2",
"tracked-built-ins": "^4.1.2",
"typescript": "^6.0.3",
- "typescript-eslint": "^8.59.0",
+ "typescript-eslint": "^8.59.2",
"webpack": "^5.106.2",
"xstate": "^4.12.0",
"xterm": "^5.3.0",
diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js
index b1c14f3b035..7aef7f0c47c 100644
--- a/ui/tests/acceptance/allocation-detail-test.js
+++ b/ui/tests/acceptance/allocation-detail-test.js
@@ -465,18 +465,17 @@ module('Acceptance | allocation detail', function (hooks) {
await click('[data-test-breadcrumb="jobs.job.index"]');
await click('[data-test-tab="allocations"] > a');
- const component = this.owner.lookup('component:allocation-row');
+ const previousURL = currentURL();
const router = this.owner.lookup('service:router');
const allocRoute = this.owner.lookup('route:allocations.allocation');
const originalMethod = allocRoute.goBackToReferrer;
+ let redirectCount = 0;
+
allocRoute.goBackToReferrer = () => {
- assert.step('Transition dispatched.');
- router.transitionTo('jobs.job.allocations');
+ redirectCount++;
+ return router.transitionTo('jobs.job.allocations');
};
- component.onClick = () =>
- router.transitionTo('allocations.allocation', 'aaa');
-
this.server.get('/allocation/:id', function () {
return new AdapterError([
{
@@ -486,11 +485,24 @@ module('Acceptance | allocation detail', function (hooks) {
]);
});
- component.onClick();
+ try {
+ await router.transitionTo('allocations.allocation', 'aaa');
+ } catch (error) {
+ assert.strictEqual(
+ error?.name,
+ 'TransitionAborted',
+ 'The failed allocation transition aborts after redirecting',
+ );
+ }
await waitFor('.flash-message.alert-critical');
- assert.verifySteps(['Transition dispatched.']);
+ assert.ok(redirectCount > 0, 'Redirect was dispatched');
+ assert.strictEqual(
+ currentURL(),
+ previousURL,
+ 'The user remains on the previous page after the missing allocation redirect',
+ );
assert
.dom('.flash-message.alert-critical')
.exists('A toast error message pops up.');
diff --git a/ui/tests/integration/components/agent-monitor-test.js b/ui/tests/integration/components/agent-monitor-test.gjs
similarity index 68%
rename from ui/tests/integration/components/agent-monitor-test.js
rename to ui/tests/integration/components/agent-monitor-test.gjs
index fee79d9ed35..e4697b0a207 100644
--- a/ui/tests/integration/components/agent-monitor-test.js
+++ b/ui/tests/integration/components/agent-monitor-test.gjs
@@ -7,7 +7,6 @@ import { later, cancelTimers } from '@ember/runloop';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import Pretender from 'pretender';
import sinon from 'sinon';
import { logEncode } from '../../../mirage/data/logs';
@@ -17,6 +16,7 @@ import {
} from '../../utils/ember-power-select-extensions';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { capitalize } from '@ember/string';
+import AgentMonitor from 'nomad-ui/components/agent-monitor';
module('Integration | Component | agent-monitor', function (hooks) {
setupRenderingTest(hooks);
@@ -48,43 +48,50 @@ module('Integration | Component | agent-monitor', function (hooks) {
const INTERVAL = 200;
- const commonTemplate = hbs`
-
- `;
-
test('basic appearance', async function (assert) {
- this.setProperties({
- level: 'info',
- isStreaming: false,
- client: { id: 'client1' },
- });
-
- await render(commonTemplate);
+ this.level = 'info';
+ this.isStreaming = false;
+ this.client = { id: 'client1' };
+
+ await render(
+
+
+ ,
+ );
assert.ok(find('[data-test-level-switcher-parent]'));
assert.ok(find('[data-test-toggle]'));
assert.ok(find('[data-test-log-box]'));
assert.ok(find('[data-test-log-box].is-full-bleed.is-dark'));
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('.boxed-section'), assert);
});
// TODO(ember5-upgrade): Re-enable streaming behaviors once long-lived
// log polling is isolated from test settlement in this suite.
test.skip('when provided with a client, AgentMonitor streams logs for the client', async function (assert) {
- this.setProperties({
- level: 'info',
- client: { id: 'client1', region: 'us-west-1' },
- });
+ this.level = 'info';
+ this.client = { id: 'client1', region: 'us-west-1' };
later(cancelTimers, INTERVAL);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
const logRequest = this.pretender.handledRequests[1];
assert.ok(logRequest.url.startsWith('/v1/agent/monitor'));
@@ -95,14 +102,22 @@ module('Integration | Component | agent-monitor', function (hooks) {
});
test.skip('when provided with a server, AgentMonitor streams logs for the server', async function (assert) {
- this.setProperties({
- level: 'warn',
- server: { id: 'server1', region: 'us-west-1' },
- });
+ this.level = 'warn';
+ this.server = { id: 'server1', region: 'us-west-1' };
later(cancelTimers, INTERVAL);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
const logRequest = this.pretender.handledRequests[1];
assert.ok(logRequest.url.startsWith('/v1/agent/monitor'));
@@ -116,15 +131,23 @@ module('Integration | Component | agent-monitor', function (hooks) {
const onLevelChange = sinon.spy();
const newLevel = 'trace';
- this.setProperties({
- level: 'info',
- client: { id: 'client1' },
- onLevelChange,
- });
+ this.level = 'info';
+ this.client = { id: 'client1' };
+ this.onLevelChange = onLevelChange;
later(cancelTimers, INTERVAL);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
const contentId = await selectOpen('[data-test-level-switcher-parent]');
later(cancelTimers, INTERVAL);
@@ -142,15 +165,23 @@ module('Integration | Component | agent-monitor', function (hooks) {
const newLevel = 'trace';
const onLevelChange = sinon.spy();
- this.setProperties({
- level: 'info',
- client: { id: 'client1' },
- onLevelChange,
- });
+ this.level = 'info';
+ this.client = { id: 'client1' };
+ this.onLevelChange = onLevelChange;
later(cancelTimers, INTERVAL);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
find('[data-test-log-cli]').textContent,
@@ -188,15 +219,23 @@ module('Integration | Component | agent-monitor', function (hooks) {
),
]);
- this.setProperties({
- level: 'info',
- client: { id: 'client1' },
- onLevelChange,
- });
+ this.level = 'info';
+ this.client = { id: 'client1' };
+ this.onLevelChange = onLevelChange;
later(cancelTimers, INTERVAL);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(find('[data-test-log-cli]').textContent, '');
diff --git a/ui/tests/integration/components/allocation-row-test.js b/ui/tests/integration/components/allocation-row-test.gjs
similarity index 85%
rename from ui/tests/integration/components/allocation-row-test.js
rename to ui/tests/integration/components/allocation-row-test.gjs
index e93112227f0..d65d524b4a2 100644
--- a/ui/tests/integration/components/allocation-row-test.js
+++ b/ui/tests/integration/components/allocation-row-test.gjs
@@ -5,13 +5,13 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import generateResources from '../../../mirage/data/generate-resources';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { find, render } from '@ember/test-helpers';
import { Response } from 'miragejs';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import AllocationRow from 'nomad-ui/components/allocation-row';
module('Integration | Component | allocation row', function (hooks) {
setupRenderingTest(hooks);
@@ -34,7 +34,7 @@ module('Integration | Component | allocation row', function (hooks) {
const component = this;
let currentFrame = 0;
- let frames = [
+ const frames = [
JSON.stringify({ ResourceUsage: generateResources() }),
JSON.stringify({ ResourceUsage: generateResources() }),
null,
@@ -45,7 +45,6 @@ module('Integration | Component | allocation row', function (hooks) {
this.server.get('/client/allocation/:id/stats', function () {
const response = frames[++currentFrame];
- // Disable polling to stop the EC task in the component
if (currentFrame >= frames.length) {
component.set('enablePolling', false);
}
@@ -67,12 +66,15 @@ module('Integration | Component | allocation row', function (hooks) {
enablePolling: true,
});
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
this.server.pretender.handledRequests.filterBy(
@@ -105,11 +107,14 @@ module('Integration | Component | allocation row', function (hooks) {
context: 'job',
});
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.ok(
find('[data-test-icon="unhealthy-driver"]'),
@@ -123,11 +128,14 @@ module('Integration | Component | allocation row', function (hooks) {
const allocation = await this.store.findRecord('allocation', allocId);
this.setProperties({ allocation, context: 'job' });
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.ok(find('[data-test-icon="preemption"]'), 'Preempted icon is shown');
await componentA11yAudit(this.element, assert);
@@ -139,7 +147,6 @@ module('Integration | Component | allocation row', function (hooks) {
enablePolling: false,
});
- // All non-running statuses need to be tested
['pending', 'complete', 'failed', 'lost'].forEach((clientStatus) =>
this.server.create('allocation', { clientStatus }),
);
@@ -150,12 +157,15 @@ module('Integration | Component | allocation row', function (hooks) {
for (const allocation of allocations.toArray()) {
this.set('allocation', allocation);
- await render(hbs`
+ await render(
+
- `);
+ @enablePolling={{this.enablePolling}}
+ />
+ ,
+ );
const status = allocation.get('clientStatus');
assert.notOk(
diff --git a/ui/tests/integration/components/allocation-service-sidebar-test.js b/ui/tests/integration/components/allocation-service-sidebar-test.gjs
similarity index 61%
rename from ui/tests/integration/components/allocation-service-sidebar-test.js
rename to ui/tests/integration/components/allocation-service-sidebar-test.gjs
index 99e3778660b..4642339c619 100644
--- a/ui/tests/integration/components/allocation-service-sidebar-test.js
+++ b/ui/tests/integration/components/allocation-service-sidebar-test.gjs
@@ -5,19 +5,20 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { click, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { click, render, rerender } from '@ember/test-helpers';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import Service from '@ember/service';
-import EmberObject from '@ember/object';
+import { TrackedObject } from 'tracked-built-ins';
+import AllocationServiceSidebar from 'nomad-ui/components/allocation-service-sidebar';
module(
'Integration | Component | allocation-service-sidebar',
function (hooks) {
setupRenderingTest(hooks);
+
hooks.beforeEach(function () {
const mockSystem = Service.extend({
- agent: EmberObject.create({
+ agent: {
config: {
UI: {
Consul: {
@@ -25,7 +26,7 @@ module(
},
},
},
- }),
+ },
});
this.owner.register('service:system', mockSystem);
this.system = this.owner.lookup('service:system');
@@ -34,24 +35,31 @@ module(
test('it supports basic open/close states', async function (assert) {
await componentA11yAudit(this.element, assert);
- this.set('closeSidebar', () => this.set('service', null));
+ const state = new TrackedObject({
+ service: { name: 'Funky Service' },
+ });
+ const closeSidebar = () => {
+ state.service = null;
+ };
+ const fns = { closeSidebar };
- this.set('service', { name: 'Funky Service' });
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('h1').includesText('Funky Service');
assert.dom('.sidebar').hasClass('open');
- this.set('service', null);
- await render(
- hbs` `,
- );
+ state.service = null;
+ await rerender();
assert.dom(this.element).hasText('');
assert.dom('.sidebar').doesNotHaveClass('open');
- this.set('service', { name: 'Funky Service' });
+ state.service = { name: 'Funky Service' };
+ await rerender();
await click('[data-test-close-service-sidebar]');
+ await rerender();
assert.dom(this.element).hasText('');
assert.dom('.sidebar').doesNotHaveClass('open');
});
@@ -74,28 +82,36 @@ module(
],
};
- this.set('closeSidebar', () => this.set('service', null));
- this.set('allocation', { id: 'myAlloc', clientStatus: 'running' });
- this.set('service', healthyService);
+ const state = new TrackedObject({
+ service: healthyService,
+ allocation: { id: 'myAlloc', clientStatus: 'running' },
+ });
+ const closeSidebar = () => {
+ state.service = null;
+ };
+ const fns = { closeSidebar };
+
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('h1 .aggregate-status').includesText('Healthy');
assert
.dom('table.health-checks tbody tr:not(.service-status-indicators)')
.exists({ count: 2 }, 'has two rows');
- this.set('service', unhealthyService);
- await render(
- hbs` `,
- );
+ state.service = unhealthyService;
+ await rerender();
assert.dom('h1 .aggregate-status').includesText('Unhealthy');
- this.set('service', healthyService);
- this.set('allocation', { id: 'myAlloc2', clientStatus: 'failed' });
- await render(
- hbs` `,
- );
+ state.service = healthyService;
+ state.allocation = { id: 'myAlloc2', clientStatus: 'failed' };
+ await rerender();
assert.dom('h1 .aggregate-status').includesText('Health Unknown');
});
@@ -106,19 +122,28 @@ module(
healthChecks: [],
};
- this.set('closeSidebar', () => this.set('service', null));
- this.set('service', consulService);
+ const state = new TrackedObject({
+ service: consulService,
+ });
+ const closeSidebar = () => {
+ state.service = null;
+ };
+ const fns = { closeSidebar };
+
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('h1 .aggregate-status').doesNotExist();
assert.dom('table.health-checks').doesNotExist();
assert.dom('[data-test-consul-link-notice]').doesNotExist();
this.system.agent.config.UI.Consul.BaseUIURL = 'http://localhost:8500';
-
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('[data-test-consul-link-notice]').exists();
diff --git a/ui/tests/integration/components/app-breadcrumbs-test.gjs b/ui/tests/integration/components/app-breadcrumbs-test.gjs
new file mode 100644
index 00000000000..ff74f43ca27
--- /dev/null
+++ b/ui/tests/integration/components/app-breadcrumbs-test.gjs
@@ -0,0 +1,111 @@
+/**
+ * Copyright IBM Corp. 2015, 2026
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { findAll, render } from '@ember/test-helpers';
+import AppBreadcrumbs from 'nomad-ui/components/app-breadcrumbs';
+import Breadcrumb from 'nomad-ui/components/breadcrumb';
+
+module('Integration | Component | app breadcrumbs', function (hooks) {
+ setupRenderingTest(hooks);
+
+ const commonCrumbs = [
+ { label: 'Jobs', args: ['jobs.index'] },
+ { label: 'Job', args: ['jobs.job.index'] },
+ ];
+
+ test('every breadcrumb is rendered correctly', async function (assert) {
+ this.commonCrumbs = commonCrumbs;
+
+ await render(
+
+
+ {{#each this.commonCrumbs as |crumb|}}
+
+ {{/each}}
+ ,
+ );
+
+ assert
+ .dom('[data-test-breadcrumb-default]')
+ .exists(
+ 'We register the default breadcrumb component if no type is specified on the crumb',
+ );
+
+ const renderedCrumbs = findAll('[data-test-breadcrumb]');
+
+ renderedCrumbs.forEach((crumb, index) => {
+ assert.deepEqual(
+ crumb.textContent.trim(),
+ commonCrumbs[index].label,
+ `Crumb ${index} is ${commonCrumbs[index].label}`,
+ );
+ });
+ });
+
+ test('crumbs without a type default to the default breadcrumb component', async function (assert) {
+ this.crumbs = [
+ { label: 'Jobs', args: ['jobs.index'] },
+ { label: 'Job', args: ['jobs.job.index'] },
+ ];
+
+ await render(
+
+
+ {{#each this.crumbs as |crumb|}}
+
+ {{/each}}
+ ,
+ );
+
+ assert
+ .dom('[data-test-breadcrumb-default]')
+ .exists(
+ { count: 2 },
+ 'All crumbs without a type render as default breadcrumbs',
+ );
+ });
+
+ test('crumbs with type job render the job breadcrumb component', async function (assert) {
+ const job = {
+ idWithNamespace: 'example@default',
+ trimmedName: 'example',
+ hasChildren: false,
+ belongsTo() {
+ return {
+ id() {
+ return null;
+ },
+ };
+ },
+ get() {
+ return null;
+ },
+ };
+
+ this.crumbs = [
+ {
+ label: 'Job',
+ type: 'job',
+ args: ['jobs.job.index'],
+ job,
+ },
+ ];
+
+ await render(
+
+
+ {{#each this.crumbs as |crumb|}}
+
+ {{/each}}
+ ,
+ );
+
+ assert
+ .dom('[data-test-job-breadcrumb]')
+ .exists({ count: 1 }, 'Job breadcrumb is rendered for type=job');
+ });
+});
diff --git a/ui/tests/integration/components/app-breadcrumbs-test.js b/ui/tests/integration/components/app-breadcrumbs-test.js
deleted file mode 100644
index c6cbd02ae17..00000000000
--- a/ui/tests/integration/components/app-breadcrumbs-test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { findAll, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
-
-module('Integration | Component | app breadcrumbs', function (hooks) {
- setupRenderingTest(hooks);
-
- const commonCrumbs = [
- { label: 'Jobs', args: ['jobs.index'] },
- { label: 'Job', args: ['jobs.job.index'] },
- ];
-
- test('every breadcrumb is rendered correctly', async function (assert) {
- this.set('commonCrumbs', commonCrumbs);
- await render(hbs`
-
- {{#each this.commonCrumbs as |crumb|}}
-
- {{/each}}
- `);
-
- assert
- .dom('[data-test-breadcrumb-default]')
- .exists(
- 'We register the default breadcrumb component if no type is specified on the crumb',
- );
-
- const renderedCrumbs = findAll('[data-test-breadcrumb]');
-
- renderedCrumbs.forEach((crumb, index) => {
- assert.deepEqual(
- crumb.textContent.trim(),
- commonCrumbs[index].label,
- `Crumb ${index} is ${commonCrumbs[index].label}`,
- );
- });
- });
-
- test('crumbs without a type default to the default breadcrumb component', async function (assert) {
- this.set('crumbs', [
- { label: 'Jobs', args: ['jobs.index'] },
- { label: 'Job', args: ['jobs.job.index'] },
- ]);
-
- await render(hbs`
-
- {{#each this.crumbs as |crumb|}}
-
- {{/each}}
- `);
-
- assert
- .dom('[data-test-breadcrumb-default]')
- .exists(
- { count: 2 },
- 'All crumbs without a type render as default breadcrumbs',
- );
- });
-});
diff --git a/ui/tests/integration/components/attributes-table-test.js b/ui/tests/integration/components/attributes-table-test.gjs
similarity index 85%
rename from ui/tests/integration/components/attributes-table-test.js
rename to ui/tests/integration/components/attributes-table-test.gjs
index 97ba30577ea..422bbd615b3 100644
--- a/ui/tests/integration/components/attributes-table-test.js
+++ b/ui/tests/integration/components/attributes-table-test.gjs
@@ -6,9 +6,9 @@
import { find, findAll, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import PathTree from 'nomad-ui/utils/path-tree';
+import AttributesTable from 'nomad-ui/components/attributes-table';
module('Integration | Component | attributes table', function (hooks) {
setupRenderingTest(hooks);
@@ -45,8 +45,10 @@ module('Integration | Component | attributes table', function (hooks) {
});
test('should render a row for each key/value pair in a deep object', async function (assert) {
- this.set('attributes', commonAttributesTree.root);
- await render(hbs` `);
+ const attributes = commonAttributesTree.root;
+ await render(
+ ,
+ );
const rowsCount = commonAttributes.length;
assert.deepEqual(
@@ -61,8 +63,10 @@ module('Integration | Component | attributes table', function (hooks) {
});
test('should render the full path of key/value pair from the root of the object', async function (assert) {
- this.set('attributes', commonAttributesTree.root);
- await render(hbs` `);
+ const attributes = commonAttributesTree.root;
+ await render(
+ ,
+ );
assert.deepEqual(
find('[data-test-key]').textContent.trim(),
diff --git a/ui/tests/integration/components/breadcrumbs-test.js b/ui/tests/integration/components/breadcrumbs-test.gjs
similarity index 62%
rename from ui/tests/integration/components/breadcrumbs-test.js
rename to ui/tests/integration/components/breadcrumbs-test.gjs
index fc46d5e8309..41b694b63bd 100644
--- a/ui/tests/integration/components/breadcrumbs-test.js
+++ b/ui/tests/integration/components/breadcrumbs-test.gjs
@@ -6,7 +6,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, findAll, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { on } from '@ember/modifier';
+import Breadcrumbs from 'nomad-ui/components/breadcrumbs';
+import Breadcrumb from 'nomad-ui/components/breadcrumb';
module('Integration | Component | breadcrumbs', function (hooks) {
setupRenderingTest(hooks);
@@ -14,20 +16,27 @@ module('Integration | Component | breadcrumbs', function (hooks) {
test('it declaratively renders a list of registered crumbs', async function (assert) {
this.set('isRegistered', false);
this.set('toggleCrumb', () => this.set('isRegistered', !this.isRegistered));
- await render(hbs`
-
-
- {{#each bb as |crumb|}}
- - {{crumb.args.crumb}}
- {{/each}}
-
-
-
-
- {{#if this.isRegistered}}
-
- {{/if}}
- `);
+
+ await render(
+
+
+
+ {{#each bb as |crumb|}}
+ - {{crumb.args.crumb}}
+ {{/each}}
+
+
+
+
+ {{#if this.isRegistered}}
+
+ {{/if}}
+ ,
+ );
assert
.dom('[data-test-crumb]')
@@ -68,16 +77,20 @@ module('Integration | Component | breadcrumbs', function (hooks) {
});
test('it can register complex crumb objects', async function (assert) {
- await render(hbs`
-
-
- {{#each bb as |crumb|}}
- - {{crumb.args.crumb.name}}
- {{/each}}
-
-
-
- `);
+ this.set('complexCrumb', { name: 'Tomster' });
+
+ await render(
+
+
+
+ {{#each bb as |crumb|}}
+ - {{crumb.args.crumb.name}}
+ {{/each}}
+
+
+
+ ,
+ );
assert
.dom('[data-test-crumb]')
diff --git a/ui/tests/integration/components/copy-button-test.gjs b/ui/tests/integration/components/copy-button-test.gjs
new file mode 100644
index 00000000000..e0ff00c1dd0
--- /dev/null
+++ b/ui/tests/integration/components/copy-button-test.gjs
@@ -0,0 +1,60 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { click, render, find, waitUntil } from '@ember/test-helpers';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import CopyButton from 'nomad-ui/components/copy-button';
+import sinon from 'sinon';
+
+function stubClipboardWrite(stub) {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: stub },
+ });
+}
+
+module('Integration | Component | copy-button', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it shows the copy icon by default', async function (assert) {
+ await render( );
+
+ assert.dom('.copy-button .hds-icon-clipboard-copy').exists();
+ await componentA11yAudit(find('.copy-button'), assert);
+ });
+
+ test('it shows the success icon on success and resets afterward', async function (assert) {
+ const writeText = sinon.stub().resolves();
+ stubClipboardWrite(writeText);
+
+ await render( );
+
+ await click('.copy-button button');
+
+ assert.dom('[data-test-copy-success]').exists();
+ await componentA11yAudit(find('.copy-button'), assert);
+
+ await waitUntil(() => !find('[data-test-copy-success]'), { timeout: 3000 });
+
+ assert.dom('[data-test-copy-success]').doesNotExist();
+ assert.dom('.copy-button .hds-icon-clipboard-copy').exists();
+ assert.ok(writeText.calledWith('tomster'));
+ });
+
+ test('it shows the error icon on error', async function (assert) {
+ const writeText = sinon.stub().rejects(new Error('clipboard error'));
+ stubClipboardWrite(writeText);
+
+ await render( );
+
+ await click('.copy-button button');
+
+ assert.dom('.copy-button .hds-icon-clipboard-x').exists();
+ assert.ok(writeText.calledWith('tomster'));
+ await componentA11yAudit(find('.copy-button'), assert);
+ });
+});
diff --git a/ui/tests/integration/components/copy-button-test.js b/ui/tests/integration/components/copy-button-test.js
deleted file mode 100644
index 0d14c3c78ae..00000000000
--- a/ui/tests/integration/components/copy-button-test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { click, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-
-import sinon from 'sinon';
-
-import {
- triggerCopyError,
- triggerCopySuccess,
-} from 'ember-cli-clipboard/test-support';
-
-module('Integration | Component | copy-button', function (hooks) {
- setupRenderingTest(hooks);
-
- test('it shows the copy icon by default', async function (assert) {
- await render(hbs` `);
- assert.dom('.copy-button .hds-icon-clipboard-copy').exists();
- await componentA11yAudit(this.element, assert);
- });
-
- test('it shows the success icon on success and resets afterward', async function (assert) {
- const clock = sinon.useFakeTimers({ shouldAdvanceTime: true });
-
- await render(hbs` `);
-
- await click('.copy-button button');
- await triggerCopySuccess('.copy-button button');
-
- assert.dom('[data-test-copy-success]').exists();
- await componentA11yAudit(this.element, assert);
-
- clock.runAll();
-
- assert.dom('[data-test-copy-success]').doesNotExist();
- assert.dom('.copy-button .hds-icon-clipboard-copy').exists();
-
- clock.restore();
- });
-
- test('it shows the error icon on error', async function (assert) {
- await render(hbs` `);
-
- await click('.copy-button button');
- await triggerCopyError('.copy-button button');
-
- assert.dom('.copy-button .hds-icon-clipboard-x').exists();
- await componentA11yAudit(this.element, assert);
- });
-});
diff --git a/ui/tests/integration/components/das/dismissed-test.js b/ui/tests/integration/components/das/dismissed-test.gjs
similarity index 71%
rename from ui/tests/integration/components/das/dismissed-test.js
rename to ui/tests/integration/components/das/dismissed-test.gjs
index b76d70d2beb..a97c52b5257 100644
--- a/ui/tests/integration/components/das/dismissed-test.js
+++ b/ui/tests/integration/components/das/dismissed-test.gjs
@@ -5,9 +5,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { click, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { click, find, render } from '@ember/test-helpers';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import DasDismissed from 'nomad-ui/components/das/dismissed';
import sinon from 'sinon';
module('Integration | Component | das/dismissed', function (hooks) {
@@ -19,11 +19,10 @@ module('Integration | Component | das/dismissed', function (hooks) {
test('it renders the dismissal interstitial with a button to proceed and an option to never show again and proceeds manually', async function (assert) {
const proceedSpy = sinon.spy();
- this.set('proceedSpy', proceedSpy);
- await render(hbs` `);
+ await render( );
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('.das-dismissed'), assert);
await click('input[type=checkbox]');
await click('[data-test-understood]');
@@ -36,16 +35,18 @@ module('Integration | Component | das/dismissed', function (hooks) {
});
test('it renders the dismissal interstitial with no button when the option to never show again has been chosen and proceeds automatically', async function (assert) {
- window.localStorage.setItem('nomadRecommendationDismssalUnderstood', true);
+ window.localStorage.setItem(
+ 'nomadRecommendationDismssalUnderstood',
+ 'true',
+ );
const proceedSpy = sinon.spy();
- this.set('proceedSpy', proceedSpy);
- await render(hbs` `);
+ await render( );
assert.dom('[data-test-understood]').doesNotExist();
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('.das-dismissed'), assert);
assert.ok(proceedSpy.calledWith({ manuallyDismissed: false }));
});
diff --git a/ui/tests/integration/components/das/recommendation-card-test.js b/ui/tests/integration/components/das/recommendation-card-test.gjs
similarity index 65%
rename from ui/tests/integration/components/das/recommendation-card-test.js
rename to ui/tests/integration/components/das/recommendation-card-test.gjs
index 0a411ce33ec..9eabc59bb24 100644
--- a/ui/tests/integration/components/das/recommendation-card-test.js
+++ b/ui/tests/integration/components/das/recommendation-card-test.gjs
@@ -6,17 +6,21 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import DasRecommendationCard from 'nomad-ui/components/das/recommendation-card';
import Service from '@ember/service';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-
import RecommendationCardComponent from 'nomad-ui/tests/pages/components/recommendation-card';
import { create } from 'ember-cli-page-object';
+import { tracked } from '@glimmer/tracking';
+import { action, set } from '@ember/object';
+
const RecommendationCard = create(RecommendationCardComponent);
-import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
-import { set } from '@ember/object';
+function renderRecommendationCard() {
+ return render(
+ ,
+ );
+}
module('Integration | Component | das/recommendation-card', function (hooks) {
setupRenderingTest(hooks);
@@ -48,57 +52,54 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 256,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- jobNamespace: 'namespace',
- recommendations: [
- {
- resource: 'MemoryMB',
- stats: {},
- task: task1,
- value: 192,
- currentValue: task1.reservedMemory,
- },
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- currentValue: task1.reservedCPU,
- },
- {
- resource: 'CPU',
- stats: {},
- task: task2,
- value: 150,
- currentValue: task2.reservedCPU,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task2,
- value: 320,
- currentValue: task2.reservedMemory,
- },
- ],
-
- taskGroup: {
- count: 2,
- name: 'group-name',
- job: {
- name: 'job-name',
- namespace: {
- name: 'namespace',
- },
+ this.summary = new MockRecommendationSummary({
+ jobNamespace: 'namespace',
+ recommendations: [
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task1,
+ value: 192,
+ currentValue: task1.reservedMemory,
+ },
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
+ currentValue: task1.reservedCPU,
+ },
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task2,
+ value: 150,
+ currentValue: task2.reservedCPU,
+ },
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task2,
+ value: 320,
+ currentValue: task2.reservedMemory,
+ },
+ ],
+
+ taskGroup: {
+ count: 2,
+ name: 'group-name',
+ job: {
+ name: 'job-name',
+ namespace: {
+ name: 'namespace',
},
- reservedCPU: task1.reservedCPU + task2.reservedCPU,
- reservedMemory: task1.reservedMemory + task2.reservedMemory,
},
- }),
- );
+ reservedCPU: task1.reservedCPU + task2.reservedCPU,
+ reservedMemory: task1.reservedMemory + task2.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
assert.deepEqual(RecommendationCard.slug.jobName, 'job-name');
assert.deepEqual(RecommendationCard.slug.groupName, 'group-name');
@@ -132,7 +133,6 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
'+128 MiB',
);
- // Expected signal has a minus character, not a hyphen.
assert.deepEqual(RecommendationCard.totalsTable.percentDiff.cpu, '−27%');
assert.deepEqual(RecommendationCard.totalsTable.percentDiff.memory, '+33%');
@@ -247,33 +247,30 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 128,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task1,
- value: 192,
- },
- ],
-
- taskGroup: {
- count: 1,
- reservedCPU: task1.reservedCPU,
- reservedMemory: task1.reservedMemory,
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
},
- }),
- );
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task1,
+ value: 192,
+ },
+ ],
- await render(hbs` `);
+ taskGroup: {
+ count: 1,
+ reservedCPU: task1.reservedCPU,
+ reservedMemory: task1.reservedMemory,
+ },
+ });
+
+ await renderRecommendationCard.call(this);
assert.notOk(RecommendationCard.togglesTable.toggleAllIsPresent);
assert.notOk(RecommendationCard.togglesTable.toggleAllCPU.isPresent);
@@ -287,33 +284,30 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 128,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task1,
- value: 192,
- },
- ],
-
- taskGroup: {
- count: 1,
- reservedCPU: task1.reservedCPU,
- reservedMemory: task1.reservedMemory,
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
},
- }),
- );
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task1,
+ value: 192,
+ },
+ ],
+
+ taskGroup: {
+ count: 1,
+ reservedCPU: task1.reservedCPU,
+ reservedMemory: task1.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
await RecommendationCard.togglesTable.tasks[0].cpu.toggle();
await RecommendationCard.togglesTable.tasks[0].memory.toggle();
@@ -328,31 +322,28 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 128,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- },
- ],
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
+ },
+ ],
- taskGroup: {
- count: 2,
- name: 'group-name',
- job: {
- name: 'job-name',
- },
- reservedCPU: task1.reservedCPU,
- reservedMemory: task1.reservedMemory,
+ taskGroup: {
+ count: 2,
+ name: 'group-name',
+ job: {
+ name: 'job-name',
},
- }),
- );
+ reservedCPU: task1.reservedCPU,
+ reservedMemory: task1.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
assert.deepEqual(
RecommendationCard.totalsTable.recommended.memory.text,
@@ -383,37 +374,34 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 128,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- },
- {
- resource: 'CPU',
- stats: {},
- task: task2,
- value: 50,
- },
- ],
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
+ },
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task2,
+ value: 50,
+ },
+ ],
- taskGroup: {
- count: 2,
- name: 'group-name',
- job: {
- name: 'job-name',
- },
- reservedCPU: task1.reservedCPU + task2.reservedCPU,
- reservedMemory: task1.reservedMemory + task2.reservedMemory,
+ taskGroup: {
+ count: 2,
+ name: 'group-name',
+ job: {
+ name: 'job-name',
},
- }),
- );
+ reservedCPU: task1.reservedCPU + task2.reservedCPU,
+ reservedMemory: task1.reservedMemory + task2.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
assert.ok(RecommendationCard.togglesTable.toggleAllMemory.isDisabled);
assert.notOk(RecommendationCard.togglesTable.toggleAllMemory.isActive);
@@ -433,56 +421,53 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 256,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- currentValue: task1.reservedCPU,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task1,
- value: 192,
- currentValue: task1.reservedMemory,
- },
- {
- resource: 'CPU',
- stats: {},
- task: task2,
- value: 150,
- currentValue: task2.reservedCPU,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task2,
- value: 320,
- currentValue: task2.reservedMemory,
- },
- ],
-
- taskGroup: {
- count: 10,
- name: 'group-name',
- job: {
- name: 'job-name',
- namespace: {
- name: 'namespace',
- },
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
+ currentValue: task1.reservedCPU,
+ },
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task1,
+ value: 192,
+ currentValue: task1.reservedMemory,
+ },
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task2,
+ value: 150,
+ currentValue: task2.reservedCPU,
+ },
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task2,
+ value: 320,
+ currentValue: task2.reservedMemory,
+ },
+ ],
+
+ taskGroup: {
+ count: 10,
+ name: 'group-name',
+ job: {
+ name: 'job-name',
+ namespace: {
+ name: 'namespace',
},
- reservedCPU: task1.reservedCPU + task2.reservedCPU,
- reservedMemory: task1.reservedMemory + task2.reservedMemory,
},
- }),
- );
+ reservedCPU: task1.reservedCPU + task2.reservedCPU,
+ reservedMemory: task1.reservedMemory + task2.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
const [cpuRec1, memRec1, cpuRec2, memRec2] = this.summary.recommendations;
@@ -560,56 +545,53 @@ module('Integration | Component | das/recommendation-card', function (hooks) {
reservedMemory: 256,
};
- this.set(
- 'summary',
- new MockRecommendationSummary({
- recommendations: [
- {
- resource: 'CPU',
- stats: {},
- task: task1,
- value: 50,
- currentValue: task1.reservedCPU,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task1,
- value: 192,
- currentValue: task1.reservedMemory,
- },
- {
- resource: 'CPU',
- stats: {},
- task: task2,
- value: 150,
- currentValue: task2.reservedCPU,
- },
- {
- resource: 'MemoryMB',
- stats: {},
- task: task2,
- value: 320,
- currentValue: task2.reservedMemory,
- },
- ],
-
- taskGroup: {
- count: 1,
- name: 'group-name',
- job: {
- name: 'job-name',
- namespace: {
- name: 'namespace',
- },
+ this.summary = new MockRecommendationSummary({
+ recommendations: [
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task1,
+ value: 50,
+ currentValue: task1.reservedCPU,
+ },
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task1,
+ value: 192,
+ currentValue: task1.reservedMemory,
+ },
+ {
+ resource: 'CPU',
+ stats: {},
+ task: task2,
+ value: 150,
+ currentValue: task2.reservedCPU,
+ },
+ {
+ resource: 'MemoryMB',
+ stats: {},
+ task: task2,
+ value: 320,
+ currentValue: task2.reservedMemory,
+ },
+ ],
+
+ taskGroup: {
+ count: 1,
+ name: 'group-name',
+ job: {
+ name: 'job-name',
+ namespace: {
+ name: 'namespace',
},
- reservedCPU: task1.reservedCPU + task2.reservedCPU,
- reservedMemory: task1.reservedMemory + task2.reservedMemory,
},
- }),
- );
+ reservedCPU: task1.reservedCPU + task2.reservedCPU,
+ reservedMemory: task1.reservedMemory + task2.reservedMemory,
+ },
+ });
- await render(hbs` `);
+ await renderRecommendationCard.call(this);
assert.deepEqual(
RecommendationCard.narrative.trim(),
diff --git a/ui/tests/integration/components/das/recommendation-chart-test.js b/ui/tests/integration/components/das/recommendation-chart-test.gjs
similarity index 61%
rename from ui/tests/integration/components/das/recommendation-chart-test.js
rename to ui/tests/integration/components/das/recommendation-chart-test.gjs
index 7fb0bb07f84..78e8be4009e 100644
--- a/ui/tests/integration/components/das/recommendation-chart-test.js
+++ b/ui/tests/integration/components/das/recommendation-chart-test.gjs
@@ -5,26 +5,30 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { render, triggerEvent } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { render, settled, triggerEvent } from '@ember/test-helpers';
+import DasRecommendationChart from 'nomad-ui/components/das/recommendation-chart';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | das/recommendation-chart', function (hooks) {
setupRenderingTest(hooks);
test('it renders a chart for a recommended CPU increase', async function (assert) {
- this.set('resource', 'CPU');
- this.set('current', 1312);
- this.set('recommended', 1919);
- this.set('stats', {});
+ this.setProperties({
+ resource: 'CPU',
+ current: 1312,
+ recommended: 1919,
+ stats: {},
+ });
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.recommendation-chart.increase').exists();
@@ -35,18 +39,22 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
});
test('it renders a chart for a recommended memory decrease', async function (assert) {
- this.set('resource', 'MemoryMB');
- this.set('current', 1919);
- this.set('recommended', 1312);
- this.set('stats', {});
+ this.setProperties({
+ resource: 'MemoryMB',
+ current: 1919,
+ recommended: 1312,
+ stats: {},
+ });
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.recommendation-chart.decrease').exists();
@@ -57,20 +65,24 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
});
test('it handles the maximum being far beyond the recommended', async function (assert) {
- this.set('resource', 'CPU');
- this.set('current', 1312);
- this.set('recommended', 1919);
- this.set('stats', {
- max: 3000,
+ this.setProperties({
+ resource: 'CPU',
+ current: 1312,
+ recommended: 1919,
+ stats: {
+ max: 3000,
+ },
});
await render(
- hbs` `,
+
+
+ ,
);
const chartSvg = this.element.querySelector('.recommendation-chart svg');
@@ -80,19 +92,23 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
});
test('it can be disabled and will show no delta', async function (assert) {
- this.set('resource', 'CPU');
- this.set('current', 1312);
- this.set('recommended', 1919);
- this.set('stats', {});
+ this.setProperties({
+ resource: 'CPU',
+ current: 1312,
+ recommended: 1919,
+ stats: {},
+ });
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.recommendation-chart.disabled');
@@ -105,23 +121,26 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
});
test('the stats labels shift aligment and disappear to account for space', async function (assert) {
- this.set('resource', 'CPU');
- this.set('current', 50);
- this.set('recommended', 100);
-
- this.set('stats', {
- mean: 5,
- p99: 99,
- max: 100,
+ this.setProperties({
+ resource: 'CPU',
+ current: 50,
+ recommended: 100,
+ stats: {
+ mean: 5,
+ p99: 99,
+ max: 100,
+ },
});
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('[data-test-label=max]').hasClass('right');
@@ -132,6 +151,8 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
max: 100,
});
+ await settled();
+
assert.dom('[data-test-label=max]').hasNoClass('right');
assert.dom('[data-test-label=p99]').hasClass('right');
@@ -141,30 +162,35 @@ module('Integration | Component | das/recommendation-chart', function (hooks) {
max: 7,
});
+ await settled();
+
assert.dom('[data-test-label=max]').hasClass('right');
assert.dom('[data-test-label=p99]').hasClass('hidden');
});
test('a legend tooltip shows the sorted stats values on hover', async function (assert) {
- this.set('resource', 'CPU');
- this.set('current', 50);
- this.set('recommended', 101);
-
- this.set('stats', {
- mean: 5,
- p99: 99,
- max: 100,
- min: 1,
- median: 55,
+ this.setProperties({
+ resource: 'CPU',
+ current: 50,
+ recommended: 101,
+ stats: {
+ mean: 5,
+ p99: 99,
+ max: 100,
+ min: 1,
+ median: 55,
+ },
});
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.chart-tooltip').isNotVisible();
diff --git a/ui/tests/integration/components/flex-masonry-test.gjs b/ui/tests/integration/components/flex-masonry-test.gjs
new file mode 100644
index 00000000000..88510b42b3b
--- /dev/null
+++ b/ui/tests/integration/components/flex-masonry-test.gjs
@@ -0,0 +1,213 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { htmlSafe } from '@ember/template';
+import { click, find, findAll, render, settled } from '@ember/test-helpers';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import FlexMasonry from 'nomad-ui/components/flex-masonry';
+
+// Used to prevent XSS warnings in console
+const h = (height) => htmlSafe(`height:${height}px`);
+
+module('Integration | Component | FlexMasonry', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('presents as a single div when @items is empty', async function (assert) {
+ const items = [];
+ const columns = undefined;
+
+ await render(
+
+
+ ,
+ );
+
+ const containerEl = find('[data-test-flex-masonry]');
+ assert.ok(containerEl);
+ assert.deepEqual(containerEl.tagName.toLowerCase(), 'div');
+ assert.deepEqual(containerEl.children.length, 0);
+
+ await componentA11yAudit(containerEl, assert);
+ });
+
+ test('each item in @items gets wrapped in a flex-masonry-item wrapper', async function (assert) {
+ const items = ['one', 'two', 'three'];
+ const columns = 2;
+
+ await render(
+
+
+ {{item}}
+
+ ,
+ );
+
+ assert.deepEqual(
+ findAll('[data-test-flex-masonry-item]').length,
+ items.length,
+ );
+ });
+
+ test('the @withSpacing arg adds the with-spacing class', async function (assert) {
+ const items = [];
+ const columns = 1;
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(
+ find('[data-test-flex-masonry]').classList.contains('with-spacing'),
+ );
+ });
+
+ test('individual items along with the reflow action are yielded', async function (assert) {
+ class State {
+ @tracked height = h(50);
+ }
+
+ const state = new State();
+ const items = ['one', 'two'];
+ const columns = 2;
+
+ await render(
+
+
+ {{item}}
+
+ ,
+ );
+
+ const containerEl = find('[data-test-flex-masonry]');
+ assert.deepEqual(containerEl.style.maxHeight, '51px');
+ assert.ok(containerEl.textContent.includes('one'));
+ assert.ok(containerEl.textContent.includes('two'));
+
+ state.height = h(500);
+ await settled();
+ assert.deepEqual(containerEl.style.maxHeight, '51px');
+
+ // The height of the div changes when reflow is called
+ await click('[data-test-flex-masonry-item]:first-child div');
+
+ assert.deepEqual(containerEl.style.maxHeight, '501px');
+ });
+
+ test('items are rendered to the DOM in the order they were passed into the component', async function (assert) {
+ const items = [
+ { text: 'One', height: h(20) },
+ { text: 'Two', height: h(100) },
+ { text: 'Three', height: h(20) },
+ { text: 'Four', height: h(20) },
+ ];
+ const columns = 2;
+
+ await render(
+
+
+ {{item.text}}
+
+ ,
+ );
+
+ findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
+ assert.deepEqual(el.textContent.trim(), items[index].text);
+ });
+ });
+
+ test('each item gets an order property', async function (assert) {
+ const items = [
+ { text: 'One', height: h(20), expectedOrder: 0 },
+ { text: 'Two', height: h(100), expectedOrder: 3 },
+ { text: 'Three', height: h(20), expectedOrder: 1 },
+ { text: 'Four', height: h(20), expectedOrder: 2 },
+ ];
+ const columns = 2;
+
+ await render(
+
+
+ {{item.text}}
+
+ ,
+ );
+
+ findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
+ assert.strictEqual(Number(el.style.order), items[index].expectedOrder);
+ });
+ });
+
+ test('the last item in each column gets a specific flex-basis value', async function (assert) {
+ const items = [
+ { text: 'One', height: h(20) },
+ { text: 'Two', height: h(100), flexBasis: '100px' },
+ { text: 'Three', height: h(20) },
+ { text: 'Four', height: h(100), flexBasis: '100px' },
+ { text: 'Five', height: h(20), flexBasis: '80px' },
+ { text: 'Six', height: h(20), flexBasis: '80px' },
+ ];
+ const columns = 4;
+
+ await render(
+
+
+ {{item.text}}
+
+ ,
+ );
+
+ findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
+ if (el.style.flexBasis) {
+ assert.deepEqual(el.style.flexBasis, items[index].flexBasis);
+ }
+ });
+ });
+
+ test('when a multi-column layout becomes a single column layout, all inline-styles are reset', async function (assert) {
+ class State {
+ @tracked columns = 4;
+ }
+
+ const state = new State();
+ const items = [
+ { text: 'One', height: h(20) },
+ { text: 'Two', height: h(100) },
+ { text: 'Three', height: h(20) },
+ { text: 'Four', height: h(100) },
+ { text: 'Five', height: h(20) },
+ { text: 'Six', height: h(20) },
+ ];
+
+ await render(
+
+
+ {{item.text}}
+
+ ,
+ );
+
+ assert.deepEqual(find('[data-test-flex-masonry]').style.maxHeight, '101px');
+
+ state.columns = 1;
+ await settled();
+
+ findAll('[data-test-flex-masonry-item]').forEach((el) => {
+ assert.deepEqual(el.style.flexBasis, '');
+ assert.deepEqual(el.style.order, '');
+ });
+
+ assert.deepEqual(find('[data-test-flex-masonry]').style.maxHeight, '');
+ });
+});
diff --git a/ui/tests/integration/components/flex-masonry-test.js b/ui/tests/integration/components/flex-masonry-test.js
deleted file mode 100644
index 9770dac1b69..00000000000
--- a/ui/tests/integration/components/flex-masonry-test.js
+++ /dev/null
@@ -1,215 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { htmlSafe } from '@ember/template';
-import { click, find, findAll, render, settled } from '@ember/test-helpers';
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-
-// Used to prevent XSS warnings in console
-const h = (height) => htmlSafe(`height:${height}px`);
-
-module('Integration | Component | FlexMasonry', function (hooks) {
- setupRenderingTest(hooks);
-
- test('presents as a single div when @items is empty', async function (assert) {
- this.setProperties({
- items: [],
- });
-
- await render(hbs`
-
-
- `);
-
- const div = find('[data-test-flex-masonry]');
- assert.ok(div);
- assert.deepEqual(div.tagName.toLowerCase(), 'div');
- assert.deepEqual(div.children.length, 0);
-
- await componentA11yAudit(this.element, assert);
- });
-
- test('each item in @items gets wrapped in a flex-masonry-item wrapper', async function (assert) {
- this.setProperties({
- items: ['one', 'two', 'three'],
- columns: 2,
- });
-
- await render(hbs`
-
- {{item}}
-
- `);
-
- assert.deepEqual(
- findAll('[data-test-flex-masonry-item]').length,
- this.items.length,
- );
- });
-
- test('the @withSpacing arg adds the with-spacing class', async function (assert) {
- await render(hbs`
-
-
- `);
-
- assert.ok(
- find('[data-test-flex-masonry]').classList.contains('with-spacing'),
- );
- });
-
- test('individual items along with the reflow action are yielded', async function (assert) {
- this.setProperties({
- items: ['one', 'two'],
- columns: 2,
- height: h(50),
- });
-
- await render(hbs`
-
- {{item}}
-
- `);
-
- const div = find('[data-test-flex-masonry]');
- assert.deepEqual(div.style.maxHeight, '51px');
- assert.ok(div.textContent.includes('one'));
- assert.ok(div.textContent.includes('two'));
-
- this.set('height', h(500));
- await settled();
- assert.deepEqual(div.style.maxHeight, '51px');
-
- // The height of the div changes when reflow is called
- await click('[data-test-flex-masonry-item]:first-child div');
-
- assert.deepEqual(div.style.maxHeight, '501px');
- });
-
- test('items are rendered to the DOM in the order they were passed into the component', async function (assert) {
- this.setProperties({
- items: [
- { text: 'One', height: h(20) },
- { text: 'Two', height: h(100) },
- { text: 'Three', height: h(20) },
- { text: 'Four', height: h(20) },
- ],
- columns: 2,
- });
-
- await render(hbs`
-
- {{item.text}}
-
- `);
-
- findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
- assert.deepEqual(el.textContent.trim(), this.items[index].text);
- });
- });
-
- test('each item gets an order property', async function (assert) {
- this.setProperties({
- items: [
- { text: 'One', height: h(20), expectedOrder: 0 },
- { text: 'Two', height: h(100), expectedOrder: 3 },
- { text: 'Three', height: h(20), expectedOrder: 1 },
- { text: 'Four', height: h(20), expectedOrder: 2 },
- ],
- columns: 2,
- });
-
- await render(hbs`
-
- {{item.text}}
-
- `);
-
- findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
- assert.strictEqual(
- Number(el.style.order),
- this.items[index].expectedOrder,
- );
- });
- });
-
- test('the last item in each column gets a specific flex-basis value', async function (assert) {
- this.setProperties({
- items: [
- { text: 'One', height: h(20) },
- { text: 'Two', height: h(100), flexBasis: '100px' },
- { text: 'Three', height: h(20) },
- { text: 'Four', height: h(100), flexBasis: '100px' },
- { text: 'Five', height: h(20), flexBasis: '80px' },
- { text: 'Six', height: h(20), flexBasis: '80px' },
- ],
- columns: 4,
- });
-
- await render(hbs`
-
- {{item.text}}
-
- `);
-
- findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
- if (el.style.flexBasis) {
- assert.deepEqual(el.style.flexBasis, this.items[index].flexBasis);
- }
- });
- });
-
- test('when a multi-column layout becomes a single column layout, all inline-styles are reset', async function (assert) {
- this.setProperties({
- items: [
- { text: 'One', height: h(20) },
- { text: 'Two', height: h(100) },
- { text: 'Three', height: h(20) },
- { text: 'Four', height: h(100) },
- { text: 'Five', height: h(20) },
- { text: 'Six', height: h(20) },
- ],
- columns: 4,
- });
-
- await render(hbs`
-
- {{item.text}}
-
- `);
-
- assert.deepEqual(find('[data-test-flex-masonry]').style.maxHeight, '101px');
-
- this.set('columns', 1);
- await settled();
-
- findAll('[data-test-flex-masonry-item]').forEach((el) => {
- assert.deepEqual(el.style.flexBasis, '');
- assert.deepEqual(el.style.order, '');
- });
-
- assert.deepEqual(find('[data-test-flex-masonry]').style.maxHeight, '');
- });
-});
diff --git a/ui/tests/integration/components/fs/file-test.js b/ui/tests/integration/components/fs/file-test.gjs
similarity index 87%
rename from ui/tests/integration/components/fs/file-test.js
rename to ui/tests/integration/components/fs/file-test.gjs
index 9429967449f..4c27da2b4e6 100644
--- a/ui/tests/integration/components/fs/file-test.js
+++ b/ui/tests/integration/components/fs/file-test.gjs
@@ -6,7 +6,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, click, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import FsFile from 'nomad-ui/components/fs/file';
import Pretender from 'pretender';
import { logEncode } from '../../../../mirage/data/logs';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
@@ -14,6 +14,19 @@ import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
const { assign } = Object;
const HOST = '1.1.1.1:1111';
+function renderFile() {
+ return render(
+
+
+ ,
+ );
+}
+
module('Integration | Component | fs/file', function (hooks) {
setupRenderingTest(hooks);
@@ -64,10 +77,6 @@ module('Integration | Component | fs/file', function (hooks) {
window.localStorage.clear();
});
- const commonTemplate = hbs`
-
- `;
-
const fileStat = (type, size = 0) => ({
stat: {
Size: size,
@@ -100,7 +109,7 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('text/plain', 500));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.ok(
find('[data-test-file-box] [data-test-log-cli]'),
@@ -118,7 +127,7 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('image/png', 1234));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.ok(
find('[data-test-file-box] [data-test-image-file]'),
@@ -136,7 +145,7 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('wat/ohno', 1234));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.notOk(
find('[data-test-file-box] [data-test-image-file]'),
@@ -157,7 +166,7 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('wat/ohno', 1234));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.ok(
find('[data-test-unsupported-type] [data-test-log-action="raw"]'),
@@ -174,12 +183,12 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('image/png', 1234));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
await click('[data-test-log-action="raw"]');
assert.ok(
this.server.handledRequests.find(
- ({ url: url }) =>
+ ({ url }) =>
url ===
`/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent(
`${props.taskState.name}/${props.file}`,
@@ -197,13 +206,13 @@ module('Integration | Component | fs/file', function (hooks) {
this.setProperties(props);
await this.system.get('regions');
- await render(commonTemplate);
+ await renderFile.call(this);
await click('[data-test-log-action="raw"]');
assert.ok(
this.server.handledRequests.find(
- ({ url: url }) =>
+ ({ url }) =>
url ===
`/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent(
`${props.taskState.name}/${props.file}`,
@@ -217,12 +226,12 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('application/json', 5000));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
- this.set('stat.Size', 100000);
+ this.set('stat', { ...this.stat, Size: 100000 });
await settled();
@@ -234,12 +243,12 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('image/svg', 5000));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
- this.set('stat.Size', 100000);
+ this.set('stat', { ...this.stat, Size: 100000 });
await settled();
@@ -251,11 +260,18 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('image/svg', 5000));
this.setProperties(props);
- await render(hbs`
-
- Yielded content
-
- `);
+ await render(
+
+
+ Yielded content
+
+ ,
+ );
assert.ok(
find('[data-test-header] [data-test-yield-spy]'),
@@ -269,7 +285,7 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('application/json', 5000));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
const classes = Array.from(find('[data-test-file-box]').classList);
assert.ok(classes.includes('is-dark'), 'Body is dark');
@@ -280,13 +296,13 @@ module('Integration | Component | fs/file', function (hooks) {
const props = makeProps(fileStat('image/jpeg', 5000));
this.setProperties(props);
- await render(commonTemplate);
+ await renderFile.call(this);
let classes = Array.from(find('[data-test-file-box]').classList);
assert.notOk(classes.includes('is-dark'), 'Body is not dark');
assert.notOk(classes.includes('is-full-bleed'), 'Body is not full-bleed');
- this.set('stat.ContentType', 'something/unknown');
+ this.set('stat', { ...this.stat, ContentType: 'something/unknown' });
await settled();
diff --git a/ui/tests/integration/components/gauge-chart-test.js b/ui/tests/integration/components/gauge-chart-test.gjs
similarity index 68%
rename from ui/tests/integration/components/gauge-chart-test.js
rename to ui/tests/integration/components/gauge-chart-test.gjs
index 2b0add4f8b8..7d2ce28de82 100644
--- a/ui/tests/integration/components/gauge-chart-test.js
+++ b/ui/tests/integration/components/gauge-chart-test.gjs
@@ -6,9 +6,9 @@
import { find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
+import GaugeChartComponent from 'nomad-ui/components/gauge-chart';
import gaugeChart from 'nomad-ui/tests/pages/components/gauge-chart';
const GaugeChart = create(gaugeChart());
@@ -24,34 +24,38 @@ module('Integration | Component | gauge chart', function (hooks) {
test('presents as an svg, a formatted percentage, and a label', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(GaugeChart.label, props.label);
assert.deepEqual(GaugeChart.percentage, '50%');
assert.ok(GaugeChart.svgIsPresent);
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('.chart.gauge-chart'), assert);
});
test('the width of the chart is determined based on the container and the height is a function of the width', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
-
- await render(hbs`
-
-
-
- `);
+
+ await render(
+
+
+
+
+ ,
+ );
const svg = find('[data-test-gauge-svg]');
diff --git a/ui/tests/integration/components/image-file-test.js b/ui/tests/integration/components/image-file-test.gjs
similarity index 61%
rename from ui/tests/integration/components/image-file-test.js
rename to ui/tests/integration/components/image-file-test.gjs
index 27aa899f325..fc10da43a62 100644
--- a/ui/tests/integration/components/image-file-test.js
+++ b/ui/tests/integration/components/image-file-test.gjs
@@ -6,19 +6,15 @@
import { find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import sinon from 'sinon';
import RSVP from 'rsvp';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { formatBytes } from 'nomad-ui/utils/units';
+import ImageFile from 'nomad-ui/components/image-file';
module('Integration | Component | image file', function (hooks) {
setupRenderingTest(hooks);
- const commonTemplate = hbs`
-
- `;
-
const commonProperties = {
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
alt: 'This is the alt text',
@@ -26,32 +22,32 @@ module('Integration | Component | image file', function (hooks) {
};
test('component displays the image', async function (assert) {
- this.setProperties(commonProperties);
+ const { src, alt, size } = commonProperties;
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.ok(find('img'), 'Image is in the DOM');
- assert.deepEqual(
- find('img').getAttribute('src'),
- commonProperties.src,
- `src is ${commonProperties.src}`,
- );
+ assert.deepEqual(find('img').getAttribute('src'), src, `src is ${src}`);
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('[data-test-image-file]'), assert);
});
test('the image is wrapped in an anchor that links directly to the image', async function (assert) {
- this.setProperties(commonProperties);
+ const { src, alt, size } = commonProperties;
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.ok(find('a'), 'Anchor');
assert.ok(find('a > img'), 'Image in anchor');
- assert.deepEqual(
- find('a').getAttribute('href'),
- commonProperties.src,
- `href is ${commonProperties.src}`,
- );
+ assert.deepEqual(find('a').getAttribute('href'), src, `href is ${src}`);
assert.deepEqual(
find('a').getAttribute('target'),
'_blank',
@@ -66,22 +62,31 @@ module('Integration | Component | image file', function (hooks) {
test('component updates image meta when the image loads', async function (assert) {
const { spy, wrapper, notifier } = notifyingSpy();
-
- this.setProperties(commonProperties);
- this.set('spy', wrapper);
-
- render(hbs`
-
- `);
+ const { src, alt, size } = commonProperties;
+
+ await render(
+
+
+ ,
+ );
await notifier;
assert.ok(spy.calledOnce);
});
test('component shows the width, height, and size of the image', async function (assert) {
- this.setProperties(commonProperties);
+ const { src, alt, size } = commonProperties;
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
const statsEl = find('[data-test-file-stats]');
assert.ok(
@@ -89,16 +94,14 @@ module('Integration | Component | image file', function (hooks) {
'Width and height are formatted correctly',
);
assert.ok(
- statsEl.textContent
- .trim()
- .endsWith(formatBytes(commonProperties.size) + ')'),
+ statsEl.textContent.trim().endsWith(formatBytes(size) + ')'),
'Human-formatted size is included',
);
});
});
function notifyingSpy() {
- // The notifier must resolve when the spy wrapper is called
+ // The notifier must resolve when the spy wrapper is called.
let dispatch;
const notifier = new RSVP.Promise((resolve) => {
dispatch = resolve;
@@ -106,14 +109,11 @@ function notifyingSpy() {
const spy = sinon.spy();
- // The spy wrapper must call the spy, passing all arguments through, and it must
- // call dispatch in order to resolve the promise.
+ // The spy wrapper calls through and resolves the notifier.
const wrapper = (...args) => {
spy(...args);
dispatch();
};
- // All three pieces are required to wire up a component, pause test execution, and
- // write assertions.
return { spy, wrapper, notifier };
}
diff --git a/ui/tests/integration/components/job-client-status-bar-test.js b/ui/tests/integration/components/job-client-status-bar-test.gjs
similarity index 70%
rename from ui/tests/integration/components/job-client-status-bar-test.js
rename to ui/tests/integration/components/job-client-status-bar-test.gjs
index 60f57ef87e1..03427d350c8 100644
--- a/ui/tests/integration/components/job-client-status-bar-test.js
+++ b/ui/tests/integration/components/job-client-status-bar-test.gjs
@@ -7,8 +7,8 @@ import { module, test } from 'qunit';
import { create } from 'ember-cli-page-object';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
+import JobClientStatusBarComponent from 'nomad-ui/components/job-client-status-bar';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar';
@@ -40,18 +40,20 @@ module('Integration | Component | job-client-status-bar', function (hooks) {
isNarrow: true,
});
- const commonTemplate = hbs`
- `;
-
test('it renders', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
assert.ok(JobClientStatusBar.isPresent, 'Client Status Bar is rendered');
await componentA11yAudit(this.element, assert);
@@ -60,7 +62,18 @@ module('Integration | Component | job-client-status-bar', function (hooks) {
test('it fires the onBarClick handler method when clicking a bar in the chart', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
+
await JobClientStatusBar.slices[0].click();
assert.ok(props.onSliceClick.calledOnce);
});
@@ -68,7 +81,18 @@ module('Integration | Component | job-client-status-bar', function (hooks) {
test('it handles an update to client status property', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
+
const newProps = {
...props,
jobClientStatus: {
diff --git a/ui/tests/integration/components/job-diff-test.js b/ui/tests/integration/components/job-diff-test.gjs
similarity index 89%
rename from ui/tests/integration/components/job-diff-test.js
rename to ui/tests/integration/components/job-diff-test.gjs
index 333d67a7aa1..a5cbd05443e 100644
--- a/ui/tests/integration/components/job-diff-test.js
+++ b/ui/tests/integration/components/job-diff-test.gjs
@@ -6,23 +6,15 @@
import { findAll, find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import cleanWhitespace from '../../utils/clean-whitespace';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import JobDiff from 'nomad-ui/components/job-diff';
module('Integration | Component | job diff', function (hooks) {
setupRenderingTest(hooks);
- const commonTemplate = hbs`
-
-
-
-
-
- `;
-
test('job field diffs', async function (assert) {
- this.set('diff', {
+ const diff = {
ID: 'test-case-1',
Type: 'Edited',
Objects: null,
@@ -31,10 +23,17 @@ module('Integration | Component | job diff', function (hooks) {
field('Added Field', 'added', 'Foobar'),
field('Edited Field', 'edited', 512, 256),
],
- });
-
- await render(commonTemplate);
+ };
+ await render(
+
+
+
+
+
+
+ ,
+ );
assert.deepEqual(
findAll('[data-test-diff-section-label]').length,
5,
@@ -72,7 +71,7 @@ module('Integration | Component | job diff', function (hooks) {
});
test('job object diffs', async function (assert) {
- this.set('diff', {
+ const diff = {
ID: 'test-case-2',
Type: 'Edited',
Objects: [
@@ -113,9 +112,17 @@ module('Integration | Component | job diff', function (hooks) {
},
],
Fields: null,
- });
+ };
- await render(commonTemplate);
+ await render(
+
+
+
+
+
+
+ ,
+ );
assert.ok(
cleanWhitespace(
@@ -171,7 +178,7 @@ module('Integration | Component | job diff', function (hooks) {
findAll(
'[data-test-diff-section-label="object"][data-test-diff-field="added"] > [data-test-diff-section-label]',
).length,
- this.diff.Objects[1].Objects.length + this.diff.Objects[1].Fields.length,
+ diff.Objects[1].Objects.length + diff.Objects[1].Fields.length,
'Edited block contains each nested field and object',
);
@@ -179,7 +186,7 @@ module('Integration | Component | job diff', function (hooks) {
findAll(
'[data-test-diff-section-label="object"][data-test-diff-field="added"] [data-test-diff-section-label="object"] [data-test-diff-section-label="field"]',
).length,
- this.diff.Objects[1].Objects[0].Fields.length,
+ diff.Objects[1].Objects[0].Fields.length,
'Objects within objects are rendered',
);
diff --git a/ui/tests/integration/components/job-editor-test.js b/ui/tests/integration/components/job-editor-test.gjs
similarity index 86%
rename from ui/tests/integration/components/job-editor-test.js
rename to ui/tests/integration/components/job-editor-test.gjs
index c0a4ab35ecb..e20588b2531 100644
--- a/ui/tests/integration/components/job-editor-test.js
+++ b/ui/tests/integration/components/job-editor-test.gjs
@@ -5,10 +5,11 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { render, waitUntil } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { click, render, waitUntil } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
+import JobEditor from 'nomad-ui/components/job-editor';
+import JobEditorAlert from 'nomad-ui/components/job-editor/alert';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
@@ -80,23 +81,28 @@ module('Integration | Component | job-editor', function (hooks) {
}
`;
- const commonTemplate = hbs`
-
- `;
-
const renderNewJob = async (component, job) => {
+ const onSubmit = sinon.spy();
+ const handleSaveAsTemplate = sinon.spy();
+ const context = 'new';
+
component.setProperties({
job,
- onSubmit: sinon.spy(),
- handleSaveAsTemplate: sinon.spy(),
- context: 'new',
+ onSubmit,
+ handleSaveAsTemplate,
+ context,
});
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
};
const planJob = async (spec) => {
@@ -363,16 +369,18 @@ module('Integration | Component | job-editor', function (hooks) {
this.set('handleSaveAsTemplate', () => {});
this.set('onSelect', () => {});
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
await planJob(spec);
await waitForReviewStage();
@@ -445,17 +453,19 @@ module('Integration | Component | job-editor', function (hooks) {
this.set('handleSaveAsTemplate', () => {});
this.set('onSelect', () => {});
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.ok(Editor.cancelEditingIsAvailable, 'Cancel editing button exists');
@@ -481,12 +491,17 @@ module('Integration | Component | job-editor', function (hooks) {
this.set('job', job);
// Act
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
// Check if the definition is set on the model
assert.deepEqual(
@@ -516,4 +531,29 @@ module('Integration | Component | job-editor', function (hooks) {
'Variables are set on the model',
);
});
+
+ test('variable notification alert can be dismissed', async function (assert) {
+ this.set('data', {
+ stage: 'read',
+ hasVariables: true,
+ view: 'job-spec',
+ });
+ this.set('fns', { onDismissPlanMessage: () => {} });
+
+ await render(
+
+
+ ,
+ );
+
+ assert
+ .dom('[data-test-variable-notification]')
+ .exists('Variable notification is shown');
+
+ await click('[data-test-variable-notification] button');
+
+ assert
+ .dom('[data-test-variable-notification]')
+ .doesNotExist('Variable notification is dismissed');
+ });
});
diff --git a/ui/tests/integration/components/job-page/parts/body-test.js b/ui/tests/integration/components/job-page/parts/body-test.gjs
similarity index 75%
rename from ui/tests/integration/components/job-page/parts/body-test.js
rename to ui/tests/integration/components/job-page/parts/body-test.gjs
index 8c9d8559ee6..1e375c1df07 100644
--- a/ui/tests/integration/components/job-page/parts/body-test.js
+++ b/ui/tests/integration/components/job-page/parts/body-test.gjs
@@ -6,9 +6,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, findAll, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import JobPagePartsBody from 'nomad-ui/components/job-page/parts/body';
module('Integration | Component | job-page/parts/body', function (hooks) {
setupRenderingTest(hooks);
@@ -25,13 +25,15 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
});
test('includes a subnav for the job', async function (assert) {
- this.set('job', {});
-
- await render(hbs`
-
- Inner content
-
- `);
+ const job = {};
+
+ await render(
+
+
+ Inner content
+
+ ,
+ );
assert.ok(find('[data-test-subnav="job"]'), 'Job subnav is rendered');
});
@@ -42,13 +44,13 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
type: 'service',
});
- this.set('job', job);
-
- await render(hbs`
-
- Inner content
-
- `);
+ await render(
+
+
+ Inner content
+
+ ,
+ );
const subnavLabels = findAll('[data-test-tab]').map((anchor) =>
anchor.textContent.trim(),
@@ -77,13 +79,13 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
type: 'batch',
});
- this.set('job', job);
-
- await render(hbs`
-
- Inner content
-
- `);
+ await render(
+
+
+ Inner content
+
+ ,
+ );
const subnavLabels = findAll('[data-test-tab]').map((anchor) =>
anchor.textContent.trim(),
@@ -103,13 +105,15 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
});
test('body yields content to a section after the subnav', async function (assert) {
- this.set('job', {});
-
- await render(hbs`
-
- Inner content
-
- `);
+ const job = {};
+
+ await render(
+
+
+ Inner content
+
+ ,
+ );
assert.ok(
find('[data-test-subnav="job"] + .section > .inner-content'),
diff --git a/ui/tests/integration/components/job-page/parts/children-test.js b/ui/tests/integration/components/job-page/parts/children-test.gjs
similarity index 77%
rename from ui/tests/integration/components/job-page/parts/children-test.js
rename to ui/tests/integration/components/job-page/parts/children-test.gjs
index c512f39afc4..59301f20d44 100644
--- a/ui/tests/integration/components/job-page/parts/children-test.js
+++ b/ui/tests/integration/components/job-page/parts/children-test.gjs
@@ -3,10 +3,10 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { hbs } from 'ember-cli-htmlbars';
import { findAll, find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
+import JobPagePartsChildren from 'nomad-ui/components/job-page/parts/children';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
@@ -52,15 +52,18 @@ module('Integration | Component | job-page/parts/children', function (hooks) {
this.setProperties(props(parent, children));
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
findAll('[data-test-job-name]').length,
@@ -86,15 +89,17 @@ module('Integration | Component | job-page/parts/children', function (hooks) {
this.setProperties(props(parent, children));
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
const childrenCount = parent.get('children.length');
assert.ok(
@@ -132,15 +137,18 @@ module('Integration | Component | job-page/parts/children', function (hooks) {
this.setProperties(props(parent, children));
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
const sortedChildren = parent.get('children').sortBy('name');
const childRows = findAll('[data-test-job-name]');
diff --git a/ui/tests/integration/components/job-page/parts/placement-failures-test.js b/ui/tests/integration/components/job-page/parts/placement-failures-test.gjs
similarity index 89%
rename from ui/tests/integration/components/job-page/parts/placement-failures-test.js
rename to ui/tests/integration/components/job-page/parts/placement-failures-test.gjs
index 37fe6165e0c..3f46920938d 100644
--- a/ui/tests/integration/components/job-page/parts/placement-failures-test.js
+++ b/ui/tests/integration/components/job-page/parts/placement-failures-test.gjs
@@ -4,10 +4,10 @@
*/
/* Mirage fixtures are random so we can't expect a set number of assertions */
-import { hbs } from 'ember-cli-htmlbars';
import { findAll, find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
+import JobPagePartsPlacementFailures from 'nomad-ui/components/job-page/parts/placement-failures';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
@@ -41,13 +41,11 @@ module(
const job = this.store.peekAll('job').get('firstObject');
await job.reload();
- this.set('job', job);
-
- await render(hbs`
- )
- `);
+ await render(
+ ,
+ );
- const failedEvaluation = this.job.evaluations
+ const failedEvaluation = job.evaluations
.filterBy('hasPlacementFailures')
.sortBy('modifyIndex')
.reverse()
@@ -90,11 +88,9 @@ module(
const job = this.store.peekAll('job').get('firstObject');
await job.reload();
- this.set('job', job);
-
- await render(hbs`
- )
- `);
+ await render(
+ ,
+ );
assert.notOk(
find('[data-test-placement-failures]'),
diff --git a/ui/tests/integration/components/job-page/parts/summary-test.js b/ui/tests/integration/components/job-page/parts/summary-test.gjs
similarity index 82%
rename from ui/tests/integration/components/job-page/parts/summary-test.js
rename to ui/tests/integration/components/job-page/parts/summary-test.gjs
index ecf83785cce..c31ba86670b 100644
--- a/ui/tests/integration/components/job-page/parts/summary-test.js
+++ b/ui/tests/integration/components/job-page/parts/summary-test.gjs
@@ -3,12 +3,12 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { hbs } from 'ember-cli-htmlbars';
import { find, click, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
+import JobPagePartsSummary from 'nomad-ui/components/job-page/parts/summary';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | job-page/parts/summary', function (hooks) {
@@ -35,11 +35,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.ok(
find('[data-test-children-status-bar]'),
@@ -60,11 +60,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.ok(
find('[data-test-allocation-status-bar]'),
@@ -85,11 +85,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.strictEqual(
Number(find('[data-test-legend-value="queued"]').textContent.trim()),
@@ -135,11 +135,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.strictEqual(
Number(find('[data-test-legend-value="queued"]').textContent.trim()),
@@ -167,11 +167,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
await click('[data-test-accordion-toggle]');
@@ -186,11 +186,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- await this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
await click('[data-test-accordion-toggle]');
@@ -213,11 +213,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
await this.store.findAll('job');
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.notOk(
window.localStorage.nomadExpandJobSummary,
@@ -241,11 +241,11 @@ module('Integration | Component | job-page/parts/summary', function (hooks) {
window.localStorage.nomadExpandJobSummary = 'false';
- this.set('job', this.store.peekAll('job').get('firstObject'));
+ this.job = this.store.peekAll('job').get('firstObject');
- await render(hbs`
-
- `);
+ await render(
+ ,
+ );
assert.ok(
find('[data-test-allocation-status-bar]'),
diff --git a/ui/tests/integration/components/job-page/parts/task-groups-test.js b/ui/tests/integration/components/job-page/parts/task-groups-test.gjs
similarity index 86%
rename from ui/tests/integration/components/job-page/parts/task-groups-test.js
rename to ui/tests/integration/components/job-page/parts/task-groups-test.gjs
index 7750015ab46..bd9f018838b 100644
--- a/ui/tests/integration/components/job-page/parts/task-groups-test.js
+++ b/ui/tests/integration/components/job-page/parts/task-groups-test.gjs
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { hbs } from 'ember-cli-htmlbars';
import { findAll, find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { setupRenderingTest } from 'ember-qunit';
+import JobPagePartsTaskGroups from 'nomad-ui/components/job-page/parts/task-groups';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import {
formatScheduledHertz,
@@ -53,13 +53,15 @@ module(
const job = this.store.peekAll('job').get('firstObject');
this.setProperties(props(job));
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
findAll('[data-test-task-group]').length,
@@ -84,13 +86,15 @@ module(
this.setProperties(props(job));
- await render(hbs`
-
- `);
+ await render(
+
+
+ ,
+ );
const taskGroupRow = find('[data-test-task-group]');
diff --git a/ui/tests/integration/components/job-page/periodic-test.js b/ui/tests/integration/components/job-page/periodic-test.gjs
similarity index 90%
rename from ui/tests/integration/components/job-page/periodic-test.js
rename to ui/tests/integration/components/job-page/periodic-test.gjs
index f2d82d4317f..daf2c67e896 100644
--- a/ui/tests/integration/components/job-page/periodic-test.js
+++ b/ui/tests/integration/components/job-page/periodic-test.gjs
@@ -6,7 +6,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, find, findAll, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import JobPagePeriodic from 'nomad-ui/components/job-page/periodic';
import moment from 'moment';
import { create, collection } from 'ember-cli-page-object';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
@@ -24,13 +24,26 @@ import {
} from './helpers';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-// A minimum viable page object to use with the pageSizeSelect behavior
const PeriodicJobPage = create({
pageSize: 25,
jobs: collection('[data-test-job-row]'),
pageSizeSelect: pageSizeSelectPageObject(),
});
+function renderPeriodic() {
+ return render(
+
+
+ ,
+ );
+}
+
module('Integration | Component | job-page/periodic', function (hooks) {
setupRenderingTest(hooks);
@@ -42,7 +55,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
this.server.create('namespace');
this.server.create('node-pool');
this.server.create('node');
- let managementToken = this.server.create('token');
+ const managementToken = this.server.create('token');
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
@@ -51,16 +64,6 @@ module('Integration | Component | job-page/periodic', function (hooks) {
window.localStorage.clear();
});
- const commonTemplate = hbs`
-
- `;
-
const commonProperties = (job) => ({
job,
sortProperty: 'name',
@@ -82,7 +85,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
const currentJobCount = this.server.db.jobs.length;
@@ -129,7 +132,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
assert.notOk(find('[data-test-job-error-title]'), 'No error message yet');
@@ -147,13 +150,12 @@ module('Integration | Component | job-page/periodic', function (hooks) {
status: 'running',
});
- let job;
await this.store.findAll('job');
- job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
+ const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
await stopJob();
expectDeleteRequest(assert, this.server, job);
@@ -174,7 +176,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
assert.ok(
find('[data-test-stop] [data-test-idle-button]').hasAttribute('disabled'),
@@ -198,7 +200,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
await startJob();
await expectStartRequest(assert, this.server, job);
@@ -220,7 +222,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
assert.ok(
find('[data-test-start] [data-test-idle-button]').hasAttribute(
@@ -247,7 +249,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
try {
await purgeJob();
@@ -270,7 +272,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
assert.deepEqual(
find('[data-test-job-submit-time]').textContent.trim(),
@@ -297,7 +299,7 @@ module('Integration | Component | job-page/periodic', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ await renderPeriodic.call(this);
},
});
});
diff --git a/ui/tests/integration/components/job-page/service-test.js b/ui/tests/integration/components/job-page/service-test.gjs
similarity index 85%
rename from ui/tests/integration/components/job-page/service-test.js
rename to ui/tests/integration/components/job-page/service-test.gjs
index 4d9f816cc61..2bbf764763b 100644
--- a/ui/tests/integration/components/job-page/service-test.js
+++ b/ui/tests/integration/components/job-page/service-test.gjs
@@ -6,7 +6,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, find, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import {
startJob,
@@ -20,6 +19,8 @@ import {
import Job from 'nomad-ui/tests/pages/jobs/detail';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import { TrackedObject } from 'tracked-built-ins';
+import JobPageService from 'nomad-ui/components/job-page/service';
module('Integration | Component | job-page/service', function (hooks) {
setupRenderingTest(hooks);
@@ -33,7 +34,7 @@ module('Integration | Component | job-page/service', function (hooks) {
this.server.create('namespace');
this.server.create('node-pool');
this.server.create('node');
- let managementToken = this.server.create('token');
+ const managementToken = this.server.create('token');
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
@@ -42,18 +43,6 @@ module('Integration | Component | job-page/service', function (hooks) {
window.localStorage.clear();
});
- const commonTemplate = hbs`
-
- `;
-
const commonProperties = (job) => ({
job,
sortProperty: 'name',
@@ -64,6 +53,22 @@ module('Integration | Component | job-page/service', function (hooks) {
setStatusMode() {},
});
+ const renderPage = async (state) => {
+ await render(
+
+
+ ,
+ );
+ };
+
const makeMirageJob = (server, props = {}) =>
server.create(
'job',
@@ -85,8 +90,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await stopJob();
expectDeleteRequest(assert, this.server, job);
@@ -101,8 +106,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
assert.ok(
find('[data-test-stop] [data-test-idle-button]').hasAttribute('disabled'),
@@ -123,8 +128,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await startJob();
await expectStartRequest(assert, this.server, job);
@@ -143,8 +148,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
assert.ok(
find('[data-test-start] [data-test-idle-button]').hasAttribute(
@@ -167,8 +172,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
try {
await purgeJob();
@@ -186,8 +191,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
const allocation = this.server.db.allocations
.sortBy('modifyIndex')
@@ -213,8 +218,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
assert.deepEqual(Job.allocations.length, 5, 'Capped at 5 allocations');
assert.ok(
@@ -231,8 +236,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
assert.ok(
Job.recentAllocationsEmptyState.headline.includes('No Allocations'),
@@ -266,8 +271,8 @@ module('Integration | Component | job-page/service', function (hooks) {
Canary: true,
},
});
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await click('[data-test-promote-canary]');
@@ -312,8 +317,8 @@ module('Integration | Component | job-page/service', function (hooks) {
},
});
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await click('[data-test-promote-canary]');
@@ -323,7 +328,7 @@ module('Integration | Component | job-page/service', function (hooks) {
this.element,
assert,
'scrollable-region-focusable',
- ); //keyframe animation fades from opacity 0
+ );
});
test('Active deployment can be failed', async function (assert) {
@@ -336,8 +341,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
const deployment = await job.get('latestDeployment');
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await click('.active-deployment [data-test-fail]');
@@ -362,8 +367,8 @@ module('Integration | Component | job-page/service', function (hooks) {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
- this.setProperties(commonProperties(job));
- await render(commonTemplate);
+ const state = new TrackedObject(commonProperties(job));
+ await renderPage(state);
await click('.active-deployment [data-test-fail]');
@@ -373,6 +378,6 @@ module('Integration | Component | job-page/service', function (hooks) {
this.element,
assert,
'scrollable-region-focusable',
- ); //keyframe animation fades from opacity 0
+ );
});
});
diff --git a/ui/tests/integration/components/job-search-box-test.js b/ui/tests/integration/components/job-search-box-test.gjs
similarity index 77%
rename from ui/tests/integration/components/job-search-box-test.js
rename to ui/tests/integration/components/job-search-box-test.gjs
index 2dce3cf3cff..e51c9735441 100644
--- a/ui/tests/integration/components/job-search-box-test.js
+++ b/ui/tests/integration/components/job-search-box-test.gjs
@@ -5,10 +5,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
-import { fillIn, find, triggerEvent } from '@ember/test-helpers';
+import { fillIn, find, render, triggerEvent } from '@ember/test-helpers';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import JobSearchBox from 'nomad-ui/components/job-search-box';
const DEBOUNCE_MS = 500;
@@ -17,19 +16,21 @@ module('Integration | Component | job-search-box', function (hooks) {
test('debouncer debounces appropriately', async function (assert) {
let message = '';
-
- this.set('externalAction', (value) => {
+ const externalAction = (value) => {
message = value;
- });
+ };
await render(
- hbs` `,
+
+
+ ,
);
- await componentA11yAudit(this.element, assert);
+ await componentA11yAudit(find('[data-test-jobs-search]'), assert);
const element = find('input');
await fillIn('input', 'test1');
assert.deepEqual(message, 'test1', 'Initial typing');
+
element.value += ' wont be ';
triggerEvent('input', 'input');
assert.deepEqual(
@@ -37,6 +38,7 @@ module('Integration | Component | job-search-box', function (hooks) {
'test1',
'Typing has happened within debounce window',
);
+
element.value += 'seen ';
triggerEvent('input', 'input');
await delay(DEBOUNCE_MS - 100);
@@ -45,6 +47,7 @@ module('Integration | Component | job-search-box', function (hooks) {
'test1',
'Typing has happened within debounce window, albeit a little slower',
);
+
element.value += 'until now.';
triggerEvent('input', 'input');
await delay(DEBOUNCE_MS + 100);
diff --git a/ui/tests/integration/components/job-status-panel-test.js b/ui/tests/integration/components/job-status-panel-test.gjs
similarity index 87%
rename from ui/tests/integration/components/job-status-panel-test.js
rename to ui/tests/integration/components/job-status-panel-test.gjs
index 4fff03bb263..9f55b46c71a 100644
--- a/ui/tests/integration/components/job-status-panel-test.js
+++ b/ui/tests/integration/components/job-status-panel-test.gjs
@@ -6,12 +6,16 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import JobStatusPanel from 'nomad-ui/components/job-status/panel';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import percySnapshot from '@percy/ember';
+function renderPanel() {
+ return render( );
+}
+
module(
'Integration | Component | job status panel | active deployment',
function (hooks) {
@@ -41,9 +45,7 @@ module(
await this.store.findAll('job');
this.set('job', this.store.peekAll('job').get('firstObject'));
- await render(hbs`
- )
- `);
+ await renderPanel.call(this);
assert.notOk(find('.active-deployment'), 'No active deployment');
});
@@ -65,11 +67,11 @@ module(
const job = await this.server.create('job', {
type: 'service',
createAllocations: true,
- noDeployments: true, // manually created below
+ noDeployments: true,
activeDeployment: true,
groupAllocCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'),
allocStatusDistribution,
});
@@ -96,11 +98,8 @@ module(
this.set('job', jobRecord);
await this.job.allocations;
- await render(hbs`
-
- `);
+ await renderPanel.call(this);
- // Initially no active deployment
assert.notOk(
find('.active-deployment'),
'Does not show an active deployment when latest is failed',
@@ -115,8 +114,6 @@ module(
'Shows an active deployment if latest status is Running',
);
- // Half the shown allocations are running, 1 is pending, 1 is failed; none are canaries or healthy.
- // The rest (lost, unknown, etc.) all show up as "Unplaced"
assert
.dom('.new-allocations .allocation-status-row .represented-allocation')
.exists(
@@ -211,12 +208,11 @@ module(
'Summary text shows accurate numbers when 0 are running/healthy',
);
- let NUMBER_OF_RUNNING_CANARIES = 2;
- let NUMBER_OF_RUNNING_HEALTHY = 5;
- let NUMBER_OF_FAILED_CANARIES = 1;
- let NUMBER_OF_PENDING_CANARIES = 1;
+ const NUMBER_OF_RUNNING_CANARIES = 2;
+ const NUMBER_OF_RUNNING_HEALTHY = 5;
+ const NUMBER_OF_FAILED_CANARIES = 1;
+ const NUMBER_OF_PENDING_CANARIES = 1;
- // Set some allocs to canary, and to healthy
this.job.allocations
.filter((a) => a.clientStatus === 'running')
.slice(0, NUMBER_OF_RUNNING_CANARIES)
@@ -254,9 +250,7 @@ module(
}),
);
- await render(hbs`
-
- `);
+ await renderPanel.call(this);
assert
.dom(
'.new-allocations .allocation-status-row .represented-allocation.running.canary',
@@ -328,7 +322,6 @@ module(
"Job Status Panel: 'New' and 'Previous' allocations, initial deploying state",
);
- // Try setting a few of the old allocs to complete and make sure number ticks down
await Promise.all(
this.job.allocations
.filter(
@@ -386,7 +379,7 @@ module(
this.element,
assert,
'scrollable-region-focusable',
- ); //keyframe animation fades from opacity 0
+ );
});
test('non-running allocations are grouped regardless of health', async function (assert) {
@@ -406,11 +399,11 @@ module(
const job = await this.server.create('job', {
type: 'service',
createAllocations: true,
- noDeployments: true, // manually created below
+ noDeployments: true,
activeDeployment: true,
groupAllocCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'),
allocStatusDistribution,
});
@@ -425,7 +418,7 @@ module(
status: 'failed',
});
- let activelyDeployingJobAllocs = this.server.schema.allocations
+ const activelyDeployingJobAllocs = this.server.schema.allocations
.all()
.filter((a) => a.jobId === job.id);
@@ -443,9 +436,7 @@ module(
await this.job.allocations;
- await render(hbs`
-
- `);
+ await renderPanel.call(this);
assert
.dom('.allocation-status-block .represented-allocation.failed')
@@ -488,11 +479,11 @@ module(
const job = await this.server.create('job', {
type: 'service',
createAllocations: true,
- noDeployments: true, // manually created below
+ noDeployments: true,
activeDeployment: true,
groupAllocCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'),
allocStatusDistribution,
});
@@ -509,11 +500,9 @@ module(
groupDesiredTotal: ALLOCS_PER_GROUP,
versionNumber: 1,
status: 'failed',
- // requiresPromotion: false,
},
);
- // requiresPromotion goes to false
deployment.deploymentTaskGroupSummaries.models.forEach((d) => {
d.update({
desiredCanaries: 0,
@@ -521,8 +510,7 @@ module(
});
});
- // All allocations set to Healthy and non-canary
- let activelyDeployingJobAllocs = this.server.schema.allocations
+ const activelyDeployingJobAllocs = this.server.schema.allocations
.all()
.filter((a) => a.jobId === job.id);
@@ -537,9 +525,7 @@ module(
await this.job.allocations;
- await render(hbs`
-
- `);
+ await renderPanel.call(this);
assert
.dom(find('.legend-item .represented-allocation.running').parentElement)
@@ -552,7 +538,6 @@ module(
.dom('.canary-promotion-alert')
.doesNotExist('No canary promotion alert when no canaries');
- // Set 3 allocations to health-pending canaries
await Promise.all(
this.job.allocations
.filterBy('clientStatus', 'running')
@@ -562,7 +547,6 @@ module(
}),
);
- // Set the deployment's requiresPromotion to true
await Promise.all(
this.job.latestDeployment.get('taskGroupSummaries').map(async (a) => {
await a.set('desiredCanaries', 3);
@@ -579,7 +563,6 @@ module(
.dom('.canary-promotion-alert')
.containsText('Checking Canary health');
- // Fail the health check on 1 canary
await Promise.all(
this.job.allocations
.filterBy('clientStatus', 'running')
@@ -593,7 +576,6 @@ module(
.dom('.canary-promotion-alert')
.containsText('Some Canaries have failed');
- // That 1 passes its health checks, but two peers remain pending
await Promise.all(
this.job.allocations
.filterBy('clientStatus', 'running')
@@ -607,7 +589,6 @@ module(
.dom('.canary-promotion-alert')
.containsText('Checking Canary health');
- // Fail one of the running canaries, but dont specifically touch its deploymentStatus.health
await Promise.all(
this.job.allocations
.filterBy('clientStatus', 'running')
@@ -621,7 +602,6 @@ module(
.dom('.canary-promotion-alert')
.containsText('Some Canaries have failed');
- // Canaries all running and healthy
await Promise.all(
this.job.allocations.slice(0, 3).map(async (a) => {
await a.setProperties({
@@ -637,26 +617,5 @@ module(
.dom('.canary-promotion-alert')
.containsText('Deployment requires promotion');
});
-
- test('when there is no running deployment, the latest deployment section shows up for the last deployment', async function (assert) {
- this.server.create('job', {
- type: 'service',
- createAllocations: false,
- noActiveDeployment: true,
- });
-
- await this.store.findAll('job');
-
- this.set('job', this.store.peekAll('job').get('firstObject'));
- await render(hbs`
-
- `);
-
- assert.notOk(find('.active-deployment'), 'No active deployment');
- assert.ok(
- find('.running-allocs-title'),
- 'Steady-state mode shown instead',
- );
- });
},
);
diff --git a/ui/tests/integration/components/job-status/failed-or-lost-test.js b/ui/tests/integration/components/job-status/failed-or-lost-test.gjs
similarity index 52%
rename from ui/tests/integration/components/job-status/failed-or-lost-test.js
rename to ui/tests/integration/components/job-status/failed-or-lost-test.gjs
index ad630d9becb..c2661efc95e 100644
--- a/ui/tests/integration/components/job-status/failed-or-lost-test.js
+++ b/ui/tests/integration/components/job-status/failed-or-lost-test.gjs
@@ -5,19 +5,32 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { render, settled } from '@ember/test-helpers';
+import { tracked } from '@glimmer/tracking';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import JobStatusFailedOrLost from 'nomad-ui/components/job-status/failed-or-lost';
+
+class FailedOrLostTestState {
+ @tracked allocs;
+ @tracked restartedAllocs;
+ @tracked rescheduledAllocs;
+ @tracked supportsRescheduling;
+
+ constructor() {
+ this.allocs = [];
+ this.restartedAllocs = [];
+ this.rescheduledAllocs = [];
+ this.supportsRescheduling = false;
+ }
+}
module('Integration | Component | job-status/failed-or-lost', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
- let job = {
- id: 'job1',
- };
-
- let allocs = [
+ const job = { id: 'job1' };
+ const state = new FailedOrLostTestState();
+ state.allocs = [
{
id: 1,
name: 'alloc1',
@@ -28,13 +41,11 @@ module('Integration | Component | job-status/failed-or-lost', function (hooks) {
},
];
- this.set('allocs', allocs);
- this.set('job', job);
-
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
assert.dom('h4').hasText('Replaced Allocations');
assert.dom('.failed-or-lost-links').hasText('2 Restarted');
@@ -42,11 +53,9 @@ module('Integration | Component | job-status/failed-or-lost', function (hooks) {
});
test('it links or does not link appropriately', async function (assert) {
- let job = {
- id: 'job1',
- };
-
- let allocs = [
+ const job = { id: 'job1' };
+ const state = new FailedOrLostTestState();
+ state.allocs = [
{
id: 1,
name: 'alloc1',
@@ -57,28 +66,24 @@ module('Integration | Component | job-status/failed-or-lost', function (hooks) {
},
];
- this.set('allocs', allocs);
- this.set('job', job);
-
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
- // Ensure it's of type a
assert.dom('.failed-or-lost-links > span > *:last-child').hasTagName('a');
- this.set('allocs', []);
+ state.allocs = [];
+ await settled();
assert
.dom('.failed-or-lost-links > span > *:last-child')
.doesNotHaveTagName('a');
});
test('it shows rescheduling as well', async function (assert) {
- let job = {
- id: 'job1',
- };
-
- let restartedAllocs = [
+ const job = { id: 'job1' };
+ const state = new FailedOrLostTestState();
+ state.restartedAllocs = [
{
id: 1,
name: 'alloc1',
@@ -89,7 +94,7 @@ module('Integration | Component | job-status/failed-or-lost', function (hooks) {
},
];
- let rescheduledAllocs = [
+ state.rescheduledAllocs = [
{
id: 1,
name: 'alloc1',
@@ -103,22 +108,23 @@ module('Integration | Component | job-status/failed-or-lost', function (hooks) {
name: 'alloc3',
},
];
-
- this.set('restartedAllocs', restartedAllocs);
- this.set('rescheduledAllocs', rescheduledAllocs);
- this.set('job', job);
- this.set('supportsRescheduling', true);
-
- await render(hbs` `);
+ state.supportsRescheduling = true;
+
+ await render(
+
+
+ ,
+ );
assert.dom('.failed-or-lost-links').containsText('2 Restarted');
assert.dom('.failed-or-lost-links').containsText('3 Rescheduled');
- this.set('supportsRescheduling', false);
+ state.supportsRescheduling = false;
+ await settled();
assert.dom('.failed-or-lost-links').doesNotContainText('Rescheduled');
});
});
diff --git a/ui/tests/integration/components/lifecycle-chart-test.js b/ui/tests/integration/components/lifecycle-chart-test.gjs
similarity index 81%
rename from ui/tests/integration/components/lifecycle-chart-test.js
rename to ui/tests/integration/components/lifecycle-chart-test.gjs
index ffba4955f04..cdf7d796cdf 100644
--- a/ui/tests/integration/components/lifecycle-chart-test.js
+++ b/ui/tests/integration/components/lifecycle-chart-test.gjs
@@ -6,13 +6,13 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { set } from '@ember/object';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
-import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart';
+import LifecycleChartPage from 'nomad-ui/tests/pages/components/lifecycle-chart';
+import LifecycleChartComponent from 'nomad-ui/components/lifecycle-chart';
-const Chart = create(LifecycleChart);
+const Chart = create(LifecycleChartPage);
const tasks = [
{
@@ -51,7 +51,9 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
test('it renders stateless phases and lifecycle- and name-sorted tasks', async function (assert) {
this.set('tasks', tasks);
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.ok(Chart.isPresent);
assert.deepEqual(Chart.phases[0].name, 'Prestart');
@@ -59,7 +61,9 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
assert.deepEqual(Chart.phases[2].name, 'Poststart');
assert.deepEqual(Chart.phases[3].name, 'Poststop');
- Chart.phases.forEach((phase) => assert.notOk(phase.isActive));
+ Chart.phases.forEach((phase) => {
+ assert.notOk(phase.isActive);
+ });
assert.deepEqual(Chart.tasks.mapBy('name'), [
'prestart ephemeral: 0',
@@ -95,21 +99,25 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
await componentA11yAudit(this.element, assert);
});
- test('it doesn’t render when there’s only one phase', async function (assert) {
+ test("it doesn't render when there's only one phase", async function (assert) {
this.set('tasks', [
{
lifecycleName: 'main',
},
]);
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.notOk(Chart.isPresent);
});
test('it renders all phases when there are any non-main tasks', async function (assert) {
this.set('tasks', [tasks[0], tasks[6]]);
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.deepEqual(Chart.phases.length, 4);
});
@@ -121,17 +129,22 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
}),
);
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
assert.ok(Chart.isPresent);
- Chart.phases.forEach((phase) => assert.notOk(phase.isActive));
+ Chart.phases.forEach((phase) => {
+ assert.notOk(phase.isActive);
+ });
Chart.tasks.forEach((task) => {
assert.notOk(task.isActive);
assert.notOk(task.isFinished);
});
- // Change poststart-ephemeral to be running
this.set('taskStates.4.state', 'running');
await settled();
@@ -163,7 +176,7 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
activePhaseNames: ['Prestart', 'Main', 'Poststop'],
},
{
- testName: 'sidecar task states don’t affect phase active states',
+ testName: "sidecar task states don't affect phase active states",
runningTaskNames: ['prestart sidecar', 'poststart sidecar'],
activePhaseNames: [],
},
@@ -173,14 +186,18 @@ module('Integration | Component | lifecycle-chart', function (hooks) {
runningTaskNames: ['poststart ephemeral'],
activePhaseNames: ['Main'],
},
- ].forEach(async ({ testName, runningTaskNames, activePhaseNames }) => {
+ ].forEach(({ testName, runningTaskNames, activePhaseNames }) => {
test(testName, async function (assert) {
this.set(
'taskStates',
tasks.map((task) => ({ task })),
);
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
runningTaskNames.forEach((taskName) => {
const taskState = this.taskStates.find((taskState) =>
diff --git a/ui/tests/integration/components/line-chart-test.js b/ui/tests/integration/components/line-chart-test.gjs
similarity index 60%
rename from ui/tests/integration/components/line-chart-test.js
rename to ui/tests/integration/components/line-chart-test.gjs
index 3ba6e0e57fb..f641fb6a557 100644
--- a/ui/tests/integration/components/line-chart-test.js
+++ b/ui/tests/integration/components/line-chart-test.gjs
@@ -4,18 +4,18 @@
*/
import {
+ click,
find,
findAll,
- click,
render,
triggerEvent,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import moment from 'moment';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import LineChart from 'nomad-ui/components/line-chart';
const REF_DATE = new Date();
@@ -36,20 +36,19 @@ module('Integration | Component | line-chart', function (hooks) {
],
});
- await render(hbs`
-
- <:after as |c|>
-
-
-
- `);
+ await render(
+
+
+ <:after as |c|>
+
+
+
+ ,
+ );
const sortedAnnotations = annotations.sortBy('x');
- findAll('[data-test-annotation]').forEach((annotation, idx) => {
- const datum = sortedAnnotations[idx];
+ findAll('[data-test-annotation]').forEach((annotation, index) => {
+ const datum = sortedAnnotations[index];
assert.deepEqual(
annotation.querySelector('button').getAttribute('title'),
`${datum.type} event at ${datum.x}`,
@@ -82,21 +81,24 @@ module('Integration | Component | line-chart', function (hooks) {
],
});
- await render(hbs`
-
- <:after as |c|>
-
-
-
- `);
+ await render(
+
+
+ <:after as |c|>
+
+
+
+ ,
+ );
const sortedAnnotations = annotations.sortBy('x').reverse();
- findAll('[data-test-annotation]').forEach((annotation, idx) => {
- const datum = sortedAnnotations[idx];
+ findAll('[data-test-annotation]').forEach((annotation, index) => {
+ const datum = sortedAnnotations[index];
assert.deepEqual(
annotation.querySelector('button').getAttribute('title'),
`${datum.type} event at ${moment(datum.x).format('MMM DD, HH:mm')}`,
@@ -115,16 +117,18 @@ module('Integration | Component | line-chart', function (hooks) {
click: sinon.spy(),
});
- await render(hbs`
-
- <:after as |c|>
-
-
-
- `);
+ await render(
+
+
+ <:after as |c|>
+
+
+
+ ,
+ );
await click('[data-test-annotation] button');
assert.ok(this.click.calledWith(annotations[0]));
@@ -145,23 +149,22 @@ module('Integration | Component | line-chart', function (hooks) {
click: sinon.spy(),
});
- await render(hbs`
-
-
- <:after as |c|>
-
-
-
-
- `);
+ await render(
+
+
+
+ <:after as |c|>
+
+
+
+
+ ,
+ );
- const annotationEls = findAll('[data-test-annotation]');
- assert.notOk(annotationEls[0].classList.contains('is-staggered'));
- assert.ok(annotationEls[1].classList.contains('is-staggered'));
- assert.notOk(annotationEls[2].classList.contains('is-staggered'));
+ const annotationElements = findAll('[data-test-annotation]');
+ assert.notOk(annotationElements[0].classList.contains('is-staggered'));
+ assert.ok(annotationElements[1].classList.contains('is-staggered'));
+ assert.notOk(annotationElements[2].classList.contains('is-staggered'));
await componentA11yAudit(this.element, assert);
});
@@ -180,24 +183,26 @@ module('Integration | Component | line-chart', function (hooks) {
],
});
- await render(hbs`
-
- <:after as |c|>
-
-
-
- `);
+ await render(
+
+
+ <:after as |c|>
+
+
+
+ ,
+ );
- const annotationEls = findAll('[data-test-annotation]');
+ const annotationElements = findAll('[data-test-annotation]');
annotations
.sortBy('y')
.reverse()
.forEach((annotation, index) => {
assert.deepEqual(
- annotationEls[index].textContent.trim(),
+ annotationElements[index].textContent.trim(),
annotation.label,
);
});
@@ -221,44 +226,41 @@ module('Integration | Component | line-chart', function (hooks) {
],
});
- await render(hbs`
-
-
- <:svg as |c|>
- {{#each this.data as |series idx|}}
-
- {{/each}}
-
- <:after as |c|>
-
-
- {{series.series}}
- {{datum.formattedY}}
-
-
-
-
-
- `);
+ await render(
+
+
+
+ <:svg as |c|>
+ {{#each this.data as |series index|}}
+
+ {{/each}}
+
+ <:after as |c|>
+
+
+ {{series.series}}
+ {{datum.formattedY}}
+
+
+
+
+
+ ,
+ );
- // All tooltip events are attached to the hover target
const hoverTarget = find('[data-test-hover-target]');
- // Mouse to data mapping happens based on the clientX of the MouseEvent
const bbox = hoverTarget.getBoundingClientRect();
- // The MouseEvent needs to be translated based on the location of the hover target
const xOffset = bbox.x;
- // An interval here is the width between x values given the fixed dimensions of the line chart
- // and the domain of the data
const interval = bbox.width / 5;
- // MouseEnter triggers the tooltip visibility
await triggerEvent(hoverTarget, 'mouseenter');
- // MouseMove positions the tooltip and updates the active datum
await triggerEvent(hoverTarget, 'mousemove', {
clientX: xOffset + interval * 1 + 5,
});
@@ -269,16 +271,18 @@ module('Integration | Component | line-chart', function (hooks) {
);
assert.deepEqual(
find('[data-test-chart-tooltip] .value').textContent.trim(),
- String(series2.find((d) => d.x === 2).y),
+ String(series2.find((datum) => datum.x === 2).y),
);
- // When the mouse falls between points and each series has points with different x values,
- // points will only be shown in the tooltip if they are close enough to the closest point
- // to the cursor.
- // This event is intentionally between points such that both points are within proximity.
const expected = [
- { label: this.data[0].series, value: series1.find((d) => d.x === 3).y },
- { label: this.data[1].series, value: series2.find((d) => d.x === 2).y },
+ {
+ label: this.data[0].series,
+ value: series1.find((datum) => datum.x === 3).y,
+ },
+ {
+ label: this.data[1].series,
+ value: series2.find((datum) => datum.x === 2).y,
+ },
];
await triggerEvent(hoverTarget, 'mousemove', {
clientX: xOffset + interval * 1.5 + 5,
diff --git a/ui/tests/integration/components/list-pagination-test.js b/ui/tests/integration/components/list-pagination-test.gjs
similarity index 59%
rename from ui/tests/integration/components/list-pagination-test.js
rename to ui/tests/integration/components/list-pagination-test.gjs
index 209edfaa4df..edec881b4ec 100644
--- a/ui/tests/integration/components/list-pagination-test.js
+++ b/ui/tests/integration/components/list-pagination-test.gjs
@@ -6,8 +6,8 @@
import { findAll, find, render } from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import ListPagination from 'nomad-ui/components/list-pagination';
module('Integration | Component | list pagination', function (hooks) {
setupRenderingTest(hooks);
@@ -24,23 +24,28 @@ module('Integration | Component | list pagination', function (hooks) {
.map((_, i) => i);
test('the source property', async function (assert) {
- this.set('source', list100);
- await render(hbs`
-
- {{p.currentPage}} of {{p.totalPages}}
- first
- prev
- {{#each p.pageLinks as |link|}}
- {{link.pageNumber}}
- {{/each}}
- next
- last
-
- {{#each p.list as |item|}}
- {{item}}
- {{/each}}
-
- `);
+ const source = list100;
+
+ await render(
+
+
+ {{p.currentPage}} of {{p.totalPages}}
+ first
+ prev
+ {{#each p.pageLinks as |link|}}
+ {{link.pageNumber}}
+ {{/each}}
+ next
+ last
+
+ {{#each p.list as |item|}}
+ {{item}}
+ {{/each}}
+
+ ,
+ );
assert.notOk(
findAll('.first').length,
@@ -91,17 +96,18 @@ module('Integration | Component | list pagination', function (hooks) {
});
test('the size property', async function (assert) {
- this.setProperties({
- size: 5,
- source: list100,
- });
- await render(hbs`
-
- {{p.currentPage}} of {{p.totalPages}}
-
- `);
+ const size = 5;
+ const source = list100;
- const totalPages = Math.ceil(this.source.length / this.size);
+ await render(
+
+
+ {{p.currentPage}} of {{p.totalPages}}
+
+ ,
+ );
+
+ const totalPages = Math.ceil(source.length / size);
assert.deepEqual(
find('.page-info').textContent,
`1 of ${totalPages}`,
@@ -117,13 +123,23 @@ module('Integration | Component | list pagination', function (hooks) {
currentPage: 5,
});
- await render(hbs`
-
- {{#each p.pageLinks as |link|}}
- {{link.pageNumber}}
- {{/each}}
-
- `);
+ await render(
+
+
+ {{#each p.pageLinks as |link|}}
+ {{link.pageNumber}}
+ {{/each}}
+
+ ,
+ );
testSpread.call(this, assert);
this.set('spread', 4);
@@ -137,13 +153,20 @@ module('Integration | Component | list pagination', function (hooks) {
currentPage: 5,
});
- await render(hbs`
-
- {{#each p.list as |item|}}
- {{item}}
- {{/each}}
-
- `);
+ await render(
+
+
+ {{#each p.list as |item|}}
+ {{item}}
+ {{/each}}
+
+ ,
+ );
testItems.call(this, assert);
this.set('currentPage', 2);
@@ -156,23 +179,28 @@ module('Integration | Component | list pagination', function (hooks) {
skip('pagination links link with query params', function () {});
test('there are no pagination links when source is less than page size', async function (assert) {
- this.set('source', list100.slice(0, 10));
- await render(hbs`
-
- {{p.currentPage}} of {{p.totalPages}}
- first
- prev
- {{#each p.pageLinks as |link|}}
- {{link.pageNumber}}
- {{/each}}
- next
- last
-
- {{#each p.list as |item|}}
- {{item}}
- {{/each}}
-
- `);
+ const source = list100.slice(0, 10);
+
+ await render(
+
+
+ {{p.currentPage}} of {{p.totalPages}}
+ first
+ prev
+ {{#each p.pageLinks as |link|}}
+ {{link.pageNumber}}
+ {{/each}}
+ next
+ last
+
+ {{#each p.list as |item|}}
+ {{item}}
+ {{/each}}
+
+ ,
+ );
assert.notOk(findAll('.first').length, 'No first link');
assert.notOk(findAll('.prev').length, 'No prev link');
@@ -182,7 +210,7 @@ module('Integration | Component | list pagination', function (hooks) {
assert.deepEqual(find('.page-info').textContent, '1 of 1', 'Only one page');
assert.deepEqual(
findAll('.item').length,
- this.source.length,
+ source.length,
'Number of items equals length of source',
);
});
@@ -198,18 +226,28 @@ module('Integration | Component | list pagination', function (hooks) {
const totalPages = Math.ceil(this.source.length / this.size);
- await render(hbs`
-
- {{p.currentPage}} of {{p.totalPages}}
- first
- prev
- {{#each p.pageLinks as |link|}}
- {{link.pageNumber}}
- {{/each}}
- next
- last
-
- `);
+ await render(
+
+
+ {{p.currentPage}} of {{p.totalPages}}
+ first
+ prev
+ {{#each p.pageLinks as |link|}}
+ {{link.pageNumber}}
+ {{/each}}
+ next
+ last
+
+ ,
+ );
assert.ok(findAll('.first').length, 'First page still exists');
assert.ok(findAll('.prev').length, 'Prev page still exists');
diff --git a/ui/tests/integration/components/list-table-test.js b/ui/tests/integration/components/list-table-test.gjs
similarity index 70%
rename from ui/tests/integration/components/list-table-test.js
rename to ui/tests/integration/components/list-table-test.gjs
index ffb66f2aa74..b3d76bf6a90 100644
--- a/ui/tests/integration/components/list-table-test.js
+++ b/ui/tests/integration/components/list-table-test.gjs
@@ -6,9 +6,9 @@
import { findAll, find, render } from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import faker from 'nomad-ui/mirage/faker';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import ListTable from 'nomad-ui/components/list-table';
+import faker from 'nomad-ui/mirage/faker';
module('Integration | Component | list table', function (hooks) {
setupRenderingTest(hooks);
@@ -23,16 +23,19 @@ module('Integration | Component | list table', function (hooks) {
// thead
test('component exposes a thead contextual component', async function (assert) {
- this.set('source', commonTable);
- await render(hbs`
-
-
- First Name
- Last Name
- Age
-
-
- `);
+ const source = commonTable;
+
+ await render(
+
+
+
+ First Name
+ Last Name
+ Age
+
+
+ ,
+ );
assert.ok(findAll('.head').length, 'Table head is rendered');
assert.deepEqual(
@@ -44,23 +47,29 @@ module('Integration | Component | list table', function (hooks) {
// tbody
test('component exposes a tbody contextual component', async function (assert) {
- this.setProperties({
- source: commonTable,
- sortProperty: 'firstName',
- sortDescending: false,
- });
- await render(hbs`
-
-
-
- {{row.model.firstName}}
- {{row.model.lastName}}
- {{row.model.age}}
- {{index}}
-
-
-
- `);
+ const source = commonTable;
+ const sortProperty = 'firstName';
+ const sortDescending = false;
+
+ await render(
+
+
+
+
+ {{row.model.firstName}}
+ {{row.model.lastName}}
+ {{row.model.age}}
+ {{index}}
+
+
+
+ ,
+ );
assert.ok(findAll('.body').length, 'Table body is rendered');
assert.deepEqual(
@@ -71,13 +80,13 @@ module('Integration | Component | list table', function (hooks) {
assert.deepEqual(
findAll('.item').length,
- this.source.length,
+ source.length,
'Each item gets its own row',
);
// list-table is not responsible for sorting, only dispatching sort events. The table is still
// rendered in index-order.
- this.source.forEach((item, index) => {
+ source.forEach((item, index) => {
const $item = this.element.querySelectorAll('.item')[index];
assert.strictEqual(
$item.querySelectorAll('td')[0].textContent.trim(),
diff --git a/ui/tests/integration/components/multi-select-dropdown-test.js b/ui/tests/integration/components/multi-select-dropdown-test.gjs
similarity index 91%
rename from ui/tests/integration/components/multi-select-dropdown-test.js
rename to ui/tests/integration/components/multi-select-dropdown-test.gjs
index dde42e73c83..297ce82400f 100644
--- a/ui/tests/integration/components/multi-select-dropdown-test.js
+++ b/ui/tests/integration/components/multi-select-dropdown-test.gjs
@@ -14,8 +14,8 @@ import {
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import sinon from 'sinon';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import MultiSelectDropdown from 'nomad-ui/components/multi-select-dropdown';
const TAB = 9;
const ESC = 27;
@@ -40,18 +40,22 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
onSelect: sinon.spy(),
});
- const commonTemplate = hbs`
-
- `;
+ const renderComponent = (context) =>
+ render(
+
+
+ ,
+ );
test('component is initially closed', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(find('.dropdown-trigger'), 'Trigger is shown');
assert.deepEqual(
@@ -70,14 +74,11 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('component opens the options dropdown when clicked', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
- await assert.ok(
- find('[data-test-dropdown-options]'),
- 'Options are shown now',
- );
+ assert.ok(find('[data-test-dropdown-options]'), 'Options are shown now');
await componentA11yAudit(this.element, assert);
await click('[data-test-dropdown-trigger]');
@@ -91,7 +92,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('all options are shown in the options dropdown, each with a checkbox input', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -117,7 +118,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('onSelect gets called when an option is clicked', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
await click('[data-test-dropdown-option] label');
@@ -135,7 +136,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
const props = commonProperties();
props.selection = [props.options[0].key, props.options[1].key];
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(
find('[data-test-dropdown-trigger] [data-test-dropdown-count]'),
@@ -164,7 +165,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing DOWN when the trigger has focus opens the options list', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await focus('[data-test-dropdown-trigger]');
assert.notOk(
@@ -183,7 +184,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing DOWN when the trigger has focus and the options list is open focuses the first option', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await focus('[data-test-dropdown-trigger]');
await triggerKeyEvent('[data-test-dropdown-trigger]', 'keyup', ARROW_DOWN);
@@ -198,7 +199,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing TAB when the trigger has focus and the options list is open focuses the first option', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await focus('[data-test-dropdown-trigger]');
await triggerKeyEvent('[data-test-dropdown-trigger]', 'keyup', ARROW_DOWN);
@@ -213,7 +214,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing UP when the first list option is focused does nothing', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -229,7 +230,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing DOWN when the a list option is focused moves focus to the next list option', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -245,7 +246,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing DOWN when the last list option has focus does nothing', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -276,7 +277,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('onSelect gets called when pressing SPACE when a list option is focused', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -295,7 +296,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('list options have a zero tabindex and are therefore sequentially navigable', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -311,7 +312,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('the checkboxes inside list options have a negative tabindex and are therefore not sequentially navigable', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
@@ -331,7 +332,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
test('pressing ESC when the options list is open closes the list and returns focus to the dropdown trigger', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await focus('[data-test-dropdown-trigger]');
await triggerKeyEvent('[data-test-dropdown-trigger]', 'keyup', ARROW_DOWN);
@@ -353,7 +354,7 @@ module('Integration | Component | multi-select dropdown', function (hooks) {
const props = commonProperties();
props.options = [];
this.setProperties(props);
- await render(commonTemplate);
+ await renderComponent(this);
await click('[data-test-dropdown-trigger]');
assert.ok(
diff --git a/ui/tests/integration/components/page-layout-test.js b/ui/tests/integration/components/page-layout-test.gjs
similarity index 89%
rename from ui/tests/integration/components/page-layout-test.js
rename to ui/tests/integration/components/page-layout-test.gjs
index 0d532700cb0..75ee6d984d4 100644
--- a/ui/tests/integration/components/page-layout-test.js
+++ b/ui/tests/integration/components/page-layout-test.gjs
@@ -6,9 +6,9 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, click, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import PageLayout from 'nomad-ui/components/page-layout';
module('Integration | Component | page layout', function (hooks) {
setupRenderingTest(hooks);
@@ -22,7 +22,7 @@ module('Integration | Component | page layout', function (hooks) {
});
test('the global-header hamburger menu opens the gutter menu', async function (assert) {
- await render(hbs` `);
+ await render( );
assert.notOk(
find('[data-test-gutter-menu]').classList.contains('is-open'),
@@ -38,7 +38,7 @@ module('Integration | Component | page layout', function (hooks) {
});
test('the gutter-menu hamburger menu closes the gutter menu', async function (assert) {
- await render(hbs` `);
+ await render( );
await click('[data-test-header-gutter-toggle]');
@@ -55,7 +55,7 @@ module('Integration | Component | page layout', function (hooks) {
});
test('the gutter-menu backdrop closes the gutter menu', async function (assert) {
- await render(hbs` `);
+ await render( );
await click('[data-test-header-gutter-toggle]');
diff --git a/ui/tests/integration/components/placement-failure-test.js b/ui/tests/integration/components/placement-failure-test.gjs
similarity index 85%
rename from ui/tests/integration/components/placement-failure-test.js
rename to ui/tests/integration/components/placement-failure-test.gjs
index 258d77b27cd..e846916f9a2 100644
--- a/ui/tests/integration/components/placement-failure-test.js
+++ b/ui/tests/integration/components/placement-failure-test.gjs
@@ -1,36 +1,31 @@
/**
- * Copyright IBM Corp. 2015, 2025
+ * Copyright IBM Corp. 2015, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { find, findAll, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import cleanWhitespace from '../../utils/clean-whitespace';
+import PlacementFailure from 'nomad-ui/components/placement-failure';
+import cleanWhitespace from 'nomad-ui/tests/utils/clean-whitespace';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | placement failures', function (hooks) {
setupRenderingTest(hooks);
- const commonTemplate = hbs`
-
- `;
-
test('should render the placement failure (basic render)', async function (assert) {
const name = 'Placement Failure';
const failures = 11;
- this.set(
- 'taskGroup',
- createFixture(
- {
- coalescedFailures: failures - 1,
- },
- name,
- ),
+ const taskGroup = createFixture(
+ {
+ coalescedFailures: failures - 1,
+ },
+ name,
);
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.deepEqual(
cleanWhitespace(
@@ -96,15 +91,14 @@ module('Integration | Component | placement failures', function (hooks) {
});
test('should render correctly when a node is not evaluated', async function (assert) {
- this.set(
- 'taskGroup',
- createFixture({
- nodesEvaluated: 1,
- nodesExhausted: 0,
- }),
- );
+ const taskGroup = createFixture({
+ nodesEvaluated: 1,
+ nodesExhausted: 0,
+ });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.deepEqual(
findAll('[data-test-placement-failure-no-evaluated-nodes]').length,
@@ -122,10 +116,10 @@ module('Integration | Component | placement failures', function (hooks) {
function createFixture(obj = {}, name = 'Placement Failure') {
return {
- name: name,
+ name,
placementFailures: Object.assign(
{
- name: name,
+ name,
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {
diff --git a/ui/tests/integration/components/plugin-allocation-row-test.js b/ui/tests/integration/components/plugin-allocation-row-test.gjs
similarity index 80%
rename from ui/tests/integration/components/plugin-allocation-row-test.js
rename to ui/tests/integration/components/plugin-allocation-row-test.gjs
index 37589d3cb23..80d36db4e73 100644
--- a/ui/tests/integration/components/plugin-allocation-row-test.js
+++ b/ui/tests/integration/components/plugin-allocation-row-test.gjs
@@ -5,11 +5,11 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { render, settled } from '@ember/test-helpers';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import PluginAllocationRow from 'nomad-ui/components/plugin-allocation-row';
module('Integration | Component | plugin allocation row', function (hooks) {
setupRenderingTest(hooks);
@@ -39,12 +39,16 @@ module('Integration | Component | plugin allocation row', function (hooks) {
plugin: pluginRecord.get('controllers.firstObject'),
});
- await render(hbs`
-
- `);
+ await render(
+
+
+
+
+ ,
+ );
const allocationRequest = this.server.pretender.handledRequests.find(
- (req) => req.url.startsWith('/v1/allocation'),
+ (request) => request.url.startsWith('/v1/allocation'),
);
assert.deepEqual(
allocationRequest.url,
@@ -66,9 +70,13 @@ module('Integration | Component | plugin allocation row', function (hooks) {
plugin: pluginRecord.get('controllers.firstObject'),
});
- await render(hbs`
-
- `);
+ await render(
+
+
+
+
+ ,
+ );
const [statsRequest] = this.server.pretender.handledRequests.slice(-1);
@@ -94,12 +102,16 @@ module('Integration | Component | plugin allocation row', function (hooks) {
plugin: pluginRecord.get('controllers.firstObject'),
});
- await render(hbs`
-
- `);
+ await render(
+
+
+
+
+ ,
+ );
const allocationRequest = this.server.pretender.handledRequests.find(
- (req) => req.url.startsWith('/v1/allocation'),
+ (request) => request.url.startsWith('/v1/allocation'),
);
assert.deepEqual(
@@ -111,7 +123,7 @@ module('Integration | Component | plugin allocation row', function (hooks) {
await settled();
const latestAllocationRequest = this.server.pretender.handledRequests
- .filter((req) => req.url.startsWith('/v1/allocation'))
+ .filter((request) => request.url.startsWith('/v1/allocation'))
.reverse()[0];
assert.deepEqual(
diff --git a/ui/tests/integration/components/policy-editor-test.js b/ui/tests/integration/components/policy-editor-test.gjs
similarity index 72%
rename from ui/tests/integration/components/policy-editor-test.js
rename to ui/tests/integration/components/policy-editor-test.gjs
index 88972b92cd7..47e2397a0a4 100644
--- a/ui/tests/integration/components/policy-editor-test.js
+++ b/ui/tests/integration/components/policy-editor-test.gjs
@@ -6,14 +6,14 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import PolicyEditor from 'nomad-ui/components/policy-editor';
module('Integration | Component | policy-editor', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
- await render(hbs` `);
+ await render( );
await componentA11yAudit(this.element, assert);
});
@@ -28,12 +28,14 @@ module('Integration | Component | policy-editor', function (hooks) {
name: 'Old Policy',
};
- this.set('newMockPolicy', newMockPolicy);
- this.set('oldMockPolicy', oldMockPolicy);
-
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.dom('[data-test-policy-name-input]').exists();
- await render(hbs` `);
+
+ await render(
+ ,
+ );
assert.dom('[data-test-policy-name-input]').doesNotExist();
});
});
diff --git a/ui/tests/integration/components/popover-menu-test.gjs b/ui/tests/integration/components/popover-menu-test.gjs
new file mode 100644
index 00000000000..2f904298850
--- /dev/null
+++ b/ui/tests/integration/components/popover-menu-test.gjs
@@ -0,0 +1,232 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { click, render } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { on } from '@ember/modifier';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import { create } from 'ember-cli-page-object';
+import PopoverMenuComponent from 'nomad-ui/components/popover-menu';
+import popoverMenuPageObject from 'nomad-ui/tests/pages/components/popover-menu';
+
+const PopoverMenu = create(popoverMenuPageObject());
+
+module('Integration | Component | popover-menu', function (hooks) {
+ setupRenderingTest(hooks);
+
+ const commonProperties = (overrides) =>
+ Object.assign(
+ {
+ triggerClass: '',
+ label: 'Trigger Label',
+ },
+ overrides,
+ );
+
+ test('presents as a button with a chevron-down icon', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+
+ assert.ok(PopoverMenu.isPresent);
+ assert.ok(PopoverMenu.labelHasIcon);
+ assert.notOk(PopoverMenu.menu.isOpen);
+ assert.deepEqual(PopoverMenu.label, props.label);
+ await componentA11yAudit(this.element, assert);
+ });
+
+ test('clicking the trigger button toggles the popover menu', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+ assert.notOk(PopoverMenu.menu.isOpen);
+
+ await PopoverMenu.toggle();
+
+ assert.ok(PopoverMenu.menu.isOpen);
+ await componentA11yAudit(this.element, assert);
+ });
+
+ test('the trigger gets the triggerClass prop assigned as a class', async function (assert) {
+ const specialClass = 'is-special';
+ const props = commonProperties({ triggerClass: specialClass });
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+
+ assert.dom('[data-test-popover-trigger]').hasClass('is-special');
+ });
+
+ test('pressing DOWN ARROW when the trigger is focused opens the popover menu', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+ assert.notOk(PopoverMenu.menu.isOpen);
+
+ await PopoverMenu.focus();
+ await PopoverMenu.downArrow();
+
+ assert.ok(PopoverMenu.menu.isOpen);
+ });
+
+ test('pressing TAB when the trigger button is focused and the menu is open focuses the first focusable element in the popover menu', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+
+ await PopoverMenu.focus();
+ await PopoverMenu.downArrow();
+
+ assert.dom('[data-test-popover-trigger]').isFocused();
+
+ await PopoverMenu.focusNext();
+
+ assert.dom('#mock-input-for-test').isFocused();
+ });
+
+ test('pressing ESC when the popover menu is open closes the menu and returns focus to the trigger button', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+
+ await PopoverMenu.toggle();
+ assert.ok(PopoverMenu.menu.isOpen);
+
+ await PopoverMenu.esc();
+
+ assert.notOk(PopoverMenu.menu.isOpen);
+ });
+
+ test('the ember-basic-dropdown object is yielded as context, including the close action', async function (assert) {
+ const props = commonProperties();
+
+ await render(
+
+
+ This is a heading
+
+
+
+ ,
+ );
+
+ await PopoverMenu.toggle();
+ assert.ok(PopoverMenu.menu.isOpen);
+
+ await click('#mock-button-for-test');
+ assert.notOk(PopoverMenu.menu.isOpen);
+ });
+});
diff --git a/ui/tests/integration/components/popover-menu-test.js b/ui/tests/integration/components/popover-menu-test.js
deleted file mode 100644
index 28bee1fc8d1..00000000000
--- a/ui/tests/integration/components/popover-menu-test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { click, render } from '@ember/test-helpers';
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-import { create } from 'ember-cli-page-object';
-import popoverMenuPageObject from 'nomad-ui/tests/pages/components/popover-menu';
-
-const PopoverMenu = create(popoverMenuPageObject());
-
-module('Integration | Component | popover-menu', function (hooks) {
- setupRenderingTest(hooks);
-
- const commonProperties = (overrides) =>
- Object.assign(
- {
- triggerClass: '',
- label: 'Trigger Label',
- },
- overrides,
- );
-
- const commonTemplate = hbs`
-
- This is a heading
-
-
-
- `;
-
- test('presents as a button with a chevron-down icon', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
-
- assert.ok(PopoverMenu.isPresent);
- assert.ok(PopoverMenu.labelHasIcon);
- assert.notOk(PopoverMenu.menu.isOpen);
- assert.deepEqual(PopoverMenu.label, props.label);
- await componentA11yAudit(this.element, assert);
- });
-
- test('clicking the trigger button toggles the popover menu', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
- assert.notOk(PopoverMenu.menu.isOpen);
-
- await PopoverMenu.toggle();
-
- assert.ok(PopoverMenu.menu.isOpen);
- await componentA11yAudit(this.element, assert);
- });
-
- test('the trigger gets the triggerClass prop assigned as a class', async function (assert) {
- const specialClass = 'is-special';
- const props = commonProperties({ triggerClass: specialClass });
- this.setProperties(props);
- await render(commonTemplate);
-
- assert.dom('[data-test-popover-trigger]').hasClass('is-special');
- });
-
- test('pressing DOWN ARROW when the trigger is focused opens the popover menu', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
- assert.notOk(PopoverMenu.menu.isOpen);
-
- await PopoverMenu.focus();
- await PopoverMenu.downArrow();
-
- assert.ok(PopoverMenu.menu.isOpen);
- });
-
- test('pressing TAB when the trigger button is focused and the menu is open focuses the first focusable element in the popover menu', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
-
- await PopoverMenu.focus();
- await PopoverMenu.downArrow();
-
- assert.dom('[data-test-popover-trigger]').isFocused();
-
- await PopoverMenu.focusNext();
-
- assert.dom('#mock-input-for-test').isFocused();
- });
-
- test('pressing ESC when the popover menu is open closes the menu and returns focus to the trigger button', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
-
- await PopoverMenu.toggle();
- assert.ok(PopoverMenu.menu.isOpen);
-
- await PopoverMenu.esc();
-
- assert.notOk(PopoverMenu.menu.isOpen);
- });
-
- test('the ember-basic-dropdown object is yielded as context, including the close action', async function (assert) {
- const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
-
- await PopoverMenu.toggle();
- assert.ok(PopoverMenu.menu.isOpen);
-
- await click('#mock-button-for-test');
- assert.notOk(PopoverMenu.menu.isOpen);
- });
-});
diff --git a/ui/tests/integration/components/primary-metric/allocation-test.js b/ui/tests/integration/components/primary-metric/allocation-test.gjs
similarity index 85%
rename from ui/tests/integration/components/primary-metric/allocation-test.js
rename to ui/tests/integration/components/primary-metric/allocation-test.gjs
index 2cc38f54f6e..5098eb23938 100644
--- a/ui/tests/integration/components/primary-metric/allocation-test.js
+++ b/ui/tests/integration/components/primary-metric/allocation-test.gjs
@@ -7,10 +7,10 @@ import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { findAll, render } from '@ember/test-helpers';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
-import { hbs } from 'ember-cli-htmlbars';
import { setupPrimaryMetricMocks, primaryMetric } from './primary-metric';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
+import PrimaryMetricAllocation from 'nomad-ui/components/primary-metric/allocation';
const mockTasks = [
{ task: 'One', reservedCPU: 200, reservedMemory: 500, cpu: [], memory: [] },
@@ -41,12 +41,6 @@ module('Integration | Component | PrimaryMetric::Allocation', function (hooks) {
this.server.shutdown();
});
- const template = hbs`
-
- `;
-
const preload = async (store) => {
await store.findAll('allocation');
};
@@ -54,13 +48,24 @@ module('Integration | Component | PrimaryMetric::Allocation', function (hooks) {
const findResource = (store) =>
store.peekAll('allocation').get('firstObject');
+ const renderMetric = async (ctx) => {
+ await render(
+
+
+ ,
+ );
+ };
+
test('Must pass an accessibility audit', async function (assert) {
await preload(this.store);
const resource = findResource(this.store);
this.setProperties({ resource, metric: 'cpu' });
- await render(template);
+ await renderMetric(this);
await componentA11yAudit(this.element, assert);
});
@@ -70,7 +75,7 @@ module('Integration | Component | PrimaryMetric::Allocation', function (hooks) {
const resource = findResource(this.store);
this.setProperties({ resource, metric: 'cpu' });
- await render(template);
+ await renderMetric(this);
assert.deepEqual(
findAll('[data-test-chart-area]').length,
mockTasks.length,
@@ -78,7 +83,7 @@ module('Integration | Component | PrimaryMetric::Allocation', function (hooks) {
});
primaryMetric({
- template,
+ renderMetric,
preload,
findResource,
});
diff --git a/ui/tests/integration/components/primary-metric/node-test.js b/ui/tests/integration/components/primary-metric/node-test.gjs
similarity index 86%
rename from ui/tests/integration/components/primary-metric/node-test.js
rename to ui/tests/integration/components/primary-metric/node-test.gjs
index 8462cdbbd17..5ffff20837a 100644
--- a/ui/tests/integration/components/primary-metric/node-test.js
+++ b/ui/tests/integration/components/primary-metric/node-test.gjs
@@ -7,11 +7,11 @@ import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { find, render } from '@ember/test-helpers';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
-import { hbs } from 'ember-cli-htmlbars';
import { setupPrimaryMetricMocks, primaryMetric } from './primary-metric';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { formatScheduledHertz } from 'nomad-ui/utils/units';
+import PrimaryMetricNode from 'nomad-ui/components/primary-metric/node';
module('Integration | Component | PrimaryMetric::Node', function (hooks) {
setupRenderingTest(hooks);
@@ -30,25 +30,27 @@ module('Integration | Component | PrimaryMetric::Node', function (hooks) {
this.server.shutdown();
});
- const template = hbs`
-
- `;
-
const preload = async (store) => {
await store.findAll('node');
};
const findResource = (store) => store.peekAll('node').get('firstObject');
+ const renderMetric = async (ctx) => {
+ await render(
+
+
+ ,
+ );
+ };
+
test('Must pass an accessibility audit', async function (assert) {
await preload(this.store);
const resource = findResource(this.store);
this.setProperties({ resource, metric: 'cpu' });
- await render(template);
+ await renderMetric(this);
await componentA11yAudit(this.element, assert);
});
@@ -59,7 +61,7 @@ module('Integration | Component | PrimaryMetric::Node', function (hooks) {
const resource = this.store.peekRecord('node', 'withAnnotation');
this.setProperties({ resource, metric: 'cpu' });
- await render(template);
+ await renderMetric(this);
assert.ok(find('[data-test-annotation]'));
assert.deepEqual(
@@ -69,7 +71,7 @@ module('Integration | Component | PrimaryMetric::Node', function (hooks) {
});
primaryMetric({
- template,
+ renderMetric,
preload,
findResource,
});
diff --git a/ui/tests/integration/components/primary-metric/primary-metric.js b/ui/tests/integration/components/primary-metric/primary-metric.js
index 721ac4f7fe1..4cde545f3a5 100644
--- a/ui/tests/integration/components/primary-metric/primary-metric.js
+++ b/ui/tests/integration/components/primary-metric/primary-metric.js
@@ -5,7 +5,7 @@
import EmberObject, { computed } from '@ember/object';
import Service from '@ember/service';
-import { find, render, clearRender } from '@ember/test-helpers';
+import { find, clearRender } from '@ember/test-helpers';
import { test } from 'qunit';
import { task } from 'ember-concurrency';
import sinon from 'sinon';
@@ -50,7 +50,7 @@ export function setupPrimaryMetricMocks(hooks, tasks = []) {
});
}
-export function primaryMetric({ template, findResource, preload }) {
+export function primaryMetric({ renderMetric, findResource, preload }) {
test('Contains a line chart, a percentage bar, a percentage figure, and an absolute usage figure', async function (assert) {
const metric = 'cpu';
@@ -59,7 +59,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
assert.ok(find('[data-test-line-chart]'), 'Line chart');
assert.ok(find('[data-test-percentage-bar]'), 'Percentage bar');
@@ -75,7 +75,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
assert.ok(
find('[data-test-current-value]').classList.contains('is-info'),
@@ -91,7 +91,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
assert.ok(
find('[data-test-current-value]').classList.contains('is-danger'),
@@ -107,7 +107,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
const spy =
this.getTrackerSpy.calledWith(resource) ||
@@ -127,7 +127,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
assert.ok(
this.trackerPollSpy.calledOnce,
@@ -145,7 +145,7 @@ export function primaryMetric({ template, findResource, preload }) {
const resource = findResource(this.store);
this.setProperties({ resource, metric });
- await render(template);
+ await renderMetric(this);
assert.notOk(
trackerSignalPauseSpy.called,
diff --git a/ui/tests/integration/components/primary-metric/task-test.js b/ui/tests/integration/components/primary-metric/task-test.gjs
similarity index 87%
rename from ui/tests/integration/components/primary-metric/task-test.js
rename to ui/tests/integration/components/primary-metric/task-test.gjs
index d9a4a94a044..797b470ddc8 100644
--- a/ui/tests/integration/components/primary-metric/task-test.js
+++ b/ui/tests/integration/components/primary-metric/task-test.gjs
@@ -7,10 +7,10 @@ import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { render } from '@ember/test-helpers';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
-import { hbs } from 'ember-cli-htmlbars';
import { setupPrimaryMetricMocks, primaryMetric } from './primary-metric';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
+import PrimaryMetricTask from 'nomad-ui/components/primary-metric/task';
const mockTasks = [
{ task: 'One', reservedCPU: 200, reservedMemory: 500, cpu: [], memory: [] },
@@ -35,7 +35,6 @@ module('Integration | Component | PrimaryMetric::Task', function (hooks) {
createAllocations: false,
});
- // Update job > group > task names to match mockTasks
job.taskGroups.models[0].tasks.models.forEach((task, index) => {
task.update({ name: mockTasks[index].task });
});
@@ -47,12 +46,6 @@ module('Integration | Component | PrimaryMetric::Task', function (hooks) {
this.server.shutdown();
});
- const template = hbs`
-
- `;
-
const preload = async (store) => {
await store.findAll('allocation');
};
@@ -60,18 +53,26 @@ module('Integration | Component | PrimaryMetric::Task', function (hooks) {
const findResource = (store) =>
store.peekAll('allocation').get('firstObject.states.firstObject');
+ const renderMetric = async (ctx) => {
+ await render(
+
+
+ ,
+ );
+ };
+
test('Must pass an accessibility audit', async function (assert) {
await preload(this.store);
const resource = findResource(this.store);
this.setProperties({ resource, metric: 'cpu' });
- await render(template);
+ await renderMetric(this);
await componentA11yAudit(this.element, assert);
});
primaryMetric({
- template,
+ renderMetric,
preload,
findResource,
});
diff --git a/ui/tests/integration/components/reschedule-event-timeline-test.js b/ui/tests/integration/components/reschedule-event-timeline-test.gjs
similarity index 85%
rename from ui/tests/integration/components/reschedule-event-timeline-test.js
rename to ui/tests/integration/components/reschedule-event-timeline-test.gjs
index b466fabddf1..76e5328774e 100644
--- a/ui/tests/integration/components/reschedule-event-timeline-test.js
+++ b/ui/tests/integration/components/reschedule-event-timeline-test.gjs
@@ -7,9 +7,10 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, findAll, render } from '@ember/test-helpers';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import { TrackedObject } from 'tracked-built-ins';
import moment from 'moment';
+import RescheduleEventTimeline from 'nomad-ui/components/reschedule-event-timeline';
module('Integration | Component | reschedule event timeline', function (hooks) {
setupRenderingTest(hooks);
@@ -27,10 +28,6 @@ module('Integration | Component | reschedule event timeline', function (hooks) {
this.server.shutdown();
});
- const commonTemplate = hbs`
-
- `;
-
test('when the allocation is running, the timeline shows past allocations', async function (assert) {
const attempts = 2;
@@ -45,8 +42,12 @@ module('Integration | Component | reschedule event timeline', function (hooks) {
.peekAll('allocation')
.find((alloc) => !alloc.get('nextAllocation.content'));
- this.set('allocation', allocation);
- await render(commonTemplate);
+ const state = new TrackedObject({ allocation });
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
findAll('[data-test-allocation]').length,
@@ -94,8 +95,12 @@ module('Integration | Component | reschedule event timeline', function (hooks) {
.peekAll('allocation')
.find((alloc) => !alloc.get('nextAllocation.content'));
- this.set('allocation', allocation);
- await render(commonTemplate);
+ const state = new TrackedObject({ allocation });
+ await render(
+
+
+ ,
+ );
assert.ok(
find('[data-test-stop-warning]'),
@@ -128,12 +133,16 @@ module('Integration | Component | reschedule event timeline', function (hooks) {
await this.store.findAll('allocation');
- let allocation = this.store
+ const allocation = this.store
.peekAll('allocation')
.find((alloc) => !alloc.get('nextAllocation.content'));
- this.set('allocation', allocation);
+ const state = new TrackedObject({ allocation });
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.ok(
find('[data-test-attempt-notice]'),
@@ -157,9 +166,12 @@ module('Integration | Component | reschedule event timeline', function (hooks) {
const allocation = this.store
.peekAll('allocation')
.findBy('id', originalAllocation.id);
-
- this.set('allocation', allocation);
- await render(commonTemplate);
+ const state = new TrackedObject({ allocation });
+ await render(
+
+
+ ,
+ );
assert.deepEqual(
find('[data-test-reschedule-label]').textContent.trim(),
diff --git a/ui/tests/integration/components/scale-events-accordion-test.js b/ui/tests/integration/components/scale-events-accordion-test.gjs
similarity index 77%
rename from ui/tests/integration/components/scale-events-accordion-test.js
rename to ui/tests/integration/components/scale-events-accordion-test.gjs
index 4dc7dc89beb..99c9133864e 100644
--- a/ui/tests/integration/components/scale-events-accordion-test.js
+++ b/ui/tests/integration/components/scale-events-accordion-test.gjs
@@ -6,11 +6,12 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, find, findAll, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror';
+import { TrackedObject } from 'tracked-built-ins';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import ScaleEventsAccordion from 'nomad-ui/components/scale-events-accordion';
module('Integration | Component | scale-events-accordion', function (hooks) {
setupRenderingTest(hooks);
@@ -42,16 +43,16 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
this.server.shutdown();
});
- const commonTemplate = hbs` `;
-
test('it shows an accordion with an entry for each event', async function (assert) {
const eventCount = 5;
const taskGroup = await this.taskGroupWithEvents(
this.server.createList('scale-event', eventCount),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.deepEqual(
findAll('[data-test-scale-events] [data-test-accordion-head]').length,
@@ -64,9 +65,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
const taskGroup = await this.taskGroupWithEvents(
this.server.createList('scale-event', 1, { error: true }),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.ok(find('[data-test-error]'));
await componentA11yAudit(this.element, assert);
@@ -81,9 +84,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
error: false,
}),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-error]'));
assert.strictEqual(
@@ -102,9 +107,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
error: false,
}),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-error]'));
assert.strictEqual(
@@ -117,9 +124,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
const taskGroup = await this.taskGroupWithEvents(
this.server.createList('scale-event', 1, { count: null }),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-count]'));
assert.notOk(find('[data-test-count-icon]'));
@@ -129,9 +138,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
const taskGroup = await this.taskGroupWithEvents(
this.server.createList('scale-event', 1, { meta: {} }),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.ok(
find('[data-test-accordion-toggle]').classList.contains('is-invisible'),
@@ -151,9 +162,11 @@ module('Integration | Component | scale-events-accordion', function (hooks) {
const taskGroup = await this.taskGroupWithEvents(
this.server.createList('scale-event', 1, { meta }),
);
- this.set('events', taskGroup.scaleState.events);
+ const state = new TrackedObject({ events: taskGroup.scaleState.events });
- await render(commonTemplate);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-accordion-body]'));
await click('[data-test-accordion-toggle]');
diff --git a/ui/tests/integration/components/scale-events-chart-test.js b/ui/tests/integration/components/scale-events-chart-test.gjs
similarity index 88%
rename from ui/tests/integration/components/scale-events-chart-test.js
rename to ui/tests/integration/components/scale-events-chart-test.gjs
index 108bde855f9..4da5f6bbd46 100644
--- a/ui/tests/integration/components/scale-events-chart-test.js
+++ b/ui/tests/integration/components/scale-events-chart-test.gjs
@@ -6,10 +6,10 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, find, findAll, render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import moment from 'moment';
import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import ScaleEventsChart from 'nomad-ui/components/scale-events-chart';
module('Integration | Component | scale-events-chart', function (hooks) {
setupRenderingTest(hooks);
@@ -58,7 +58,10 @@ module('Integration | Component | scale-events-chart', function (hooks) {
test('each event is rendered as an annotation', async function (assert) {
this.set('events', events);
- await render(hbs` `);
+
+ await render(
+ ,
+ );
assert.deepEqual(
findAll('[data-test-annotation]').length,
@@ -71,7 +74,9 @@ module('Integration | Component | scale-events-chart', function (hooks) {
const annotation = events.rejectBy('hasCount').sortBy('time').reverse()[0];
this.set('events', events);
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-event-details]'));
await click('[data-test-annotation] button');
@@ -96,7 +101,9 @@ module('Integration | Component | scale-events-chart', function (hooks) {
test('clicking an active annotation closes event details', async function (assert) {
this.set('events', events);
- await render(hbs` `);
+ await render(
+ ,
+ );
assert.notOk(find('[data-test-event-details]'));
await click('[data-test-annotation] button');
diff --git a/ui/tests/integration/components/service-status-bar-test.js b/ui/tests/integration/components/service-status-bar-test.gjs
similarity index 70%
rename from ui/tests/integration/components/service-status-bar-test.js
rename to ui/tests/integration/components/service-status-bar-test.gjs
index 5cadbc258bf..89aa8a7209e 100644
--- a/ui/tests/integration/components/service-status-bar-test.js
+++ b/ui/tests/integration/components/service-status-bar-test.gjs
@@ -5,30 +5,27 @@
import { findAll, render } from '@ember/test-helpers';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
+import ServiceStatusBar from 'nomad-ui/components/service-status-bar';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | Service Status Bar', function (hooks) {
setupRenderingTest(hooks);
test('Visualizes aggregate status of a service', async function (assert) {
- const serviceStatus = {
+ this.serviceStatus = {
success: 1,
pending: 1,
failure: 1,
};
- this.set('serviceStatus', serviceStatus);
-
- await render(hbs`
-
-
-
- `);
+ await render(
+
+
+
+
+ ,
+ );
await componentA11yAudit(this.element, assert);
const bars = findAll('g > g').length;
diff --git a/ui/tests/integration/components/single-select-dropdown-test.js b/ui/tests/integration/components/single-select-dropdown-test.gjs
similarity index 74%
rename from ui/tests/integration/components/single-select-dropdown-test.js
rename to ui/tests/integration/components/single-select-dropdown-test.gjs
index c688c316a30..786a5a0d10f 100644
--- a/ui/tests/integration/components/single-select-dropdown-test.js
+++ b/ui/tests/integration/components/single-select-dropdown-test.gjs
@@ -9,7 +9,7 @@ import { setupRenderingTest } from 'ember-qunit';
import { selectChoose } from 'ember-power-select/test-support';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import sinon from 'sinon';
-import { hbs } from 'ember-cli-htmlbars';
+import SingleSelectDropdown from 'nomad-ui/components/single-select-dropdown';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | single-select dropdown', function (hooks) {
@@ -29,18 +29,20 @@ module('Integration | Component | single-select dropdown', function (hooks) {
onSelect: sinon.spy(),
});
- const commonTemplate = hbs`
-
- `;
-
test('component shows label and selection in the trigger', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
assert.ok(
find('.ember-power-select-trigger').textContent.includes(props.label),
@@ -58,7 +60,17 @@ module('Integration | Component | single-select dropdown', function (hooks) {
test('all options are shown in the dropdown', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
await clickTrigger('[data-test-single-select-dropdown]');
@@ -78,7 +90,17 @@ module('Integration | Component | single-select dropdown', function (hooks) {
test('selecting an option calls `onSelect` with the key for the selected option', async function (assert) {
const props = commonProperties();
this.setProperties(props);
- await render(commonTemplate);
+
+ await render(
+
+
+ ,
+ );
const option = props.options.findBy('key', 'terraform');
await selectChoose('[data-test-single-select-dropdown]', option.label);
diff --git a/ui/tests/integration/components/stepper-input-test.js b/ui/tests/integration/components/stepper-input-test.gjs
similarity index 60%
rename from ui/tests/integration/components/stepper-input-test.js
rename to ui/tests/integration/components/stepper-input-test.gjs
index 370418b4af4..714ab791cd5 100644
--- a/ui/tests/integration/components/stepper-input-test.js
+++ b/ui/tests/integration/components/stepper-input-test.gjs
@@ -6,10 +6,10 @@
import { find, render, settled, triggerEvent } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import sinon from 'sinon';
import { create } from 'ember-cli-page-object';
+import StepperInputComponent from 'nomad-ui/components/stepper-input';
import stepperInput from 'nomad-ui/tests/pages/components/stepper-input';
const StepperInput = create(stepperInput());
@@ -27,142 +27,147 @@ module('Integration | Component | stepper input', function (hooks) {
onChange: sinon.spy(),
});
- const commonTemplate = hbs`
-
- {{this.label}}
-
- `;
+ const renderStepperInput = async (props) => {
+ await render(
+
+
+ {{props.label}}
+
+ ,
+ );
+ };
test('basic appearance includes a label, an input, and two buttons', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
- await render(commonTemplate);
+ await renderStepperInput(props);
- assert.strictEqual(StepperInput.label, this.label);
- assert.strictEqual(Number(StepperInput.input.value), this.value);
+ assert.strictEqual(StepperInput.label, props.label);
+ assert.strictEqual(Number(StepperInput.input.value), props.value);
assert.ok(StepperInput.decrement.isPresent);
assert.ok(StepperInput.increment.isPresent);
assert.ok(
- StepperInput.decrement.classNames.split(' ').includes(this.classVariant),
+ StepperInput.decrement.classNames.split(' ').includes(props.classVariant),
);
assert.ok(
- StepperInput.increment.classNames.split(' ').includes(this.classVariant),
+ StepperInput.increment.classNames.split(' ').includes(props.classVariant),
);
await componentA11yAudit(this.element, assert);
});
test('clicking the increment and decrement buttons immediately changes the shown value in the input but debounces the onUpdate call', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
- const baseValue = this.value;
+ const baseValue = props.value;
- await render(commonTemplate);
+ await renderStepperInput(props);
const incrementButton = find('[data-test-stepper-increment]');
const decrementButton = find('[data-test-stepper-decrement]');
incrementButton.click();
assert.strictEqual(Number(StepperInput.input.value), baseValue + 1);
- assert.strictEqual(this.onChange.callCount, 0);
+ assert.strictEqual(props.onChange.callCount, 0);
decrementButton.click();
assert.strictEqual(Number(StepperInput.input.value), baseValue);
- assert.strictEqual(this.onChange.callCount, 0);
+ assert.strictEqual(props.onChange.callCount, 0);
decrementButton.click();
assert.strictEqual(Number(StepperInput.input.value), baseValue - 1);
- assert.strictEqual(this.onChange.callCount, 0);
+ assert.strictEqual(props.onChange.callCount, 0);
await settled();
- assert.ok(this.onChange.calledOnceWithExactly(baseValue - 1));
+ assert.ok(props.onChange.calledOnceWithExactly(baseValue - 1));
});
test('the increment button is disabled when the internal value is the max value', async function (assert) {
- this.setProperties(commonProperties());
- this.set('value', this.max);
+ const props = commonProperties();
+ props.value = props.max;
- await render(commonTemplate);
+ await renderStepperInput(props);
assert.ok(StepperInput.increment.isDisabled);
});
test('the decrement button is disabled when the internal value is the min value', async function (assert) {
- this.setProperties(commonProperties());
- this.set('value', this.min);
+ const props = commonProperties();
+ props.value = props.min;
- await render(commonTemplate);
+ await renderStepperInput(props);
assert.ok(StepperInput.decrement.isDisabled);
});
test('the text input does not call the onUpdate function on oninput', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
const newValue = 8;
- await render(commonTemplate);
+ await renderStepperInput(props);
const input = find('[data-test-stepper-input]');
input.value = newValue;
assert.strictEqual(Number(StepperInput.input.value), newValue);
- assert.notOk(this.onChange.called);
+ assert.notOk(props.onChange.called);
await triggerEvent(input, 'input');
assert.strictEqual(Number(StepperInput.input.value), newValue);
- assert.notOk(this.onChange.called);
+ assert.notOk(props.onChange.called);
await triggerEvent(input, 'change');
assert.strictEqual(Number(StepperInput.input.value), newValue);
- assert.ok(this.onChange.calledWith(newValue));
+ assert.ok(props.onChange.calledWith(newValue));
});
test('the text input does call the onUpdate function on onchange', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
const newValue = 8;
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.input.fill(newValue);
await settled();
assert.strictEqual(Number(StepperInput.input.value), newValue);
- assert.ok(this.onChange.calledWith(newValue));
+ assert.ok(props.onChange.calledWith(newValue));
});
test('text input limits input to the bounds of the min/max range', async function (assert) {
- this.setProperties(commonProperties());
- let newValue = this.max + 1;
+ const props = commonProperties();
+ let newValue = props.max + 1;
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.input.fill(newValue);
await settled();
- assert.strictEqual(Number(StepperInput.input.value), this.max);
- assert.ok(this.onChange.calledWith(this.max));
+ assert.strictEqual(Number(StepperInput.input.value), props.max);
+ assert.ok(props.onChange.calledWith(props.max));
- newValue = this.min - 1;
+ newValue = props.min - 1;
await StepperInput.input.fill(newValue);
await settled();
- assert.strictEqual(Number(StepperInput.input.value), this.min);
- assert.ok(this.onChange.calledWith(this.min));
+ assert.strictEqual(Number(StepperInput.input.value), props.min);
+ assert.ok(props.onChange.calledWith(props.min));
});
test('pressing ESC in the text input reverts the text value back to the current value', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
const newValue = 8;
- await render(commonTemplate);
+ await renderStepperInput(props);
const input = find('[data-test-stepper-input]');
@@ -170,13 +175,13 @@ module('Integration | Component | stepper input', function (hooks) {
assert.strictEqual(Number(StepperInput.input.value), newValue);
await StepperInput.input.esc();
- assert.strictEqual(Number(StepperInput.input.value), this.value);
+ assert.strictEqual(Number(StepperInput.input.value), props.value);
});
test('clicking the label focuses in the input', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.clickLabel();
const input = find('[data-test-stepper-input]');
@@ -184,40 +189,40 @@ module('Integration | Component | stepper input', function (hooks) {
});
test('focusing the input selects the input value', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.input.focus();
assert.strictEqual(
window.getSelection().toString().trim(),
- this.value.toString(),
+ props.value.toString(),
);
});
test('entering a fractional value floors the value', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
const newValue = 3.14159;
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.input.fill(newValue);
await settled();
assert.strictEqual(Number(StepperInput.input.value), Math.floor(newValue));
- assert.ok(this.onChange.calledWith(Math.floor(newValue)));
+ assert.ok(props.onChange.calledWith(Math.floor(newValue)));
});
test('entering an invalid value reverts the value', async function (assert) {
- this.setProperties(commonProperties());
+ const props = commonProperties();
const newValue = 'NaN';
- await render(commonTemplate);
+ await renderStepperInput(props);
await StepperInput.input.fill(newValue);
await settled();
- assert.strictEqual(Number(StepperInput.input.value), this.value);
- assert.notOk(this.onChange.called);
+ assert.strictEqual(Number(StepperInput.input.value), props.value);
+ assert.notOk(props.onChange.called);
});
});
diff --git a/ui/tests/integration/components/streaming-file-test.js b/ui/tests/integration/components/streaming-file-test.gjs
similarity index 79%
rename from ui/tests/integration/components/streaming-file-test.js
rename to ui/tests/integration/components/streaming-file-test.gjs
index 54d810b6b28..37e6ab1b373 100644
--- a/ui/tests/integration/components/streaming-file-test.js
+++ b/ui/tests/integration/components/streaming-file-test.gjs
@@ -6,11 +6,11 @@
import { find, render, triggerKeyEvent, waitUntil } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import Pretender from 'pretender';
import { logEncode } from '../../../mirage/data/logs';
import Log from 'nomad-ui/utils/classes/log';
+import StreamingFile from 'nomad-ui/components/streaming-file';
const { assign } = Object;
const A_KEY = 65;
@@ -26,7 +26,7 @@ const makeLogger = (url, params) =>
url,
params,
plainText: true,
- logFetch: (url) => fetch(url).then((res) => res),
+ logFetch: (fetchUrl) => fetch(fetchUrl).then((res) => res),
});
module('Integration | Component | streaming file', function (hooks) {
@@ -43,20 +43,16 @@ module('Integration | Component | streaming file', function (hooks) {
this.server.shutdown();
});
- const commonTemplate = hbs`
-
- `;
-
test('when mode is `head`, the logger signals head', async function (assert) {
const url = '/file/endpoint';
const params = { path: 'hello/world.txt', offset: 0, limit: 50000 };
- this.setProperties({
- logger: makeLogger(url, params),
- mode: 'head',
- isStreaming: false,
- });
+ const logger = makeLogger(url, params);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
await waitUntil(
() => find('[data-test-output]')?.textContent === 'Hello World',
@@ -77,13 +73,13 @@ module('Integration | Component | streaming file', function (hooks) {
test('when mode is `tail`, the logger signals tail', async function (assert) {
const url = '/file/endpoint';
const params = { path: 'hello/world.txt', limit: 50000 };
- this.setProperties({
- logger: makeLogger(url, params),
- mode: 'tail',
- isStreaming: false,
- });
+ const logger = makeLogger(url, params);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
await waitUntil(
() => find('[data-test-output]')?.textContent === 'Hello World',
@@ -103,13 +99,17 @@ module('Integration | Component | streaming file', function (hooks) {
test('when mode is `streaming` and `isStreaming` is true, streaming starts', async function (assert) {
const url = '/file/stream';
const params = { path: 'hello/world.txt', limit: 50000 };
- this.setProperties({
- logger: makeLogger(url, params),
- mode: 'streaming',
- isStreaming: true,
- });
-
- await render(commonTemplate);
+ const logger = makeLogger(url, params);
+
+ await render(
+
+
+ ,
+ );
await waitUntil(
() => find('[data-test-output]')?.textContent === 'Hello World',
@@ -123,19 +123,17 @@ module('Integration | Component | streaming file', function (hooks) {
test('the ctrl+a/cmd+a shortcut selects only the text in the output window', async function (assert) {
const url = '/file/endpoint';
const params = { path: 'hello/world.txt', offset: 0, limit: 50000 };
- this.setProperties({
- logger: makeLogger(url, params),
- mode: 'head',
- isStreaming: false,
- });
-
- await render(hbs`
- Extra text
-
- On either side
- `);
+ const logger = makeLogger(url, params);
+
+ await render(
+
+ Extra text
+
+ On either side
+ ,
+ );
- // Windows and Linux shortcut
+ // Windows and Linux shortcut.
await triggerKeyEvent('[data-test-output]', 'keydown', A_KEY, {
ctrlKey: true,
});
@@ -146,7 +144,7 @@ module('Integration | Component | streaming file', function (hooks) {
window.getSelection().removeAllRanges();
- // MacOS shortcut
+ // MacOS shortcut.
await triggerKeyEvent('[data-test-output]', 'keydown', A_KEY, {
metaKey: true,
});
diff --git a/ui/tests/integration/components/task-group-row-test.js b/ui/tests/integration/components/task-group-row-test.gjs
similarity index 93%
rename from ui/tests/integration/components/task-group-row-test.js
rename to ui/tests/integration/components/task-group-row-test.gjs
index 3c4ea6e970e..7c0a82f9f11 100644
--- a/ui/tests/integration/components/task-group-row-test.js
+++ b/ui/tests/integration/components/task-group-row-test.gjs
@@ -6,10 +6,10 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import TaskGroupRow from 'nomad-ui/components/task-group-row';
const jobName = 'test-job';
const jobId = JSON.stringify([jobName, 'default']);
@@ -18,8 +18,6 @@ let managementToken;
let clientToken;
const makeJob = (server, props = {}) => {
- // These tests require a job with particular task groups. This requires
- // mild Mirage surgery.
server.create('namespace', {
id: 'default',
});
@@ -69,9 +67,8 @@ module('Integration | Component | task group row', function (hooks) {
window.localStorage.clear();
});
- const commonTemplate = hbs`
-
- `;
+ const renderComponent = (context) =>
+ render( );
test('Task group row conditionally shows scaling buttons based on the presence of the scaling attr on the task group', async function (assert) {
makeJob(this.server, { noActiveDeployment: true });
@@ -81,7 +78,7 @@ module('Integration | Component | task group row', function (hooks) {
const job = await this.store.find('job', jobId);
this.set('group', job.taskGroups.findBy('name', 'no-scaling'));
- await render(commonTemplate);
+ await renderComponent(this);
assert.notOk(find('[data-test-scale]'));
this.set('group', job.taskGroups.findBy('name', 'scaling'));
@@ -100,7 +97,7 @@ module('Integration | Component | task group row', function (hooks) {
const job = await this.store.find('job', jobId);
this.set('group', job.taskGroups.findBy('name', 'scaling'));
- await render(commonTemplate);
+ await renderComponent(this);
assert.strictEqual(
Number(find('[data-test-task-group-count]').textContent.trim()),
2,
@@ -142,7 +139,7 @@ module('Integration | Component | task group row', function (hooks) {
group.set('count', group.scaling.max);
this.set('group', group);
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(find('[data-test-scale="increment"]:disabled'));
await componentA11yAudit(this.element, assert);
@@ -158,7 +155,7 @@ module('Integration | Component | task group row', function (hooks) {
group.set('count', group.scaling.min);
this.set('group', group);
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(find('[data-test-scale="decrement"]:disabled'));
await componentA11yAudit(this.element, assert);
@@ -172,7 +169,7 @@ module('Integration | Component | task group row', function (hooks) {
const job = await this.store.find('job', jobId);
this.set('group', job.taskGroups.findBy('name', 'scaling'));
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(find('[data-test-scale="increment"]:disabled'));
assert.ok(find('[data-test-scale="decrement"]:disabled'));
@@ -188,7 +185,7 @@ module('Integration | Component | task group row', function (hooks) {
const job = await this.store.find('job', jobId);
this.set('group', job.taskGroups.findBy('name', 'scaling'));
- await render(commonTemplate);
+ await renderComponent(this);
assert.ok(find('[data-test-scale="increment"]:disabled'));
assert.ok(find('[data-test-scale="decrement"]:disabled'));
assert.ok(
diff --git a/ui/tests/integration/components/task-log-test.js b/ui/tests/integration/components/task-log-test.gjs
similarity index 83%
rename from ui/tests/integration/components/task-log-test.js
rename to ui/tests/integration/components/task-log-test.gjs
index cddd7b9a7f7..75844bc2e17 100644
--- a/ui/tests/integration/components/task-log-test.js
+++ b/ui/tests/integration/components/task-log-test.gjs
@@ -7,11 +7,11 @@ import { later, cancelTimers } from '@ember/runloop';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { find, click, render, settled } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import Pretender from 'pretender';
import { logEncode } from '../../../mirage/data/logs';
import { startMirage } from 'nomad-ui/tests/helpers/start-mirage';
+import TaskLog from 'nomad-ui/components/task-log';
const HOST = '1.1.1.1:1111';
const allowedConnectionTime = 100;
@@ -50,7 +50,6 @@ module.skip('Integration | Component | task log', function (hooks) {
);
});
await Promise.race([tokenPromise, timeoutPromise]);
- // ^--- TODO: noticed some flakiness in local testing; this is meant to suss it out in CI.
await settled();
const handler = ({ queryParams }) => {
@@ -93,13 +92,24 @@ module.skip('Integration | Component | task log', function (hooks) {
logMode = null;
});
+ const renderComponent = (context) =>
+ render(
+
+
+ ,
+ );
+
test('Basic appearance', async function (assert) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
assert.ok(find('[data-test-log-action="stdout"]'), 'Stdout button');
assert.ok(find('[data-test-log-action="stderr"]'), 'Stderr button');
@@ -127,9 +137,7 @@ module.skip('Integration | Component | task log', function (hooks) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
const logUrlRegex = new RegExp(
`${HOST}/v1/client/fs/logs/${commonProps.allocation.id}`,
@@ -155,13 +163,10 @@ module.skip('Integration | Component | task log', function (hooks) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
- click('[data-test-log-action="head"]');
+ await click('[data-test-log-action="head"]');
- await settled();
assert.ok(
this.server.handledRequests.find(
({ queryParams: qp }) => qp.origin === 'start' && qp.offset === '0',
@@ -180,13 +185,10 @@ module.skip('Integration | Component | task log', function (hooks) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
- click('[data-test-log-action="tail"]');
+ await click('[data-test-log-action="tail"]');
- await settled();
assert.ok(
this.server.handledRequests.find(
({ queryParams: qp }) => qp.origin === 'end',
@@ -205,12 +207,10 @@ module.skip('Integration | Component | task log', function (hooks) {
const { interval } = commonProps;
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
- later(() => {
- click('[data-test-log-action="toggle-stream"]');
+ later(async () => {
+ await click('[data-test-log-action="toggle-stream"]');
}, interval);
await settled();
@@ -220,13 +220,13 @@ module.skip('Integration | Component | task log', function (hooks) {
'First frame loaded',
);
- later(() => {
+ later(async () => {
assert.deepEqual(
find('[data-test-log-cli]').textContent,
streamFrames[0],
'Still only first frame',
);
- click('[data-test-log-action="toggle-stream"]');
+ await click('[data-test-log-action="toggle-stream"]');
later(cancelTimers, interval * 2);
}, interval * 2);
@@ -242,11 +242,9 @@ module.skip('Integration | Component | task log', function (hooks) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
- click('[data-test-log-action="stderr"]');
+ await click('[data-test-log-action="stderr"]');
later(cancelTimers, commonProps.interval);
await settled();
@@ -261,15 +259,13 @@ module.skip('Integration | Component | task log', function (hooks) {
test('Clicking stderr/stdout mode buttons does nothing when the mode remains the same', async function (assert) {
const { interval } = commonProps;
- later(() => {
- click('[data-test-log-action="stdout"]');
+ later(async () => {
+ await click('[data-test-log-action="stdout"]');
later(cancelTimers, interval * 6);
}, interval * 2);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
assert.deepEqual(
find('[data-test-log-cli]').textContent,
@@ -281,7 +277,6 @@ module.skip('Integration | Component | task log', function (hooks) {
test('When the client is inaccessible, task-log falls back to requesting logs through the server', async function (assert) {
later(cancelTimers, allowedConnectionTime * 2);
- // override client response to timeout
this.server.get(
`http://${HOST}/v1/client/fs/logs/:allocation_id`,
() => [400, {}, ''],
@@ -289,11 +284,7 @@ module.skip('Integration | Component | task log', function (hooks) {
);
this.setProperties(commonProps);
- await render(hbs` `);
+ await renderComponent(this);
const clientUrlRegex = new RegExp(
`${HOST}/v1/client/fs/logs/${commonProps.allocation.id}`,
@@ -323,7 +314,6 @@ module.skip('Integration | Component | task log', function (hooks) {
test('When both the client and the server are inaccessible, an error message is shown', async function (assert) {
later(cancelTimers, allowedConnectionTime * 5);
- // override client and server responses to timeout
this.server.get(
`http://${HOST}/v1/client/fs/logs/:allocation_id`,
() => [400, {}, ''],
@@ -336,11 +326,7 @@ module.skip('Integration | Component | task log', function (hooks) {
);
this.setProperties(commonProps);
- await render(hbs` `);
+ await renderComponent(this);
const clientUrlRegex = new RegExp(
`${HOST}/v1/client/fs/logs/${commonProps.allocation.id}`,
@@ -371,25 +357,19 @@ module.skip('Integration | Component | task log', function (hooks) {
});
test('When the client is inaccessible, the server is accessible, and stderr is pressed before the client timeout occurs, the no connection error is not shown', async function (assert) {
- // override client response to timeout
this.server.get(
`http://${HOST}/v1/client/fs/logs/:allocation_id`,
() => [400, {}, ''],
allowedConnectionTime * 2,
);
- // Click stderr before the client request responds
- later(() => {
- click('[data-test-log-action="stderr"]');
+ later(async () => {
+ await click('[data-test-log-action="stderr"]');
later(cancelTimers, commonProps.interval * 5);
}, allowedConnectionTime / 2);
this.setProperties(commonProps);
- await render(hbs` `);
+ await renderComponent(this);
const clientUrlRegex = new RegExp(
`${HOST}/v1/client/fs/logs/${commonProps.allocation.id}`,
@@ -426,9 +406,7 @@ module.skip('Integration | Component | task log', function (hooks) {
later(cancelTimers, commonProps.interval);
this.setProperties(commonProps);
- await render(
- hbs` `,
- );
+ await renderComponent(this);
assert.ok(
this.server.handledRequests.filter(
@@ -441,7 +419,7 @@ module.skip('Integration | Component | task log', function (hooks) {
).length,
);
- click('[data-test-log-action="stdout"]');
+ await click('[data-test-log-action="stdout"]');
later(cancelTimers, commonProps.interval);
await settled();
diff --git a/ui/tests/integration/components/task-sub-row-test.js b/ui/tests/integration/components/task-sub-row-test.gjs
similarity index 79%
rename from ui/tests/integration/components/task-sub-row-test.js
rename to ui/tests/integration/components/task-sub-row-test.gjs
index c605ff64683..81cfd7486fe 100644
--- a/ui/tests/integration/components/task-sub-row-test.js
+++ b/ui/tests/integration/components/task-sub-row-test.gjs
@@ -6,8 +6,8 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import TaskSubRow from 'nomad-ui/components/task-sub-row';
const mockTask = {
name: 'another-server',
@@ -55,25 +55,36 @@ const mockTask = {
module('Integration | Component | task-sub-row', function (hooks) {
setupRenderingTest(hooks);
+
test('it renders', async function (assert) {
this.set('task', mockTask);
- await render(hbs` `);
+
+ await render( );
+
assert.ok(
this.element.textContent.includes(`${mockTask.name}`),
'Task name is rendered',
);
assert.dom('.task-sub-row').doesNotHaveClass('is-active');
- await render(hbs` `);
+ await render(
+
+
+ ,
+ );
assert.dom('.task-sub-row').hasClass('is-active');
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.task-sub-row td:nth-child(1)').hasAttribute('colspan', '5');
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.task-sub-row td:nth-child(1)').hasAttribute('colspan', '9');
diff --git a/ui/tests/integration/components/toggle-test.js b/ui/tests/integration/components/toggle-test.gjs
similarity index 76%
rename from ui/tests/integration/components/toggle-test.js
rename to ui/tests/integration/components/toggle-test.gjs
index 81f93c7dd1c..1e3ad1c3d74 100644
--- a/ui/tests/integration/components/toggle-test.js
+++ b/ui/tests/integration/components/toggle-test.gjs
@@ -3,13 +3,13 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import { find, render, settled } from '@ember/test-helpers';
+import { find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import sinon from 'sinon';
import { create } from 'ember-cli-page-object';
+import ToggleComponent from 'nomad-ui/components/toggle';
import togglePageObject from 'nomad-ui/tests/pages/components/toggle';
const Toggle = create(togglePageObject());
@@ -24,19 +24,24 @@ module('Integration | Component | toggle', function (hooks) {
onToggle: sinon.spy(),
});
- const commonTemplate = hbs`
-
- {{this.label}}
-
- `;
+ const renderToggle = async (props) => {
+ await render(
+
+
+ {{props.label}}
+
+ ,
+ );
+ };
test('presents as a label with an inner checkbox and display span, and text', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+
+ await renderToggle(props);
assert.deepEqual(
Toggle.label,
@@ -62,14 +67,16 @@ module('Integration | Component | toggle', function (hooks) {
test('the isActive property dictates the active state and class', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+
+ await renderToggle(props);
assert.notOk(Toggle.isActive);
assert.notOk(Toggle.hasActiveClass);
- this.set('isActive', true);
- await settled();
+ await renderToggle({
+ ...props,
+ isActive: true,
+ });
assert.ok(Toggle.isActive);
assert.ok(Toggle.hasActiveClass);
@@ -79,14 +86,16 @@ module('Integration | Component | toggle', function (hooks) {
test('the isDisabled property dictates the disabled state and class', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+
+ await renderToggle(props);
assert.notOk(Toggle.isDisabled);
assert.notOk(Toggle.hasDisabledClass);
- this.set('isDisabled', true);
- await settled();
+ await renderToggle({
+ ...props,
+ isDisabled: true,
+ });
assert.ok(Toggle.isDisabled);
assert.ok(Toggle.hasDisabledClass);
@@ -96,8 +105,8 @@ module('Integration | Component | toggle', function (hooks) {
test('toggling the input calls the onToggle action', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+
+ await renderToggle(props);
await Toggle.toggle();
assert.deepEqual(props.onToggle.callCount, 1);
diff --git a/ui/tests/integration/components/topo-viz-test.gjs b/ui/tests/integration/components/topo-viz-test.gjs
new file mode 100644
index 00000000000..f5445336231
--- /dev/null
+++ b/ui/tests/integration/components/topo-viz-test.gjs
@@ -0,0 +1,281 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { find, render, triggerEvent } from '@ember/test-helpers';
+import { setupRenderingTest } from 'ember-qunit';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import { create } from 'ember-cli-page-object';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import sinon from 'sinon';
+import faker from 'nomad-ui/mirage/faker';
+import topoVizPageObject from 'nomad-ui/tests/pages/components/topo-viz';
+import { HOSTS } from '../../../mirage/common';
+import TopoVizComponent from 'nomad-ui/components/topo-viz';
+
+const TopoViz = create(topoVizPageObject());
+const noop = () => {};
+
+const alloc = (nodeId, jobId, taskGroupName, memory, cpu, props = {}) => ({
+ id: faker.random.uuid(),
+ taskGroupName,
+ isScheduled: true,
+ allocatedResources: {
+ cpu,
+ memory,
+ },
+ belongsTo: (type) => ({
+ id: () => (type === 'job' ? jobId : nodeId),
+ }),
+ ...props,
+});
+
+const node = (datacenter, id, memory, cpu) => ({
+ datacenter,
+ id,
+ name: `nomad@${HOSTS[Math.floor(Math.random() * 10) % HOSTS.length]}`,
+ resources: { memory, cpu },
+});
+
+module('Integration | Component | TopoViz', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ test('presents as a FlexMasonry of datacenters', async function (assert) {
+ const nodes = [
+ node('dc1', 'node0', 1000, 500),
+ node('dc2', 'node1', 1000, 500),
+ ];
+
+ const allocations = [
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ ];
+
+ await render(
+
+
+ ,
+ );
+
+ assert.deepEqual(TopoViz.datacenters.length, 2);
+ assert.deepEqual(TopoViz.datacenters[0].nodes.length, 1);
+ assert.deepEqual(TopoViz.datacenters[1].nodes.length, 1);
+ assert.deepEqual(TopoViz.datacenters[0].nodes[0].memoryRects.length, 2);
+ assert.deepEqual(TopoViz.datacenters[1].nodes[0].memoryRects.length, 1);
+
+ await componentA11yAudit(find('[data-test-topo-viz]'), assert);
+ });
+
+ test('clicking on a node in a deeply nested TopoViz::Node will toggle node selection and call @onNodeSelect', async function (assert) {
+ // TopoViz must be dense for node selection to be a feature
+ const nodes = Array(55)
+ .fill(null)
+ .map((_, index) => node('dc1', `node${index}`, 1000, 500));
+ const allocations = [];
+ const onNodeSelect = sinon.spy();
+
+ await render(
+
+
+ ,
+ );
+
+ await TopoViz.datacenters[0].nodes[0].selectNode();
+ assert.ok(onNodeSelect.calledOnce);
+ assert.deepEqual(onNodeSelect.getCall(0).args[0].node, nodes[0]);
+
+ await TopoViz.datacenters[0].nodes[0].selectNode();
+ assert.ok(onNodeSelect.calledTwice);
+ assert.deepEqual(onNodeSelect.getCall(1).args[0], null);
+ });
+
+ test('clicking on an allocation in a deeply nested TopoViz::Node will update the topology object with selections and call @onAllocationSelect and @onNodeSelect', async function (assert) {
+ const nodes = [node('dc1', 'node0', 1000, 500)];
+ const allocations = [alloc('node0', 'job1', 'group', 100, 100)];
+ const onNodeSelect = sinon.spy();
+ const onAllocationSelect = sinon.spy();
+
+ await render(
+
+
+ ,
+ );
+
+ await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
+ assert.ok(onAllocationSelect.calledOnce);
+ assert.deepEqual(onAllocationSelect.getCall(0).args[0], allocations[0]);
+ assert.ok(onNodeSelect.calledOnce);
+
+ await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
+ assert.ok(onAllocationSelect.calledTwice);
+ assert.deepEqual(onAllocationSelect.getCall(1).args[0], null);
+ assert.ok(onNodeSelect.calledTwice);
+ assert.ok(onNodeSelect.alwaysCalledWith(null));
+ });
+
+ test('clicking on an allocation in a deeply nested TopoViz::Node will associate sibling allocations with curves', async function (assert) {
+ const nodes = [
+ node('dc1', 'node0', 1000, 500),
+ node('dc1', 'node1', 1000, 500),
+ node('dc1', 'node2', 1000, 500),
+ node('dc2', 'node3', 1000, 500),
+ node('dc2', 'node4', 1000, 500),
+ node('dc2', 'node5', 1000, 500),
+ ];
+ const allocations = [
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node2', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'groupTwo', 100, 100),
+ alloc('node1', 'job2', 'group', 100, 100),
+ alloc('node2', 'job2', 'groupTwo', 100, 100),
+ ];
+ const onNodeSelect = sinon.spy();
+ const onAllocationSelect = sinon.spy();
+
+ const selectedAllocations = allocations.filter(
+ (allocEntry) =>
+ allocEntry.belongsTo('job').id() === 'job1' &&
+ allocEntry.taskGroupName === 'group',
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.notOk(TopoViz.allocationAssociationsArePresent);
+
+ await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
+
+ assert.ok(TopoViz.allocationAssociationsArePresent);
+ assert.deepEqual(
+ TopoViz.allocationAssociations.length,
+ selectedAllocations.length * 2,
+ );
+
+ // Lines get redrawn when the window resizes; make sure the lines persist.
+ await triggerEvent(window, 'resize');
+ assert.deepEqual(
+ TopoViz.allocationAssociations.length,
+ selectedAllocations.length * 2,
+ );
+
+ await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
+ assert.notOk(TopoViz.allocationAssociationsArePresent);
+ });
+
+ test('when the count of sibling allocations is high enough relative to the node count, curves are not rendered', async function (assert) {
+ const nodes = [
+ node('dc1', 'node0', 1000, 500),
+ node('dc1', 'node1', 1000, 500),
+ ];
+ const allocations = [
+ // There need to be at least 10 sibling allocations to trigger this behavior
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'groupTwo', 100, 100),
+ ];
+ const onNodeSelect = sinon.spy();
+ const onAllocationSelect = sinon.spy();
+
+ await render(
+
+
+ ,
+ );
+ assert.notOk(TopoViz.allocationAssociationsArePresent);
+
+ await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
+ assert.deepEqual(TopoViz.allocationAssociations.length, 0);
+
+ // Lines get redrawn when the window resizes; make sure that doesn't make the lines show up again
+ await triggerEvent(window, 'resize');
+ assert.deepEqual(TopoViz.allocationAssociations.length, 0);
+ });
+
+ test('when one or more nodes are missing the resources property, those nodes are filtered out of the topology view and onDataError is called', async function (assert) {
+ const badNode = node('dc1', 'node0', 1000, 500);
+ delete badNode.resources;
+
+ const nodes = [badNode, node('dc1', 'node1', 1000, 500)];
+ const allocations = [
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node1', 'job1', 'group', 100, 100),
+ alloc('node0', 'job1', 'groupTwo', 100, 100),
+ ];
+ const onNodeSelect = sinon.spy();
+ const onAllocationSelect = sinon.spy();
+ const onDataError = sinon.spy();
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(onDataError.calledOnce);
+ assert.deepEqual(onDataError.getCall(0).args[0], [
+ {
+ type: 'filtered-nodes',
+ context: [nodes[0]],
+ },
+ ]);
+
+ assert.deepEqual(TopoViz.datacenters[0].nodes.length, 1);
+ });
+});
diff --git a/ui/tests/integration/components/topo-viz-test.js b/ui/tests/integration/components/topo-viz-test.js
deleted file mode 100644
index 23fb7a1a223..00000000000
--- a/ui/tests/integration/components/topo-viz-test.js
+++ /dev/null
@@ -1,238 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { module, test } from 'qunit';
-import { render, triggerEvent } from '@ember/test-helpers';
-import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-import { create } from 'ember-cli-page-object';
-import { setupMirage } from 'ember-cli-mirage/test-support';
-import sinon from 'sinon';
-import faker from 'nomad-ui/mirage/faker';
-import topoVizPageObject from 'nomad-ui/tests/pages/components/topo-viz';
-import { HOSTS } from '../../../mirage/common';
-
-const TopoViz = create(topoVizPageObject());
-
-const alloc = (nodeId, jobId, taskGroupName, memory, cpu, props = {}) => ({
- id: faker.random.uuid(),
- taskGroupName,
- isScheduled: true,
- allocatedResources: {
- cpu,
- memory,
- },
- belongsTo: (type) => ({
- id: () => (type === 'job' ? jobId : nodeId),
- }),
- ...props,
-});
-
-const node = (datacenter, id, memory, cpu) => ({
- datacenter,
- id,
- name: `nomad@${HOSTS[Math.floor(Math.random() * 10) % HOSTS.length]}`,
- resources: { memory, cpu },
-});
-
-module('Integration | Component | TopoViz', function (hooks) {
- setupRenderingTest(hooks);
- setupMirage(hooks);
-
- const commonTemplate = hbs`
-
- `;
-
- test('presents as a FlexMasonry of datacenters', async function (assert) {
- this.setProperties({
- nodes: [node('dc1', 'node0', 1000, 500), node('dc2', 'node1', 1000, 500)],
-
- allocations: [
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- ],
- });
-
- await render(commonTemplate);
-
- assert.deepEqual(TopoViz.datacenters.length, 2);
- assert.deepEqual(TopoViz.datacenters[0].nodes.length, 1);
- assert.deepEqual(TopoViz.datacenters[1].nodes.length, 1);
- assert.deepEqual(TopoViz.datacenters[0].nodes[0].memoryRects.length, 2);
- assert.deepEqual(TopoViz.datacenters[1].nodes[0].memoryRects.length, 1);
-
- await componentA11yAudit(this.element, assert);
- });
-
- test('clicking on a node in a deeply nested TopoViz::Node will toggle node selection and call @onNodeSelect', async function (assert) {
- this.setProperties({
- // TopoViz must be dense for node selection to be a feature
- nodes: Array(55)
- .fill(null)
- .map((_, index) => node('dc1', `node${index}`, 1000, 500)),
- allocations: [],
- onNodeSelect: sinon.spy(),
- });
-
- await render(commonTemplate);
-
- await TopoViz.datacenters[0].nodes[0].selectNode();
- assert.ok(this.onNodeSelect.calledOnce);
- assert.deepEqual(this.onNodeSelect.getCall(0).args[0].node, this.nodes[0]);
-
- await TopoViz.datacenters[0].nodes[0].selectNode();
- assert.ok(this.onNodeSelect.calledTwice);
- assert.deepEqual(this.onNodeSelect.getCall(1).args[0], null);
- });
-
- test('clicking on an allocation in a deeply nested TopoViz::Node will update the topology object with selections and call @onAllocationSelect and @onNodeSelect', async function (assert) {
- this.setProperties({
- nodes: [node('dc1', 'node0', 1000, 500)],
- allocations: [alloc('node0', 'job1', 'group', 100, 100)],
- onNodeSelect: sinon.spy(),
- onAllocationSelect: sinon.spy(),
- });
-
- await render(commonTemplate);
-
- await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
- assert.ok(this.onAllocationSelect.calledOnce);
- assert.deepEqual(
- this.onAllocationSelect.getCall(0).args[0],
- this.allocations[0],
- );
- assert.ok(this.onNodeSelect.calledOnce);
-
- await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
- assert.ok(this.onAllocationSelect.calledTwice);
- assert.deepEqual(this.onAllocationSelect.getCall(1).args[0], null);
- assert.ok(this.onNodeSelect.calledTwice);
- assert.ok(this.onNodeSelect.alwaysCalledWith(null));
- });
-
- test('clicking on an allocation in a deeply nested TopoViz::Node will associate sibling allocations with curves', async function (assert) {
- this.setProperties({
- nodes: [
- node('dc1', 'node0', 1000, 500),
- node('dc1', 'node1', 1000, 500),
- node('dc1', 'node2', 1000, 500),
- node('dc2', 'node3', 1000, 500),
- node('dc2', 'node4', 1000, 500),
- node('dc2', 'node5', 1000, 500),
- ],
- allocations: [
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node2', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'groupTwo', 100, 100),
- alloc('node1', 'job2', 'group', 100, 100),
- alloc('node2', 'job2', 'groupTwo', 100, 100),
- ],
- onNodeSelect: sinon.spy(),
- onAllocationSelect: sinon.spy(),
- });
-
- const selectedAllocations = this.allocations.filter(
- (alloc) =>
- alloc.belongsTo('job').id() === 'job1' &&
- alloc.taskGroupName === 'group',
- );
-
- await render(commonTemplate);
-
- assert.notOk(TopoViz.allocationAssociationsArePresent);
-
- await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
-
- assert.ok(TopoViz.allocationAssociationsArePresent);
- assert.deepEqual(
- TopoViz.allocationAssociations.length,
- selectedAllocations.length * 2,
- );
-
- // Lines get redrawn when the window resizes; make sure the lines persist.
- await triggerEvent(window, 'resize');
- assert.deepEqual(
- TopoViz.allocationAssociations.length,
- selectedAllocations.length * 2,
- );
-
- await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
- assert.notOk(TopoViz.allocationAssociationsArePresent);
- });
-
- test('when the count of sibling allocations is high enough relative to the node count, curves are not rendered', async function (assert) {
- this.setProperties({
- nodes: [node('dc1', 'node0', 1000, 500), node('dc1', 'node1', 1000, 500)],
- allocations: [
- // There need to be at least 10 sibling allocations to trigger this behavior
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'groupTwo', 100, 100),
- ],
- onNodeSelect: sinon.spy(),
- onAllocationSelect: sinon.spy(),
- });
-
- await render(commonTemplate);
- assert.notOk(TopoViz.allocationAssociationsArePresent);
-
- await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
- assert.deepEqual(TopoViz.allocationAssociations.length, 0);
-
- // Lines get redrawn when the window resizes; make sure that doesn't make the lines show up again
- await triggerEvent(window, 'resize');
- assert.deepEqual(TopoViz.allocationAssociations.length, 0);
- });
-
- test('when one or more nodes are missing the resources property, those nodes are filtered out of the topology view and onDataError is called', async function (assert) {
- const badNode = node('dc1', 'node0', 1000, 500);
- delete badNode.resources;
-
- this.setProperties({
- nodes: [badNode, node('dc1', 'node1', 1000, 500)],
- allocations: [
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node1', 'job1', 'group', 100, 100),
- alloc('node0', 'job1', 'groupTwo', 100, 100),
- ],
- onNodeSelect: sinon.spy(),
- onAllocationSelect: sinon.spy(),
- onDataError: sinon.spy(),
- });
-
- await render(commonTemplate);
-
- assert.ok(this.onDataError.calledOnce);
- assert.deepEqual(this.onDataError.getCall(0).args[0], [
- {
- type: 'filtered-nodes',
- context: [this.nodes[0]],
- },
- ]);
-
- assert.deepEqual(TopoViz.datacenters[0].nodes.length, 1);
- });
-});
diff --git a/ui/tests/integration/components/topo-viz/datacenter-test.js b/ui/tests/integration/components/topo-viz/datacenter-test.gjs
similarity index 75%
rename from ui/tests/integration/components/topo-viz/datacenter-test.js
rename to ui/tests/integration/components/topo-viz/datacenter-test.gjs
index 1084628f192..7125c2618ea 100644
--- a/ui/tests/integration/components/topo-viz/datacenter-test.js
+++ b/ui/tests/integration/components/topo-viz/datacenter-test.gjs
@@ -6,11 +6,11 @@
import { find, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
+import TopoVizDatacenterComponent from 'nomad-ui/components/topo-viz/datacenter';
import faker from 'nomad-ui/mirage/faker';
import topoVizDatacenterPageObject from 'nomad-ui/tests/pages/components/topo-viz/datacenter';
import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
@@ -34,7 +34,6 @@ const nodeGen = (name, datacenter, memory, cpu, allocations = []) => ({
})),
});
-// Used in Array#reduce to sum by a property common to an array of objects
const sumBy = (prop) => (sum, obj) => (sum += obj[prop]);
module('Integration | Component | TopoViz::Datacenter', function (hooks) {
@@ -50,16 +49,6 @@ module('Integration | Component | TopoViz::Datacenter', function (hooks) {
...props,
});
- const commonTemplate = hbs`
-
- `;
-
test('presents as a div with a label and a FlexMasonry with a collection of nodes', async function (assert) {
this.setProperties(
commonProps({
@@ -70,7 +59,18 @@ module('Integration | Component | TopoViz::Datacenter', function (hooks) {
}),
);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.ok(TopoVizDatacenter.isPresent);
assert.deepEqual(
@@ -102,7 +102,18 @@ module('Integration | Component | TopoViz::Datacenter', function (hooks) {
}),
);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
const allocs = this.datacenter.nodes.reduce(
(allocs, node) => allocs.concat(node.allocations),
@@ -147,7 +158,18 @@ module('Integration | Component | TopoViz::Datacenter', function (hooks) {
}),
);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
assert.ok(find('[data-test-flex-masonry].flex-masonry-columns-1'));
@@ -173,7 +195,18 @@ module('Integration | Component | TopoViz::Datacenter', function (hooks) {
}),
);
- await render(commonTemplate);
+ await render(
+
+
+ ,
+ );
TopoVizDatacenter.nodes[0].as(async (TopoVizNode) => {
assert.notOk(TopoVizNode.labelIsPresent);
diff --git a/ui/tests/integration/components/topo-viz/node-test.gjs b/ui/tests/integration/components/topo-viz/node-test.gjs
new file mode 100644
index 00000000000..762a3b6053c
--- /dev/null
+++ b/ui/tests/integration/components/topo-viz/node-test.gjs
@@ -0,0 +1,332 @@
+/**
+ * Copyright IBM Corp. 2015, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { render } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { create } from 'ember-cli-page-object';
+import sinon from 'sinon';
+import TopoVizNodeComponent from 'nomad-ui/components/topo-viz/node';
+import faker from 'nomad-ui/mirage/faker';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import topoVizNodePageObject from 'nomad-ui/tests/pages/components/topo-viz/node';
+import {
+ formatScheduledBytes,
+ formatScheduledHertz,
+} from 'nomad-ui/utils/units';
+
+const TopoVizNode = create(topoVizNodePageObject());
+
+const nodeGen = (name, datacenter, memory, cpu, flags = {}) => ({
+ datacenter,
+ memory,
+ cpu,
+ isSelected: !!flags.isSelected,
+ node: {
+ name,
+ isEligible: flags.isEligible || flags.isEligible == null,
+ isDraining: !!flags.isDraining,
+ },
+});
+
+const allocGen = (node, memory, cpu, isScheduled = true) => ({
+ memory,
+ cpu,
+ isSelected: false,
+ memoryPercent: memory / node.memory,
+ cpuPercent: cpu / node.cpu,
+ allocation: {
+ id: faker.random.uuid(),
+ isScheduled,
+ },
+});
+
+const props = (overrides) => ({
+ isDense: false,
+ heightScale: () => 50,
+ onAllocationSelect: sinon.spy(),
+ onNodeSelect: sinon.spy(),
+ onAllocationFocus: sinon.spy(),
+ onAllocationBlur: sinon.spy(),
+ ...overrides,
+});
+
+module('Integration | Component | TopoViz::Node', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ test('presents as a div with a label and an svg with CPU and memory rows', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000);
+ this.setProperties(
+ props({
+ node: {
+ ...node,
+ allocations: [
+ allocGen(node, 100, 100),
+ allocGen(node, 250, 250),
+ allocGen(node, 300, 300, false),
+ ],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(TopoVizNode.isPresent);
+ assert.deepEqual(
+ TopoVizNode.memoryRects.length,
+ this.node.allocations.filterBy('allocation.isScheduled').length,
+ );
+ assert.ok(TopoVizNode.cpuRects.length);
+
+ await componentA11yAudit(this.element, assert);
+ });
+
+ test('the label contains aggregate information about the node', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000);
+ this.setProperties(
+ props({
+ node: {
+ ...node,
+ allocations: [
+ allocGen(node, 100, 100),
+ allocGen(node, 250, 250),
+ allocGen(node, 300, 300, false),
+ ],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(TopoVizNode.label.includes(node.node.name));
+ assert.ok(
+ TopoVizNode.label.includes(
+ `${this.node.allocations.filterBy('allocation.isScheduled').length} Allocs`,
+ ),
+ );
+ assert.ok(
+ TopoVizNode.label.includes(
+ `${formatScheduledBytes(this.node.memory, 'MiB')}`,
+ ),
+ );
+ assert.ok(
+ TopoVizNode.label.includes(
+ `${formatScheduledHertz(this.node.cpu, 'MHz')}`,
+ ),
+ );
+ });
+
+ test('the status icon indicates when the node is draining', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000, { isDraining: true });
+ this.setProperties(
+ props({
+ node: {
+ ...node,
+ allocations: [],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(TopoVizNode.statusIcon.includes('clock'));
+ assert.deepEqual(TopoVizNode.statusIconLabel, 'Client is draining');
+ });
+
+ test('the status icon indicates when the node is ineligible for scheduling', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000, { isEligible: false });
+ this.setProperties(
+ props({
+ node: {
+ ...node,
+ allocations: [],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(TopoVizNode.statusIcon.includes('lock'));
+ assert.deepEqual(TopoVizNode.statusIconLabel, 'Client is ineligible');
+ });
+
+ test('when isDense is false, clicking the node does nothing', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000);
+ this.setProperties(
+ props({
+ isDense: false,
+ node: {
+ ...node,
+ allocations: [],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+ await TopoVizNode.selectNode();
+
+ assert.notOk(TopoVizNode.nodeIsInteractive);
+ assert.notOk(this.onNodeSelect.called);
+ });
+
+ test('when isDense is true, clicking the node calls onNodeSelect', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000);
+ this.setProperties(
+ props({
+ isDense: true,
+ node: {
+ ...node,
+ allocations: [],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+ await TopoVizNode.selectNode();
+
+ assert.ok(TopoVizNode.nodeIsInteractive);
+ assert.ok(this.onNodeSelect.called);
+ assert.ok(this.onNodeSelect.calledWith(this.node));
+ });
+
+ test('the node gets the is-selected class when the node is selected', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000, { isSelected: true });
+ this.setProperties(
+ props({
+ isDense: true,
+ node: {
+ ...node,
+ allocations: [],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(TopoVizNode.nodeIsSelected);
+ });
+
+ test('the node gets its height form the @heightScale arg', async function (assert) {
+ const node = nodeGen('Node One', 'dc1', 1000, 1000);
+ const height = 50;
+ const heightSpy = sinon.spy();
+ this.setProperties(
+ props({
+ heightScale: (...args) => {
+ heightSpy(...args);
+ return height;
+ },
+ node: {
+ ...node,
+ allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
+ },
+ }),
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ assert.ok(heightSpy.calledWith(this.node.memory));
+ assert.dom('[data-test-memory-rect]').exists();
+ assert.strictEqual(TopoVizNode.memoryRects[0].height, `${height}px`);
+ });
+});
diff --git a/ui/tests/integration/components/topo-viz/node-test.js b/ui/tests/integration/components/topo-viz/node-test.js
deleted file mode 100644
index bbd6bc36456..00000000000
--- a/ui/tests/integration/components/topo-viz/node-test.js
+++ /dev/null
@@ -1,443 +0,0 @@
-/**
- * Copyright IBM Corp. 2015, 2025
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { findAll, render } from '@ember/test-helpers';
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
-import { create } from 'ember-cli-page-object';
-import sinon from 'sinon';
-import faker from 'nomad-ui/mirage/faker';
-import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-import { setupMirage } from 'ember-cli-mirage/test-support';
-import topoVisNodePageObject from 'nomad-ui/tests/pages/components/topo-viz/node';
-import {
- formatScheduledBytes,
- formatScheduledHertz,
-} from 'nomad-ui/utils/units';
-
-const TopoVizNode = create(topoVisNodePageObject());
-
-const nodeGen = (name, datacenter, memory, cpu, flags = {}) => ({
- datacenter,
- memory,
- cpu,
- isSelected: !!flags.isSelected,
- node: {
- name,
- isEligible: flags.isEligible || flags.isEligible == null,
- isDraining: !!flags.isDraining,
- },
-});
-
-const allocGen = (node, memory, cpu, isScheduled = true) => ({
- memory,
- cpu,
- isSelected: false,
- memoryPercent: memory / node.memory,
- cpuPercent: cpu / node.cpu,
- allocation: {
- id: faker.random.uuid(),
- isScheduled,
- },
-});
-
-const props = (overrides) => ({
- isDense: false,
- heightScale: () => 50,
- onAllocationSelect: sinon.spy(),
- onNodeSelect: sinon.spy(),
- onAllocationFocus: sinon.spy(),
- onAllocationBlur: sinon.spy(),
- ...overrides,
-});
-
-module('Integration | Component | TopoViz::Node', function (hooks) {
- setupRenderingTest(hooks);
- setupMirage(hooks);
-
- const commonTemplate = hbs`
-
- `;
-
- test('presents as a div with a label and an svg with CPU and memory rows', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [
- allocGen(node, 100, 100),
- allocGen(node, 250, 250),
- allocGen(node, 300, 300, false),
- ],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(TopoVizNode.isPresent);
- assert.deepEqual(
- TopoVizNode.memoryRects.length,
- this.node.allocations.filterBy('allocation.isScheduled').length,
- );
- assert.ok(TopoVizNode.cpuRects.length);
-
- await componentA11yAudit(this.element, assert);
- });
-
- test('the label contains aggregate information about the node', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [
- allocGen(node, 100, 100),
- allocGen(node, 250, 250),
- allocGen(node, 300, 300, false),
- ],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(TopoVizNode.label.includes(node.node.name));
- assert.ok(
- TopoVizNode.label.includes(
- `${
- this.node.allocations.filterBy('allocation.isScheduled').length
- } Allocs`,
- ),
- );
- assert.ok(
- TopoVizNode.label.includes(
- `${formatScheduledBytes(this.node.memory, 'MiB')}`,
- ),
- );
- assert.ok(
- TopoVizNode.label.includes(
- `${formatScheduledHertz(this.node.cpu, 'MHz')}`,
- ),
- );
- });
-
- test('the status icon indicates when the node is draining', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000, { isDraining: true });
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(TopoVizNode.statusIcon.includes('clock'));
- assert.deepEqual(TopoVizNode.statusIconLabel, 'Client is draining');
- });
-
- test('the status icon indicates when the node is ineligible for scheduling', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000, { isEligible: false });
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(TopoVizNode.statusIcon.includes('lock'));
- assert.deepEqual(TopoVizNode.statusIconLabel, 'Client is ineligible');
- });
-
- test('when isDense is false, clicking the node does nothing', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- isDense: false,
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
- await TopoVizNode.selectNode();
-
- assert.notOk(TopoVizNode.nodeIsInteractive);
- assert.notOk(this.onNodeSelect.called);
- });
-
- test('when isDense is true, clicking the node calls onNodeSelect', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- isDense: true,
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
- await TopoVizNode.selectNode();
-
- assert.ok(TopoVizNode.nodeIsInteractive);
- assert.ok(this.onNodeSelect.called);
- assert.ok(this.onNodeSelect.calledWith(this.node));
- });
-
- test('the node gets the is-selected class when the node is selected', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000, { isSelected: true });
- this.setProperties(
- props({
- isDense: true,
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(TopoVizNode.nodeIsSelected);
- });
-
- test('the node gets its height form the @heightScale arg', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- const height = 50;
- const heightSpy = sinon.spy();
- this.setProperties(
- props({
- heightScale: (...args) => {
- heightSpy(...args);
- return height;
- },
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.ok(heightSpy.called);
- assert.ok(heightSpy.calledWith(this.node.memory));
- assert.deepEqual(TopoVizNode.memoryRects[0].height, `${height}px`);
- });
-
- test('each allocation gets a memory rect and a cpu rect', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(commonTemplate);
-
- assert.deepEqual(
- TopoVizNode.memoryRects.length,
- this.node.allocations.length,
- );
- assert.deepEqual(TopoVizNode.cpuRects.length, this.node.allocations.length);
- });
-
- test('each allocation is sized according to its percentage of utilization', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(hbs`
-
-
-
- `);
-
- // Remove the width of the padding and the label from the SVG width
- const width = 100 - 5 - 5 - 20;
- this.node.allocations.forEach((alloc, index) => {
- const memWidth = alloc.memoryPercent * width - (index === 0 ? 0.5 : 1);
- const cpuWidth = alloc.cpuPercent * width - (index === 0 ? 0.5 : 1);
- assert.deepEqual(TopoVizNode.memoryRects[index].width, `${memWidth}px`);
- assert.deepEqual(TopoVizNode.cpuRects[index].width, `${cpuWidth}px`);
- });
- });
-
- test('clicking either the memory or cpu rect for an allocation will call onAllocationSelect', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(commonTemplate);
-
- await TopoVizNode.memoryRects[0].select();
- assert.deepEqual(this.onAllocationSelect.callCount, 1);
- assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[0]));
-
- await TopoVizNode.cpuRects[0].select();
- assert.deepEqual(this.onAllocationSelect.callCount, 2);
-
- await TopoVizNode.cpuRects[1].select();
- assert.deepEqual(this.onAllocationSelect.callCount, 3);
- assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[1]));
-
- await TopoVizNode.memoryRects[1].select();
- assert.deepEqual(this.onAllocationSelect.callCount, 4);
- });
-
- test('hovering over a memory or cpu rect for an allocation will call onAllocationFocus', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(commonTemplate);
-
- await TopoVizNode.memoryRects[0].hover();
- assert.deepEqual(this.onAllocationFocus.callCount, 1);
- assert.deepEqual(
- this.onAllocationFocus.getCall(0).args[0].allocation,
- this.node.allocations[0].allocation,
- );
- assert.deepEqual(
- this.onAllocationFocus.getCall(0).args[1],
- findAll('[data-test-memory-rect]')[0],
- );
-
- await TopoVizNode.cpuRects[1].hover();
- assert.deepEqual(this.onAllocationFocus.callCount, 2);
- assert.deepEqual(
- this.onAllocationFocus.getCall(1).args[0].allocation,
- this.node.allocations[1].allocation,
- );
- assert.deepEqual(
- this.onAllocationFocus.getCall(1).args[1],
- findAll('[data-test-cpu-rect]')[1],
- );
- });
-
- test('leaving the entire node will call onAllocationBlur, which allows for the tooltip transitions', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
- },
- }),
- );
-
- await render(commonTemplate);
-
- await TopoVizNode.memoryRects[0].hover();
- assert.deepEqual(this.onAllocationFocus.callCount, 1);
- assert.deepEqual(this.onAllocationBlur.callCount, 0);
-
- await TopoVizNode.memoryRects[0].mouseleave();
- assert.deepEqual(this.onAllocationBlur.callCount, 0);
-
- await TopoVizNode.mouseout();
- assert.deepEqual(this.onAllocationBlur.callCount, 1);
- });
-
- test('allocations are sorted by smallest to largest delta of memory to cpu percent utilizations', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
-
- const evenAlloc = allocGen(node, 100, 100);
- const mediumMemoryAlloc = allocGen(node, 200, 150);
- const largeMemoryAlloc = allocGen(node, 300, 50);
- const mediumCPUAlloc = allocGen(node, 150, 200);
- const largeCPUAlloc = allocGen(node, 50, 300);
-
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [
- largeCPUAlloc,
- mediumCPUAlloc,
- evenAlloc,
- mediumMemoryAlloc,
- largeMemoryAlloc,
- ],
- },
- }),
- );
-
- await render(commonTemplate);
-
- const expectedOrder = [
- evenAlloc,
- mediumCPUAlloc,
- mediumMemoryAlloc,
- largeCPUAlloc,
- largeMemoryAlloc,
- ];
- expectedOrder.forEach((alloc, index) => {
- assert.deepEqual(TopoVizNode.memoryRects[index].id, alloc.allocation.id);
- assert.deepEqual(TopoVizNode.cpuRects[index].id, alloc.allocation.id);
- });
- });
-
- test('when there are no allocations, a "no allocations" note is shown', async function (assert) {
- const node = nodeGen('Node One', 'dc1', 1000, 1000);
- this.setProperties(
- props({
- node: {
- ...node,
- allocations: [],
- },
- }),
- );
-
- await render(commonTemplate);
- assert.deepEqual(TopoVizNode.emptyMessage, 'Empty Client');
- });
-});
diff --git a/ui/tests/integration/components/trigger-test.js b/ui/tests/integration/components/trigger-test.gjs
similarity index 57%
rename from ui/tests/integration/components/trigger-test.js
rename to ui/tests/integration/components/trigger-test.gjs
index c91e4a730bf..ae2d1c9a176 100644
--- a/ui/tests/integration/components/trigger-test.js
+++ b/ui/tests/integration/components/trigger-test.gjs
@@ -6,21 +6,37 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, waitFor } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import Trigger from 'nomad-ui/components/trigger';
+
+class State {
+ @tracked name = 'Tomster';
+}
module('Integration | Component | trigger', function (hooks) {
setupRenderingTest(hooks);
module('Synchronous Interactions', function () {
test('it can trigger a synchronous action', async function (assert) {
- this.set('name', 'Tomster');
- this.set('changeName', () => this.set('name', 'Zoey'));
- await render(hbs`
-
- {{this.name}}
-
-
- `);
+ const state = new State();
+ const changeName = () => {
+ state.name = 'Zoey';
+ };
+
+ await render(
+
+
+ {{state.name}}
+
+
+ ,
+ );
+
assert
.dom('[data-test-name]')
.hasText('Tomster', 'Initial state renders correctly.');
@@ -36,15 +52,23 @@ module('Integration | Component | trigger', function (hooks) {
});
test('it sets the result of the action', async function (assert) {
- this.set('tomster', () => 'Tomster');
- await render(hbs`
-
- {{#if trigger.data.result}}
- {{trigger.data.result}}
- {{/if}}
-
-
- `);
+ const tomster = () => 'Tomster';
+
+ await render(
+
+
+ {{#if trigger.data.result}}
+ {{trigger.data.result}}
+ {{/if}}
+
+
+ ,
+ );
+
assert
.dom('[data-test-name]')
.doesNotExist(
@@ -64,25 +88,29 @@ module('Integration | Component | trigger', function (hooks) {
module('Asynchronous Interactions', function () {
test('it can trigger an asynchronous action', async function (assert) {
- this.set(
- 'onTrigger',
- () =>
- new Promise((resolve) => {
- this.set('resolve', resolve);
- }),
- );
+ let resolve;
+ const onTrigger = () =>
+ new Promise((res) => {
+ resolve = res;
+ });
- await render(hbs`
-
- {{#if trigger.data.isBusy}}
- ...Loading
- {{/if}}
- {{#if trigger.data.isSuccess}}
- Success!
- {{/if}}
-
-
- `);
+ await render(
+
+
+ {{#if trigger.data.isBusy}}
+ ...Loading
+ {{/if}}
+ {{#if trigger.data.isSuccess}}
+ Success!
+ {{/if}}
+
+
+ ,
+ );
assert
.dom('[data-test-div]')
@@ -102,7 +130,7 @@ module('Integration | Component | trigger', function (hooks) {
'Success message does not display until after promise resolves',
);
- this.resolve();
+ resolve();
await waitFor('[data-test-div]');
assert
.dom('[data-test-div-loading]')
@@ -127,7 +155,7 @@ module('Integration | Component | trigger', function (hooks) {
'After clicking the button, again, we are back in the loading state',
);
- this.resolve();
+ resolve();
await waitFor('[data-test-div]');
assert
@@ -138,23 +166,27 @@ module('Integration | Component | trigger', function (hooks) {
});
test('it handles the success state', async function (assert) {
- this.set(
- 'onTrigger',
- () =>
- new Promise((resolve) => {
- this.set('resolve', resolve);
- }),
- );
- this.set('onSuccess', () => assert.step('On success happened'));
+ let resolve;
+ const onTrigger = () =>
+ new Promise((res) => {
+ resolve = res;
+ });
+ const onSuccess = () => assert.step('On success happened');
- await render(hbs`
-
- {{#if trigger.data.isSuccess}}
- Success!
- {{/if}}
-
-
- `);
+ await render(
+
+
+ {{#if trigger.data.isSuccess}}
+ Success!
+ {{/if}}
+
+
+ ,
+ );
assert
.dom('[data-test-div]')
@@ -162,34 +194,38 @@ module('Integration | Component | trigger', function (hooks) {
'No text should appear until after the onSuccess callback is fired',
);
await click('[data-test-button]');
- this.resolve();
+ resolve();
await waitFor('[data-test-div]');
assert.verifySteps(['On success happened']);
});
test('it handles the error state', async function (assert) {
- this.set(
- 'onTrigger',
- () =>
- new Promise((_, reject) => {
- this.set('reject', reject);
- }),
- );
- this.set('onError', () => {
+ let reject;
+ const onTrigger = () =>
+ new Promise((_, rej) => {
+ reject = rej;
+ });
+ const onError = () => {
assert.step('On error happened');
- });
-
- await render(hbs`
-
- {{#if trigger.data.isBusy}}
- ...Loading
- {{/if}}
- {{#if trigger.data.isError}}
- Error!
- {{/if}}
-
-
- `);
+ };
+
+ await render(
+
+
+ {{#if trigger.data.isBusy}}
+ ...Loading
+ {{/if}}
+ {{#if trigger.data.isError}}
+ Error!
+ {{/if}}
+
+
+ ,
+ );
await click('[data-test-button]');
assert
@@ -204,7 +240,7 @@ module('Integration | Component | trigger', function (hooks) {
'No text should appear until after the onError callback is fired',
);
- this.reject();
+ reject();
await waitFor('[data-test-span]');
assert.verifySteps(['On error happened']);
@@ -218,7 +254,7 @@ module('Integration | Component | trigger', function (hooks) {
assert.dom('[data-test-div]').doesNotExist('The error state is cleared');
- this.reject();
+ reject();
await waitFor('[data-test-span]');
assert.verifySteps(['On error happened'], 'The error dispatches');
});
diff --git a/ui/tests/integration/components/two-step-button-test.js b/ui/tests/integration/components/two-step-button-test.gjs
similarity index 80%
rename from ui/tests/integration/components/two-step-button-test.js
rename to ui/tests/integration/components/two-step-button-test.gjs
index 8a374831298..c9c913b9dad 100644
--- a/ui/tests/integration/components/two-step-button-test.js
+++ b/ui/tests/integration/components/two-step-button-test.gjs
@@ -6,10 +6,10 @@
import { find, click, render } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import sinon from 'sinon';
import { create } from 'ember-cli-page-object';
+import TwoStepButtonComponent from 'nomad-ui/components/two-step-button';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
const TwoStepButton = create(twoStepButton());
@@ -28,22 +28,26 @@ module('Integration | Component | two step button', function (hooks) {
onCancel: sinon.spy(),
});
- const commonTemplate = hbs`
-
- `;
+ const renderButton = async (props) => {
+ await render(
+
+
+ ,
+ );
+ };
test('presents as a button in the idle state', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
assert.deepEqual(
@@ -64,8 +68,7 @@ module('Integration | Component | two step button', function (hooks) {
test('clicking the idle state button transitions into the promptForConfirmation state', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
await TwoStepButton.idle();
@@ -95,8 +98,7 @@ module('Integration | Component | two step button', function (hooks) {
test('canceling in the promptForConfirmation state calls the onCancel hook and resets to the idle state', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
await TwoStepButton.idle();
@@ -108,8 +110,7 @@ module('Integration | Component | two step button', function (hooks) {
test('confirming the promptForConfirmation state calls the onConfirm hook and resets to the idle state', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
await TwoStepButton.idle();
@@ -120,10 +121,11 @@ module('Integration | Component | two step button', function (hooks) {
});
test('when awaitingConfirmation is true, the cancel and submit buttons are disabled and the submit button is loading', async function (assert) {
- const props = commonProperties();
- props.awaitingConfirmation = true;
- this.setProperties(props);
- await render(commonTemplate);
+ const props = {
+ ...commonProperties(),
+ awaitingConfirmation: true,
+ };
+ await renderButton(props);
await TwoStepButton.idle();
@@ -144,8 +146,7 @@ module('Integration | Component | two step button', function (hooks) {
test('when in the prompt state, clicking outside will reset state back to idle', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
await TwoStepButton.idle();
@@ -158,8 +159,7 @@ module('Integration | Component | two step button', function (hooks) {
test('when in the prompt state, clicking inside will not reset state back to idle', async function (assert) {
const props = commonProperties();
- this.setProperties(props);
- await render(commonTemplate);
+ await renderButton(props);
await TwoStepButton.idle();
@@ -171,10 +171,11 @@ module('Integration | Component | two step button', function (hooks) {
});
test('when awaitingConfirmation is true, clicking outside does nothing', async function (assert) {
- const props = commonProperties();
- props.awaitingConfirmation = true;
- this.setProperties(props);
- await render(commonTemplate);
+ const props = {
+ ...commonProperties(),
+ awaitingConfirmation: true,
+ };
+ await renderButton(props);
await TwoStepButton.idle();
@@ -186,10 +187,11 @@ module('Integration | Component | two step button', function (hooks) {
});
test('when disabled is true, the idle button is disabled', async function (assert) {
- const props = commonProperties();
- props.disabled = true;
- this.setProperties(props);
- await render(commonTemplate);
+ const props = {
+ ...commonProperties(),
+ disabled: true,
+ };
+ await renderButton(props);
assert.ok(TwoStepButton.isDisabled, 'The idle button is disabled');
diff --git a/ui/tests/integration/components/variable-form-test.js b/ui/tests/integration/components/variable-form-test.gjs
similarity index 64%
rename from ui/tests/integration/components/variable-form-test.js
rename to ui/tests/integration/components/variable-form-test.gjs
index c0e38cc370f..57d6c1db039 100644
--- a/ui/tests/integration/components/variable-form-test.js
+++ b/ui/tests/integration/components/variable-form-test.gjs
@@ -5,9 +5,16 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
-import { hbs } from 'ember-cli-htmlbars';
+import { tracked } from '@glimmer/tracking';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
-import { click, typeIn, find, findAll, render } from '@ember/test-helpers';
+import {
+ click,
+ typeIn,
+ triggerEvent,
+ findAll,
+ render,
+ settled,
+} from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror';
import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';
@@ -15,6 +22,47 @@ import percySnapshot from '@percy/ember';
import { clickToggle, clickOption } from 'nomad-ui/tests/helpers/helios';
import faker from 'nomad-ui/mirage/faker';
+import VariableForm from 'nomad-ui/components/variable-form';
+
+function pickInteractiveControl(scope, selectors) {
+ const root = document.querySelector(scope);
+ if (!root) return null;
+
+ const controls = selectors.flatMap((selector) =>
+ Array.from(root.querySelectorAll(selector)),
+ );
+
+ const interactive = controls.filter((control) => {
+ if (!control) return false;
+ if (control.disabled) return false;
+ if (control.type === 'hidden') return false;
+ return control.offsetParent !== null || control.getClientRects().length > 0;
+ });
+
+ return interactive[0] || controls[0] || null;
+}
+
+function findKeyControl(scope = '.key-value:last-of-type') {
+ return pickInteractiveControl(scope, [
+ 'input[data-test-var-key]',
+ 'textarea[data-test-var-key]',
+ '[data-test-var-key] input',
+ '[data-test-var-key] textarea',
+ ]);
+}
+
+function findValueControl(scope = '.key-value:last-of-type') {
+ return pickInteractiveControl(scope, [
+ 'textarea[data-test-var-value]',
+ 'input[data-test-var-value]',
+ '[data-test-var-value] textarea',
+ '[data-test-var-value] input',
+ ]);
+}
+
+class State {
+ @tracked view = 'table';
+}
module('Integration | Component | variable-form', function (hooks) {
setupRenderingTest(hooks);
@@ -22,25 +70,19 @@ module('Integration | Component | variable-form', function (hooks) {
setupCodeMirror(hooks);
test('passes an accessibility audit', async function (assert) {
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- keyValues: [{ key: '', value: '' }],
- }),
- );
- await render(hbs` `);
+ const mockedModel = this.server.create('variable', {
+ keyValues: [{ key: '', value: '' }],
+ });
+ await render( );
await componentA11yAudit(this.element, assert);
});
test('shows a single row by default and modifies on "Add More" and "Delete"', async function (assert) {
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- keyValues: [{ key: '', value: '' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ keyValues: [{ key: '', value: '' }],
+ });
- await render(hbs` `);
+ await render( );
assert.deepEqual(
findAll('div.key-value').length,
1,
@@ -99,21 +141,20 @@ module('Integration | Component | variable-form', function (hooks) {
module('editing and creating new key/value pairs', function () {
test('it should allow each key/value row to toggle password visibility', async function (assert) {
faker.seed(1);
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- keyValues: [{ key: 'foo', value: 'bar' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ keyValues: [{ key: 'foo', value: 'bar' }],
+ });
- await render(hbs` `);
- await click('[data-test-add-kv]'); // add a second variable
+ await render(
+ ,
+ );
+ await click('[data-test-add-kv]');
- findAll('.value-label').forEach((label, iter) => {
+ findAll('.value-label').forEach((label, index) => {
const maskedInput = label.querySelector('.hds-form-masked-input');
assert.ok(
maskedInput.classList.contains('hds-form-masked-input--is-masked'),
- `Value ${iter + 1} is hidden by default`,
+ `Value ${index + 1} is hidden by default`,
);
});
@@ -151,14 +192,11 @@ module('Integration | Component | variable-form', function (hooks) {
{ key: 'and\\now/for-something_completely@different', value: 'up' },
];
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: 'my/path/to',
- keyValues,
- }),
- );
- await render(hbs` `);
+ const mockedModel = this.server.create('variable', {
+ path: 'my/path/to',
+ keyValues,
+ });
+ await render( );
assert.deepEqual(
findAll('div.key-value').length,
5,
@@ -175,17 +213,17 @@ module('Integration | Component | variable-form', function (hooks) {
'Shows "add more" only on the last row',
);
- findAll('div.key-value').forEach((row, idx) => {
+ findAll('div.key-value').forEach((row, index) => {
assert.deepEqual(
row.querySelector(`[data-test-var-key]`).value,
- keyValues[idx].key,
- `Key ${idx + 1} is correct`,
+ keyValues[index].key,
+ `Key ${index + 1} is correct`,
);
assert.deepEqual(
row.querySelector(`[data-test-var-value]`).value,
- keyValues[idx].value,
- keyValues[idx].value,
+ keyValues[index].value,
+ keyValues[index].value,
);
});
});
@@ -198,8 +236,7 @@ module('Integration | Component | variable-form', function (hooks) {
keyValues: [{ key: '', value: '' }],
});
variable.isNew = false;
- this.set('variable', variable);
- await render(hbs` `);
+ await render( );
assert.dom('[data-test-path-input]').hasValue('/baz/bat', 'Path is set');
assert
.dom('[data-test-path-input]')
@@ -207,8 +244,7 @@ module('Integration | Component | variable-form', function (hooks) {
variable.isNew = true;
variable.path = '';
- this.set('variable', variable);
- await render(hbs` `);
+ await render( );
assert
.dom('[data-test-path-input]')
.isNotDisabled('New variable is not in disabled state');
@@ -218,13 +254,10 @@ module('Integration | Component | variable-form', function (hooks) {
test('warns when you try to create a path that already exists', async function (assert) {
this.server.createList('namespace', 3);
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: '',
- keyValues: [{ key: '', value: '' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ path: '',
+ keyValues: [{ key: '', value: '' }],
+ });
this.server.create('variable', {
path: 'baz/bat',
@@ -234,10 +267,15 @@ module('Integration | Component | variable-form', function (hooks) {
namespace: this.server.db.namespaces[2].id,
});
- this.set('existingVariables', this.server.db.variables.toArray());
+ const existingVariables = this.server.db.variables.toArray();
await render(
- hbs` `,
+
+
+ ,
);
await typeIn('[data-test-path-input]', 'foo/bar');
@@ -246,7 +284,7 @@ module('Integration | Component | variable-form', function (hooks) {
.dom('[data-test-path-input]')
.doesNotHaveClass('hds-form-text-input--is-invalid');
- document.querySelector('[data-test-path-input]').value = ''; // clear current input
+ document.querySelector('[data-test-path-input]').value = '';
await typeIn('[data-test-path-input]', 'baz/bat');
assert.dom('[data-test-duplicate-variable-error]').exists();
@@ -264,7 +302,7 @@ module('Integration | Component | variable-form', function (hooks) {
.dom('[data-test-path-input]')
.doesNotHaveClass('hds-form-text-input--is-invalid');
- document.querySelector('[data-test-path-input]').value = ''; // clear current input
+ document.querySelector('[data-test-path-input]').value = '';
await typeIn('[data-test-path-input]', 'baz/bat/qux');
assert.dom('[data-test-duplicate-variable-error]').exists();
assert
@@ -275,15 +313,14 @@ module('Integration | Component | variable-form', function (hooks) {
test('warns when you try to create a path with invalid characters', async function (assert) {
this.server.createList('namespace', 3);
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: '',
- keyValues: [{ key: '', value: '' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ path: '',
+ keyValues: [{ key: '', value: '' }],
+ });
- await render(hbs` `);
+ await render(
+ ,
+ );
await typeIn('[data-test-path-input]', 'foo-bar');
assert.dom('[data-test-invalid-path-error]').doesNotExist();
@@ -291,7 +328,7 @@ module('Integration | Component | variable-form', function (hooks) {
.dom('[data-test-path-input]')
.doesNotHaveClass('hds-form-text-input--is-invalid');
- document.querySelector('[data-test-path-input]').value = ''; // clear current input
+ document.querySelector('[data-test-path-input]').value = '';
await typeIn('[data-test-path-input]', 'foo bar');
assert
@@ -301,12 +338,11 @@ module('Integration | Component | variable-form', function (hooks) {
.dom('[data-test-path-input]')
.hasClass('hds-form-text-input--is-invalid');
- document.querySelector('[data-test-path-input]').value = ''; // clear current input
+ document.querySelector('[data-test-path-input]').value = '';
await typeIn('[data-test-path-input]', '_');
assert.dom('[data-test-invalid-path-error]').doesNotExist();
- // Try 129 characters
- let longString = 'a'.repeat(129);
+ const longString = 'a'.repeat(129);
await typeIn('[data-test-path-input]', longString);
assert
.dom('[data-test-invalid-path-error]')
@@ -314,12 +350,9 @@ module('Integration | Component | variable-form', function (hooks) {
});
test('warns you when you set a key with . in it', async function (assert) {
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- keyValues: [{ key: '', value: '' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ keyValues: [{ key: '', value: '' }],
+ });
const testCases = [
{
@@ -358,26 +391,27 @@ module('Integration | Component | variable-form', function (hooks) {
warn: false,
},
];
- for (const tc of testCases) {
- await render(hbs` `);
- await typeIn('[data-test-var-key]', tc.key);
- if (tc.warn) {
- assert.dom('.key-value-error').exists(tc.name);
+ for (const testCase of testCases) {
+ await render(
+ ,
+ );
+ await typeIn('[data-test-var-key]', testCase.key);
+ if (testCase.warn) {
+ assert.dom('.key-value-error').exists(testCase.name);
} else {
- assert.dom('.key-value-error').doesNotExist(tc.name);
+ assert.dom('.key-value-error').doesNotExist(testCase.name);
}
}
});
test('warns you when you create a duplicate key', async function (assert) {
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- keyValues: [{ key: 'myKey', value: 'myVal' }],
- }),
- );
+ const mockedModel = this.server.create('variable', {
+ keyValues: [{ key: 'myKey', value: 'myVal' }],
+ });
- await render(hbs` `);
+ await render(
+ ,
+ );
await click('[data-test-add-kv]');
@@ -394,60 +428,62 @@ module('Integration | Component | variable-form', function (hooks) {
module('Views', function () {
test('Allows you to swap between JSON and Key/Value Views', async function (assert) {
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: '',
- keyValues: [{ key: '', value: '' }],
- }),
- );
-
- this.set(
- 'existingVariables',
- this.server.createList('variable', 1, {
- path: 'baz/bat',
- }),
- );
+ const state = new State();
+ const mockedModel = this.server.create('variable', {
+ path: '',
+ keyValues: [{ key: '', value: '' }],
+ });
- this.set('view', 'table');
+ const existingVariables = this.server.createList('variable', 1, {
+ path: 'baz/bat',
+ });
await render(
- hbs` `,
+
+
+ ,
);
assert.dom('.key-value').exists();
assert.dom('.CodeMirror').doesNotExist();
- this.set('view', 'json');
+ state.view = 'json';
+ await settled();
assert.dom('.key-value').doesNotExist();
assert.dom('.CodeMirror').exists();
});
test('Persists Key/Values table data to JSON', async function (assert) {
+ const state = new State();
faker.seed(1);
const keyValues = [
{ key: 'foo', value: '123' },
{ key: 'bar', value: '456' },
];
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: '',
- keyValues,
- }),
- );
-
- this.set('view', 'json');
+ const mockedModel = this.server.create('variable', {
+ path: '',
+ keyValues,
+ });
+ state.view = 'json';
await render(
- hbs` `,
+
+
+ ,
);
await percySnapshot(assert);
- const keyValuesAsJSON = keyValues.reduce((acc, { key, value }) => {
- acc[key] = value;
- return acc;
- }, {});
+ const keyValuesAsJSON = keyValues.reduce(
+ (accumulator, { key, value }) => {
+ accumulator[key] = value;
+ return accumulator;
+ },
+ {},
+ );
assert.deepEqual(
code('.editor-wrapper').get(),
@@ -455,49 +491,66 @@ module('Integration | Component | variable-form', function (hooks) {
'JSON editor contains the key values, stringified, by default',
);
- this.set('view', 'table');
+ state.view = 'table';
+ await settled();
await click('[data-test-add-kv]');
- await typeIn('.key-value:last-of-type [data-test-var-key]', 'howdy');
- await typeIn('.key-value:last-of-type [data-test-var-value]', 'partner');
-
- this.set('view', 'json');
-
- assert.ok(
- code('[data-test-json-editor]').get().includes('"howdy": "partner"'),
+ const keyControl = findKeyControl();
+ const valueControl = findValueControl();
+ assert.ok(keyControl, 'Found key input control');
+ assert.ok(valueControl, 'Found value input control');
+
+ await typeIn(keyControl, 'howdy');
+ await typeIn(valueControl, 'partner');
+ await triggerEvent(keyControl, 'change');
+ await triggerEvent(valueControl, 'change');
+ await triggerEvent(keyControl, 'blur');
+ await triggerEvent(valueControl, 'blur');
+
+ state.view = 'json';
+ await settled();
+
+ const parsedJSON = JSON.parse(code('[data-test-json-editor]').get());
+ const parsedObject = Array.isArray(parsedJSON)
+ ? parsedJSON[0]
+ : parsedJSON;
+
+ assert.strictEqual(
+ parsedObject?.howdy,
+ 'partner',
'JSON editor contains the new key value',
);
});
test('Persists JSON data to Key/Values table', async function (assert) {
+ const state = new State();
const keyValues = [{ key: '', value: '' }];
- this.set(
- 'mockedModel',
- this.server.create('variable', {
- path: '',
- keyValues,
- }),
- );
-
- this.set('view', 'json');
+ const mockedModel = this.server.create('variable', {
+ path: '',
+ keyValues,
+ });
+ state.view = 'json';
await render(
- hbs` `,
+
+
+ ,
);
codeFillable('[data-test-json-editor]').get()(
JSON.stringify({ golden: 'gate' }, null, 2),
);
- this.set('view', 'table');
+ state.view = 'table';
+ await settled();
assert.deepEqual(
- find(`.key-value:last-of-type [data-test-var-key]`).value,
+ findKeyControl()?.value,
'golden',
'Key persists from JSON to Table',
);
assert.deepEqual(
- find(`.key-value:last-of-type [data-test-var-value]`).value,
+ findValueControl()?.value,
'gate',
'Value persists from JSON to Table',
);
diff --git a/ui/tests/integration/components/variable-paths-test.js b/ui/tests/integration/components/variable-paths-test.gjs
similarity index 89%
rename from ui/tests/integration/components/variable-paths-test.js
rename to ui/tests/integration/components/variable-paths-test.gjs
index 95e011ecd4d..b70624e89c8 100644
--- a/ui/tests/integration/components/variable-paths-test.js
+++ b/ui/tests/integration/components/variable-paths-test.gjs
@@ -6,14 +6,16 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import pathTree from 'nomad-ui/utils/path-tree';
import Service from '@ember/service';
+import VariablePaths from 'nomad-ui/components/variable-paths';
+
let tree;
module('Integration | Component | variable-paths', function (hooks) {
setupRenderingTest(hooks);
+
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
@@ -36,28 +38,39 @@ module('Integration | Component | variable-paths', function (hooks) {
varInstance.setAndTrimPath();
return varInstance;
});
+
tree = new pathTree(PATHSTRINGS);
});
test('it renders without data', async function (assert) {
this.set('emptyRoot', { children: {}, files: [] });
- await render(hbs` `);
- assert.dom('tbody tr').exists({ count: 0 });
+ await render(
+ ,
+ );
+
+ assert.dom('tbody tr').exists({ count: 0 });
await componentA11yAudit(this.element, assert);
});
test('it renders with data', async function (assert) {
this.set('tree', tree);
- await render(hbs` `);
- assert.dom('tbody tr').exists({ count: 2 }, 'There are two rows');
+ await render(
+ ,
+ );
+
+ assert.dom('tbody tr').exists({ count: 2 }, 'There are two rows');
await componentA11yAudit(this.element, assert);
});
test('it allows for traversal: Folders', async function (assert) {
this.set('tree', tree);
- await render(hbs` `);
+
+ await render(
+ ,
+ );
+
assert
.dom('tbody tr:first-child td:first-child a')
.hasAttribute(
@@ -77,7 +90,6 @@ module('Integration | Component | variable-paths', function (hooks) {
});
test('it allows for traversal: Files', async function (assert) {
- // Arrange Test Set-up
const mockToken = Service.extend({
selfTokenPolicies: [
[
@@ -104,11 +116,10 @@ module('Integration | Component | variable-paths', function (hooks) {
});
this.owner.register('service:token', mockToken);
+ this.set('tree', tree.findPath('foo/bar'));
- // End Test Set-up
+ await render( );
- this.set('tree', tree.findPath('foo/bar'));
- await render(hbs` `);
assert
.dom('tbody tr:first-child td:first-child a')
.hasAttribute(
@@ -137,6 +148,7 @@ module('Integration | Component | variable-paths', function (hooks) {
'file-text',
'Correctly renders the file icon',
);
+
await componentA11yAudit(this.element, assert);
});
});
diff --git a/ui/tests/pages/jobs/job/actions.js b/ui/tests/pages/jobs/job/actions.js
index 500b7aeb221..4316d44c984 100644
--- a/ui/tests/pages/jobs/job/actions.js
+++ b/ui/tests/pages/jobs/job/actions.js
@@ -23,7 +23,7 @@ export default create({
click: clickable('button'),
actions: collection('.hds-dropdown__list li', {
text: text(),
- click: clickable('button'),
+ click: clickable('[data-test-task-row-action]'),
}),
}),
diff --git a/ui/tests/unit/components/gauge-chart-test.js b/ui/tests/unit/components/gauge-chart-test.js
index 194e41acec6..dc44a454266 100644
--- a/ui/tests/unit/components/gauge-chart-test.js
+++ b/ui/tests/unit/components/gauge-chart-test.js
@@ -5,27 +5,22 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
+import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
module('Unit | Component | gauge-chart', function (hooks) {
setupTest(hooks);
-
- hooks.beforeEach(function () {
- this.subject = this.owner.factoryFor('component:gauge-chart');
- });
+ setupGlimmerComponentFactory(hooks, 'gauge-chart');
test('percent is a function of value and total OR complement', function (assert) {
- const chart = this.subject.create();
- chart.setProperties({
+ const chart = this.createComponent({
value: 5,
total: 10,
});
assert.deepEqual(chart.percent, 0.5);
- chart.setProperties({
- total: null,
- complement: 15,
- });
+ chart.args.total = null;
+ chart.args.complement = 15;
assert.deepEqual(chart.percent, 0.25);
});
diff --git a/ui/tests/unit/services/stats-trackers-registry-test.js b/ui/tests/unit/services/stats-trackers-registry-test.js
index 92b674dd70c..2a3ae4c4055 100644
--- a/ui/tests/unit/services/stats-trackers-registry-test.js
+++ b/ui/tests/unit/services/stats-trackers-registry-test.js
@@ -142,46 +142,59 @@ module('Unit | Service | Stats Trackers Registry', function (hooks) {
assert.ok(ref.limit < Infinity, `A limit (${ref.limit}) is set`);
});
- test('Registry re-attaches deleted resources to cached trackers', function (assert) {
+ test('Registry replaces cached trackers when resources are deleted', function (assert) {
const registry = this.subject();
const id = 'some-id';
const node1 = mockNode.create({ id });
- let tracker = registry.getTracker(node1);
+ const tracker1 = registry.getTracker(node1);
- assert.ok(tracker.get('node'), 'The tracker has a node');
+ assert.ok(tracker1.get('node'), 'The tracker has a node');
- tracker.set('node', null);
- assert.notOk(tracker.get('node'), 'The tracker does not have a node');
+ tracker1.set('node', null);
+ assert.notOk(tracker1.get('node'), 'The tracker does not have a node');
- tracker = registry.getTracker(node1);
+ const tracker2 = registry.getTracker(node1);
+ assert.notEqual(
+ tracker1,
+ tracker2,
+ 'A new tracker is returned when the cached tracker has no resource',
+ );
assert.deepEqual(
- tracker.get('node'),
+ tracker2.get('node'),
node1,
- 'The node was re-attached to the tracker after calling getTracker again',
+ 'The replacement tracker is attached to the resource',
);
});
- test('Registry re-attaches destroyed resources to cached trackers', async function (assert) {
+ test('Registry replaces cached trackers when resources are destroyed', async function (assert) {
const registry = this.subject();
const id = 'some-id';
const node1 = mockNode.create({ id });
- let tracker = registry.getTracker(node1);
+ const tracker1 = registry.getTracker(node1);
- assert.ok(tracker.get('node'), 'The tracker has a node');
+ assert.ok(tracker1.get('node'), 'The tracker has a node');
node1.destroy();
await settled();
- assert.ok(tracker.get('node').isDestroyed, 'The tracker node is destroyed');
+ assert.ok(
+ tracker1.get('node').isDestroyed,
+ 'The tracker node is destroyed',
+ );
const node2 = mockNode.create({ id });
- tracker = registry.getTracker(node2);
+ const tracker2 = registry.getTracker(node2);
+ assert.notEqual(
+ tracker1,
+ tracker2,
+ 'A new tracker is returned when the cached tracker resource is destroyed',
+ );
assert.deepEqual(
- tracker.get('node'),
+ tracker2.get('node'),
node2,
- 'Since node1 was destroyed but it matches the tracker of node2, node2 is attached to the tracker',
+ 'The replacement tracker is attached to the new resource',
);
});