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); + } + }; + + +} diff --git a/ui/app/components/action-card.hbs b/ui/app/components/action-card.hbs deleted file mode 100644 index b5cf6df6564..00000000000 --- a/ui/app/components/action-card.hbs +++ /dev/null @@ -1,180 +0,0 @@ -{{! - Copyright IBM Corp. 2015, 2025 - SPDX-License-Identifier: BUSL-1.1 -~}} - -
- - - - {{this.instance.action.name}} - - {{this.instance.action.task.taskGroup.job.name}} - - - - - - {{! - Action instance with peers (run on multiple allocs): - - If instance is running, user can stop it. - - If peers are running, user can stop all of them, - - And if none are running, user can clear all of them. - }} - {{#if this.instance.peerID}} - {{#if (eq this.instance.state "running")}} - - {{/if}} - {{#if - (get - (filter-by - "state" - "running" - (filter-by - "peerID" this.instance.peerID this.nomadActions.actionsQueue - ) - ) - "length" - ) - }} - - {{else}} - - {{/if}} - {{else}} - {{! - Action instance run on a single alloc: - - If instance is running, user can stop it. - - if not, user can clear it from queue. - }} - {{#if (eq this.instance.state "running")}} - - {{else}} - - {{/if}} - {{/if}} - - - - {{#if this.instance.peerID}} - - {{#each - (filter-by "peerID" this.instance.peerID this.nomadActions.actionsQueue) - as |peer| - }} - - {{/each}} - - {{/if}} - -
- {{#if this.instance.error}} -
Error: {{this.instance.error}}
- {{/if}} - {{#if this.instance.messages.length}} - - -
-          {{this.instance.messages}}
-        
-
- - {{else}} - {{#if (eq this.instance.state "complete")}} -

Action completed with no output

- {{/if}} - {{/if}} -
- -
- -
    -
  • Task: {{this.instance.action.task.name}}
  • -
  • Job: - {{this.instance.action.task.taskGroup.job.name}}
  • -
  • Allocation: {{this.instance.allocID}}
  • -
  • Created: {{format-ts this.instance.createdAt}}
  • - {{#if this.instance.completedAt}} - {{#if - (gt - (moment-diff - this.instance.createdAt - this.instance.completedAt - precision="seconds" - ) - 1 - ) - }} -
  • Completed after - {{moment-diff - this.instance.createdAt - this.instance.completedAt - precision="seconds" - }} - seconds
  • - {{else}} -
  • Completed in - {{moment-diff - this.instance.createdAt - this.instance.completedAt - precision="seconds" - float=true - }} - seconds
  • - {{/if}} - {{/if}} -
-
-
- - {{yield}} -
\ No newline at end of file diff --git a/ui/app/components/action-card.js b/ui/app/components/action-card.js deleted file mode 100644 index 6efc5dae295..00000000000 --- a/ui/app/components/action-card.js +++ /dev/null @@ -1,76 +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 { tracked } from '@glimmer/tracking'; - -export default class ActionCardComponent extends Component { - @service nomadActions; - get stateColor() { - /** - * @type {import('../models/action-instance').default} - */ - 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'; - } - } - - @action stop() { - this.instance.socket.close(); - } - - @action stopAll() { - this.nomadActions.stopPeers(this.instance.peerID); - } - - @tracked selectedPeer = null; - - @action selectPeer(peer) { - this.selectedPeer = peer; - } - - get instance() { - // Either the passed instance, or the peer-selected instance - return this.selectedPeer || this.args.instance; - } - - @tracked hasBeenAnchored = false; - - /** - * Runs from the action-card template whenever instance.messages updates, - * and serves to keep the user's view anchored to the bottom of the messages. - * This uses a hidden element and the overflow-anchor css attribute, which - * keeps the element visible within the scrollable 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(); + } + }; + + +} 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'; + } + + +} 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; + } + + +} 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 -~}} - -
-
    -
  • Overview
  • -
  • Tokens
  • -
  • Roles
  • -
  • Policies
  • -
  • Namespaces
  • - {{#if (can "list sentinel-policy")}} -
  • Sentinel Policies
  • - {{/if}} -
-
\ 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; + }; + + +} 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); + }); + + +} 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; + } + + +} 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'), }, ]; } + + } 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 -~}} - -
-
    -
  • Overview
  • -
  • Files
  • -
-
\ 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; + }; + + +} 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 = ; + +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 = ; + +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 -~}} - - - - - - - - - - - -
NameValue
\ 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; - } + } 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); + }; + + +} 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'); + } + }; + + +} 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); - } + }; + + } 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 = ; + +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); - } + }; + + } 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 || ''); + } + + +} 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' }, ]; } + + } 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 || ''); + } + + +} 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 -~}} - -
    -
      -
    • Overview
    • -
    • Monitor
    • -
    -
    \ 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 || ''; + } + + +} 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 -~}} - -
    - {{#if (eq this.state "success")}} -
    - {{#if @inset}} - - {{else}} - - - - {{/if}} - {{yield}} -
    - {{else if (eq this.state "error")}} -
    - {{#if @inset}} - - {{else}} - - - - {{/if}} - {{yield}} -
    - {{else}} - - - {{yield}} - - {{/if}} -
    \ 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 = ; + +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); + } + + +} + +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}} MiBDifference{{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; + }; + + +} 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 }); + }; + + +} 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, + ); + } + + +} 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; + } + + +} + +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}} - - - - - {{else}} - - - - {{/if}} - - - - {{#each this.taskToggleRows key="task.name" as |taskToggleRow index|}} - - {{/each}} - -
    TaskToggle All - - - - TaskCPUMem
    -
    - -
    - - -
    - -
    -
    - - {{@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; - } + }; + + } 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|}} -
    1. - - {{stat.label}} - - {{stat.value}} -
    2. - {{/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, + }; + }; + + +} 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; + }; + + +} 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|}} -
    1. - - - {{datum.label}} - - {{datum.value}} -
    2. - {{/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; + }; + + +} 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 }} - -
    -

    Drain Options

    -
    - -
    - {{#if this.deadlineEnabled}} -
    - - {{opt.label}} - -
    - {{#if this.durationIsCustom}} -
    - - - {{#if this.parseError}} - {{this.parseError}} - {{/if}} -
    - {{/if}} - {{/if}} -
    - -
    -
    - -
    -
    - - -
    -
    -
    \ 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 = ; + +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(), + }, + ]; + } + + +} 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 = ; + +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, + }); + } + + +} 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 = ; + +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); + }; + + +} 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'; }); - } + }; + + } 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 -~}} - -
    -

    Not Authorized

    -

    - {{#if this.token.secret}} - You currently lack the - {{#if this.permission}} - {{this.permission}} - {{else}} - required - {{/if}} - permission - for this resource.
    - Contact your administrator if this is an error. - {{else}} - {{#if this.authMethods}} - Sign in with - {{#each this.authMethods as |authMethod|}} - {{authMethod.name}}, - {{/each}} - or - {{/if}} - - {{conditionally-capitalize "provide" (not this.authMethods.length)}} - a - token - with the - {{#if this.permission}} - {{this.permission}} - {{else}} - requisite - {{/if}} - permission to view this. - {{/if}} -

    - - {{#unless this.token.secret}} -

    - If you have signed in via the Nomad CLI, authenticate with: -

    -
    $ nomad ui -authenticate
    -
    -

    - {{/unless}} -
    \ 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; + } + + +} + +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}} -
    -
    -

    No Files

    -

    - Directory is currently empty. -

    -
    -
    - {{/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}`; + } + + +} 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(); + } + + +} 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}} -
    -

    Unsupported File Type

    -

    The Nomad UI could not render this - file, but you can still view the file directly.

    -

    - -

    -
    - {{/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 = ; + +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); + }; + + +} 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 + }; + `, + ); + } + + +} 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 = -} +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); + }; + + +} 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}} -
    - - v{{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); + } + }; + + +} 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 -~}} - - - {{or - -
    - - {{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 { }, ]; } + + } 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(); } + + } 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 = ; + +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; + }; + + +} 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 = ; + +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}} -
    -

    - No Allocations -

    -

    - No allocations have been placed. -

    -
    - {{/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 = ; + +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 = ; + +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 }; + }); + } + + +} + +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; + + +} 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|}} - - {{/each}} -
    - -{{#each this.objects as |object|}} - -{{/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(' '); + } + + +} 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 }} - - -{{! 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|}} - -{{/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; + } + } + + +} 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}} - -
    -

    Dispatch an instance of '{{@job.name}}'

    - - {{#each this.metaFields as |meta|}} -
    -
    -
    - -
    - - -

    - {{#if meta.required}}Required{{else}}Optional{{/if}} - Meta Param - - {{meta.name}} - -

    -
    -
    -
    -
    - {{/each}} - -
    -
    - Payload - {{#if this.payloadRequired}}*{{/if}} -
    - {{#if this.hasPayload}} -
    -
    -
    - {{else}} -
    -
    -

    Payload Disabled

    -

    Payload is disabled for this job.

    -
    -
    - {{/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, }; } + + } 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; + }; + + +} 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 = ; + +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 = ; + +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(); + } + }; + + +} 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'), + }; + }; + + +} 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; + } + + +} 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 -
    -
    -
    -

    - No Meta Attributes -

    -

    - This job is configured with no meta attributes. -

    -
    -
    - {{/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 = ; + +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 = ; + +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 } }); + } + }; + + +} + +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}} -
    -

    - No Job Launches -

    -

    - No remaining living job launches. -

    -
    - {{/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 = ; + +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 = ; + +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 = ; + +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 = ; + +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); + }; + + +} 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}} -
    -

    - No Allocations -

    -

    - No allocations have been placed. -

    -
    - {{/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; + }, {}); + } + + +} 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)]); + }; + + +} 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|}} -
    1. - -
    2. - {{/each}} -
    -
    -{{else}} - -
      - {{#each chart.data as |datum index|}} -
    1. - {{#if (and (gt datum.value 0) datum.legendLink)}} - - - - {{else}} - - {{/if}} -
    2. - {{/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 = ; + +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++; + }; + + +} 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, + ); + }; + + +} + +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; + } + + +} 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}} - - - - {{/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 = ; + +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); + }); + }; + + +} 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); + }; + + +} 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'; + } + + +} 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}]`, + }; + } + + +} 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; + }; + + +} 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; + }; + + +} 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|}} -
    1. -
      - {{deployment-log.state.allocation.shortId}} - {{deployment-log.type}}: - {{deployment-log.message}} - - {{format-ts deployment-log.time}} - -
      -
    2. - {{else}} - {{#if this.errorState}} -
    3. -
      - Error loading deployment history -
      -
    4. - {{else}} - {{#if this.deploymentAllocations.length}} - {{#if this.searchTerm}} -
    5. -
      - No events match {{this.searchTerm}} -
      -
    6. - {{else}} -
    7. -
      - No deployment events yet -
      -
    8. - {{/if}} - {{else}} -
    9. -
      - Loading deployment events -
      -
    10. - {{/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'); + } + + +} 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

    - -
    \ 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, + }, + }; + } + + +} 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)}`; + } + + +} 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'); + } + + +} 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(' '); + } + + +} 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}} -
    -
    -

    -
    - -
    -
    - - - - {{get - this.oldRunningHealthyAllocBlocks - "length" - }} - Running - - - - {{get - this.oldCompleteHealthyAllocBlocks - "length" - }} - Complete - - -
    - - {{/if}} - -

    New - allocations: - {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}} - running and healthy - - - - - -

    -
    - -
    -
    - -
    - - {{! Legend by Status, then by Health, then by Canary }} - - {{#each-in this.newAllocsByStatus as |status count|}} - - - {{count}} {{capitalize status}} - - {{/each-in}} - - {{#each-in this.newAllocsByHealth as |health count|}} - - - - {{#if (eq health "healthy")}} - - {{else if (eq health "unhealthy")}} - - {{else}} - - {{/if}} - - - {{count}} {{humanize health}} - - {{/each-in}} - - - - - - {{this.newAllocsByCanary.canary}} 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'); + }; + + +} 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}} -

    - - -
    - - {{#each this.allocTypes as |type|}} - - - {{get - (get - (get (get this.allocBlocks type.label) "healthy") - "nonCanary" - ) - "length" - }} - {{capitalize type.label}} - - {{/each}} - - - - -
    -

    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(); + }; + + +} 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 -~}} - -
    -
      -
    • - - Overview - -
    • -
    • - - Definition - -
    • -
    • - - Versions - -
    • - {{#if @job.supportsDeployments}} -
    • - - Deployments - -
    • - {{/if}} - {{#unless this.shouldHideNonParentTabs}} -
    • - - Allocations - -
    • -
    • - - Evaluations - -
    • - {{#if this.shouldRenderClientsTab}} -
    • - - Clients - -
    • - {{/if}} -
    • - - Services - -
    • - {{/unless}} - {{#if (can "list variables")}} -
    • - - Variables - -
    • - {{/if}} - -
    -
    \ 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', + }); + } + }; + + +} + +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}} -
    - {{#if this.isEditing}} -
    - - - - - {{#if this.version.versionTag}} - - {{/if}} - - - {{else}} -
    - {{#if this.version.versionTag}} - - {{else}} - {{#if (can "tag version" namespace=this.version.job.namespace)}} - - {{/if}} - {{/if}} - - {{this.version.versionTag.description}} - -
    -
    - {{#unless this.isCurrent}} - {{#if (eq this.cloneButtonStatus "idle")}} - {{#if (can "run job")}} - - {{/if}} - - {{else if (eq this.cloneButtonStatus "confirming")}} - - {{#if (can "start job" namespace=this.version.job.namespaceId)}} - - {{/if}} - - {{/if}} - {{/unless}} -
    - {{/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 }; + }); + } + + +} + +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; + }; + + +} 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}} -
    -
    - - Keyboard shortcuts - {{#if this.keyboard.enabled}}enabled{{else}}disabled{{/if}} - -
    -
    -{{/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); + } + + +} 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)); + }); + } + + +} + +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(); - } + }; + + } 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); + }; + + +} 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 = ; + +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 = ; + +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; + } + + +} 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 }, + }); + }; + + +} 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, + })); + } + + +} 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; + } + + +} 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 = ; + +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 = ; + +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; + }; + + +} 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(); + } + }; + + +} 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 -~}} - - - - - - - - {{#if this.options}} -
      - {{#each this.options key="key" as |option|}} - - {{/each}} -
    - {{else}} -
      - -
    - {{/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 -~}} - -
    - {{#if this.namespace.isNew}} - - Name - - {{/if}} - -
    -
    - Definition -
    -
    -
    -
    - {{#if this.JSONError}} -

    - {{this.JSONError}} -

    - {{/if}} -
    -
    -
    - -
    - {{#if (can "update namespace")}} - - {{/if}} -
    - \ 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; + }; + + +} 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); + }; + + +} 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; + } + + +} 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(); + } + } + }; + + +} 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 -~}} - -
    -
      -
    • Overview
    • -
    • Allocations
    • -
    -
    \ 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 -~}} - -
    - {{#if @policy.isNew}} - - Policy Name - - {{/if}} - -
    -
    - Policy Definition -
    -
    - -
    -
    -
    - -
    - -
    - -
    - {{#if (can "update policy")}} - - {{/if}} -
    - \ 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(); + } + }; + + +} 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?.(); + } + + +} 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 = ; + +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 -~}} - -
    -
    -
    - - {{@percent}} - -
    -
    -
    - {{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?.(); + } + + +} 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?.(); + } + + +} 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'); + }; + + +} 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; + + } 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 = ; + +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), + }; + }); + } + + +} 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 = ; + +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; + }; + + +} 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}} -
    1. - - Nomad has stopped attempting to reschedule this allocation. -
    2. - {{/if}} - {{#if - (and - @allocation.followUpEvaluation.waitUntil (not @allocation.nextAllocation) - ) - }} -
    3. - - Nomad will attempt to reschedule - - {{moment-from-now - @allocation.followUpEvaluation.waitUntil - interval=1000 - }} - -
    4. - {{/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 -~}} - -
    - - Role Name - - -
    - -
    - -
    - - - <:body as |B|> - - - - - {{B.data.name}} - {{B.data.description}} - - - View Policy Definition - - - - - -
    - -
    - {{#if (can "update role")}} - - {{/if}} -
    -
    \ 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 = ; + +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; + }; + + +} + +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}} -
    -
    -

    {{format-month-ts - this.activeEvent.event.time - }}

    -

    {{this.activeEvent.event.message}}

    -
    -
    - -
    -{{/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 -~}} - -
    - {{#if @policy.isNew}} - - Policy Name - - {{/if}} - -
    -
    - Policy Definition -
    -
    -
    -
    -
    - -
    - -
    - -
    - - Enforcement Level - See - Sentinel Policy documentation - for more information. - - Advisory - - - Soft Mandatory - - - Hard Mandatory - - -
    - -
    - - Scope - - Submit Job - - - Submit Host Volume - - - Submit CSI Volume - - -
    - -
    - {{#if (can "update sentinel-policy")}} - - {{/if}} -
    - \ 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 || ''); + } + + +} 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 -~}} - -
    -
      -
    • Overview
    • -
    • Monitor
    • -
    -
    \ 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 = ; + +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}} - - {{format-ts - @check.Timestamp - timeOnly=true - }} - \ 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); + }; + + +} 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 -~}} - - \ 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)]); + } + + +} 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 = ; + +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 -~}} - -
    -
      -
    • - - Overview - -
    • -
    • - - CSI Plugins - -
    • -
    -
    \ 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(); + } + + +} 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 = ; + +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; + }; + + +} 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); + }; + + +} 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; + }; + + +} 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); + }); + + +} 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', + }); + } + }); + + +} 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 -~}} - -
    -
      -
    • Overview
    • -
    • Logs
    • -
    • Files
    • -
    -
    \ 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(' '); + } + + +} 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 -~}} - -
    - - -
    - {{#if @token.isNew}} - - Expiration time - - - {{! Radio to select between 1, 4, 8, 24, or never }} - - - 10 minutes - - - 8 hours - - - 24 hours - - - Never - - - Custom - - - - {{#if @token.expirationTime}} - - {{/if}} - - {{else}} - - {{#if @token.expirationTime}} - Token - {{#if @token.isExpired}}expired{{else}}expires{{/if}} - - {{moment-from-now @token.expirationTime interval=1000}} - - {{else}} - Token never expires - {{/if}} - - {{/if}} -
    - - {{#unless @token.isNew}} -
    - - Token Accessor - -
    - -
    - - Token Secret - -
    - {{/unless}} - - {{#if @token.isNew}} - {{#if this.system.shouldShowRegions}} - - Token Region - See - ACL token fundamentals: Token replication settings - for more information. - - {{this.system.activeRegion}} - - {{#if this.system.defaultRegion.region}} - {{! template-lint-disable simple-unless }} - {{#unless - (eq this.system.activeRegion this.system.defaultRegion.region) - }} - - {{this.system.defaultRegion.region}} - (authoritative region) - - {{/unless}} - {{/if}} - - global - - - {{/if}} - {{/if}} - -
    - - Client or Management token? - See - Token types documentation - for more information. - - Client - - - Management - - -
    - - {{#if (eq @token.type "client")}} -
    - - {{#if @policies.length}} - - <:body as |B|> - - - - - {{B.data.name}} - {{B.data.description}} - - - View Policy Definition - - - - - - {{else}} -
    -

    - No Policies -

    -

    - Get started by - creating a new policy -

    -
    - {{/if}} -
    - -
    - - {{#if @roles.length}} - - <:body as |B|> - - - - - {{B.data.name}} - {{B.data.description}} - -
    - {{#each B.data.policies as |policy|}} - {{#if policy.name}} - - {{/if}} - {{else}} - Role contains no policies - {{/each}} -
    -
    - - - View Role Info - - -
    - -
    - {{else}} -
    -

    - No Roles -

    -

    - Get started by - creating a new role -

    -
    - {{/if}} -
    - - {{else}} -

    Management-type tokens have access to all permissions.

    - {{/if}} - -
    - {{#if (can "update token")}} - - {{/if}} -
    -
    \ 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'; + } + + } 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 }; }); - } + }; + + } 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|}} -
      -
    1. - Job - {{allocation.allocation.job.name}}/{{allocation.allocation.taskGroupName}} -
    2. - {{#if this.system.shouldShowNamespaces}} -
    3. - Namespace - {{allocation.allocation.job.namespace.name}} -
    4. - {{/if}} -
    5. - Memory - {{format-scheduled-bytes - allocation.memory - start="MiB" - }} -
    6. -
    7. - CPU - {{format-scheduled-hertz allocation.cpu}} -
    8. -
    - {{/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 }, + ); + } + + +} 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 }, + }; + } + + +} 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(); - } + }; + + } 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(); + } + }; + + +} 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}} -
    - - {{#if @model.isNew}} - {{#unless this.isJobTemplateVariable}} - - {{/unless}} - - {{#if this.shouldShowLinkedEntities}} - - {{/if}} - - {{/if}} - - {{#if this.hasConflict}} -
    -

    Heads up! Your variable has a conflict.

    -

    This might be because someone else tried saving in the time since - you've had it open.

    - {{#if this.conflictingVariable.modifyTime}} - - {{moment-from-now this.conflictingVariable.modifyTime}} - - {{/if}} - {{#if this.conflictingVariable.items}} -
    {{stringify-object
    -              this.conflictingVariable.items
    -              whitespace=2
    -            }}
    - {{else}} -

    Your ACL token limits your ability to see further details about the - conflicting variable.

    - {{/if}} -
    - - -
    -
    - {{/if}} - -
    - - - Path - - {{#if this.duplicatePathWarning}} - - There is already a variable located at - {{this.path}} - . -
    - Please choose a different path, or - - edit the existing variable - - . -
    - {{/if}} - {{#if @model.isNew}} - {{#if this.hasInvalidPath}} - - Path must contain only alphanumeric or "-", "_", "~", or "/" - characters, and be fewer than 128 characters in length. - - {{/if}} - {{/if}} - {{#if this.isJobTemplateVariable}} - - Use this variable to generate job templates with - - - {{/if}} -
    - - -
    - {{#if this.isJobTemplateVariable}} - - {{else}} - {{#if (eq this.view "json")}} -
    -
    - {{#if this.JSONError}} -

    - {{this.JSONError}} -

    - {{/if}} -
    - {{else}} - {{#each this.keyValues as |entry iter|}} -
    - - Key - - - - {{#each-in entry.warnings as |k v|}} - - {{v}} - - {{/each-in}} -
    - {{/each}} - {{/if}} - {{/if}} - -
    - {{#unless this.isJSONView}} - {{#unless this.isJobTemplateVariable}} - - {{/unless}} - {{/unless}} - -
    - \ 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); + }; + + +} 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 = ; + +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); + } + }; + + +} 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( + , + ); 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); 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( + , + ); 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); - 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( + , + ); - 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( + , + ); - // 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( + , + ); 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( + , + ); + + 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); 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( + , + ); + + 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( + , + ); + 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( + , + ); + + 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( + , + ); + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + }; 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( + , + ); - // 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( + , + ); + }; 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( + , + ); + 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( + , + ); + 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( + , + ); 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( + , + ); 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( + , + ); 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', ); });