diff options
author | Andrey V.Kosteltsev <AKosteltsev@IBS.RU> | 2022-07-04 23:59:32 +0300 |
---|---|---|
committer | Andrey V.Kosteltsev <AKosteltsev@IBS.RU> | 2022-07-04 23:59:32 +0300 |
commit | 8047ae3d83ba0718fb7a42907036157e5c680b85 (patch) | |
tree | 600b017fe3a75ab4d1577eb9367afe8548401f9f | |
parent | 3f4094d08b873e17464a51c817ea7d41177f848d (diff) | |
download | webui-vue-8047ae3d83ba0718fb7a42907036157e5c680b85.tar.xz |
IBS: _sila UI theme
164 files changed, 16870 insertions, 0 deletions
diff --git a/.env.sila b/.env.sila new file mode 100644 index 00000000..f5000278 --- /dev/null +++ b/.env.sila @@ -0,0 +1,12 @@ +NODE_ENV=production +VUE_APP_ENV_NAME="sila" +VUE_APP_COMPANY_NAME="IBS" +VUE_APP_GUI_NAME="BMC System Management" +VUE_APP_SUBSCRIBE_SOCKET_DISABLED="true" +VUE_APP_SWITCH_TO_BACKUP_IMAGE_DISABLED="true" +VUE_APP_MODIFY_SSH_POLICY_DISABLED="true" +VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED="true" +CUSTOM_STYLES="true" +CUSTOM_APP_NAV="true" +CUSTOM_STORE="true" +CUSTOM_ROUTER="true" diff --git a/src/assets/images/_sila/built-on-openbmc-logo.svg b/src/assets/images/_sila/built-on-openbmc-logo.svg new file mode 100644 index 00000000..53e7fdc5 --- /dev/null +++ b/src/assets/images/_sila/built-on-openbmc-logo.svg @@ -0,0 +1,13 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="615.034" height="606.872" viewBox="88.47 5 615.034 606.872"> + <path fill="#A7A8AB" d="M703.504 524.604c-10.908-23.902-39.128-34.437-63.03-23.528s-34.437 39.128-23.528 63.03 39.128 34.437 63.031 23.528a47.578 47.578 0 0022.916-22.239l-9.51-4.513c-9.083 18.301-31.281 25.773-49.582 16.69s-25.772-31.282-16.689-49.583c9.083-18.3 31.281-25.772 49.582-16.689a36.994 36.994 0 0117.225 17.816l9.585-4.512z"/> + <path fill="#579AC8" d="M256.378 213.7a136.858 136.858 0 0029.599 85.178c-.255.739-.382 1.529-.561 2.294a27.54 27.54 0 00-.867 6.553c0 15.487 12.556 28.044 28.044 28.044s28.044-12.557 28.044-28.044c0-15.489-12.556-28.045-28.044-28.045a27.219 27.219 0 00-3.06.179c-.79 0-1.555.204-2.32.331-36.755-47.793-27.808-116.333 19.986-153.088a109.135 109.135 0 0116.115-10.282V10.277l-1.963.536a204.136 204.136 0 00-18.152 5.609c-3.289 1.198-6.578 2.549-9.79 3.773v82.169l-.332.255A137.14 137.14 0 00256.378 213.7z"/> + <path fill="#579AC8" d="M395.146 251.228c-20.315.016-36.857-16.322-37.095-36.636H327.1c.124 31.721 22.467 59.011 53.539 65.394v115.619c-99.899-7.141-175.094-93.913-167.954-193.811a181.344 181.344 0 0138.721-99.659c.791.153 1.606.255 2.55.332.943.076 1.631 0 2.549 0 15.249.036 27.64-12.296 27.676-27.545.037-15.249-12.295-27.64-27.544-27.676-15.249-.037-27.64 12.295-27.677 27.544a27.607 27.607 0 001.413 8.785c-72.331 90.409-57.675 222.336 32.735 294.667a209.641 209.641 0 00130.89 45.941c3.595 0 7.165-.153 10.708-.356l4.156-.229V248.653a36.9 36.9 0 01-13.716 2.575z"/> + <path fill="#A3CE4D" d="M526.877 179.741a137.108 137.108 0 00-25.112-50.25c.337-1.088.609-2.194.815-3.314.391-1.819.596-3.672.612-5.532-.006-15.488-12.566-28.04-28.055-28.034-15.488.006-28.04 12.567-28.033 28.055.006 15.488 12.566 28.04 28.055 28.033 1.802 0 3.6-.175 5.368-.521 36.745 47.818 27.769 116.371-20.05 153.116a109.15 109.15 0 01-16.076 10.253v106.466a212.604 212.604 0 008.158-2.218 209.772 209.772 0 0021.747-7.648v-82.144c46.035-33.228 66.902-91.341 52.52-146.263h.051z"/> + <path fill="#A3CE4D" d="M559.204 343.442c71.162-91.297 54.84-222.996-36.457-294.157A209.588 209.588 0 00393.896 5c-4.181 0-8.311.153-12.416.408h-.28v174.358a37.046 37.046 0 0113.232-2.728h.688c20.5-.056 37.166 16.517 37.222 37.018V214.668h28.248c-.067-30.889-21.298-57.705-51.347-64.859v-116c99.813 8.435 173.888 96.185 165.453 195.997a181.375 181.375 0 01-37.495 95.994h-.179c-15.327-2.225-29.557 8.397-31.781 23.725a28.227 28.227 0 00-.291 4.014c0 15.488 12.557 28.044 28.045 28.044 15.488-.008 28.038-12.569 28.03-28.058a28.06 28.06 0 00-1.159-7.967 16.997 16.997 0 00-.662-2.116z"/> + <path fill="#A7A8AB" d="M601.5 589.998v-91.194h-11.957l-37.044 75.183-37.044-75.183h-12.109v91.194h10.631v-69.371l34.264 69.371h8.516l34.239-69.371v69.371z"/> + <path fill="#636567" d="M393.973 531.31a27.02 27.02 0 00-20.141-8.516 25.878 25.878 0 00-10.606 2.116 40.606 40.606 0 00-9.229 5.711v-6.884h-10.784v66.286h10.784v-37.706a19.726 19.726 0 015.303-14.022c7.39-7.505 19.463-7.599 26.968-.209l.209.209a19.783 19.783 0 015.303 13.971v37.682h10.478v-38.242a28.352 28.352 0 00-8.26-20.396M250.846 572.101a31.638 31.638 0 01-12.391 13.181 34.52 34.52 0 01-17.846 4.717h-16.011v21.874H194.4v-88.135h25.75a34.186 34.186 0 0124.398 9.586c10.573 10.042 13.149 25.905 6.298 38.777m-46.247 8.26h14.71a24.178 24.178 0 0017.184-6.73 21.899 21.899 0 007.036-16.674 21.362 21.362 0 00-6.883-16.342 24.037 24.037 0 00-17.03-6.399h-15.017v46.145zM334.367 560.246a11.65 11.65 0 00.357-3.468c0-18.814-15.985-34.061-35.693-34.061-19.707 0-35.692 15.297-35.692 34.061 0 18.765 15.985 34.062 35.692 34.062a35.695 35.695 0 0030.416-16.623l-8.286-6.246a25.215 25.215 0 01-22.129 12.747c-13.256.749-24.61-9.391-25.358-22.647s9.391-24.61 22.647-25.358a23.576 23.576 0 012.711 0 24.985 24.985 0 0123.838 16.673c0 .179.382 1.606.433 1.785H283.76v8.974l50.607.101zM173.113 544.388c0 20.459-16.585 37.044-37.044 37.044-20.458 0-37.044-16.585-37.044-37.044 0-20.458 16.585-37.043 37.044-37.043 20.459 0 37.044 16.585 37.044 37.043m10.504 0c0-26.273-21.299-47.572-47.573-47.572S88.47 518.114 88.47 544.388c0 26.274 21.299 47.573 47.573 47.573s47.574-21.299 47.574-47.573"/> + <path fill="#A7A8AB" d="M482.058 562.643c.028 9.532-7.677 17.282-17.209 17.311h-39.466v-34.622h39.491c9.526.042 17.223 7.784 17.209 17.312m-5.839-40.615c.001 7.123-5.752 12.909-12.874 12.951h-37.962v-25.928h37.859c7.129.027 12.889 5.822 12.875 12.951m2.397 17.082c9.51-8.376 10.429-22.875 2.053-32.384a22.952 22.952 0 00-16.56-7.771h-49.204v91.144H466.888c15.067-1.113 26.379-14.229 25.266-29.297a27.357 27.357 0 00-13.589-21.667"/> + <g fill="#99C248"> + <path d="M101.054 479.658l8.647-50.502h14.193c3.789 0 6.487.241 8.095.724 2.503.735 4.432 2.102 5.788 4.1 1.354 1.998 2.032 4.467 2.032 7.406 0 2.963-.654 5.442-1.963 7.44s-3.273 3.515-5.891 4.548c2.044.688 3.68 1.929 4.909 3.721 1.229 1.791 1.843 3.881 1.843 6.27 0 3.261-.74 6.212-2.222 8.853-1.481 2.642-3.411 4.542-5.788 5.702-2.377 1.159-5.748 1.739-10.11 1.739h-19.533zm9.884-8.13h8.215c3.221 0 5.43-.229 6.626-.689 1.197-.459 2.198-1.303 3.003-2.531s1.208-2.612 1.208-4.151c0-1.813-.528-3.238-1.585-4.271s-2.722-1.551-4.996-1.551H113.21l-2.272 13.193zm3.786-21.978h6.513c2.849 0 4.894-.229 6.134-.688a5.744 5.744 0 002.929-2.377c.712-1.125 1.068-2.412 1.068-3.858 0-1.171-.265-2.164-.793-2.979s-1.195-1.361-2-1.637c-.805-.275-2.54-.413-5.207-.413h-6.586l-2.058 11.952zM148.174 443.073h8.144l-3.709 21.592c-.435 2.586-.652 4.206-.652 4.861 0 1.123.324 2.06.972 2.809.648.748 1.438 1.122 2.371 1.122 1.091 0 2.171-.39 3.24-1.171 1.5-1.079 2.694-2.485 3.581-4.22.887-1.734 1.645-4.255 2.273-7.562l3.245-17.432h8.143l-6.788 36.585h-7.618l.905-4.961c-3.299 3.858-6.859 5.788-10.681 5.788-2.411 0-4.328-.845-5.75-2.532-1.422-1.688-2.133-4.014-2.133-6.976 0-1.24.323-3.743.968-7.51l3.489-20.393zM184.179 443.073h8.096l-6.27 36.585h-8.096l6.27-36.585zm2.377-13.917h8.13l-1.55 8.957h-8.096l1.516-8.957zM194.1 479.658l8.647-50.502h8.13l-8.682 50.502H194.1zM211.703 450.411l1.316-7.338h4.193l1.019-5.855 9.445-6.914-2.32 12.77h5.122l-1.245 7.338h-5.202l-2.836 15.281c-.491 2.747-.736 4.343-.736 4.788 0 .82.218 1.43.655 1.828.437.398 1.188.598 2.256.598.339 0 1.273-.08 2.802-.241l-1.346 7.338a19.843 19.843 0 01-4.342.482c-2.824 0-4.936-.644-6.336-1.93s-2.1-3.169-2.1-5.649c0-1.148.369-3.743 1.106-7.785l2.694-14.71h-4.145zM246.634 464.467c0-5.994 1.354-11.093 4.065-15.296 3.008-4.616 7.36-6.924 13.056-6.924 4.271 0 7.676 1.446 10.214 4.341 2.537 2.894 3.807 6.924 3.807 12.091 0 6.063-1.58 11.213-4.737 15.45-3.158 4.237-7.286 6.356-12.384 6.356-4.157 0-7.533-1.436-10.128-4.307-2.596-2.869-3.893-6.774-3.893-11.711zm23.115-7.063c0-2.411-.57-4.306-1.709-5.684-1.139-1.378-2.583-2.067-4.333-2.067-1.473 0-2.854.506-4.144 1.516-1.289 1.011-2.417 2.768-3.383 5.271a21.43 21.43 0 00-1.45 7.786c0 2.732.593 4.856 1.778 6.373 1.186 1.516 2.653 2.273 4.402 2.273 2.21 0 4.085-1.172 5.628-3.514 2.141-3.215 3.211-7.2 3.211-11.954zM287.105 443.073h7.711l-.795 4.754c1.993-2.089 3.819-3.542 5.48-4.357 1.661-.814 3.373-1.223 5.137-1.223 2.405 0 4.329.838 5.771 2.514 1.443 1.676 2.165 3.972 2.165 6.887 0 1.263-.296 3.686-.887 7.269l-3.515 20.742h-8.143l3.56-20.738c.525-3.101.788-4.96.788-5.58 0-1.31-.315-2.313-.944-3.015-.629-.7-1.448-1.051-2.454-1.051-1.098 0-2.219.437-3.364 1.31-1.647 1.239-2.872 2.716-3.672 4.426-.801 1.712-1.604 4.921-2.412 9.629l-2.594 15.02h-8.095l6.263-36.587z"/> + </g> +</svg> diff --git a/src/assets/images/_sila/login-company-logo.svg b/src/assets/images/_sila/login-company-logo.svg new file mode 100644 index 00000000..d0fa158c --- /dev/null +++ b/src/assets/images/_sila/login-company-logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 241.23 240.05"><defs><style>.cls-1{fill:#a6a8ab;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}.cls-4{fill:url(#linear-gradient-3);}.cls-5{fill:url(#linear-gradient-4);}.cls-6{fill:#626366;}</style><linearGradient id="linear-gradient" x1="82.9" y1="11.55" x2="82.9" y2="154.54" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#00b0da"/><stop offset="1" stop-color="#008abf"/></linearGradient><linearGradient id="linear-gradient-2" x1="81.55" y1="27.55" x2="81.55" y2="158.66" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="156.66" y1="51.54" x2="156.66" y2="154.8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a5d440"/><stop offset="1" stop-color="#8cce3f"/></linearGradient><linearGradient id="linear-gradient-4" x1="158.41" y1="51.54" x2="158.41" y2="154.8" xlink:href="#linear-gradient-3"/></defs><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M241.23,205.77a18.66,18.66,0,1,0-.24,16L237.26,220a14.51,14.51,0,1,1,.21-12.46Z"/><path class="cls-2" d="M65.85,81.86a53.68,53.68,0,0,0,11.61,33.41c-.1.29-.15.6-.22.9a10.81,10.81,0,0,0-.34,2.57,11,11,0,1,0,11-11,10.75,10.75,0,0,0-1.2.07c-.31,0-.61.08-.91.13A42.82,42.82,0,0,1,99.95,43.86h0V2.07l-.77.21q-3.63.94-7.12,2.2c-1.29.47-2.58,1-3.84,1.48h0V38.19l-.13.1A53.79,53.79,0,0,0,65.85,81.86Z"/><path class="cls-3" d="M120.28,96.58a14.54,14.54,0,0,1-14.55-14.37H93.59v0a26.29,26.29,0,0,0,21,25.65v45.35A71.13,71.13,0,0,1,63.9,38.1c.31.06.63.1,1,.13s.64,0,1,0a10.83,10.83,0,1,0-10.25-7.41,82.23,82.23,0,0,0,64.18,133.6c1.41,0,2.81-.06,4.2-.14l1.63-.09h0V95.57A14.47,14.47,0,0,1,120.28,96.58Z"/><path class="cls-4" d="M171.95,68.54a53.78,53.78,0,0,0-9.85-19.71,11.31,11.31,0,0,0,.32-1.3,10.78,10.78,0,0,0,.24-2.17,11,11,0,1,0-8.89,10.8,42.83,42.83,0,0,1-14.17,64.08V162c1.08-.27,2.14-.56,3.2-.87a82.35,82.35,0,0,0,8.53-3V125.91a53.91,53.91,0,0,0,20.6-57.37Z"/><path class="cls-5" d="M184.63,132.75A82.21,82.21,0,0,0,119.79,0c-1.64,0-3.26.06-4.87.16h-.11V68.55h0A14.53,14.53,0,0,1,120,67.48h.27A14.56,14.56,0,0,1,134.87,82s0,.07,0,.11,0,.08,0,.13h11.08A26.21,26.21,0,0,0,125.81,56.8V11.3A71.14,71.14,0,0,1,176,125.83h-.07a11,11,0,0,0-12.58,10.88,11,11,0,0,0,11,11h0a11,11,0,0,0,10.54-14.13C184.82,133.3,184.73,133,184.63,132.75Z"/><polygon class="cls-1" points="201.22 231.42 201.22 195.65 196.53 195.65 182 225.14 167.47 195.65 162.72 195.65 162.72 231.42 166.89 231.42 166.89 204.21 180.33 231.42 183.67 231.42 197.1 204.21 197.1 231.42 201.22 231.42"/><path class="cls-6" d="M119.82,208.4a10.6,10.6,0,0,0-7.9-3.34,10.15,10.15,0,0,0-4.16.83,15.94,15.94,0,0,0-3.62,2.24v-2.7H99.91v26h4.23V216.64a7.74,7.74,0,0,1,2.08-5.5,7.48,7.48,0,0,1,10.66,0,7.76,7.76,0,0,1,2.08,5.48v14.78h4.11v-15a11.12,11.12,0,0,0-3.24-8"/><path class="cls-6" d="M63.68,224.4a12.41,12.41,0,0,1-4.86,5.17,13.54,13.54,0,0,1-7,1.85H45.54V240h-4V205.43H51.64a13.41,13.41,0,0,1,9.57,3.76,12.73,12.73,0,0,1,2.47,15.21m-18.14,3.24h5.77A9.48,9.48,0,0,0,58.05,225a8.59,8.59,0,0,0,2.76-6.54,8.38,8.38,0,0,0-2.7-6.41,9.43,9.43,0,0,0-6.68-2.51H45.54Z"/><path class="cls-6" d="M96.44,219.75a4.56,4.56,0,0,0,.14-1.36c0-7.38-6.27-13.36-14-13.36s-14,6-14,13.36,6.27,13.36,14,13.36a14,14,0,0,0,11.93-6.52l-3.25-2.45a9.89,9.89,0,0,1-8.68,5,9.43,9.43,0,1,1,0-18.83,9.8,9.8,0,0,1,9.35,6.54c0,.07.15.63.17.7H76.59v3.52Z"/><path class="cls-6" d="M33.19,213.53A14.53,14.53,0,1,1,18.66,199a14.53,14.53,0,0,1,14.53,14.53m4.12,0a18.66,18.66,0,1,0-18.66,18.66,18.66,18.66,0,0,0,18.66-18.66"/><path class="cls-1" d="M154.37,220.69a6.77,6.77,0,0,1-6.75,6.79H132.14V213.9h15.49a6.78,6.78,0,0,1,6.75,6.79m-2.29-15.93a5.08,5.08,0,0,1-5.05,5.08l-14.89,0V199.67H147a5.07,5.07,0,0,1,5.05,5.08m.94,6.7a9,9,0,0,0-5.69-15.75H128v35.75h20.14l.28,0v0A10.73,10.73,0,0,0,153,211.46"/></g></g></svg>
\ No newline at end of file diff --git a/src/assets/images/_sila/logo-header.svg b/src/assets/images/_sila/logo-header.svg new file mode 100644 index 00000000..2dba9c4c --- /dev/null +++ b/src/assets/images/_sila/logo-header.svg @@ -0,0 +1 @@ +<svg width="157" height="32" xmlns="http://www.w3.org/2000/svg"><g fill="#FFF" fill-rule="nonzero"><path d="M9.694 8.048c.016.761-.602 1.69-1.87 1.574-2.433 3.002-3.097 7.524-.99 11.37 1.078 1.966 2.637 3.435 4.661 4.409a10.04 10.04 0 003.743.981v-6.61c-1.17-.274-2.058-.918-2.626-1.978a3.713 3.713 0 01-.436-1.736h1.749c.046.723.343 1.313.954 1.72.607.405 1.264.455 1.96.227v9.957a11.43 11.43 0 01-4.16-.43c-2.385-.692-4.382-1.986-5.952-3.903C4.94 21.447 4.019 18.94 4 16.116c-.018-2.82.887-5.33 2.62-7.551-.34-1.039.44-1.988 1.329-2.06.97-.077 1.726.607 1.745 1.543zm12.485 2.994c-.017.08.01.191.06.257.897 1.191 1.45 2.524 1.562 4.006.217 2.864-.825 5.2-3.079 6.998-.044.035-.089.068-.132.103-.009.007-.015.016-.031.033v4.714l-1.692.561v-6.1c1.811-1.02 2.956-2.53 3.286-4.592.28-1.75-.154-3.342-1.212-4.774-.192 0-.383.019-.57-.003-.765-.088-1.374-.799-1.367-1.58.007-.802.62-1.493 1.398-1.577 1.144-.124 2.01.833 1.777 1.954zM16.372 4c.325.03.652.054.977.092 2.154.251 4.107 1.017 5.833 2.323 2.446 1.849 4.007 4.28 4.583 7.283.668 3.482-.094 6.666-2.202 9.527l-.103.141c.385 1.137-.411 2.074-1.344 2.18-.87.1-1.687-.57-1.76-1.472a1.61 1.61 0 011.834-1.707c2.157-2.655 3.036-6.877 1.246-10.714-1.976-4.235-5.944-5.847-8.593-5.997v6.625c1.82.567 2.806 1.787 2.95 3.7h-1.61c-.033-.74-.326-1.345-.947-1.766-.618-.417-1.286-.47-1.992-.226V4.04L15.61 4h.76zm-3.328.286v6.084c-1.916 1.087-3.064 2.704-3.309 4.902-.182 1.638.261 3.123 1.255 4.447a1.677 1.677 0 011.154.21c.597.355.888 1.099.7 1.796-.174.646-.806 1.137-1.479 1.15-1.09.02-1.842-.918-1.602-1.992a.244.244 0 00-.033-.18c-1.205-1.579-1.754-3.367-1.61-5.337.171-2.357 1.235-4.259 3.105-5.708l.14-.107V4.84l1.68-.553zM42.25 22c.476 0 .917-.049 1.323-.147a2.723 2.723 0 001.043-.483c.29-.224.516-.518.679-.882.163-.364.245-.807.245-1.33 0-.672-.17-1.232-.511-1.68-.34-.448-.861-.723-1.561-.826v-.028c.205-.037.404-.105.595-.203.191-.098.362-.233.511-.406.15-.173.27-.387.364-.644.093-.257.14-.562.14-.917 0-.373-.06-.716-.182-1.029a1.973 1.973 0 00-.581-.805c-.266-.224-.609-.397-1.029-.518-.42-.121-.924-.182-1.512-.182h-3.64V22h4.116zm-.756-5.628h-2.408v-3.64h2.198c.523 0 .964.03 1.323.091.36.06.651.159.875.294.224.135.387.313.49.532.103.22.154.488.154.805 0 .308-.044.581-.133.819a1.369 1.369 0 01-.441.602c-.205.163-.476.287-.812.371-.336.084-.751.126-1.246.126zm.014 4.816h-2.422v-4.004h2.534c.448 0 .854.026 1.218.077.364.051.677.147.938.287.261.14.462.338.602.595.14.257.21.595.21 1.015 0 .439-.063.791-.189 1.057a1.369 1.369 0 01-.567.616c-.252.145-.572.24-.959.287-.387.047-.842.07-1.365.07zm8.414 1.008c.317 0 .597-.04.84-.119a2.772 2.772 0 001.12-.679c.14-.14.266-.275.378-.406V22h.84v-7.252h-.84v3.948c0 .401-.051.77-.154 1.106a2.705 2.705 0 01-.434.868 1.946 1.946 0 01-.693.567 2.083 2.083 0 01-.931.203c-.663 0-1.139-.166-1.428-.497-.29-.331-.434-.81-.434-1.435v-4.76h-.84v4.774c0 .383.044.735.133 1.057.089.322.233.604.434.847.2.243.464.432.791.567.327.135.733.203 1.218.203zm6.216-9.03V11.92h-.924v1.246h.924zM56.096 22v-7.252h-.84V22h.84zm3.108 0V11.92h-.84V22h.84zm3.724.196c.205 0 .392-.023.56-.07.168-.047.303-.08.406-.098v-.686a4.055 4.055 0 01-.343.07 2.17 2.17 0 01-.343.028c-.177 0-.315-.019-.413-.056a.402.402 0 01-.217-.182.775.775 0 01-.084-.322 6.594 6.594 0 01-.014-.462v-4.97h1.358v-.7H62.48v-2.016h-.84v2.016h-1.022v.7h1.022v5.306c0 .541.11.917.329 1.127.22.21.539.315.959.315zm9.086 0a3.4 3.4 0 001.379-.273 2.99 2.99 0 001.064-.777 3.53 3.53 0 00.679-1.204c.159-.467.238-.99.238-1.568 0-.532-.068-1.031-.203-1.498a3.352 3.352 0 00-.623-1.211c-.28-.34-.63-.611-1.05-.812-.42-.2-.915-.301-1.484-.301-.56 0-1.05.1-1.47.301-.42.2-.77.474-1.05.819a3.52 3.52 0 00-.63 1.211c-.14.462-.21.959-.21 1.491 0 .635.089 1.19.266 1.666.177.476.418.873.721 1.19.303.317.658.555 1.064.714.406.159.842.243 1.309.252zm0-.756a2.41 2.41 0 01-1.064-.224 2.106 2.106 0 01-.77-.63 2.904 2.904 0 01-.469-.973 4.523 4.523 0 01-.161-1.239c0-.392.047-.772.14-1.141.093-.369.238-.695.434-.98.196-.285.45-.513.763-.686.313-.173.688-.259 1.127-.259.448 0 .826.08 1.134.238.308.159.56.376.756.651.196.275.34.6.434.973.093.373.14.775.14 1.204 0 .392-.047.77-.14 1.134-.093.364-.238.69-.434.98-.196.29-.45.52-.763.693-.313.173-.688.259-1.127.259zm5.74.56v-3.906c0-.252.01-.485.028-.7a2.67 2.67 0 01.154-.672 2.079 2.079 0 01.721-.98c.173-.13.366-.236.581-.315.215-.08.453-.119.714-.119.672 0 1.153.163 1.442.49.29.327.434.817.434 1.47V22h.84v-4.746c0-.299-.016-.586-.049-.861a1.806 1.806 0 00-.315-.805c-.261-.383-.583-.651-.966-.805a3.307 3.307 0 00-1.246-.231c-.504 0-.936.112-1.295.336-.36.224-.707.513-1.043.868v-1.008h-.84V22h.84zm15.199.294c1.654 0 2.928-.583 3.821-1.75.761-.994 1.142-2.24 1.142-3.74 0-1.385-.333-2.522-.998-3.41-.853-1.14-2.165-1.71-3.938-1.71-1.695 0-2.976.62-3.842 1.86-.674.966-1.011 2.128-1.011 3.486 0 1.504.394 2.748 1.182 3.733.884 1.02 2.099 1.53 3.644 1.53zm.164-1.217c-1.263 0-2.175-.382-2.738-1.145-.563-.763-.844-1.7-.844-2.813 0-1.39.328-2.441.984-3.155.656-.713 1.504-1.07 2.543-1.07 1.071 0 1.915.36 2.533 1.08.617.72.926 1.67.926 2.851 0 1.117-.268 2.105-.803 2.964-.536.859-1.403 1.288-2.601 1.288zm7.39 3.842v-3.74c.268.333.512.568.73.705.375.241.837.362 1.389.362.701 0 1.317-.216 1.845-.65.834-.683 1.251-1.822 1.251-3.417 0-1.18-.29-2.089-.868-2.724-.579-.636-1.283-.954-2.112-.954-.56 0-1.048.137-1.463.41-.292.182-.56.44-.807.773v-.971h-1.196v10.206h1.23zm1.954-3.746c-.847 0-1.43-.378-1.75-1.135-.168-.396-.252-.877-.252-1.442 0-.702.084-1.28.252-1.737.315-.852.898-1.278 1.75-1.278.848 0 1.431.403 1.75 1.21.17.42.253.92.253 1.504 0 .957-.19 1.676-.57 2.157-.38.48-.858.72-1.433.72zm7.554 1.087c.374 0 .709-.037 1.005-.11a2.867 2.867 0 001.388-.738c.223-.21.425-.48.605-.81.18-.33.286-.63.318-.899h-1.21a2.143 2.143 0 01-.465.855c-.383.423-.896.635-1.538.635-.688 0-1.197-.224-1.525-.673-.328-.449-.503-1.045-.526-1.788h5.366c0-.738-.036-1.276-.11-1.613a3.266 3.266 0 00-.491-1.292c-.256-.387-.627-.703-1.115-.947a3.34 3.34 0 00-1.51-.365c-1.03 0-1.864.367-2.502 1.1-.638.734-.957 1.689-.957 2.864 0 1.195.316 2.123.95 2.786.633.663 1.406.995 2.317.995zm2.154-4.505H108.1c.023-.629.226-1.147.609-1.555a1.872 1.872 0 011.421-.612c.789 0 1.352.296 1.689.889.182.319.298.745.349 1.278zM116.174 22v-3.835c0-.478.031-.846.093-1.104.061-.257.194-.507.4-.748.255-.301.551-.502.888-.602.187-.06.426-.089.718-.089.574 0 .97.228 1.19.684.132.273.198.633.198 1.08V22h1.25v-4.696c0-.739-.1-1.306-.3-1.702-.365-.725-1.067-1.087-2.106-1.087-.474 0-.904.093-1.292.28-.387.187-.754.494-1.1.923v-1.04h-1.17V22h1.231zm10.931 0c1.322 0 2.286-.392 2.892-1.176a2.8 2.8 0 00.608-1.757c0-.77-.226-1.374-.677-1.811-.255-.246-.64-.467-1.155-.663.35-.178.62-.374.807-.588.36-.406.54-.925.54-1.559 0-.542-.146-1.02-.438-1.435-.497-.702-1.333-1.053-2.509-1.053h-4.313V22h4.245zm-.437-5.797h-2.475v-3.11h2.427c.592 0 1.039.064 1.34.191.533.228.8.67.8 1.326 0 .652-.251 1.103-.753 1.354-.319.16-.765.24-1.34.24zm.458 4.635h-2.933v-3.534h2.7c.57 0 1.028.07 1.374.212.652.264.978.76.978 1.49 0 .433-.112.802-.335 1.107-.356.483-.95.725-1.784.725zm6.37 1.162v-6.036a64.19 64.19 0 00-.016-1.002 54.71 54.71 0 01-.018-1.124v-.308l2.885 8.47h1.347l2.864-8.47c0 .543-.004 1.05-.014 1.525-.009.474-.013.813-.013 1.018V22h1.292V11.958h-1.928l-2.864 8.49-2.885-8.49h-1.948V22h1.299zm14.452.26c1.353 0 2.429-.43 3.226-1.292.666-.716 1.058-1.602 1.176-2.66h-1.326c-.137.661-.344 1.195-.622 1.6-.524.77-1.288 1.155-2.29 1.155-1.09 0-1.908-.365-2.458-1.097-.549-.731-.823-1.685-.823-2.86 0-1.436.304-2.501.912-3.196.609-.695 1.407-1.043 2.396-1.043.811 0 1.441.19 1.89.568.45.378.749.89.9 1.538h1.325c-.077-.853-.467-1.614-1.168-2.284-.702-.67-1.689-1.004-2.96-1.004-1.49 0-2.66.515-3.507 1.544-.784.948-1.176 2.163-1.176 3.644 0 1.95.522 3.393 1.565 4.327.793.707 1.773 1.06 2.94 1.06z"/></g></svg>
\ No newline at end of file diff --git a/src/assets/styles/_obmc-sila.scss b/src/assets/styles/_obmc-sila.scss new file mode 100644 index 00000000..ea2507f0 --- /dev/null +++ b/src/assets/styles/_obmc-sila.scss @@ -0,0 +1,6 @@ +// Vendor styles +@import "./bootstrap"; +@import "~bootstrap-vue/src/index"; + +// IBS BMC styles +@import "./bmc/_sila"; diff --git a/src/assets/styles/bmc/_sila/_alert.scss b/src/assets/styles/bmc/_sila/_alert.scss new file mode 100644 index 00000000..0e78ba64 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_alert.scss @@ -0,0 +1,70 @@ +.alert { + display: flex; + padding: $spacer; + border-width: 0 0 0 3px; + color: gray("800"); + margin-bottom: $spacer; + + &.small { + padding: $spacer / 2; + font-size: 1rem; + } + + .close { + font-weight: 300; + opacity: 1; + } + + .alert-icon { + display: inline-flex; + align-items: flex-start; + margin-right: $spacer; + margin-bottom: $spacer; + + @include media-breakpoint-up(sm) { + margin-bottom: 0; + } + } + + .alert-content { + flex: 1 1 auto; + } + + .alert-title { + margin-bottom: $spacer / 2; + } + + .alert-msg { + p + p { + margin-bottom: $spacer; + } + + p:last-of-type { + margin-bottom: 0; + } + } + + &.alert-info { + border-left-color: theme-color("info"); + background-color: theme-color-light("info"); + fill: theme-color("info"); + } + + &.alert-success { + border-left-color: theme-color("success"); + background-color: theme-color-light("success"); + fill: theme-color("success"); + } + + &.alert-danger { + border-left-color: theme-color("danger"); + background-color: theme-color-light("danger"); + fill: theme-color("danger"); + } + + &.alert-warning { + border-left-color: theme-color("warning"); + background-color: theme-color-light("warning"); + fill: theme-color("warning"); + } + }
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_badge.scss b/src/assets/styles/bmc/_sila/_badge.scss new file mode 100644 index 00000000..0b88b499 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_badge.scss @@ -0,0 +1,21 @@ +.badge-pill { + // Need to explicitly set border-radius + // for pill variant because global $enable-rounded + // Bootstrap setting removes rounded pill style + border-radius: 10rem; + fill: currentColor; + font-weight: 400; + line-height: 1.5; + display: inline-flex; + .close { + font-size: 1em; + margin-left: $spacer/2; + font-weight: inherit; + color: inherit; + } +} + +.badge-primary { + background-color: theme-color-light("info"); + color: theme-color("info"); +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_base.scss b/src/assets/styles/bmc/_sila/_base.scss new file mode 100644 index 00000000..c11e046c --- /dev/null +++ b/src/assets/styles/bmc/_sila/_base.scss @@ -0,0 +1,50 @@ +dt, +legend, +label { + color: gray("800"); + font-size: 14px; + font-weight: 400; + line-height: 1.4285; +} + +h1, +.h1 { + font-size: 2.625rem; + font-weight: 300; + line-height: 1.238; +} + +h2, +.h2 { + font-size: 2.25rem; + font-weight: 300; + line-height: 1.3333; +} + +h3, +.h3 { + font-size: 1.75rem; + font-weight: 400; + line-height: 1.2857; +} + +h4, +.h4 { + font-size: 1.25rem; + font-weight: 400; + line-height: 1.3; +} + +h5, +.h5 { + font-size: 1rem; + font-weight: 500; + line-height: 1.375; +} + +h6, +.h6 { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.2857; +} diff --git a/src/assets/styles/bmc/_sila/_bootstrap-grid.scss b/src/assets/styles/bmc/_sila/_bootstrap-grid.scss new file mode 100644 index 00000000..7ad7c81b --- /dev/null +++ b/src/assets/styles/bmc/_sila/_bootstrap-grid.scss @@ -0,0 +1,8 @@ +.container-xl { + // Fluid layout container class sets 100% + // width until xl breakpoint. Once a max-width + // is set, setting the left margin to 0 is needed + // so the content doesn't center align + // https://bootstrap-vue.org/docs/components/layout#fluid-width-container + margin-left: 0; +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_buttons.scss b/src/assets/styles/bmc/_sila/_buttons.scss new file mode 100644 index 00000000..2a7b8169 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_buttons.scss @@ -0,0 +1,82 @@ +.btn { + padding-top: $spacer / 2; + padding-right: $spacer; + padding-bottom: $spacer / 2; + padding-left: $spacer; + display: inline-flex; + align-items: center; + justify-content: space-around; + svg { + margin-right: $spacer / 4; + } + &:disabled { + color: gray("600"); + fill: currentColor; + box-shadow: none !important; + &:not(.btn-link) { + border-color: gray("400"); + background-color: gray("400"); + } + } +} + +.btn-primary { + fill: currentColor; + &:focus, + &:not(:disabled):not(.disabled):active:focus { + border-color: $white; + box-shadow: inset 0 0 0 3px theme-color('primary'), inset 0 0 0 5px $white; + } +} + +.btn-secondary { + fill: currentColor; + &:focus, + &:not(:disabled):not(.disabled):active:focus { + border-color: $white; + box-shadow: inset 0 0 0 3px theme-color('secondary'), inset 0 0 0 5px $white; + } +} + +// Global style for all button link +.btn-link { + font-weight: $headings-font-weight; + fill: theme-color("primary"); + text-decoration: none !important; + &:hover { + background-color: gray("200"); + color: theme-color("primary"); + } + &:active { + background-color: gray("300"); + } + &:focus { + box-shadow: inset 0 0 0 2px theme-color("primary"); + color: theme-color("primary"); + outline: none; + } + &:disabled { + box-shadow: $btn-focus-box-shadow; + } +} + +// Icon only buttons +.btn-icon-only svg { + margin-right: 0; +} + +// Datepicker, clear search and Password toggle buttons +.input-action-btn, +.btn-datepicker { + position: absolute; + right: 0; + top: 0; + z-index: $zindex-dropdown + 1; +} + +// Contain input buttons within input +.btn-datepicker .dropdown-toggle, +.input-action-btn { + padding: 7px; + margin: 1px; +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_calendar.scss b/src/assets/styles/bmc/_sila/_calendar.scss new file mode 100644 index 00000000..0307a6ce --- /dev/null +++ b/src/assets/styles/bmc/_sila/_calendar.scss @@ -0,0 +1,17 @@ +.b-calendar-nav { + .btn { + &:hover { + background: none; + color: theme-color("dark"); + } + } +} + +.b-calendar-grid .btn { + display: inline-block; +} + +// Date picker focus +.b-calendar .b-calendar-grid { + padding: 6px 12px; +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_card.scss b/src/assets/styles/bmc/_sila/_card.scss new file mode 100644 index 00000000..5f2a5962 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_card.scss @@ -0,0 +1,5 @@ +.card { + .bg-success { + background-color: theme-color-light('success')!important; + } +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_dropdown.scss b/src/assets/styles/bmc/_sila/_dropdown.scss new file mode 100644 index 00000000..969c4c68 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_dropdown.scss @@ -0,0 +1,31 @@ +// Make calendar visible over the table +.dropdown-menu { + z-index: $zindex-dropdown + 1; + padding: 0; +} +.dropdown-item { + padding-left: $spacer/4; + margin-top: -1 * $spacer/4; +} +.b-dropdown-form { + padding: $spacer/2; + .form-group { + margin-bottom: $spacer/2; + } +} +// Table filter dropdown clear button style +.table-filter { + .dropdown-item { + &:hover { + background-color: gray("200"); + } + &:active { + background-color: gray("300"); + } + &:focus { + outline: none; + background-color: transparent; + box-shadow: inset 0 0 0 2px theme-color("primary"); + } + } +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_forms.scss b/src/assets/styles/bmc/_sila/_forms.scss new file mode 100644 index 00000000..428a40c2 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_forms.scss @@ -0,0 +1,132 @@ +// Helper text +.form-text { + font-size: $form-label-font-size; + line-height: $form-line-height; + margin-top: -$spacer / 4; + margin-bottom: $spacer / 2; + color: gray("700")!important; +} + +// Legend label +.col-form-label { + color: gray("800"); + font-size: $form-label-font-size; + line-height: $form-line-height; +} + +.form-group { + margin-bottom: $spacer * 2; +} + +.custom-select, +.form-control, +.input-group-text { + border-color: gray("500") !important; + background-color: gray("100"); +} + +.custom-select, +.form-control { + &:active { + border: 1px solid $primary!important; + } + &:focus { + color: theme-color("dark"); + background-color: gray("100"); + box-shadow: inset 0 0 0 3px gray("100"), inset 0 0 0 5px $primary !important; + } + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + &::placeholder { + color: gray("600"); + } + &.is-invalid, + &:invalid { + border: 1px solid theme-color("danger") !important; + } +} + +.custom-select, +.custom-control-label, +.form-control { + color: theme-color("dark") !important; + font-size: 1rem; +} + +// Inverted form colors +.form-background { + background-color: gray("100"); + .custom-select, + .form-control { + background-color: $white; + &:focus { + background-color: $white; + } + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + } +} + +.invalid-feedback { + font-size: $form-label-font-size; + line-height: $form-line-height; +} + +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after, +.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before, +.custom-control-input:checked ~ .custom-control-label::before { + background-color: $black; + border-color: $black; + cursor: pointer; +} + +.custom-control { + .custom-control-input[disabled=disabled] { + & + .custom-control-label { + // Disabled label for checkbox, radio, + // switch bootstrap form components + color: gray("600")!important; + } + } +} + +.custom-control-input:focus ~ .custom-control-label::before{ + box-shadow: 0 0 0 2px theme-color("primary"); +} + +.custom-control-label::after { + cursor: pointer; +} + +.b-form-tag-remove { + // X button to remove tag + font-weight: normal; +} + +.b-form-tags-button { + // Add button inside input field + white-space: nowrap; + margin-right: -$spacer; + &.btn-link-primary { + color: theme-color("primary"); + fill: currentColor; + } +} + +// Form validation icon + .form-control.is-invalid, + .form-control.is-valid { + background-position: right 1rem bottom 50%; + } + +// Form validation icon with datepicker or password toggle icon +.form-control-with-button { + &.is-invalid, + &.is-valid { + background-position: right 3rem bottom 50%; + } +} diff --git a/src/assets/styles/bmc/_sila/_index.scss b/src/assets/styles/bmc/_sila/_index.scss new file mode 100644 index 00000000..74594e35 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_index.scss @@ -0,0 +1,18 @@ +// OpenBMC Global Style Overrides of out of the box +// Bootstrap styles +@import "./alert"; +@import "./badge"; +@import "./base"; +@import "./bootstrap-grid"; +@import "./buttons"; +@import "./calendar"; +@import "./card"; +@import "./dropdown"; +@import "./forms"; +@import "./kvm"; +@import "./modal"; +@import "./pagination"; +@import "./section-divider"; +@import "./sol"; +@import "./tables"; +@import "./toasts";
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_kvm.scss b/src/assets/styles/bmc/_sila/_kvm.scss new file mode 100644 index 00000000..a7223844 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_kvm.scss @@ -0,0 +1,12 @@ +#terminal-kvm { + height: calc(100vh - 300px); + display: flex; + &.full-window { + height: calc(100vh - 80px); + } + div:nth-child(1) { + background: transparent !important; + display: block !important; + overflow: hidden !important; + } +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_modal.scss b/src/assets/styles/bmc/_sila/_modal.scss new file mode 100644 index 00000000..e2fa0cd8 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_modal.scss @@ -0,0 +1,12 @@ +.modal-header { + .close { + font-weight: normal; + color: theme-color("dark"); + opacity: 1; + } + .modal-title { + font-size: 1.25rem; + font-weight: normal; + line-height: 1.3; + } +} diff --git a/src/assets/styles/bmc/_sila/_pagination.scss b/src/assets/styles/bmc/_sila/_pagination.scss new file mode 100644 index 00000000..d38ce5d2 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_pagination.scss @@ -0,0 +1,24 @@ +.table-pagination-select { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + select { + width: fit-content; + } + label { + margin-left: $spacer; + line-height: $spacer * 2; + } +} + +.b-pagination { + @include media-breakpoint-up(sm) { + justify-content: flex-end; + } + .page-item.active button { + color: theme-color("dark"); + background-color: color("white"); + border-color: $border-color; + box-shadow: inset 0px -3px theme-color("primary"); + } +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_section-divider.scss b/src/assets/styles/bmc/_sila/_section-divider.scss new file mode 100644 index 00000000..620c9e56 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_section-divider.scss @@ -0,0 +1,3 @@ +.section-divider { + border-bottom: 1px solid gray('400'); + }
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_sol.scss b/src/assets/styles/bmc/_sila/_sol.scss new file mode 100644 index 00000000..6987cf79 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_sol.scss @@ -0,0 +1,3 @@ +#terminal .xterm .xterm-viewport { + overflow: auto; +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_tables.scss b/src/assets/styles/bmc/_sila/_tables.scss new file mode 100644 index 00000000..e8b5a832 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_tables.scss @@ -0,0 +1,171 @@ +.table { + position: relative; + z-index: $zindex-dropdown; + + td { + border-top: 1px solid gray("300"); + border-bottom: 1px solid gray("300"); + &:first-of-type { + border-left: 1px solid gray("300"); + } + &:last-of-type { + border-right: 1px solid gray("300"); + } + vertical-align: middle; + + // Table action buttons + .btn-link { + width: 40px; + height: 40px; + padding: 5px !important; + display: inline-flex; + justify-content: center; + align-items: center; + } + } + + // thead-light added for specificity + .thead-light th { + vertical-align: middle; + border-top: 1px solid gray("300"); + border-bottom: 1px solid gray("300"); + &:first-of-type { + border-left: 1px solid gray("300"); + } + &:last-of-type { + border-right: 1px solid gray("300"); + } + color: theme-color("dark"); + &:focus { + outline: none; + } + } + + .status-icon svg { + width: 1rem; + height: auto; + } + + .b-table-has-details { + td { + border-bottom: none; + } + .table-row-expand svg { + transform: rotate(180deg); + } + } + + .b-table-details { + background-color: theme-color("light"); + td { + padding-left: calc(50px + (#{$table-cell-padding} * 2)); + padding-right: calc(50px + (#{$table-cell-padding} * 2)); + } + dl { + margin: 0; + } + dt { + float: left; + clear: left; + margin-right: $spacer / 2; + } + dd { + line-height: 1.2 + } + } + + .table-row-expand { + width: 50px; + .btn { + padding: 0; + width: 50px; + } + svg { + fill: theme-color("dark"); + } + } + .b-table-sort-icon-left { + background-position: left calc(1.5rem / 2) center !important; + padding-left: calc(1.2rem + 0.65em) !important; + &:focus { + outline: none; + box-shadow: inset 0 0 0 2px theme-color('primary') !important; + } + &:hover { + background-color: theme-color-dark('light'); + } + } +} + +.b-table-sticky-header td { + border-top: none; +} + +// Table stacked style for small screen only +@include media-breakpoint-down(xs) { + .b-table-stacked-sm { + border: 1px solid gray("300"); + + tr { + + &:not(:first-child) > td[aria-colindex='1'] { + border-top: 1px solid gray("300"); + padding-top: 0.625rem; + } + + &:not(.b-table-empty-row) { + position: relative; // Restrict background color to get zebra striping for the row + + &::before, + &::after { + position: absolute; + top: 0; + height: 100%; + z-index: -1; + } + + &:before { + content: ''; + background-color: gray("200"); + width: 40%; + border-right: 1px solid gray("300"); + } + + &:after { + content: ''; + right: 0; + width: 60%; + } + + &:nth-child(even)::after { + background-color: gray("100"); // Zebra striping for the row + } + } + + td { + border: 0; + padding: 0.75rem; + text-align: left !important; + + &:last-of-type { + border-right: 0; + } + } + } + } + + .table.b-table.b-table-stacked-sm > tbody > tr > [data-label] { + &::before { + text-align: left; + padding-left: $spacer /2; + } + + > div { + padding-left: 1rem; + } + } + + .table.b-table.b-table-stacked-sm > tbody > tr > :first-child { + border-top-width: 1px; + } +} diff --git a/src/assets/styles/bmc/_sila/_toasts.scss b/src/assets/styles/bmc/_sila/_toasts.scss new file mode 100644 index 00000000..4e2ad7fa --- /dev/null +++ b/src/assets/styles/bmc/_sila/_toasts.scss @@ -0,0 +1,61 @@ +.b-toaster { + top: 75px!important; // make sure toasts do not hide top header +} + +// Toast component and status icon style +.toast { + padding: $spacer/2 $spacer/2 $spacer/2 $spacer+2; + border-width: 0 0 0 3px; + box-shadow: $box-shadow; + .close { + font-weight: 300; + opacity: 1; + } +} + +.toast-header { + display: flex; + align-items: flex-start; + background-color: inherit!important; //override specificity + border: none; + color: theme-color("dark")!important; //override specificity + padding-bottom: 0; +} + +.toast-icon { + display: flex; + margin-right: 1rem; + + svg { + margin-left: -2.5rem; + } + + + .close { + line-height: .9; + } +} + +.toast-body { + color: theme-color("dark"); + padding-top: 0; +} + +.b-toast-success .toast { + border-left-color: theme-color("success")!important; + background-color: theme-color-light("success")!important; +} + +.b-toast-info .toast { + border-left-color: theme-color("info")!important; + background-color: theme-color-light("info")!important; +} + +.b-toast-danger .toast { + border-left-color: theme-color("danger")!important; + background-color: theme-color-light("danger")!important; +} + +.b-toast-warning .toast { + border-left-color: theme-color("warning")!important; + background-color: theme-color-light("warning")!important; +}
\ No newline at end of file diff --git a/src/components/_sila/AppHeader/AppHeader.vue b/src/components/_sila/AppHeader/AppHeader.vue new file mode 100644 index 00000000..84e4588f --- /dev/null +++ b/src/components/_sila/AppHeader/AppHeader.vue @@ -0,0 +1,384 @@ +<template> + <div> + <header id="page-header"> + <a + class="link-skip-nav btn btn-light" + href="#main-content" + @click="setFocus" + > + {{ $t('appHeader.skipToContent') }} + </a> + + <b-navbar type="dark" :aria-label="$t('appHeader.applicationHeader')"> + <!-- Left aligned nav items --> + <b-button + id="app-header-trigger" + class="nav-trigger" + aria-hidden="true" + type="button" + variant="link" + :class="{ open: isNavigationOpen }" + @click="toggleNavigation" + > + <icon-close + v-if="isNavigationOpen" + :title="$t('appHeader.titleHideNavigation')" + /> + <icon-menu + v-if="!isNavigationOpen" + :title="$t('appHeader.titleShowNavigation')" + /> + </b-button> + <b-navbar-nav> + <b-navbar-brand + class="mr-0" + to="/" + data-test-id="appHeader-container-overview" + > + <img + class="header-logo" + src="@/assets/images/logo-header.svg" + :alt="altLogo" + /> + </b-navbar-brand> + <div v-if="isNavTagPresent" :key="routerKey" class="pl-2 nav-tags"> + <span>|</span> + <span class="pl-3 asset-tag">{{ assetTag }}</span> + <span class="pl-3">{{ modelType }}</span> + <span class="pl-3">{{ serialNumber }}</span> + </div> + </b-navbar-nav> + <!-- Right aligned nav items --> + <b-navbar-nav class="ml-auto helper-menu"> + <b-nav-item + to="/logs/event-logs" + data-test-id="appHeader-container-health" + > + <status-icon :status="healthStatusIcon" /> + {{ $t('appHeader.health') }} + </b-nav-item> + <b-nav-item + to="/operations/server-power-operations" + data-test-id="appHeader-container-power" + > + <status-icon :status="serverStatusIcon" /> + {{ $t('appHeader.power') }} + </b-nav-item> + <!-- Using LI elements instead of b-nav-item to support semantic button elements --> + <li class="nav-item"> + <b-button + id="app-header-refresh" + variant="link" + data-test-id="appHeader-button-refresh" + @click="refresh" + > + <icon-renew :title="$t('appHeader.titleRefresh')" /> + <span class="responsive-text">{{ $t('appHeader.refresh') }}</span> + </b-button> + </li> + <li class="nav-item"> + <b-dropdown + id="app-header-user" + variant="link" + right + data-test-id="appHeader-container-user" + > + <template #button-content> + <icon-avatar :title="$t('appHeader.titleProfile')" /> + <span class="responsive-text">{{ username }}</span> + </template> + <b-dropdown-item + to="/profile-settings" + data-test-id="appHeader-link-profile" + >{{ $t('appHeader.profileSettings') }} + </b-dropdown-item> + <b-dropdown-item + data-test-id="appHeader-link-logout" + @click="logout" + > + {{ $t('appHeader.logOut') }} + </b-dropdown-item> + </b-dropdown> + </li> + </b-navbar-nav> + </b-navbar> + </header> + <loading-bar /> + </div> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import IconAvatar from '@carbon/icons-vue/es/user--avatar/20'; +import IconClose from '@carbon/icons-vue/es/close/20'; +import IconMenu from '@carbon/icons-vue/es/menu/20'; +import IconRenew from '@carbon/icons-vue/es/renew/20'; +import StatusIcon from '@/components/Global/StatusIcon'; +import LoadingBar from '@/components/Global/LoadingBar'; + +export default { + name: 'AppHeader', + components: { + IconAvatar, + IconClose, + IconMenu, + IconRenew, + StatusIcon, + LoadingBar, + }, + mixins: [BVToastMixin], + props: { + routerKey: { + type: Number, + default: 0, + }, + }, + data() { + return { + isNavigationOpen: false, + altLogo: process.env.VUE_APP_COMPANY_NAME || 'Built on OpenBMC', + }; + }, + computed: { + isNavTagPresent() { + return this.assetTag || this.modelType || this.serialNumber; + }, + assetTag() { + return this.$store.getters['global/assetTag']; + }, + modelType() { + return this.$store.getters['global/modelType']; + }, + serialNumber() { + return this.$store.getters['global/serialNumber']; + }, + isAuthorized() { + return this.$store.getters['global/isAuthorized']; + }, + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + healthStatus() { + return this.$store.getters['eventLog/healthStatus']; + }, + serverStatusIcon() { + switch (this.serverStatus) { + case 'on': + return 'success'; + case 'error': + return 'danger'; + case 'diagnosticMode': + return 'warning'; + case 'off': + default: + return 'secondary'; + } + }, + healthStatusIcon() { + switch (this.healthStatus) { + case 'OK': + return 'success'; + case 'Warning': + return 'warning'; + case 'Critical': + return 'danger'; + default: + return 'secondary'; + } + }, + username() { + return this.$store.getters['global/username']; + }, + }, + watch: { + isAuthorized(value) { + if (value === false) { + this.errorToast(this.$t('global.toast.unAuthDescription'), { + title: this.$t('global.toast.unAuthTitle'), + }); + } + }, + }, + created() { + // Reset auth state to check if user is authenticated based + // on available browser cookies + this.$store.dispatch('authentication/resetStoreState'); + this.getSystemInfo(); + this.getEvents(); + }, + mounted() { + this.$root.$on( + 'change-is-navigation-open', + (isNavigationOpen) => (this.isNavigationOpen = isNavigationOpen) + ); + }, + methods: { + getSystemInfo() { + this.$store.dispatch('global/getSystemInfo'); + }, + getEvents() { + this.$store.dispatch('eventLog/getEventLogData'); + }, + refresh() { + this.$emit('refresh'); + }, + logout() { + this.$store.dispatch('authentication/logout'); + }, + toggleNavigation() { + this.$root.$emit('toggle-navigation'); + }, + setFocus(event) { + event.preventDefault(); + this.$root.$emit('skip-navigation'); + }, + }, +}; +</script> + +<style lang="scss"> +@mixin focus-box-shadow($padding-color: $navbar-color, $outline-color: $white) { + box-shadow: inset 0 0 0 3px $padding-color, inset 0 0 0 5px $outline-color; +} +.app-header { + .link-skip-nav { + position: absolute; + top: -60px; + left: 0.5rem; + z-index: $zindex-popover; + transition: $duration--moderate-01 $exit-easing--expressive; + &:focus { + top: 0.5rem; + transition-timing-function: $entrance-easing--expressive; + } + } + .navbar-text, + .nav-link, + .btn-link { + color: color('white') !important; + fill: currentColor; + padding: 0.68rem 1rem !important; + + &:hover { + background-color: theme-color-level(light, 10); + } + &:active { + background-color: theme-color-level(light, 9); + } + &:focus { + @include focus-box-shadow; + outline: 0; + } + } + + .nav-item { + fill: theme-color('light'); + } + + .navbar { + padding: 0; + background-color: $navbar-color; + @include media-breakpoint-up($responsive-layout-bp) { + height: $header-height; + } + + .helper-menu { + @include media-breakpoint-down(sm) { + background-color: gray('800'); + width: 100%; + justify-content: flex-end; + + .nav-link, + .btn { + padding: $spacer / 1.125 $spacer / 2; + } + + .nav-link:focus, + .btn:focus { + @include focus-box-shadow($gray-800); + } + } + + .responsive-text { + @include media-breakpoint-down(xs) { + @include sr-only; + } + } + } + } + + .navbar-nav { + @include media-breakpoint-up($responsive-layout-bp) { + padding: 0 $spacer; + } + align-items: center; + + .navbar-brand, + .nav-link { + transition: $focus-transition; + } + .nav-tags { + color: theme-color-level(light, 3); + @include media-breakpoint-down(xs) { + @include sr-only; + } + .asset-tag { + @include media-breakpoint-down($responsive-layout-bp) { + @include sr-only; + } + } + } + } + + .nav-trigger { + fill: theme-color('light'); + width: $header-height; + height: $header-height; + transition: none; + display: inline-flex; + flex: 0 0 20px; + align-items: center; + + svg { + margin: 0; + } + + &:hover { + fill: theme-color('light'); + background-color: theme-color-level(light, 10); + } + + &.open { + background-color: gray('800'); + } + + @include media-breakpoint-up($responsive-layout-bp) { + display: none; + } + } + + .dropdown-menu { + margin-top: 0; + + @include media-breakpoint-only(md) { + margin-top: 4px; + } + } + + .navbar-expand { + @include media-breakpoint-down(sm) { + flex-flow: wrap; + } + } +} + +.navbar-brand { + padding: $spacer/2; + height: $header-height; + line-height: 1; + &:focus { + box-shadow: inset 0 0 0 3px $navbar-color, inset 0 0 0 5px color('white'); + outline: 0; + } +} +</style> diff --git a/src/components/_sila/AppHeader/index.js b/src/components/_sila/AppHeader/index.js new file mode 100644 index 00000000..e180e80f --- /dev/null +++ b/src/components/_sila/AppHeader/index.js @@ -0,0 +1,2 @@ +import AppHeader from './AppHeader'; +export default AppHeader; diff --git a/src/components/_sila/AppNavigation/AppNavigation.vue b/src/components/_sila/AppNavigation/AppNavigation.vue new file mode 100644 index 00000000..acfabe76 --- /dev/null +++ b/src/components/_sila/AppNavigation/AppNavigation.vue @@ -0,0 +1,255 @@ +<template> + <div> + <div class="nav-container" :class="{ open: isNavigationOpen }"> + <nav ref="nav" :aria-label="$t('appNavigation.primaryNavigation')"> + <b-nav vertical class="mb-4"> + <template v-for="(navItem, index) in navigationItems"> + <!-- Navigation items with no children --> + <b-nav-item + v-if="!navItem.children" + :key="index" + :to="navItem.route" + :data-test-id="`nav-item-${navItem.id}`" + > + <component :is="navItem.icon" /> + {{ navItem.label }} + </b-nav-item> + + <!-- Navigation items with children --> + <li v-else :key="index" class="nav-item"> + <b-button + v-b-toggle="`${navItem.id}`" + variant="link" + :data-test-id="`nav-button-${navItem.id}`" + > + <component :is="navItem.icon" /> + {{ navItem.label }} + <icon-expand class="icon-expand" /> + </b-button> + <b-collapse :id="navItem.id" tag="ul" class="nav-item__nav"> + <li class="nav-item"> + <router-link + v-for="(subNavItem, i) of navItem.children" + :key="i" + :to="subNavItem.route" + :data-test-id="`nav-item-${subNavItem.id}`" + class="nav-link" + > + {{ subNavItem.label }} + </router-link> + </li> + </b-collapse> + </li> + </template> + </b-nav> + </nav> + </div> + <transition name="fade"> + <div + v-if="isNavigationOpen" + id="nav-overlay" + class="nav-overlay" + @click="toggleIsOpen" + ></div> + </transition> + </div> +</template> + +<script> +//Do not change Mixin import. +//Exact match alias set to support +//dotenv customizations. +import AppNavigationMixin from './AppNavigationMixin'; + +export default { + name: 'AppNavigation', + mixins: [AppNavigationMixin], + data() { + return { + isNavigationOpen: false, + }; + }, + watch: { + $route: function () { + this.isNavigationOpen = false; + }, + isNavigationOpen: function (isNavigationOpen) { + this.$root.$emit('change-is-navigation-open', isNavigationOpen); + }, + }, + mounted() { + this.$root.$on('toggle-navigation', () => this.toggleIsOpen()); + }, + methods: { + toggleIsOpen() { + this.isNavigationOpen = !this.isNavigationOpen; + }, + }, +}; +</script> + +<style scoped lang="scss"> +svg { + fill: currentColor; + height: 1.2rem; + width: 1.2rem; + margin-left: 0 !important; //!important overriding button specificity + vertical-align: text-bottom; + &:not(.icon-expand) { + margin-right: $spacer; + } +} + +.nav { + padding-top: $spacer / 4; + @include media-breakpoint-up($responsive-layout-bp) { + padding-top: $spacer; + } +} + +.nav-item__nav { + list-style: none; + padding-left: 0; + margin-left: 0; + + .nav-item { + outline: none; + } + + .nav-link { + padding-left: $spacer * 4; + outline: none; + + &:not(.nav-link--current) { + font-weight: normal; + } + } +} + +.btn-link { + display: inline-block; + width: 100%; + text-align: left; + text-decoration: none !important; + border-radius: 0; + + &.collapsed { + .icon-expand { + transform: rotate(180deg); + } + } +} + +.icon-expand { + float: right; + margin-top: $spacer / 4; +} + +.btn-link, +.nav-link { + position: relative; + font-weight: $headings-font-weight; + padding-left: $spacer; // defining consistent padding for links and buttons + padding-right: $spacer; + color: theme-color('secondary'); + + &:hover { + background-color: theme-color-level(dark, -10.5); + color: theme-color('dark'); + } + + &:focus { + background-color: theme-color-level(light, 0); + box-shadow: inset 0 0 0 2px theme-color('primary'); + color: theme-color('dark'); + outline: 0; + } + + &:active { + background-color: theme-color('secondary'); + color: $white; + } +} + +.nav-link--current { + font-weight: $headings-font-weight; + background-color: theme-color('secondary'); + color: theme-color('light'); + cursor: default; + box-shadow: none; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 4px; + background-color: theme-color('primary'); + } + + &:hover, + &:focus { + background-color: theme-color('secondary'); + color: theme-color('light'); + } +} + +.nav-container { + position: fixed; + width: $navigation-width; + top: $header-height; + bottom: 0; + left: 0; + z-index: $zindex-fixed; + overflow-y: auto; + background-color: theme-color('light'); + transform: translateX(-$navigation-width); + transition: transform $exit-easing--productive $duration--moderate-02; + border-right: 1px solid theme-color-level('light', 2.85); + + @include media-breakpoint-down(md) { + z-index: $zindex-fixed + 2; + } + + &.open, + &:focus-within { + transform: translateX(0); + transition-timing-function: $entrance-easing--productive; + } + + @include media-breakpoint-up($responsive-layout-bp) { + transition-duration: $duration--fast-01; + transform: translateX(0); + } +} + +.nav-overlay { + position: fixed; + top: $header-height; + bottom: 0; + left: 0; + right: 0; + z-index: $zindex-fixed + 1; + background-color: $black; + opacity: 0.5; + + &.fade-enter-active { + transition: opacity $duration--moderate-02 $entrance-easing--productive; + } + + &.fade-leave-active { + transition: opacity $duration--fast-02 $exit-easing--productive; + } + + &.fade-enter, // Remove this vue2 based only class when switching to vue3 + &.fade-enter-from, // This is vue3 based only class modified from 'fade-enter' + &.fade-leave-to { + opacity: 0; + } + + @include media-breakpoint-up($responsive-layout-bp) { + display: none; + } +} +</style> diff --git a/src/components/_sila/AppNavigation/AppNavigationMixin.js b/src/components/_sila/AppNavigation/AppNavigationMixin.js new file mode 100644 index 00000000..bbbbb1ee --- /dev/null +++ b/src/components/_sila/AppNavigation/AppNavigationMixin.js @@ -0,0 +1,182 @@ +import IconDashboard from '@carbon/icons-vue/es/dashboard/16'; +import IconTextLinkAnalysis from '@carbon/icons-vue/es/text-link--analysis/16'; +import IconDataCheck from '@carbon/icons-vue/es/data--check/16'; +import IconSettingsAdjust from '@carbon/icons-vue/es/settings--adjust/16'; +import IconSettings from '@carbon/icons-vue/es/settings/16'; +import IconSecurity from '@carbon/icons-vue/es/security/16'; +import IconChevronUp from '@carbon/icons-vue/es/chevron--up/16'; +import IconDataBase from '@carbon/icons-vue/es/data--base--alt/16'; + +const AppNavigationMixin = { + components: { + iconOverview: IconDashboard, + iconLogs: IconTextLinkAnalysis, + iconHealth: IconDataCheck, + iconControl: IconSettingsAdjust, + iconSettings: IconSettings, + iconSecurityAndAccess: IconSecurity, + iconExpand: IconChevronUp, + iconResourceManagement: IconDataBase, + }, + data() { + return { + navigationItems: [ + { + id: 'overview', + label: this.$t('appNavigation.overview'), + route: '/', + icon: 'iconOverview', + }, + { + id: 'logs', + label: this.$t('appNavigation.logs'), + icon: 'iconLogs', + children: [ + { + id: 'event-logs', + label: this.$t('appNavigation.eventLogs'), + route: '/logs/event-logs', + }, + { + id: 'post-code-logs', + label: this.$t('appNavigation.postCodeLogs'), + route: '/logs/post-code-logs', + }, + ], + }, + { + id: 'hardware-status', + label: this.$t('appNavigation.hardwareStatus'), + icon: 'iconHealth', + children: [ + { + id: 'inventory', + label: this.$t('appNavigation.inventory'), + route: '/hardware-status/inventory', + }, + { + id: 'sensors', + label: this.$t('appNavigation.sensors'), + route: '/hardware-status/sensors', + }, + ], + }, + { + id: 'operations', + label: this.$t('appNavigation.operations'), + icon: 'iconControl', + children: [ + { + id: 'factory-reset', + label: this.$t('appNavigation.factoryReset'), + route: '/operations/factory-reset', + }, + { + id: 'kvm', + label: this.$t('appNavigation.kvm'), + route: '/operations/kvm', + }, + { + id: 'key-clear', + label: this.$t('appNavigation.keyClear'), + route: '/operations/key-clear', + }, + { + id: 'firmware', + label: this.$t('appNavigation.firmware'), + route: '/operations/firmware', + }, + { + id: 'reboot-bmc', + label: this.$t('appNavigation.rebootBmc'), + route: '/operations/reboot-bmc', + }, + { + id: 'serial-over-lan', + label: this.$t('appNavigation.serialOverLan'), + route: '/operations/serial-over-lan', + }, + { + id: 'server-power-operations', + label: this.$t('appNavigation.serverPowerOperations'), + route: '/operations/server-power-operations', + }, + { + id: 'virtual-media', + label: this.$t('appNavigation.virtualMedia'), + route: '/operations/virtual-media', + }, + ], + }, + { + id: 'settings', + label: this.$t('appNavigation.settings'), + icon: 'iconSettings', + children: [ + { + id: 'date-time', + label: this.$t('appNavigation.dateTime'), + route: '/settings/date-time', + }, + { + id: 'network', + label: this.$t('appNavigation.network'), + route: '/settings/network', + }, + { + id: 'power-restore-policy', + label: this.$t('appNavigation.powerRestorePolicy'), + route: '/settings/power-restore-policy', + }, + ], + }, + { + id: 'security-and-access', + label: this.$t('appNavigation.securityAndAccess'), + icon: 'iconSecurityAndAccess', + children: [ + { + id: 'sessions', + label: this.$t('appNavigation.sessions'), + route: '/security-and-access/sessions', + }, + { + id: 'ldap', + label: this.$t('appNavigation.ldap'), + route: '/security-and-access/ldap', + }, + { + id: 'user-management', + label: this.$t('appNavigation.userManagement'), + route: '/security-and-access/user-management', + }, + { + id: 'policies', + label: this.$t('appNavigation.policies'), + route: '/security-and-access/policies', + }, + { + id: 'certificates', + label: this.$t('appNavigation.certificates'), + route: '/security-and-access/certificates', + }, + ], + }, + { + id: 'resource-management', + label: this.$t('appNavigation.resourceManagement'), + icon: 'iconResourceManagement', + children: [ + { + id: 'power', + label: this.$t('appNavigation.power'), + route: '/resource-management/power', + }, + ], + }, + ], + }; + }, +}; + +export default AppNavigationMixin; diff --git a/src/components/_sila/AppNavigation/index.js b/src/components/_sila/AppNavigation/index.js new file mode 100644 index 00000000..88fe8eb6 --- /dev/null +++ b/src/components/_sila/AppNavigation/index.js @@ -0,0 +1,2 @@ +import AppNavigation from './AppNavigation'; +export default AppNavigation; diff --git a/src/components/_sila/Global/Alert.vue b/src/components/_sila/Global/Alert.vue new file mode 100644 index 00000000..e8de9e27 --- /dev/null +++ b/src/components/_sila/Global/Alert.vue @@ -0,0 +1,47 @@ +<template> + <b-alert :show="show" :variant="variant" :class="{ small }"> + <div + v-if=" + variant == 'info' || + variant == 'success' || + variant == 'warning' || + variant == 'danger' + " + class="alert-icon" + > + <status-icon :status="variant" /> + </div> + <div class="alert-content"> + <div class="alert-msg"> + <slot /> + </div> + </div> + <div class="alert-action"> + <slot name="action"></slot> + </div> + </b-alert> +</template> + +<script> +import StatusIcon from '@/components/Global/StatusIcon'; +import { BAlert } from 'bootstrap-vue'; + +export default { + name: 'Alert', + components: { + BAlert: BAlert, + StatusIcon: StatusIcon, + }, + props: { + show: { + type: Boolean, + default: true, + }, + variant: { + type: String, + default: '', + }, + small: Boolean, + }, +}; +</script> diff --git a/src/components/_sila/Global/ButtonBackToTop.vue b/src/components/_sila/Global/ButtonBackToTop.vue new file mode 100644 index 00000000..9160c7b7 --- /dev/null +++ b/src/components/_sila/Global/ButtonBackToTop.vue @@ -0,0 +1,68 @@ +<template> + <b-button + id="scrollToTopBtn" + class="btn-top btn-icon-only" + :class="{ 'show-btn': showButton }" + variant="secondary" + :title="$t('global.ariaLabel.scrollToTop')" + @click="scrollToTop" + > + <icon-up-to-top /> + <span class="sr-only">{{ $t('global.ariaLabel.scrollToTop') }}</span> + </b-button> +</template> + +<script> +import UpToTop24 from '@carbon/icons-vue/es/up-to-top/24'; + +import { debounce } from 'lodash'; + +export default { + name: 'BackToTop', + components: { IconUpToTop: UpToTop24 }, + data() { + return { + showButton: false, + }; + }, + created() { + window.addEventListener('scroll', debounce(this.handleScroll, 200)); + }, + methods: { + handleScroll() { + document.documentElement.scrollTop > 500 + ? (this.showButton = true) + : (this.showButton = false); + }, + scrollToTop() { + document.documentElement.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn-top { + position: fixed; + bottom: 24px; + right: 24px; + + box-shadow: $box-shadow; + visibility: hidden; + opacity: 0; + transition: $transition-base; + z-index: $zindex-fixed; + + @media (min-width: 1600px) { + left: 1485px; + right: auto; + } +} +.show-btn { + visibility: visible; + opacity: 1; +} +</style> diff --git a/src/components/_sila/Global/FormFile.vue b/src/components/_sila/Global/FormFile.vue new file mode 100644 index 00000000..cf713acf --- /dev/null +++ b/src/components/_sila/Global/FormFile.vue @@ -0,0 +1,119 @@ +<template> + <div class="custom-form-file-container"> + <label> + <b-form-file + :id="id" + v-model="file" + :accept="accept" + :disabled="disabled" + :state="state" + plain + @input="$emit('input', file)" + > + </b-form-file> + <span + class="add-file-btn btn" + :class="{ + disabled, + 'btn-secondary': isSecondary, + 'btn-primary': !isSecondary, + }" + > + {{ $t('global.fileUpload.browseText') }} + </span> + <slot name="invalid"></slot> + </label> + <div v-if="file" class="clear-selected-file px-3 py-2 mt-2"> + {{ file ? file.name : '' }} + <b-button + variant="light" + class="px-2 ml-auto" + :disabled="disabled" + @click="file = null" + ><icon-close :title="$t('global.fileUpload.clearSelectedFile')" /><span + class="sr-only" + >{{ $t('global.fileUpload.clearSelectedFile') }}</span + > + </b-button> + </div> + </div> +</template> + +<script> +import { BFormFile } from 'bootstrap-vue'; +import IconClose from '@carbon/icons-vue/es/close/20'; + +export default { + name: 'FormFile', + components: { BFormFile, IconClose }, + props: { + id: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + accept: { + type: String, + default: '', + }, + state: { + type: Boolean, + default: true, + }, + variant: { + type: String, + default: 'secondary', + }, + }, + data() { + return { + file: null, + }; + }, + computed: { + isSecondary() { + return this.variant === 'secondary'; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.form-control-file { + opacity: 0; + height: 0; + &:focus + span { + box-shadow: inset 0 0 0 3px theme-color('primary'), inset 0 0 0 5px $white; + } +} + +// Get mouse pointer on complete element +.add-file-btn { + position: relative; + &.disabled { + border-color: gray('400'); + background-color: gray('400'); + color: gray('600'); + box-shadow: none !important; + } +} + +.clear-selected-file { + display: flex; + align-items: center; + background-color: theme-color('light'); + .btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + + &:focus { + box-shadow: inset 0 0 0 2px theme-color('primary'); + } + } +} +</style> diff --git a/src/components/_sila/Global/InfoTooltip.vue b/src/components/_sila/Global/InfoTooltip.vue new file mode 100644 index 00000000..c91109d1 --- /dev/null +++ b/src/components/_sila/Global/InfoTooltip.vue @@ -0,0 +1,35 @@ +<template> + <b-button + v-b-tooltip + variant="link" + class="btn-tooltip btn-icon-only" + :title="title" + > + <icon-tooltip /> + <span class="sr-only">{{ $t('global.ariaLabel.tooltip') }}</span> + </b-button> +</template> + +<script> +import IconTooltip from '@carbon/icons-vue/es/information/16'; + +export default { + components: { IconTooltip }, + props: { + title: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn-tooltip { + padding: 0; + line-height: 1em; + svg { + vertical-align: baseline; + } +} +</style> diff --git a/src/components/_sila/Global/InputPasswordToggle.vue b/src/components/_sila/Global/InputPasswordToggle.vue new file mode 100644 index 00000000..d2c0d4a6 --- /dev/null +++ b/src/components/_sila/Global/InputPasswordToggle.vue @@ -0,0 +1,54 @@ +<template> + <div class="input-password-toggle-container"> + <slot></slot> + <b-button + :title="togglePasswordLabel" + variant="link" + class="input-action-btn btn-icon-only" + :class="{ isVisible: isVisible }" + @click="toggleVisibility" + > + <icon-view-off v-if="isVisible" /> + <icon-view v-else /> + <span class="sr-only">{{ togglePasswordLabel }}</span> + </b-button> + </div> +</template> + +<script> +import IconView from '@carbon/icons-vue/es/view/20'; +import IconViewOff from '@carbon/icons-vue/es/view--off/20'; + +export default { + name: 'InputPasswordToggle', + components: { IconView, IconViewOff }, + data() { + return { + isVisible: false, + togglePasswordLabel: this.$t('global.ariaLabel.showPassword'), + }; + }, + methods: { + toggleVisibility() { + const firstChild = this.$children[0]; + const inputEl = firstChild ? firstChild.$el : null; + + this.isVisible = !this.isVisible; + + if (inputEl && inputEl.nodeName === 'INPUT') { + inputEl.type = this.isVisible ? 'text' : 'password'; + } + + this.isVisible + ? (this.togglePasswordLabel = this.$t('global.ariaLabel.hidePassword')) + : (this.togglePasswordLabel = this.$t('global.ariaLabel.showPassword')); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.input-password-toggle-container { + position: relative; +} +</style> diff --git a/src/components/_sila/Global/LoadingBar.vue b/src/components/_sila/Global/LoadingBar.vue new file mode 100644 index 00000000..0e9551b5 --- /dev/null +++ b/src/components/_sila/Global/LoadingBar.vue @@ -0,0 +1,93 @@ +<template> + <transition name="fade"> + <b-progress v-if="!isLoadingComplete"> + <b-progress-bar + striped + animated + :value="loadingIndicatorValue" + :aria-label="$t('global.ariaLabel.progressBar')" + /> + </b-progress> + </transition> +</template> + +<script> +export default { + data() { + return { + loadingIndicatorValue: 0, + isLoadingComplete: false, + loadingIntervalId: null, + timeoutId: null, + }; + }, + created() { + this.$root.$on('loader-start', () => { + this.startLoadingInterval(); + }); + this.$root.$on('loader-end', () => { + this.endLoadingInterval(); + }); + this.$root.$on('loader-hide', () => { + this.hideLoadingBar(); + }); + }, + methods: { + startLoadingInterval() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 0; + this.isLoadingComplete = false; + this.loadingIntervalId = setInterval(() => { + this.loadingIndicatorValue += 1; + if (this.loadingIndicatorValue > 100) this.clearLoadingInterval(); + }, 100); + }, + endLoadingInterval() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 100; + this.timeoutId = setTimeout(() => { + // Let animation complete before hiding + // the loading bar + this.isLoadingComplete = true; + }, 1000); + }, + hideLoadingBar() { + this.clearLoadingInterval(); + this.clearTimeout(); + this.loadingIndicatorValue = 0; + this.isLoadingComplete = true; + }, + clearLoadingInterval() { + if (this.loadingIntervalId) clearInterval(this.loadingIntervalId); + this.loadingIntervalId = null; + }, + clearTimeout() { + if (this.timeoutId) clearTimeout(this.timeoutId); + this.timeoutId = null; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.progress { + position: absolute; + left: 0; + right: 0; + bottom: -0.4rem; + opacity: 1; + transition: opacity $duration--moderate-01 $standard-easing--productive; + height: 0.4rem; + + &.fade-enter, // Remove this vue2 based only class when switching to vue3 + &.fade-enter-from, // This is vue3 based only class modified from 'fade-enter' + &.fade-leave-to { + opacity: 0; + } +} +.progress-bar { + background-color: $loading-color; +} +</style> diff --git a/src/components/_sila/Global/PageContainer.vue b/src/components/_sila/Global/PageContainer.vue new file mode 100644 index 00000000..ab4adb63 --- /dev/null +++ b/src/components/_sila/Global/PageContainer.vue @@ -0,0 +1,37 @@ +<template> + <main id="main-content" class="page-container"> + <slot /> + </main> +</template> + +<script> +import JumpLinkMixin from '@/components/Mixins/JumpLinkMixin'; +export default { + name: 'PageContainer', + mixins: [JumpLinkMixin], + created() { + this.$root.$on('skip-navigation', () => { + this.setFocus(this.$el); + }); + }, +}; +</script> +<style lang="scss" scoped> +main { + width: 100%; + height: 100%; + padding-top: $spacer * 1.5; + padding-bottom: $spacer * 3; + padding-left: $spacer; + padding-right: $spacer; + + &:focus-visible { + box-shadow: inset 0 0 0 2px theme-color('primary'); + outline: none; + } + + @include media-breakpoint-up($responsive-layout-bp) { + padding-left: $spacer * 2; + } +} +</style> diff --git a/src/components/_sila/Global/PageSection.vue b/src/components/_sila/Global/PageSection.vue new file mode 100644 index 00000000..dd39ddd5 --- /dev/null +++ b/src/components/_sila/Global/PageSection.vue @@ -0,0 +1,29 @@ +<template> + <div class="page-section"> + <h2 v-if="sectionTitle">{{ sectionTitle }}</h2> + <slot /> + </div> +</template> + +<script> +export default { + name: 'PageSection', + props: { + sectionTitle: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.page-section { + margin-bottom: $spacer * 4; +} + +h2 { + @include font-size($h3-font-size); + margin-bottom: $spacer; +} +</style> diff --git a/src/components/_sila/Global/PageTitle.vue b/src/components/_sila/Global/PageTitle.vue new file mode 100644 index 00000000..45c75edb --- /dev/null +++ b/src/components/_sila/Global/PageTitle.vue @@ -0,0 +1,32 @@ +<template> + <div class="page-title"> + <h1>{{ title }}</h1> + <p v-if="description">{{ description }}</p> + </div> +</template> + +<script> +export default { + name: 'PageTitle', + props: { + description: { + type: String, + default: '', + }, + }, + data() { + return { + title: this.$route.meta.title, + }; + }, +}; +</script> + +<style lang="scss" scoped> +.page-title { + margin-bottom: $spacer * 2; +} +p { + max-width: 72ch; +} +</style> diff --git a/src/components/_sila/Global/Search.vue b/src/components/_sila/Global/Search.vue new file mode 100644 index 00000000..ac8f9bfb --- /dev/null +++ b/src/components/_sila/Global/Search.vue @@ -0,0 +1,83 @@ +<template> + <div class="search-global"> + <b-form-group + :label="$t('global.form.search')" + :label-for="`searchInput-${_uid}`" + label-class="invisible" + class="mb-2" + > + <b-input-group size="md" class="align-items-center"> + <b-input-group-prepend> + <icon-search class="search-icon" /> + </b-input-group-prepend> + <b-form-input + :id="`searchInput-${_uid}`" + ref="searchInput" + v-model="filter" + class="search-input" + type="text" + :aria-label="$t('global.form.search')" + :placeholder="placeholder" + @input="onChangeInput" + > + </b-form-input> + <b-button + v-if="filter" + variant="link" + class="btn-icon-only input-action-btn" + :title="$t('global.ariaLabel.clearSearch')" + @click="onClearSearch" + > + <icon-close /> + <span class="sr-only">{{ $t('global.ariaLabel.clearSearch') }}</span> + </b-button> + </b-input-group> + </b-form-group> + </div> +</template> + +<script> +import IconSearch from '@carbon/icons-vue/es/search/16'; +import IconClose from '@carbon/icons-vue/es/close/20'; + +export default { + name: 'Search', + components: { IconSearch, IconClose }, + props: { + placeholder: { + type: String, + default: function () { + return this.$t('global.form.search'); + }, + }, + }, + data() { + return { + filter: null, + }; + }, + methods: { + onChangeInput() { + this.$emit('change-search', this.filter); + }, + onClearSearch() { + this.filter = ''; + this.$emit('clear-search'); + this.$refs.searchInput.focus(); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.search-input { + padding-left: ($spacer * 2); +} +.search-icon { + position: absolute; + left: 10px; + top: 12px; + z-index: 4; + stroke: gray('400'); +} +</style> diff --git a/src/components/_sila/Global/StatusIcon.vue b/src/components/_sila/Global/StatusIcon.vue new file mode 100644 index 00000000..4552633e --- /dev/null +++ b/src/components/_sila/Global/StatusIcon.vue @@ -0,0 +1,61 @@ +<template> + <span :class="['status-icon', status]"> + <icon-info v-if="status === 'info'" /> + <icon-success v-else-if="status === 'success'" /> + <icon-warning v-else-if="status === 'warning'" /> + <icon-danger v-else-if="status === 'danger'" /> + <icon-secondary v-else /> + </span> +</template> + +<script> +import IconInfo from '@carbon/icons-vue/es/information--filled/20'; +import IconCheckmark from '@carbon/icons-vue/es/checkmark--filled/20'; +import IconWarning from '@carbon/icons-vue/es/warning--filled/20'; +import IconError from '@carbon/icons-vue/es/error--filled/20'; +import IconMisuse from '@carbon/icons-vue/es/misuse/20'; + +export default { + name: 'StatusIcon', + components: { + IconInfo: IconInfo, + iconSuccess: IconCheckmark, + iconDanger: IconMisuse, + iconSecondary: IconError, + iconWarning: IconWarning, + }, + props: { + status: { + type: String, + default: '', + }, + }, +}; +</script> + +<style lang="scss" scoped> +.status-icon { + vertical-align: text-bottom; + + &.info { + color: theme-color('info'); + } + &.success { + color: theme-color('success'); + } + &.danger { + color: theme-color('danger'); + } + &.secondary { + color: gray('600'); + transform: rotate(-45deg); + } + &.warning { + color: theme-color('warning'); + } + + svg { + fill: currentColor; + } +} +</style> diff --git a/src/components/_sila/Global/TableCellCount.vue b/src/components/_sila/Global/TableCellCount.vue new file mode 100644 index 00000000..acb4d443 --- /dev/null +++ b/src/components/_sila/Global/TableCellCount.vue @@ -0,0 +1,35 @@ +<template> + <div class="mt-2"> + <p v-if="!filterActive"> + {{ $t('global.table.items', { count: totalNumberOfCells }) }} + </p> + <p v-else> + {{ + $t('global.table.selectedItems', { + count: totalNumberOfCells, + filterCount: filteredItemsCount, + }) + }} + </p> + </div> +</template> + +<script> +export default { + props: { + filteredItemsCount: { + type: Number, + required: true, + }, + totalNumberOfCells: { + type: Number, + required: true, + }, + }, + computed: { + filterActive() { + return this.filteredItemsCount !== this.totalNumberOfCells; + }, + }, +}; +</script> diff --git a/src/components/_sila/Global/TableDateFilter.vue b/src/components/_sila/Global/TableDateFilter.vue new file mode 100644 index 00000000..aa10cb5c --- /dev/null +++ b/src/components/_sila/Global/TableDateFilter.vue @@ -0,0 +1,165 @@ +<template> + <b-row class="mb-2"> + <b-col class="d-sm-flex"> + <b-form-group + :label="$t('global.table.fromDate')" + label-for="input-from-date" + class="mr-3 my-0 w-100" + > + <b-input-group> + <b-form-input + id="input-from-date" + v-model="fromDate" + placeholder="YYYY-MM-DD" + :state="getValidationState($v.fromDate)" + class="form-control-with-button mb-3 mb-md-0" + @blur="$v.fromDate.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.fromDate.pattern"> + {{ $t('global.form.invalidFormat') }} + </template> + <template v-if="!$v.fromDate.maxDate"> + {{ $t('global.form.dateMustBeBefore', { date: toDate }) }} + </template> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="fromDate" + class="btn-datepicker btn-icon-only" + button-only + right + :max="toDate" + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + button-variant="link" + aria-controls="input-from-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + <b-form-group + :label="$t('global.table.toDate')" + label-for="input-to-date" + class="my-0 w-100" + > + <b-input-group> + <b-form-input + id="input-to-date" + v-model="toDate" + placeholder="YYYY-MM-DD" + :state="getValidationState($v.toDate)" + class="form-control-with-button" + @blur="$v.toDate.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.toDate.pattern"> + {{ $t('global.form.invalidFormat') }} + </template> + <template v-if="!$v.toDate.minDate"> + {{ $t('global.form.dateMustBeAfter', { date: fromDate }) }} + </template> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="toDate" + class="btn-datepicker btn-icon-only" + button-only + right + :min="fromDate" + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + button-variant="link" + aria-controls="input-to-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + </b-col> + </b-row> +</template> + +<script> +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import { helpers } from 'vuelidate/lib/validators'; + +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +const isoDateRegex = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/; + +export default { + components: { IconCalendar }, + mixins: [VuelidateMixin], + data() { + return { + fromDate: '', + toDate: '', + offsetToDate: '', + locale: this.$store.getters['global/languagePreference'], + }; + }, + validations() { + return { + fromDate: { + pattern: helpers.regex('pattern', isoDateRegex), + maxDate: (value) => { + if (!this.toDate) return true; + const date = new Date(value); + const maxDate = new Date(this.toDate); + if (date.getTime() > maxDate.getTime()) return false; + return true; + }, + }, + toDate: { + pattern: helpers.regex('pattern', isoDateRegex), + minDate: (value) => { + if (!this.fromDate) return true; + const date = new Date(value); + const minDate = new Date(this.fromDate); + if (date.getTime() < minDate.getTime()) return false; + return true; + }, + }, + }; + }, + watch: { + fromDate() { + this.emitChange(); + }, + toDate(newVal) { + // Offset the end date to end of day to make sure all + // entries from selected end date are included in filter + this.offsetToDate = new Date(newVal).setUTCHours(23, 59, 59, 999); + this.emitChange(); + }, + }, + methods: { + emitChange() { + if (this.$v.$invalid) return; + this.$v.$reset(); //reset to re-validate on blur + this.$emit('change', { + fromDate: this.fromDate ? new Date(this.fromDate) : null, + toDate: this.toDate ? new Date(this.offsetToDate) : null, + }); + }, + }, +}; +</script> diff --git a/src/components/_sila/Global/TableFilter.vue b/src/components/_sila/Global/TableFilter.vue new file mode 100644 index 00000000..7c66bea6 --- /dev/null +++ b/src/components/_sila/Global/TableFilter.vue @@ -0,0 +1,114 @@ +<template> + <div class="table-filter d-inline-block"> + <p class="d-inline-block mb-0"> + <b-badge v-for="(tag, index) in tags" :key="index" pill> + {{ tag }} + <b-button-close + :disabled="dropdownVisible" + :aria-hidden="true" + @click="removeTag(tag)" + /> + </b-badge> + </p> + <b-dropdown + variant="link" + no-caret + right + data-test-id="tableFilter-dropdown-options" + @hide="dropdownVisible = false" + @show="dropdownVisible = true" + > + <template #button-content> + <icon-filter /> + {{ $t('global.action.filter') }} + </template> + <b-dropdown-form> + <b-form-group + v-for="(filter, index) of filters" + :key="index" + :label="filter.label" + > + <b-form-checkbox-group v-model="tags"> + <b-form-checkbox + v-for="value in filter.values" + :key="value" + :value="value" + :data-test-id="`tableFilter-checkbox-${value}`" + > + <b-dropdown-item> + {{ value }} + </b-dropdown-item> + </b-form-checkbox> + </b-form-checkbox-group> + </b-form-group> + </b-dropdown-form> + <b-dropdown-item-button + variant="primary" + data-test-id="tableFilter-button-clearAll" + @click="clearAllTags" + > + {{ $t('global.action.clearAll') }} + </b-dropdown-item-button> + </b-dropdown> + </div> +</template> + +<script> +import IconFilter from '@carbon/icons-vue/es/settings--adjust/20'; + +export default { + name: 'TableFilter', + components: { IconFilter }, + props: { + filters: { + type: Array, + default: () => [], + validator: (prop) => { + return prop.every( + (filter) => 'label' in filter && 'values' in filter && 'key' in filter + ); + }, + }, + }, + data() { + return { + dropdownVisible: false, + tags: [], + }; + }, + watch: { + tags: { + handler() { + this.emitChange(); + }, + deep: true, + }, + }, + methods: { + removeTag(removedTag) { + this.tags = this.tags.filter((tag) => tag !== removedTag); + }, + clearAllTags() { + this.tags = []; + }, + emitChange() { + const activeFilters = this.filters.map(({ key, values }) => { + const activeValues = values.filter( + (value) => this.tags.indexOf(value) !== -1 + ); + return { + key, + values: activeValues, + }; + }); + this.$emit('filter-change', { activeFilters }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.badge { + margin-right: $spacer / 2; +} +</style> diff --git a/src/components/_sila/Global/TableRowAction.vue b/src/components/_sila/Global/TableRowAction.vue new file mode 100644 index 00000000..549f1b52 --- /dev/null +++ b/src/components/_sila/Global/TableRowAction.vue @@ -0,0 +1,112 @@ +<template> + <span> + <b-link + v-if="value === 'export'" + class="align-bottom btn-icon-only py-0 btn-link" + :download="download" + :href="href" + :title="title" + > + <slot name="icon"> + {{ $t('global.action.export') }} + </slot> + <span v-if="btnIconOnly" class="sr-only">{{ title }}</span> + </b-link> + <b-link + v-else-if=" + value === 'download' && downloadInNewTab && downloadLocation !== '' + " + class="align-bottom btn-icon-only py-0 btn-link" + target="_blank" + :href="downloadLocation" + :title="title" + > + <slot name="icon" /> + <span class="sr-only"> + {{ $t('global.action.download') }} + </span> + </b-link> + <b-link + v-else-if="value === 'download' && downloadLocation !== ''" + class="align-bottom btn-icon-only py-0 btn-link" + :download="exportName" + :href="downloadLocation" + :title="title" + > + <slot name="icon" /> + <span class="sr-only"> + {{ $t('global.action.download') }} + </span> + </b-link> + <b-button + v-else-if="showButton" + variant="link" + :class="{ 'btn-icon-only': btnIconOnly }" + :disabled="!enabled" + :title="btnIconOnly ? title : !title" + @click="$emit('click-table-action', value)" + > + <slot name="icon"> + {{ title }} + </slot> + <span v-if="btnIconOnly" class="sr-only">{{ title }}</span> + </b-button> + </span> +</template> + +<script> +import { omit } from 'lodash'; + +export default { + name: 'TableRowAction', + props: { + value: { + type: String, + required: true, + }, + enabled: { + type: Boolean, + default: true, + }, + title: { + type: String, + default: null, + }, + rowData: { + type: Object, + default: () => {}, + }, + exportName: { + type: String, + default: 'export', + }, + downloadLocation: { + type: String, + default: '', + }, + btnIconOnly: { + type: Boolean, + default: true, + }, + downloadInNewTab: { + type: Boolean, + default: false, + }, + showButton: { + type: Boolean, + default: true, + }, + }, + computed: { + dataForExport() { + return JSON.stringify(omit(this.rowData, 'actions')); + }, + download() { + return `${this.exportName}.json`; + }, + href() { + return `data:text/json;charset=utf-8,${this.dataForExport}`; + }, + }, +}; +</script> diff --git a/src/components/_sila/Global/TableToolbar.vue b/src/components/_sila/Global/TableToolbar.vue new file mode 100644 index 00000000..5235feae --- /dev/null +++ b/src/components/_sila/Global/TableToolbar.vue @@ -0,0 +1,130 @@ +<template> + <transition name="slide"> + <div v-if="isToolbarActive" class="toolbar-container"> + <div class="toolbar-content"> + <p class="toolbar-selected"> + {{ selectedItemsCount }} {{ $t('global.action.selected') }} + </p> + <div class="toolbar-actions d-flex"> + <slot name="toolbar-buttons"></slot> + <b-button + v-for="(action, index) in actions" + :key="index" + :data-test-id="`table-button-${action.value}Selected`" + variant="primary" + class="d-block" + @click="$emit('batch-action', action.value)" + > + {{ action.label }} + </b-button> + <b-button + variant="secondary" + class="d-block" + @click="$emit('clear-selected')" + > + {{ $t('global.action.cancel') }} + </b-button> + </div> + </div> + </div> + </transition> +</template> + +<script> +export default { + name: 'TableToolbar', + props: { + selectedItemsCount: { + type: Number, + required: true, + }, + actions: { + type: Array, + default: () => [], + validator: (prop) => { + return prop.every((action) => { + return ( + Object.prototype.hasOwnProperty.call(action, 'value') && + Object.prototype.hasOwnProperty.call(action, 'label') + ); + }); + }, + }, + }, + data() { + return { + isToolbarActive: false, + }; + }, + watch: { + selectedItemsCount: function (selectedItemsCount) { + if (selectedItemsCount > 0) { + this.isToolbarActive = true; + } else { + this.isToolbarActive = false; + } + }, + }, +}; +</script> + +<style lang="scss" scoped> +$toolbar-height: 46px; + +.toolbar-container { + width: 100%; + position: relative; + z-index: $zindex-dropdown + 1; +} + +.toolbar-content { + height: $toolbar-height; + background-color: theme-color('primary'); + color: $white; + position: absolute; + left: 0; + right: 0; + top: -$toolbar-height; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.toolbar-selected { + line-height: $toolbar-height; + margin: 0; + padding: 0 $spacer; +} + +// Using v-deep to style export slot child-element +// depricated and vue-js 3 +.toolbar-actions ::v-deep .btn { + position: relative; + &:after { + content: ''; + position: absolute; + left: 0; + height: 1.5rem; + width: 1px; + background: rgba($white, 0.6); + } + &:last-child, + &:first-child { + &:after { + width: 0; + } + } +} + +.slide-enter-active { + transition: transform $duration--moderate-02 $entrance-easing--productive; +} +.slide-leave-active { + transition: transform $duration--moderate-02 $exit-easing--productive; +} +.slide-enter, // Remove this vue2 based only class when switching to vue3 +.slide-enter-from, // This is vue3 based only class modified from 'slide-enter' +.slide-leave-to { + transform: translateY($toolbar-height); +} +</style> diff --git a/src/components/_sila/Global/TableToolbarExport.vue b/src/components/_sila/Global/TableToolbarExport.vue new file mode 100644 index 00000000..69646ea6 --- /dev/null +++ b/src/components/_sila/Global/TableToolbarExport.vue @@ -0,0 +1,36 @@ +<template> + <b-button + class="d-flex align-items-center" + variant="primary" + :download="download" + :href="href" + > + {{ $t('global.action.export') }} + </b-button> +</template> + +<script> +export default { + props: { + data: { + type: Array, + default: () => [], + }, + fileName: { + type: String, + default: 'data', + }, + }, + computed: { + dataForExport() { + return JSON.stringify(this.data); + }, + download() { + return `${this.fileName}.json`; + }, + href() { + return `data:text/json;charset=utf-8,${this.dataForExport}`; + }, + }, +}; +</script> diff --git a/src/components/_sila/Mixins/BVPaginationMixin.js b/src/components/_sila/Mixins/BVPaginationMixin.js new file mode 100644 index 00000000..4ccf6f2c --- /dev/null +++ b/src/components/_sila/Mixins/BVPaginationMixin.js @@ -0,0 +1,34 @@ +import i18n from '@/i18n'; +export const currentPage = 1; +export const perPage = 20; +export const itemsPerPageOptions = [ + { + value: 10, + text: '10', + }, + { + value: 20, + text: '20', + }, + { + value: 30, + text: '30', + }, + { + value: 40, + text: '40', + }, + { + value: 0, + text: i18n.t('global.table.viewAll'), + }, +]; +const BVPaginationMixin = { + methods: { + getTotalRowCount(count) { + return this.perPage === 0 ? 0 : count; + }, + }, +}; + +export default BVPaginationMixin; diff --git a/src/components/_sila/Mixins/BVTableSelectableMixin.js b/src/components/_sila/Mixins/BVTableSelectableMixin.js new file mode 100644 index 00000000..b4f0b953 --- /dev/null +++ b/src/components/_sila/Mixins/BVTableSelectableMixin.js @@ -0,0 +1,41 @@ +export const selectedRows = []; +export const tableHeaderCheckboxModel = false; +export const tableHeaderCheckboxIndeterminate = false; + +const BVTableSelectableMixin = { + methods: { + clearSelectedRows(tableRef) { + if (tableRef) tableRef.clearSelected(); + }, + toggleSelectRow(tableRef, rowIndex) { + if (tableRef && rowIndex !== undefined) { + tableRef.isRowSelected(rowIndex) + ? tableRef.unselectRow(rowIndex) + : tableRef.selectRow(rowIndex); + } + }, + onRowSelected(selectedRows, totalRowsCount) { + if (selectedRows && totalRowsCount !== undefined) { + this.selectedRows = selectedRows; + if (selectedRows.length === 0) { + this.tableHeaderCheckboxIndeterminate = false; + this.tableHeaderCheckboxModel = false; + } else if (selectedRows.length === totalRowsCount) { + this.tableHeaderCheckboxIndeterminate = false; + this.tableHeaderCheckboxModel = true; + } else { + this.tableHeaderCheckboxIndeterminate = true; + this.tableHeaderCheckboxModel = true; + } + } + }, + onChangeHeaderCheckbox(tableRef) { + if (tableRef) { + if (this.tableHeaderCheckboxModel) tableRef.selectAllRows(); + else tableRef.clearSelected(); + } + }, + }, +}; + +export default BVTableSelectableMixin; diff --git a/src/components/_sila/Mixins/BVToastMixin.js b/src/components/_sila/Mixins/BVToastMixin.js new file mode 100644 index 00000000..a04ef438 --- /dev/null +++ b/src/components/_sila/Mixins/BVToastMixin.js @@ -0,0 +1,115 @@ +import StatusIcon from '../Global/StatusIcon'; + +const BVToastMixin = { + components: { + StatusIcon, + }, + methods: { + $_BVToastMixin_createTitle(title, status) { + const statusIcon = this.$createElement('StatusIcon', { + props: { status }, + }); + const titleWithIcon = this.$createElement( + 'strong', + { class: 'toast-icon' }, + [statusIcon, title] + ); + return titleWithIcon; + }, + $_BVToastMixin_createBody(messageBody) { + if (Array.isArray(messageBody)) { + return messageBody.map((message) => + this.$createElement('p', { class: 'mb-0' }, message) + ); + } else { + return [this.$createElement('p', { class: 'mb-0' }, messageBody)]; + } + }, + $_BVToastMixin_createTimestamp() { + const timestamp = this.$options.filters.formatTime(new Date()); + return this.$createElement('p', { class: 'mt-3 mb-0' }, timestamp); + }, + $_BVToastMixin_createRefreshAction() { + return this.$createElement( + 'BLink', + { + class: 'd-inline-block mt-3', + on: { + click: () => { + this.$root.$emit('refresh-application'); + }, + }, + }, + this.$t('global.action.refresh') + ); + }, + $_BVToastMixin_initToast(body, title, variant) { + this.$root.$bvToast.toast(body, { + title, + variant, + autoHideDelay: 10000, //auto hide in milliseconds + noAutoHide: variant !== 'success', + isStatus: true, + solid: true, + }); + }, + successToast( + message, + { + title: t = this.$t('global.status.success'), + timestamp, + refreshAction, + } = {} + ) { + const body = this.$_BVToastMixin_createBody(message); + const title = this.$_BVToastMixin_createTitle(t, 'success'); + if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction()); + if (timestamp) body.push(this.$_BVToastMixin_createTimestamp()); + this.$_BVToastMixin_initToast(body, title, 'success'); + }, + errorToast( + message, + { + title: t = this.$t('global.status.error'), + timestamp, + refreshAction, + } = {} + ) { + const body = this.$_BVToastMixin_createBody(message); + const title = this.$_BVToastMixin_createTitle(t, 'danger'); + if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction()); + if (timestamp) body.push(this.$_BVToastMixin_createTimestamp()); + this.$_BVToastMixin_initToast(body, title, 'danger'); + }, + warningToast( + message, + { + title: t = this.$t('global.status.warning'), + timestamp, + refreshAction, + } = {} + ) { + const body = this.$_BVToastMixin_createBody(message); + const title = this.$_BVToastMixin_createTitle(t, 'warning'); + if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction()); + if (timestamp) body.push(this.$_BVToastMixin_createTimestamp()); + this.$_BVToastMixin_initToast(body, title, 'warning'); + }, + infoToast( + message, + { + title: t = this.$t('global.status.informational'), + timestamp, + refreshAction, + } = {} + ) { + const body = this.$_BVToastMixin_createBody(message); + const title = this.$_BVToastMixin_createTitle(t, 'info'); + if (refreshAction) body.push(this.$_BVToastMixin_createRefreshAction()); + if (timestamp) body.push(this.$_BVToastMixin_createTimestamp()); + this.$_BVToastMixin_initToast(body, title, 'info'); + }, + }, +}; + +export default BVToastMixin; diff --git a/src/components/_sila/Mixins/DataFormatterMixin.js b/src/components/_sila/Mixins/DataFormatterMixin.js new file mode 100644 index 00000000..5ce79327 --- /dev/null +++ b/src/components/_sila/Mixins/DataFormatterMixin.js @@ -0,0 +1,30 @@ +const DataFormatterMixin = { + methods: { + dataFormatter(value) { + if (value === undefined || value === null || value === '') { + return '--'; + } else if (typeof value === 'number') { + return parseFloat(value.toFixed(3)); + } else { + return value; + } + }, + statusIcon(status) { + switch (status) { + case 'OK': + return 'success'; + case 'Warning': + return 'warning'; + case 'Critical': + return 'danger'; + default: + return ''; + } + }, + dataFormatterArray(value) { + return value.join(', '); + }, + }, +}; + +export default DataFormatterMixin; diff --git a/src/components/_sila/Mixins/JumpLinkMixin.js b/src/components/_sila/Mixins/JumpLinkMixin.js new file mode 100644 index 00000000..b038527b --- /dev/null +++ b/src/components/_sila/Mixins/JumpLinkMixin.js @@ -0,0 +1,27 @@ +const JumpLinkMixin = { + methods: { + setFocus(element) { + element.setAttribute('tabindex', '-1'); + element.focus(); + // Reason: https://axesslab.com/skip-links/#update-3-a-comment-from-gov-uk + element.removeAttribute('tabindex'); + }, + scrollToOffset(event) { + // Select element to scroll to + const ref = event.target.getAttribute('data-ref'); + const element = this.$refs[ref].$el; + + // Set focus and tabindex on selected element + this.setFocus(element); + + // Set scroll offset below header + const offset = element.offsetTop - 50; + window.scroll({ + top: offset, + behavior: 'smooth', + }); + }, + }, +}; + +export default JumpLinkMixin; diff --git a/src/components/_sila/Mixins/LoadingBarMixin.js b/src/components/_sila/Mixins/LoadingBarMixin.js new file mode 100644 index 00000000..d1152703 --- /dev/null +++ b/src/components/_sila/Mixins/LoadingBarMixin.js @@ -0,0 +1,19 @@ +export const loading = true; + +const LoadingBarMixin = { + methods: { + startLoader() { + this.$root.$emit('loader-start'); + this.loading = true; + }, + endLoader() { + this.$root.$emit('loader-end'); + this.loading = false; + }, + hideLoader() { + this.$root.$emit('loader-hide'); + }, + }, +}; + +export default LoadingBarMixin; diff --git a/src/components/_sila/Mixins/LocalTimezoneLabelMixin.js b/src/components/_sila/Mixins/LocalTimezoneLabelMixin.js new file mode 100644 index 00000000..6b4141c6 --- /dev/null +++ b/src/components/_sila/Mixins/LocalTimezoneLabelMixin.js @@ -0,0 +1,14 @@ +import { format } from 'date-fns-tz'; + +const LocalTimezoneLabelMixin = { + methods: { + localOffset() { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const shortTz = this.$options.filters.shortTimeZone(new Date()); + const pattern = `'${shortTz}' O`; + return format(new Date(), pattern, { timezone }).replace('GMT', 'UTC'); + }, + }, +}; + +export default LocalTimezoneLabelMixin; diff --git a/src/components/_sila/Mixins/SearchFilterMixin.js b/src/components/_sila/Mixins/SearchFilterMixin.js new file mode 100644 index 00000000..a4819e26 --- /dev/null +++ b/src/components/_sila/Mixins/SearchFilterMixin.js @@ -0,0 +1,14 @@ +export const searchFilter = null; + +const SearchFilterMixin = { + methods: { + onChangeSearchInput(searchValue) { + this.searchFilter = searchValue; + }, + onClearSearchInput() { + this.searchFilter = null; + }, + }, +}; + +export default SearchFilterMixin; diff --git a/src/components/_sila/Mixins/TableFilterMixin.js b/src/components/_sila/Mixins/TableFilterMixin.js new file mode 100644 index 00000000..7a2cc540 --- /dev/null +++ b/src/components/_sila/Mixins/TableFilterMixin.js @@ -0,0 +1,58 @@ +import { includes } from 'lodash'; + +const TableFilterMixin = { + methods: { + getFilteredTableData(tableData = [], filters = []) { + const filterItems = filters.reduce((arr, filter) => { + return [...arr, ...filter.values]; + }, []); + // If no filters are active, then return all table data + if (filterItems.length === 0) return tableData; + + // Check if row property value is included in list of + // active filters + return tableData.filter((row) => { + let returnRow = false; + for (const { key, values } of filters) { + const rowProperty = row[key]; + if (rowProperty && includes(values, rowProperty)) { + returnRow = true; + break; + } + } + return returnRow; + }); + }, + getFilteredTableDataByDate( + tableData = [], + startDate, + endDate, + propertyKey = 'date' + ) { + if (!startDate && !endDate) return tableData; + let startDateInMs = startDate ? startDate.getTime() : 0; + let endDateInMs = endDate ? endDate.getTime() : Number.POSITIVE_INFINITY; + + const isUtcDisplay = this.$store.getters['global/isUtcDisplay']; + + //Offset preference selected + if (!isUtcDisplay) { + startDateInMs = startDate + ? startDate.getTime() + startDate.getTimezoneOffset() * 60000 + : 0; + endDateInMs = endDate + ? endDate.getTime() + endDate.getTimezoneOffset() * 60000 + : Number.POSITIVE_INFINITY; + } + + return tableData.filter((row) => { + const date = row[propertyKey]; + if (!(date instanceof Date)) return; + const dateInMs = date.getTime(); + if (dateInMs >= startDateInMs && dateInMs <= endDateInMs) return row; + }); + }, + }, +}; + +export default TableFilterMixin; diff --git a/src/components/_sila/Mixins/TableRowExpandMixin.js b/src/components/_sila/Mixins/TableRowExpandMixin.js new file mode 100644 index 00000000..7f815a46 --- /dev/null +++ b/src/components/_sila/Mixins/TableRowExpandMixin.js @@ -0,0 +1,15 @@ +import i18n from '@/i18n'; +export const expandRowLabel = i18n.t('global.table.expandTableRow'); + +const TableRowExpandMixin = { + methods: { + toggleRowDetails(row) { + row.toggleDetails(); + row.detailsShowing + ? (this.expandRowLabel = this.$t('global.table.expandTableRow')) + : (this.expandRowLabel = this.$t('global.table.collapseTableRow')); + }, + }, +}; + +export default TableRowExpandMixin; diff --git a/src/components/_sila/Mixins/TableSortMixin.js b/src/components/_sila/Mixins/TableSortMixin.js new file mode 100644 index 00000000..c0997350 --- /dev/null +++ b/src/components/_sila/Mixins/TableSortMixin.js @@ -0,0 +1,11 @@ +const STATUS = ['OK', 'Warning', 'Critical']; + +const TableSortMixin = { + methods: { + sortStatus(a, b, key) { + return STATUS.indexOf(a[key]) - STATUS.indexOf(b[key]); + }, + }, +}; + +export default TableSortMixin; diff --git a/src/components/_sila/Mixins/VuelidateMixin.js b/src/components/_sila/Mixins/VuelidateMixin.js new file mode 100644 index 00000000..fec85251 --- /dev/null +++ b/src/components/_sila/Mixins/VuelidateMixin.js @@ -0,0 +1,10 @@ +const VuelidateMixin = { + methods: { + getValidationState(model) { + const { $dirty, $error } = model; + return $dirty ? !$error : null; + }, + }, +}; + +export default VuelidateMixin; diff --git a/src/env/assets/styles/_sila.scss b/src/env/assets/styles/_sila.scss new file mode 100644 index 00000000..884d62b5 --- /dev/null +++ b/src/env/assets/styles/_sila.scss @@ -0,0 +1,92 @@ +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-ExtraLight.woff2') format('woff2'); + font-weight: 200; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-ExtraLightItalic.woff2') format('woff2'); + font-weight: 200; + font-style: italic; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-Light.woff2') format('woff2'); + font-weight: 300; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-Regular.woff2') format('woff2'); + font-weight: 400; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-Italic.woff2') format('woff2'); + font-weight: 400; + font-style: italic; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-Medium.woff2') format('woff2'); + font-weight: 500; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-Bold.woff2') format('woff2'); + font-weight: 700; +} +@font-face { + font-family: 'Inter'; + src: url('~@/env/assets/fonts/Inter/Inter-BoldItalic.woff2') format('woff2'); + font-weight: 700; + font-style: italic; +} + +// IBS uses Inter https://github.com/rsms/inter + +$font-family-base: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif; + +$dark: #2c405a; + +$blue: #0070ff; +$red: #e11717; +$green: #34b233; +$yellow: #f5bd1f; + +$primary: $red; +$danger: $red; +$success: $green; +$warning: $yellow; + +$dark-hover: #3c506a; + +$red-hover: #FC2A2A; +$red-active: #df2323; +$red-disabled: #E17171; +$red-click: #C71414; +$red-shadow: #e1171780; +$red-light-background: #e117170d; + +$gray-2: #fbfbfc; +$gray-5: #1a3e5b0d; +$gray-5-hover: #1427351a; +$gray-10: #1a3e5b1a; +$gray-20: #1a3e5b33; +$red-40:#e1171766; + +$surface-secondary: #F3F4F5; +$on-surface-secondary: #040a0f99; +$on-surface-tretiatry: #040A0F4D; + +$text-primary: #0C1C29; +$text-secondary: #0C1C29E5; +$text-tretiatry: #0c1c2999; +$text-quaternary: #0c1c294d; + +$login-page-description-color: #0c1c2999; + +$border-radius: 8px; + +$loading-color: #c11d1d; +$navbar-color: $dark; + diff --git a/src/env/components/AppNavigation/sila.js b/src/env/components/AppNavigation/sila.js new file mode 100644 index 00000000..bbbbb1ee --- /dev/null +++ b/src/env/components/AppNavigation/sila.js @@ -0,0 +1,182 @@ +import IconDashboard from '@carbon/icons-vue/es/dashboard/16'; +import IconTextLinkAnalysis from '@carbon/icons-vue/es/text-link--analysis/16'; +import IconDataCheck from '@carbon/icons-vue/es/data--check/16'; +import IconSettingsAdjust from '@carbon/icons-vue/es/settings--adjust/16'; +import IconSettings from '@carbon/icons-vue/es/settings/16'; +import IconSecurity from '@carbon/icons-vue/es/security/16'; +import IconChevronUp from '@carbon/icons-vue/es/chevron--up/16'; +import IconDataBase from '@carbon/icons-vue/es/data--base--alt/16'; + +const AppNavigationMixin = { + components: { + iconOverview: IconDashboard, + iconLogs: IconTextLinkAnalysis, + iconHealth: IconDataCheck, + iconControl: IconSettingsAdjust, + iconSettings: IconSettings, + iconSecurityAndAccess: IconSecurity, + iconExpand: IconChevronUp, + iconResourceManagement: IconDataBase, + }, + data() { + return { + navigationItems: [ + { + id: 'overview', + label: this.$t('appNavigation.overview'), + route: '/', + icon: 'iconOverview', + }, + { + id: 'logs', + label: this.$t('appNavigation.logs'), + icon: 'iconLogs', + children: [ + { + id: 'event-logs', + label: this.$t('appNavigation.eventLogs'), + route: '/logs/event-logs', + }, + { + id: 'post-code-logs', + label: this.$t('appNavigation.postCodeLogs'), + route: '/logs/post-code-logs', + }, + ], + }, + { + id: 'hardware-status', + label: this.$t('appNavigation.hardwareStatus'), + icon: 'iconHealth', + children: [ + { + id: 'inventory', + label: this.$t('appNavigation.inventory'), + route: '/hardware-status/inventory', + }, + { + id: 'sensors', + label: this.$t('appNavigation.sensors'), + route: '/hardware-status/sensors', + }, + ], + }, + { + id: 'operations', + label: this.$t('appNavigation.operations'), + icon: 'iconControl', + children: [ + { + id: 'factory-reset', + label: this.$t('appNavigation.factoryReset'), + route: '/operations/factory-reset', + }, + { + id: 'kvm', + label: this.$t('appNavigation.kvm'), + route: '/operations/kvm', + }, + { + id: 'key-clear', + label: this.$t('appNavigation.keyClear'), + route: '/operations/key-clear', + }, + { + id: 'firmware', + label: this.$t('appNavigation.firmware'), + route: '/operations/firmware', + }, + { + id: 'reboot-bmc', + label: this.$t('appNavigation.rebootBmc'), + route: '/operations/reboot-bmc', + }, + { + id: 'serial-over-lan', + label: this.$t('appNavigation.serialOverLan'), + route: '/operations/serial-over-lan', + }, + { + id: 'server-power-operations', + label: this.$t('appNavigation.serverPowerOperations'), + route: '/operations/server-power-operations', + }, + { + id: 'virtual-media', + label: this.$t('appNavigation.virtualMedia'), + route: '/operations/virtual-media', + }, + ], + }, + { + id: 'settings', + label: this.$t('appNavigation.settings'), + icon: 'iconSettings', + children: [ + { + id: 'date-time', + label: this.$t('appNavigation.dateTime'), + route: '/settings/date-time', + }, + { + id: 'network', + label: this.$t('appNavigation.network'), + route: '/settings/network', + }, + { + id: 'power-restore-policy', + label: this.$t('appNavigation.powerRestorePolicy'), + route: '/settings/power-restore-policy', + }, + ], + }, + { + id: 'security-and-access', + label: this.$t('appNavigation.securityAndAccess'), + icon: 'iconSecurityAndAccess', + children: [ + { + id: 'sessions', + label: this.$t('appNavigation.sessions'), + route: '/security-and-access/sessions', + }, + { + id: 'ldap', + label: this.$t('appNavigation.ldap'), + route: '/security-and-access/ldap', + }, + { + id: 'user-management', + label: this.$t('appNavigation.userManagement'), + route: '/security-and-access/user-management', + }, + { + id: 'policies', + label: this.$t('appNavigation.policies'), + route: '/security-and-access/policies', + }, + { + id: 'certificates', + label: this.$t('appNavigation.certificates'), + route: '/security-and-access/certificates', + }, + ], + }, + { + id: 'resource-management', + label: this.$t('appNavigation.resourceManagement'), + icon: 'iconResourceManagement', + children: [ + { + id: 'power', + label: this.$t('appNavigation.power'), + route: '/resource-management/power', + }, + ], + }, + ], + }; + }, +}; + +export default AppNavigationMixin; diff --git a/src/env/router/sila.js b/src/env/router/sila.js new file mode 100644 index 00000000..39590cfa --- /dev/null +++ b/src/env/router/sila.js @@ -0,0 +1,286 @@ +import AppLayout from '@/layouts/_sila/AppLayout.vue'; +import ChangePassword from '@/views/_sila/ChangePassword'; +import Sessions from '@/views/_sila/SecurityAndAccess/Sessions'; +import ConsoleLayout from '@/layouts/_sila/ConsoleLayout.vue'; +import DateTime from '@/views/_sila/Settings/DateTime'; +import EventLogs from '@/views/_sila/Logs/EventLogs'; +import Firmware from '@/views/_sila/Operations/Firmware'; +import FactoryReset from '@/views/_sila/Operations/FactoryReset'; +import Inventory from '@/views/_sila/HardwareStatus/Inventory'; +import Kvm from '@/views/_sila/Operations/Kvm'; +import KvmConsole from '@/views/_sila/Operations/Kvm/KvmConsole'; +import Ldap from '@/views/_sila/SecurityAndAccess/Ldap'; +import UserManagement from '@/views/_sila/SecurityAndAccess/UserManagement'; +import Login from '@/views/_sila/Login'; +import LoginLayout from '@/layouts/_sila/LoginLayout'; +import Network from '@/views/_sila/Settings/Network'; +import Overview from '@/views/_sila/Overview'; +import PageNotFound from '@/views/_sila/PageNotFound'; +import PostCodeLogs from '@/views/_sila/Logs/PostCodeLogs'; +import PowerRestorePolicy from '@/views/_sila/Settings/PowerRestorePolicy'; +import ProfileSettings from '@/views/_sila/ProfileSettings'; +import RebootBmc from '@/views/_sila/Operations/RebootBmc'; +import Policies from '@/views/_sila/SecurityAndAccess/Policies'; +import Sensors from '@/views/_sila/HardwareStatus/Sensors'; +import SerialOverLan from '@/views/_sila/Operations/SerialOverLan'; +import SerialOverLanConsole from '@/views/_sila/Operations/SerialOverLan/SerialOverLanConsole'; +import ServerPowerOperations from '@/views/_sila/Operations/ServerPowerOperations'; +import KeyClear from '@/views/_sila/Operations/KeyClear'; +import Certificates from '@/views/_sila/SecurityAndAccess/Certificates'; +import VirtualMedia from '@/views/_sila/Operations/VirtualMedia'; +import Power from '@/views/_sila/ResourceManagement/Power'; +import i18n from '@/i18n'; + +const routes = [ + { + path: '/login', + component: LoginLayout, + children: [ + { + path: '', + name: 'login', + component: Login, + meta: { + title: i18n.t('appPageTitle.login'), + }, + }, + { + path: '/change-password', + name: 'change-password', + component: ChangePassword, + meta: { + title: i18n.t('appPageTitle.changePassword'), + requiresAuth: true, + }, + }, + ], + }, + { + path: '/console', + component: ConsoleLayout, + meta: { + requiresAuth: true, + }, + children: [ + { + path: 'serial-over-lan-console', + name: 'serial-over-lan-console', + component: SerialOverLanConsole, + meta: { + title: i18n.t('appPageTitle.serialOverLan'), + }, + }, + { + path: 'kvm', + name: 'kvm-console', + component: KvmConsole, + meta: { + title: i18n.t('appPageTitle.kvm'), + }, + }, + ], + }, + { + path: '/', + meta: { + requiresAuth: true, + }, + component: AppLayout, + children: [ + { + path: '', + name: 'overview', + component: Overview, + meta: { + title: i18n.t('appPageTitle.overview'), + }, + }, + { + path: '/profile-settings', + name: 'profile-settings', + component: ProfileSettings, + meta: { + title: i18n.t('appPageTitle.profileSettings'), + }, + }, + { + path: '/logs/event-logs', + name: 'event-logs', + component: EventLogs, + meta: { + title: i18n.t('appPageTitle.eventLogs'), + }, + }, + { + path: '/logs/post-code-logs', + name: 'post-code-logs', + component: PostCodeLogs, + meta: { + title: i18n.t('appPageTitle.postCodeLogs'), + }, + }, + { + path: '/hardware-status/inventory', + name: 'inventory', + component: Inventory, + meta: { + title: i18n.t('appPageTitle.inventory'), + }, + }, + { + path: '/hardware-status/sensors', + name: 'sensors', + component: Sensors, + meta: { + title: i18n.t('appPageTitle.sensors'), + }, + }, + { + path: '/security-and-access/sessions', + name: 'sessions', + component: Sessions, + meta: { + title: i18n.t('appPageTitle.sessions'), + }, + }, + { + path: '/security-and-access/ldap', + name: 'ldap', + component: Ldap, + meta: { + title: i18n.t('appPageTitle.ldap'), + }, + }, + { + path: '/security-and-access/user-management', + name: 'user-management', + component: UserManagement, + meta: { + title: i18n.t('appPageTitle.userManagement'), + }, + }, + { + path: '/security-and-access/policies', + name: 'policies', + component: Policies, + meta: { + title: i18n.t('appPageTitle.policies'), + }, + }, + { + path: '/security-and-access/certificates', + name: 'certificates', + component: Certificates, + meta: { + title: i18n.t('appPageTitle.certificates'), + }, + }, + { + path: '/settings/date-time', + name: 'date-time', + component: DateTime, + meta: { + title: i18n.t('appPageTitle.dateTime'), + }, + }, + { + path: '/operations/kvm', + name: 'kvm', + component: Kvm, + meta: { + title: i18n.t('appPageTitle.kvm'), + }, + }, + { + path: '/operations/firmware', + name: 'firmware', + component: Firmware, + meta: { + title: i18n.t('appPageTitle.firmware'), + }, + }, + { + path: '/settings/network', + name: 'network', + component: Network, + meta: { + title: i18n.t('appPageTitle.network'), + }, + }, + { + path: '/settings/power-restore-policy', + name: 'power-restore-policy', + component: PowerRestorePolicy, + meta: { + title: i18n.t('appPageTitle.powerRestorePolicy'), + }, + }, + { + path: '/resource-management/power', + name: 'power', + component: Power, + meta: { + title: i18n.t('appPageTitle.power'), + }, + }, + { + path: '/operations/factory-reset', + name: 'factory-reset', + component: FactoryReset, + meta: { + title: i18n.t('appPageTitle.factoryReset'), + }, + }, + { + path: '/operations/key-clear', + name: 'key-clear', + component: KeyClear, + meta: { + title: i18n.t('appPageTitle.keyClear'), + }, + }, + { + path: '/operations/reboot-bmc', + name: 'reboot-bmc', + component: RebootBmc, + meta: { + title: i18n.t('appPageTitle.rebootBmc'), + }, + }, + { + path: '/operations/serial-over-lan', + name: 'serial-over-lan', + component: SerialOverLan, + meta: { + title: i18n.t('appPageTitle.serialOverLan'), + }, + }, + { + path: '/operations/server-power-operations', + name: 'server-power-operations', + component: ServerPowerOperations, + meta: { + title: i18n.t('appPageTitle.serverPowerOperations'), + }, + }, + { + path: '/operations/virtual-media', + name: 'virtual-media', + component: VirtualMedia, + meta: { + title: i18n.t('appPageTitle.virtualMedia'), + }, + }, + { + path: '*', + name: 'page-not-found', + component: PageNotFound, + meta: { + title: i18n.t('appPageTitle.pageNotFound'), + }, + }, + ], + }, +]; + +export default routes; diff --git a/src/env/store/sila.js b/src/env/store/sila.js new file mode 100644 index 00000000..d0834c3b --- /dev/null +++ b/src/env/store/sila.js @@ -0,0 +1,10 @@ +import store from '@/store'; +import KeyClearStore from '@/store/modules/Operations/KeyClearStore'; + +store.registerModule('key-clear', KeyClearStore); + +// Use store.registerModule() to register env specific +// store modules +// https://vuex.vuejs.org/api/#registermodule + +export default store; diff --git a/src/layouts/_sila/AppLayout.vue b/src/layouts/_sila/AppLayout.vue new file mode 100644 index 00000000..0b78e5b1 --- /dev/null +++ b/src/layouts/_sila/AppLayout.vue @@ -0,0 +1,91 @@ +<template> + <div class="app-container"> + <app-header + ref="focusTarget" + class="app-header" + :router-key="routerKey" + @refresh="refresh" + /> + <app-navigation class="app-navigation" /> + <page-container class="app-content"> + <router-view ref="routerView" :key="routerKey" /> + <!-- Scroll to top button --> + <button-back-to-top /> + </page-container> + </div> +</template> + +<script> +import AppHeader from '@/components/AppHeader'; +import AppNavigation from '@/components/AppNavigation'; +import PageContainer from '@/components/Global/PageContainer'; +import ButtonBackToTop from '@/components/Global/ButtonBackToTop'; +import JumpLinkMixin from '@/components/Mixins/JumpLinkMixin'; + +export default { + name: 'App', + components: { + AppHeader, + AppNavigation, + PageContainer, + ButtonBackToTop, + }, + mixins: [JumpLinkMixin], + data() { + return { + routerKey: 0, + }; + }, + watch: { + $route: function () { + this.$nextTick(function () { + this.setFocus(this.$refs.focusTarget.$el); + }); + }, + }, + mounted() { + this.$root.$on('refresh-application', () => this.refresh()); + }, + methods: { + refresh() { + // Changing the component :key value will trigger + // a component re-rendering and 'refresh' the view + this.routerKey += 1; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.app-container { + display: grid; + grid-template-columns: 100%; + grid-template-rows: auto; + grid-template-areas: + 'header' + 'content'; + + @include media-breakpoint-up($responsive-layout-bp) { + grid-template-columns: $navigation-width 1fr; + grid-template-areas: + 'header header' + 'navigation content'; + } +} + +.app-header { + grid-area: header; + position: sticky; + top: 0; + z-index: $zindex-fixed + 1; +} + +.app-navigation { + grid-area: navigation; +} + +.app-content { + grid-area: content; + background-color: $white; +} +</style> diff --git a/src/layouts/_sila/ConsoleLayout.vue b/src/layouts/_sila/ConsoleLayout.vue new file mode 100644 index 00000000..9f8175bf --- /dev/null +++ b/src/layouts/_sila/ConsoleLayout.vue @@ -0,0 +1,9 @@ +<template> + <router-view /> +</template> + +<script> +export default { + name: 'Console', +}; +</script> diff --git a/src/layouts/_sila/LoginLayout.vue b/src/layouts/_sila/LoginLayout.vue new file mode 100644 index 00000000..cdff2040 --- /dev/null +++ b/src/layouts/_sila/LoginLayout.vue @@ -0,0 +1,112 @@ +<template> + <main> + <div class="login-container"> + <div class="login-main"> + <div> + <div class="login-brand mb-5"> + <img + width="90px" + src="@/assets/images/login-company-logo.svg" + :alt="altLogo" + /> + </div> + <h1 v-if="customizableGuiName" class="h3 mb-5"> + {{ customizableGuiName }} + </h1> + <router-view class="login=form form-background" /> + </div> + </div> + <div class="login-aside"> + <div class="login-aside__logo-brand"> + <!-- Add Secondary brand logo if needed --> + </div> + <div class="login-aside__logo-bmc"> + <img + height="60px" + src="@/assets/images/built-on-openbmc-logo.svg" + alt="Built on OpenBMC" + /> + </div> + </div> + </div> + </main> +</template> + +<script> +export default { + name: 'LoginLayout', + data() { + return { + altLogo: process.env.VUE_APP_COMPANY_NAME || 'OpenBMC', + customizableGuiName: process.env.VUE_APP_GUI_NAME || '', + }; + }, +}; +</script> + +<style lang="scss" scoped> +.login-container { + background: gray('100'); + display: flex; + flex-direction: column; + gap: $spacer * 2; + max-width: 1400px; + min-width: 320px; + min-height: 100vh; + justify-content: space-around; + + @include media-breakpoint-up('md') { + background: $white; + flex-direction: row; + } +} + +.login-main { + min-height: 50vh; + padding: $spacer * 3; + + @include media-breakpoint-up('md') { + background: gray('100'); + display: flex; + flex-direction: column; + flex: 1 1 75%; + min-height: 100vh; + justify-content: center; + align-items: center; + } +} + +.login-form { + @include media-breakpoint-up('md') { + max-width: 360px; + } +} + +.login-aside { + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: $spacer * 1.5; + margin-right: $spacer * 3; + margin-bottom: $spacer; + + @include media-breakpoint-up('md') { + min-height: 100vh; + padding-bottom: $spacer; + flex: 1 1 25%; + margin-bottom: 0; + } +} + +.login-aside__logo-brand:not(:empty) { + &::after { + content: ''; + display: inline-block; + height: 2.5rem; + width: 2px; + background-color: gray('200'); + margin-left: $spacer * 1.5; + vertical-align: middle; + } +} +</style> diff --git a/src/views/_sila/ChangePassword/ChangePassword.vue b/src/views/_sila/ChangePassword/ChangePassword.vue new file mode 100644 index 00000000..2440ace1 --- /dev/null +++ b/src/views/_sila/ChangePassword/ChangePassword.vue @@ -0,0 +1,134 @@ +<template> + <div class="change-password-container"> + <alert variant="danger" class="mb-4"> + <p v-if="changePasswordError"> + {{ $t('pageChangePassword.changePasswordError') }} + </p> + <p v-else>{{ $t('pageChangePassword.changePasswordAlertMessage') }}</p> + </alert> + <div class="change-password__form-container"> + <dl> + <dt>{{ $t('pageChangePassword.username') }}</dt> + <dd>{{ username }}</dd> + </dl> + <b-form novalidate @submit.prevent="changePassword"> + <b-form-group + label-for="password" + :label="$t('pageChangePassword.newPassword')" + > + <input-password-toggle> + <b-form-input + id="password" + v-model="form.password" + autofocus="autofocus" + type="password" + :state="getValidationState($v.form.password)" + class="form-control-with-button" + @change="$v.form.password.$touch()" + > + </b-form-input> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.password.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + <b-form-group + label-for="password-confirm" + :label="$t('pageChangePassword.confirmNewPassword')" + > + <input-password-toggle> + <b-form-input + id="password-confirm" + v-model="form.passwordConfirm" + type="password" + :state="getValidationState($v.form.passwordConfirm)" + class="form-control-with-button" + @change="$v.form.passwordConfirm.$touch()" + > + </b-form-input> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.passwordConfirm.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-else-if="!$v.form.passwordConfirm.sameAsPassword"> + {{ $t('global.form.passwordsDoNotMatch') }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + <div class="text-right"> + <b-button type="button" variant="link" @click="goBack"> + {{ $t('pageChangePassword.goBack') }} + </b-button> + <b-button type="submit" variant="primary"> + {{ $t('pageChangePassword.changePassword') }} + </b-button> + </div> + </b-form> + </div> + </div> +</template> + +<script> +import { required, sameAs } from 'vuelidate/lib/validators'; +import Alert from '@/components/Global/Alert'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'ChangePassword', + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin, BVToastMixin], + data() { + return { + form: { + password: null, + passwordConfirm: null, + }, + username: this.$store.getters['global/username'], + changePasswordError: false, + }; + }, + validations() { + return { + form: { + password: { required }, + passwordConfirm: { + required, + sameAsPassword: sameAs('password'), + }, + }, + }; + }, + methods: { + goBack() { + // Remove session created if navigating back to the Login page + this.$store.dispatch('authentication/logout'); + }, + changePassword() { + this.$v.$touch(); + if (this.$v.$invalid) return; + let data = { + originalUsername: this.username, + password: this.form.password, + }; + + this.$store + .dispatch('userManagement/updateUser', data) + .then(() => this.$router.push('/')) + .catch(() => (this.changePasswordError = true)); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.change-password__form-container { + @include media-breakpoint-up('md') { + max-width: 360px; + } +} +</style> diff --git a/src/views/_sila/ChangePassword/index.js b/src/views/_sila/ChangePassword/index.js new file mode 100644 index 00000000..9de0af42 --- /dev/null +++ b/src/views/_sila/ChangePassword/index.js @@ -0,0 +1,2 @@ +import ChangePassword from './ChangePassword.vue'; +export default ChangePassword; diff --git a/src/views/_sila/HardwareStatus/Inventory/Inventory.vue b/src/views/_sila/HardwareStatus/Inventory/Inventory.vue new file mode 100644 index 00000000..fcdbf8d2 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/Inventory.vue @@ -0,0 +1,196 @@ +<template> + <b-container fluid="xl"> + <page-title /> + + <!-- Service indicators --> + <service-indicator /> + + <!-- Quicklinks section --> + <page-section :section-title="$t('pageInventory.quicklinkTitle')"> + <b-row class="w-75"> + <b-col v-for="column in quicklinkColumns" :key="column.id" xl="4"> + <div v-for="item in column" :key="item.id"> + <b-link + :href="item.href" + :data-ref="item.dataRef" + @click.prevent="scrollToOffset" + > + <jump-link /> {{ item.linkText }} + </b-link> + </div> + </b-col> + </b-row> + </page-section> + + <!-- System table --> + <table-system ref="system" /> + + <!-- BMC manager table --> + <table-bmc-manager ref="bmc" /> + + <!-- Chassis table --> + <table-chassis ref="chassis" /> + + <!-- DIMM slot table --> + <table-dimm-slot ref="dimms" /> + + <!-- Fans table --> + <table-fans ref="fans" /> + + <!-- Power supplies table --> + <table-power-supplies ref="powerSupply" /> + + <!-- Processors table --> + <table-processors ref="processors" /> + + <!-- Assembly table --> + <table-assembly ref="assembly" /> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import ServiceIndicator from './InventoryServiceIndicator'; +import TableSystem from './InventoryTableSystem'; +import TablePowerSupplies from './InventoryTablePowerSupplies'; +import TableDimmSlot from './InventoryTableDimmSlot'; +import TableFans from './InventoryTableFans'; +import TableBmcManager from './InventoryTableBmcManager'; +import TableChassis from './InventoryTableChassis'; +import TableProcessors from './InventoryTableProcessors'; +import TableAssembly from './InventoryTableAssembly'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import PageSection from '@/components/Global/PageSection'; +import JumpLink16 from '@carbon/icons-vue/es/jump-link/16'; +import JumpLinkMixin from '@/components/Mixins/JumpLinkMixin'; +import { chunk } from 'lodash'; + +export default { + components: { + PageTitle, + ServiceIndicator, + TableDimmSlot, + TablePowerSupplies, + TableSystem, + TableFans, + TableBmcManager, + TableChassis, + TableProcessors, + TableAssembly, + PageSection, + JumpLink: JumpLink16, + }, + mixins: [LoadingBarMixin, JumpLinkMixin], + beforeRouteLeave(to, from, next) { + // Hide loader if user navigates away from page + // before requests complete + this.hideLoader(); + next(); + }, + data() { + return { + links: [ + { + id: 'system', + dataRef: 'system', + href: '#system', + linkText: this.$t('pageInventory.system'), + }, + { + id: 'bmc', + dataRef: 'bmc', + href: '#bmc', + linkText: this.$t('pageInventory.bmcManager'), + }, + { + id: 'chassis', + dataRef: 'chassis', + href: '#chassis', + linkText: this.$t('pageInventory.chassis'), + }, + { + id: 'dimms', + dataRef: 'dimms', + href: '#dimms', + linkText: this.$t('pageInventory.dimmSlot'), + }, + { + id: 'fans', + dataRef: 'fans', + href: '#fans', + linkText: this.$t('pageInventory.fans'), + }, + { + id: 'powerSupply', + dataRef: 'powerSupply', + href: '#powerSupply', + linkText: this.$t('pageInventory.powerSupplies'), + }, + { + id: 'processors', + dataRef: 'processors', + href: '#processors', + linkText: this.$t('pageInventory.processors'), + }, + { + id: 'assembly', + dataRef: 'assembly', + href: '#assembly', + linkText: this.$t('pageInventory.assemblies'), + }, + ], + }; + }, + computed: { + quicklinkColumns() { + // Chunk links array to 3 array's to display 3 items per column + return chunk(this.links, 3); + }, + }, + created() { + this.startLoader(); + const bmcManagerTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-bmc-manager-complete', () => resolve()); + }); + const chassisTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-chassis-complete', () => resolve()); + }); + const dimmSlotTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-dimm-slot-complete', () => resolve()); + }); + const fansTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-fans-complete', () => resolve()); + }); + const powerSuppliesTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-power-supplies-complete', () => + resolve() + ); + }); + const processorsTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-processors-complete', () => resolve()); + }); + const serviceIndicatorPromise = new Promise((resolve) => { + this.$root.$on('hardware-status-service-complete', () => resolve()); + }); + const systemTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-system-complete', () => resolve()); + }); + const assemblyTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-assembly-complete', () => resolve()); + }); + // Combine all child component Promises to indicate + // when page data load complete + Promise.all([ + bmcManagerTablePromise, + chassisTablePromise, + dimmSlotTablePromise, + fansTablePromise, + powerSuppliesTablePromise, + processorsTablePromise, + serviceIndicatorPromise, + systemTablePromise, + assemblyTablePromise, + ]).finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryServiceIndicator.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryServiceIndicator.vue new file mode 100644 index 00000000..01f4a446 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryServiceIndicator.vue @@ -0,0 +1,76 @@ +<template> + <page-section + :section-title="$t('pageInventory.systemIndicator.sectionTitle')" + > + <div class="form-background pl-4 pt-4 pb-1"> + <b-row> + <b-col sm="6" md="3"> + <dl> + <dt>{{ $t('pageInventory.systemIndicator.powerStatus') }}</dt> + <dd> + {{ $t(powerStatus) }} + </dd> + </dl> + </b-col> + <b-col sm="6" md="3"> + <dl> + <dt> + {{ $t('pageInventory.systemIndicator.identifyLed') }} + </dt> + <dd> + <b-form-checkbox + id="identifyLedSwitchService" + v-model="systems.locationIndicatorActive" + data-test-id="inventoryService-toggle-identifyLed" + switch + @change="toggleIdentifyLedSwitch" + > + <span v-if="systems.locationIndicatorActive"> + {{ $t('global.status.on') }} + </span> + <span v-else>{{ $t('global.status.off') }}</span> + </b-form-checkbox> + </dd> + </dl> + </b-col> + </b-row> + </div> + </page-section> +</template> +<script> +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + components: { PageSection }, + mixins: [BVToastMixin], + computed: { + systems() { + let systemData = this.$store.getters['system/systems'][0]; + return systemData ? systemData : {}; + }, + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + powerStatus() { + if (this.serverStatus === 'unreachable') { + return `global.status.off`; + } + return `global.status.${this.serverStatus}`; + }, + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-service-complete'); + }); + }, + methods: { + toggleIdentifyLedSwitch(state) { + this.$store + .dispatch('system/changeIdentifyLedState', state) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableAssembly.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableAssembly.vue new file mode 100644 index 00000000..b4010bfe --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableAssembly.vue @@ -0,0 +1,153 @@ +<template> + <page-section :section-title="$t('pageInventory.assemblies')"> + <b-table + sort-icon-left + no-sort-reset + hover + responsive="md" + :items="items" + :fields="fields" + show-empty + :empty-text="$t('global.table.emptyMessage')" + :busy="isBusy" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandAssembly" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Toggle identify LED --> + <template #cell(identifyLed)="row"> + <b-form-checkbox + v-if="hasIdentifyLed(row.item.identifyLed)" + v-model="row.item.identifyLed" + name="switch" + switch + @change="toggleIdentifyLedValue(row.item)" + > + <span v-if="row.item.identifyLed"> + {{ $t('global.status.on') }} + </span> + <span v-else> {{ $t('global.status.off') }} </span> + </b-form-checkbox> + <div v-else>--</div> + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <!-- Nmae --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + <!-- Serial number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <!-- Model--> + <dt>Model</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + <!-- Spare Part Number --> + <dt>Spare Part Number</dt> + <dd>{{ dataFormatter(item.sparePartNumber) }}</dd> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'name', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'partNumber', + label: this.$t('pageInventory.table.partNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + assemblies() { + return this.$store.getters['assemblies/assemblies']; + }, + items() { + if (this.assemblies) { + return this.assemblies; + } else { + return []; + } + }, + }, + created() { + this.$store.dispatch('assemblies/getAssemblyInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-assembly-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('assemblies/updateIdentifyLedValue', { + uri: row.uri, + memberId: row.id, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableBmcManager.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableBmcManager.vue new file mode 100644 index 00000000..48b914f4 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableBmcManager.vue @@ -0,0 +1,245 @@ +<template> + <page-section :section-title="$t('pageInventory.bmcManager')"> + <b-table + responsive="md" + hover + :items="items" + :fields="fields" + show-empty + :empty-text="$t('global.table.emptyMessage')" + :busy="isBusy" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandBmc" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + + <!-- Toggle identify LED --> + <template #cell(identifyLed)="row"> + <b-form-checkbox + v-if="hasIdentifyLed(row.item.identifyLed)" + v-model="row.item.identifyLed" + name="switch" + switch + @change="toggleIdentifyLedValue(row.item)" + > + <span v-if="row.item.identifyLed"> + {{ $t('global.status.on') }} + </span> + <span v-else> {{ $t('global.status.off') }} </span> + </b-form-checkbox> + <div v-else>--</div> + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Name --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + <!-- Part number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + <!-- Serial number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + <!-- Spare part number --> + <dt>{{ $t('pageInventory.table.sparePartNumber') }}:</dt> + <dd>{{ dataFormatter(item.sparePartNumber) }}</dd> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + <!-- UUID --> + <dt>{{ $t('pageInventory.table.uuid') }}:</dt> + <dd>{{ dataFormatter(item.uuid) }}</dd> + <!-- Service entry point UUID --> + <dt>{{ $t('pageInventory.table.serviceEntryPointUuid') }}:</dt> + <dd>{{ dataFormatter(item.serviceEntryPointUuid) }}</dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + <!-- Power state --> + <dt>{{ $t('pageInventory.table.power') }}:</dt> + <dd>{{ dataFormatter(item.powerState) }}</dd> + <!-- Health rollup --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.healthRollup) }}</dd> + <!-- BMC date and time --> + <dt>{{ $t('pageInventory.table.bmcDateTime') }}:</dt> + <dd> + {{ item.dateTime | formatDate }} + {{ item.dateTime | formatTime }} + </dd> + <!-- Reset date and time --> + <dt>{{ $t('pageInventory.table.lastResetTime') }}:</dt> + <dd> + {{ item.lastResetTime | formatDate }} + {{ item.lastResetTime | formatTime }} + </dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Manufacturer --> + <dt>{{ $t('pageInventory.table.manufacturer') }}:</dt> + <dd>{{ dataFormatter(item.manufacturer) }}</dd> + <!-- Description --> + <dt>{{ $t('pageInventory.table.description') }}:</dt> + <dd>{{ dataFormatter(item.description) }}</dd> + <!-- Manager type --> + <dt>{{ $t('pageInventory.table.managerType') }}:</dt> + <dd>{{ dataFormatter(item.managerType) }}</dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <!-- Firmware Version --> + <dl> + <dt>{{ $t('pageInventory.table.firmwareVersion') }}:</dt> + <dd>{{ item.firmwareVersion }}</dd> + </dl> + <!-- Graphical console --> + <p class="mt-1 mb-2 h6 float-none m-0"> + {{ $t('pageInventory.table.graphicalConsole') }} + </p> + <dl class="ml-4"> + <dt>{{ $t('pageInventory.table.connectTypesSupported') }}:</dt> + <dd> + {{ dataFormatterArray(item.graphicalConsoleConnectTypes) }} + </dd> + <dt>{{ $t('pageInventory.table.maxConcurrentSessions') }}:</dt> + <dd> + {{ dataFormatter(item.graphicalConsoleMaxSessions) }} + </dd> + <dt>{{ $t('pageInventory.table.serviceEnabled') }}:</dt> + <dd> + {{ dataFormatter(item.graphicalConsoleEnabled) }} + </dd> + </dl> + <!-- Serial console --> + <p class="mt-1 mb-2 h6 float-none m-0"> + {{ $t('pageInventory.table.serialConsole') }} + </p> + <dl class="ml-4"> + <dt>{{ $t('pageInventory.table.connectTypesSupported') }}:</dt> + <dd> + {{ dataFormatterArray(item.serialConsoleConnectTypes) }} + </dd> + <dt>{{ $t('pageInventory.table.maxConcurrentSessions') }}:</dt> + <dd>{{ dataFormatter(item.serialConsoleMaxSessions) }}</dd> + <dt>{{ $t('pageInventory.table.serviceEnabled') }}:</dt> + <dd>{{ dataFormatter(item.serialConsoleEnabled) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/Global/StatusIcon'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + bmc() { + return this.$store.getters['bmc/bmc']; + }, + items() { + if (this.bmc) { + return [this.bmc]; + } else { + return []; + } + }, + }, + created() { + this.$store.dispatch('bmc/getBmcInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-bmc-manager-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('bmc/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: remove hasIdentifyLed method once the following story is merged: + // https://gerrit.openbmc-project.xyz/c/openbmc/bmcweb/+/43179 + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableChassis.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableChassis.vue new file mode 100644 index 00000000..b49cec7f --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableChassis.vue @@ -0,0 +1,191 @@ +<template> + <page-section :section-title="$t('pageInventory.chassis')"> + <b-table + responsive="md" + hover + :items="chassis" + :fields="fields" + show-empty + :empty-text="$t('global.table.emptyMessage')" + :busy="isBusy" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandChassis" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + <!-- Toggle identify LED --> + <template #cell(identifyLed)="row"> + <b-form-checkbox + v-if="hasIdentifyLed(row.item.identifyLed)" + v-model="row.item.identifyLed" + name="switch" + switch + @change="toggleIdentifyLedValue(row.item)" + > + <span v-if="row.item.identifyLed"> + {{ $t('global.status.on') }} + </span> + <span v-else> {{ $t('global.status.off') }} </span> + </b-form-checkbox> + <div v-else>--</div> + </template> + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Name --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + <!-- Part number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + <!-- Serial Number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd class="mb-2"> + {{ dataFormatter(item.model) }} + </dd> + <!-- Asset tag --> + <dt>{{ $t('pageInventory.table.assetTag') }}:</dt> + <dd class="mb-2"> + {{ dataFormatter(item.assetTag) }} + </dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + <!-- Power state --> + <dt>{{ $t('pageInventory.table.power') }}:</dt> + <dd>{{ dataFormatter(item.power) }}</dd> + <!-- Health rollup --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.healthRollup) }}</dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Manufacturer --> + <dt>{{ $t('pageInventory.table.manufacturer') }}:</dt> + <dd>{{ dataFormatter(item.manufacturer) }}</dd> + <!-- Chassis Type --> + <dt>{{ $t('pageInventory.table.chassisType') }}:</dt> + <dd>{{ dataFormatter(item.chassisType) }}</dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Min power --> + <dt>{{ $t('pageInventory.table.minPowerWatts') }}:</dt> + <dd>{{ dataFormatter(item.minPowerWatts) }}</dd> + <!-- Max power --> + <dt>{{ $t('pageInventory.table.maxPowerWatts') }}:</dt> + <dd>{{ dataFormatter(item.maxPowerWatts) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import StatusIcon from '@/components/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + chassis() { + return this.$store.getters['chassis/chassis']; + }, + }, + created() { + this.$store.dispatch('chassis/getChassisInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-chassis-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('chassis/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: Remove this method when the LocationIndicatorActive is added from backend. + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableDimmSlot.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableDimmSlot.vue new file mode 100644 index 00000000..65994810 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableDimmSlot.vue @@ -0,0 +1,255 @@ +<template> + <page-section :section-title="$t('pageInventory.dimmSlot')"> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="6" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="dimms.length" + ></table-cell-count> + </b-col> + </b-row> + <b-table + sort-icon-left + no-sort-reset + hover + sort-by="health" + responsive="md" + show-empty + :items="dimms" + :fields="fields" + :sort-desc="true" + :sort-compare="sortCompare" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :busy="isBusy" + @filtered="onFiltered" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandDimms" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + <!-- Toggle identify LED --> + <template #cell(identifyLed)="row"> + <b-form-checkbox + v-model="row.item.identifyLed" + name="switch" + switch + @change="toggleIdentifyLedValue(row.item)" + > + <span v-if="row.item.identifyLed"> + {{ $t('global.status.on') }} + </span> + <span v-else> {{ $t('global.status.off') }} </span> + </b-form-checkbox> + </template> + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col sm="6" xl="6"> + <dl> + <!-- Part Number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + </dl> + <dl> + <!-- Serial Number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + </dl> + <dl> + <!-- Spare Part Number --> + <dt>{{ $t('pageInventory.table.sparePartNumber') }}:</dt> + <dd>{{ dataFormatter(item.sparePartNumber) }}</dd> + </dl> + <dl> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + </dl> + </b-col> + <b-col sm="6" xl="6"> + <dl> + <!-- Memory Size in kb --> + <dt>{{ $t('pageInventory.table.memorySize') }}:</dt> + <dd>{{ dataFormatter(item.memorySize) }} KB</dd> + </dl> + <dl> + <!-- Status--> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + </dl> + <dl> + <!-- Enabled--> + <dt>{{ $t('pageInventory.table.enabled') }}:</dt> + <dd>{{ dataFormatter(item.enabled) }}</dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col sm="6" xl="6"> + <dl> + <!-- Description --> + <dt>{{ $t('pageInventory.table.description') }}:</dt> + <dd>{{ dataFormatter(item.description) }}</dd> + </dl> + <dl> + <!-- Memory Type --> + <dt>{{ $t('pageInventory.table.memoryType') }}:</dt> + <dd>{{ dataFormatter(item.memoryType) }}</dd> + </dl> + <dl> + <!-- Base Module Type --> + <dt>{{ $t('pageInventory.table.baseModuleType') }}:</dt> + <dd>{{ dataFormatter(item.baseModuleType) }}</dd> + </dl> + <dl> + <!-- Capacity MiB --> + <dt>{{ $t('pageInventory.table.capacityMiB') }}:</dt> + <dd>{{ dataFormatter(item.capacityMiB) }}</dd> + </dl> + </b-col> + <b-col sm="6" xl="6"> + <dl> + <!-- Bus Width Bits --> + <dt>{{ $t('pageInventory.table.busWidthBits') }}:</dt> + <dd>{{ dataFormatter(item.busWidthBits) }}</dd> + </dl> + <dl> + <!-- Data Width Bits --> + <dt>{{ $t('pageInventory.table.dataWidthBits') }}:</dt> + <dd>{{ dataFormatter(item.dataWidthBits) }}</dd> + </dl> + <dl> + <!-- Operating Speed Mhz --> + <dt>{{ $t('pageInventory.table.operatingSpeedMhz') }}:</dt> + <dd>{{ dataFormatter(item.operatingSpeedMhz) }} MHz</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.dimms.length; + }, + dimms() { + return this.$store.getters['memory/dimms']; + }, + }, + created() { + this.$store.dispatch('memory/getDimms').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-dimm-slot-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + toggleIdentifyLedValue(row) { + this.$store + .dispatch('memory/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableFans.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableFans.vue new file mode 100644 index 00000000..fe788c53 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableFans.vue @@ -0,0 +1,190 @@ +<template> + <page-section :section-title="$t('pageInventory.fans')"> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="6" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="fans.length" + ></table-cell-count> + </b-col> + </b-row> + <b-table + sort-icon-left + no-sort-reset + hover + responsive="md" + sort-by="health" + show-empty + :items="fans" + :fields="fields" + :sort-desc="true" + :sort-compare="sortCompare" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :busy="isBusy" + @filtered="onFiltered" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandFans" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col sm="6" xl="4"> + <dl> + <!-- Name --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + </dl> + <dl> + <!-- Serial number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + </dl> + <dl> + <!-- Part number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + </dl> + <dl> + <!-- Fan speed --> + <dt>{{ $t('pageInventory.table.fanSpeed') }}:</dt> + <dd>{{ dataFormatter(item.speed) }}</dd> + </dl> + </b-col> + <b-col sm="6" xl="4"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + </dl> + <dl> + <!-- Health Rollup state --> + <dt>{{ $t('pageInventory.table.statusHealthRollup') }}:</dt> + <dd>{{ dataFormatter(item.healthRollup) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'partNumber', + label: this.$t('pageInventory.table.partNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'serialNumber', + label: this.$t('pageInventory.table.serialNumber'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.fans.length; + }, + fans() { + return this.$store.getters['fan/fans']; + }, + }, + created() { + this.$store.dispatch('fan/getFanInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-fans-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue new file mode 100644 index 00000000..aed7871a --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue @@ -0,0 +1,208 @@ +<template> + <page-section :section-title="$t('pageInventory.powerSupplies')"> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="6" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="powerSupplies.length" + ></table-cell-count> + </b-col> + </b-row> + <b-table + sort-icon-left + no-sort-reset + hover + responsive="md" + sort-by="health" + show-empty + :items="powerSupplies" + :fields="fields" + :sort-desc="true" + :sort-compare="sortCompare" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :busy="isBusy" + @filtered="onFiltered" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandPowerSupplies" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col sm="6" xl="4"> + <dl> + <!-- Name --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + <!-- Part number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + <!-- Serial number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + <!-- Spare part number --> + <dt>{{ $t('pageInventory.table.sparePartNumber') }}:</dt> + <dd>{{ dataFormatter(item.sparePartNumber) }}</dd> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + </dl> + </b-col> + <b-col sm="6" xl="4"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + <!-- Status Health rollup state --> + <dt>{{ $t('pageInventory.table.statusHealthRollup') }}:</dt> + <dd>{{ dataFormatter(item.statusHealth) }}</dd> + <!-- Efficiency percent --> + <dt>{{ $t('pageInventory.table.efficiencyPercent') }}:</dt> + <dd>{{ dataFormatter(item.efficiencyPercent) }}</dd> + <!-- Power input watts --> + <dt>{{ $t('pageInventory.table.powerInputWatts') }}:</dt> + <dd>{{ dataFormatter(item.powerInputWatts) }}</dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col sm="6" xl="4"> + <dl> + <!-- Manufacturer --> + <dt>{{ $t('pageInventory.table.manufacturer') }}:</dt> + <dd>{{ dataFormatter(item.manufacturer) }}</dd> + </dl> + </b-col> + <b-col sm="6" xl="4"> + <dl> + <!-- Firmware version --> + <dt>{{ $t('pageInventory.table.firmwareVersion') }}:</dt> + <dd>{{ dataFormatter(item.firmwareVersion) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.powerSupplies.length; + }, + powerSupplies() { + return this.$store.getters['powerSupply/powerSupplies']; + }, + }, + created() { + this.$store.dispatch('powerSupply/getAllPowerSupplies').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-power-supplies-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableProcessors.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableProcessors.vue new file mode 100644 index 00000000..7d5dd700 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableProcessors.vue @@ -0,0 +1,251 @@ +<template> + <page-section :section-title="$t('pageInventory.processors')"> + <!-- Search --> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="6" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="processors.length" + ></table-cell-count> + </b-col> + </b-row> + <b-table + sort-icon-left + no-sort-reset + hover + responsive="md" + show-empty + :items="processors" + :fields="fields" + :sort-desc="true" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :busy="isBusy" + @filtered="onFiltered" + > + <!-- Expand button --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandProcessors" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + + <!-- Toggle identify LED --> + <template #cell(identifyLed)="row"> + <b-form-checkbox + v-if="hasIdentifyLed(row.item.identifyLed)" + v-model="row.item.identifyLed" + name="switch" + switch + @change="toggleIdentifyLedValue(row.item)" + > + <span v-if="row.item.identifyLed"> + {{ $t('global.status.on') }} + </span> + <span v-else> {{ $t('global.status.off') }} </span> + </b-form-checkbox> + <div v-else>--</div> + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Name --> + <dt>{{ $t('pageInventory.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + <!-- Part Number --> + <dt>{{ $t('pageInventory.table.partNumber') }}:</dt> + <dd>{{ dataFormatter(item.partNumber) }}</dd> + <!-- Serial Number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + <!-- Spare Part Number --> + <dt>{{ $t('pageInventory.table.sparePartNumber') }}:</dt> + <dd>{{ dataFormatter(item.sparePartNumber) }}</dd> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + <!-- Asset Tag --> + <dt>{{ $t('pageInventory.table.assetTag') }}:</dt> + <dd>{{ dataFormatter(item.assetTag) }}</dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6" xl="6"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + <!-- Health Rollup --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.healthRollup) }}</dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col class="mt-1" sm="6" xl="6"> + <dl> + <!-- Manufacturer --> + <dt>{{ $t('pageInventory.table.manufacturer') }}:</dt> + <dd>{{ dataFormatter(item.manufacturer) }}</dd> + <!-- Processor Type --> + <dt>{{ $t('pageInventory.table.processorType') }}:</dt> + <dd>{{ dataFormatter(item.processorType) }}</dd> + <!-- Processor Architecture --> + <dt>{{ $t('pageInventory.table.processorArchitecture') }}:</dt> + <dd>{{ dataFormatter(item.processorArchitecture) }}</dd> + <!-- Instruction Set --> + <dt>{{ $t('pageInventory.table.instructionSet') }}:</dt> + <dd>{{ dataFormatter(item.instructionSet) }}</dd> + <!-- Version --> + <dt>{{ $t('pageInventory.table.version') }}:</dt> + <dd>{{ dataFormatter(item.version) }}</dd> + </dl> + </b-col> + <b-col class="mt-1" sm="6" xl="6"> + <dl> + <!-- Min Speed MHz --> + <dt>{{ $t('pageInventory.table.minSpeedMHz') }}:</dt> + <dd>{{ dataFormatter(item.minSpeedMHz) }}</dd> + <!-- Max Speed MHz --> + <dt>{{ $t('pageInventory.table.maxSpeedMHz') }}:</dt> + <dd>{{ dataFormatter(item.maxSpeedMHz) }}</dd> + <!-- Total Cores --> + <dt>{{ $t('pageInventory.table.totalCores') }}:</dt> + <dd>{{ dataFormatter(item.totalCores) }}</dd> + <!-- Total Threads --> + <dt>{{ $t('pageInventory.table.totalThreads') }}:</dt> + <dd>{{ dataFormatter(item.totalThreads) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + BVToastMixin, + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + sortable: false, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.processors.length; + }, + processors() { + return this.$store.getters['processors/processors']; + }, + }, + created() { + this.$store.dispatch('processors/getProcessorsInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-processors-complete'); + this.isBusy = false; + }); + }, + methods: { + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + toggleIdentifyLedValue(row) { + this.$store + .dispatch('processors/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: remove hasIdentifyLed when the following is merged: + // https://gerrit.openbmc-project.xyz/c/openbmc/bmcweb/+/37045 + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/InventoryTableSystem.vue b/src/views/_sila/HardwareStatus/Inventory/InventoryTableSystem.vue new file mode 100644 index 00000000..cf2cf020 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/InventoryTableSystem.vue @@ -0,0 +1,224 @@ +<template> + <page-section :section-title="$t('pageInventory.system')"> + <b-table + responsive="md" + hover + show-empty + :items="systems" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + :busy="isBusy" + > + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + data-test-id="hardwareStatus-button-expandSystem" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + <span class="sr-only">{{ expandRowLabel }}</span> + </b-button> + </template> + + <!-- Health --> + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + + <template #cell(locationIndicatorActive)="{ item }"> + <b-form-checkbox + id="identifyLedSwitchSystem" + v-model="item.locationIndicatorActive" + data-test-id="inventorySystem-toggle-identifyLed" + switch + @change="toggleIdentifyLedSwitch" + > + <span v-if="item.locationIndicatorActive"> + {{ $t('global.status.on') }} + </span> + <span v-else>{{ $t('global.status.off') }}</span> + </b-form-checkbox> + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col class="mt-2" sm="6"> + <dl> + <!-- Serial number --> + <dt>{{ $t('pageInventory.table.serialNumber') }}:</dt> + <dd>{{ dataFormatter(item.serialNumber) }}</dd> + <!-- Model --> + <dt>{{ $t('pageInventory.table.model') }}:</dt> + <dd>{{ dataFormatter(item.model) }}</dd> + <!-- Asset tag --> + <dt>{{ $t('pageInventory.table.assetTag') }}:</dt> + <dd class="mb-2"> + {{ dataFormatter(item.assetTag) }} + </dd> + </dl> + </b-col> + <b-col class="mt-2" sm="6"> + <dl> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.statusState) }}</dd> + <!-- Power state --> + <dt>{{ $t('pageInventory.table.power') }}:</dt> + <dd>{{ dataFormatter(item.powerState) }}</dd> + <!-- Health rollup --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.healthRollup) }}</dd> + </dl> + </b-col> + </b-row> + <div class="section-divider mb-3 mt-3"></div> + <b-row> + <b-col class="mt-1" sm="6"> + <dl> + <!-- Manufacturer --> + <dt>{{ $t('pageInventory.table.manufacturer') }}:</dt> + <dd>{{ dataFormatter(item.manufacturer) }}</dd> + <!-- Description --> + <dt>{{ $t('pageInventory.table.description') }}:</dt> + <dd>{{ dataFormatter(item.description) }}</dd> + <!-- Sub Model --> + <dt>{{ $t('pageInventory.table.subModel') }}:</dt> + <dd> + {{ dataFormatter(item.subModel) }} + </dd> + <!-- System Type --> + <dt>{{ $t('pageInventory.table.systemType') }}:</dt> + <dd> + {{ dataFormatter(item.systemType) }} + </dd> + </dl> + </b-col> + <b-col sm="6"> + <!-- Memory Summary --> + <p class="mt-1 mb-2 h6 float-none m-0"> + {{ $t('pageInventory.table.memorySummary') }} + </p> + <dl class="ml-4"> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.memorySummaryState) }}</dd> + <!-- Health --> + <dt>{{ $t('pageInventory.table.health') }}:</dt> + <dd>{{ dataFormatter(item.memorySummaryHealth) }}</dd> + <!-- Health Roll --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.memorySummaryHealthRollup) }}</dd> + <!-- Total system memory --> + <dt>{{ $t('pageInventory.table.totalSystemMemoryGiB') }}:</dt> + <dd>{{ dataFormatter(item.totalSystemMemoryGiB) }}GB</dd> + </dl> + <!-- Processor Summary --> + <p class="mt-1 mb-2 h6 float-none m-0"> + {{ $t('pageInventory.table.processorSummary') }} + </p> + <dl class="ml-4"> + <!-- Status state --> + <dt>{{ $t('pageInventory.table.statusState') }}:</dt> + <dd>{{ dataFormatter(item.processorSummaryState) }}</dd> + <!-- Health --> + <dt>{{ $t('pageInventory.table.health') }}:</dt> + <dd>{{ dataFormatter(item.processorSummaryHealth) }}</dd> + <!-- Health Rollup --> + <dt>{{ $t('pageInventory.table.healthRollup') }}:</dt> + <dd>{{ dataFormatter(item.processorSummaryHealthRoll) }}</dd> + <!-- Count --> + <dt>{{ $t('pageInventory.table.count') }}:</dt> + <dd>{{ dataFormatter(item.processorSummaryCount) }}</dd> + <!-- Core Count --> + <dt>{{ $t('pageInventory.table.coreCount') }}:</dt> + <dd>{{ dataFormatter(item.processorSummaryCoreCount) }}</dd> + </dl> + </b-col> + </b-row> + </b-container> + </template> + </b-table> + </page-section> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'hardwareType', + label: this.$t('pageInventory.table.hardwareType'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'locationIndicatorActive', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + systems() { + return this.$store.getters['system/systems']; + }, + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-system-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedSwitch(state) { + this.$store + .dispatch('system/changeIdentifyLedState', state) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Inventory/index.js b/src/views/_sila/HardwareStatus/Inventory/index.js new file mode 100644 index 00000000..c9fde8d2 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Inventory/index.js @@ -0,0 +1,2 @@ +import Inventory from './Inventory.vue'; +export default Inventory; diff --git a/src/views/_sila/HardwareStatus/Sensors/Sensors.vue b/src/views/_sila/HardwareStatus/Sensors/Sensors.vue new file mode 100644 index 00000000..6329d9d8 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Sensors/Sensors.vue @@ -0,0 +1,256 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + :placeholder="$t('pageSensors.searchForSensors')" + data-test-id="sensors-input-searchForSensors" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="3" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allSensors.length" + ></table-cell-count> + </b-col> + <b-col sm="3" md="4" xl="6" class="text-right"> + <table-filter :filters="tableFilters" @filter-change="onFilterChange" /> + </b-col> + </b-row> + <b-row> + <b-col xl="12"> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + @clear-selected="clearSelectedRows($refs.table)" + > + <template #toolbar-buttons> + <table-toolbar-export + :data="selectedRows" + :file-name="exportFileNameByDate()" + /> + </template> + </table-toolbar> + <b-table + ref="table" + responsive="md" + selectable + no-select-on-click + sort-icon-left + hover + no-sort-reset + sticky-header="75vh" + sort-by="status" + show-empty + :no-border-collapse="true" + :items="filteredSensors" + :fields="fields" + :sort-desc="true" + :sort-compare="sortCompare" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :busy="isBusy" + @filtered="onFiltered" + @row-selected="onRowSelected($event, filteredSensors.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <template #cell(status)="{ value }"> + <status-icon :status="statusIcon(value)" /> {{ value }} + </template> + <template #cell(currentValue)="data"> + {{ data.value }} {{ data.item.units }} + </template> + <template #cell(lowerCaution)="data"> + {{ data.value }} {{ data.item.units }} + </template> + <template #cell(upperCaution)="data"> + {{ data.value }} {{ data.item.units }} + </template> + <template #cell(lowerCritical)="data"> + {{ data.value }} {{ data.item.units }} + </template> + <template #cell(upperCritical)="data"> + {{ data.value }} {{ data.item.units }} + </template> + </b-table> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import StatusIcon from '@/components/Global/StatusIcon'; +import TableFilter from '@/components/Global/TableFilter'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + name: 'Sensors', + components: { + PageTitle, + Search, + StatusIcon, + TableCellCount, + TableFilter, + TableToolbar, + TableToolbarExport, + }, + mixins: [ + TableFilterMixin, + BVTableSelectableMixin, + LoadingBarMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + label: '', + }, + { + key: 'name', + sortable: true, + label: this.$t('pageSensors.table.name'), + }, + { + key: 'status', + sortable: true, + label: this.$t('pageSensors.table.status'), + tdClass: 'text-nowrap', + }, + { + key: 'lowerCritical', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.lowerCritical'), + }, + { + key: 'lowerCaution', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.lowerWarning'), + }, + + { + key: 'currentValue', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.currentValue'), + }, + { + key: 'upperCaution', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.upperWarning'), + }, + { + key: 'upperCritical', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.upperCritical'), + }, + ], + tableFilters: [ + { + key: 'status', + label: this.$t('pageSensors.table.status'), + values: ['OK', 'Warning', 'Critical'], + }, + ], + activeFilters: [], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + allSensors() { + return this.$store.getters['sensors/sensors']; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredSensors.length; + }, + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('sensors/getAllSensors').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'status') { + return this.sortStatus(a, b, key); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeSearchInput(event) { + this.searchFilter = event; + }, + exportFileNameByDate() { + // Create export file name based on date + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + return this.$t('pageSensors.exportFilePrefix') + date; + }, + }, +}; +</script> diff --git a/src/views/_sila/HardwareStatus/Sensors/index.js b/src/views/_sila/HardwareStatus/Sensors/index.js new file mode 100644 index 00000000..fc71b611 --- /dev/null +++ b/src/views/_sila/HardwareStatus/Sensors/index.js @@ -0,0 +1,2 @@ +import Sensors from './Sensors.vue'; +export default Sensors; diff --git a/src/views/_sila/Login/Login.vue b/src/views/_sila/Login/Login.vue new file mode 100644 index 00000000..8d96573a --- /dev/null +++ b/src/views/_sila/Login/Login.vue @@ -0,0 +1,146 @@ +<template> + <b-form class="login-form" novalidate @submit.prevent="login"> + <alert class="login-error mb-4" :show="authError" variant="danger"> + <p id="login-error-alert"> + {{ $t('pageLogin.alert.message') }} + </p> + </alert> + <b-form-group label-for="language" :label="$t('pageLogin.language')"> + <b-form-select + id="language" + v-model="$i18n.locale" + :options="languages" + data-test-id="login-select-language" + ></b-form-select> + </b-form-group> + <b-form-group label-for="username" :label="$t('pageLogin.username')"> + <b-form-input + id="username" + v-model="userInfo.username" + aria-describedby="login-error-alert username-required" + :state="getValidationState($v.userInfo.username)" + type="text" + autofocus="autofocus" + data-test-id="login-input-username" + @input="$v.userInfo.username.$touch()" + > + </b-form-input> + <b-form-invalid-feedback id="username-required" role="alert"> + <template v-if="!$v.userInfo.username.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + <div class="login-form__section mb-3"> + <label for="password">{{ $t('pageLogin.password') }}</label> + <input-password-toggle> + <b-form-input + id="password" + v-model="userInfo.password" + aria-describedby="login-error-alert password-required" + :state="getValidationState($v.userInfo.password)" + type="password" + data-test-id="login-input-password" + class="form-control-with-button" + @input="$v.userInfo.password.$touch()" + > + </b-form-input> + </input-password-toggle> + <b-form-invalid-feedback id="password-required" role="alert"> + <template v-if="!$v.userInfo.password.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </div> + <b-button + class="mt-3" + type="submit" + variant="primary" + data-test-id="login-button-submit" + :disabled="disableSubmitButton" + >{{ $t('pageLogin.logIn') }}</b-button + > + </b-form> +</template> + +<script> +import { required } from 'vuelidate/lib/validators'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import i18n from '@/i18n'; +import Alert from '@/components/Global/Alert'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; + +export default { + name: 'Login', + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin], + data() { + return { + userInfo: { + username: null, + password: null, + }, + disableSubmitButton: false, + languages: [ + { + value: 'en-US', + text: 'English', + }, + { + value: 'es', + text: 'Español', + }, + { + value: 'ru-RU', + text: 'Русский', + }, + ], + }; + }, + computed: { + authError() { + return this.$store.getters['authentication/authError']; + }, + }, + validations: { + userInfo: { + username: { + required, + }, + password: { + required, + }, + }, + }, + methods: { + login: function () { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.disableSubmitButton = true; + const username = this.userInfo.username; + const password = this.userInfo.password; + this.$store + .dispatch('authentication/login', { username, password }) + .then(() => { + localStorage.setItem('storedLanguage', i18n.locale); + localStorage.setItem('storedUsername', username); + this.$store.commit('global/setUsername', username); + this.$store.commit('global/setLanguagePreference', i18n.locale); + return this.$store.dispatch( + 'authentication/checkPasswordChangeRequired', + username + ); + }) + .then((passwordChangeRequired) => { + if (passwordChangeRequired) { + this.$router.push('/change-password'); + } else { + this.$router.push('/'); + } + }) + .catch((error) => console.log(error)) + .finally(() => (this.disableSubmitButton = false)); + }, + }, +}; +</script> diff --git a/src/views/_sila/Login/index.js b/src/views/_sila/Login/index.js new file mode 100644 index 00000000..8fe0250d --- /dev/null +++ b/src/views/_sila/Login/index.js @@ -0,0 +1,2 @@ +import Login from './Login.vue'; +export default Login; diff --git a/src/views/_sila/Logs/Dumps/Dumps.vue b/src/views/_sila/Logs/Dumps/Dumps.vue new file mode 100644 index 00000000..81c9de04 --- /dev/null +++ b/src/views/_sila/Logs/Dumps/Dumps.vue @@ -0,0 +1,404 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col sm="6" lg="5" xl="4"> + <page-section :section-title="$t('pageDumps.initiateDump')"> + <dumps-form /> + </page-section> + </b-col> + </b-row> + <b-row> + <b-col xl="10"> + <page-section :section-title="$t('pageDumps.dumpsAvailableOnBmc')"> + <b-row class="align-items-start"> + <b-col sm="8" xl="6" class="d-sm-flex align-items-end"> + <search + :placeholder="$t('pageDumps.table.searchDumps')" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + <div class="ml-sm-4"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allDumps.length" + ></table-cell-count> + </div> + </b-col> + <b-col sm="8" md="7" xl="6"> + <table-date-filter @change="onChangeDateTimeFilter" /> + </b-col> + </b-row> + <b-row> + <b-col class="text-right"> + <table-filter + :filters="tableFilters" + @filter-change="onFilterChange" + /> + </b-col> + </b-row> + <table-toolbar + :selected-items-count="selectedRows.length" + :actions="batchActions" + @clear-selected="clearSelectedRows($refs.table)" + @batch-action="onTableBatchAction" + /> + <b-table + ref="table" + show-empty + hover + sort-icon-left + no-sort-reset + sort-desc + selectable + no-select-on-click + responsive="md" + sort-by="dateTime" + :fields="fields" + :items="filteredDumps" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :filter="searchFilter" + :busy="isBusy" + @filtered="onChangeSearchFilter" + @row-selected="onRowSelected($event, filteredTableItems.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <!-- Date and Time column --> + <template #cell(dateTime)="{ value }"> + <p class="mb-0">{{ value | formatDate }}</p> + <p class="mb-0">{{ value | formatTime }}</p> + </template> + + <!-- Size column --> + <template #cell(size)="{ value }"> + {{ convertBytesToMegabytes(value) }} MB + </template> + + <!-- Actions column --> + <template #cell(actions)="row"> + <table-row-action + v-for="(action, index) in row.item.actions" + :key="index" + :value="action.value" + :title="action.title" + :download-location="row.item.data" + :export-name="exportFileName(row)" + @click-table-action="onTableRowAction($event, row.item)" + > + <template #icon> + <icon-download v-if="action.value === 'download'" /> + <icon-delete v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </page-section> + </b-col> + </b-row> + <!-- Table pagination --> + <b-row> + <b-col sm="6" xl="5"> + <b-form-group + class="table-pagination-select" + :label="$t('global.table.itemsPerPage')" + label-for="pagination-items-per-page" + > + <b-form-select + id="pagination-items-per-page" + v-model="perPage" + :options="itemsPerPageOptions" + /> + </b-form-group> + </b-col> + <b-col sm="6" xl="5"> + <b-pagination + v-model="currentPage" + first-number + last-number + :per-page="perPage" + :total-rows="getTotalRowCount()" + aria-controls="table-dump-entries" + /> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import IconDelete from '@carbon/icons-vue/es/trash-can/20'; +import IconDownload from '@carbon/icons-vue/es/download/20'; +import DumpsForm from './DumpsForm'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableFilter from '@/components/Global/TableFilter'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; + +export default { + components: { + DumpsForm, + IconDelete, + IconDownload, + PageSection, + PageTitle, + Search, + TableCellCount, + TableDateFilter, + TableRowAction, + TableToolbar, + TableFilter, + }, + mixins: [ + BVTableSelectableMixin, + BVToastMixin, + BVPaginationMixin, + LoadingBarMixin, + SearchFilterMixin, + TableFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'dateTime', + label: this.$t('pageDumps.table.dateAndTime'), + sortable: true, + }, + { + key: 'dumpType', + label: this.$t('pageDumps.table.dumpType'), + sortable: true, + }, + { + key: 'id', + label: this.$t('pageDumps.table.id'), + sortable: true, + }, + { + key: 'size', + label: this.$t('pageDumps.table.size'), + sortable: true, + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + tableFilters: [ + { + key: 'dumpType', + label: this.$t('pageDumps.table.dumpType'), + values: [ + 'BMC Dump Entry', + 'Hostboot Dump Entry', + 'Resource Dump Entry', + 'System Dump Entry', + ], + }, + ], + activeFilters: [], + currentPage: currentPage, + filterEndDate: null, + filterStartDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter, + searchTotalFilteredRows: 0, + selectedRows, + tableHeaderCheckboxIndeterminate, + tableHeaderCheckboxModel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredDumps.length; + }, + allDumps() { + return this.$store.getters['dumps/allDumps'].map((item) => { + return { + ...item, + actions: [ + { + value: 'download', + title: this.$t('global.action.download'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + }; + }); + }, + filteredDumpsByDate() { + return this.getFilteredTableDataByDate( + this.allDumps, + this.filterStartDate, + this.filterEndDate, + 'dateTime' + ); + }, + filteredDumps() { + return this.getFilteredTableData( + this.filteredDumpsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('dumps/getBmcDumpEntries').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + convertBytesToMegabytes(bytes) { + return parseFloat((bytes / 1000000).toFixed(3)); + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onTableRowAction(action, dump) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageDumps.modal.deleteDumpConfirmation'), { + title: this.$tc('pageDumps.modal.deleteDump'), + okTitle: this.$tc('pageDumps.modal.deleteDump'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfrimed) => { + if (deleteConfrimed) { + this.$store + .dispatch('dumps/deleteDumps', [dump]) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + } + }); + } + }, + onTableBatchAction(action) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageDumps.modal.deleteDumpConfirmation', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageDumps.modal.deleteDump', + this.selectedRows.length + ), + okTitle: this.$tc( + 'pageDumps.modal.deleteDump', + this.selectedRows.length + ), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfrimed) => { + if (deleteConfrimed) { + if (this.selectedRows.length === this.dumps.length) { + this.$store + .dispatch('dumps/deleteAllDumps') + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)); + } else { + this.$store + .dispatch('dumps/deleteDumps', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + } + } + }); + } + }, + exportFileName(row) { + let filename = row.item.dumpType + '_' + row.item.id + '.tar.xz'; + filename = filename.replace(RegExp(' ', 'g'), '_'); + return filename; + }, + }, +}; +</script> diff --git a/src/views/_sila/Logs/Dumps/DumpsForm.vue b/src/views/_sila/Logs/Dumps/DumpsForm.vue new file mode 100644 index 00000000..07f4a060 --- /dev/null +++ b/src/views/_sila/Logs/Dumps/DumpsForm.vue @@ -0,0 +1,97 @@ +<template> + <div class="form-background p-3"> + <b-form id="form-new-dump" novalidate @submit.prevent="handleSubmit"> + <b-form-group + :label="$t('pageDumps.form.selectDumpType')" + label-for="selectDumpType" + > + <b-form-select + id="selectDumpType" + v-model="selectedDumpType" + :options="dumpTypeOptions" + :state="getValidationState($v.selectedDumpType)" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + </b-form-group> + <alert variant="info" class="mb-3" :show="selectedDumpType === 'system'"> + {{ $t('pageDumps.form.systemDumpInfo') }} + </alert> + <b-button variant="primary" type="submit" form="form-new-dump"> + {{ $t('pageDumps.form.initiateDump') }} + </b-button> + </b-form> + <modal-confirmation @ok="createSystemDump" /> + </div> +</template> + +<script> +import { required } from 'vuelidate/lib/validators'; +import ModalConfirmation from './DumpsModalConfirmation'; +import Alert from '@/components/Global/Alert'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + components: { Alert, ModalConfirmation }, + mixins: [BVToastMixin, VuelidateMixin], + data() { + return { + selectedDumpType: null, + dumpTypeOptions: [ + { value: 'bmc', text: this.$t('pageDumps.form.bmcDump') }, + { value: 'system', text: this.$t('pageDumps.form.systemDump') }, + ], + }; + }, + validations() { + return { + selectedDumpType: { required }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + + // System dump initiation + if (this.selectedDumpType === 'system') { + this.showConfirmationModal(); + } + // BMC dump initiation + else if (this.selectedDumpType === 'bmc') { + this.$store + .dispatch('dumps/createBmcDump') + .then(() => + this.infoToast(this.$t('pageDumps.toast.successStartBmcDump'), { + title: this.$t('pageDumps.toast.successStartBmcDumpTitle'), + timestamp: true, + }) + ) + .catch(({ message }) => this.errorToast(message)); + } + }, + showConfirmationModal() { + this.$bvModal.show('modal-confirmation'); + }, + createSystemDump() { + this.$store + .dispatch('dumps/createSystemDump') + .then(() => + this.infoToast(this.$t('pageDumps.toast.successStartSystemDump'), { + title: this.$t('pageDumps.toast.successStartSystemDumpTitle'), + timestamp: true, + }) + ) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_sila/Logs/Dumps/DumpsModalConfirmation.vue b/src/views/_sila/Logs/Dumps/DumpsModalConfirmation.vue new file mode 100644 index 00000000..f8e20cfd --- /dev/null +++ b/src/views/_sila/Logs/Dumps/DumpsModalConfirmation.vue @@ -0,0 +1,75 @@ +<template> + <b-modal + id="modal-confirmation" + ref="modal" + :title="$t('pageDumps.modal.initiateSystemDump')" + @hidden="resetForm" + > + <p> + <strong> + {{ $t('pageDumps.modal.initiateSystemDumpMessage1') }} + </strong> + </p> + <p> + {{ $t('pageDumps.modal.initiateSystemDumpMessage2') }} + </p> + <p> + <status-icon status="danger" /> + {{ $t('pageDumps.modal.initiateSystemDumpMessage3') }} + </p> + <b-form-checkbox v-model="confirmed" @input="$v.confirmed.$touch()"> + {{ $t('pageDumps.modal.initiateSystemDumpMessage4') }} + </b-form-checkbox> + <b-form-invalid-feedback + :state="getValidationState($v.confirmed)" + role="alert" + > + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button variant="danger" @click="handleSubmit"> + {{ $t('pageDumps.form.initiateDump') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import StatusIcon from '@/components/Global/StatusIcon'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + components: { StatusIcon }, + mixins: [VuelidateMixin], + data() { + return { + confirmed: false, + }; + }, + validations: { + confirmed: { + mustBeTrue: (value) => value === true, + }, + }, + methods: { + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok'); + this.closeModal(); + }, + resetForm() { + this.confirmed = false; + this.$v.$reset(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Logs/Dumps/index.js b/src/views/_sila/Logs/Dumps/index.js new file mode 100644 index 00000000..65525fb0 --- /dev/null +++ b/src/views/_sila/Logs/Dumps/index.js @@ -0,0 +1,2 @@ +import Dumps from './Dumps.vue'; +export default Dumps; diff --git a/src/views/_sila/Logs/EventLogs/EventLogs.vue b/src/views/_sila/Logs/EventLogs/EventLogs.vue new file mode 100644 index 00000000..5b8ca110 --- /dev/null +++ b/src/views/_sila/Logs/EventLogs/EventLogs.vue @@ -0,0 +1,604 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-start"> + <b-col sm="8" xl="6" class="d-sm-flex align-items-end mb-4"> + <search + :placeholder="$t('pageEventLogs.table.searchLogs')" + data-test-id="eventLogs-input-searchLogs" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + <div class="ml-sm-4"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allLogs.length" + ></table-cell-count> + </div> + </b-col> + <b-col sm="8" md="7" xl="6"> + <table-date-filter @change="onChangeDateTimeFilter" /> + </b-col> + </b-row> + <b-row> + <b-col class="text-right"> + <table-filter :filters="tableFilters" @filter-change="onFilterChange" /> + <b-button + variant="link" + :disabled="allLogs.length === 0" + @click="deleteAllLogs" + > + <icon-delete /> {{ $t('global.action.deleteAll') }} + </b-button> + <b-button + variant="primary" + :class="{ disabled: allLogs.length === 0 }" + :download="exportFileNameByDate()" + :href="href" + > + <icon-export /> {{ $t('global.action.exportAll') }} + </b-button> + </b-col> + </b-row> + <b-row> + <b-col> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + :actions="batchActions" + @clear-selected="clearSelectedRows($refs.table)" + @batch-action="onBatchAction" + > + <template #toolbar-buttons> + <b-button variant="primary" @click="resolveLogs"> + {{ $t('pageEventLogs.resolve') }} + </b-button> + <b-button variant="primary" @click="unresolveLogs"> + {{ $t('pageEventLogs.unresolve') }} + </b-button> + <table-toolbar-export + :data="batchExportData" + :file-name="exportFileNameByDate()" + /> + </template> + </table-toolbar> + <b-table + id="table-event-logs" + ref="table" + responsive="md" + selectable + no-select-on-click + sort-icon-left + hover + no-sort-reset + sort-desc + show-empty + sort-by="id" + :fields="fields" + :items="filteredLogs" + :sort-compare="onSortCompare" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :per-page="perPage" + :current-page="currentPage" + :filter="searchFilter" + :busy="isBusy" + @filtered="onFiltered" + @row-selected="onRowSelected($event, filteredLogs.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + data-test-id="eventLogs-checkbox-selectAll" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + :data-test-id="`eventLogs-checkbox-selectRow-${row.index}`" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <!-- Expand chevron icon --> + <template #cell(expandRow)="row"> + <b-button + variant="link" + :aria-label="expandRowLabel" + :title="expandRowLabel" + class="btn-icon-only" + @click="toggleRowDetails(row)" + > + <icon-chevron /> + </b-button> + </template> + + <template #row-details="{ item }"> + <b-container fluid> + <b-row> + <b-col> + <dl> + <!-- Name --> + <dt>{{ $t('pageEventLogs.table.name') }}:</dt> + <dd>{{ dataFormatter(item.name) }}</dd> + </dl> + <dl> + <!-- Type --> + <dt>{{ $t('pageEventLogs.table.type') }}:</dt> + <dd>{{ dataFormatter(item.type) }}</dd> + </dl> + </b-col> + <b-col> + <dl> + <!-- Modified date --> + <dt>{{ $t('pageEventLogs.table.modifiedDate') }}:</dt> + <dd v-if="item.modifiedDate"> + {{ item.modifiedDate | formatDate }} + {{ item.modifiedDate | formatTime }} + </dd> + <dd v-else>--</dd> + </dl> + </b-col> + <b-col class="text-nowrap"> + <b-button + class="btn btn-secondary float-right" + :href="item.additionalDataUri" + target="_blank" + > + <icon-download />{{ $t('pageEventLogs.additionalDataUri') }} + </b-button> + </b-col> + </b-row> + </b-container> + </template> + + <!-- Severity column --> + <template #cell(severity)="{ value }"> + <status-icon v-if="value" :status="statusIcon(value)" /> + {{ value }} + </template> + <!-- Date column --> + <template #cell(date)="{ value }"> + <p class="mb-0">{{ value | formatDate }}</p> + <p class="mb-0">{{ value | formatTime }}</p> + </template> + + <!-- Status column --> + <template #cell(status)="row"> + <b-form-checkbox + v-model="row.item.status" + name="switch" + switch + @change="changelogStatus(row.item)" + > + <span v-if="row.item.status"> + {{ $t('pageEventLogs.resolved') }} + </span> + <span v-else> {{ $t('pageEventLogs.unresolved') }} </span> + </b-form-checkbox> + </template> + <template #cell(filterByStatus)="{ value }"> + {{ value }} + </template> + + <!-- Actions column --> + <template #cell(actions)="row"> + <table-row-action + v-for="(action, index) in row.item.actions" + :key="index" + :value="action.value" + :title="action.title" + :row-data="row.item" + :export-name="exportFileNameByDate('export')" + :data-test-id="`eventLogs-button-deleteRow-${row.index}`" + @click-table-action="onTableRowAction($event, row.item)" + > + <template #icon> + <icon-export v-if="action.value === 'export'" /> + <icon-trashcan v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + + <!-- Table pagination --> + <b-row> + <b-col sm="6"> + <b-form-group + class="table-pagination-select" + :label="$t('global.table.itemsPerPage')" + label-for="pagination-items-per-page" + > + <b-form-select + id="pagination-items-per-page" + v-model="perPage" + :options="itemsPerPageOptions" + /> + </b-form-group> + </b-col> + <b-col sm="6"> + <b-pagination + v-model="currentPage" + first-number + last-number + :per-page="perPage" + :total-rows="getTotalRowCount(filteredRows)" + aria-controls="table-event-logs" + /> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import IconDelete from '@carbon/icons-vue/es/trash-can/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import IconExport from '@carbon/icons-vue/es/document--export/20'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import IconDownload from '@carbon/icons-vue/es/download/20'; +import { omit } from 'lodash'; + +import PageTitle from '@/components/Global/PageTitle'; +import StatusIcon from '@/components/Global/StatusIcon'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableFilter from '@/components/Global/TableFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + IconDelete, + IconExport, + IconTrashcan, + IconChevron, + IconDownload, + PageTitle, + Search, + StatusIcon, + TableCellCount, + TableFilter, + TableRowAction, + TableToolbar, + TableToolbarExport, + TableDateFilter, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + TableFilterMixin, + DataFormatterMixin, + TableSortMixin, + TableRowExpandMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'checkbox', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageEventLogs.table.id'), + sortable: true, + }, + { + key: 'severity', + label: this.$t('pageEventLogs.table.severity'), + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'date', + label: this.$t('pageEventLogs.table.date'), + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'description', + label: this.$t('pageEventLogs.table.description'), + tdClass: 'text-break', + }, + { + key: 'status', + label: this.$t('pageEventLogs.table.status'), + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + tableFilters: [ + { + key: 'severity', + label: this.$t('pageEventLogs.table.severity'), + values: ['OK', 'Warning', 'Critical'], + }, + { + key: 'filterByStatus', + label: this.$t('pageEventLogs.table.status'), + values: ['Resolved', 'Unresolved'], + }, + ], + expandRowLabel, + activeFilters: [], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + currentPage: currentPage, + filterStartDate: null, + filterEndDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + href() { + return `data:text/json;charset=utf-8,${this.exportAllLogs()}`; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredLogs.length; + }, + allLogs() { + return this.$store.getters['eventLog/allEvents'].map((event) => { + return { + ...event, + actions: [ + { + value: 'export', + title: this.$t('global.action.export'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + }; + }); + }, + batchExportData() { + return this.selectedRows.map((row) => omit(row, 'actions')); + }, + filteredLogsByDate() { + return this.getFilteredTableDataByDate( + this.allLogs, + this.filterStartDate, + this.filterEndDate + ); + }, + filteredLogs() { + return this.getFilteredTableData( + this.filteredLogsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('eventLog/getEventLogData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + changelogStatus(row) { + this.$store + .dispatch('eventLog/updateEventLogStatus', { + uri: row.uri, + status: row.status, + }) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => this.errorToast(message)); + }, + deleteAllLogs() { + this.$bvModal + .msgBoxConfirm(this.$t('pageEventLogs.modal.deleteAllMessage'), { + title: this.$t('pageEventLogs.modal.deleteAllTitle'), + okTitle: this.$t('global.action.delete'), + okVariant: 'danger', + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.$store + .dispatch('eventLog/deleteAllEventLogs', this.allLogs) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + } + }); + }, + deleteLogs(uris) { + this.$store + .dispatch('eventLog/deleteEventLogs', uris) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + exportAllLogs() { + { + return this.$store.getters['eventLog/allEvents'].map((eventLogs) => { + const allEventLogsString = JSON.stringify(eventLogs); + return allEventLogsString; + }); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onSortCompare(a, b, key) { + if (key === 'severity') { + return this.sortStatus(a, b, key); + } + }, + onTableRowAction(action, { uri }) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageEventLogs.modal.deleteMessage'), { + title: this.$tc('pageEventLogs.modal.deleteTitle'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.deleteLogs([uri]); + }); + } + }, + onBatchAction(action) { + if (action === 'delete') { + const uris = this.selectedRows.map((row) => row.uri); + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageEventLogs.modal.deleteMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageEventLogs.modal.deleteTitle', + this.selectedRows.length + ), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + if (this.selectedRows.length === this.allLogs.length) { + this.$store + .dispatch( + 'eventLog/deleteAllEventLogs', + this.selectedRows.length + ) + .then(() => { + this.successToast( + this.$tc('pageEventLogs.toast.successDelete', uris.length) + ); + }) + .catch(({ message }) => this.errorToast(message)); + } else { + this.deleteLogs(uris); + } + } + }); + } + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + // Create export file name based on date + exportFileNameByDate(value) { + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName; + if (value === 'export') { + fileName = 'event_log_'; + } else { + fileName = 'all_event_logs_'; + } + return fileName + date; + }, + resolveLogs() { + this.$store + .dispatch('eventLog/resolveEventLogs', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + unresolveLogs() { + this.$store + .dispatch('eventLog/unresolveEventLogs', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Logs/EventLogs/index.js b/src/views/_sila/Logs/EventLogs/index.js new file mode 100644 index 00000000..521efde4 --- /dev/null +++ b/src/views/_sila/Logs/EventLogs/index.js @@ -0,0 +1,2 @@ +import EventLogs from './EventLogs.vue'; +export default EventLogs; diff --git a/src/views/_sila/Logs/PostCodeLogs/PostCodeLogs.vue b/src/views/_sila/Logs/PostCodeLogs/PostCodeLogs.vue new file mode 100644 index 00000000..d116d2ed --- /dev/null +++ b/src/views/_sila/Logs/PostCodeLogs/PostCodeLogs.vue @@ -0,0 +1,347 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-start"> + <b-col sm="8" xl="6" class="d-sm-flex align-items-end mb-4"> + <search + :placeholder="$t('pagePostCodeLogs.table.searchLogs')" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + <div class="ml-sm-4"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allLogs.length" + ></table-cell-count> + </div> + </b-col> + <b-col sm="8" md="7" xl="6"> + <table-date-filter @change="onChangeDateTimeFilter" /> + </b-col> + </b-row> + <b-row> + <b-col xl="12" class="text-right"> + <b-button + variant="primary" + :disabled="allLogs.length === 0" + :download="exportFileNameByDate()" + :href="href" + > + <icon-export /> {{ $t('pagePostCodeLogs.button.exportAll') }} + </b-button> + </b-col> + </b-row> + <b-row> + <b-col> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + @clear-selected="clearSelectedRows($refs.table)" + > + <template #toolbar-buttons> + <table-toolbar-export + :data="batchExportData" + :file-name="exportFileNameByDate()" + /> + </template> + </table-toolbar> + <b-table + id="table-post-code-logs" + ref="table" + responsive="md" + selectable + no-select-on-click + sort-icon-left + hover + no-sort-reset + sort-desc + show-empty + sort-by="id" + :fields="fields" + :items="filteredLogs" + :empty-text="$t('global.table.emptyMessage')" + :empty-filtered-text="$t('global.table.emptySearchMessage')" + :per-page="perPage" + :current-page="currentPage" + :filter="searchFilter" + :busy="isBusy" + @filtered="onFiltered" + @row-selected="onRowSelected($event, filteredLogs.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + data-test-id="postCode-checkbox-selectAll" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + :data-test-id="`postCode-checkbox-selectRow-${row.index}`" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + <!-- Date column --> + <template #cell(date)="{ value }"> + <p class="mb-0">{{ value | formatDate }}</p> + <p class="mb-0">{{ value | formatTime }}</p> + </template> + + <!-- Actions column --> + <template #cell(actions)="row"> + <table-row-action + v-for="(action, index) in row.item.actions" + :key="index" + :value="action.value" + :title="action.title" + :row-data="row.item" + :btn-icon-only="true" + :export-name="exportFileNameByDate(action.value)" + :download-location="row.item.uri" + :download-in-new-tab="true" + :show-button="false" + > + <template #icon> + <icon-export v-if="action.value === 'export'" /> + <icon-download v-if="action.value === 'download'" /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + + <!-- Table pagination --> + <b-row> + <b-col sm="6"> + <b-form-group + class="table-pagination-select" + :label="$t('global.table.itemsPerPage')" + label-for="pagination-items-per-page" + > + <b-form-select + id="pagination-items-per-page" + v-model="perPage" + :options="itemsPerPageOptions" + /> + </b-form-group> + </b-col> + <b-col sm="6"> + <b-pagination + v-model="currentPage" + first-number + last-number + :per-page="perPage" + :total-rows="getTotalRowCount(filteredRows)" + aria-controls="table-post-code-logs" + /> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import IconDownload from '@carbon/icons-vue/es/download/20'; +import IconExport from '@carbon/icons-vue/es/document--export/20'; +import { omit } from 'lodash'; +import PageTitle from '@/components/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + IconExport, + IconDownload, + PageTitle, + Search, + TableCellCount, + TableRowAction, + TableToolbar, + TableToolbarExport, + TableDateFilter, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + TableFilterMixin, + TableSortMixin, + TableRowExpandMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'date', + label: this.$t('pagePostCodeLogs.table.created'), + sortable: true, + }, + { + key: 'timeStampOffset', + label: this.$t('pagePostCodeLogs.table.timeStampOffset'), + }, + { + key: 'bootCount', + label: this.$t('pagePostCodeLogs.table.bootCount'), + }, + { + key: 'postCode', + label: this.$t('pagePostCodeLogs.table.postCode'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + expandRowLabel, + activeFilters: [], + currentPage: currentPage, + filterStartDate: null, + filterEndDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + href() { + return `data:text/json;charset=utf-8,${this.exportAllLogsString()}`; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredLogs.length; + }, + allLogs() { + return this.$store.getters['postCodeLogs/allPostCodes'].map( + (postCodes) => { + return { + ...postCodes, + actions: [ + { + value: 'export', + title: this.$t('pagePostCodeLogs.action.exportLogs'), + }, + { + value: 'download', + title: this.$t('pagePostCodeLogs.action.downloadDetails'), + }, + ], + }; + } + ); + }, + batchExportData() { + return this.selectedRows.map((row) => omit(row, 'actions')); + }, + filteredLogsByDate() { + return this.getFilteredTableDataByDate( + this.allLogs, + this.filterStartDate, + this.filterEndDate + ); + }, + filteredLogs() { + return this.getFilteredTableData( + this.filteredLogsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('postCodeLogs/getPostCodesLogData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + exportAllLogsString() { + { + return this.$store.getters['postCodeLogs/allPostCodes'].map( + (postCodes) => { + const allLogsString = JSON.stringify(postCodes); + return allLogsString; + } + ); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + // Create export file name based on date and action + exportFileNameByDate(value) { + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName; + if (value === 'download') { + fileName = this.$t('pagePostCodeLogs.downloadFilePrefix'); + } else if (value === 'export') { + fileName = this.$t('pagePostCodeLogs.exportFilePrefix'); + } else { + fileName = this.$t('pagePostCodeLogs.allExportFilePrefix'); + } + return fileName + date; + }, + }, +}; +</script> diff --git a/src/views/_sila/Logs/PostCodeLogs/index.js b/src/views/_sila/Logs/PostCodeLogs/index.js new file mode 100644 index 00000000..ab591124 --- /dev/null +++ b/src/views/_sila/Logs/PostCodeLogs/index.js @@ -0,0 +1,2 @@ +import PostCodeLogs from './PostCodeLogs.vue'; +export default PostCodeLogs; diff --git a/src/views/_sila/Operations/FactoryReset/FactoryReset.vue b/src/views/_sila/Operations/FactoryReset/FactoryReset.vue new file mode 100644 index 00000000..897348fc --- /dev/null +++ b/src/views/_sila/Operations/FactoryReset/FactoryReset.vue @@ -0,0 +1,117 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pageFactoryReset.description')" /> + + <!-- Reset Form --> + <b-form id="factory-reset" @submit.prevent="onResetSubmit"> + <b-row> + <b-col md="8"> + <b-form-group :label="$t('pageFactoryReset.form.resetOptionsLabel')"> + <b-form-radio-group + id="factory-reset-options" + v-model="resetOption" + stacked + > + <b-form-radio + class="mb-1" + value="resetBios" + aria-describedby="reset-bios" + data-test-id="factoryReset-radio-resetBios" + > + {{ $t('pageFactoryReset.form.resetBiosOptionLabel') }} + </b-form-radio> + <b-form-text id="reset-bios" class="ml-4 mb-3"> + {{ $t('pageFactoryReset.form.resetBiosOptionHelperText') }} + </b-form-text> + + <b-form-radio + class="mb-1" + value="resetToDefaults" + aria-describedby="reset-to-defaults" + data-test-id="factoryReset-radio-resetToDefaults" + > + {{ $t('pageFactoryReset.form.resetToDefaultsOptionLabel') }} + </b-form-radio> + <b-form-text id="reset-to-defaults" class="ml-4 mb-3"> + {{ + $t('pageFactoryReset.form.resetToDefaultsOptionHelperText') + }} + </b-form-text> + </b-form-radio-group> + </b-form-group> + <b-button + type="submit" + variant="primary" + data-test-id="factoryReset-button-submit" + > + {{ $t('global.action.reset') }} + </b-button> + </b-col> + </b-row> + </b-form> + + <!-- Modals --> + <modal-reset :reset-type="resetOption" @okConfirm="onOkConfirm" /> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import ModalReset from './FactoryResetModal'; + +export default { + name: 'FactoryReset', + components: { PageTitle, ModalReset }, + mixins: [LoadingBarMixin, BVToastMixin], + data() { + return { + resetOption: 'resetBios', + }; + }, + created() { + this.hideLoader(); + }, + methods: { + onResetSubmit() { + this.$bvModal.show('modal-reset'); + }, + onOkConfirm() { + if (this.resetOption == 'resetBios') { + this.onResetBiosConfirm(); + } else { + this.onResetToDefaultsConfirm(); + } + }, + onResetBiosConfirm() { + this.$store + .dispatch('factoryReset/resetBios') + .then((title) => { + this.successToast('', { + title, + }); + }) + .catch(({ message }) => { + this.errorToast('', { + title: message, + }); + }); + }, + onResetToDefaultsConfirm() { + this.$store + .dispatch('factoryReset/resetToDefaults') + .then((title) => { + this.successToast('', { + title, + }); + }) + .catch(({ message }) => { + this.errorToast('', { + title: message, + }); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/FactoryReset/FactoryResetModal.vue b/src/views/_sila/Operations/FactoryReset/FactoryResetModal.vue new file mode 100644 index 00000000..170bf284 --- /dev/null +++ b/src/views/_sila/Operations/FactoryReset/FactoryResetModal.vue @@ -0,0 +1,113 @@ +<template> + <b-modal + id="modal-reset" + ref="modal" + :title="$t(`pageFactoryReset.modal.${resetType}Title`)" + title-tag="h2" + @hidden="resetConfirm" + > + <p class="mb-2"> + <strong>{{ $t(`pageFactoryReset.modal.${resetType}Header`) }}</strong> + </p> + <ul class="pl-3 mb-4"> + <li + v-for="(item, index) in $t( + `pageFactoryReset.modal.${resetType}SettingsList` + )" + :key="index" + class="mt-1 mb-1" + > + {{ $t(item) }} + </li> + </ul> + + <!-- Warning message --> + <template v-if="!isServerOff"> + <p class="d-flex mb-2"> + <status-icon status="danger" /> + <span id="reset-to-default-warning" class="ml-1"> + {{ $t(`pageFactoryReset.modal.resetWarningMessage`) }} + </span> + </p> + <b-form-checkbox + v-model="confirm" + aria-describedby="reset-to-default-warning" + @input="$v.confirm.$touch()" + > + {{ $t(`pageFactoryReset.modal.resetWarningCheckLabel`) }} + </b-form-checkbox> + <b-form-invalid-feedback + role="alert" + :state="getValidationState($v.confirm)" + > + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </template> + + <template #modal-footer="{ cancel }"> + <b-button + variant="secondary" + data-test-id="factoryReset-button-cancel" + @click="cancel()" + > + {{ $t('global.action.cancel') }} + </b-button> + <b-button + type="sumbit" + variant="primary" + data-test-id="factoryReset-button-confirm" + @click="handleConfirm" + > + {{ $t(`pageFactoryReset.modal.${resetType}SubmitText`) }} + </b-button> + </template> + </b-modal> +</template> +<script> +import StatusIcon from '@/components/Global/StatusIcon'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; + +export default { + components: { StatusIcon }, + mixins: [VuelidateMixin], + props: { + resetType: { + type: String, + default: null, + }, + }, + data() { + return { + confirm: false, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isServerOff() { + return this.serverStatus === 'off' ? true : false; + }, + }, + validations: { + confirm: { + mustBeTrue: function (value) { + return this.isServerOff || value === true; + }, + }, + }, + methods: { + handleConfirm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('okConfirm'); + this.$nextTick(() => this.$refs.modal.hide()); + this.resetConfirm(); + }, + resetConfirm() { + this.confirm = false; + this.$v.$reset(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/FactoryReset/index.js b/src/views/_sila/Operations/FactoryReset/index.js new file mode 100644 index 00000000..eae747e0 --- /dev/null +++ b/src/views/_sila/Operations/FactoryReset/index.js @@ -0,0 +1,2 @@ +import FactoryReset from './FactoryReset.vue'; +export default FactoryReset; diff --git a/src/views/_sila/Operations/Firmware/Firmware.vue b/src/views/_sila/Operations/Firmware/Firmware.vue new file mode 100644 index 00000000..a2acb9b0 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/Firmware.vue @@ -0,0 +1,93 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <alerts-server-power + v-if="isServerPowerOffRequired" + :is-server-off="isServerOff" + /> + + <!-- Firmware cards --> + <b-row> + <b-col xl="10"> + <!-- BMC Firmware --> + <bmc-cards :is-page-disabled="isPageDisabled" /> + + <!-- Host Firmware --> + <host-cards v-if="!isSingleFileUploadEnabled" /> + </b-col> + </b-row> + + <!-- Update firmware--> + <page-section + :section-title="$t('pageFirmware.sectionTitleUpdateFirmware')" + > + <b-row> + <b-col sm="8" md="6" xl="4"> + <!-- Update form --> + <form-update + :is-server-off="isServerOff" + :is-page-disabled="isPageDisabled" + /> + </b-col> + </b-row> + </page-section> + </b-container> +</template> + +<script> +import AlertsServerPower from './FirmwareAlertServerPower'; +import BmcCards from './FirmwareCardsBmc'; +import FormUpdate from './FirmwareFormUpdate'; +import HostCards from './FirmwareCardsHost'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; + +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'FirmwareSingleImage', + components: { + AlertsServerPower, + BmcCards, + FormUpdate, + HostCards, + PageSection, + PageTitle, + }, + mixins: [LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + loading, + isServerPowerOffRequired: + process.env.VUE_APP_SERVER_OFF_REQUIRED === 'true', + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isServerOff() { + return this.serverStatus === 'off' ? true : false; + }, + isSingleFileUploadEnabled() { + return this.$store.getters['firmware/isSingleFileUploadEnabled']; + }, + isPageDisabled() { + if (this.isServerPowerOffRequired) { + return !this.isServerOff || this.loading || this.isOperationInProgress; + } + return this.loading || this.isOperationInProgress; + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('firmware/getFirmwareInformation') + .finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/FirmwareAlertServerPower.vue b/src/views/_sila/Operations/Firmware/FirmwareAlertServerPower.vue new file mode 100644 index 00000000..24aa1d69 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareAlertServerPower.vue @@ -0,0 +1,50 @@ +<template> + <b-row> + <b-col xl="10"> + <!-- Operation in progress alert --> + <alert v-if="isOperationInProgress" variant="info" class="mb-5"> + <p> + {{ $t('pageFirmware.alert.operationInProgress') }} + </p> + </alert> + <!-- Power off server warning alert --> + <alert v-else-if="!isServerOff" variant="warning" class="mb-5"> + <p class="mb-0"> + {{ $t('pageFirmware.alert.serverMustBePoweredOffTo') }} + </p> + <ul class="m-0"> + <li> + {{ $t('pageFirmware.alert.switchRunningAndBackupImages') }} + </li> + <li> + {{ $t('pageFirmware.alert.updateFirmware') }} + </li> + </ul> + <template #action> + <b-link to="/operations/server-power-operations"> + {{ $t('pageFirmware.alert.viewServerPowerOperations') }} + </b-link> + </template> + </alert> + </b-col> + </b-row> +</template> + +<script> +import Alert from '@/components/Global/Alert'; + +export default { + components: { Alert }, + props: { + isServerOff: { + required: true, + type: Boolean, + }, + }, + computed: { + isOperationInProgress() { + return this.$store.getters['controls/isOperationInProgress']; + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/FirmwareCardsBmc.vue b/src/views/_sila/Operations/Firmware/FirmwareCardsBmc.vue new file mode 100644 index 00000000..d79a8769 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareCardsBmc.vue @@ -0,0 +1,136 @@ +<template> + <div> + <page-section :section-title="sectionTitle"> + <b-card-group deck> + <!-- Running image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleRunning') }} + </p> + </template> + <dl class="mb-0"> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd class="mb-0">{{ runningVersion }}</dd> + </dl> + </b-card> + + <!-- Backup image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleBackup') }} + </p> + </template> + <dl> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd> + <status-icon v-if="showBackupImageStatus" status="danger" /> + <span v-if="showBackupImageStatus" class="sr-only"> + {{ backupStatus }} + </span> + {{ backupVersion }} + </dd> + </dl> + <b-btn + v-if="!switchToBackupImageDisabled" + v-b-modal.modal-switch-to-running + data-test-id="firmware-button-switchToRunning" + variant="link" + size="sm" + class="py-0 px-1 mt-2" + :disabled="isPageDisabled || !backup" + > + <icon-switch class="d-none d-sm-inline-block" /> + {{ $t('pageFirmware.cardActionSwitchToRunning') }} + </b-btn> + </b-card> + </b-card-group> + </page-section> + <modal-switch-to-running :backup="backupVersion" @ok="switchToRunning" /> + </div> +</template> + +<script> +import IconSwitch from '@carbon/icons-vue/es/arrows--horizontal/20'; +import PageSection from '@/components/Global/PageSection'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +import ModalSwitchToRunning from './FirmwareModalSwitchToRunning'; + +export default { + components: { IconSwitch, ModalSwitchToRunning, PageSection }, + mixins: [BVToastMixin, LoadingBarMixin], + props: { + isPageDisabled: { + required: true, + type: Boolean, + default: false, + }, + }, + data() { + return { + loading, + switchToBackupImageDisabled: + process.env.VUE_APP_SWITCH_TO_BACKUP_IMAGE_DISABLED === 'true', + }; + }, + computed: { + isSingleFileUploadEnabled() { + return this.$store.getters['firmware/isSingleFileUploadEnabled']; + }, + sectionTitle() { + if (this.isSingleFileUploadEnabled) { + return this.$t('pageFirmware.sectionTitleBmcCardsCombined'); + } + return this.$t('pageFirmware.sectionTitleBmcCards'); + }, + running() { + return this.$store.getters['firmware/activeBmcFirmware']; + }, + backup() { + return this.$store.getters['firmware/backupBmcFirmware']; + }, + runningVersion() { + return this.running?.version || '--'; + }, + backupVersion() { + return this.backup?.version || '--'; + }, + backupStatus() { + return this.backup?.status || null; + }, + showBackupImageStatus() { + return ( + this.backupStatus === 'Critical' || this.backupStatus === 'Warning' + ); + }, + }, + methods: { + switchToRunning() { + this.startLoader(); + const timerId = setTimeout(() => { + this.endLoader(); + this.infoToast(this.$t('pageFirmware.toast.verifySwitchMessage'), { + title: this.$t('pageFirmware.toast.verifySwitch'), + refreshAction: true, + }); + }, 60000); + + this.$store + .dispatch('firmware/switchBmcFirmwareAndReboot') + .then(() => + this.infoToast(this.$t('pageFirmware.toast.rebootStartedMessage'), { + title: this.$t('pageFirmware.toast.rebootStarted'), + }) + ) + .catch(({ message }) => { + this.errorToast(message); + clearTimeout(timerId); + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/FirmwareCardsHost.vue b/src/views/_sila/Operations/Firmware/FirmwareCardsHost.vue new file mode 100644 index 00000000..b4a8e90d --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareCardsHost.vue @@ -0,0 +1,73 @@ +<template> + <page-section :section-title="$t('pageFirmware.sectionTitleHostCards')"> + <b-card-group deck> + <!-- Running image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleRunning') }} + </p> + </template> + <dl class="mb-0"> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd class="mb-0">{{ runningVersion }}</dd> + </dl> + </b-card> + + <!-- Backup image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleBackup') }} + </p> + </template> + <dl class="mb-0"> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd class="mb-0"> + <status-icon v-if="showBackupImageStatus" status="danger" /> + <span v-if="showBackupImageStatus" class="sr-only"> + {{ backupStatus }} + </span> + {{ backupVersion }} + </dd> + </dl> + </b-card> + </b-card-group> + </page-section> +</template> + +<script> +import PageSection from '@/components/Global/PageSection'; + +export default { + components: { PageSection }, + computed: { + running() { + return this.$store.getters['firmware/activeHostFirmware']; + }, + backup() { + return this.$store.getters['firmware/backupHostFirmware']; + }, + runningVersion() { + return this.running?.version || '--'; + }, + backupVersion() { + return this.backup?.version || '--'; + }, + backupStatus() { + return this.backup?.status || null; + }, + showBackupImageStatus() { + return ( + this.backupStatus === 'Critical' || this.backupStatus === 'Warning' + ); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.page-section { + margin-top: -$spacer * 1.5; +} +</style> diff --git a/src/views/_sila/Operations/Firmware/FirmwareFormUpdate.vue b/src/views/_sila/Operations/Firmware/FirmwareFormUpdate.vue new file mode 100644 index 00000000..ac4b23fc --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareFormUpdate.vue @@ -0,0 +1,187 @@ +<template> + <div> + <div class="form-background p-3"> + <b-form @submit.prevent="onSubmitUpload"> + <b-form-group + v-if="isTftpUploadAvailable" + :label="$t('pageFirmware.form.updateFirmware.fileSource')" + :disabled="isPageDisabled" + > + <b-form-radio v-model="isWorkstationSelected" :value="true"> + {{ $t('pageFirmware.form.updateFirmware.workstation') }} + </b-form-radio> + <b-form-radio v-model="isWorkstationSelected" :value="false"> + {{ $t('pageFirmware.form.updateFirmware.tftpServer') }} + </b-form-radio> + </b-form-group> + + <!-- Workstation Upload --> + <template v-if="isWorkstationSelected"> + <b-form-group + :label="$t('pageFirmware.form.updateFirmware.imageFile')" + label-for="image-file" + > + <form-file + id="image-file" + :disabled="isPageDisabled" + :state="getValidationState($v.file)" + aria-describedby="image-file-help-block" + @input="onFileUpload($event)" + > + <template #invalid> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + </template> + </form-file> + </b-form-group> + </template> + + <!-- TFTP Server Upload --> + <template v-else> + <b-form-group + :label="$t('pageFirmware.form.updateFirmware.fileAddress')" + label-for="tftp-address" + > + <b-form-input + id="tftp-address" + v-model="tftpFileAddress" + type="text" + :state="getValidationState($v.tftpFileAddress)" + :disabled="isPageDisabled" + @input="$v.tftpFileAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </template> + <b-btn + data-test-id="firmware-button-startUpdate" + type="submit" + variant="primary" + :disabled="isPageDisabled" + > + {{ $t('pageFirmware.form.updateFirmware.startUpdate') }} + </b-btn> + </b-form> + </div> + + <!-- Modals --> + <modal-update-firmware @ok="updateFirmware" /> + </div> +</template> + +<script> +import { requiredIf } from 'vuelidate/lib/validators'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/Global/FormFile'; +import ModalUpdateFirmware from './FirmwareModalUpdateFirmware'; + +export default { + components: { FormFile, ModalUpdateFirmware }, + mixins: [BVToastMixin, LoadingBarMixin, VuelidateMixin], + props: { + isPageDisabled: { + required: true, + type: Boolean, + default: false, + }, + isServerOff: { + required: true, + type: Boolean, + }, + }, + data() { + return { + loading, + isWorkstationSelected: true, + file: null, + tftpFileAddress: null, + isServerPowerOffRequired: + process.env.VUE_APP_SERVER_OFF_REQUIRED === 'true', + }; + }, + computed: { + isTftpUploadAvailable() { + return this.$store.getters['firmware/isTftpUploadAvailable']; + }, + }, + watch: { + isWorkstationSelected: function () { + this.$v.$reset(); + this.file = null; + this.tftpFileAddress = null; + }, + }, + validations() { + return { + file: { + required: requiredIf(function () { + return this.isWorkstationSelected; + }), + }, + tftpFileAddress: { + required: requiredIf(function () { + return !this.isWorkstationSelected; + }), + }, + }; + }, + created() { + this.$store.dispatch('firmware/getUpdateServiceSettings'); + }, + methods: { + updateFirmware() { + this.startLoader(); + const timerId = setTimeout(() => { + this.endLoader(); + this.infoToast(this.$t('pageFirmware.toast.verifyUpdateMessage'), { + title: this.$t('pageFirmware.toast.verifyUpdate'), + refreshAction: true, + }); + }, 360000); + this.infoToast(this.$t('pageFirmware.toast.updateStartedMessage'), { + title: this.$t('pageFirmware.toast.updateStarted'), + timestamp: true, + }); + if (this.isWorkstationSelected) { + this.dispatchWorkstationUpload(timerId); + } else { + this.dispatchTftpUpload(timerId); + } + }, + dispatchWorkstationUpload(timerId) { + this.$store + .dispatch('firmware/uploadFirmware', this.file) + .catch(({ message }) => { + this.endLoader(); + this.errorToast(message); + clearTimeout(timerId); + }); + }, + dispatchTftpUpload(timerId) { + this.$store + .dispatch('firmware/uploadFirmwareTFTP', this.tftpFileAddress) + .catch(({ message }) => { + this.endLoader(); + this.errorToast(message); + clearTimeout(timerId); + }); + }, + onSubmitUpload() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$bvModal.show('modal-update-firmware'); + }, + onFileUpload(file) { + this.file = file; + this.$v.file.$touch(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/FirmwareModalSwitchToRunning.vue b/src/views/_sila/Operations/Firmware/FirmwareModalSwitchToRunning.vue new file mode 100644 index 00000000..dc4a4973 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareModalSwitchToRunning.vue @@ -0,0 +1,31 @@ +<template> + <b-modal + id="modal-switch-to-running" + :ok-title="$t('pageFirmware.modal.switchImages')" + :cancel-title="$t('global.action.cancel')" + :title="$t('pageFirmware.modal.switchRunningImage')" + @ok="$emit('ok')" + > + <p> + {{ $t('pageFirmware.modal.switchRunningImageInfo') }} + </p> + <p class="m-0"> + {{ + $t('pageFirmware.modal.switchRunningImageInfo2', { + backup, + }) + }} + </p> + </b-modal> +</template> + +<script> +export default { + props: { + backup: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/FirmwareModalUpdateFirmware.vue b/src/views/_sila/Operations/Firmware/FirmwareModalUpdateFirmware.vue new file mode 100644 index 00000000..18355217 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareModalUpdateFirmware.vue @@ -0,0 +1,44 @@ +<template> + <b-modal + id="modal-update-firmware" + :title="$t('pageFirmware.sectionTitleUpdateFirmware')" + :ok-title="$t('pageFirmware.form.updateFirmware.startUpdate')" + :cancel-title="$t('global.action.cancel')" + @ok="$emit('ok')" + > + <template v-if="isSingleFileUploadEnabled"> + <p> + {{ $t('pageFirmware.modal.updateFirmwareInfo') }} + </p> + <p> + {{ + $t('pageFirmware.modal.updateFirmwareInfo2', { + running: runningBmcVersion, + }) + }} + </p> + <p class="m-0"> + {{ $t('pageFirmware.modal.updateFirmwareInfo3') }} + </p> + </template> + <template v-else> + {{ $t('pageFirmware.modal.updateFirmwareInfoDefault') }} + </template> + </b-modal> +</template> + +<script> +export default { + computed: { + runningBmc() { + return this.$store.getters['firmware/activeBmcFirmware']; + }, + runningBmcVersion() { + return this.runningBmc?.version || '--'; + }, + isSingleFileUploadEnabled() { + return this.$store.getters['firmware/isSingleFileUploadEnabled']; + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/Firmware/index.js b/src/views/_sila/Operations/Firmware/index.js new file mode 100644 index 00000000..ad15cc03 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/index.js @@ -0,0 +1,2 @@ +import Firmware from './Firmware.vue'; +export default Firmware; diff --git a/src/views/_sila/Operations/KeyClear/KeyClear.vue b/src/views/_sila/Operations/KeyClear/KeyClear.vue new file mode 100644 index 00000000..2524da10 --- /dev/null +++ b/src/views/_sila/Operations/KeyClear/KeyClear.vue @@ -0,0 +1,106 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pageKeyClear.description')" /> + <b-row> + <b-col md="8" xl="6"> + <alert variant="info" class="mb-4"> + <div class="font-weight-bold"> + {{ $t('pageKeyClear.alert.title') }} + </div> + <div> + {{ $t('pageKeyClear.alert.description') }} + </div> + </alert> + </b-col> + </b-row> + <!-- Reset Form --> + <b-form id="key-clear" @submit.prevent="onKeyClearSubmit(keyOption)"> + <b-row> + <b-col md="8"> + <b-form-group :label="$t('pageKeyClear.form.keyClearOptionsLabel')"> + <b-form-radio-group + id="key-clear-options" + v-model="keyOption" + stacked + > + <b-form-radio class="mb-1" value="NONE"> + {{ $t('pageKeyClear.form.none') }} + </b-form-radio> + <b-form-text id="key-clear-not-requested" class="ml-4 mb-3"> + {{ $t('pageKeyClear.form.keyClearNotRequested') }} + </b-form-text> + <b-form-radio class="mb-1" value="ALL"> + {{ $t('pageKeyClear.form.clearAllLabel') }} + </b-form-radio> + <b-form-text id="clear-all" class="ml-4 mb-3"> + {{ $t('pageKeyClear.form.clearAllHeperText') }} + </b-form-text> + <b-form-radio class="mb-1" value="POWERVM_SYSKEY"> + {{ $t('pageKeyClear.form.clearHypervisorSystemKeyLabel') }} + </b-form-radio> + <b-form-text id="clear-hypervisor-key" class="ml-4 mb-3"> + {{ $t('pageKeyClear.form.clearHypervisorSystemKeyHelperText') }} + </b-form-text> + <template v-if="username == 'service'"> + <b-form-radio class="mb-1" value="MFG_ALL"> + {{ $t('pageKeyClear.form.clearAllSetGenesisIPL') }} + </b-form-radio> + <b-form-radio class="mb-1" value="MFG"> + {{ $t('pageKeyClear.form.setFactoryDefault') }} + </b-form-radio> + </template> + </b-form-radio-group> + </b-form-group> + <b-button + type="submit" + variant="primary" + data-test-id="keyClear-button-submit" + > + {{ $t('pageKeyClear.form.clear') }} + </b-button> + </b-col> + </b-row> + </b-form> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import Alert from '@/components/Global/Alert'; + +export default { + name: 'KeyClear', + components: { PageTitle, Alert }, + mixins: [LoadingBarMixin, BVToastMixin], + data() { + return { + keyOption: 'NONE', + username: this.$store.getters['global/username'], + }; + }, + created() { + this.hideLoader(); + }, + methods: { + onKeyClearSubmit(valueSelected) { + this.$bvModal + .msgBoxConfirm(this.$t('pageKeyClear.modal.clearAllMessage'), { + title: this.$t('pageKeyClear.modal.clearAllTitle'), + okTitle: this.$t('pageKeyClear.modal.clear'), + okVariant: 'danger', + cancelTitle: this.$t('global.action.cancel'), + }) + .then((clearConfirmed) => { + if (clearConfirmed) { + this.$store + .dispatch('keyClear/clearEncryptionKeys', valueSelected) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + } + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/KeyClear/index.js b/src/views/_sila/Operations/KeyClear/index.js new file mode 100644 index 00000000..56de8c4e --- /dev/null +++ b/src/views/_sila/Operations/KeyClear/index.js @@ -0,0 +1,2 @@ +import KeyClear from './KeyClear.vue'; +export default KeyClear; diff --git a/src/views/_sila/Operations/Kvm/Kvm.vue b/src/views/_sila/Operations/Kvm/Kvm.vue new file mode 100644 index 00000000..1a41baaf --- /dev/null +++ b/src/views/_sila/Operations/Kvm/Kvm.vue @@ -0,0 +1,24 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <div class="terminal-container"> + <kvm-console :is-full-window="false" /> + </div> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import KvmConsole from './KvmConsole'; + +export default { + name: 'Kvm', + components: { PageTitle, KvmConsole }, +}; +</script> + +<style scoped> +.terminal-container { + width: 100%; +} +</style> diff --git a/src/views/_sila/Operations/Kvm/KvmConsole.vue b/src/views/_sila/Operations/Kvm/KvmConsole.vue new file mode 100644 index 00000000..c028a9fc --- /dev/null +++ b/src/views/_sila/Operations/Kvm/KvmConsole.vue @@ -0,0 +1,170 @@ +<template> + <div :class="marginClass"> + <div ref="toolbar" class="kvm-toolbar"> + <b-row class="d-flex"> + <b-col class="d-flex flex-column justify-content-end" cols="4"> + <dl class="mb-2" sm="2" md="2"> + <dt class="d-inline font-weight-bold mr-1"> + {{ $t('pageKvm.status') }}: + </dt> + <dd class="d-inline"> + <status-icon :status="serverStatusIcon" /> + <span class="d-none d-md-inline"> {{ serverStatus }}</span> + </dd> + </dl> + </b-col> + + <b-col class="d-flex justify-content-end pr-1"> + <b-button + v-if="isConnected" + variant="link" + type="button" + @click="sendCtrlAltDel" + > + <icon-arrow-down /> + {{ $t('pageKvm.buttonCtrlAltDelete') }} + </b-button> + <b-button + v-if="!isFullWindow" + variant="link" + type="button" + @click="openConsoleWindow()" + > + <icon-launch /> + {{ $t('pageKvm.openNewTab') }} + </b-button> + </b-col> + </b-row> + </div> + <div id="terminal-kvm" ref="panel" :class="terminalClass"></div> + </div> +</template> + +<script> +import RFB from '@novnc/novnc/core/rfb'; +import StatusIcon from '@/components/Global/StatusIcon'; +import IconLaunch from '@carbon/icons-vue/es/launch/20'; +import IconArrowDown from '@carbon/icons-vue/es/arrow--down/16'; +import { throttle } from 'lodash'; + +const Connecting = 0; +const Connected = 1; +const Disconnected = 2; + +export default { + name: 'KvmConsole', + components: { StatusIcon, IconLaunch, IconArrowDown }, + props: { + isFullWindow: { + type: Boolean, + default: true, + }, + }, + data() { + return { + rfb: null, + isConnected: false, + terminalClass: this.isFullWindow ? 'full-window' : '', + marginClass: this.isFullWindow ? 'margin-left-full-window' : '', + status: Connecting, + convasRef: null, + resizeKvmWindow: null, + }; + }, + computed: { + serverStatusIcon() { + if (this.status === Connected) { + return 'success'; + } else if (this.status === Disconnected) { + return 'danger'; + } + return 'secondary'; + }, + serverStatus() { + if (this.status === Connected) { + return this.$t('pageKvm.connected'); + } else if (this.status === Disconnected) { + return this.$t('pageKvm.disconnected'); + } + return this.$t('pageKvm.connecting'); + }, + }, + mounted() { + this.openTerminal(); + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeKvmWindow); + this.closeTerminal(); + }, + methods: { + sendCtrlAltDel() { + this.rfb.sendCtrlAltDel(); + }, + closeTerminal() { + this.rfb.disconnect(); + this.rfb = null; + }, + openTerminal() { + const token = this.$store.getters['authentication/token']; + this.rfb = new RFB( + this.$refs.panel, + `wss://${window.location.host}/kvm/0`, + { wsProtocols: [token] } + ); + + this.rfb.scaleViewport = true; + this.rfb.clipViewport = true; + const that = this; + + this.resizeKvmWindow = throttle(() => { + setTimeout(that.setWidthToolbar, 0); + }, 1000); + window.addEventListener('resize', this.resizeKvmWindow); + + this.rfb.addEventListener('connect', () => { + that.isConnected = true; + that.status = Connected; + that.setWidthToolbar(); + }); + + this.rfb.addEventListener('disconnect', () => { + this.isConnected = false; + that.status = Disconnected; + }); + }, + setWidthToolbar() { + if ( + this.$refs.panel.children && + this.$refs.panel.children.length > 0 && + this.$refs.panel.children[0].children.length > 0 + ) { + this.$refs.toolbar.style.width = + this.$refs.panel.children[0].children[0].clientWidth - 10 + 'px'; + } + }, + openConsoleWindow() { + window.open( + '#/console/kvm', + '_blank', + 'directories=no,titlebar=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=yes,width=700,height=550' + ); + }, + }, +}; +</script> + +<style scoped lang="scss"> +.button-ctrl-alt-delete { + float: right; +} + +.kvm-status { + padding-top: $spacer / 2; + padding-left: $spacer / 4; + display: inline-block; +} + +.margin-left-full-window { + margin-left: 5px; +} +</style> diff --git a/src/views/_sila/Operations/Kvm/index.js b/src/views/_sila/Operations/Kvm/index.js new file mode 100644 index 00000000..ac4f9667 --- /dev/null +++ b/src/views/_sila/Operations/Kvm/index.js @@ -0,0 +1,2 @@ +import Kvm from './Kvm.vue'; +export default Kvm; diff --git a/src/views/_sila/Operations/RebootBmc/RebootBmc.vue b/src/views/_sila/Operations/RebootBmc/RebootBmc.vue new file mode 100644 index 00000000..900619cd --- /dev/null +++ b/src/views/_sila/Operations/RebootBmc/RebootBmc.vue @@ -0,0 +1,83 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col md="8" lg="8" xl="6"> + <page-section> + <b-row> + <b-col> + <dl> + <dt> + {{ $t('pageRebootBmc.lastReboot') }} + </dt> + <dd v-if="lastBmcRebootTime"> + {{ lastBmcRebootTime | formatDate }} + {{ lastBmcRebootTime | formatTime }} + </dd> + <dd v-else>--</dd> + </dl> + </b-col> + </b-row> + {{ $t('pageRebootBmc.rebootInformation') }} + <b-button + variant="primary" + class="d-block mt-5" + data-test-id="rebootBmc-button-reboot" + @click="onClick" + > + {{ $t('pageRebootBmc.rebootBmc') }} + </b-button> + </page-section> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'RebootBmc', + components: { PageTitle, PageSection }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + computed: { + lastBmcRebootTime() { + return this.$store.getters['controls/lastBmcRebootTime']; + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('controls/getLastBmcRebootTime') + .finally(() => this.endLoader()); + }, + methods: { + onClick() { + this.$bvModal + .msgBoxConfirm(this.$t('pageRebootBmc.modal.confirmMessage'), { + title: this.$t('pageRebootBmc.modal.confirmTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((confirmed) => { + if (confirmed) this.rebootBmc(); + }); + }, + rebootBmc() { + this.$store + .dispatch('controls/rebootBmc') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> + +<style lang="scss" scoped></style> diff --git a/src/views/_sila/Operations/RebootBmc/index.js b/src/views/_sila/Operations/RebootBmc/index.js new file mode 100644 index 00000000..ac31417e --- /dev/null +++ b/src/views/_sila/Operations/RebootBmc/index.js @@ -0,0 +1,2 @@ +import RebootBmc from './RebootBmc.vue'; +export default RebootBmc; diff --git a/src/views/_sila/Operations/SerialOverLan/SerialOverLan.vue b/src/views/_sila/Operations/SerialOverLan/SerialOverLan.vue new file mode 100644 index 00000000..48a68345 --- /dev/null +++ b/src/views/_sila/Operations/SerialOverLan/SerialOverLan.vue @@ -0,0 +1,24 @@ +<template> + <b-container fluid="xl"> + <page-title class="mb-4" :description="$t('pageSerialOverLan.subTitle')" /> + + <page-section class="mb-0"> + <serial-over-lan-console :is-full-window="false" /> + </page-section> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import SerialOverLanConsole from './SerialOverLanConsole'; + +export default { + name: 'SerialOverLan', + components: { + PageSection, + PageTitle, + SerialOverLanConsole, + }, +}; +</script> diff --git a/src/views/_sila/Operations/SerialOverLan/SerialOverLanConsole.vue b/src/views/_sila/Operations/SerialOverLan/SerialOverLanConsole.vue new file mode 100644 index 00000000..694083fd --- /dev/null +++ b/src/views/_sila/Operations/SerialOverLan/SerialOverLanConsole.vue @@ -0,0 +1,172 @@ +<template> + <div :class="isFullWindow ? 'full-window-container' : 'terminal-container'"> + <b-row class="d-flex"> + <b-col sm="4" md="6"> + <alert + v-if="serverStatus === 'on' ? false : true" + variant="warning" + :small="true" + class="mt-4" + > + <p class="col-form-label"> + {{ $t('pageSerialOverLan.alert.disconnectedAlertMessage') }} + </p> + </alert> + </b-col> + </b-row> + <b-row class="d-flex"> + <b-col class="d-flex flex-column justify-content-end"> + <dl class="mb-2" sm="6" md="6"> + <dt class="d-inline font-weight-bold mr-1"> + {{ $t('pageSerialOverLan.status') }}: + </dt> + <dd class="d-inline"> + <status-icon :status="serverStatusIcon" /> {{ connectionStatus }} + </dd> + </dl> + </b-col> + + <b-col v-if="!isFullWindow" class="d-flex justify-content-end"> + <b-button variant="link" type="button" @click="openConsoleWindow()"> + <icon-launch /> + {{ $t('pageSerialOverLan.openNewTab') }} + </b-button> + </b-col> + </b-row> + <div id="terminal" ref="panel"></div> + </div> +</template> + +<script> +import Alert from '@/components/Global/Alert'; +import { AttachAddon } from 'xterm-addon-attach'; +import { FitAddon } from 'xterm-addon-fit'; +import { Terminal } from 'xterm'; +import { throttle } from 'lodash'; +import IconLaunch from '@carbon/icons-vue/es/launch/20'; +import StatusIcon from '@/components/Global/StatusIcon'; + +export default { + name: 'SerialOverLanConsole', + components: { + Alert, + IconLaunch, + StatusIcon, + }, + props: { + isFullWindow: { + type: Boolean, + default: true, + }, + }, + data() { + return { + resizeConsoleWindow: null, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + serverStatusIcon() { + return this.serverStatus === 'on' ? 'success' : 'danger'; + }, + connectionStatus() { + return this.serverStatus === 'on' + ? this.$t('pageSerialOverLan.connected') + : this.$t('pageSerialOverLan.disconnected'); + }, + }, + created() { + this.$store.dispatch('global/getServerStatus'); + }, + mounted() { + this.openTerminal(); + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeConsoleWindow); + this.closeTerminal(); + }, + methods: { + openTerminal() { + const token = this.$store.getters['authentication/token']; + + this.ws = new WebSocket(`wss://${window.location.host}/console0`, [ + token, + ]); + + // Refer https://github.com/xtermjs/xterm.js/ for xterm implementation and addons. + + this.term = new Terminal({ + fontSize: 15, + fontFamily: + 'SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace', + }); + + const attachAddon = new AttachAddon(this.ws); + this.term.loadAddon(attachAddon); + + const fitAddon = new FitAddon(); + this.term.loadAddon(fitAddon); + + const SOL_THEME = { + background: '#19273c', + cursor: 'rgba(83, 146, 255, .5)', + scrollbar: 'rgba(83, 146, 255, .5)', + }; + this.term.setOption('theme', SOL_THEME); + + this.term.open(this.$refs.panel); + fitAddon.fit(); + + this.resizeConsoleWindow = throttle(() => { + fitAddon.fit(); + }, 1000); + window.addEventListener('resize', this.resizeConsoleWindow); + + try { + this.ws.onopen = function () { + console.log('websocket console0/ opened'); + }; + this.ws.onclose = function (event) { + console.log( + 'websocket console0/ closed. code: ' + + event.code + + ' reason: ' + + event.reason + ); + }; + } catch (error) { + console.log(error); + } + }, + closeTerminal() { + console.log('closeTerminal'); + this.term.dispose(); + this.term = null; + this.ws.close(); + this.ws = null; + }, + openConsoleWindow() { + window.open( + '#/console/serial-over-lan-console', + '_blank', + 'directories=no,titlebar=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=yes,width=600,height=550' + ); + }, + }, +}; +</script> + +<style lang="scss" scoped> +@import '~xterm/css/xterm.css'; + +#terminal { + overflow: auto; +} + +.full-window-container { + width: 97%; + margin: 1.5%; +} +</style> diff --git a/src/views/_sila/Operations/SerialOverLan/index.js b/src/views/_sila/Operations/SerialOverLan/index.js new file mode 100644 index 00000000..7c8bc7c0 --- /dev/null +++ b/src/views/_sila/Operations/SerialOverLan/index.js @@ -0,0 +1,2 @@ +import SerialOverLan from './SerialOverLan.vue'; +export default SerialOverLan; diff --git a/src/views/_sila/Operations/ServerPowerOperations/BootSettings.vue b/src/views/_sila/Operations/ServerPowerOperations/BootSettings.vue new file mode 100644 index 00000000..4896286b --- /dev/null +++ b/src/views/_sila/Operations/ServerPowerOperations/BootSettings.vue @@ -0,0 +1,132 @@ +<template> + <div class="form-background p-3"> + <b-form novalidate @submit.prevent="handleSubmit"> + <b-form-group + :label=" + $t('pageServerPowerOperations.bootSettings.bootSettingsOverride') + " + label-for="boot-option" + class="mb-3" + > + <b-form-select + id="boot-option" + v-model="form.bootOption" + :disabled="bootSourceOptions.length === 0" + :options="bootSourceOptions" + @change="onChangeSelect" + > + </b-form-select> + </b-form-group> + <b-form-checkbox + v-model="form.oneTimeBoot" + class="mb-4" + :disabled="form.bootOption === 'None'" + @change="$v.form.oneTimeBoot.$touch()" + > + {{ $t('pageServerPowerOperations.bootSettings.enableOneTimeBoot') }} + </b-form-checkbox> + <b-form-group + :label="$t('pageServerPowerOperations.bootSettings.tpmRequiredPolicy')" + > + <b-form-text id="tpm-required-policy-help-block"> + {{ + $t('pageServerPowerOperations.bootSettings.tpmRequiredPolicyHelper') + }} + </b-form-text> + <b-form-checkbox + id="tpm-required-policy" + v-model="form.tpmPolicyOn" + aria-describedby="tpm-required-policy-help-block" + @change="$v.form.tpmPolicyOn.$touch()" + > + {{ $t('global.status.enabled') }} + </b-form-checkbox> + </b-form-group> + <b-button variant="primary" type="submit" class="mb-3"> + {{ $t('global.action.save') }} + </b-button> + </b-form> + </div> +</template> + +<script> +import { mapState } from 'vuex'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'BootSettings', + mixins: [BVToastMixin, LoadingBarMixin], + data() { + return { + form: { + bootOption: this.$store.getters['serverBootSettings/bootSource'], + oneTimeBoot: this.$store.getters['serverBootSettings/overrideEnabled'], + tpmPolicyOn: this.$store.getters['serverBootSettings/tpmEnabled'], + }, + }; + }, + computed: { + ...mapState('serverBootSettings', [ + 'bootSourceOptions', + 'bootSource', + 'overrideEnabled', + 'tpmEnabled', + ]), + }, + watch: { + bootSource: function (value) { + this.form.bootOption = value; + }, + overrideEnabled: function (value) { + this.form.oneTimeBoot = value; + }, + tpmEnabled: function (value) { + this.form.tpmPolicyOn = value; + }, + }, + validations: { + // Empty validations to leverage vuelidate form states + // to check for changed values + form: { + bootOption: {}, + oneTimeBoot: {}, + tpmPolicyOn: {}, + }, + }, + created() { + this.$store + .dispatch('serverBootSettings/getTpmPolicy') + .finally(() => + this.$root.$emit('server-power-operations-boot-settings-complete') + ); + }, + methods: { + handleSubmit() { + this.startLoader(); + const tpmPolicyChanged = this.$v.form.tpmPolicyOn.$dirty; + let settings; + let bootSource = this.form.bootOption; + let overrideEnabled = this.form.oneTimeBoot; + let tpmEnabled = null; + + if (tpmPolicyChanged) tpmEnabled = this.form.tpmPolicyOn; + settings = { bootSource, overrideEnabled, tpmEnabled }; + + this.$store + .dispatch('serverBootSettings/saveSettings', settings) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.$v.form.$reset(); + this.endLoader(); + }); + }, + onChangeSelect(selectedOption) { + this.$v.form.bootOption.$touch(); + // Disable one time boot if selected boot option is 'None' + if (selectedOption === 'None') this.form.oneTimeBoot = false; + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/ServerPowerOperations/ServerPowerOperations.vue b/src/views/_sila/Operations/ServerPowerOperations/ServerPowerOperations.vue new file mode 100644 index 00000000..9e030837 --- /dev/null +++ b/src/views/_sila/Operations/ServerPowerOperations/ServerPowerOperations.vue @@ -0,0 +1,260 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="mb-4"> + <b-col md="8" xl="6"> + <page-section + :section-title="$t('pageServerPowerOperations.currentStatus')" + > + <b-row> + <b-col> + <dl> + <dt>{{ $t('pageServerPowerOperations.serverStatus') }}</dt> + <dd + v-if="serverStatus === 'on'" + data-test-id="powerServerOps-text-hostStatus" + > + {{ $t('global.status.on') }} + </dd> + <dd + v-else-if="serverStatus === 'off'" + data-test-id="powerServerOps-text-hostStatus" + > + {{ $t('global.status.off') }} + </dd> + <dd v-else> + {{ $t('global.status.notAvailable') }} + </dd> + </dl> + </b-col> + </b-row> + <b-row> + <b-col> + <dl> + <dt> + {{ $t('pageServerPowerOperations.lastPowerOperation') }} + </dt> + <dd + v-if="lastPowerOperationTime" + data-test-id="powerServerOps-text-lastPowerOp" + > + {{ lastPowerOperationTime | formatDate }} + {{ lastPowerOperationTime | formatTime }} + </dd> + <dd v-else>--</dd> + </dl> + </b-col> + </b-row> + </page-section> + </b-col> + </b-row> + <b-row> + <b-col v-if="hasBootSourceOptions" sm="8" md="6" xl="4"> + <page-section + :section-title="$t('pageServerPowerOperations.serverBootSettings')" + > + <boot-settings /> + </page-section> + </b-col> + <b-col sm="8" md="6" xl="7"> + <page-section + :section-title="$t('pageServerPowerOperations.operations')" + > + <alert :show="oneTimeBootEnabled" variant="warning"> + {{ $t('pageServerPowerOperations.oneTimeBootWarning') }} + </alert> + <template v-if="isOperationInProgress"> + <alert variant="info"> + {{ $t('pageServerPowerOperations.operationInProgress') }} + </alert> + </template> + <template v-else-if="serverStatus === 'off'"> + <b-button + variant="primary" + data-test-id="serverPowerOperations-button-powerOn" + @click="powerOn" + > + {{ $t('pageServerPowerOperations.powerOn') }} + </b-button> + </template> + <template v-else> + <!-- Reboot server options --> + <b-form novalidate class="mb-5" @submit.prevent="rebootServer"> + <b-form-group + :label="$t('pageServerPowerOperations.rebootServer')" + > + <b-form-radio + v-model="form.rebootOption" + name="reboot-option" + data-test-id="serverPowerOperations-radio-rebootOrderly" + value="orderly" + > + {{ $t('pageServerPowerOperations.orderlyReboot') }} + </b-form-radio> + <b-form-radio + v-model="form.rebootOption" + name="reboot-option" + data-test-id="serverPowerOperations-radio-rebootImmediate" + value="immediate" + > + {{ $t('pageServerPowerOperations.immediateReboot') }} + </b-form-radio> + </b-form-group> + <b-button + variant="primary" + type="submit" + data-test-id="serverPowerOperations-button-reboot" + > + {{ $t('pageServerPowerOperations.reboot') }} + </b-button> + </b-form> + <!-- Shutdown server options --> + <b-form novalidate @submit.prevent="shutdownServer"> + <b-form-group + :label="$t('pageServerPowerOperations.shutdownServer')" + > + <b-form-radio + v-model="form.shutdownOption" + name="shutdown-option" + data-test-id="serverPowerOperations-radio-shutdownOrderly" + value="orderly" + > + {{ $t('pageServerPowerOperations.orderlyShutdown') }} + </b-form-radio> + <b-form-radio + v-model="form.shutdownOption" + name="shutdown-option" + data-test-id="serverPowerOperations-radio-shutdownImmediate" + value="immediate" + > + {{ $t('pageServerPowerOperations.immediateShutdown') }} + </b-form-radio> + </b-form-group> + <b-button + variant="primary" + type="submit" + data-test-id="serverPowerOperations-button-shutDown" + > + {{ $t('pageServerPowerOperations.shutDown') }} + </b-button> + </b-form> + </template> + </page-section> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import BootSettings from './BootSettings'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import Alert from '@/components/Global/Alert'; + +export default { + name: 'ServerPowerOperations', + components: { PageTitle, PageSection, BootSettings, Alert }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + form: { + rebootOption: 'orderly', + shutdownOption: 'orderly', + }, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isOperationInProgress() { + return this.$store.getters['controls/isOperationInProgress']; + }, + lastPowerOperationTime() { + return this.$store.getters['controls/lastPowerOperationTime']; + }, + oneTimeBootEnabled() { + return this.$store.getters['serverBootSettings/overrideEnabled']; + }, + hasBootSourceOptions() { + let bootOptions = this.$store.getters[ + 'serverBootSettings/bootSourceOptions' + ]; + return bootOptions.length !== 0; + }, + }, + created() { + this.startLoader(); + const bootSettingsPromise = new Promise((resolve) => { + this.$root.$on('server-power-operations-boot-settings-complete', () => + resolve() + ); + }); + Promise.all([ + this.$store.dispatch('serverBootSettings/getBootSettings'), + this.$store.dispatch('controls/getLastPowerOperationTime'), + bootSettingsPromise, + ]).finally(() => this.endLoader()); + }, + methods: { + powerOn() { + this.$store.dispatch('controls/serverPowerOn'); + }, + rebootServer() { + const modalMessage = this.$t( + 'pageServerPowerOperations.modal.confirmRebootMessage' + ); + const modalOptions = { + title: this.$t('pageServerPowerOperations.modal.confirmRebootTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }; + + if (this.form.rebootOption === 'orderly') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverSoftReboot'); + }); + } else if (this.form.rebootOption === 'immediate') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverHardReboot'); + }); + } + }, + shutdownServer() { + const modalMessage = this.$t( + 'pageServerPowerOperations.modal.confirmShutdownMessage' + ); + const modalOptions = { + title: this.$t('pageServerPowerOperations.modal.confirmShutdownTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }; + + if (this.form.shutdownOption === 'orderly') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverSoftPowerOff'); + }); + } + if (this.form.shutdownOption === 'immediate') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverHardPowerOff'); + }); + } + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/ServerPowerOperations/index.js b/src/views/_sila/Operations/ServerPowerOperations/index.js new file mode 100644 index 00000000..10430047 --- /dev/null +++ b/src/views/_sila/Operations/ServerPowerOperations/index.js @@ -0,0 +1,2 @@ +import ServerPowerOperations from './ServerPowerOperations.vue'; +export default ServerPowerOperations; diff --git a/src/views/_sila/Operations/VirtualMedia/ModalConfigureConnection.vue b/src/views/_sila/Operations/VirtualMedia/ModalConfigureConnection.vue new file mode 100644 index 00000000..b0bcfb2b --- /dev/null +++ b/src/views/_sila/Operations/VirtualMedia/ModalConfigureConnection.vue @@ -0,0 +1,145 @@ +<template> + <b-modal + id="configure-connection" + ref="modal" + @ok="onOk" + @hidden="resetForm" + @show="initModal" + > + <template #modal-title> + {{ $t('pageVirtualMedia.modal.title') }} + </template> + <b-form> + <b-form-group + :label="$t('pageVirtualMedia.modal.serverUri')" + label-for="serverUri" + > + <b-form-input + id="serverUri" + v-model="form.serverUri" + type="text" + :state="getValidationState($v.form.serverUri)" + data-test-id="configureConnection-input-serverUri" + @input="$v.form.serverUri.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.serverUri.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + <b-form-group + :label="$t('pageVirtualMedia.modal.username')" + label-for="username" + > + <b-form-input + id="username" + v-model="form.username" + type="text" + data-test-id="configureConnection-input-username" + /> + </b-form-group> + <b-form-group + :label="$t('pageVirtualMedia.modal.password')" + label-for="password" + > + <b-form-input + id="password" + v-model="form.password" + type="password" + data-test-id="configureConnection-input-password" + /> + </b-form-group> + <b-form-group> + <b-form-checkbox + v-model="form.isRW" + data-test-id="configureConnection-input-isRW" + name="check-button" + > + RW + </b-form-checkbox> + </b-form-group> + </b-form> + <template #modal-ok> + {{ $t('global.action.save') }} + </template> + <template #modal-cancel> + {{ $t('global.action.cancel') }} + </template> + </b-modal> +</template> + +<script> +import { required } from 'vuelidate/lib/validators'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + mixins: [VuelidateMixin], + props: { + connection: { + type: Object, + default: null, + validator: (prop) => { + console.log(prop); + return true; + }, + }, + }, + data() { + return { + form: { + serverUri: null, + username: null, + password: null, + isRW: false, + }, + }; + }, + watch: { + connection: function (value) { + if (value === null) return; + Object.assign(this.form, value); + }, + }, + validations() { + return { + form: { + serverUri: { + required, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + let connectionData = {}; + Object.assign(connectionData, this.form); + this.$emit('ok', connectionData); + this.closeModal(); + }, + initModal() { + if (this.connection) { + Object.assign(this.form, this.connection); + } + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.serverUri = null; + this.form.username = null; + this.form.password = null; + this.form.isRW = false; + this.$v.$reset(); + }, + onOk(bvModalEvt) { + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/VirtualMedia/VirtualMedia.vue b/src/views/_sila/Operations/VirtualMedia/VirtualMedia.vue new file mode 100644 index 00000000..8a3d5add --- /dev/null +++ b/src/views/_sila/Operations/VirtualMedia/VirtualMedia.vue @@ -0,0 +1,221 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="mb-4"> + <b-col md="12"> + <page-section + :section-title="$t('pageVirtualMedia.virtualMediaSubTitleFirst')" + > + <b-row> + <b-col v-for="(dev, $index) in proxyDevices" :key="$index" md="6"> + <b-form-group :label="dev.id" label-class="bold"> + <form-file + v-if="!dev.isActive" + :id="concatId(dev.id)" + v-model="dev.file" + > + <template #invalid> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + </template> + </form-file> + </b-form-group> + <b-button + v-if="!dev.isActive" + variant="primary" + :disabled="!dev.file" + @click="startVM(dev)" + > + {{ $t('pageVirtualMedia.start') }} + </b-button> + <b-button + v-if="dev.isActive" + variant="primary" + :disabled="!dev.file" + @click="stopVM(dev)" + > + {{ $t('pageVirtualMedia.stop') }} + </b-button> + </b-col> + </b-row> + </page-section> + </b-col> + </b-row> + <b-row v-if="loadImageFromExternalServer" class="mb-4"> + <b-col md="12"> + <page-section + :section-title="$t('pageVirtualMedia.virtualMediaSubTitleSecond')" + > + <b-row> + <b-col + v-for="(device, $index) in legacyDevices" + :key="$index" + md="6" + > + <b-form-group + :label="device.id" + :label-for="device.id" + label-class="bold" + > + <b-button + variant="primary" + :disabled="device.isActive" + @click="configureConnection(device)" + > + {{ $t('pageVirtualMedia.configureConnection') }} + </b-button> + + <b-button + v-if="!device.isActive" + variant="primary" + class="float-right" + :disabled="!device.serverUri" + @click="startLegacy(device)" + > + {{ $t('pageVirtualMedia.start') }} + </b-button> + <b-button + v-if="device.isActive" + variant="primary" + class="float-right" + @click="stopLegacy(device)" + > + {{ $t('pageVirtualMedia.stop') }} + </b-button> + </b-form-group> + </b-col> + </b-row> + </page-section> + </b-col> + </b-row> + <modal-configure-connection + :connection="modalConfigureConnection" + @ok="saveConnection" + /> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import ModalConfigureConnection from './ModalConfigureConnection'; +import NbdServer from '@/utilities/NBDServer'; +import FormFile from '@/components/Global/FormFile'; + +export default { + name: 'VirtualMedia', + components: { PageTitle, PageSection, ModalConfigureConnection, FormFile }, + mixins: [BVToastMixin, LoadingBarMixin], + data() { + return { + modalConfigureConnection: null, + loadImageFromExternalServer: + process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true' + ? true + : false, + }; + }, + computed: { + proxyDevices() { + return this.$store.getters['virtualMedia/proxyDevices']; + }, + legacyDevices() { + return this.$store.getters['virtualMedia/legacyDevices']; + }, + }, + created() { + if (this.proxyDevices.length > 0 || this.legacyDevices.length > 0) return; + this.startLoader(); + this.$store + .dispatch('virtualMedia/getData') + .finally(() => this.endLoader()); + }, + methods: { + startVM(device) { + const token = this.$store.getters['authentication/token']; + device.nbd = new NbdServer( + `wss://${window.location.host}${device.websocket}`, + device.file, + device.id, + token + ); + device.nbd.socketStarted = () => + this.successToast(this.$t('pageVirtualMedia.toast.serverRunning')); + device.nbd.errorReadingFile = () => + this.errorToast(this.$t('pageVirtualMedia.toast.errorReadingFile')); + device.nbd.socketClosed = (code) => { + if (code === 1000) + this.successToast( + this.$t('pageVirtualMedia.toast.serverClosedSuccessfully') + ); + else + this.errorToast( + this.$t('pageVirtualMedia.toast.serverClosedWithErrors') + ); + device.file = null; + device.isActive = false; + }; + + device.nbd.start(); + device.isActive = true; + }, + stopVM(device) { + device.nbd.stop(); + }, + startLegacy(connectionData) { + var data = {}; + data.Image = connectionData.serverUri; + data.UserName = connectionData.username; + data.Password = connectionData.password; + data.WriteProtected = !connectionData.isRW; + this.startLoader(); + this.$store + .dispatch('virtualMedia/mountImage', { + id: connectionData.id, + data: data, + }) + .then(() => { + this.successToast( + this.$t('pageVirtualMedia.toast.serverConnectionEstablished') + ); + connectionData.isActive = true; + }) + .catch(() => { + this.errorToast(this.$t('pageVirtualMedia.toast.errorMounting')); + this.isActive = false; + }) + .finally(() => this.endLoader()); + }, + stopLegacy(connectionData) { + this.$store + .dispatch('virtualMedia/unmountImage', connectionData.id) + .then(() => { + this.successToast( + this.$t('pageVirtualMedia.toast.serverClosedSuccessfully') + ); + connectionData.isActive = false; + }) + .catch(() => + this.errorToast(this.$t('pageVirtualMedia.toast.errorUnmounting')) + ) + .finally(() => this.endLoader()); + }, + saveConnection(connectionData) { + this.modalConfigureConnection.serverUri = connectionData.serverUri; + this.modalConfigureConnection.username = connectionData.username; + this.modalConfigureConnection.password = connectionData.password; + this.modalConfigureConnection.isRW = connectionData.isRW; + }, + configureConnection(connectionData) { + this.modalConfigureConnection = connectionData; + this.$bvModal.show('configure-connection'); + }, + concatId(val) { + return val.split(' ').join('_').toLowerCase(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Operations/VirtualMedia/index.js b/src/views/_sila/Operations/VirtualMedia/index.js new file mode 100644 index 00000000..4573e865 --- /dev/null +++ b/src/views/_sila/Operations/VirtualMedia/index.js @@ -0,0 +1,2 @@ +import VirtualMedia from './VirtualMedia.vue'; +export default VirtualMedia; diff --git a/src/views/_sila/Overview/Overview.vue b/src/views/_sila/Overview/Overview.vue new file mode 100644 index 00000000..9960f373 --- /dev/null +++ b/src/views/_sila/Overview/Overview.vue @@ -0,0 +1,100 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <overview-quick-links class="mb-4" /> + <page-section + :section-title="$t('pageOverview.systemInformation')" + class="mb-1" + > + <b-card-group deck> + <overview-server /> + <overview-firmware /> + </b-card-group> + <b-card-group deck> + <overview-network /> + <overview-power /> + </b-card-group> + </page-section> + <page-section :section-title="$t('pageOverview.statusInformation')"> + <b-card-group deck> + <overview-events /> + <overview-inventory /> + <overview-dumps v-if="showDumps" /> + </b-card-group> + </page-section> + </b-container> +</template> + +<script> +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import OverviewDumps from './OverviewDumps.vue'; +import OverviewEvents from './OverviewEvents.vue'; +import OverviewFirmware from './OverviewFirmware.vue'; +import OverviewInventory from './OverviewInventory.vue'; +import OverviewNetwork from './OverviewNetwork'; +import OverviewPower from './OverviewPower'; +import OverviewQuickLinks from './OverviewQuickLinks'; +import OverviewServer from './OverviewServer'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; + +export default { + name: 'Overview', + components: { + OverviewDumps, + OverviewEvents, + OverviewFirmware, + OverviewInventory, + OverviewNetwork, + OverviewPower, + OverviewQuickLinks, + OverviewServer, + PageSection, + PageTitle, + }, + mixins: [LoadingBarMixin], + data() { + return { + showDumps: process.env.VUE_APP_ENV_NAME === 'ibm', + }; + }, + created() { + this.startLoader(); + const dumpsPromise = new Promise((resolve) => { + this.$root.$on('overview-dumps-complete', () => resolve()); + }); + const eventsPromise = new Promise((resolve) => { + this.$root.$on('overview-events-complete', () => resolve()); + }); + const firmwarePromise = new Promise((resolve) => { + this.$root.$on('overview-firmware-complete', () => resolve()); + }); + const inventoryPromise = new Promise((resolve) => { + this.$root.$on('overview-inventory-complete', () => resolve()); + }); + const networkPromise = new Promise((resolve) => { + this.$root.$on('overview-network-complete', () => resolve()); + }); + const powerPromise = new Promise((resolve) => { + this.$root.$on('overview-power-complete', () => resolve()); + }); + const quicklinksPromise = new Promise((resolve) => { + this.$root.$on('overview-quicklinks-complete', () => resolve()); + }); + const serverPromise = new Promise((resolve) => { + this.$root.$on('overview-server-complete', () => resolve()); + }); + + Promise.all([ + dumpsPromise, + eventsPromise, + firmwarePromise, + inventoryPromise, + networkPromise, + powerPromise, + quicklinksPromise, + serverPromise, + ]).finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewCard.vue b/src/views/_sila/Overview/OverviewCard.vue new file mode 100644 index 00000000..4fc0a031 --- /dev/null +++ b/src/views/_sila/Overview/OverviewCard.vue @@ -0,0 +1,81 @@ +<template> + <b-card bg-variant="light" border-variant="light" class="mb-4"> + <div class="justify-content-between align-items-center d-flex flex-wrap"> + <h3 class="h5 mb-0">{{ title }}</h3> + <div class="card-buttons"> + <b-button + v-if="exportButton || downloadButton" + :disabled="disabled" + :download="download" + :href="href" + class="p-0" + variant="link" + > + <span v-if="downloadButton">{{ $t('global.action.download') }}</span> + <span v-if="exportButton">{{ $t('global.action.exportAll') }}</span> + </b-button> + <span v-if="exportButton || downloadButton" class="pl-2 pr-2">|</span> + <b-link :to="to">{{ $t('pageOverview.viewMore') }}</b-link> + </div> + </div> + <slot></slot> + </b-card> +</template> + +<script> +export default { + name: 'OverviewCard', + props: { + data: { + type: Array, + default: () => [], + }, + disabled: { + type: Boolean, + default: true, + }, + downloadButton: { + type: Boolean, + default: false, + }, + exportButton: { + type: Boolean, + default: false, + }, + + fileName: { + type: String, + default: 'data', + }, + title: { + type: String, + default: '', + }, + to: { + type: String, + default: '/', + }, + }, + computed: { + dataForExport() { + return JSON.stringify(this.data); + }, + download() { + return `${this.fileName}.json`; + }, + href() { + return `data:text/json;charset=utf-8,${this.dataForExport}`; + }, + }, +}; +</script> + +<style lang="scss" scoped> +a { + vertical-align: middle; + font-size: 14px; +} +.card { + min-width: 310px; +} +</style> diff --git a/src/views/_sila/Overview/OverviewDumps.vue b/src/views/_sila/Overview/OverviewDumps.vue new file mode 100644 index 00000000..a2ae4e4e --- /dev/null +++ b/src/views/_sila/Overview/OverviewDumps.vue @@ -0,0 +1,54 @@ +<template> + <overview-card + :data="dumps" + :disabled="dumps.length === 0" + :download-button="true" + :file-name="exportFileNameByDate()" + :title="$t('pageOverview.dumps')" + :to="`/logs/dumps`" + > + <b-row class="mt-3"> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.total') }}</dt> + <dd class="h3">{{ dataFormatter(dumps.length) }}</dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + name: 'Dumps', + components: { + OverviewCard, + }, + mixins: [DataFormatterMixin], + computed: { + dumps() { + return this.$store.getters['dumps/allDumps']; + }, + }, + created() { + this.$store.dispatch('dumps/getBmcDumpEntries').finally(() => { + this.$root.$emit('overview-dumps-complete'); + }); + }, + methods: { + exportFileNameByDate() { + // Create export file name based on date + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName = 'all_dumps_'; + return fileName + date; + }, + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewEvents.vue b/src/views/_sila/Overview/OverviewEvents.vue new file mode 100644 index 00000000..b73c0b48 --- /dev/null +++ b/src/views/_sila/Overview/OverviewEvents.vue @@ -0,0 +1,85 @@ +<template> + <overview-card + :data="eventLogData" + :disabled="eventLogData.length === 0" + :export-button="true" + :file-name="exportFileNameByDate()" + :title="$t('pageOverview.eventLogs')" + :to="`/logs/event-logs`" + > + <b-row class="mt-3"> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.criticalEvents') }}</dt> + <dd class="h3"> + {{ dataFormatter(criticalEvents.length) }} + <status-icon status="danger" /> + </dd> + </dl> + </b-col> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.warningEvents') }}</dt> + <dd class="h3"> + {{ dataFormatter(warningEvents.length) }} + <status-icon status="warning" /> + </dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import StatusIcon from '@/components/Global/StatusIcon'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + name: 'Events', + components: { OverviewCard, StatusIcon }, + mixins: [DataFormatterMixin], + computed: { + eventLogData() { + return this.$store.getters['eventLog/allEvents']; + }, + criticalEvents() { + return this.eventLogData + .filter((log) => log.severity === 'Critical') + .map((log) => { + return log; + }); + }, + warningEvents() { + return this.eventLogData + .filter((log) => log.severity === 'Warning') + .map((log) => { + return log; + }); + }, + }, + created() { + this.$store.dispatch('eventLog/getEventLogData').finally(() => { + this.$root.$emit('overview-events-complete'); + }); + }, + methods: { + exportFileNameByDate() { + // Create export file name based on date + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName = 'all_event_logs_'; + return fileName + date; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.status-icon { + vertical-align: text-top; +} +</style> diff --git a/src/views/_sila/Overview/OverviewFirmware.vue b/src/views/_sila/Overview/OverviewFirmware.vue new file mode 100644 index 00000000..f1f9ce53 --- /dev/null +++ b/src/views/_sila/Overview/OverviewFirmware.vue @@ -0,0 +1,49 @@ +<template> + <overview-card + :title="$t('pageOverview.firmwareInformation')" + :to="`/operations/firmware`" + > + <b-row class="mt-3"> + <b-col> + <dl> + <dt>{{ $t('pageOverview.runningVersion') }}</dt> + <dd>{{ dataFormatter(runningVersion) }}</dd> + <dt>{{ $t('pageOverview.backupVersion') }}</dt> + <dd>{{ dataFormatter(backupVersion) }}</dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + name: 'Firmware', + components: { + OverviewCard, + }, + mixins: [DataFormatterMixin], + computed: { + backupBmcFirmware() { + return this.$store.getters['firmware/backupBmcFirmware']; + }, + backupVersion() { + return this.backupBmcFirmware?.version; + }, + activeBmcFirmware() { + return this.$store.getters[`firmware/activeBmcFirmware`]; + }, + runningVersion() { + return this.activeBmcFirmware?.version; + }, + }, + created() { + this.$store.dispatch('firmware/getFirmwareInformation').finally(() => { + this.$root.$emit('overview-firmware-complete'); + }); + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewInventory.vue b/src/views/_sila/Overview/OverviewInventory.vue new file mode 100644 index 00000000..575cb7b7 --- /dev/null +++ b/src/views/_sila/Overview/OverviewInventory.vue @@ -0,0 +1,57 @@ +<template> + <overview-card + :title="$t('pageOverview.inventory')" + :to="`/hardware-status/inventory`" + > + <b-row class="mt-3"> + <b-col sm="6"> + <dl sm="6"> + <dt>{{ $t('pageOverview.systemIdentifyLed') }}</dt> + <dd> + <b-form-checkbox + id="identifyLedSwitch" + v-model="systems.locationIndicatorActive" + data-test-id="overviewInventory-checkbox-identifyLed" + switch + @change="toggleIdentifyLedSwitch" + > + <span v-if="systems.locationIndicatorActive"> + {{ $t('global.status.on') }} + </span> + <span v-else>{{ $t('global.status.off') }}</span> + </b-form-checkbox> + </dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; + +export default { + name: 'Inventory', + components: { + OverviewCard, + }, + computed: { + systems() { + let systemData = this.$store.getters['system/systems'][0]; + return systemData ? systemData : {}; + }, + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + this.$root.$emit('overview-inventory-complete'); + }); + }, + methods: { + toggleIdentifyLedSwitch(state) { + this.$store + .dispatch('system/changeIdentifyLedState', state) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewNetwork.vue b/src/views/_sila/Overview/OverviewNetwork.vue new file mode 100644 index 00000000..b81e5c73 --- /dev/null +++ b/src/views/_sila/Overview/OverviewNetwork.vue @@ -0,0 +1,71 @@ +<template> + <overview-card + v-if="network" + :title="$t('pageOverview.networkInformation')" + :to="`/settings/network`" + > + <b-row class="mt-3"> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.hostName') }}</dt> + <dd>{{ dataFormatter(network.hostname) }}</dd> + </dl> + </b-col> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.linkStatus') }}</dt> + <dd> + {{ dataFormatter(network.linkStatus) }} + </dd> + </dl> + </b-col> + </b-row> + <b-row> + <b-col> + <dl> + <dt>{{ $t('pageOverview.ipv4') }}</dt> + <dd> + {{ dataFormatter(network.staticAddress) }} + </dd> + </dl> + </b-col> + <b-col> + <dl> + <dt>{{ $t('pageOverview.dhcp') }}</dt> + <dd> + {{ + dataFormatter( + network.dhcpAddress.length !== 0 + ? network.dhcpAddress[0].Address + : null + ) + }} + </dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + name: 'Network', + components: { + OverviewCard, + }, + mixins: [DataFormatterMixin], + computed: { + network() { + return this.$store.getters['network/globalNetworkSettings'][0]; + }, + }, + created() { + this.$store.dispatch('network/getEthernetData').finally(() => { + this.$root.$emit('overview-network-complete'); + }); + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewPower.vue b/src/views/_sila/Overview/OverviewPower.vue new file mode 100644 index 00000000..0d84c76c --- /dev/null +++ b/src/views/_sila/Overview/OverviewPower.vue @@ -0,0 +1,48 @@ +<template> + <overview-card + :title="$t('pageOverview.powerInformation')" + :to="`/resource-management/power`" + > + <b-row class="mt-3"> + <b-col sm="6"> + <dl> + <dt>{{ $t('pageOverview.powerConsumption') }}</dt> + <dd v-if="powerConsumptionValue == null"> + {{ $t('global.status.notAvailable') }} + </dd> + <dd v-else>{{ powerConsumptionValue }} W</dd> + <dt>{{ $t('pageOverview.powerCap') }}</dt> + <dd v-if="powerCapValue == null"> + {{ $t('global.status.disabled') }} + </dd> + <dd v-else>{{ powerCapValue }} W</dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import { mapGetters } from 'vuex'; + +export default { + name: 'Power', + components: { + OverviewCard, + }, + mixins: [DataFormatterMixin], + computed: { + ...mapGetters({ + powerCapValue: 'powerControl/powerCapValue', + powerConsumptionValue: 'powerControl/powerConsumptionValue', + }), + }, + created() { + this.$store.dispatch('powerControl/getPowerControl').finally(() => { + this.$root.$emit('overview-power-complete'); + }); + }, +}; +</script> diff --git a/src/views/_sila/Overview/OverviewQuickLinks.vue b/src/views/_sila/Overview/OverviewQuickLinks.vue new file mode 100644 index 00000000..bc579b03 --- /dev/null +++ b/src/views/_sila/Overview/OverviewQuickLinks.vue @@ -0,0 +1,56 @@ +<template> + <b-card bg-variant="light" border-variant="light"> + <b-row class="d-flex justify-content-between align-items-center"> + <b-col sm="6" lg="9" class="mb-2 mt-2"> + <dl> + <dt>{{ $t('pageOverview.bmcTime') }}</dt> + <dd v-if="bmcTime" data-test-id="overviewQuickLinks-text-bmcTime"> + {{ bmcTime | formatDate }} {{ bmcTime | formatTime }} + </dd> + <dd v-else>--</dd> + </dl> + </b-col> + <b-col sm="6" lg="3" class="mb-2 mt-2"> + <b-button + to="/operations/serial-over-lan" + variant="secondary" + data-test-id="overviewQuickLinks-button-solConsole" + class="d-flex justify-content-between align-items-center" + > + {{ $t('pageOverview.solConsole') }} + <icon-arrow-right /> + </b-button> + </b-col> + </b-row> + </b-card> +</template> + +<script> +import ArrowRight16 from '@carbon/icons-vue/es/arrow--right/16'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'QuickLinks', + components: { + IconArrowRight: ArrowRight16, + }, + mixins: [BVToastMixin], + computed: { + bmcTime() { + return this.$store.getters['global/bmcTime']; + }, + }, + created() { + Promise.all([this.$store.dispatch('global/getBmcTime')]).finally(() => { + this.$root.$emit('overview-quicklinks-complete'); + }); + }, +}; +</script> + +<style lang="scss" scoped> +dd, +dl { + margin: 0; +} +</style> diff --git a/src/views/_sila/Overview/OverviewServer.vue b/src/views/_sila/Overview/OverviewServer.vue new file mode 100644 index 00000000..d066d391 --- /dev/null +++ b/src/views/_sila/Overview/OverviewServer.vue @@ -0,0 +1,47 @@ +<template> + <overview-card + :title="$t('pageOverview.serverInformation')" + :to="`/hardware-status/inventory`" + > + <b-row class="mt-3"> + <b-col lg="6"> + <dl> + <dt>{{ $t('pageOverview.model') }}</dt> + <dd>{{ dataFormatter(serverModel) }}</dd> + <dt>{{ $t('pageOverview.serialNumber') }}</dt> + <dd>{{ dataFormatter(serverSerialNumber) }}</dd> + </dl> + </b-col> + </b-row> + </overview-card> +</template> + +<script> +import OverviewCard from './OverviewCard'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import { mapState } from 'vuex'; + +export default { + name: 'Server', + components: { + OverviewCard, + }, + mixins: [DataFormatterMixin], + computed: { + ...mapState({ + server: (state) => state.system.systems[0], + serverModel() { + return this.server?.model; + }, + serverSerialNumber() { + return this.server?.serialNumber; + }, + }), + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + this.$root.$emit('overview-server-complete'); + }); + }, +}; +</script> diff --git a/src/views/_sila/Overview/index.js b/src/views/_sila/Overview/index.js new file mode 100644 index 00000000..8553ef3d --- /dev/null +++ b/src/views/_sila/Overview/index.js @@ -0,0 +1,2 @@ +import Overview from './Overview.vue'; +export default Overview; diff --git a/src/views/_sila/PageNotFound/PageNotFound.vue b/src/views/_sila/PageNotFound/PageNotFound.vue new file mode 100644 index 00000000..91341dbb --- /dev/null +++ b/src/views/_sila/PageNotFound/PageNotFound.vue @@ -0,0 +1,12 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pagePageNotFound.description')" /> + </b-container> +</template> +<script> +import PageTitle from '@/components/Global/PageTitle'; +export default { + name: 'PageNotFound', + components: { PageTitle }, +}; +</script> diff --git a/src/views/_sila/PageNotFound/index.js b/src/views/_sila/PageNotFound/index.js new file mode 100644 index 00000000..ed1d519a --- /dev/null +++ b/src/views/_sila/PageNotFound/index.js @@ -0,0 +1,2 @@ +import PageNotFound from './PageNotFound.vue'; +export default PageNotFound; diff --git a/src/views/_sila/ProfileSettings/ProfileSettings.vue b/src/views/_sila/ProfileSettings/ProfileSettings.vue new file mode 100644 index 00000000..8f01c59b --- /dev/null +++ b/src/views/_sila/ProfileSettings/ProfileSettings.vue @@ -0,0 +1,222 @@ +<template> + <b-container fluid="xl"> + <page-title /> + + <b-row> + <b-col md="8" lg="8" xl="6"> + <page-section + :section-title="$t('pageProfileSettings.profileInfoTitle')" + > + <dl> + <dt>{{ $t('pageProfileSettings.username') }}</dt> + <dd> + {{ username }} + </dd> + </dl> + </page-section> + </b-col> + </b-row> + + <b-form @submit.prevent="submitForm"> + <b-row> + <b-col sm="8" md="6" xl="3"> + <page-section + :section-title="$t('pageProfileSettings.changePassword')" + > + <b-form-group + id="input-group-1" + :label="$t('pageProfileSettings.newPassword')" + label-for="input-1" + > + <b-form-text id="password-help-block"> + {{ + $t('pageUserManagement.modal.passwordMustBeBetween', { + min: passwordRequirements.minLength, + max: passwordRequirements.maxLength, + }) + }} + </b-form-text> + <input-password-toggle> + <b-form-input + id="password" + v-model="form.newPassword" + type="password" + aria-describedby="password-help-block" + :state="getValidationState($v.form.newPassword)" + data-test-id="profileSettings-input-newPassword" + class="form-control-with-button" + @input="$v.form.newPassword.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template + v-if=" + !$v.form.newPassword.minLength || + !$v.form.newPassword.maxLength + " + > + {{ + $t('pageProfileSettings.newPassLabelTextInfo', { + min: passwordRequirements.minLength, + max: passwordRequirements.maxLength, + }) + }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + <b-form-group + id="input-group-2" + :label="$t('pageProfileSettings.confirmPassword')" + label-for="input-2" + > + <input-password-toggle> + <b-form-input + id="password-confirmation" + v-model="form.confirmPassword" + type="password" + :state="getValidationState($v.form.confirmPassword)" + data-test-id="profileSettings-input-confirmPassword" + class="form-control-with-button" + @input="$v.form.confirmPassword.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.confirmPassword.sameAsPassword"> + {{ $t('pageProfileSettings.passwordsDoNotMatch') }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + </page-section> + </b-col> + </b-row> + <page-section :section-title="$t('pageProfileSettings.timezoneDisplay')"> + <p>{{ $t('pageProfileSettings.timezoneDisplayDesc') }}</p> + <b-row> + <b-col md="9" lg="8" xl="9"> + <b-form-group :label="$t('pageProfileSettings.timezone')"> + <b-form-radio + v-model="form.isUtcDisplay" + :value="true" + data-test-id="profileSettings-radio-defaultUTC" + > + {{ $t('pageProfileSettings.defaultUTC') }} + </b-form-radio> + <b-form-radio + v-model="form.isUtcDisplay" + :value="false" + data-test-id="profileSettings-radio-browserOffset" + > + {{ + $t('pageProfileSettings.browserOffset', { + timezone, + }) + }} + </b-form-radio> + </b-form-group> + </b-col> + </b-row> + </page-section> + <b-button + variant="primary" + type="submit" + data-test-id="profileSettings-button-saveSettings" + > + {{ $t('global.action.saveSettings') }} + </b-button> + </b-form> + </b-container> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import { maxLength, minLength, sameAs } from 'vuelidate/lib/validators'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/Mixins/LocalTimezoneLabelMixin'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + name: 'ProfileSettings', + components: { InputPasswordToggle, PageSection, PageTitle }, + mixins: [ + BVToastMixin, + LocalTimezoneLabelMixin, + LoadingBarMixin, + VuelidateMixin, + ], + data() { + return { + form: { + newPassword: '', + confirmPassword: '', + isUtcDisplay: this.$store.getters['global/isUtcDisplay'], + }, + }; + }, + computed: { + username() { + return this.$store.getters['global/username']; + }, + passwordRequirements() { + return this.$store.getters['userManagement/accountPasswordRequirements']; + }, + timezone() { + return this.localOffset(); + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('userManagement/getAccountSettings') + .finally(() => this.endLoader()); + }, + validations() { + return { + form: { + newPassword: { + minLength: minLength(this.passwordRequirements.minLength), + maxLength: maxLength(this.passwordRequirements.maxLength), + }, + confirmPassword: { + sameAsPassword: sameAs('newPassword'), + }, + }, + }; + }, + methods: { + saveNewPasswordInputData() { + this.$v.form.confirmPassword.$touch(); + this.$v.form.newPassword.$touch(); + if (this.$v.$invalid) return; + let userData = { + originalUsername: this.username, + password: this.form.newPassword, + }; + + this.$store + .dispatch('userManagement/updateUser', userData) + .then((message) => { + (this.form.newPassword = ''), (this.form.confirmPassword = ''); + this.$v.$reset(); + this.successToast(message); + }) + .catch(({ message }) => this.errorToast(message)); + }, + saveTimeZonePrefrenceData() { + localStorage.setItem('storedUtcDisplay', this.form.isUtcDisplay); + this.$store.commit('global/setUtcTime', this.form.isUtcDisplay); + this.successToast( + this.$t('pageProfileSettings.toast.successUpdatingTimeZone') + ); + }, + submitForm() { + if (this.form.confirmPassword || this.form.newPassword) { + this.saveNewPasswordInputData(); + } + this.saveTimeZonePrefrenceData(); + }, + }, +}; +</script> diff --git a/src/views/_sila/ProfileSettings/index.js b/src/views/_sila/ProfileSettings/index.js new file mode 100644 index 00000000..d6589c72 --- /dev/null +++ b/src/views/_sila/ProfileSettings/index.js @@ -0,0 +1,2 @@ +import ProfileSettings from './ProfileSettings.vue'; +export default ProfileSettings; diff --git a/src/views/_sila/ResourceManagement/Power.vue b/src/views/_sila/ResourceManagement/Power.vue new file mode 100644 index 00000000..cc0cc993 --- /dev/null +++ b/src/views/_sila/ResourceManagement/Power.vue @@ -0,0 +1,170 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pagePower.description')" /> + + <b-row> + <b-col sm="8" md="6" xl="12"> + <dl> + <dt>{{ $t('pagePower.powerConsumption') }}</dt> + <dd> + {{ + powerConsumptionValue + ? `${powerConsumptionValue} W` + : $t('global.status.notAvailable') + }} + </dd> + </dl> + </b-col> + </b-row> + + <b-form @submit.prevent="submitForm"> + <b-form-group :disabled="loading"> + <b-row> + <b-col sm="8" md="6" xl="12"> + <b-form-group :label="$t('pagePower.powerCapSettingLabel')"> + <b-form-checkbox + v-model="isPowerCapFieldEnabled" + data-test-id="power-checkbox-togglePowerCapField" + name="power-cap-setting" + > + {{ $t('pagePower.powerCapSettingData') }} + </b-form-checkbox> + </b-form-group> + </b-col> + </b-row> + + <b-row> + <b-col sm="8" md="6" xl="3"> + <b-form-group + id="input-group-1" + :label="$t('pagePower.powerCapLabel')" + label-for="input-1" + > + <b-form-text id="power-help-text"> + {{ + $t('pagePower.powerCapLabelTextInfo', { + min: 1, + max: 10000, + }) + }} + </b-form-text> + + <b-form-input + id="input-1" + v-model.number="powerCapValue" + :disabled="!isPowerCapFieldEnabled" + data-test-id="power-input-powerCapValue" + type="number" + aria-describedby="power-help-text" + :state="getValidationState($v.powerCapValue)" + ></b-form-input> + + <b-form-invalid-feedback id="input-live-feedback" role="alert"> + <template v-if="!$v.powerCapValue.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-else-if="!$v.powerCapValue.between"> + {{ $t('global.form.invalidValue') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + + <b-button + variant="primary" + type="submit" + data-test-id="power-button-savePowerCapValue" + > + {{ $t('global.action.save') }} + </b-button> + </b-form-group> + </b-form> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import { requiredIf, between } from 'vuelidate/lib/validators'; +import { mapGetters } from 'vuex'; + +export default { + name: 'Power', + components: { PageTitle }, + mixins: [VuelidateMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + loading, + }; + }, + computed: { + ...mapGetters({ + powerConsumptionValue: 'powerControl/powerConsumptionValue', + }), + + /** + Computed property isPowerCapFieldEnabled is used to enable or disable the input field. + The input field is enabled when the powercapValue property is not null. + **/ + isPowerCapFieldEnabled: { + get() { + return this.powerCapValue !== null; + }, + set(value) { + this.$v.$reset(); + let newValue = null; + if (value) { + if (this.powerCapValue) { + newValue = this.powerCapValue; + } else { + newValue = ''; + } + } + this.$store.dispatch('powerControl/setPowerCapUpdatedValue', newValue); + }, + }, + powerCapValue: { + get() { + return this.$store.getters['powerControl/powerCapValue']; + }, + set(value) { + this.$v.$touch(); + this.$store.dispatch('powerControl/setPowerCapUpdatedValue', value); + }, + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('powerControl/getPowerControl') + .finally(() => this.endLoader()); + }, + validations: { + powerCapValue: { + between: between(1, 10000), + required: requiredIf(function () { + return this.isPowerCapFieldEnabled; + }), + }, + }, + methods: { + submitForm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.startLoader(); + this.$store + .dispatch('powerControl/setPowerControl', this.powerCapValue) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> diff --git a/src/views/_sila/ResourceManagement/index.js b/src/views/_sila/ResourceManagement/index.js new file mode 100644 index 00000000..5882decd --- /dev/null +++ b/src/views/_sila/ResourceManagement/index.js @@ -0,0 +1,2 @@ +import Power from './Power.vue'; +export default Power; diff --git a/src/views/_sila/SecurityAndAccess/Certificates/Certificates.vue b/src/views/_sila/SecurityAndAccess/Certificates/Certificates.vue new file mode 100644 index 00000000..0113b80a --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/Certificates.vue @@ -0,0 +1,322 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col xl="11"> + <!-- Expired certificates banner --> + <alert :show="expiredCertificateTypes.length > 0" variant="danger"> + <template v-if="expiredCertificateTypes.length > 1"> + {{ $t('pageCertificates.alert.certificatesExpiredMessage') }} + </template> + <template v-else> + {{ + $t('pageCertificates.alert.certificateExpiredMessage', { + certificate: expiredCertificateTypes[0], + }) + }} + </template> + </alert> + <!-- Expiring certificates banner --> + <alert :show="expiringCertificateTypes.length > 0" variant="warning"> + <template v-if="expiringCertificateTypes.length > 1"> + {{ $t('pageCertificates.alert.certificatesExpiringMessage') }} + </template> + <template v-else> + {{ + $t('pageCertificates.alert.certificateExpiringMessage', { + certificate: expiringCertificateTypes[0], + }) + }} + </template> + </alert> + </b-col> + </b-row> + <b-row> + <b-col xl="11" class="text-right"> + <b-button + v-b-modal.generate-csr + data-test-id="certificates-button-generateCsr" + variant="link" + > + <icon-add /> + {{ $t('pageCertificates.generateCsr') }} + </b-button> + <b-button + variant="primary" + :disabled="certificatesForUpload.length === 0" + @click="initModalUploadCertificate(null)" + > + <icon-add /> + {{ $t('pageCertificates.addNewCertificate') }} + </b-button> + </b-col> + </b-row> + <b-row> + <b-col xl="11"> + <b-table + responsive="md" + show-empty + hover + :busy="isBusy" + :fields="fields" + :items="tableItems" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(validFrom)="{ value }"> + {{ value | formatDate }} + </template> + + <template #cell(validUntil)="{ value }"> + <status-icon + v-if="getDaysUntilExpired(value) < 31" + :status="getIconStatus(value)" + /> + {{ value | formatDate }} + </template> + + <template #cell(actions)="{ value, item }"> + <table-row-action + v-for="(action, index) in value" + :key="index" + :value="action.value" + :title="action.title" + :enabled="action.enabled" + @click-table-action="onTableRowAction($event, item)" + > + <template #icon> + <icon-replace v-if="action.value === 'replace'" /> + <icon-trashcan v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + + <!-- Modals --> + <modal-upload-certificate :certificate="modalCertificate" @ok="onModalOk" /> + <modal-generate-csr /> + </b-container> +</template> + +<script> +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconReplace from '@carbon/icons-vue/es/renew/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; + +import ModalGenerateCsr from './ModalGenerateCsr'; +import ModalUploadCertificate from './ModalUploadCertificate'; +import PageTitle from '@/components/Global/PageTitle'; +import TableRowAction from '@/components/Global/TableRowAction'; +import StatusIcon from '@/components/Global/StatusIcon'; +import Alert from '@/components/Global/Alert'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'Certificates', + components: { + Alert, + IconAdd, + IconReplace, + IconTrashcan, + ModalGenerateCsr, + ModalUploadCertificate, + PageTitle, + StatusIcon, + TableRowAction, + }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + modalCertificate: null, + fields: [ + { + key: 'certificate', + label: this.$t('pageCertificates.table.certificate'), + }, + { + key: 'issuedBy', + label: this.$t('pageCertificates.table.issuedBy'), + }, + { + key: 'issuedTo', + label: this.$t('pageCertificates.table.issuedTo'), + }, + { + key: 'validFrom', + label: this.$t('pageCertificates.table.validFrom'), + }, + { + key: 'validUntil', + label: this.$t('pageCertificates.table.validUntil'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + }; + }, + computed: { + certificates() { + return this.$store.getters['certificates/allCertificates']; + }, + tableItems() { + return this.certificates.map((certificate) => { + return { + ...certificate, + actions: [ + { + value: 'replace', + title: this.$t('pageCertificates.replaceCertificate'), + }, + { + value: 'delete', + title: this.$t('pageCertificates.deleteCertificate'), + enabled: + certificate.type === 'TrustStore Certificate' ? true : false, + }, + ], + }; + }); + }, + certificatesForUpload() { + return this.$store.getters['certificates/availableUploadTypes']; + }, + bmcTime() { + return this.$store.getters['global/bmcTime']; + }, + expiredCertificateTypes() { + return this.certificates.reduce((acc, val) => { + const daysUntilExpired = this.getDaysUntilExpired(val.validUntil); + if (daysUntilExpired < 1) { + acc.push(val.certificate); + } + return acc; + }, []); + }, + expiringCertificateTypes() { + return this.certificates.reduce((acc, val) => { + const daysUntilExpired = this.getDaysUntilExpired(val.validUntil); + if (daysUntilExpired < 31 && daysUntilExpired > 0) { + acc.push(val.certificate); + } + return acc; + }, []); + }, + }, + async created() { + this.startLoader(); + await this.$store.dispatch('global/getBmcTime'); + this.$store.dispatch('certificates/getCertificates').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + onTableRowAction(event, rowItem) { + switch (event) { + case 'replace': + this.initModalUploadCertificate(rowItem); + break; + case 'delete': + this.initModalDeleteCertificate(rowItem); + break; + default: + break; + } + }, + initModalUploadCertificate(certificate = null) { + this.modalCertificate = certificate; + this.$bvModal.show('upload-certificate'); + }, + initModalDeleteCertificate(certificate) { + this.$bvModal + .msgBoxConfirm( + this.$t('pageCertificates.modal.deleteConfirmMessage', { + issuedBy: certificate.issuedBy, + certificate: certificate.certificate, + }), + { + title: this.$t('pageCertificates.deleteCertificate'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.deleteCertificate(certificate); + }); + }, + onModalOk({ addNew, file, type, location }) { + if (addNew) { + // Upload a new certificate + this.addNewCertificate(file, type); + } else { + // Replace an existing certificate + this.replaceCertificate(file, type, location); + } + }, + addNewCertificate(file, type) { + this.startLoader(); + this.$store + .dispatch('certificates/addNewCertificate', { file, type }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + replaceCertificate(file, type, location) { + this.startLoader(); + const reader = new FileReader(); + reader.readAsBinaryString(file); + reader.onloadend = (event) => { + const certificateString = event.target.result; + this.$store + .dispatch('certificates/replaceCertificate', { + certificateString, + type, + location, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }; + }, + deleteCertificate({ type, location }) { + this.startLoader(); + this.$store + .dispatch('certificates/deleteCertificate', { + type, + location, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + getDaysUntilExpired(date) { + if (this.bmcTime) { + const validUntilMs = date.getTime(); + const currentBmcTimeMs = this.bmcTime.getTime(); + const oneDayInMs = 24 * 60 * 60 * 1000; + return Math.round((validUntilMs - currentBmcTimeMs) / oneDayInMs); + } + return new Date(); + }, + getIconStatus(date) { + const daysUntilExpired = this.getDaysUntilExpired(date); + if (daysUntilExpired < 1) { + return 'danger'; + } else if (daysUntilExpired < 31) { + return 'warning'; + } + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Certificates/CsrCountryCodes.js b/src/views/_sila/SecurityAndAccess/Certificates/CsrCountryCodes.js new file mode 100644 index 00000000..a2d70007 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/CsrCountryCodes.js @@ -0,0 +1,345 @@ +import i18n from '@/i18n'; + +export const COUNTRY_LIST = [ + { name: 'Afghanistan', code: 'AF', label: i18n.t('countries.AF') }, + { name: 'Albania', code: 'AL', label: i18n.t('countries.AL') }, + { name: 'Algeria', code: 'DZ', label: i18n.t('countries.DZ') }, + { name: 'American Samoa', code: 'AS', label: i18n.t('countries.AS') }, + { name: 'Andorra', code: 'AD', label: i18n.t('countries.AD') }, + { name: 'Angola', code: 'AO', label: i18n.t('countries.AO') }, + { name: 'Anguilla', code: 'AI', label: i18n.t('countries.AI') }, + { name: 'Antarctica', code: 'AQ', label: i18n.t('countries.AQ') }, + { name: 'Antigua and Barbuda', code: 'AG', label: i18n.t('countries.AG') }, + { name: 'Argentina', code: 'AR', label: i18n.t('countries.AR') }, + { name: 'Armenia', code: 'AM', label: i18n.t('countries.AM') }, + { name: 'Aruba', code: 'AW', label: i18n.t('countries.AW') }, + { name: 'Australia', code: 'AU', label: i18n.t('countries.AU') }, + { name: 'Austria', code: 'AT', label: i18n.t('countries.AT') }, + { name: 'Azerbaijan', code: 'AZ', label: i18n.t('countries.AZ') }, + { name: 'Bahamas, The', code: 'BS', label: i18n.t('countries.BS') }, + { name: 'Bahrain', code: 'BH', label: i18n.t('countries.BH') }, + { name: 'Bangladesh', code: 'BD', label: i18n.t('countries.BD') }, + { name: 'Barbados', code: 'BB', label: i18n.t('countries.BB') }, + { name: 'Belarus', code: 'BY', label: i18n.t('countries.BY') }, + { name: 'Belgium', code: 'BE', label: i18n.t('countries.BE') }, + { name: 'Belize', code: 'BZ', label: i18n.t('countries.BZ') }, + { name: 'Benin', code: 'BJ', label: i18n.t('countries.BJ') }, + { name: 'Bermuda', code: 'BM', label: i18n.t('countries.BM') }, + { name: 'Bhutan', code: 'BT', label: i18n.t('countries.BT') }, + { name: 'Bolivia', code: 'BO', label: i18n.t('countries.BO') }, + { + name: 'Bonaire, Sint Eustatius and Saba', + code: 'BQ', + label: i18n.t('countries.BQ'), + }, + { + name: 'Bosnia and Herzegovina ', + code: 'BA', + label: i18n.t('countries.BA'), + }, + { name: 'Bostwana', code: 'BW', label: i18n.t('countries.BW') }, + { name: 'Bouvet Island', code: 'BV', label: i18n.t('countries.BV') }, + { name: 'Brazil', code: 'BR', label: i18n.t('countries.BR') }, + { + name: 'British Indian Ocean Territory', + code: 'IO', + label: i18n.t('countries.IO'), + }, + { name: 'Brunei Darussalam ', code: 'BN', label: i18n.t('countries.BN') }, + { name: 'Bulgaria', code: 'BG', label: i18n.t('countries.BG') }, + { name: 'Burkina Faso', code: 'BF', label: i18n.t('countries.BF') }, + { name: 'Burundi', code: 'BI', label: i18n.t('countries.BI') }, + { name: 'Cabo Verde', code: 'CV', label: i18n.t('countries.CV') }, + { name: 'Cambodia', code: 'KH', label: i18n.t('countries.KH') }, + { name: 'Cameroon', code: 'CM', label: i18n.t('countries.CM') }, + { name: 'Canada', code: 'CA', label: i18n.t('countries.CA') }, + { name: 'Cayman Islands', code: 'KY', label: i18n.t('countries.KY') }, + { + name: 'Central African Republic', + code: 'CF', + label: i18n.t('countries.CF'), + }, + { name: 'Chad', code: 'TD', label: i18n.t('countries.TD') }, + { name: 'Chile', code: 'CL', label: i18n.t('countries.CL') }, + { name: 'China', code: 'CN', label: i18n.t('countries.CN') }, + { name: 'Christmas Island ', code: 'CX', label: i18n.t('countries.CX') }, + { name: 'Cocos(Keeling) Islands', code: 'CC', label: i18n.t('countries.CC') }, + { name: 'Columbia', code: 'CO', label: i18n.t('countries.CO') }, + { name: 'Comoros', code: 'KM', label: i18n.t('countries.KM') }, + { + name: 'Congo, The Democratic Republic of the', + code: 'CD', + label: i18n.t('countries.CD'), + }, + { name: 'Congo', code: 'CG', label: i18n.t('countries.CG') }, + { name: 'Cook Islands', code: 'CK', label: i18n.t('countries.CK') }, + { name: 'Costa Rica', code: 'CR', label: i18n.t('countries.CR') }, + { name: 'Croatia', code: 'HR', label: i18n.t('countries.HR') }, + { name: 'Cuba', code: 'CU', label: i18n.t('countries.CU') }, + { name: 'Curaçao', code: 'CW', label: i18n.t('countries.CW') }, + { name: 'Cyprus', code: 'CY', label: i18n.t('countries.CY') }, + { name: 'Czechia', code: 'CZ', label: i18n.t('countries.CZ') }, + { name: "Côte d'Ivoire", code: 'CI', label: i18n.t('countries.CI') }, + { name: 'Denmark', code: 'DK', label: i18n.t('countries.DK') }, + { name: 'Djibouti', code: 'DJ', label: i18n.t('countries.DJ') }, + { name: 'Dominica', code: 'DM', label: i18n.t('countries.DM') }, + { name: 'Dominican Republic', code: 'DO', label: i18n.t('countries.DO') }, + { name: 'Ecuador', code: 'EC', label: i18n.t('countries.EC') }, + { name: 'Egypt', code: 'EG', label: i18n.t('countries.EG') }, + { name: 'El Salvador', code: 'SV', label: i18n.t('countries.SV') }, + { name: 'Equatorial Guinea ', code: 'GQ', label: i18n.t('countries.GQ') }, + { name: 'Eritrea', code: 'ER', label: i18n.t('countries.ER') }, + { name: 'Estonia', code: 'EE', label: i18n.t('countries.EE') }, + { name: 'Eswatini', code: 'SZ', label: i18n.t('countries.SZ') }, + { name: 'Ethiopia', code: 'ET', label: i18n.t('countries.ET') }, + { + name: 'Falkland Islands (Malvinas)', + code: 'FK', + label: i18n.t('countries.FK'), + }, + { name: 'Faroe Islands', code: 'FO', label: i18n.t('countries.FO') }, + { name: 'Fiji', code: 'FJ', label: i18n.t('countries.FJ') }, + { name: 'Finland', code: 'FI', label: i18n.t('countries.FI') }, + { name: 'France', code: 'FR', label: i18n.t('countries.FR') }, + { name: 'French Guiana', code: 'GF', label: i18n.t('countries.GF') }, + { name: 'French Polynesia', code: 'PF', label: i18n.t('countries.PF') }, + { + name: 'French Southern Territories', + code: 'TF', + label: i18n.t('countries.TF'), + }, + { name: 'Gabon', code: 'GA', label: i18n.t('countries.GA') }, + { name: 'Gambia, The', code: 'GM', label: i18n.t('countries.GM') }, + { name: 'Georgia', code: 'GE', label: i18n.t('countries.GE') }, + { name: 'Germany', code: 'DE', label: i18n.t('countries.DE') }, + { name: 'Ghana', code: 'GH', label: i18n.t('countries.GH') }, + { name: 'Gibraltar', code: 'GI', label: i18n.t('countries.GI') }, + { name: 'Greece', code: 'GR', label: i18n.t('countries.GR') }, + { name: 'Greenland', code: 'GL', label: i18n.t('countries.GL') }, + { name: 'Grenada', code: 'GD', label: i18n.t('countries.GD') }, + { name: 'Guadeloupe', code: 'GP', label: i18n.t('countries.GP') }, + { name: 'Guam', code: 'GU', label: i18n.t('countries.GU') }, + { name: 'Guatemala', code: 'GT', label: i18n.t('countries.GT') }, + { name: 'Guernsey', code: 'GG', label: i18n.t('countries.GG') }, + { name: 'Guinea', code: 'GN', label: i18n.t('countries.GN') }, + { name: 'Guinea-Bissau', code: 'GW', label: i18n.t('countries.GW') }, + { name: 'Guyana', code: 'GY', label: i18n.t('countries.GY') }, + { name: 'Haiti', code: 'HT', label: i18n.t('countries.HT') }, + { + name: 'Heard Island and McDonald Islands', + code: 'HM', + label: i18n.t('countries.HM'), + }, + { name: 'Holy See', code: 'VA', label: i18n.t('countries.VA') }, + { name: 'Honduras', code: 'HN', label: i18n.t('countries.HN') }, + { name: 'Hong Kong', code: 'HK', label: i18n.t('countries.HK') }, + { name: 'Hungary', code: 'HU', label: i18n.t('countries.HU') }, + { name: 'Iceland', code: 'IS', label: i18n.t('countries.IS') }, + { name: 'India', code: 'IN', label: i18n.t('countries.IN') }, + { name: 'Indonesia', code: 'ID', label: i18n.t('countries.ID') }, + { + name: 'Iran, Islamic Republic of', + code: 'IR', + label: i18n.t('countries.IR'), + }, + { name: 'Iraq', code: 'IQ', label: i18n.t('countries.IQ') }, + { name: 'Ireland', code: 'IE', label: i18n.t('countries.IE') }, + { name: 'Isle of Man', code: 'IM', label: i18n.t('countries.IM') }, + { name: 'Israel', code: 'IL', label: i18n.t('countries.IL') }, + { name: 'Italy', code: 'IT', label: i18n.t('countries.IT') }, + { name: 'Jamaica', code: 'JM', label: i18n.t('countries.JM') }, + { name: 'Japan', code: 'JP', label: i18n.t('countries.JP') }, + { name: 'Jersey', code: 'JE', label: i18n.t('countries.JE') }, + { name: 'Jordan', code: 'JO', label: i18n.t('countries.JO') }, + { name: 'Kazakhstan', code: 'KZ', label: i18n.t('countries.KZ') }, + { name: 'Kenya', code: 'KE', label: i18n.t('countries.KE') }, + { name: 'Kiribati', code: 'KI', label: i18n.t('countries.KI') }, + { name: 'Korea, Republic of', code: 'KR', label: i18n.t('countries.KR') }, + { + name: "Korea, Democratic People's Republic of", + code: 'KP', + label: i18n.t('countries.KP'), + }, + { name: 'Kuwait', code: 'KW', label: i18n.t('countries.KW') }, + { name: 'Kyrgyzstan', code: 'KG', label: i18n.t('countries.KG') }, + { + name: "Lao People's Democratic Republic", + code: 'LA', + label: i18n.t('countries.LA'), + }, + { name: 'Latvia', code: 'LV', label: i18n.t('countries.LV') }, + { name: 'Lebanon', code: 'LB', label: i18n.t('countries.LB') }, + { name: 'Lesotho', code: 'LS', label: i18n.t('countries.LS') }, + { name: 'Liberia', code: 'LR', label: i18n.t('countries.LR') }, + { name: 'Libya', code: 'LY', label: i18n.t('countries.LY') }, + { name: 'Liechtenstein', code: 'LI', label: i18n.t('countries.LI') }, + { name: 'Lithuania', code: 'LT', label: i18n.t('countries.LT') }, + { name: 'Luxembourg', code: 'LU', label: i18n.t('countries.LU') }, + { name: 'Macao', code: 'MO', label: i18n.t('countries.MO') }, + { + name: 'Macedonia, The Former Yugoslav Republic of', + code: 'MK', + label: i18n.t('countries.MK'), + }, + { name: 'Madagascar', code: 'MG', label: i18n.t('countries.MG') }, + { name: 'Malawi', code: 'MW', label: i18n.t('countries.MW') }, + { name: 'Malaysia', code: 'MY', label: i18n.t('countries.MY') }, + { name: 'Maldives', code: 'MV', label: i18n.t('countries.MV') }, + { name: 'Mali', code: 'ML', label: i18n.t('countries.ML') }, + { name: 'Malta', code: 'MT', label: i18n.t('countries.MT') }, + { name: 'Marshall Islands', code: 'MH', label: i18n.t('countries.MH') }, + { name: 'Martinique', code: 'MQ', label: i18n.t('countries.MQ') }, + { name: 'Mauritania', code: 'MR', label: i18n.t('countries.MR') }, + { name: 'Mauritius', code: 'MU', label: i18n.t('countries.MU') }, + { name: 'Mayotte', code: 'YT', label: i18n.t('countries.YT') }, + { name: 'Mexico', code: 'MX', label: i18n.t('countries.MX') }, + { + name: 'Micronesia, Federated States of', + code: 'FM', + label: i18n.t('countries.FM'), + }, + { name: 'Moldova, Republic of', code: 'MD', label: i18n.t('countries.MD') }, + { name: 'Monaco', code: 'MC', label: i18n.t('countries.MC') }, + { name: 'Mongolia', code: 'MN', label: i18n.t('countries.MN') }, + { name: 'Montenegro', code: 'ME', label: i18n.t('countries.ME') }, + { name: 'Montserrat', code: 'MS', label: i18n.t('countries.MS') }, + { name: 'Morocco', code: 'MA', label: i18n.t('countries.MA') }, + { name: 'Mozambique', code: 'MZ', label: i18n.t('countries.MZ') }, + { name: 'Myanmar', code: 'MM', label: i18n.t('countries.MM') }, + { name: 'Namibia', code: 'NA', label: i18n.t('countries.NA') }, + { name: 'Nauru', code: 'NR', label: i18n.t('countries.NR') }, + { name: 'Nepal', code: 'NP', label: i18n.t('countries.NP') }, + { name: 'Netherlands', code: 'NL', label: i18n.t('countries.NL') }, + { name: 'New Caledonia', code: 'NC', label: i18n.t('countries.NC') }, + { name: 'New Zealand', code: 'NZ', label: i18n.t('countries.NZ') }, + { name: 'Nicaragua', code: 'NI', label: i18n.t('countries.NI') }, + { name: 'Niger', code: 'NE', label: i18n.t('countries.NE') }, + { name: 'Nigeria', code: 'NG', label: i18n.t('countries.NG') }, + { name: 'Niue', code: 'NU', label: i18n.t('countries.NU') }, + { name: 'Norfolk Island', code: 'NF', label: i18n.t('countries.NF') }, + { + name: 'Northern Mariana Islands', + code: 'MP', + label: i18n.t('countries.MP'), + }, + { name: 'Norway', code: 'NO', label: i18n.t('countries.NO') }, + { name: 'Oman', code: 'OM', label: i18n.t('countries.OM') }, + { name: 'Pakistan', code: 'PK', label: i18n.t('countries.PK') }, + { name: 'Palau', code: 'PW', label: i18n.t('countries.PW') }, + { name: 'Palestine', code: 'PS', label: i18n.t('countries.PS') }, + { name: 'Panama', code: 'PA', label: i18n.t('countries.PA') }, + { name: 'Papua New Guinea', code: 'PG', label: i18n.t('countries.PG') }, + { name: 'Paraguay', code: 'PY', label: i18n.t('countries.PY') }, + { name: 'Peru', code: 'PE', label: i18n.t('countries.PE') }, + { name: 'Philippines', code: 'PH', label: i18n.t('countries.PH') }, + { name: 'Pitcairn', code: 'PN', label: i18n.t('countries.PN') }, + { name: 'Poland', code: 'PL', label: i18n.t('countries.PL') }, + { name: 'Portugal', code: 'PT', label: i18n.t('countries.PT') }, + { name: 'Puerto Rico', code: 'PR', label: i18n.t('countries.PR') }, + { name: 'Qatar', code: 'QA', label: i18n.t('countries.QA') }, + { name: 'Romania', code: 'RO', label: i18n.t('countries.RO') }, + { name: 'Russian Federation', code: 'RU', label: i18n.t('countries.RU') }, + { name: 'Rwanda', code: 'RW', label: i18n.t('countries.RW') }, + { name: 'Réunion', code: 'RE', label: i18n.t('countries.RE') }, + { name: 'Saint Barthélemy', code: 'BL', label: i18n.t('countries.BL') }, + { + name: 'Saint Helena, Ascension and Tristan da Cunha', + code: 'SH', + label: i18n.t('countries.SH'), + }, + { name: 'Saint Kitts and Nevis ', code: 'KN', label: i18n.t('countries.KN') }, + { name: 'Saint Lucia', code: 'LC', label: i18n.t('countries.LC') }, + { name: 'Saint Martin', code: 'MF', label: i18n.t('countries.MF') }, + { + name: 'Saint Pierre and Miquelon', + code: 'PM', + label: i18n.t('countries.PM'), + }, + { + name: 'Saint Vincent and the Grenadines', + code: 'VC', + label: i18n.t('countries.VC'), + }, + { name: 'Samoa', code: 'WS', label: i18n.t('countries.WS') }, + { name: 'San Marino ', code: 'SM', label: i18n.t('countries.SM') }, + { name: 'Sao Tome and Principe', code: 'ST', label: i18n.t('countries.ST') }, + { name: 'Saudi Arabia', code: 'SA', label: i18n.t('countries.SA') }, + { name: 'Senegal', code: 'SN', label: i18n.t('countries.SN') }, + { name: 'Serbia', code: 'RS', label: i18n.t('countries.RS') }, + { name: 'Seychelles', code: 'SC', label: i18n.t('countries.SC') }, + { name: 'Sierra Leone', code: 'SL', label: i18n.t('countries.SL') }, + { name: 'Singapore', code: 'SG', label: i18n.t('countries.SG') }, + { name: 'Sint Maarten', code: 'SX', label: i18n.t('countries.SX') }, + { name: 'Slovakia', code: 'SK', label: i18n.t('countries.SK') }, + { name: 'Slovenia', code: 'SI', label: i18n.t('countries.SI') }, + { name: 'Solomon Islands', code: 'SB', label: i18n.t('countries.SB') }, + { name: 'Somalia', code: 'SO', label: i18n.t('countries.SO') }, + { name: 'South Africa ', code: 'ZA', label: i18n.t('countries.ZA') }, + { + name: 'South Georgia and the South Sandwich Islands', + code: 'GS', + label: i18n.t('countries.GS'), + }, + { name: 'South Sudan', code: 'SS', label: i18n.t('countries.SS') }, + { name: 'Spain', code: 'ES', label: i18n.t('countries.ES') }, + { name: 'Sri Lanka', code: 'LK', label: i18n.t('countries.LK') }, + { name: 'Sudan', code: 'SD', label: i18n.t('countries.SD') }, + { name: 'Suriname', code: 'SR', label: i18n.t('countries.SR') }, + { name: 'Svalbard and Jan Mayen', code: 'SJ', label: i18n.t('countries.SJ') }, + { name: 'Sweden', code: 'SE', label: i18n.t('countries.SE') }, + { name: 'Switzerland', code: 'CH', label: i18n.t('countries.CH') }, + { name: 'Syrian Arab Republic', code: 'SY', label: i18n.t('countries.SY') }, + { name: 'Taiwan', code: 'TW', label: i18n.t('countries.TW') }, + { name: 'Tajikistan', code: 'TJ', label: i18n.t('countries.TJ') }, + { + name: 'Tanzania, United Republic of', + code: 'TZ', + label: i18n.t('countries.TZ'), + }, + { name: 'Thailand', code: 'TH', label: i18n.t('countries.TH') }, + { name: 'Timor-Leste', code: 'TL', label: i18n.t('countries.TL') }, + { name: 'Togo', code: 'TG', label: i18n.t('countries.TG') }, + { name: 'Tokelau', code: 'TK', label: i18n.t('countries.TK') }, + { name: 'Tonga', code: 'TO', label: i18n.t('countries.TO') }, + { name: 'Trinidad and Tobago', code: 'TT', label: i18n.t('countries.TT') }, + { name: 'Tunisia', code: 'TN', label: i18n.t('countries.TN') }, + { name: 'Turkey', code: 'TR', label: i18n.t('countries.TR') }, + { name: 'Turkmenistan', code: 'TM', label: i18n.t('countries.TM') }, + { + name: 'Turks and Caicos Islands', + code: 'TC', + label: i18n.t('countries.TC'), + }, + { name: 'Tuvalu', code: 'TV', label: i18n.t('countries.TV') }, + { name: 'Uganda', code: 'UG', label: i18n.t('countries.UG') }, + { name: 'Ukraine', code: 'UA', label: i18n.t('countries.UA') }, + { name: 'United Arab Emirates', code: 'AE', label: i18n.t('countries.AE') }, + { name: 'United Kingdom', code: 'GB', label: i18n.t('countries.GB') }, + { + name: 'United States Minor Outlying Islands', + code: 'UM', + label: i18n.t('countries.UM'), + }, + { + name: 'United States of America', + code: 'US', + label: i18n.t('countries.US'), + }, + { name: 'Uruguay', code: 'UY', label: i18n.t('countries.UY') }, + { name: 'Uzbekistan', code: 'UZ', label: i18n.t('countries.UZ') }, + { name: 'Vanuatu', code: 'VU', label: i18n.t('countries.VU') }, + { name: 'Venezuela', code: 'VE', label: i18n.t('countries.VE') }, + { name: 'Viet Nam', code: 'VN', label: i18n.t('countries.VN') }, + { + name: 'Virgin Islands, British', + code: 'VG', + label: i18n.t('countries.VG'), + }, + { name: 'Virgin Islands, U.S', code: 'VI', label: i18n.t('countries.VI') }, + { name: 'Wallis and Futuna', code: 'WF', label: i18n.t('countries.WF') }, + { name: 'Western Sahara', code: 'EH', label: i18n.t('countries.EH') }, + { name: 'Yemen', code: 'YE', label: i18n.t('countries.YE') }, + { name: 'Zambia', code: 'ZM', label: i18n.t('countries.ZM') }, + { name: 'Zimbabwe', code: 'ZW', label: i18n.t('countries.ZW') }, + { name: 'Åland Islands', code: 'AX', label: i18n.t('countries.AX') }, +]; diff --git a/src/views/_sila/SecurityAndAccess/Certificates/ModalGenerateCsr.vue b/src/views/_sila/SecurityAndAccess/Certificates/ModalGenerateCsr.vue new file mode 100644 index 00000000..d76f9fe1 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/ModalGenerateCsr.vue @@ -0,0 +1,496 @@ +<template> + <div> + <b-modal + id="generate-csr" + ref="modal" + size="lg" + no-stacking + :title="$t('pageCertificates.modal.generateACertificateSigningRequest')" + @ok="onOkGenerateCsrModal" + @cancel="resetForm" + @hidden="$v.$reset()" + > + <b-form id="generate-csr-form" novalidate @submit.prevent="handleSubmit"> + <b-container fluid> + <b-row> + <b-col lg="9"> + <b-row> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.certificateType')" + label-for="certificate-type" + > + <b-form-select + id="certificate-type" + v-model="form.certificateType" + data-test-id="modalGenerateCsr-select-certificateType" + :options="certificateOptions" + :state="getValidationState($v.form.certificateType)" + @input="$v.form.certificateType.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.country')" + label-for="country" + > + <b-form-select + id="country" + v-model="form.country" + data-test-id="modalGenerateCsr-select-country" + :options="countryOptions" + :state="getValidationState($v.form.country)" + @input="$v.form.country.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.state')" + label-for="state" + > + <b-form-input + id="state" + v-model="form.state" + type="text" + data-test-id="modalGenerateCsr-input-state" + :state="getValidationState($v.form.state)" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.city')" + label-for="city" + > + <b-form-input + id="city" + v-model="form.city" + type="text" + data-test-id="modalGenerateCsr-input-city" + :state="getValidationState($v.form.city)" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.companyName')" + label-for="company-name" + > + <b-form-input + id="company-name" + v-model="form.companyName" + type="text" + data-test-id="modalGenerateCsr-input-companyName" + :state="getValidationState($v.form.companyName)" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.companyUnit')" + label-for="company-unit" + > + <b-form-input + id="company-unit" + v-model="form.companyUnit" + type="text" + data-test-id="modalGenerateCsr-input-companyUnit" + :state="getValidationState($v.form.companyUnit)" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="6"> + <b-form-group + :label="$t('pageCertificates.modal.commonName')" + label-for="common-name" + > + <b-form-input + id="common-name" + v-model="form.commonName" + type="text" + data-test-id="modalGenerateCsr-input-commonName" + :state="getValidationState($v.form.commonName)" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col lg="6"> + <b-form-group label-for="challenge-password"> + <template #label> + {{ $t('pageCertificates.modal.challengePassword') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-input + id="challenge-password" + v-model="form.challengePassword" + type="text" + data-test-id="modalGenerateCsr-input-challengePassword" + /> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="6"> + <b-form-group label-for="contact-person"> + <template #label> + {{ $t('pageCertificates.modal.contactPerson') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-input + id="contact-person" + v-model="form.contactPerson" + type="text" + data-test-id="modalGenerateCsr-input-contactPerson" + /> + </b-form-group> + </b-col> + <b-col lg="6"> + <b-form-group label-for="email-address"> + <template #label> + {{ $t('pageCertificates.modal.emailAddress') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-input + id="email-address" + v-model="form.emailAddress" + type="text" + data-test-id="modalGenerateCsr-input-emailAddress" + /> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="12"> + <b-form-group label-for="alternate-name"> + <template #label> + {{ $t('pageCertificates.modal.alternateName') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-text id="alternate-name-help-block"> + {{ $t('pageCertificates.modal.alternateNameHelperText') }} + </b-form-text> + <b-form-tags + v-model="form.alternateName" + :remove-on-delete="true" + :tag-pills="true" + input-id="alternate-name" + size="lg" + separator=" " + :input-attrs="{ + 'aria-describedby': 'alternate-name-help-block', + }" + :duplicate-tag-text=" + $t('pageCertificates.modal.duplicateAlternateName') + " + placeholder="" + data-test-id="modalGenerateCsr-input-alternateName" + > + <template #add-button-text> + <icon-add /> {{ $t('global.action.add') }} + </template> + </b-form-tags> + </b-form-group> + </b-col> + </b-row> + </b-col> + <b-col lg="3"> + <b-row> + <b-col lg="12"> + <p class="col-form-label"> + {{ $t('pageCertificates.modal.privateKey') }} + </p> + <b-form-group + :label="$t('pageCertificates.modal.keyPairAlgorithm')" + label-for="key-pair-algorithm" + > + <b-form-select + id="key-pair-algorithm" + v-model="form.keyPairAlgorithm" + data-test-id="modalGenerateCsr-select-keyPairAlgorithm" + :options="keyPairAlgorithmOptions" + :state="getValidationState($v.form.keyPairAlgorithm)" + @input="$v.form.keyPairAlgorithm.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col lg="12"> + <template v-if="$v.form.keyPairAlgorithm.$model === 'EC'"> + <b-form-group + :label="$t('pageCertificates.modal.keyCurveId')" + label-for="key-curve-id" + > + <b-form-select + id="key-curve-id" + v-model="form.keyCurveId" + data-test-id="modalGenerateCsr-select-keyCurveId" + :options="keyCurveIdOptions" + :state="getValidationState($v.form.keyCurveId)" + @input="$v.form.keyCurveId.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </template> + <template v-if="$v.form.keyPairAlgorithm.$model === 'RSA'"> + <b-form-group + :label="$t('pageCertificates.modal.keyBitLength')" + label-for="key-bit-length" + > + <b-form-select + id="key-bit-length" + v-model="form.keyBitLength" + data-test-id="modalGenerateCsr-select-keyBitLength" + :options="keyBitLengthOptions" + :state="getValidationState($v.form.keyBitLength)" + @input="$v.form.keyBitLength.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </template> + </b-col> + </b-row> + </b-col> + </b-row> + </b-container> + </b-form> + <template #modal-footer="{ ok, cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button + form="generate-csr-form" + type="submit" + variant="primary" + data-test-id="modalGenerateCsr-button-ok" + @click="ok()" + > + {{ $t('pageCertificates.generateCsr') }} + </b-button> + </template> + </b-modal> + <b-modal + id="csr-string" + no-stacking + size="lg" + :title="$t('pageCertificates.modal.certificateSigningRequest')" + @hidden="onHiddenCsrStringModal" + > + {{ csrString }} + <template #modal-footer> + <b-btn variant="secondary" @click="copyCsrString"> + <template v-if="csrStringCopied"> + <icon-checkmark /> + {{ $t('global.status.copied') }} + </template> + <template v-else> + {{ $t('global.action.copy') }} + </template> + </b-btn> + <a + :href="`data:text/json;charset=utf-8,${csrString}`" + download="certificate.txt" + class="btn btn-primary" + > + {{ $t('global.action.download') }} + </a> + </template> + </b-modal> + </div> +</template> + +<script> +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconCheckmark from '@carbon/icons-vue/es/checkmark/20'; + +import { required, requiredIf } from 'vuelidate/lib/validators'; + +import { COUNTRY_LIST } from './CsrCountryCodes'; +import { CERTIFICATE_TYPES } from '@/store/modules/SecurityAndAccess/CertificatesStore'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + name: 'ModalGenerateCsr', + components: { IconAdd, IconCheckmark }, + mixins: [BVToastMixin, VuelidateMixin], + data() { + return { + form: { + certificateType: null, + country: null, + state: null, + city: null, + companyName: null, + companyUnit: null, + commonName: null, + challengePassword: null, + contactPerson: null, + emailAddress: null, + alternateName: [], + keyPairAlgorithm: null, + keyCurveId: null, + keyBitLength: null, + }, + certificateOptions: CERTIFICATE_TYPES.reduce((arr, cert) => { + if (cert.type === 'TrustStore Certificate') return arr; + arr.push({ + text: cert.label, + value: cert.type, + }); + return arr; + }, []), + countryOptions: COUNTRY_LIST.map((country) => ({ + text: country.label, + value: country.code, + })), + keyPairAlgorithmOptions: ['EC', 'RSA'], + keyCurveIdOptions: ['prime256v1', 'secp521r1', 'secp384r1'], + keyBitLengthOptions: [2048], + csrString: '', + csrStringCopied: false, + }; + }, + validations: { + form: { + certificateType: { required }, + country: { required }, + state: { required }, + city: { required }, + companyName: { required }, + companyUnit: { required }, + commonName: { required }, + challengePassword: {}, + contactPerson: {}, + emailAddress: {}, + alternateName: {}, + keyPairAlgorithm: { required }, + keyCurveId: { + reuired: requiredIf(function (form) { + return form.keyPairAlgorithm === 'EC'; + }), + }, + keyBitLength: { + reuired: requiredIf(function (form) { + return form.keyPairAlgorithm === 'RSA'; + }), + }, + }, + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$store + .dispatch('certificates/generateCsr', this.form) + .then(({ data: { CSRString } }) => { + this.csrString = CSRString; + this.$bvModal.show('csr-string'); + this.$v.$reset(); + }); + }, + resetForm() { + for (let key of Object.keys(this.form)) { + if (key === 'alternateName') { + this.form[key] = []; + } else { + this.form[key] = null; + } + } + }, + onOkGenerateCsrModal(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + onHiddenCsrStringModal() { + this.csrString = ''; + this.resetForm(); + }, + copyCsrString(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + navigator.clipboard.writeText(this.csrString).then(() => { + // Show copied text for 5 seconds + this.csrStringCopied = true; + setTimeout(() => { + this.csrStringCopied = false; + }, 5000 /*5 seconds*/); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Certificates/ModalUploadCertificate.vue b/src/views/_sila/SecurityAndAccess/Certificates/ModalUploadCertificate.vue new file mode 100644 index 00000000..f4db7a26 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/ModalUploadCertificate.vue @@ -0,0 +1,168 @@ +<template> + <b-modal id="upload-certificate" ref="modal" @ok="onOk" @hidden="resetForm"> + <template #modal-title> + <template v-if="certificate"> + {{ $t('pageCertificates.replaceCertificate') }} + </template> + <template v-else> + {{ $t('pageCertificates.addNewCertificate') }} + </template> + </template> + <b-form> + <!-- Replace Certificate type --> + <template v-if="certificate !== null"> + <dl class="mb-4"> + <dt>{{ $t('pageCertificates.modal.certificateType') }}</dt> + <dd>{{ certificate.certificate }}</dd> + </dl> + </template> + + <!-- Add new Certificate type --> + <template v-else> + <b-form-group + :label="$t('pageCertificates.modal.certificateType')" + label-for="certificate-type" + > + <b-form-select + id="certificate-type" + v-model="form.certificateType" + :options="certificateOptions" + :state="getValidationState($v.form.certificateType)" + @input="$v.form.certificateType.$touch()" + > + </b-form-select> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.certificateType.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </template> + + <b-form-group :label="$t('pageCertificates.modal.certificateFile')"> + <form-file + id="certificate-file" + v-model="form.file" + accept=".pem" + :state="getValidationState($v.form.file)" + > + <template #invalid> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + </template> + </form-file> + </b-form-group> + </b-form> + <template #modal-ok> + <template v-if="certificate"> + {{ $t('global.action.replace') }} + </template> + <template v-else> + {{ $t('global.action.add') }} + </template> + </template> + <template #modal-cancel> + {{ $t('global.action.cancel') }} + </template> + </b-modal> +</template> + +<script> +import { required, requiredIf } from 'vuelidate/lib/validators'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/Global/FormFile'; + +export default { + components: { FormFile }, + mixins: [VuelidateMixin], + props: { + certificate: { + type: Object, + default: null, + validator: (prop) => { + if (prop === null) return true; + return ( + Object.prototype.hasOwnProperty.call(prop, 'type') && + Object.prototype.hasOwnProperty.call(prop, 'certificate') + ); + }, + }, + }, + data() { + return { + form: { + certificateType: null, + file: null, + }, + }; + }, + computed: { + certificateTypes() { + return this.$store.getters['certificates/availableUploadTypes']; + }, + certificateOptions() { + return this.certificateTypes.map(({ type, label }) => { + return { + text: label, + value: type, + }; + }); + }, + }, + watch: { + certificateOptions: function (options) { + if (options.length) { + this.form.certificateType = options[0].value; + } + }, + }, + validations() { + return { + form: { + certificateType: { + required: requiredIf(function () { + return !this.certificate; + }), + }, + file: { + required, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { + addNew: !this.certificate, + file: this.form.file, + location: this.certificate ? this.certificate.location : null, + type: this.certificate + ? this.certificate.type + : this.form.certificateType, + }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.certificateType = this.certificateOptions.length + ? this.certificateOptions[0].value + : null; + this.form.file = null; + this.$v.$reset(); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Certificates/index.js b/src/views/_sila/SecurityAndAccess/Certificates/index.js new file mode 100644 index 00000000..aff57b59 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/index.js @@ -0,0 +1,2 @@ +import Certificates from './Certificates.vue'; +export default Certificates; diff --git a/src/views/_sila/SecurityAndAccess/Ldap/Ldap.vue b/src/views/_sila/SecurityAndAccess/Ldap/Ldap.vue new file mode 100644 index 00000000..1f2108de --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Ldap/Ldap.vue @@ -0,0 +1,435 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pageLdap.pageDescription')" /> + <page-section :section-title="$t('pageLdap.settings')"> + <b-form novalidate @submit.prevent="handleSubmit"> + <b-row> + <b-col> + <b-form-group + class="mb-3" + :label="$t('pageLdap.form.ldapAuthentication')" + :disabled="loading" + > + <b-form-checkbox + v-model="form.ldapAuthenticationEnabled" + data-test-id="ldap-checkbox-ldapAuthenticationEnabled" + @change="onChangeldapAuthenticationEnabled" + > + {{ $t('global.action.enable') }} + </b-form-checkbox> + </b-form-group> + </b-col> + </b-row> + <div class="form-background p-3"> + <b-form-group + class="m-0" + :label="$t('pageLdap.ariaLabel.ldapSettings')" + label-class="sr-only" + :disabled="!form.ldapAuthenticationEnabled || loading" + > + <b-row> + <b-col md="3" lg="4" xl="3"> + <b-form-group + class="mb-4" + :label="$t('pageLdap.form.secureLdapUsingSsl')" + > + <b-form-text id="enable-secure-help-block"> + {{ $t('pageLdap.form.secureLdapHelper') }} + </b-form-text> + <b-form-checkbox + id="enable-secure-ldap" + v-model="form.secureLdapEnabled" + aria-describedby="enable-secure-help-block" + data-test-id="ldap-checkbox-secureLdapEnabled" + :disabled=" + !caCertificateExpiration || !ldapCertificateExpiration + " + @change="$v.form.secureLdapEnabled.$touch()" + > + {{ $t('global.action.enable') }} + </b-form-checkbox> + </b-form-group> + <dl> + <dt>{{ $t('pageLdap.form.caCertificateValidUntil') }}</dt> + <dd v-if="caCertificateExpiration"> + {{ caCertificateExpiration | formatDate }} + </dd> + <dd v-else>--</dd> + <dt>{{ $t('pageLdap.form.ldapCertificateValidUntil') }}</dt> + <dd v-if="ldapCertificateExpiration"> + {{ ldapCertificateExpiration | formatDate }} + </dd> + <dd v-else>--</dd> + </dl> + <b-link + class="d-inline-block mb-4 m-md-0" + to="/security-and-access/certificates" + > + {{ $t('pageLdap.form.manageSslCertificates') }} + </b-link> + </b-col> + <b-col md="9" lg="8" xl="9"> + <b-row> + <b-col> + <b-form-group :label="$t('pageLdap.form.serviceType')"> + <b-form-radio + v-model="form.activeDirectoryEnabled" + data-test-id="ldap-radio-activeDirectoryEnabled" + :value="false" + @change="onChangeServiceType" + > + OpenLDAP + </b-form-radio> + <b-form-radio + v-model="form.activeDirectoryEnabled" + data-test-id="ldap-radio-activeDirectoryEnabled" + :value="true" + @change="onChangeServiceType" + > + Active Directory + </b-form-radio> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col sm="6" xl="4"> + <b-form-group label-for="server-uri"> + <template #label> + {{ $t('pageLdap.form.serverUri') }} + <info-tooltip + :title="$t('pageLdap.form.serverUriTooltip')" + /> + </template> + <b-input-group :prepend="ldapProtocol"> + <b-form-input + id="server-uri" + v-model="form.serverUri" + data-test-id="ldap-input-serverUri" + :state="getValidationState($v.form.serverUri)" + @change="$v.form.serverUri.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" xl="4"> + <b-form-group + :label="$t('pageLdap.form.bindDn')" + label-for="bind-dn" + > + <b-form-input + id="bind-dn" + v-model="form.bindDn" + data-test-id="ldap-input-bindDn" + :state="getValidationState($v.form.bindDn)" + @change="$v.form.bindDn.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col sm="6" xl="4"> + <b-form-group + :label="$t('pageLdap.form.bindPassword')" + label-for="bind-password" + > + <input-password-toggle + data-test-id="ldap-input-togglePassword" + > + <b-form-input + id="bind-password" + v-model="form.bindPassword" + type="password" + :state="getValidationState($v.form.bindPassword)" + class="form-control-with-button" + @change="$v.form.bindPassword.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + </b-col> + <b-col sm="6" xl="4"> + <b-form-group + :label="$t('pageLdap.form.baseDn')" + label-for="base-dn" + > + <b-form-input + id="base-dn" + v-model="form.baseDn" + data-test-id="ldap-input-baseDn" + :state="getValidationState($v.form.baseDn)" + @change="$v.form.baseDn.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col sm="6" xl="4"> + <b-form-group label-for="user-id-attribute"> + <template #label> + {{ $t('pageLdap.form.userIdAttribute') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-input + id="user-id-attribute" + v-model="form.userIdAttribute" + data-test-id="ldap-input-userIdAttribute" + @change="$v.form.userIdAttribute.$touch()" + /> + </b-form-group> + </b-col> + <b-col sm="6" xl="4"> + <b-form-group label-for="group-id-attribute"> + <template #label> + {{ $t('pageLdap.form.groupIdAttribute') }} - + <span class="form-text d-inline"> + {{ $t('global.form.optional') }} + </span> + </template> + <b-form-input + id="group-id-attribute" + v-model="form.groupIdAttribute" + data-test-id="ldap-input-groupIdAttribute" + @change="$v.form.groupIdAttribute.$touch()" + /> + </b-form-group> + </b-col> + </b-row> + </b-col> + </b-row> + </b-form-group> + </div> + <b-row class="mt-4 mb-5"> + <b-col> + <b-btn + variant="primary" + type="submit" + data-test-id="ldap-button-saveSettings" + :disabled="loading" + > + {{ $t('global.action.saveSettings') }} + </b-btn> + </b-col> + </b-row> + </b-form> + </page-section> + + <!-- Role groups --> + <page-section :section-title="$t('pageLdap.roleGroups')"> + <table-role-groups /> + </page-section> + </b-container> +</template> + +<script> +import { mapGetters } from 'vuex'; +import { find } from 'lodash'; +import { requiredIf } from 'vuelidate/lib/validators'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import InfoTooltip from '@/components/Global/InfoTooltip'; +import TableRoleGroups from './TableRoleGroups'; + +export default { + name: 'Ldap', + components: { + InfoTooltip, + InputPasswordToggle, + PageTitle, + PageSection, + TableRoleGroups, + }, + mixins: [BVToastMixin, VuelidateMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + form: { + ldapAuthenticationEnabled: this.$store.getters['ldap/isServiceEnabled'], + secureLdapEnabled: false, + activeDirectoryEnabled: this.$store.getters[ + 'ldap/isActiveDirectoryEnabled' + ], + serverUri: '', + bindDn: '', + bindPassword: '', + baseDn: '', + userIdAttribute: '', + groupIdAttribute: '', + loading, + }, + }; + }, + computed: { + ...mapGetters('ldap', [ + 'isServiceEnabled', + 'isActiveDirectoryEnabled', + 'ldap', + 'activeDirectory', + ]), + sslCertificates() { + return this.$store.getters['certificates/allCertificates']; + }, + caCertificateExpiration() { + const caCertificate = find(this.sslCertificates, { + type: 'TrustStore Certificate', + }); + if (caCertificate === undefined) return null; + return caCertificate.validUntil; + }, + ldapCertificateExpiration() { + const ldapCertificate = find(this.sslCertificates, { + type: 'LDAP Certificate', + }); + if (ldapCertificate === undefined) return null; + return ldapCertificate.validUntil; + }, + ldapProtocol() { + return this.form.secureLdapEnabled ? 'ldaps://' : 'ldap://'; + }, + }, + watch: { + isServiceEnabled: function (value) { + this.form.ldapAuthenticationEnabled = value; + }, + isActiveDirectoryEnabled: function (value) { + this.form.activeDirectoryEnabled = value; + this.setFormValues(); + }, + }, + validations: { + form: { + ldapAuthenticationEnabled: {}, + secureLdapEnabled: {}, + activeDirectoryEnabled: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + serverUri: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + bindDn: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + bindPassword: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + baseDn: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + userIdAttribute: {}, + groupIdAttribute: {}, + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('ldap/getAccountSettings') + .finally(() => this.endLoader()); + this.$store + .dispatch('certificates/getCertificates') + .finally(() => this.endLoader()); + this.setFormValues(); + }, + methods: { + setFormValues(serviceType) { + if (!serviceType) { + serviceType = this.isActiveDirectoryEnabled + ? this.activeDirectory + : this.ldap; + } + const { + serviceAddress = '', + bindDn = '', + baseDn = '', + userAttribute = '', + groupsAttribute = '', + } = serviceType; + const secureLdap = + serviceAddress && serviceAddress.includes('ldaps://') ? true : false; + const serverUri = serviceAddress + ? serviceAddress.replace(/ldaps?:\/\//, '') + : ''; + this.form.secureLdapEnabled = secureLdap; + this.form.serverUri = serverUri; + this.form.bindDn = bindDn; + this.form.bindPassword = ''; + this.form.baseDn = baseDn; + this.form.userIdAttribute = userAttribute; + this.form.groupIdAttribute = groupsAttribute; + }, + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + const data = { + serviceEnabled: this.form.ldapAuthenticationEnabled, + activeDirectoryEnabled: this.form.activeDirectoryEnabled, + serviceAddress: `${this.ldapProtocol}${this.form.serverUri}`, + bindDn: this.form.bindDn, + bindPassword: this.form.bindPassword, + baseDn: this.form.baseDn, + userIdAttribute: this.form.userIdAttribute, + groupIdAttribute: this.form.groupIdAttribute, + }; + this.startLoader(); + this.$store + .dispatch('ldap/saveAccountSettings', data) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => { + this.errorToast(message); + }) + .finally(() => { + this.form.bindPassword = ''; + this.$v.form.$reset(); + this.endLoader(); + }); + }, + onChangeServiceType(isActiveDirectoryEnabled) { + this.$v.form.activeDirectoryEnabled.$touch(); + const serviceType = isActiveDirectoryEnabled + ? this.activeDirectory + : this.ldap; + // Set form values according to user selected + // service type + this.setFormValues(serviceType); + }, + onChangeldapAuthenticationEnabled(isServiceEnabled) { + this.$v.form.ldapAuthenticationEnabled.$touch(); + if (!isServiceEnabled) { + // Request will fail if sent with empty values. + // The frontend only checks for required fields + // when the service is enabled. This is to prevent + // an error if a user clears any properties then + // disables the service. + this.setFormValues(); + } + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue b/src/views/_sila/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue new file mode 100644 index 00000000..6ea2561a --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue @@ -0,0 +1,164 @@ +<template> + <b-modal id="modal-role-group" ref="modal" @ok="onOk" @hidden="resetForm"> + <template #modal-title> + <template v-if="roleGroup"> + {{ $t('pageLdap.modal.editRoleGroup') }} + </template> + <template v-else> + {{ $t('pageLdap.modal.addNewRoleGroup') }} + </template> + </template> + <b-container> + <b-row> + <b-col sm="8"> + <b-form id="role-group" @submit.prevent="handleSubmit"> + <!-- Edit role group --> + <template v-if="roleGroup !== null"> + <dl class="mb-4"> + <dt>{{ $t('pageLdap.modal.groupName') }}</dt> + <dd>{{ form.groupName }}</dd> + </dl> + </template> + + <!-- Add new role group --> + <template v-else> + <b-form-group + :label="$t('pageLdap.modal.groupName')" + label-for="role-group-name" + > + <b-form-input + id="role-group-name" + v-model="form.groupName" + :state="getValidationState($v.form.groupName)" + @input="$v.form.groupName.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </template> + + <b-form-group + :label="$t('pageLdap.modal.groupPrivilege')" + label-for="privilege" + > + <b-form-select + id="privilege" + v-model="form.groupPrivilege" + :options="accountRoles" + :state="getValidationState($v.form.groupPrivilege)" + @input="$v.form.groupPrivilege.$touch()" + > + <template v-if="!roleGroup" #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </b-form> + </b-col> + </b-row> + </b-container> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button form="role-group" type="submit" variant="primary" @click="onOk"> + <template v-if="roleGroup"> + {{ $t('global.action.save') }} + </template> + <template v-else> + {{ $t('global.action.add') }} + </template> + </b-button> + </template> + </b-modal> +</template> + +<script> +import { required, requiredIf } from 'vuelidate/lib/validators'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + mixins: [VuelidateMixin], + props: { + roleGroup: { + type: Object, + default: null, + validator: (prop) => { + if (prop === null) return true; + return ( + Object.prototype.hasOwnProperty.call(prop, 'groupName') && + Object.prototype.hasOwnProperty.call(prop, 'groupPrivilege') + ); + }, + }, + }, + data() { + return { + form: { + groupName: null, + groupPrivilege: null, + }, + }; + }, + computed: { + accountRoles() { + return this.$store.getters['userManagement/accountRoles']; + }, + }, + watch: { + roleGroup: function (value) { + if (value === null) return; + this.form.groupName = value.groupName; + this.form.groupPrivilege = value.groupPrivilege; + }, + }, + validations() { + return { + form: { + groupName: { + required: requiredIf(function () { + return !this.roleGroup; + }), + }, + groupPrivilege: { + required, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { + addNew: !this.roleGroup, + groupName: this.form.groupName, + groupPrivilege: this.form.groupPrivilege, + }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.groupName = null; + this.form.groupPrivilege = null; + this.$v.$reset(); + this.$emit('hidden'); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Ldap/TableRoleGroups.vue b/src/views/_sila/SecurityAndAccess/Ldap/TableRoleGroups.vue new file mode 100644 index 00000000..5ae3e3d1 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Ldap/TableRoleGroups.vue @@ -0,0 +1,269 @@ +<template> + <div> + <b-row> + <b-col md="9"> + <alert :show="isServiceEnabled === false" variant="info"> + {{ $t('pageLdap.tableRoleGroups.alertContent') }} + </alert> + </b-col> + </b-row> + <b-row> + <b-col class="text-right" md="9"> + <b-btn + variant="primary" + :disabled="!isServiceEnabled" + @click="initRoleGroupModal(null)" + > + <icon-add /> + {{ $t('pageLdap.addRoleGroup') }} + </b-btn> + </b-col> + </b-row> + <b-row> + <b-col md="9"> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + :actions="batchActions" + @clear-selected="clearSelectedRows($refs.table)" + @batch-action="onBatchAction" + /> + <b-table + ref="table" + responsive + selectable + show-empty + no-select-on-click + hover + no-sort-reset + sort-icon-left + :busy="isBusy" + :items="tableItems" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + @row-selected="onRowSelected($event, tableItems.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + :indeterminate="tableHeaderCheckboxIndeterminate" + :disabled="!isServiceEnabled" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + :disabled="!isServiceEnabled" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <!-- table actions column --> + <template #cell(actions)="{ item }"> + <table-row-action + v-for="(action, index) in item.actions" + :key="index" + :value="action.value" + :enabled="action.enabled" + :title="action.title" + @click-table-action="onTableRowAction($event, item)" + > + <template #icon> + <icon-edit v-if="action.value === 'edit'" /> + <icon-trashcan v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + <modal-add-role-group + :role-group="activeRoleGroup" + @ok="saveRoleGroup" + @hidden="activeRoleGroup = null" + /> + </div> +</template> + +<script> +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import { mapGetters } from 'vuex'; + +import Alert from '@/components/Global/Alert'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableRowAction from '@/components/Global/TableRowAction'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import ModalAddRoleGroup from './ModalAddRoleGroup'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + components: { + Alert, + IconAdd, + IconEdit, + IconTrashcan, + ModalAddRoleGroup, + TableRowAction, + TableToolbar, + }, + mixins: [BVTableSelectableMixin, BVToastMixin, LoadingBarMixin], + data() { + return { + isBusy: true, + activeRoleGroup: null, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'groupName', + sortable: true, + label: this.$t('pageLdap.tableRoleGroups.groupName'), + }, + { + key: 'groupPrivilege', + sortable: true, + label: this.$t('pageLdap.tableRoleGroups.groupPrivilege'), + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right', + }, + ], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + ...mapGetters('ldap', ['isServiceEnabled', 'enabledRoleGroups']), + tableItems() { + return this.enabledRoleGroups.map(({ LocalRole, RemoteGroup }) => { + return { + groupName: RemoteGroup, + groupPrivilege: LocalRole, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + enabled: this.isServiceEnabled, + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + enabled: this.isServiceEnabled, + }, + ], + }; + }); + }, + }, + created() { + this.$store.dispatch('userManagement/getAccountRoles').finally(() => { + this.isBusy = false; + }); + }, + methods: { + onBatchAction() { + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageLdap.modal.deleteRoleGroupBatchConfirmMessage', + this.selectedRows.length + ), + { + title: this.$t('pageLdap.modal.deleteRoleGroup'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('ldap/deleteRoleGroup', { + roleGroups: this.selectedRows, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }); + }, + onTableRowAction(action, row) { + switch (action) { + case 'edit': + this.initRoleGroupModal(row); + break; + case 'delete': + this.$bvModal + .msgBoxConfirm( + this.$t('pageLdap.modal.deleteRoleGroupConfirmMessage', { + groupName: row.groupName, + }), + { + title: this.$t('pageLdap.modal.deleteRoleGroup'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('ldap/deleteRoleGroup', { roleGroups: [row] }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }); + break; + } + }, + initRoleGroupModal(roleGroup) { + this.activeRoleGroup = roleGroup; + this.$bvModal.show('modal-role-group'); + }, + saveRoleGroup({ addNew, groupName, groupPrivilege }) { + this.activeRoleGroup = null; + const data = { groupName, groupPrivilege }; + this.startLoader(); + if (addNew) { + this.$store + .dispatch('ldap/addNewRoleGroup', data) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } else { + this.$store + .dispatch('ldap/saveRoleGroup', data) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/Ldap/index.js b/src/views/_sila/SecurityAndAccess/Ldap/index.js new file mode 100644 index 00000000..6ae3abfc --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Ldap/index.js @@ -0,0 +1,2 @@ +import Ldap from './Ldap.vue'; +export default Ldap; diff --git a/src/views/_sila/SecurityAndAccess/Policies/Policies.vue b/src/views/_sila/SecurityAndAccess/Policies/Policies.vue new file mode 100644 index 00000000..1dc197c7 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Policies/Policies.vue @@ -0,0 +1,213 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col md="8"> + <b-row v-if="!modifySSHPolicyDisabled" class="setting-section"> + <b-col class="d-flex align-items-center justify-content-between"> + <dl class="mr-3 w-75"> + <dt>{{ $t('pagePolicies.ssh') }}</dt> + <dd> + {{ $t('pagePolicies.sshDescription') }} + </dd> + </dl> + <b-form-checkbox + id="sshSwitch" + v-model="sshProtocolState" + data-test-id="policies-toggle-bmcShell" + switch + @change="changeSshProtocolState" + > + <span class="sr-only"> + {{ $t('pagePolicies.ssh') }} + </span> + <span v-if="sshProtocolState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </b-col> + </b-row> + <b-row class="setting-section"> + <b-col class="d-flex align-items-center justify-content-between"> + <dl class="mt-3 mr-3 w-75"> + <dt>{{ $t('pagePolicies.ipmi') }}</dt> + <dd> + {{ $t('pagePolicies.ipmiDescription') }} + </dd> + </dl> + <b-form-checkbox + id="ipmiSwitch" + v-model="ipmiProtocolState" + data-test-id="polices-toggle-networkIpmi" + switch + @change="changeIpmiProtocolState" + > + <span class="sr-only"> + {{ $t('pagePolicies.ipmi') }} + </span> + <span v-if="ipmiProtocolState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </b-col> + </b-row> + <b-row class="setting-section"> + <b-col class="d-flex align-items-center justify-content-between"> + <dl class="mt-3 mr-3 w-75"> + <dt>{{ $t('pagePolicies.vtpm') }}</dt> + <dd> + {{ $t('pagePolicies.vtpmDescription') }} + </dd> + </dl> + <b-form-checkbox + id="vtpmSwitch" + v-model="vtpmState" + data-test-id="policies-toggle-vtpm" + switch + @change="changeVtpmState" + > + <span class="sr-only"> + {{ $t('pagePolicies.vtpm') }} + </span> + <span v-if="vtpmState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </b-col> + </b-row> + <b-row class="setting-section"> + <b-col class="d-flex align-items-center justify-content-between"> + <dl class="mt-3 mr-3 w-75"> + <dt>{{ $t('pagePolicies.rtad') }}</dt> + <dd> + {{ $t('pagePolicies.rtadDescription') }} + </dd> + </dl> + <b-form-checkbox + id="rtadSwitch" + v-model="rtadState" + data-test-id="policies-toggle-rtad" + switch + @change="changeRtadState" + > + <span class="sr-only"> + {{ $t('pagePolicies.rtad') }} + </span> + <span v-if="rtadState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </b-col> + </b-row> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'Policies', + components: { PageTitle }, + mixins: [LoadingBarMixin, BVToastMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + modifySSHPolicyDisabled: + process.env.VUE_APP_MODIFY_SSH_POLICY_DISABLED === 'true', + }; + }, + computed: { + sshProtocolState: { + get() { + return this.$store.getters['policies/sshProtocolEnabled']; + }, + set(newValue) { + return newValue; + }, + }, + ipmiProtocolState: { + get() { + return this.$store.getters['policies/ipmiProtocolEnabled']; + }, + set(newValue) { + return newValue; + }, + }, + rtadState: { + get() { + if (this.$store.getters['policies/rtadEnabled'] === 'Enabled') { + return true; + } else { + return false; + } + }, + set(newValue) { + return newValue; + }, + }, + vtpmState: { + get() { + if (this.$store.getters['policies/vtpmEnabled'] === 'Enabled') { + return true; + } else { + return false; + } + }, + set(newValue) { + return newValue; + }, + }, + }, + created() { + this.startLoader(); + Promise.all([ + this.$store.dispatch('policies/getBiosStatus'), + this.$store.dispatch('policies/getNetworkProtocolStatus'), + ]).finally(() => this.endLoader()); + }, + methods: { + changeIpmiProtocolState(state) { + this.$store + .dispatch('policies/saveIpmiProtocolState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeSshProtocolState(state) { + this.$store + .dispatch('policies/saveSshProtocolState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeRtadState(state) { + this.$store + .dispatch('policies/saveRtadState', state ? 'Enabled' : 'Disabled') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeVtpmState(state) { + this.$store + .dispatch('policies/saveVtpmState', state ? 'Enabled' : 'Disabled') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.setting-section { + border-bottom: 1px solid gray('300'); +} +</style> diff --git a/src/views/_sila/SecurityAndAccess/Policies/index.js b/src/views/_sila/SecurityAndAccess/Policies/index.js new file mode 100644 index 00000000..77023908 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Policies/index.js @@ -0,0 +1,2 @@ +import Policies from './Policies.vue'; +export default Policies; diff --git a/src/views/_sila/SecurityAndAccess/Sessions/Sessions.vue b/src/views/_sila/SecurityAndAccess/Sessions/Sessions.vue new file mode 100644 index 00000000..07ee725d --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Sessions/Sessions.vue @@ -0,0 +1,294 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-end"> + <b-col sm="6" md="5" xl="4"> + <search + :placeholder="$t('pageSessions.table.searchSessions')" + data-test-id="sessions-input-searchSessions" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + </b-col> + <b-col sm="3" md="3" xl="2"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allConnections.length" + ></table-cell-count> + </b-col> + </b-row> + <b-row> + <b-col> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + :actions="batchActions" + @clear-selected="clearSelectedRows($refs.table)" + @batch-action="onBatchAction" + > + </table-toolbar> + <b-table + id="table-session-logs" + ref="table" + responsive="md" + selectable + no-select-on-click + hover + show-empty + sort-by="clientID" + :busy="isBusy" + :fields="fields" + :items="allConnections" + :filter="searchFilter" + :empty-text="$t('global.table.emptyMessage')" + :per-page="perPage" + :current-page="currentPage" + @filtered="onFiltered" + @row-selected="onRowSelected($event, allConnections.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + data-test-id="sessions-checkbox-selectAll" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + :data-test-id="`sessions-checkbox-selectRow-${row.index}`" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <!-- Actions column --> + <template #cell(actions)="row" class="ml-3"> + <table-row-action + v-for="(action, index) in row.item.actions" + :key="index" + :value="action.value" + :title="action.title" + :row-data="row.item" + :btn-icon-only="false" + :data-test-id="`sessions-button-disconnect-${row.index}`" + @click-table-action="onTableRowAction($event, row.item)" + ></table-row-action> + </template> + </b-table> + </b-col> + </b-row> + + <!-- Table pagination --> + <b-row> + <b-col sm="6"> + <b-form-group + class="table-pagination-select" + :label="$t('global.table.itemsPerPage')" + label-for="pagination-items-per-page" + > + <b-form-select + id="pagination-items-per-page" + v-model="perPage" + :options="itemsPerPageOptions" + /> + </b-form-group> + </b-col> + <b-col sm="6"> + <b-pagination + v-model="currentPage" + first-number + last-number + :per-page="perPage" + :total-rows="getTotalRowCount(filteredRows)" + aria-controls="table-session-logs" + /> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + PageTitle, + Search, + TableCellCount, + TableRowAction, + TableToolbar, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + }, + { + key: 'clientID', + label: this.$t('pageSessions.table.clientID'), + }, + { + key: 'username', + label: this.$t('pageSessions.table.username'), + }, + { + key: 'ipAddress', + label: this.$t('pageSessions.table.ipAddress'), + }, + { + key: 'actions', + label: '', + }, + ], + batchActions: [ + { + value: 'disconnect', + label: this.$t('pageSessions.action.disconnect'), + }, + ], + currentPage: currentPage, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + selectedRows: selectedRows, + searchTotalFilteredRows: 0, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + searchFilter: searchFilter, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.allConnections.length; + }, + allConnections() { + return this.$store.getters['sessions/allConnections'].map((session) => { + return { + ...session, + actions: [ + { + value: 'disconnect', + title: this.$t('pageSessions.action.disconnect'), + }, + ], + }; + }); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('sessions/getSessionsData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeSearchInput(event) { + this.searchFilter = event; + }, + disconnectSessions(uris) { + this.$store + .dispatch('sessions/disconnectSessions', uris) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + onTableRowAction(action, { uri }) { + if (action === 'disconnect') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageSessions.modal.disconnectMessage'), { + title: this.$tc('pageSessions.modal.disconnectTitle'), + okTitle: this.$t('pageSessions.action.disconnect'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.disconnectSessions([uri]); + }); + } + }, + onBatchAction(action) { + if (action === 'disconnect') { + const uris = this.selectedRows.map((row) => row.uri); + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageSessions.modal.disconnectMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageSessions.modal.disconnectTitle', + this.selectedRows.length + ), + okTitle: this.$t('pageSessions.action.disconnect'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.disconnectSessions(uris); + } + }); + } + }, + }, +}; +</script> +<style lang="scss"> +#table-session-logs { + td .btn-link { + width: auto !important; + } +} +</style> diff --git a/src/views/_sila/SecurityAndAccess/Sessions/index.js b/src/views/_sila/SecurityAndAccess/Sessions/index.js new file mode 100644 index 00000000..aa113aff --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Sessions/index.js @@ -0,0 +1,2 @@ +import Sessions from './Sessions.vue'; +export default Sessions; diff --git a/src/views/_sila/SecurityAndAccess/UserManagement/ModalSettings.vue b/src/views/_sila/SecurityAndAccess/UserManagement/ModalSettings.vue new file mode 100644 index 00000000..0f05123c --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/ModalSettings.vue @@ -0,0 +1,215 @@ +<template> + <b-modal + id="modal-settings" + ref="modal" + :title="$t('pageUserManagement.accountPolicySettings')" + @hidden="resetForm" + > + <b-form id="form-settings" novalidate @submit.prevent="handleSubmit"> + <b-container> + <b-row> + <b-col> + <b-form-group + :label="$t('pageUserManagement.modal.maxFailedLoginAttempts')" + label-for="lockout-threshold" + > + <b-form-text id="lockout-threshold-help-block"> + {{ + $t('global.form.valueMustBeBetween', { + min: 0, + max: 65535, + }) + }} + </b-form-text> + <b-form-input + id="lockout-threshold" + v-model.number="form.lockoutThreshold" + type="number" + aria-describedby="lockout-threshold-help-block" + data-test-id="userManagement-input-lockoutThreshold" + :state="getValidationState($v.form.lockoutThreshold)" + @input="$v.form.lockoutThreshold.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.lockoutThreshold.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template + v-if=" + !$v.form.lockoutThreshold.minLength || + !$v.form.lockoutThreshold.maxLength + " + > + {{ + $t('global.form.valueMustBeBetween', { + min: 0, + max: 65535, + }) + }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col> + <b-form-group + :label="$t('pageUserManagement.modal.userUnlockMethod')" + > + <b-form-radio + v-model="form.unlockMethod" + name="unlock-method" + class="mb-2" + :value="0" + data-test-id="userManagement-radio-manualUnlock" + @input="$v.form.unlockMethod.$touch()" + > + {{ $t('pageUserManagement.modal.manual') }} + </b-form-radio> + <b-form-radio + v-model="form.unlockMethod" + name="unlock-method" + :value="1" + data-test-id="userManagement-radio-automaticUnlock" + @input="$v.form.unlockMethod.$touch()" + > + {{ $t('pageUserManagement.modal.automaticAfterTimeout') }} + </b-form-radio> + <div class="mt-3 ml-4"> + <b-form-text id="lockout-duration-help-block"> + {{ $t('pageUserManagement.modal.timeoutDurationSeconds') }} + </b-form-text> + <b-form-input + v-model.number="form.lockoutDuration" + aria-describedby="lockout-duration-help-block" + type="number" + data-test-id="userManagement-input-lockoutDuration" + :state="getValidationState($v.form.lockoutDuration)" + :readonly="$v.form.unlockMethod.$model === 0" + @input="$v.form.lockoutDuration.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.lockoutDuration.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-else-if="!$v.form.lockoutDuration.minvalue"> + {{ $t('global.form.mustBeAtLeast', { value: 1 }) }} + </template> + </b-form-invalid-feedback> + </div> + </b-form-group> + </b-col> + </b-row> + </b-container> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button + variant="secondary" + data-test-id="userManagement-button-cancel" + @click="cancel()" + > + {{ $t('global.action.cancel') }} + </b-button> + <b-button + form="form-settings" + type="submit" + variant="primary" + data-test-id="userManagement-button-submit" + @click="onOk" + > + {{ $t('global.action.save') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import { + required, + requiredIf, + minValue, + maxValue, +} from 'vuelidate/lib/validators'; + +export default { + mixins: [VuelidateMixin], + props: { + settings: { + type: Object, + required: true, + }, + }, + data() { + return { + form: { + lockoutThreshold: 0, + unlockMethod: 0, + lockoutDuration: null, + }, + }; + }, + watch: { + settings: function ({ lockoutThreshold, lockoutDuration }) { + this.form.lockoutThreshold = lockoutThreshold; + this.form.unlockMethod = lockoutDuration ? 1 : 0; + this.form.lockoutDuration = lockoutDuration ? lockoutDuration : null; + }, + }, + validations: { + form: { + lockoutThreshold: { + minValue: minValue(0), + maxValue: maxValue(65535), + required, + }, + unlockMethod: { required }, + lockoutDuration: { + minValue: function (value) { + return this.form.unlockMethod === 0 || value > 0; + }, + required: requiredIf(function () { + return this.form.unlockMethod === 1; + }), + }, + }, + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + + let lockoutThreshold; + let lockoutDuration; + if (this.$v.form.lockoutThreshold.$dirty) { + lockoutThreshold = this.form.lockoutThreshold; + } + if (this.$v.form.unlockMethod.$dirty) { + lockoutDuration = this.form.unlockMethod + ? this.form.lockoutDuration + : 0; + } + + this.$emit('ok', { lockoutThreshold, lockoutDuration }); + this.closeModal(); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + // Reset form models + this.form.lockoutThreshold = this.settings.lockoutThreshold; + this.form.unlockMethod = this.settings.lockoutDuration ? 1 : 0; + this.form.lockoutDuration = this.settings.lockoutDuration + ? this.settings.lockoutDuration + : null; + this.$v.$reset(); // clear validations + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/UserManagement/ModalUser.vue b/src/views/_sila/SecurityAndAccess/UserManagement/ModalUser.vue new file mode 100644 index 00000000..0f8757ce --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/ModalUser.vue @@ -0,0 +1,386 @@ +<template> + <b-modal id="modal-user" ref="modal" @hidden="resetForm"> + <template #modal-title> + <template v-if="newUser"> + {{ $t('pageUserManagement.addUser') }} + </template> + <template v-else> + {{ $t('pageUserManagement.editUser') }} + </template> + </template> + <b-form id="form-user" novalidate @submit.prevent="handleSubmit"> + <b-container> + <!-- Manual unlock form control --> + <b-row v-if="!newUser && manualUnlockPolicy && user.Locked"> + <b-col sm="9"> + <alert :show="true" variant="warning" small> + <template v-if="!$v.form.manualUnlock.$dirty"> + {{ $t('pageUserManagement.modal.accountLocked') }} + </template> + <template v-else> + {{ $t('pageUserManagement.modal.clickSaveToUnlockAccount') }} + </template> + </alert> + </b-col> + <b-col sm="3"> + <input + v-model="form.manualUnlock" + data-test-id="userManagement-input-manualUnlock" + type="hidden" + value="false" + /> + <b-button + variant="primary" + :disabled="$v.form.manualUnlock.$dirty" + data-test-id="userManagement-button-manualUnlock" + @click="$v.form.manualUnlock.$touch()" + > + {{ $t('pageUserManagement.modal.unlock') }} + </b-button> + </b-col> + </b-row> + <b-row> + <b-col> + <b-form-group :label="$t('pageUserManagement.modal.accountStatus')"> + <b-form-radio + v-model="form.status" + name="user-status" + :value="true" + data-test-id="userManagement-radioButton-statusEnabled" + @input="$v.form.status.$touch()" + > + {{ $t('global.status.enabled') }} + </b-form-radio> + <b-form-radio + v-model="form.status" + name="user-status" + data-test-id="userManagement-radioButton-statusDisabled" + :value="false" + @input="$v.form.status.$touch()" + > + {{ $t('global.status.disabled') }} + </b-form-radio> + </b-form-group> + <b-form-group + :label="$t('pageUserManagement.modal.username')" + label-for="username" + > + <b-form-text id="username-help-block"> + {{ $t('pageUserManagement.modal.cannotStartWithANumber') }} + <br /> + {{ + $t( + 'pageUserManagement.modal.noSpecialCharactersExceptUnderscore' + ) + }} + </b-form-text> + <b-form-input + id="username" + v-model="form.username" + type="text" + aria-describedby="username-help-block" + data-test-id="userManagement-input-username" + :state="getValidationState($v.form.username)" + :disabled="!newUser && originalUsername === 'root'" + @input="$v.form.username.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.username.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-else-if="!$v.form.username.maxLength"> + {{ + $t('global.form.lengthMustBeBetween', { min: 1, max: 16 }) + }} + </template> + <template v-else-if="!$v.form.username.pattern"> + {{ $t('global.form.invalidFormat') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + <b-form-group + :label="$t('pageUserManagement.modal.privilege')" + label-for="privilege" + > + <b-form-select + id="privilege" + v-model="form.privilege" + :options="privilegeTypes" + data-test-id="userManagement-select-privilege" + :state="getValidationState($v.form.privilege)" + @input="$v.form.privilege.$touch()" + > + <template #first> + <b-form-select-option :value="null" disabled> + {{ $t('global.form.selectAnOption') }} + </b-form-select-option> + </template> + </b-form-select> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.privilege.required"> + {{ $t('global.form.fieldRequired') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col> + <b-form-group + :label="$t('pageUserManagement.modal.userPassword')" + label-for="password" + > + <b-form-text id="password-help-block"> + {{ + $t('pageUserManagement.modal.passwordMustBeBetween', { + min: passwordRequirements.minLength, + max: passwordRequirements.maxLength, + }) + }} + </b-form-text> + <input-password-toggle> + <b-form-input + id="password" + v-model="form.password" + type="password" + data-test-id="userManagement-input-password" + aria-describedby="password-help-block" + :state="getValidationState($v.form.password)" + class="form-control-with-button" + @input="$v.form.password.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.password.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template + v-if=" + !$v.form.password.minLength || !$v.form.password.maxLength + " + > + {{ + $t('pageUserManagement.modal.passwordMustBeBetween', { + min: passwordRequirements.minLength, + max: passwordRequirements.maxLength, + }) + }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + <b-form-group + :label="$t('pageUserManagement.modal.confirmUserPassword')" + label-for="password-confirmation" + > + <input-password-toggle> + <b-form-input + id="password-confirmation" + v-model="form.passwordConfirmation" + data-test-id="userManagement-input-passwordConfirmation" + type="password" + :state="getValidationState($v.form.passwordConfirmation)" + class="form-control-with-button" + @input="$v.form.passwordConfirmation.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.passwordConfirmation.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template + v-else-if="!$v.form.passwordConfirmation.sameAsPassword" + > + {{ $t('pageUserManagement.modal.passwordsDoNotMatch') }} + </template> + </b-form-invalid-feedback> + </input-password-toggle> + </b-form-group> + </b-col> + </b-row> + </b-container> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button + variant="secondary" + data-test-id="userManagement-button-cancel" + @click="cancel()" + > + {{ $t('global.action.cancel') }} + </b-button> + <b-button + form="form-user" + data-test-id="userManagement-button-submit" + type="submit" + variant="primary" + @click="onOk" + > + <template v-if="newUser"> + {{ $t('pageUserManagement.addUser') }} + </template> + <template v-else> + {{ $t('global.action.save') }} + </template> + </b-button> + </template> + </b-modal> +</template> + +<script> +import { + required, + maxLength, + minLength, + sameAs, + helpers, + requiredIf, +} from 'vuelidate/lib/validators'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import Alert from '@/components/Global/Alert'; + +export default { + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin], + props: { + user: { + type: Object, + default: null, + }, + passwordRequirements: { + type: Object, + required: true, + }, + }, + data() { + return { + originalUsername: '', + form: { + status: true, + username: '', + privilege: null, + password: '', + passwordConfirmation: '', + manualUnlock: false, + }, + }; + }, + computed: { + newUser() { + return this.user ? false : true; + }, + accountSettings() { + return this.$store.getters['userManagement/accountSettings']; + }, + manualUnlockPolicy() { + return !this.accountSettings.accountLockoutDuration; + }, + privilegeTypes() { + return this.$store.getters['userManagement/accountRoles']; + }, + }, + watch: { + user: function (value) { + if (value === null) return; + this.originalUsername = value.username; + this.form.username = value.username; + this.form.status = value.Enabled; + this.form.privilege = value.privilege; + }, + }, + validations() { + return { + form: { + status: { + required, + }, + username: { + required, + maxLength: maxLength(16), + pattern: helpers.regex('pattern', /^([a-zA-Z_][a-zA-Z0-9_]*)/), + }, + privilege: { + required, + }, + password: { + required: requiredIf(function () { + return this.requirePassword(); + }), + minLength: minLength(this.passwordRequirements.minLength), + maxLength: maxLength(this.passwordRequirements.maxLength), + }, + passwordConfirmation: { + required: requiredIf(function () { + return this.requirePassword(); + }), + sameAsPassword: sameAs('password'), + }, + manualUnlock: {}, + }, + }; + }, + methods: { + handleSubmit() { + let userData = {}; + + if (this.newUser) { + this.$v.$touch(); + if (this.$v.$invalid) return; + userData.username = this.form.username; + userData.status = this.form.status; + userData.privilege = this.form.privilege; + userData.password = this.form.password; + } else { + if (this.$v.$invalid) return; + userData.originalUsername = this.originalUsername; + if (this.$v.form.status.$dirty) { + userData.status = this.form.status; + } + if (this.$v.form.username.$dirty) { + userData.username = this.form.username; + } + if (this.$v.form.privilege.$dirty) { + userData.privilege = this.form.privilege; + } + if (this.$v.form.password.$dirty) { + userData.password = this.form.password; + } + if (this.$v.form.manualUnlock.$dirty) { + // If form manualUnlock control $dirty then + // set user Locked property to false + userData.locked = false; + } + if (Object.entries(userData).length === 1) { + this.closeModal(); + return; + } + } + + this.$emit('ok', { isNewUser: this.newUser, userData }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.originalUsername = ''; + this.form.status = true; + this.form.username = ''; + this.form.privilege = null; + this.form.password = ''; + this.form.passwordConfirmation = ''; + this.$v.$reset(); + this.$emit('hidden'); + }, + requirePassword() { + if (this.newUser) return true; + if (this.$v.form.password.$dirty) return true; + if (this.$v.form.passwordConfirmation.$dirty) return true; + return false; + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/UserManagement/TableRoles.vue b/src/views/_sila/SecurityAndAccess/UserManagement/TableRoles.vue new file mode 100644 index 00000000..61ef1ee8 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/TableRoles.vue @@ -0,0 +1,92 @@ +<template> + <b-table stacked="sm" hover small :items="items" :fields="fields"> + <template #cell(administrator)="data"> + <template v-if="data.value"> + <checkmark20 /> + </template> + </template> + <template #cell(operator)="data"> + <template v-if="data.value"> + <checkmark20 /> + </template> + </template> + <template #cell(readonly)="data"> + <template v-if="data.value"> + <checkmark20 /> + </template> + </template> + <template #cell(noaccess)="data"> + <template v-if="data.value"> + <checkmark20 /> + </template> + </template> + </b-table> +</template> + +<script> +import Checkmark20 from '@carbon/icons-vue/es/checkmark/20'; + +export default { + components: { + Checkmark20, + }, + data() { + return { + items: [ + { + description: this.$t( + 'pageUserManagement.tableRoles.configureComponentsManagedByThisService' + ), + administrator: true, + operator: true, + readonly: false, + noaccess: false, + }, + { + description: this.$t( + 'pageUserManagement.tableRoles.configureManagerResources' + ), + administrator: true, + operator: false, + readonly: false, + noaccess: false, + }, + { + description: this.$t( + 'pageUserManagement.tableRoles.updatePasswordForCurrentUserAccount' + ), + administrator: true, + operator: true, + readonly: true, + noaccess: false, + }, + { + description: this.$t( + 'pageUserManagement.tableRoles.configureUsersAndTheirAccounts' + ), + administrator: true, + operator: false, + readonly: false, + noaccess: false, + }, + { + description: this.$t( + 'pageUserManagement.tableRoles.logInToTheServiceAndReadResources' + ), + administrator: true, + operator: true, + readonly: true, + noaccess: false, + }, + ], + fields: [ + { key: 'description', label: 'Privilege' }, + { key: 'administrator', label: 'Administrator', class: 'text-center' }, + { key: 'operator', label: 'Operator', class: 'text-center' }, + { key: 'readonly', label: 'ReadOnly', class: 'text-center' }, + { key: 'noaccess', label: 'NoAccess', class: 'text-center' }, + ], + }; + }, +}; +</script> diff --git a/src/views/_sila/SecurityAndAccess/UserManagement/UserManagement.vue b/src/views/_sila/SecurityAndAccess/UserManagement/UserManagement.vue new file mode 100644 index 00000000..c6c556c8 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/UserManagement.vue @@ -0,0 +1,391 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col xl="9" class="text-right"> + <b-button variant="link" @click="initModalSettings"> + <icon-settings /> + {{ $t('pageUserManagement.accountPolicySettings') }} + </b-button> + <b-button + variant="primary" + data-test-id="userManagement-button-addUser" + @click="initModalUser(null)" + > + <icon-add /> + {{ $t('pageUserManagement.addUser') }} + </b-button> + </b-col> + </b-row> + <b-row> + <b-col xl="9"> + <table-toolbar + ref="toolbar" + :selected-items-count="selectedRows.length" + :actions="tableToolbarActions" + @clear-selected="clearSelectedRows($refs.table)" + @batch-action="onBatchAction" + /> + <b-table + ref="table" + responsive="md" + selectable + show-empty + no-select-on-click + hover + :busy="isBusy" + :fields="fields" + :items="tableItems" + :empty-text="$t('global.table.emptyMessage')" + @row-selected="onRowSelected($event, tableItems.length)" + > + <!-- Checkbox column --> + <template #head(checkbox)> + <b-form-checkbox + v-model="tableHeaderCheckboxModel" + data-test-id="userManagement-checkbox-tableHeaderCheckbox" + :indeterminate="tableHeaderCheckboxIndeterminate" + @change="onChangeHeaderCheckbox($refs.table)" + > + <span class="sr-only">{{ $t('global.table.selectAll') }}</span> + </b-form-checkbox> + </template> + <template #cell(checkbox)="row"> + <b-form-checkbox + v-model="row.rowSelected" + data-test-id="userManagement-checkbox-toggleSelectRow" + @change="toggleSelectRow($refs.table, row.index)" + > + <span class="sr-only">{{ $t('global.table.selectItem') }}</span> + </b-form-checkbox> + </template> + + <!-- table actions column --> + <template #cell(actions)="{ item }"> + <table-row-action + v-for="(action, index) in item.actions" + :key="index" + :value="action.value" + :enabled="action.enabled" + :title="action.title" + @click-table-action="onTableRowAction($event, item)" + > + <template #icon> + <icon-edit + v-if="action.value === 'edit'" + :data-test-id="`userManagement-tableRowAction-edit-${index}`" + /> + <icon-trashcan + v-if="action.value === 'delete'" + :data-test-id="`userManagement-tableRowAction-delete-${index}`" + /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + <b-row> + <b-col xl="8"> + <b-button + v-b-toggle.collapse-role-table + data-test-id="userManagement-button-viewPrivilegeRoleDescriptions" + variant="link" + class="mt-3" + > + <icon-chevron /> + {{ $t('pageUserManagement.viewPrivilegeRoleDescriptions') }} + </b-button> + <b-collapse id="collapse-role-table" class="mt-3"> + <table-roles /> + </b-collapse> + </b-col> + </b-row> + <!-- Modals --> + <modal-settings :settings="settings" @ok="saveAccountSettings" /> + <modal-user + :user="activeUser" + :password-requirements="passwordRequirements" + @ok="saveUser" + @hidden="activeUser = null" + /> + </b-container> +</template> + +<script> +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconSettings from '@carbon/icons-vue/es/settings/20'; +import IconChevron from '@carbon/icons-vue/es/chevron--up/20'; + +import ModalUser from './ModalUser'; +import ModalSettings from './ModalSettings'; +import PageTitle from '@/components/Global/PageTitle'; +import TableRoles from './TableRoles'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableRowAction from '@/components/Global/TableRowAction'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'UserManagement', + components: { + IconAdd, + IconChevron, + IconEdit, + IconSettings, + IconTrashcan, + ModalSettings, + ModalUser, + PageTitle, + TableRoles, + TableRowAction, + TableToolbar, + }, + mixins: [BVTableSelectableMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + activeUser: null, + fields: [ + { + key: 'checkbox', + }, + { + key: 'username', + label: this.$t('pageUserManagement.table.username'), + }, + { + key: 'privilege', + label: this.$t('pageUserManagement.table.privilege'), + }, + { + key: 'status', + label: this.$t('pageUserManagement.table.status'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + tableToolbarActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + { + value: 'enable', + label: this.$t('global.action.enable'), + }, + { + value: 'disable', + label: this.$t('global.action.disable'), + }, + ], + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + allUsers() { + return this.$store.getters['userManagement/allUsers']; + }, + tableItems() { + // transform user data to table data + return this.allUsers.map((user) => { + return { + username: user.UserName, + privilege: user.RoleId, + status: user.Locked + ? 'Locked' + : user.Enabled + ? 'Enabled' + : 'Disabled', + actions: [ + { + value: 'edit', + enabled: true, + title: this.$t('pageUserManagement.editUser'), + }, + { + value: 'delete', + enabled: user.UserName === 'root' ? false : true, + title: this.$tc('pageUserManagement.deleteUser'), + }, + ], + ...user, + }; + }); + }, + settings() { + return this.$store.getters['userManagement/accountSettings']; + }, + passwordRequirements() { + return this.$store.getters['userManagement/accountPasswordRequirements']; + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('userManagement/getUsers').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + this.$store.dispatch('userManagement/getAccountSettings'); + this.$store.dispatch('userManagement/getAccountRoles'); + }, + methods: { + initModalUser(user) { + this.activeUser = user; + this.$bvModal.show('modal-user'); + }, + initModalDelete(user) { + this.$bvModal + .msgBoxConfirm( + this.$t('pageUserManagement.modal.deleteConfirmMessage', { + user: user.username, + }), + { + title: this.$tc('pageUserManagement.deleteUser'), + okTitle: this.$tc('pageUserManagement.deleteUser'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.deleteUser(user); + } + }); + }, + initModalSettings() { + this.$bvModal.show('modal-settings'); + }, + saveUser({ isNewUser, userData }) { + this.startLoader(); + if (isNewUser) { + this.$store + .dispatch('userManagement/createUser', userData) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } else { + this.$store + .dispatch('userManagement/updateUser', userData) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }, + deleteUser({ username }) { + this.startLoader(); + this.$store + .dispatch('userManagement/deleteUser', username) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + onBatchAction(action) { + switch (action) { + case 'delete': + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageUserManagement.modal.batchDeleteConfirmMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageUserManagement.deleteUser', + this.selectedRows.length + ), + okTitle: this.$tc( + 'pageUserManagement.deleteUser', + this.selectedRows.length + ), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('userManagement/deleteUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + } + }); + break; + case 'enable': + this.startLoader(); + this.$store + .dispatch('userManagement/enableUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + break; + case 'disable': + this.startLoader(); + this.$store + .dispatch('userManagement/disableUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + break; + } + }, + onTableRowAction(action, row) { + switch (action) { + case 'edit': + this.initModalUser(row); + break; + case 'delete': + this.initModalDelete(row); + break; + default: + break; + } + }, + saveAccountSettings(settings) { + this.startLoader(); + this.$store + .dispatch('userManagement/saveAccountSettings', settings) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn.collapsed { + svg { + transform: rotate(180deg); + } +} +</style> diff --git a/src/views/_sila/SecurityAndAccess/UserManagement/index.js b/src/views/_sila/SecurityAndAccess/UserManagement/index.js new file mode 100644 index 00000000..c3aebec3 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/index.js @@ -0,0 +1,2 @@ +import UserManagement from './UserManagement.vue'; +export default UserManagement; diff --git a/src/views/_sila/Settings/DateTime/DateTime.vue b/src/views/_sila/Settings/DateTime/DateTime.vue new file mode 100644 index 00000000..f5e063d1 --- /dev/null +++ b/src/views/_sila/Settings/DateTime/DateTime.vue @@ -0,0 +1,417 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col md="8" xl="6"> + <alert variant="info" class="mb-4"> + <span> + {{ $t('pageDateTime.alert.message') }} + <b-link to="/profile-settings"> + {{ $t('pageDateTime.alert.link') }}</b-link + > + </span> + </alert> + </b-col> + </b-row> + <page-section> + <b-row> + <b-col lg="3"> + <dl> + <dt>{{ $t('pageDateTime.form.date') }}</dt> + <dd v-if="bmcTime">{{ bmcTime | formatDate }}</dd> + <dd v-else>--</dd> + </dl> + </b-col> + <b-col lg="3"> + <dl> + <dt>{{ $t('pageDateTime.form.time.label') }}</dt> + <dd v-if="bmcTime">{{ bmcTime | formatTime }}</dd> + <dd v-else>--</dd> + </dl> + </b-col> + </b-row> + </page-section> + <page-section :section-title="$t('pageDateTime.configureSettings')"> + <b-form novalidate @submit.prevent="submitForm"> + <b-form-group + label="Configure date and time" + :disabled="loading" + label-sr-only + > + <b-form-radio + v-model="form.configurationSelected" + value="manual" + data-test-id="dateTime-radio-configureManual" + > + {{ $t('pageDateTime.form.manual') }} + </b-form-radio> + <b-row class="mt-3 ml-3"> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.date')" + label-for="input-manual-date" + > + <b-form-text id="date-format-help">YYYY-MM-DD</b-form-text> + <b-input-group> + <b-form-input + id="input-manual-date" + v-model="form.manual.date" + :state="getValidationState($v.form.manual.date)" + :disabled="ntpOptionSelected" + data-test-id="dateTime-input-manualDate" + class="form-control-with-button" + @blur="$v.form.manual.date.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.manual.date.pattern"> + {{ $t('global.form.invalidFormat') }} + </div> + <div v-if="!$v.form.manual.date.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="form.manual.date" + class="btn-datepicker btn-icon-only" + button-only + right + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + :disabled="ntpOptionSelected" + button-variant="link" + aria-controls="input-manual-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.time.timezone', { timezone })" + label-for="input-manual-time" + > + <b-form-text id="time-format-help">HH:MM</b-form-text> + <b-input-group> + <b-form-input + id="input-manual-time" + v-model="form.manual.time" + :state="getValidationState($v.form.manual.time)" + :disabled="ntpOptionSelected" + data-test-id="dateTime-input-manualTime" + @blur="$v.form.manual.time.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.manual.time.pattern"> + {{ $t('global.form.invalidFormat') }} + </div> + <div v-if="!$v.form.manual.time.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + </b-input-group> + </b-form-group> + </b-col> + </b-row> + <b-form-radio + v-model="form.configurationSelected" + value="ntp" + data-test-id="dateTime-radio-configureNTP" + > + NTP + </b-form-radio> + <b-row class="mt-3 ml-3"> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server1')" + label-for="input-ntp-1" + > + <b-input-group> + <b-form-input + id="input-ntp-1" + v-model="form.ntp.firstAddress" + :state="getValidationState($v.form.ntp.firstAddress)" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer1" + @blur="$v.form.ntp.firstAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.ntp.firstAddress.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server2')" + label-for="input-ntp-2" + > + <b-input-group> + <b-form-input + id="input-ntp-2" + v-model="form.ntp.secondAddress" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer2" + /> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server3')" + label-for="input-ntp-3" + > + <b-input-group> + <b-form-input + id="input-ntp-3" + v-model="form.ntp.thirdAddress" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer3" + /> + </b-input-group> + </b-form-group> + </b-col> + </b-row> + <b-button + variant="primary" + type="submit" + data-test-id="dateTime-button-saveSettings" + > + {{ $t('global.action.saveSettings') }} + </b-button> + </b-form-group> + </b-form> + </page-section> + </b-container> +</template> + +<script> +import Alert from '@/components/Global/Alert'; +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/Mixins/LocalTimezoneLabelMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +import { mapState } from 'vuex'; +import { requiredIf, helpers } from 'vuelidate/lib/validators'; + +const isoDateRegex = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/; +const isoTimeRegex = /^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/; + +export default { + name: 'DateTime', + components: { Alert, IconCalendar, PageTitle, PageSection }, + mixins: [ + BVToastMixin, + LoadingBarMixin, + LocalTimezoneLabelMixin, + VuelidateMixin, + ], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + locale: this.$store.getters['global/languagePreference'], + form: { + configurationSelected: 'manual', + manual: { + date: '', + time: '', + }, + ntp: { firstAddress: '', secondAddress: '', thirdAddress: '' }, + }, + loading, + }; + }, + validations() { + return { + form: { + manual: { + date: { + required: requiredIf(function () { + return this.form.configurationSelected === 'manual'; + }), + pattern: helpers.regex('pattern', isoDateRegex), + }, + time: { + required: requiredIf(function () { + return this.form.configurationSelected === 'manual'; + }), + pattern: helpers.regex('pattern', isoTimeRegex), + }, + }, + ntp: { + firstAddress: { + required: requiredIf(function () { + return this.form.configurationSelected === 'ntp'; + }), + }, + }, + }, + }; + }, + computed: { + ...mapState('dateTime', ['ntpServers', 'isNtpProtocolEnabled']), + bmcTime() { + return this.$store.getters['global/bmcTime']; + }, + ntpOptionSelected() { + return this.form.configurationSelected === 'ntp'; + }, + manualOptionSelected() { + return this.form.configurationSelected === 'manual'; + }, + isUtcDisplay() { + return this.$store.getters['global/isUtcDisplay']; + }, + timezone() { + if (this.isUtcDisplay) { + return 'UTC'; + } + return this.localOffset(); + }, + }, + watch: { + ntpServers() { + this.setNtpValues(); + }, + manualDate() { + this.emitChange(); + }, + bmcTime() { + this.form.manual.date = this.$options.filters.formatDate( + this.$store.getters['global/bmcTime'] + ); + this.form.manual.time = this.$options.filters + .formatTime(this.$store.getters['global/bmcTime']) + .slice(0, 5); + }, + }, + created() { + this.startLoader(); + this.setNtpValues(); + Promise.all([ + this.$store.dispatch('global/getBmcTime'), + this.$store.dispatch('dateTime/getNtpData'), + ]).finally(() => this.endLoader()); + }, + methods: { + emitChange() { + if (this.$v.$invalid) return; + this.$v.$reset(); //reset to re-validate on blur + this.$emit('change', { + manualDate: this.manualDate ? new Date(this.manualDate) : null, + }); + }, + setNtpValues() { + this.form.configurationSelected = this.isNtpProtocolEnabled + ? 'ntp' + : 'manual'; + [ + this.form.ntp.firstAddress = '', + this.form.ntp.secondAddress = '', + this.form.ntp.thirdAddress = '', + ] = [this.ntpServers[0], this.ntpServers[1], this.ntpServers[2]]; + }, + submitForm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.startLoader(); + + let dateTimeForm = {}; + let isNTPEnabled = this.form.configurationSelected === 'ntp'; + + if (!isNTPEnabled) { + const isUtcDisplay = this.$store.getters['global/isUtcDisplay']; + let date; + + dateTimeForm.ntpProtocolEnabled = false; + + if (isUtcDisplay) { + // Create UTC Date + date = this.getUtcDate(this.form.manual.date, this.form.manual.time); + } else { + // Create local Date + date = new Date(`${this.form.manual.date} ${this.form.manual.time}`); + } + + dateTimeForm.updatedDateTime = date.toISOString(); + } else { + dateTimeForm.ntpProtocolEnabled = true; + + const ntpArray = [ + this.form.ntp.firstAddress, + this.form.ntp.secondAddress, + this.form.ntp.thirdAddress, + ]; + + // Filter the ntpArray to remove empty strings, + // per Redfish spec there should be no empty strings or null on the ntp array. + const ntpArrayFiltered = ntpArray.filter((x) => x); + + dateTimeForm.ntpServersArray = [...ntpArrayFiltered]; + + [this.ntpServers[0], this.ntpServers[1], this.ntpServers[2]] = [ + ...dateTimeForm.ntpServersArray, + ]; + + this.setNtpValues(); + } + + this.$store + .dispatch('dateTime/updateDateTime', dateTimeForm) + .then((success) => { + this.successToast(success); + if (!isNTPEnabled) return; + // Shift address up if second address is empty + // to avoid refreshing after delay when updating NTP + if (!this.form.ntp.secondAddress && this.form.ntp.thirdAddres) { + this.form.ntp.secondAddress = this.form.ntp.thirdAddres; + this.form.ntp.thirdAddress = ''; + } + }) + .then(() => { + this.$store.dispatch('global/getBmcTime'); + }) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.$v.form.$reset(); + this.endLoader(); + }); + }, + getUtcDate(date, time) { + // Split user input string values to create + // a UTC Date object + const datesArray = date.split('-'); + const timeArray = time.split(':'); + let utcDate = Date.UTC( + datesArray[0], // User input year + //UTC expects zero-index month value 0-11 (January-December) + //for reference https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC#Parameters + parseInt(datesArray[1]) - 1, // User input month + datesArray[2], // User input day + timeArray[0], // User input hour + timeArray[1] // User input minute + ); + return new Date(utcDate); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/DateTime/index.js b/src/views/_sila/Settings/DateTime/index.js new file mode 100644 index 00000000..2df21eae --- /dev/null +++ b/src/views/_sila/Settings/DateTime/index.js @@ -0,0 +1,2 @@ +import DateTime from './DateTime.vue'; +export default DateTime; diff --git a/src/views/_sila/Settings/Network/ModalDns.vue b/src/views/_sila/Settings/Network/ModalDns.vue new file mode 100644 index 00000000..7f127173 --- /dev/null +++ b/src/views/_sila/Settings/Network/ModalDns.vue @@ -0,0 +1,92 @@ +<template> + <b-modal + id="modal-dns" + ref="modal" + :title="$t('pageNetwork.table.addDnsAddress')" + @hidden="resetForm" + > + <b-form id="form-dns" @submit.prevent="handleSubmit"> + <b-row> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.modal.staticDns')" + label-for="staticDns" + > + <b-form-input + id="staticDns" + v-model="form.staticDns" + type="text" + :state="getValidationState($v.form.staticDns)" + @input="$v.form.staticDns.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.staticDns.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-if="!$v.form.staticDns.ipAddress"> + {{ $t('global.form.invalidFormat') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button form="form-dns" type="submit" variant="primary" @click="onOk"> + {{ $t('global.action.add') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import { ipAddress, required } from 'vuelidate/lib/validators'; + +export default { + mixins: [VuelidateMixin], + data() { + return { + form: { + staticDns: null, + }, + }; + }, + validations() { + return { + form: { + staticDns: { + required, + ipAddress, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', [this.form.staticDns]); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.staticDns = null; + this.$v.$reset(); + this.$emit('hidden'); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/ModalHostname.vue b/src/views/_sila/Settings/Network/ModalHostname.vue new file mode 100644 index 00000000..f3221ec7 --- /dev/null +++ b/src/views/_sila/Settings/Network/ModalHostname.vue @@ -0,0 +1,110 @@ +<template> + <b-modal + id="modal-hostname" + ref="modal" + :title="$t('pageNetwork.modal.editHostnameTitle')" + @hidden="resetForm" + > + <b-form id="hostname-settings" @submit.prevent="handleSubmit"> + <b-row> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.hostname')" + label-for="hostname" + > + <b-form-input + id="hostname" + v-model="form.hostname" + type="text" + :state="getValidationState($v.form.hostname)" + @input="$v.form.hostname.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.hostname.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-if="!$v.form.hostname.validateHostname"> + {{ $t('global.form.lengthMustBeBetween', { min: 1, max: 64 }) }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button + form="hostname-settings" + type="submit" + variant="primary" + @click="onOk" + > + {{ $t('global.action.add') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import { required, helpers } from 'vuelidate/lib/validators'; + +const validateHostname = helpers.regex('validateHostname', /^\S{0,64}$/); + +export default { + mixins: [VuelidateMixin], + props: { + hostname: { + type: String, + default: '', + }, + }, + data() { + return { + form: { + hostname: '', + }, + }; + }, + watch: { + hostname() { + this.form.hostname = this.hostname; + }, + }, + validations() { + return { + form: { + hostname: { + required, + validateHostname, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { HostName: this.form.hostname }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.hostname = this.hostname; + this.$v.$reset(); + this.$emit('hidden'); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/ModalIpv4.vue b/src/views/_sila/Settings/Network/ModalIpv4.vue new file mode 100644 index 00000000..dcf4a579 --- /dev/null +++ b/src/views/_sila/Settings/Network/ModalIpv4.vue @@ -0,0 +1,165 @@ +<template> + <b-modal + id="modal-add-ipv4" + ref="modal" + :title="$t('pageNetwork.table.addIpv4Address')" + @hidden="resetForm" + > + <b-form id="form-ipv4" @submit.prevent="handleSubmit"> + <b-row> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.modal.ipAddress')" + label-for="ipAddress" + > + <b-form-input + id="ipAddress" + v-model="form.ipAddress" + type="text" + :state="getValidationState($v.form.ipAddress)" + @input="$v.form.ipAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.ipAddress.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-if="!$v.form.ipAddress.ipAddress"> + {{ $t('global.form.invalidFormat') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.modal.gateway')" + label-for="gateway" + > + <b-form-input + id="gateway" + v-model="form.gateway" + type="text" + :state="getValidationState($v.form.gateway)" + @input="$v.form.gateway.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.gateway.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-if="!$v.form.gateway.ipAddress"> + {{ $t('global.form.invalidFormat') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + <b-row> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.modal.subnetMask')" + label-for="subnetMask" + > + <b-form-input + id="subnetMask" + v-model="form.subnetMask" + type="text" + :state="getValidationState($v.form.subnetMask)" + @input="$v.form.subnetMask.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <template v-if="!$v.form.subnetMask.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-if="!$v.form.subnetMask.ipAddress"> + {{ $t('global.form.invalidFormat') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button form="form-ipv4" type="submit" variant="primary" @click="onOk"> + {{ $t('global.action.add') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import { ipAddress, required } from 'vuelidate/lib/validators'; + +export default { + mixins: [VuelidateMixin], + props: { + defaultGateway: { + type: String, + default: '', + }, + }, + data() { + return { + form: { + ipAddress: '', + gateway: '', + subnetMask: '', + }, + }; + }, + watch: { + defaultGateway() { + this.form.gateway = this.defaultGateway; + }, + }, + validations() { + return { + form: { + ipAddress: { + required, + ipAddress, + }, + gateway: { + required, + ipAddress, + }, + subnetMask: { + required, + ipAddress, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { + Address: this.form.ipAddress, + Gateway: this.form.gateway, + SubnetMask: this.form.subnetMask, + }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.ipAddress = null; + this.form.gateway = this.defaultGateway; + this.form.subnetMask = null; + this.$v.$reset(); + this.$emit('hidden'); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/ModalMacAddress.vue b/src/views/_sila/Settings/Network/ModalMacAddress.vue new file mode 100644 index 00000000..d563f4ce --- /dev/null +++ b/src/views/_sila/Settings/Network/ModalMacAddress.vue @@ -0,0 +1,109 @@ +<template> + <b-modal + id="modal-mac-address" + ref="modal" + :title="$t('pageNetwork.modal.editMacAddressTitle')" + @hidden="resetForm" + > + <b-form id="mac-settings" @submit.prevent="handleSubmit"> + <b-row> + <b-col sm="6"> + <b-form-group + :label="$t('pageNetwork.macAddress')" + label-for="macAddress" + > + <b-form-input + id="mac-address" + v-model.trim="form.macAddress" + data-test-id="network-input-macAddress" + type="text" + :state="getValidationState($v.form.macAddress)" + @change="$v.form.macAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.macAddress.required"> + {{ $t('global.form.fieldRequired') }} + </div> + <div v-if="!$v.form.macAddress.macAddress"> + {{ $t('global.form.invalidFormat') }} + </div> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + </b-form> + <template #modal-footer="{ cancel }"> + <b-button variant="secondary" @click="cancel()"> + {{ $t('global.action.cancel') }} + </b-button> + <b-button + form="mac-settings" + type="submit" + variant="primary" + @click="onOk" + > + {{ $t('global.action.add') }} + </b-button> + </template> + </b-modal> +</template> + +<script> +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import { macAddress, required } from 'vuelidate/lib/validators'; + +export default { + mixins: [VuelidateMixin], + props: { + macAddress: { + type: String, + default: '', + }, + }, + data() { + return { + form: { + macAddress: '', + }, + }; + }, + watch: { + macAddress() { + this.form.macAddress = this.macAddress; + }, + }, + validations() { + return { + form: { + macAddress: { + required, + macAddress: macAddress(), + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { MACAddress: this.form.macAddress }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.macAddress = this.macAddress; + this.$v.$reset(); + this.$emit('hidden'); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/Network.vue b/src/views/_sila/Settings/Network/Network.vue new file mode 100644 index 00000000..2abbcd7a --- /dev/null +++ b/src/views/_sila/Settings/Network/Network.vue @@ -0,0 +1,167 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pageNetwork.pageDescription')" /> + <!-- Global settings for all interfaces --> + <network-global-settings /> + <!-- Interface tabs --> + <page-section v-show="ethernetData"> + <b-row> + <b-col> + <b-card no-body> + <b-tabs + active-nav-item-class="font-weight-bold" + card + content-class="mt-3" + > + <b-tab + v-for="(data, index) in ethernetData" + :key="data.Id" + :title="data.Id" + @click="getTabIndex(index)" + > + <!-- Interface settings --> + <network-interface-settings :tab-index="tabIndex" /> + <!-- IPV4 table --> + <table-ipv-4 :tab-index="tabIndex" /> + <!-- Static DNS table --> + <table-dns :tab-index="tabIndex" /> + </b-tab> + </b-tabs> + </b-card> + </b-col> + </b-row> + </page-section> + <!-- Modals --> + <modal-ipv4 :default-gateway="defaultGateway" @ok="saveIpv4Address" /> + <modal-dns @ok="saveDnsAddress" /> + <modal-hostname :hostname="currentHostname" @ok="saveSettings" /> + <modal-mac-address :mac-address="currentMacAddress" @ok="saveSettings" /> + </b-container> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import ModalMacAddress from './ModalMacAddress.vue'; +import ModalHostname from './ModalHostname.vue'; +import ModalIpv4 from './ModalIpv4.vue'; +import ModalDns from './ModalDns.vue'; +import NetworkGlobalSettings from './NetworkGlobalSettings.vue'; +import NetworkInterfaceSettings from './NetworkInterfaceSettings.vue'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; +import TableIpv4 from './TableIpv4.vue'; +import TableDns from './TableDns.vue'; +import { mapState } from 'vuex'; + +export default { + name: 'Network', + components: { + ModalHostname, + ModalMacAddress, + ModalIpv4, + ModalDns, + NetworkGlobalSettings, + NetworkInterfaceSettings, + PageSection, + PageTitle, + TableDns, + TableIpv4, + }, + mixins: [BVToastMixin, DataFormatterMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + currentHostname: '', + currentMacAddress: '', + defaultGateway: '', + loading, + tabIndex: 0, + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + ethernetData() { + this.getModalInfo(); + }, + }, + created() { + this.startLoader(); + const globalSettings = new Promise((resolve) => { + this.$root.$on('network-global-settings-complete', () => resolve()); + }); + const interfaceSettings = new Promise((resolve) => { + this.$root.$on('network-interface-settings-complete', () => resolve()); + }); + const networkTableDns = new Promise((resolve) => { + this.$root.$on('network-table-dns-complete', () => resolve()); + }); + const networkTableIpv4 = new Promise((resolve) => { + this.$root.$on('network-table-ipv4-complete', () => resolve()); + }); + // Combine all child component Promises to indicate + // when page data load complete + Promise.all([ + this.$store.dispatch('network/getEthernetData'), + globalSettings, + interfaceSettings, + networkTableDns, + networkTableIpv4, + ]).finally(() => this.endLoader()); + }, + methods: { + getModalInfo() { + this.defaultGateway = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].defaultGateway; + + this.currentHostname = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].hostname; + + this.currentMacAddress = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].macAddress; + }, + getTabIndex(selectedIndex) { + this.tabIndex = selectedIndex; + this.$store.dispatch('network/setSelectedTabIndex', this.tabIndex); + this.$store.dispatch( + 'network/setSelectedTabId', + this.ethernetData[selectedIndex].Id + ); + this.getModalInfo(); + }, + saveIpv4Address(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveIpv4Address', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + saveDnsAddress(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveDnsAddress', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + saveSettings(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveSettings', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/NetworkGlobalSettings.vue b/src/views/_sila/Settings/Network/NetworkGlobalSettings.vue new file mode 100644 index 00000000..30287673 --- /dev/null +++ b/src/views/_sila/Settings/Network/NetworkGlobalSettings.vue @@ -0,0 +1,161 @@ +<template> + <page-section + v-if="firstInterface" + :section-title="$t('pageNetwork.networkSettings')" + > + <b-row> + <b-col md="3"> + <dl> + <dt> + {{ $t('pageNetwork.hostname') }} + <b-button variant="link" class="p-1" @click="initSettingsModal()"> + <icon-edit :title="$t('pageNetwork.modal.editHostnameTitle')" /> + </b-button> + </dt> + <dd>{{ dataFormatter(firstInterface.hostname) }}</dd> + </dl> + </b-col> + <b-col md="3"> + <dl> + <dt>{{ $t('pageNetwork.useDomainName') }}</dt> + <dd> + <b-form-checkbox + id="useDomainNameSwitch" + v-model="useDomainNameState" + data-test-id="networkSettings-switch-useDomainName" + switch + @change="changeDomainNameState" + > + <span v-if="useDomainNameState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </dd> + </dl> + </b-col> + <b-col md="3"> + <dl> + <dt>{{ $t('pageNetwork.useDns') }}</dt> + <dd> + <b-form-checkbox + id="useDnsSwitch" + v-model="useDnsState" + data-test-id="networkSettings-switch-useDns" + switch + @change="changeDnsState" + > + <span v-if="useDnsState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </dd> + </dl> + </b-col> + <b-col md="3"> + <dl> + <dt>{{ $t('pageNetwork.useNtp') }}</dt> + <dd> + <b-form-checkbox + id="useNtpSwitch" + v-model="useNtpState" + data-test-id="networkSettings-switch-useNtp" + switch + @change="changeNtpState" + > + <span v-if="useNtpState"> + {{ $t('global.status.enabled') }} + </span> + <span v-else>{{ $t('global.status.disabled') }}</span> + </b-form-checkbox> + </dd> + </dl> + </b-col> + </b-row> + </page-section> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import PageSection from '@/components/Global/PageSection'; +import { mapState } from 'vuex'; + +export default { + name: 'GlobalNetworkSettings', + components: { IconEdit, PageSection }, + mixins: [BVToastMixin, DataFormatterMixin], + + data() { + return { + hostname: '', + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + firstInterface() { + return this.$store.getters['network/globalNetworkSettings'][0]; + }, + useDomainNameState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useDomainNameEnabled; + }, + set(newValue) { + return newValue; + }, + }, + useDnsState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useDnsEnabled; + }, + set(newValue) { + return newValue; + }, + }, + useNtpState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useNtpEnabled; + }, + set(newValue) { + return newValue; + }, + }, + }, + created() { + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-global-settings-complete'); + }); + }, + methods: { + changeDomainNameState(state) { + this.$store + .dispatch('network/saveDomainNameState', state) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => this.errorToast(message)); + }, + changeDnsState(state) { + this.$store + .dispatch('network/saveDnsState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeNtpState(state) { + this.$store + .dispatch('network/saveNtpState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initSettingsModal() { + this.$bvModal.show('modal-hostname'); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/NetworkInterfaceSettings.vue b/src/views/_sila/Settings/Network/NetworkInterfaceSettings.vue new file mode 100644 index 00000000..023d29bc --- /dev/null +++ b/src/views/_sila/Settings/Network/NetworkInterfaceSettings.vue @@ -0,0 +1,117 @@ +<template> + <div> + <page-section> + <b-row> + <b-col md="3"> + <dl> + <dt>{{ $t('pageNetwork.linkStatus') }}</dt> + <dd> + {{ dataFormatter(linkStatus) }} + </dd> + </dl> + </b-col> + <b-col md="3"> + <dl> + <dt>{{ $t('pageNetwork.speed') }}</dt> + <dd> + {{ dataFormatter(linkSpeed) }} + </dd> + </dl> + </b-col> + </b-row> + </page-section> + <page-section :section-title="$t('pageNetwork.interfaceSection')"> + <b-row> + <b-col md="3"> + <dl> + <dt> + {{ $t('pageNetwork.fqdn') }} + </dt> + <dd> + {{ dataFormatter(fqdn) }} + </dd> + </dl> + </b-col> + <b-col md="3"> + <dl class="text-nowrap"> + <dt> + {{ $t('pageNetwork.macAddress') }} + <b-button + variant="link" + class="p-1" + @click="initMacAddressModal()" + > + <icon-edit + :title="$t('pageNetwork.modal.editMacAddressTitle')" + /> + </b-button> + </dt> + <dd> + {{ dataFormatter(macAddress) }} + </dd> + </dl> + </b-col> + </b-row> + </page-section> + </div> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import PageSection from '@/components/Global/PageSection'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import { mapState } from 'vuex'; + +export default { + name: 'Ipv4Table', + components: { + IconEdit, + PageSection, + }, + mixins: [BVToastMixin, DataFormatterMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + selectedInterface: '', + linkStatus: '', + linkSpeed: '', + fqdn: '', + macAddress: '', + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getSettings(); + }, + }, + created() { + this.getSettings(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-interface-settings-complete'); + }); + }, + methods: { + getSettings() { + this.selectedInterface = this.tabIndex; + this.linkStatus = this.ethernetData[this.selectedInterface].LinkStatus; + this.linkSpeed = this.ethernetData[this.selectedInterface].SpeedMbps; + this.fqdn = this.ethernetData[this.selectedInterface].FQDN; + this.macAddress = this.ethernetData[this.selectedInterface].MACAddress; + }, + initMacAddressModal() { + this.$bvModal.show('modal-mac-address'); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/TableDns.vue b/src/views/_sila/Settings/Network/TableDns.vue new file mode 100644 index 00000000..569109f1 --- /dev/null +++ b/src/views/_sila/Settings/Network/TableDns.vue @@ -0,0 +1,145 @@ +<template> + <page-section :section-title="$t('pageNetwork.staticDns')"> + <b-row> + <b-col lg="6"> + <div class="text-right"> + <b-button variant="primary" @click="initDnsModal()"> + <icon-add /> + {{ $t('pageNetwork.table.addDnsAddress') }} + </b-button> + </div> + <b-table + responsive="md" + hover + :fields="dnsTableFields" + :items="form.dnsStaticTableItems" + :empty-text="$t('global.table.emptyMessage')" + class="mb-0" + show-empty + > + <template #cell(actions)="{ item, index }"> + <table-row-action + v-for="(action, actionIndex) in item.actions" + :key="actionIndex" + :value="action.value" + :title="action.title" + :enabled="action.enabled" + @click-table-action="onDnsTableAction(action, $event, index)" + > + <template #icon> + <icon-edit v-if="action.value === 'edit'" /> + <icon-trashcan v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </b-col> + </b-row> + </page-section> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import PageSection from '@/components/Global/PageSection'; +import TableRowAction from '@/components/Global/TableRowAction'; +import { mapState } from 'vuex'; + +export default { + name: 'DNSTable', + components: { + IconAdd, + IconEdit, + IconTrashcan, + PageSection, + TableRowAction, + }, + mixins: [BVToastMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + form: { + dnsStaticTableItems: [], + }, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + dnsTableFields: [ + { + key: 'address', + label: this.$t('pageNetwork.table.ipAddress'), + }, + { key: 'actions', label: '', tdClass: 'text-right' }, + ], + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getStaticDnsItems(); + }, + ethernetData() { + this.getStaticDnsItems(); + }, + }, + created() { + this.getStaticDnsItems(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-table-dns-complete'); + }); + }, + methods: { + getStaticDnsItems() { + const index = this.tabIndex; + const dns = this.ethernetData[index].StaticNameServers || []; + this.form.dnsStaticTableItems = dns.map((server) => { + return { + address: server, + actions: [ + { + value: 'delete', + title: this.$t('pageNetwork.table.deleteDns'), + }, + ], + }; + }); + }, + onDnsTableAction(action, $event, index) { + if ($event === 'delete') { + this.deleteDnsTableRow(index); + } + }, + deleteDnsTableRow(index) { + this.form.dnsStaticTableItems.splice(index, 1); + const newDnsArray = this.form.dnsStaticTableItems.map((dns) => { + return dns.address; + }); + this.$store + .dispatch('network/editDnsAddress', newDnsArray) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initDnsModal() { + this.$bvModal.show('modal-dns'); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/TableIpv4.vue b/src/views/_sila/Settings/Network/TableIpv4.vue new file mode 100644 index 00000000..75870031 --- /dev/null +++ b/src/views/_sila/Settings/Network/TableIpv4.vue @@ -0,0 +1,169 @@ +<template> + <page-section :section-title="$t('pageNetwork.ipv4')"> + <b-row> + <b-col> + <h3 class="h5"> + {{ $t('pageNetwork.ipv4Addresses') }} + </h3> + </b-col> + <b-col class="text-right"> + <b-button variant="primary" @click="initAddIpv4Address()"> + <icon-add /> + {{ $t('pageNetwork.table.addIpv4Address') }} + </b-button> + </b-col> + </b-row> + <b-table + responsive="md" + hover + :fields="ipv4TableFields" + :items="form.ipv4TableItems" + :empty-text="$t('global.table.emptyMessage')" + class="mb-0" + show-empty + > + <template #cell(actions)="{ item, index }"> + <table-row-action + v-for="(action, actionIndex) in item.actions" + :key="actionIndex" + :value="action.value" + :title="action.title" + :enabled="action.enabled" + @click-table-action="onIpv4TableAction(action, $event, index)" + > + <template #icon> + <icon-edit v-if="action.value === 'edit'" /> + <icon-trashcan v-if="action.value === 'delete'" /> + </template> + </table-row-action> + </template> + </b-table> + </page-section> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import PageSection from '@/components/Global/PageSection'; +import TableRowAction from '@/components/Global/TableRowAction'; +import { mapState } from 'vuex'; + +export default { + name: 'Ipv4Table', + components: { + IconAdd, + IconEdit, + IconTrashcan, + PageSection, + TableRowAction, + }, + mixins: [BVToastMixin, LoadingBarMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + form: { + ipv4TableItems: [], + }, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + ipv4TableFields: [ + { + key: 'Address', + label: this.$t('pageNetwork.table.ipAddress'), + }, + { + key: 'Gateway', + label: this.$t('pageNetwork.table.gateway'), + }, + { + key: 'SubnetMask', + label: this.$t('pageNetwork.table.subnet'), + }, + { + key: 'AddressOrigin', + label: this.$t('pageNetwork.table.addressOrigin'), + }, + { key: 'actions', label: '', tdClass: 'text-right' }, + ], + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getIpv4TableItems(); + }, + ethernetData() { + this.getIpv4TableItems(); + }, + }, + created() { + this.getIpv4TableItems(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-table-ipv4-complete'); + }); + }, + methods: { + getIpv4TableItems() { + const index = this.tabIndex; + const addresses = this.ethernetData[index].IPv4Addresses || []; + this.form.ipv4TableItems = addresses.map((ipv4) => { + return { + Address: ipv4.Address, + SubnetMask: ipv4.SubnetMask, + Gateway: ipv4.Gateway, + AddressOrigin: ipv4.AddressOrigin, + actions: [ + { + value: 'delete', + title: this.$t('pageNetwork.table.deleteIpv4'), + }, + ], + }; + }); + }, + onIpv4TableAction(action, $event, index) { + if ($event === 'delete') { + this.deleteIpv4TableRow(index); + } + }, + deleteIpv4TableRow(index) { + this.form.ipv4TableItems.splice(index, 1); + const newIpv4Array = this.form.ipv4TableItems.map((ipv4) => { + const { Address, SubnetMask, Gateway } = ipv4; + return { + Address, + SubnetMask, + Gateway, + }; + }); + this.$store + .dispatch('network/editIpv4Address', newIpv4Array) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initAddIpv4Address() { + this.$bvModal.show('modal-add-ipv4'); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/Network/index.js b/src/views/_sila/Settings/Network/index.js new file mode 100644 index 00000000..97bf0397 --- /dev/null +++ b/src/views/_sila/Settings/Network/index.js @@ -0,0 +1,2 @@ +import Network from './Network.vue'; +export default Network; diff --git a/src/views/_sila/Settings/PowerRestorePolicy/PowerRestorePolicy.vue b/src/views/_sila/Settings/PowerRestorePolicy/PowerRestorePolicy.vue new file mode 100644 index 00000000..06e30f3e --- /dev/null +++ b/src/views/_sila/Settings/PowerRestorePolicy/PowerRestorePolicy.vue @@ -0,0 +1,91 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pagePowerRestorePolicy.description')" /> + + <b-row> + <b-col sm="8" md="6" xl="12"> + <b-form-group :label="$t('pagePowerRestorePolicy.powerPoliciesLabel')"> + <b-form-radio-group + v-model="currentPowerRestorePolicy" + :options="options" + name="power-restore-policy" + ></b-form-radio-group> + </b-form-group> + </b-col> + </b-row> + + <b-button variant="primary" type="submit" @click="submitForm"> + {{ $t('global.action.saveSettings') }} + </b-button> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'PowerRestorePolicy', + components: { PageTitle }, + mixins: [VuelidateMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + policyValue: null, + options: [], + }; + }, + computed: { + powerRestorePolicies() { + return this.$store.getters['powerPolicy/powerRestorePolicies']; + }, + currentPowerRestorePolicy: { + get() { + return this.$store.getters['powerPolicy/powerRestoreCurrentPolicy']; + }, + set(policy) { + this.policyValue = policy; + }, + }, + }, + created() { + this.startLoader(); + this.renderPowerRestoreSettings(); + }, + methods: { + renderPowerRestoreSettings() { + Promise.all([ + this.$store.dispatch('powerPolicy/getPowerRestorePolicies'), + this.$store.dispatch('powerPolicy/getPowerRestoreCurrentPolicy'), + ]).finally(() => { + this.options.length = 0; + this.powerRestorePolicies.map((item) => { + this.options.push({ + text: this.$t(`pagePowerRestorePolicy.policiesDesc.${item.state}`), + value: `${item.state}`, + }); + }); + this.endLoader(); + }); + }, + submitForm() { + this.startLoader(); + this.$store + .dispatch( + 'powerPolicy/setPowerRestorePolicy', + this.policyValue || this.currentPowerRestorePolicy + ) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.renderPowerRestoreSettings(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Settings/PowerRestorePolicy/index.js b/src/views/_sila/Settings/PowerRestorePolicy/index.js new file mode 100644 index 00000000..fab0d477 --- /dev/null +++ b/src/views/_sila/Settings/PowerRestorePolicy/index.js @@ -0,0 +1,2 @@ +import PowerRestorePolicy from './PowerRestorePolicy.vue'; +export default PowerRestorePolicy; |