diff options
Diffstat (limited to 'src')
395 files changed, 37141 insertions, 14 deletions
diff --git a/src/App.vue b/src/App.vue index fc04b70b..d2878946 100644 --- a/src/App.vue +++ b/src/App.vue @@ -32,5 +32,5 @@ export default { </script> <style lang="scss"> -@import '@/assets/styles/_obmc-custom'; +/* Empty SCSS code: use prependData() */ </style> diff --git a/src/assets/images/_ibs/login-aside-vector.svg b/src/assets/images/_ibs/login-aside-vector.svg new file mode 100644 index 00000000..8d1c1731 --- /dev/null +++ b/src/assets/images/_ibs/login-aside-vector.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="40" height="32" viewBox="0 0 40 32" + fill="none"> + <path d="M40 0L40 32L0 32L1.59859e-06 13.7143L13.3333 13.7143L13.3333 -2.33127e-06L40 0Z" fill="#E11717"/> +</svg> diff --git a/src/assets/images/_ibs/login-company-logo.svg b/src/assets/images/_ibs/login-company-logo.svg new file mode 100644 index 00000000..57d11ff4 --- /dev/null +++ b/src/assets/images/_ibs/login-company-logo.svg @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="220px" height="50px" viewBox="0 0 89 13" + version="1.1"> + <style type="text/css"> + .st0{fill:#e11717;} + </style> + <g id="surface1"> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 5.648438 3.261719 C 5.351562 3.253906 5.0625 3.363281 4.847656 3.566406 C 4.628906 3.773438 4.503906 4.054688 4.496094 4.351562 L 4.496094 8.160156 C 4.511719 8.773438 5.019531 9.261719 5.636719 9.246094 L 19.867188 9.246094 L 19.867188 12.960938 L 3.414062 12.960938 C 1.578125 13 0.0625 11.542969 0.0195312 9.710938 C -0.0273438 9.664062 0.0195312 3.28125 0.0195312 3.28125 C 0.0625 1.449219 1.578125 -0.0078125 3.414062 0.03125 L 19.867188 0.03125 L 19.867188 3.261719 Z M 5.648438 3.261719 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 38.398438 0.0390625 L 28.191406 9.738281 L 28.191406 3.199219 L 30.476562 3.195312 L 30.476562 0.0390625 L 23.609375 0.0390625 L 23.609375 12.96875 L 29.929688 12.96875 L 39.78125 3.296875 L 39.78125 12.96875 L 44.269531 12.96875 L 44.269531 0.0390625 Z M 38.398438 0.0390625 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 59.210938 0.0390625 L 50.109375 0.0390625 L 50.109375 3.226562 L 51.542969 3.226562 L 46.457031 12.96875 L 50.761719 12.96875 L 55.828125 3.269531 L 57.519531 3.269531 L 62.582031 12.96875 L 67.132812 12.96875 L 60.347656 0.0390625 Z M 59.210938 0.0390625 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 82.214844 0.0390625 L 72.0625 0.0390625 L 72.0625 3.226562 L 73.410156 3.226562 L 68.34375 12.96875 L 72.648438 12.96875 L 73.46875 11.347656 L 83.597656 11.347656 L 84.441406 12.96875 L 88.996094 12.96875 Z M 75.160156 8.117188 L 77.6875 3.261719 L 79.378906 3.261719 L 81.910156 8.109375 Z M 75.160156 8.117188 "/> + </g> +</svg> diff --git a/src/assets/images/_ibs/login-main-vector.svg b/src/assets/images/_ibs/login-main-vector.svg new file mode 100644 index 00000000..69d2dd75 --- /dev/null +++ b/src/assets/images/_ibs/login-main-vector.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="80" height="64" viewBox="0 0 80 64" + fill="none"> + <path d="M0 64L0 0L80 0V36.5714H53.3333V64L0 64Z" fill="#E11717"/> +</svg> diff --git a/src/assets/images/_ibs/logo-header.svg b/src/assets/images/_ibs/logo-header.svg new file mode 100644 index 00000000..3ba2b022 --- /dev/null +++ b/src/assets/images/_ibs/logo-header.svg @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<svg + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="90px" height="36px" viewBox="0 0 89 13" + version="1.1"> + <style type="text/css"> + .st0{fill:#e11717;} + </style> + <g id="surface1"> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 5.648438 3.261719 C 5.351562 3.253906 5.0625 3.363281 4.847656 3.566406 C 4.628906 3.773438 4.503906 4.054688 4.496094 4.351562 L 4.496094 8.160156 C 4.511719 8.773438 5.019531 9.261719 5.636719 9.246094 L 19.867188 9.246094 L 19.867188 12.960938 L 3.414062 12.960938 C 1.578125 13 0.0625 11.542969 0.0195312 9.710938 C -0.0273438 9.664062 0.0195312 3.28125 0.0195312 3.28125 C 0.0625 1.449219 1.578125 -0.0078125 3.414062 0.03125 L 19.867188 0.03125 L 19.867188 3.261719 Z M 5.648438 3.261719 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 38.398438 0.0390625 L 28.191406 9.738281 L 28.191406 3.199219 L 30.476562 3.195312 L 30.476562 0.0390625 L 23.609375 0.0390625 L 23.609375 12.96875 L 29.929688 12.96875 L 39.78125 3.296875 L 39.78125 12.96875 L 44.269531 12.96875 L 44.269531 0.0390625 Z M 38.398438 0.0390625 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 59.210938 0.0390625 L 50.109375 0.0390625 L 50.109375 3.226562 L 51.542969 3.226562 L 46.457031 12.96875 L 50.761719 12.96875 L 55.828125 3.269531 L 57.519531 3.269531 L 62.582031 12.96875 L 67.132812 12.96875 L 60.347656 0.0390625 Z M 59.210938 0.0390625 "/> + <path class="st0" style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 82.214844 0.0390625 L 72.0625 0.0390625 L 72.0625 3.226562 L 73.410156 3.226562 L 68.34375 12.96875 L 72.648438 12.96875 L 73.46875 11.347656 L 83.597656 11.347656 L 84.441406 12.96875 L 88.996094 12.96875 Z M 75.160156 8.117188 L 77.6875 3.261719 L 79.378906 3.261719 L 81.910156 8.109375 Z M 75.160156 8.117188 "/> + </g> +</svg> 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..e46dbb0e --- /dev/null +++ b/src/assets/images/_sila/built-on-openbmc-logo.svg @@ -0,0 +1,5 @@ +<svg width="480" height="900" viewBox="0 0 480 900" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="480" height="900" fill="url(#pattern0)"/> +<rect width="480" height="900" fill="#2c405a"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M178.44 441.619V434H138.073C133.615 434 130 437.639 130 442.127V457.873C130 462.361 133.615 466 138.073 466H178.44V456.857H144.128C142.178 456.857 140.596 455.265 140.596 453.302V445.175C140.596 443.211 142.178 441.619 144.128 441.619H178.44ZM188.028 466V434H204.679V441.619H199.128V457.873L224.358 434H238.991V466H228.394V442.127L203.67 466H188.028ZM253.624 434V441.619H257.156V442.127L244.794 466H255.138L267.752 441.619H271.789L284.404 466H295.505L278.853 434H253.624ZM308.119 441.619V434H332.844L350 466H338.395L336.376 461.937H311.147L309.128 466H299.037L311.147 442.127V441.619H308.119ZM315.688 452.794V454.317H332.339V452.794L325.78 441.619H321.743L315.688 452.794Z" fill="#E11717"/> +</svg> diff --git a/src/assets/images/_sila/chevron-down.svg b/src/assets/images/_sila/chevron-down.svg new file mode 100644 index 00000000..ee70185f --- /dev/null +++ b/src/assets/images/_sila/chevron-down.svg @@ -0,0 +1,9 @@ +<svg version="1.1" id="icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:none;} +</style> +<polygon points="16,22 6,12 7.4,10.6 16,19.2 24.6,10.6 26,12 "/> +<rect id="_x3C_Transparent_Rectangle_x3E_" class="st0" width="32" height="32"/> +</svg> + diff --git a/src/assets/images/_sila/collapsed/fan.svg b/src/assets/images/_sila/collapsed/fan.svg new file mode 100644 index 00000000..b00b459a --- /dev/null +++ b/src/assets/images/_sila/collapsed/fan.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M5.0071 14.5899C5.88063 15.1221 6.9438 15.3043 7.65225 15.3043L7.65225 9.70443C7.02871 9.57786 6.52632 9.1177 6.33946 8.51834C5.89187 8.63106 5.42752 8.80613 5.0071 9.06226C4.15196 9.58323 3.47829 10.4354 3.47829 11.8261C3.47829 13.2168 4.15196 14.0689 5.0071 14.5899ZM6.29572 7.65225H0.695659C0.695659 6.9438 0.877919 5.88063 1.41009 5.0071C1.93106 4.15196 2.7832 3.47829 4.17391 3.47829C5.56463 3.47829 6.41677 4.15196 6.93774 5.0071C7.19388 5.42754 7.36896 5.8919 7.48167 6.33951C6.88238 6.5264 6.42228 7.02876 6.29572 7.65225ZM8.34791 7.01596C8.23911 6.97751 8.12204 6.95659 8.00008 6.95659C7.86275 6.95659 7.73161 6.98312 7.61152 7.03133C7.3343 7.14262 7.11592 7.36946 7.01596 7.65225C6.97751 7.76104 6.95659 7.87812 6.95659 8.00008C6.95659 8.13735 6.9831 8.26843 7.03127 8.38849C7.14254 8.66578 7.36941 8.88422 7.65225 8.98419C7.76104 9.02264 7.87812 9.04356 8.00008 9.04356C8.18346 9.04356 8.35579 8.99625 8.50554 8.91318C8.7268 8.79044 8.89876 8.58961 8.98419 8.34791C9.02264 8.23911 9.04356 8.12204 9.04356 8.00008C9.04356 7.81664 8.99623 7.64425 8.91311 7.49447C8.79035 7.27328 8.58956 7.10138 8.34791 7.01596ZM9.586 7.28526C9.35937 6.78322 8.90135 6.40806 8.34791 6.29572V0.746159C8.96378 0.831471 9.67823 1.03288 10.2974 1.41009C11.1525 1.93106 11.8262 2.7832 11.8262 4.17391C11.8262 5.56463 11.1525 6.41677 10.2974 6.93774C10.0699 7.07632 9.82958 7.19117 9.586 7.28526ZM9.70444 8.34791C9.59208 8.9014 9.21686 9.35945 8.71476 9.58606C8.80884 9.82962 8.92369 10.0699 9.06226 10.2974C9.58323 11.1525 10.4354 11.8262 11.8261 11.8262C13.2168 11.8262 14.0689 11.1525 14.5899 10.2974C14.9671 9.67823 15.1685 8.96378 15.2538 8.34791H9.70444ZM8.34791 10.4501V16L7.65225 16C6.85343 16 5.65572 15.7997 4.64517 15.184C3.61623 14.5571 2.78264 13.4962 2.78264 11.8261C2.78264 10.1559 3.61623 9.09503 4.64517 8.46817C4.7141 8.42617 4.78391 8.38611 4.8544 8.34791H1.9281e-06L0 7.65225C0 6.85343 0.200349 5.65572 0.816001 4.64517C1.44286 3.61623 2.50376 2.78264 4.17391 2.78264C5.84407 2.78264 6.90497 3.61623 7.53183 4.64517C7.57388 4.7142 7.614 4.7841 7.65225 4.85469V0.695659V0.347832V0C8.45106 0 9.64878 0.200348 10.6593 0.816001C11.6883 1.44286 12.5219 2.50376 12.5219 4.17391C12.5219 5.84407 11.6883 6.90497 10.6593 7.53183C10.5903 7.57388 10.5204 7.614 10.4498 7.65225H15.3043H15.6522H16C16 8.45106 15.7997 9.64878 15.184 10.6593C14.5571 11.6883 13.4962 12.5219 11.8261 12.5219C10.1559 12.5219 9.09503 11.6883 8.46817 10.6593C8.42617 10.5904 8.38611 10.5206 8.34791 10.4501Z" fill="#E11717"/> +</svg>
\ No newline at end of file diff --git a/src/assets/images/_sila/collapsed/power.svg b/src/assets/images/_sila/collapsed/power.svg new file mode 100644 index 00000000..84acf641 --- /dev/null +++ b/src/assets/images/_sila/collapsed/power.svg @@ -0,0 +1,3 @@ +<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M0.5 14H4.02205L4.77627 9.65234H7.15277C10.5183 9.65234 12.9232 7.76563 13.4142 4.84668C13.9123 1.96191 12.2188 0 8.9387 0H2.91919L0.5 14ZM5.23876 6.98633L5.97875 2.72754H7.71487C9.30157 2.72754 9.98464 3.54102 9.77118 4.84668C9.55061 6.13867 8.57582 6.98633 6.96777 6.98633H5.23876Z" fill="#E11717"/> +</svg> diff --git a/src/assets/images/_sila/collapsed/temperature.svg b/src/assets/images/_sila/collapsed/temperature.svg new file mode 100644 index 00000000..eaef46f7 --- /dev/null +++ b/src/assets/images/_sila/collapsed/temperature.svg @@ -0,0 +1,3 @@ +<svg width="8" height="16" viewBox="0 0 8 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2.59649 8.70217V2.11563C2.72082 1.71432 3.14137 1.08777 3.96881 1.08777C4.80097 1.08777 5.25676 1.71835 5.40351 2.1269V8.5628V8.70217H5.40718L5.1428 9.2957C6.08491 9.74384 6.73684 10.7272 6.73684 11.8665C6.73684 13.4284 5.51152 14.6947 4 14.6947C2.48848 14.6947 1.26316 13.4284 1.26316 11.8665C1.26316 10.7267 1.91563 9.74303 2.85836 9.29515L2.59455 8.70217H2.59649ZM1.47368 8.66168V2.07598C1.47368 2.01444 1.48068 1.94562 1.50067 1.87278C1.67069 1.25325 2.37741 0 3.96881 0C5.54465 0 6.29377 1.23221 6.49026 1.84692C6.51676 1.92982 6.52632 2.00947 6.52632 2.08119V8.66168C7.42514 9.4192 8 10.5731 8 11.8665C8 14.1494 6.20914 16 4 16C1.79086 16 0 14.1494 0 11.8665C0 10.5731 0.574857 9.4192 1.47368 8.66168ZM6 12C6 13.1046 5.10457 14 4 14C2.89543 14 2 13.1046 2 12C2 11.2597 2.5 10 3.5 10V4H4.5V10C5.5 10 6 11.2597 6 12Z" fill="#E11717"/> +</svg>
\ No newline at end of file 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..f2715f11 --- /dev/null +++ b/src/assets/images/_sila/logo-header.svg @@ -0,0 +1,3 @@ +<svg width="110" height="16" viewBox="0 0 110 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2202 3.80952V0H4.0367C1.80729 0 0 1.81929 0 4.06349V11.9365C0 14.1807 1.80729 16 4.0367 16H24.2202V11.4286H7.06422C6.08885 11.4286 5.29817 10.6326 5.29817 9.65079V5.5873C5.29817 4.60546 6.08886 3.80952 7.06422 3.80952H24.2202ZM29.0138 16V0H37.3395V3.80952H34.5642V11.9365L47.1789 0H54.4954V16H49.1973V4.06349L36.8349 16H29.0138ZM61.8119 0V3.80952H63.578V4.06349L57.3968 16H62.5688L68.8762 3.80952H70.8945L77.2018 16H82.7523L74.4266 0H61.8119ZM89.0596 3.80952V0H101.422L110 16H104.197L103.188 13.9683H90.5734L89.5642 16H84.5183L90.5734 4.06349V3.80952H89.0596ZM92.844 9.39683V10.1587H101.17V9.39683L97.8899 3.80952H95.8716L92.844 9.39683Z" fill="#E11717"/> +</svg> diff --git a/src/assets/images/_sila/sort-ascending.svg b/src/assets/images/_sila/sort-ascending.svg new file mode 100644 index 00000000..7916824e --- /dev/null +++ b/src/assets/images/_sila/sort-ascending.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3 6H9V8H3V6ZM3 18V16H21V18H3ZM3 11H15V13H3V11Z" fill="#0C1C29"/> +</svg>
\ No newline at end of file diff --git a/src/assets/images/_sila/sort-descending.svg b/src/assets/images/_sila/sort-descending.svg new file mode 100644 index 00000000..843da93a --- /dev/null +++ b/src/assets/images/_sila/sort-descending.svg @@ -0,0 +1,3 @@ +<svg width="16" height="10" viewBox="0 0 16 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.33333 10H4.66667C5.125 10 5.5 9.625 5.5 9.16667C5.5 8.70833 5.125 8.33333 4.66667 8.33333H1.33333C0.875 8.33333 0.5 8.70833 0.5 9.16667C0.5 9.625 0.875 10 1.33333 10ZM0.5 0.833333C0.5 1.29167 0.875 1.66667 1.33333 1.66667H14.6667C15.125 1.66667 15.5 1.29167 15.5 0.833333C15.5 0.375 15.125 0 14.6667 0H1.33333C0.875 0 0.5 0.375 0.5 0.833333ZM1.33333 5.83333H9.66667C10.125 5.83333 10.5 5.45833 10.5 5C10.5 4.54167 10.125 4.16667 9.66667 4.16667H1.33333C0.875 4.16667 0.5 4.54167 0.5 5C0.5 5.45833 0.875 5.83333 1.33333 5.83333Z" fill="#0C1C29" fill-opacity="0.3"/> +</svg>
\ No newline at end of file diff --git a/src/assets/styles/_obmc-ibs.scss b/src/assets/styles/_obmc-ibs.scss new file mode 100644 index 00000000..eb22535d --- /dev/null +++ b/src/assets/styles/_obmc-ibs.scss @@ -0,0 +1,6 @@ +// Vendor styles +@import "./bootstrap"; +@import "~bootstrap-vue/src/index"; + +// IBS BMC styles +@import "./bmc/_ibs"; 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/_ibs/_alert.scss b/src/assets/styles/bmc/_ibs/_alert.scss new file mode 100644 index 00000000..d85e9127 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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"); + } +} diff --git a/src/assets/styles/bmc/_ibs/_badge.scss b/src/assets/styles/bmc/_ibs/_badge.scss new file mode 100644 index 00000000..7d59e9a6 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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"); +} diff --git a/src/assets/styles/bmc/_ibs/_base.scss b/src/assets/styles/bmc/_ibs/_base.scss new file mode 100644 index 00000000..40806995 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_base.scss @@ -0,0 +1,102 @@ + +// +// Scrollbars: +// +/* W3C standard: Firefox only */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(12,32,64,.7) transparent; +} + +/* For Chrome/Edge/Safari */ +*::-webkit-scrollbar { + height: 3px; + width: 3px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(12,32,64,.7); + border-radius: 2px; + border: 2px solid transparent; +} + +// +// Headers: +// +h1, +.h1 { + font-size: 2.25rem !important; + font-weight: 500; + line-height: 1.2; +} + +h2, +.h2 { + font-size: 1.5rem !important; + font-weight: 500; + line-height: 1.2; +} + +h3, +.h3 { + font-size: 1.5rem !important; + font-weight: 400; + line-height: 1.2; +} + +h4, +.h4 { + font-size: 1.25rem !important; + font-weight: 400; + line-height: 1.2; +} + +h5, +.h5 { + font-size: 1rem !important; + font-weight: 500; + line-height: 1.2; +} + +h6, +.h6 { + font-size: 0.825rem !important; + font-weight: 500; + line-height: 1.2; +} + + +.navbar-brand { + font-size: 1.4rem !important; +} + +.nav-link--current{ + background-color: $red-light-background !important; + color: $red !important; + &:hover, + &:focus { + background-color: $red-light-background !important; + color: $red !important; + } +} + +.btn-link:active, .nav-link:active { + background-color: $red-light-background !important; + color: $red !important; +} + +.app-header .navbar-text, .app-header .nav-link, .app-header .btn-link { + &:hover { + background-color: $red-40 !important; + } + &:active { + background-color: $red-active !important; + } + &:focus { + outline: 0; + } +} diff --git a/src/assets/styles/bmc/_ibs/_bootstrap-grid.scss b/src/assets/styles/bmc/_ibs/_bootstrap-grid.scss new file mode 100644 index 00000000..87332f4b --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_bootstrap-grid.scss @@ -0,0 +1,28 @@ +.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; +} + +@media (min-width: 1200px) { + .container-xl { + max-width: unset !important; + } + .col-md-9, + .col-xl-9, + .col-xl-10, + .col-xl-11 { + flex: 0 0 100%; + max-width: unset !important; + } +} + +@media (min-width: 768px) { + .col-md-9 { + flex: 0 0 100%; + max-width: unset !important; + } +} diff --git a/src/assets/styles/bmc/_ibs/_buttons.scss b/src/assets/styles/bmc/_ibs/_buttons.scss new file mode 100644 index 00000000..b3a69757 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_buttons.scss @@ -0,0 +1,174 @@ +.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: $red; + fill: currentColor; + box-shadow: none !important; + &:not(.btn-link) { + border-color: gray("400"); + background-color: gray("400"); + } + } +} + +.btn-primary { + background-color: $red; + border-radius: $border-radius; + border: none; + box-shadow: 1px 2px 2px -1px #4f252566 inset; + &:hover { + background-color: $red-hover; + } + &:not(:disabled):not(.disabled):active:focus, + &:focus-visible { + border: none; + box-shadow: none; + background-color: $red-click; + } + &:focus { + border: none; + box-shadow: 0px 0px 0px 4px $red-shadow; + background-color: $red; + } + &:active { + border: none; + box-shadow: none; + background-color: $red; + } +} + +.btn-secondary { + background-color: $dark; + color: $gray-2; + border-radius: $border-radius; + border: none; + box-shadow: 1px 2px 4px -1px rgb(79 37 37 / 10%) inset; + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 20px; + &:hover { + background-color: $dark-hover; + color: $gray-2; + } + &:focus { + border: none; + box-shadow: 0px 0px 0px 4px $red-shadow; + color: $gray-2; + background-color: $dark; + } + &:not(:disabled):not(.disabled):active:focus, + &:focus-visible { + border: none; + box-shadow: none; + color: $gray-2; + background-color: $dark; + } +} + +.btn-unstiled { + border: none; + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } +} + +.btn-popover { + border: none; + color: $red; + height: 28px; + border-radius: $border-radius; + font-weight: 500; + font-size: 12px; + transition: ease-in 0.2s; + &:hover { + color: $white; + transition: ease-in 0.2s; + } + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } + &.selected-unit-button { + transition: ease-in 0.2s; + color: $white; + } + &.selected-choice-button { + transition: ease-in 0.2s; + color: $white; + } +} + +.btn-toogle-popover { + justify-content: flex-start; + width: 25px; + height: 16px; + padding: 0; + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } +} + +// Global style for all button link +.btn-link { + color: $text-primary; + text-decoration: none !important; + &:hover { + background-color: gray("200"); + color: theme-color("primary"); + } + &:active { + background-color: gray("300"); + } + &:disabled { + box-shadow: $btn-focus-box-shadow; + } + &.collapsed { + .icon-expand { + transform: rotate(180deg); + transition: 0.3s linear; + } + .icon-expand-right { + transform: rotate(180deg); + transition: 0.3s linear; + } + } +} + +// 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: 6px; + margin: 1px; +} diff --git a/src/assets/styles/bmc/_ibs/_calendar.scss b/src/assets/styles/bmc/_ibs/_calendar.scss new file mode 100644 index 00000000..9a53169d --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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; +} diff --git a/src/assets/styles/bmc/_ibs/_card.scss b/src/assets/styles/bmc/_ibs/_card.scss new file mode 100644 index 00000000..d7ac04d2 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_card.scss @@ -0,0 +1,37 @@ +.card { + border-radius: $border-radius; + .bg-success { + background-color: theme-color-light('success')!important; + } +} + +.card-header { + .card-header-tabs { + border: none !important; + + .nav-link { + border: none !important; + color: $text-secondary; + font-weight: 500 !important; + background: none !important; + &:hover { + color: $red-hover + } + } + + .nav-link.active { + color: $red-active !important; + text-decoration: underline; + } + } +} + +.card-body dd .status-icon { + line-height: 1.1; +} + +.card-buttons > a { + font-weight: 500; + font-size: .825rem; + vertical-align: baseline; +} diff --git a/src/assets/styles/bmc/_ibs/_dropdown.scss b/src/assets/styles/bmc/_ibs/_dropdown.scss new file mode 100644 index 00000000..02e4e855 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_dropdown.scss @@ -0,0 +1,49 @@ +// Make calendar visible over the table +.dropdown-menu { + z-index: $zindex-dropdown + 1; + padding: $spacer / 2; + border-radius: $border-radius; +} + +.dropdown-item { + padding: $spacer / 4 $spacer / 2; + margin: $spacer / 4; + width: auto; + border-radius: $border-radius; + &:hover { + background-color: $red-hover; + color: $white; + } + &:active { + background-color: $red-active; + } + &:focus { + outline: none; + background-color: $red-click; + box-shadow: inset 0 0 0 2px theme-color("primary"); + } +} + +.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"); + } + } +} diff --git a/src/assets/styles/bmc/_ibs/_forms.scss b/src/assets/styles/bmc/_ibs/_forms.scss new file mode 100644 index 00000000..914bf379 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_forms.scss @@ -0,0 +1,210 @@ +// 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-radius: $border-radius; + background-color: $white; +} + +.custom-select, +.form-control { + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + &::placeholder { + color: gray("600"); + } + &.is-invalid, + &:invalid { + border: 1px solid theme-color("danger") !important; + } +} + +.form-control::-webkit-outer-spin-button, +.form-control::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.form-control[type='number'] { + -moz-appearance: textfield; +} + +.form-control { + color: $text-secondary; + border-radius: $border-radius; + border: none; + background-color: $gray-5; + font-size: 0.875rem; + &:focus { + color: $text-secondary; + } + &:hover { + background-color: $gray-5-hover; + } +} + +// Inverted form colors +.form-background { + background-color: none; + padding: 0 !important; + .custom-select, + .form-control { + color: #0c1c29e6; + border-radius: $border-radius; + border: none; + background-color: $gray-5; + &:hover { + background-color: $gray-5-hover; + } + &:focus { + border: 1px solid gray("400"); + } + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + &.is-valid { + border: 1px solid gray("400"); + } + } +} + +.invalid-feedback { + font-size: $form-label-font-size; + line-height: $form-line-height; +} + +.custom-checkbox ::before { + box-shadow: none !important; + border: 2px solid $on-surface-secondary; + background-color: #fff; + border-radius: 3px; +} + +.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: $red; + border-color: $red; + cursor: pointer; + &:focus { + box-shadow: none !important; + } +} + +.custom-control { + .custom-control-input[disabled=disabled] { + & + .custom-control-label { + // Disabled label for checkbox, radio, + // switch bootstrap form components + color: gray("600")!important; + } + } +} + +.custom-switch { + width: 28px; + height: 16px; +} + +.custom-switch +.custom-control-input:focus ~ .custom-control-label::before { + box-shadow: none; +} + +.custom-switch +.custom-control-input ~ .custom-control-label::after { + border: 1px solid $gray-20; + background: $white; +} + +.custom-control-input:focus:not(:checked) ~ .custom-control-label::before { + border-color: $gray-20; +} + +.custom-control-input:not(:disabled):active ~ .custom-control-label::before { + background-color: rgba(4, 10, 15, 0.3); + border-color: $gray-20; +} + +.custom-switch +.custom-control-input ~ .custom-control-label::before { + border: 1px solid $gray-20; + background: rgba(4, 10, 15, 0.3); +} + +.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%; + } +} +// For invisible label of fields +.invisible { + height: 0; + margin-bottom: 0; +} +// Global style progress bar +.progress { + height: 8px; + background-color: $gray-10; +} + +.progress-bar { + background-color: $red; + border-radius: 4px; +} + +.progress_bar_percent { + color: $red; +} + +.system-control__radio label { + padding-top: 4px; +} diff --git a/src/assets/styles/bmc/_ibs/_index.scss b/src/assets/styles/bmc/_ibs/_index.scss new file mode 100644 index 00000000..14c85d70 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_index.scss @@ -0,0 +1,24 @@ +// 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"; +@import "./pages/login"; +@import "./pages/virtual-media"; +@import "./pages/firmware"; +@import "./pages/inventory"; +@import "./pages/power-operations"; +@import "./pages/security"; diff --git a/src/assets/styles/bmc/_ibs/_kvm.scss b/src/assets/styles/bmc/_ibs/_kvm.scss new file mode 100644 index 00000000..8276b8ea --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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; + } +} diff --git a/src/assets/styles/bmc/_ibs/_modal.scss b/src/assets/styles/bmc/_ibs/_modal.scss new file mode 100644 index 00000000..e2fa0cd8 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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/_ibs/_pagination.scss b/src/assets/styles/bmc/_ibs/_pagination.scss new file mode 100644 index 00000000..97fe0132 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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"); + } +} diff --git a/src/assets/styles/bmc/_ibs/_section-divider.scss b/src/assets/styles/bmc/_ibs/_section-divider.scss new file mode 100644 index 00000000..bca2d77e --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_section-divider.scss @@ -0,0 +1,3 @@ +.section-divider { + border-bottom: 1px solid gray('400'); +} diff --git a/src/assets/styles/bmc/_ibs/_sol.scss b/src/assets/styles/bmc/_ibs/_sol.scss new file mode 100644 index 00000000..3641c040 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_sol.scss @@ -0,0 +1,3 @@ +#terminal .xterm .xterm-viewport { + overflow: auto; +} diff --git a/src/assets/styles/bmc/_ibs/_tables.scss b/src/assets/styles/bmc/_ibs/_tables.scss new file mode 100644 index 00000000..d73bfdde --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_tables.scss @@ -0,0 +1,188 @@ +.table { + position: relative; + z-index: $zindex-dropdown; + + td { + border-top: 1px solid $gray-10; + border-right: 1px solid $gray-10; + border-bottom-width: 0px; + &:first-of-type { + border-right: 1px solid $gray-10; + } + &:last-of-type { + border-left: 1px solid $gray-10; + border-right-width: 0px; + } + vertical-align: middle; + font-size: 0.875rem; + // 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 { + background-color: $surface-secondary; + vertical-align: middle; + border-top-width: 0px; + border-bottom: 1px solid $gray-10; + border-right: 1px solid $gray-10; + &:first-of-type { + border-right: 1px solid $gray-10; + } + &:last-of-type { + border-left: 1px solid $gray-10; + border-right-width: 0px; + } + text-transform: uppercase; + color: $text-primary; + &:focus { + outline: none; + } + & > div { + font-size: 0.875rem !important; + } + } + + .custom-switch { + width: 100%; + } + + .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 { + border-top: 1px solid rgba(26,62,91,.3); + background-color: inherit; + td { + padding-left: $table-cell-padding; + padding-right: $table-cell-padding; + } + dl { + margin: 0; + } + dt { + font-size: 0.75rem !important; + float: left; + clear: left; + margin-right: $spacer / 2; + } + dd { + font-size: 0.75rem !important; + line-height: 1.5 + } + } + + .table-row-expand { + width: 50px; + .btn { + padding: 0; + width: 40px; + } + 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-10; + + tr { + + &:not(:first-child) > td[aria-colindex='1'] { + border-top: 1px solid $gray-10; + 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-responsive, .table-responsive-md { + margin-top: 0.5rem; +} diff --git a/src/assets/styles/bmc/_ibs/_toasts.scss b/src/assets/styles/bmc/_ibs/_toasts.scss new file mode 100644 index 00000000..482765ce --- /dev/null +++ b/src/assets/styles/bmc/_ibs/_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; +} diff --git a/src/assets/styles/bmc/_ibs/pages/_firmware.scss b/src/assets/styles/bmc/_ibs/pages/_firmware.scss new file mode 100644 index 00000000..de65dc10 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_firmware.scss @@ -0,0 +1,13 @@ + +// +// Firmware Update: +// +.firmware-update .btn, +.firmware-update label { + width: 100%; +} +.firmware-update .form-background { + padding: $spacer * 2 !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_ibs/pages/_inventory.scss b/src/assets/styles/bmc/_ibs/pages/_inventory.scss new file mode 100644 index 00000000..6f065ad4 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_inventory.scss @@ -0,0 +1,9 @@ + +// +// Service Indicators: +// +.service-indicators .form-background { + padding: $spacer * 2 !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_ibs/pages/_login.scss b/src/assets/styles/bmc/_ibs/pages/_login.scss new file mode 100644 index 00000000..cd7ef538 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_login.scss @@ -0,0 +1,23 @@ + +// +// Login Page: +// +.login-form { + background-color: transparent !important; +} + +.login-form > .btn-primary { + width: 100%; + margin-top: 2rem !important; +} + +.login-main .h3 { + font-weight: 400 !important; + font-size: 1.6rem !important; +} + +@media (max-width: 768px) { + .login-brand { + margin-top: 1rem; + } +} diff --git a/src/assets/styles/bmc/_ibs/pages/_power-operations.scss b/src/assets/styles/bmc/_ibs/pages/_power-operations.scss new file mode 100644 index 00000000..0921323e --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_power-operations.scss @@ -0,0 +1,9 @@ + +// +// Power Operations: +// +.boot-settings .form-background { + padding: $spacer * 2 $spacer * 2 $spacer !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_ibs/pages/_security.scss b/src/assets/styles/bmc/_ibs/pages/_security.scss new file mode 100644 index 00000000..45473a53 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_security.scss @@ -0,0 +1,9 @@ + +// +// Security and Access: +// +.ldap-settings .form-background { + padding: $spacer * 2 $spacer * 2 $spacer !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_ibs/pages/_virtual-media.scss b/src/assets/styles/bmc/_ibs/pages/_virtual-media.scss new file mode 100644 index 00000000..040e57b1 --- /dev/null +++ b/src/assets/styles/bmc/_ibs/pages/_virtual-media.scss @@ -0,0 +1,47 @@ + +// +// Media +// +.media-start { + margin-top: $spacer * 2; +} + +.media-buttons-group >.media-start label { + width: 100%; +} + +.media-buttons-group >.media-start .btn { + width: 50%; +} + +.media-buttons-group >.media-stop > .btn { + width: 50%; +} + + +// +// Media Legacy +// +.media-legacy label { + margin-top: $spacer * 2; +} + +.media-legacy-buttons-group { + margin: 0px; +} + +.media-legacy-buttons-group .btn { + width: 100%; +} + +.media-legacy-buttons-group > .media-legacy-configure { + display: block; + width: 50%; + margin-bottom: $spacer * 2; +} + +.media-legacy-buttons-group > .media-legacy-start { + display: inline-block; + width: 50%; + margin-bottom: - $spacer * 2; +} diff --git a/src/assets/styles/bmc/_sila/_alert.scss b/src/assets/styles/bmc/_sila/_alert.scss new file mode 100644 index 00000000..d85e9127 --- /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"); + } +} diff --git a/src/assets/styles/bmc/_sila/_badge.scss b/src/assets/styles/bmc/_sila/_badge.scss new file mode 100644 index 00000000..7d59e9a6 --- /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"); +} diff --git a/src/assets/styles/bmc/_sila/_base.scss b/src/assets/styles/bmc/_sila/_base.scss new file mode 100644 index 00000000..e95a1ce1 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_base.scss @@ -0,0 +1,103 @@ + +// +// Scrollbars: +// +/* W3C standard: Firefox only */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(12,32,64,.7) transparent; +} + +/* For Chrome/Edge/Safari */ +*::-webkit-scrollbar { + height: 3px; + width: 3px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(12,32,64,.7); + border-radius: 2px; + border: 2px solid transparent; +} + +// +// Headers: +// +h1, +.h1 { + font-size: 1.5rem !important; + font-weight: 500; + line-height: 1.2; +} + +h2, +.h2 { + font-size: 1.25rem !important; + font-weight: 500; + line-height: 1.2; +} + +h3, +.h3 { + font-size: 1.25rem !important; + font-weight: 400; + line-height: 1.2; +} + +h4, +.h4 { + font-size: 1.15rem !important; + font-weight: 400; + line-height: 1.2; +} + +h5, +.h5 { + font-size: 1rem !important; + font-weight: 500; + line-height: 1.2; +} + +h6, +.h6, label { + font-size: 0.825rem !important; + font-weight: 500; + line-height: 1.2; +} + +.navbar-brand { + font-size: 1.4rem !important; +} + +.nav-link--current{ + background-color: $red-light-background !important; + color: $red !important; + &:hover, + &:focus { + background-color: $red-light-background !important; + color: $red !important; + } +} + +.btn-link:active, .nav-link:active { + background-color: $red-light-background !important; + color: $red !important; +} + +.app-header .navbar-text, .app-header .nav-link, .app-header .btn-link { + &:hover { + background-color: $red-40 !important; + border-radius: $border-radius; + } + &:active { + background-color: $red-active !important; + border-radius: $border-radius; + } + &:focus { + outline: 0; + } +} 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..2e6c0882 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_bootstrap-grid.scss @@ -0,0 +1,29 @@ +.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; + padding-left: 0; +} + +@media (min-width: 1200px) { + .container-xl { + max-width: unset !important; + } + .col-md-9, + .col-xl-9, + .col-xl-10, + .col-xl-11 { + flex: 0 0 100%; + max-width: unset !important; + } +} + +@media (min-width: 768px) { + .col-md-9 { + flex: 0 0 100%; + max-width: unset !important; + } +} diff --git a/src/assets/styles/bmc/_sila/_buttons.scss b/src/assets/styles/bmc/_sila/_buttons.scss new file mode 100644 index 00000000..0754e011 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_buttons.scss @@ -0,0 +1,202 @@ +.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: $red; + fill: currentColor; + box-shadow: none !important; + &:not(.btn-link) { + border-color: gray("400"); + background-color: gray("400"); + } + } +} + +.btn-primary { + background-color: $red; + border-radius: $border-radius; + border: none; + box-shadow: 1px 2px 2px -1px #4f252566 inset; + &:hover { + background-color: $red-hover; + } + &:not(:disabled):not(.disabled):active:focus, + &:focus-visible { + border: none; + box-shadow: none; + background-color: $red-click; + } + &:focus { + border: none; + box-shadow: 0px 0px 0px 4px $red-shadow; + background-color: $red; + } + &:active { + border: none; + box-shadow: none; + background-color: $red; + } +} + +.btn-secondary { + background-color: $gray-5; + color: $red; + border-radius: $border-radius; + border: none; + box-shadow: 1px 2px 4px -1px rgb(79 37 37 / 10%) inset; + &:hover { + background-color: $gray-5-hover; + color: $red; + } + &:focus { + border: none; + box-shadow: 0px 0px 0px 4px $red-shadow; + color: $red; + background-color: $gray-5-hover; + } + &:not(:disabled):not(.disabled):active:focus, + &:focus-visible { + border: none; + box-shadow: none; + color: $red; + background-color: $gray-5-hover; + } + &:active { + background-color: $gray-5-hover; + } +} + +.btn-danger { + border: none; + border-radius: $border-radius; +} + +.btn-unstiled { + border: none; + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } +} + +.btn-popover { + border: none; + color: $red; + height: 28px; + border-radius: $border-radius; + font-weight: 500; + font-size: 12px; + transition: ease-in 0.2s; + &:hover { + color: $white; + transition: ease-in 0.2s; + } + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } + &.selected-unit-button { + transition: ease-in 0.2s; + color: $white; + } + &.selected-choice-button { + transition: ease-in 0.2s; + color: $white; + } +} + +.btn-toogle-popover { + justify-content: flex-start; + width: 25px; + height: 16px; + padding: 0; + &:focus { + box-shadow: none; + } + &:active { + box-shadow: none; + } +} + +// Global style for all button link +.btn-link { + color: $text-primary; + text-decoration: none !important; + &:hover { + background-color: gray("200"); + color: theme-color("primary"); + } + &:active { + background-color: gray("300"); + } + &:disabled { + box-shadow: $btn-focus-box-shadow; + } + &.collapsed { + .icon-expand { + transform: rotate(180deg); + transition: 0.3s linear; + } + .icon-expand-right { + transform: rotate(180deg); + transition: 0.3s linear; + } + } +} + +// 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: 6px; + margin: 1px; +} + +.btn-collapse { + height: 56px; + width: 100%; + border-bottom: 1px solid $gray-10; + gap: 8px; + &:active, + &:focus { + box-shadow: none; + } + &:hover { + color: $text-primary; + } + .icon-expand { + margin-left: auto; + transition: 0.3s linear; + } + &.collapsed { + .icon-expand { + transform: rotate(180deg); + transition: 0.3s linear; + } + } +} diff --git a/src/assets/styles/bmc/_sila/_calendar.scss b/src/assets/styles/bmc/_sila/_calendar.scss new file mode 100644 index 00000000..9a53169d --- /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; +} diff --git a/src/assets/styles/bmc/_sila/_card.scss b/src/assets/styles/bmc/_sila/_card.scss new file mode 100644 index 00000000..d7ac04d2 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_card.scss @@ -0,0 +1,37 @@ +.card { + border-radius: $border-radius; + .bg-success { + background-color: theme-color-light('success')!important; + } +} + +.card-header { + .card-header-tabs { + border: none !important; + + .nav-link { + border: none !important; + color: $text-secondary; + font-weight: 500 !important; + background: none !important; + &:hover { + color: $red-hover + } + } + + .nav-link.active { + color: $red-active !important; + text-decoration: underline; + } + } +} + +.card-body dd .status-icon { + line-height: 1.1; +} + +.card-buttons > a { + font-weight: 500; + font-size: .825rem; + vertical-align: baseline; +} diff --git a/src/assets/styles/bmc/_sila/_chart.scss b/src/assets/styles/bmc/_sila/_chart.scss new file mode 100644 index 00000000..24da971d --- /dev/null +++ b/src/assets/styles/bmc/_sila/_chart.scss @@ -0,0 +1,7 @@ +.highcharts-credits { + display: none; +} + +.highcharts-plot-line-label { + transform: translate(-8px, 0) !important; +} diff --git a/src/assets/styles/bmc/_sila/_dropdown.scss b/src/assets/styles/bmc/_sila/_dropdown.scss new file mode 100644 index 00000000..02e4e855 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_dropdown.scss @@ -0,0 +1,49 @@ +// Make calendar visible over the table +.dropdown-menu { + z-index: $zindex-dropdown + 1; + padding: $spacer / 2; + border-radius: $border-radius; +} + +.dropdown-item { + padding: $spacer / 4 $spacer / 2; + margin: $spacer / 4; + width: auto; + border-radius: $border-radius; + &:hover { + background-color: $red-hover; + color: $white; + } + &:active { + background-color: $red-active; + } + &:focus { + outline: none; + background-color: $red-click; + box-shadow: inset 0 0 0 2px theme-color("primary"); + } +} + +.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"); + } + } +} diff --git a/src/assets/styles/bmc/_sila/_forms.scss b/src/assets/styles/bmc/_sila/_forms.scss new file mode 100644 index 00000000..82f7b710 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_forms.scss @@ -0,0 +1,244 @@ +// 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; + & > label { + color: $text-tretiatry; + margin-bottom: $spacer / 4; + } +} + +.custom-select, +.form-control, +.input-group-text { + border-radius: $border-radius; + background-color: $white; +} + +.custom-select, +.form-control { + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + &::placeholder { + color: gray("600"); + } + &.is-invalid, + &:invalid { + border: 1px solid theme-color("danger") !important; + } +} + +.custom-select { + background-image: url('~@/assets/images/_sila/chevron-down.svg'); + background-size: 18px; +} + +.form-control::-webkit-outer-spin-button, +.form-control::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.form-control[type='number'] { + -moz-appearance: textfield; +} + +.form-control { + color: $text-secondary; + border-radius: $border-radius; + border: none; + background-color: $gray-5; + font-size: 0.875rem; + &:focus { + color: $text-secondary; + } + &:hover { + background-color: $gray-5-hover; + } +} + +// Inverted form colors +.form-background { + background-color: none; + padding: 0 !important; + width: 100%; + .custom-select, + .form-control { + color: #0c1c29e6; + border-radius: $border-radius; + border: none; + background-color: $gray-5; + &:hover { + background-color: $gray-5-hover; + } + &:focus { + border: 1px solid gray("400"); + } + &:disabled { + background-color: gray("400"); + color: gray("600"); + } + &.is-valid { + border: 1px solid gray("400"); + } + } +} + +.invalid-feedback { + font-size: $form-label-font-size; + line-height: $form-line-height; +} + +.custom-checkbox ::before { + box-shadow: none !important; + border: 2px solid $on-surface-secondary; + background-color: #fff; + border-radius: 3px; +} + +.custom-control-label > span { + vertical-align: sub; +} + +.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 { + border: 1px solid $red !important; + background-color: $red !important; + box-shadow: 0px 0px 0px 3px $white inset; + cursor: pointer; + &:focus { + box-shadow: none !important; + } +} + +.custom-radio .custom-control-input:checked ~ .custom-control-label::after{ + background-image: none; +} + +.custom-control { + .custom-control-input[disabled=disabled] { + & + .custom-control-label { + // Disabled label for checkbox, radio, + // switch bootstrap form components + color: gray("600")!important; + } + } +} + +.custom-switch { + width: 28px; + height: 16px; +} + +.custom-switch +.custom-control-input:focus ~ .custom-control-label::before { + box-shadow: none; +} + +.custom-switch +.custom-control-input ~ .custom-control-label::after { + border: 1px solid $gray-20; + background: $white; +} + +.custom-control-input:focus:not(:checked) ~ .custom-control-label::before { + border-color: $gray-20; +} + +.custom-control-input:not(:disabled):active ~ .custom-control-label::before { + background-color: rgba(4, 10, 15, 0.3); + border-color: $gray-20; +} + +.custom-switch +.custom-control-input ~ .custom-control-label::before { + border: 1px solid $gray-20; + background: rgba(4, 10, 15, 0.3); +} + +.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%; + } +} +// For invisible label of fields +.invisible { + height: 0; + margin-bottom: 0; +} +// Global style progress bar +.progress { + height: 8px; + background-color: $gray-10; +} + +.progress-bar { + background-color: $red; + border-radius: 4px; +} + +.progress_bar_percent { + color: $red; +} + +.system-control__radio label { + padding-top: 4px; +} + +@media (min-width: 768px) { + .search-block { + position: absolute; + top: 4.3rem; + right: $spacer; + justify-content: end; + } +} +@media (max-width: 768px) { + .date-filter { + display: flex; + flex-direction: column; + } +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_index.scss b/src/assets/styles/bmc/_sila/_index.scss new file mode 100644 index 00000000..9483b3b4 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_index.scss @@ -0,0 +1,25 @@ +// 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 "./chart"; +@import "./card"; +@import "./dropdown"; +@import "./forms"; +@import "./kvm"; +@import "./modal"; +@import "./pagination"; +@import "./section-divider"; +@import "./sol"; +@import "./tables"; +@import "./toasts"; +@import "./pages/login"; +@import "./pages/virtual-media"; +@import "./pages/firmware"; +@import "./pages/inventory"; +@import "./pages/power-operations"; +@import "./pages/security"; diff --git a/src/assets/styles/bmc/_sila/_kvm.scss b/src/assets/styles/bmc/_sila/_kvm.scss new file mode 100644 index 00000000..8276b8ea --- /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; + } +} diff --git a/src/assets/styles/bmc/_sila/_modal.scss b/src/assets/styles/bmc/_sila/_modal.scss new file mode 100644 index 00000000..5f40d5e1 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_modal.scss @@ -0,0 +1,77 @@ +.modal-dialog { + margin: 20vh auto 0; +} + +.modal-content { + border-radius: $border-radius; +} + +.modal-header { + border-bottom: none; + + .close { + padding: 0 1.4rem 0 0; + margin-top: -2rem; + font-weight: normal; + color: theme-color("dark"); + opacity: 1; + &:hover { + background-color: none; + } + } + .modal-title { + font-size: 1.25rem !important; + font-weight: 500; + line-height: 1.3; + } +} + +.modal-header { + align-items: center; +} + +.modal-body { + display: flex; + justify-content: flex-start; +} + +.file-input_container > +.custom-file { + height: 100%; +} + + .custom-file-input ~ .custom-file-label { + background-color: transparent; + border: 1px dashed $text-tretiatry; + box-sizing: border-box; + border-radius: $border-radius; + height: 100%; + display: flex; + align-items: center; + text-align: center; + white-space: normal; + justify-content: center; +} + + +.custom-file-input ~ .custom-file-label::after { + display: none; +} + +#modal-reset { + .modal-body { + flex-direction: column; + } + .modal-footer { + flex-wrap: nowrap; + } +} + +#modal-dns, +#configure-connection { + .modal-body { + > form { + width: 100%; + } + } +} diff --git a/src/assets/styles/bmc/_sila/_pagination.scss b/src/assets/styles/bmc/_sila/_pagination.scss new file mode 100644 index 00000000..97fe0132 --- /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"); + } +} 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..e30942cc --- /dev/null +++ b/src/assets/styles/bmc/_sila/_section-divider.scss @@ -0,0 +1,56 @@ +.section-divider { + border-bottom: 1px solid gray('400'); +} + +.collapse-divider { + margin: 0 -1.95rem 0 -2rem; + padding-left: 2rem; + @include media-breakpoint-down(md) { + margin-left: -0.95rem; + } + .page-section { + margin-left: 2rem; + margin-right: 2rem; + @include media-breakpoint-down(md) { + margin-left: 2rem; + } + } + .form-group { + margin-bottom: 0; + .col-form-label { + white-space: nowrap; + } + } +} + +.page-divider { + height: 56px; + max-width: 150%; + width: auto; + margin: 0rem -1.95rem 0rem -1.95rem; + padding-left: 2rem; + @include media-breakpoint-down(md) { + padding-left: 1rem; + padding-right: 1rem; + margin-left: -0.95rem; + } + border-bottom: 1px solid $gray-10; + gap: 8px; +} + +.limit-container { + margin: 0rem -1.95rem 0rem -1.95rem; + padding-left: 1rem; + padding-bottom: 1rem; + @include media-breakpoint-down(md) { + padding-left: 0rem; + padding-right: 1rem; + margin-left: -0.95rem; + } + .form-group { + margin-bottom: 0; + .col-form-label { + white-space: nowrap; + } + } +} diff --git a/src/assets/styles/bmc/_sila/_sol.scss b/src/assets/styles/bmc/_sila/_sol.scss new file mode 100644 index 00000000..3641c040 --- /dev/null +++ b/src/assets/styles/bmc/_sila/_sol.scss @@ -0,0 +1,3 @@ +#terminal .xterm .xterm-viewport { + overflow: auto; +} diff --git a/src/assets/styles/bmc/_sila/_tables.scss b/src/assets/styles/bmc/_sila/_tables.scss new file mode 100644 index 00000000..7dca75dc --- /dev/null +++ b/src/assets/styles/bmc/_sila/_tables.scss @@ -0,0 +1,308 @@ +.table { + position: relative; + z-index: $zindex-dropdown; + border: 1px solid $gray-30; + border-radius: $border-radius; + border-collapse: separate; + border-spacing: 0px; + + &.b-table > thead > tr { + &>[aria-sort='ascending'] { + background-image: url('~@/assets/images/_sila/sort-ascending.svg'); + background-size: 17px; + + } + &>[aria-sort='descending'], + [aria-sort='none'] { + background-image: url('~@/assets/images/_sila/sort-descending.svg'); + background-size: 14px; + } + } + + thead { + th { + border: none; + &:first-child { + border-radius: $border-radius 0 0 0; + } + &:last-child { + border-radius: 0 $border-radius 0 0; + } + } + } + + tbody { + tr { + border: none; + &:first-child { + border-radius: 0 0 0 $border-radius; + } + &:last-child { + border-radius: 0 0 $border-radius 0; + } + &.b-table-details:hover { + background-color: transparent; + } + } + } + + td { + vertical-align: middle; + font-size: 0.875rem; + // 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 { + background-color: $gray-10; + vertical-align: middle; + text-transform: uppercase; + color: $text-primary; + &:focus { + outline: none; + } + & > div { + font-size: 0.875rem !important; + } + } + + .custom-switch { + width: 100%; + } + + .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 { + border-top: 1px solid rgba(26,62,91,.3); + background-color: inherit; + td { + padding-left: $table-cell-padding; + padding-right: $table-cell-padding; + } + dl { + margin: 0; + } + dt { + font-size: 0.75rem !important; + float: left; + clear: left; + margin-right: $spacer / 2; + } + dd { + font-size: 0.75rem !important; + line-height: 1.5 + } + } + + .table-row-expand { + width: 50px; + .btn { + padding: 0; + width: 40px; + } + svg { + fill: theme-color("dark"); + } + } + + .b-table-sort-icon-left { + background-position: left calc(1.1rem / 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: $gray-5-hover; + } + } +} + +.table-hover tbody tr:hover { + background-color: $gray-5; +} + +.b-table-sticky-header td { + border-top: none; +} + +.table-responsive, .table-responsive-md { + margin-top: 0.5rem; +} + +// Table stacked style for small screen only +@include media-breakpoint-down(xs) { + .b-table-stacked-sm { + border: 1px solid $gray-10; + + tr { + + &:not(:first-child) > td[aria-colindex='1'] { + // border-top: 1px solid $gray-10; + 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-accessory { + border: none; + thead th { + border-bottom: 1px solid $gray-30; + background-color: transparent !important; + padding: 5px 0; + } + + tbody tr td { + border: none; + padding: 2px; + } + td { + font-size: 0.745rem; + color: $text-tretiatry; + + } + .thead-light th > div { + font-size: 0.755rem !important; + } + + // @include media-breakpoint-down(xs) { + // .b-table-stacked-sm { + // border: 1px solid $gray-10; + + // tr { + + // &:not(:first-child) > td[aria-colindex='1'] { + // 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; + // } + // } + // } +} + +.item-color { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; +}
\ No newline at end of file diff --git a/src/assets/styles/bmc/_sila/_toasts.scss b/src/assets/styles/bmc/_sila/_toasts.scss new file mode 100644 index 00000000..482765ce --- /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; +} diff --git a/src/assets/styles/bmc/_sila/pages/_firmware.scss b/src/assets/styles/bmc/_sila/pages/_firmware.scss new file mode 100644 index 00000000..de65dc10 --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_firmware.scss @@ -0,0 +1,13 @@ + +// +// Firmware Update: +// +.firmware-update .btn, +.firmware-update label { + width: 100%; +} +.firmware-update .form-background { + padding: $spacer * 2 !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_sila/pages/_inventory.scss b/src/assets/styles/bmc/_sila/pages/_inventory.scss new file mode 100644 index 00000000..6f065ad4 --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_inventory.scss @@ -0,0 +1,9 @@ + +// +// Service Indicators: +// +.service-indicators .form-background { + padding: $spacer * 2 !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_sila/pages/_login.scss b/src/assets/styles/bmc/_sila/pages/_login.scss new file mode 100644 index 00000000..e3abde78 --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_login.scss @@ -0,0 +1,47 @@ + +// +// Login Page: +// +.login-form { + background-color: transparent !important; +} + +.login-form > .btn-primary { + width: 100%; + margin-top: 0.5rem !important; +} + +.login-form > .form-group { + margin-bottom: 1.5rem; + display: flow-root; +} + +.login-form > .form-group > div > input, +.login-form > .form-group > div > select, +.input-password-toggle-container > input { + margin: -28px 0 0 0; + padding-top: 28px; + height: 56px; +} + +.login-form > .form-group > label, +.login-form__section > label { + margin-left: 10px; + margin-bottom: 0.25rem; + color: $text-tretiatry +} + +.login-main > h1 { + font-weight: bold; +} + +.login-main .h3 { + font-weight: 400 !important; + font-size: 1.6rem !important; +} + +@media (max-width: 768px) { + .login-brand { + margin-top: 1rem; + } +} diff --git a/src/assets/styles/bmc/_sila/pages/_power-operations.scss b/src/assets/styles/bmc/_sila/pages/_power-operations.scss new file mode 100644 index 00000000..0921323e --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_power-operations.scss @@ -0,0 +1,9 @@ + +// +// Power Operations: +// +.boot-settings .form-background { + padding: $spacer * 2 $spacer * 2 $spacer !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_sila/pages/_security.scss b/src/assets/styles/bmc/_sila/pages/_security.scss new file mode 100644 index 00000000..45473a53 --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_security.scss @@ -0,0 +1,9 @@ + +// +// Security and Access: +// +.ldap-settings .form-background { + padding: $spacer * 2 $spacer * 2 $spacer !important; + border-radius: $border-radius; + background-color: $surface-secondary !important; +} diff --git a/src/assets/styles/bmc/_sila/pages/_virtual-media.scss b/src/assets/styles/bmc/_sila/pages/_virtual-media.scss new file mode 100644 index 00000000..040e57b1 --- /dev/null +++ b/src/assets/styles/bmc/_sila/pages/_virtual-media.scss @@ -0,0 +1,47 @@ + +// +// Media +// +.media-start { + margin-top: $spacer * 2; +} + +.media-buttons-group >.media-start label { + width: 100%; +} + +.media-buttons-group >.media-start .btn { + width: 50%; +} + +.media-buttons-group >.media-stop > .btn { + width: 50%; +} + + +// +// Media Legacy +// +.media-legacy label { + margin-top: $spacer * 2; +} + +.media-legacy-buttons-group { + margin: 0px; +} + +.media-legacy-buttons-group .btn { + width: 100%; +} + +.media-legacy-buttons-group > .media-legacy-configure { + display: block; + width: 50%; + margin-bottom: $spacer * 2; +} + +.media-legacy-buttons-group > .media-legacy-start { + display: inline-block; + width: 50%; + margin-bottom: - $spacer * 2; +} diff --git a/src/assets/styles/bmc/helpers/_variables.scss b/src/assets/styles/bmc/helpers/_variables.scss index ba32e3bd..fe57012b 100644 --- a/src/assets/styles/bmc/helpers/_variables.scss +++ b/src/assets/styles/bmc/helpers/_variables.scss @@ -14,3 +14,11 @@ $form-line-height: 1.25rem; $box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.3); $focus-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + +$grid-breakpoints: ( + xs: 0, + sm: 576px, + md: 768px, + lg: 992px, + xl: 1920px +);
\ No newline at end of file diff --git a/src/components/_ibs/AppHeader/AppHeader.vue b/src/components/_ibs/AppHeader/AppHeader.vue new file mode 100644 index 00000000..e6791f0a --- /dev/null +++ b/src/components/_ibs/AppHeader/AppHeader.vue @@ -0,0 +1,395 @@ +<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/_ibs/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/_ibs/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/_ibs/Global/StatusIcon'; +import LoadingBar from '@/components/_ibs/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'); + overflow-x: hidden; + white-space: nowrap; + + .nav-link { + margin-top: -2px; + } + } + + .nav-item:last-of-type { + overflow-x: visible; + } + + .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: $navbar-color; + 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 * 1.5; + 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 { + flex-flow: row wrap; + @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/_ibs/AppHeader/index.js b/src/components/_ibs/AppHeader/index.js new file mode 100644 index 00000000..e180e80f --- /dev/null +++ b/src/components/_ibs/AppHeader/index.js @@ -0,0 +1,2 @@ +import AppHeader from './AppHeader'; +export default AppHeader; diff --git a/src/components/_ibs/AppNavigation/AppNavigation.vue b/src/components/_ibs/AppNavigation/AppNavigation.vue new file mode 100644 index 00000000..acfabe76 --- /dev/null +++ b/src/components/_ibs/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/_ibs/AppNavigation/AppNavigationMixin.js b/src/components/_ibs/AppNavigation/AppNavigationMixin.js new file mode 100644 index 00000000..bbbbb1ee --- /dev/null +++ b/src/components/_ibs/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/_ibs/AppNavigation/index.js b/src/components/_ibs/AppNavigation/index.js new file mode 100644 index 00000000..88fe8eb6 --- /dev/null +++ b/src/components/_ibs/AppNavigation/index.js @@ -0,0 +1,2 @@ +import AppNavigation from './AppNavigation'; +export default AppNavigation; diff --git a/src/components/_ibs/Global/Alert.vue b/src/components/_ibs/Global/Alert.vue new file mode 100644 index 00000000..a3f31f61 --- /dev/null +++ b/src/components/_ibs/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/_ibs/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/_ibs/Global/ButtonBackToTop.vue b/src/components/_ibs/Global/ButtonBackToTop.vue new file mode 100644 index 00000000..26c3688b --- /dev/null +++ b/src/components/_ibs/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: $spacer; + right: $spacer * 2; + + box-shadow: $box-shadow; + visibility: hidden; + opacity: 0; + transition: $transition-base; + z-index: $zindex-fixed; + + @include media-breakpoint-up($responsive-layout-bp) { + left: auto; + right: $spacer * 3; + } +} +.show-btn { + visibility: visible; + opacity: 1; +} +</style> diff --git a/src/components/_ibs/Global/FormFile.vue b/src/components/_ibs/Global/FormFile.vue new file mode 100644 index 00000000..cf713acf --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/InfoTooltip.vue b/src/components/_ibs/Global/InfoTooltip.vue new file mode 100644 index 00000000..c91109d1 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/InputPasswordToggle.vue b/src/components/_ibs/Global/InputPasswordToggle.vue new file mode 100644 index 00000000..d2c0d4a6 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/LoadingBar.vue b/src/components/_ibs/Global/LoadingBar.vue new file mode 100644 index 00000000..0e9551b5 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/PageContainer.vue b/src/components/_ibs/Global/PageContainer.vue new file mode 100644 index 00000000..ac1de332 --- /dev/null +++ b/src/components/_ibs/Global/PageContainer.vue @@ -0,0 +1,38 @@ +<template> + <main id="main-content" class="page-container"> + <slot /> + </main> +</template> + +<script> +import JumpLinkMixin from '@/components/_ibs/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; + padding-right: $spacer * 2; + } +} +</style> diff --git a/src/components/_ibs/Global/PageSection.vue b/src/components/_ibs/Global/PageSection.vue new file mode 100644 index 00000000..dd39ddd5 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/PageTitle.vue b/src/components/_ibs/Global/PageTitle.vue new file mode 100644 index 00000000..45c75edb --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/Search.vue b/src/components/_ibs/Global/Search.vue new file mode 100644 index 00000000..ac8f9bfb --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/StatusIcon.vue b/src/components/_ibs/Global/StatusIcon.vue new file mode 100644 index 00000000..4552633e --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/TableCellCount.vue b/src/components/_ibs/Global/TableCellCount.vue new file mode 100644 index 00000000..acb4d443 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/TableDateFilter.vue b/src/components/_ibs/Global/TableDateFilter.vue new file mode 100644 index 00000000..cbb8cb1f --- /dev/null +++ b/src/components/_ibs/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/_ibs/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/_ibs/Global/TableFilter.vue b/src/components/_ibs/Global/TableFilter.vue new file mode 100644 index 00000000..7c66bea6 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/TableRowAction.vue b/src/components/_ibs/Global/TableRowAction.vue new file mode 100644 index 00000000..549f1b52 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/TableToolbar.vue b/src/components/_ibs/Global/TableToolbar.vue new file mode 100644 index 00000000..5235feae --- /dev/null +++ b/src/components/_ibs/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/_ibs/Global/TableToolbarExport.vue b/src/components/_ibs/Global/TableToolbarExport.vue new file mode 100644 index 00000000..69646ea6 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/BVPaginationMixin.js b/src/components/_ibs/Mixins/BVPaginationMixin.js new file mode 100644 index 00000000..4ccf6f2c --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/BVTableSelectableMixin.js b/src/components/_ibs/Mixins/BVTableSelectableMixin.js new file mode 100644 index 00000000..b4f0b953 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/BVToastMixin.js b/src/components/_ibs/Mixins/BVToastMixin.js new file mode 100644 index 00000000..a04ef438 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/DataFormatterMixin.js b/src/components/_ibs/Mixins/DataFormatterMixin.js new file mode 100644 index 00000000..5ce79327 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/JumpLinkMixin.js b/src/components/_ibs/Mixins/JumpLinkMixin.js new file mode 100644 index 00000000..b038527b --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/LoadingBarMixin.js b/src/components/_ibs/Mixins/LoadingBarMixin.js new file mode 100644 index 00000000..d1152703 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/LocalTimezoneLabelMixin.js b/src/components/_ibs/Mixins/LocalTimezoneLabelMixin.js new file mode 100644 index 00000000..6b4141c6 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/SearchFilterMixin.js b/src/components/_ibs/Mixins/SearchFilterMixin.js new file mode 100644 index 00000000..a4819e26 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/TableFilterMixin.js b/src/components/_ibs/Mixins/TableFilterMixin.js new file mode 100644 index 00000000..7a2cc540 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/TableRowExpandMixin.js b/src/components/_ibs/Mixins/TableRowExpandMixin.js new file mode 100644 index 00000000..7f815a46 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/TableSortMixin.js b/src/components/_ibs/Mixins/TableSortMixin.js new file mode 100644 index 00000000..c0997350 --- /dev/null +++ b/src/components/_ibs/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/_ibs/Mixins/VuelidateMixin.js b/src/components/_ibs/Mixins/VuelidateMixin.js new file mode 100644 index 00000000..fec85251 --- /dev/null +++ b/src/components/_ibs/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/components/_sila/AppHeader/AppHeader.vue b/src/components/_sila/AppHeader/AppHeader.vue new file mode 100644 index 00000000..165d744e --- /dev/null +++ b/src/components/_sila/AppHeader/AppHeader.vue @@ -0,0 +1,362 @@ +<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/_sila/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"> + <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; + } + } + + .navbar-nav { + @include media-breakpoint-up($responsive-layout-bp) { + padding: 0 $spacer; + } + align-items: center; + & > a:first-child { + display: contents; + } + .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..063e401e --- /dev/null +++ b/src/components/_sila/AppNavigation/AppNavigation.vue @@ -0,0 +1,269 @@ +<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}`" + > + {{ 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}`" + > + {{ 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; + } + & li:first-child { + border-top: 1px solid $gray-20; + } +} + +.nav-item__nav { + list-style: none; + padding-left: 0; + margin-left: 0; + background-color: $gray-5; + + .nav-item { + outline: none; + & > a { + border-bottom: 1px solid $gray-20; + padding-top: 0.7rem; + padding-left: 2.4rem; + } + } + + .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-item > button { + border-bottom: 1px solid $gray-20; + font-weight: 500; + padding-top: 0.7rem; + padding-bottom: 0.7rem; +} + +.nav-container { + position: fixed; + width: $navigation-width; + top: $header-height; + bottom: 0; + left: 0; + z-index: $zindex-fixed; + overflow-y: auto; + background-color: $white; + 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/Chart.vue b/src/components/_sila/Global/Chart.vue new file mode 100644 index 00000000..cfe4e372 --- /dev/null +++ b/src/components/_sila/Global/Chart.vue @@ -0,0 +1,304 @@ +<template> + <highcharts :options="chartOptions" /> +</template> + +<script> +import { Chart } from 'highcharts-vue'; + +export default { + components: { + highcharts: Chart, + }, + props: { + data: { + type: Array, + default: () => [], + }, + colors: { + type: Array, + default: () => [], + }, + type: { + type: String, + default: '', + }, + timeScale: { + type: String, + default: null, + }, + warning: { + type: Number, + default: null, + }, + shutdown: { + type: Number, + default: null, + }, + notNormal: { + type: Number, + default: null, + }, + critical: { + type: Number, + default: null, + }, + }, + data() { + return { + categories: null, + minRange: null, + yMax: null, + minTickInterval: null, + plotBands: null, + plotLines: [ + { + color: '#E11717', + dashStyle: 'solid', + value: this.warning, + zIndex: '1000', + width: 2, + label: { + text: 'Пороговое значение предупреждения', + align: 'right', + style: { + fontFamily: 'Inter, sans-serif', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '16px', + color: '#0C1C2999', + }, + }, + }, + ], + }; + }, + computed: { + readyData() { + let filteredData = this.data.filter((metric) => { + return metric.MetricValue !== 'nan'; + }); + + filteredData.sort((a, b) => { + return new Date(a.Timestamp) - new Date(b.Timestamp); + }); + + let transform = filteredData.map((metric) => { + let date = new Date(metric.Timestamp); + let time = + date.getHours() + ':' + String(date.getMinutes()).padStart(2, '0'); + return { + ...metric, + Timestamp: time, + MetricValue: Math.round(metric.MetricValue), + }; + }); + + return transform; + }, + step() { + return this.timeScale === 'hour' ? 10 : 60; + }, + metricData() { + let group = this.readyData.reduce(function (rv, x) { + (rv[x['MetricProperty']] = rv[x['MetricProperty']] || []).push(x); + return rv; + }, {}); + + let metricArr = Object.keys(group).map((key, index) => { + let groupTime = group[key].reduce(function (rv, x) { + (rv[x['Timestamp']] = rv[x['Timestamp']] || []).push(x); + return rv; + }, {}); + + let arr = Object.keys(groupTime).map((key) => { + const findAverage = (arr) => { + const { length } = arr; + return Math.round( + arr.reduce((acc, val) => { + return acc + val.MetricValue / length; + }, 0) + ); + }; + return findAverage(groupTime[key]); + }); + + return { + name: key, + data: arr, + color: this.colors[index], + }; + }); + + return metricArr; + }, + + metricTime() { + let timeGroup = this.readyData.reduce(function (rv, x) { + (rv[x['Timestamp']] = rv[x['Timestamp']] || []).push(x); + return rv; + }, {}); + + let timeArr = Object.keys(timeGroup); + + return timeArr; + }, + + chartOptions() { + return { + chart: { + type: 'spline', + margin: [12, 50, 32, 60], + height: '320px', + }, + title: null, + xAxis: { + categories: this.metricTime, + title: null, + labels: { + step: this.step, + }, + minorGridLineColor: '#1A3E5B1A', + }, + yAxis: { + categories: this.categories, + min: 0, + max: this.yMax, + title: null, + minRange: this.minRange, + minTickInterval: this.minTickInterval, + minorGridLineColor: '#1A3E5B1A', + plotLines: this.plotLines, + plotBands: this.plotBands, + }, + series: this.metricData.map((item) => ({ + ...item, + marker: { + enabled: false, + }, + })), + legend: { + enabled: false, + }, + tooltip: { + enabled: false, + crosshairs: false, + }, + plotOptions: { + series: { + showInLegend: true, + }, + }, + }; + }, + }, + + async created() { + this.setOptions(); + }, + + methods: { + setOptions() { + switch (this.type) { + case 'fans': + this.categories = this.setSpeed(4000); + this.minRange = 4000; + this.minTickInterval = 1000; + this.plotLines.push({ + color: '#1A3E5B', + dashStyle: 'solid', + value: this.shutdown, + width: 2, + label: { + text: 'Пороговое значения отказ', + align: 'right', + style: { + fontFamily: 'Inter', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '16px', + color: '#0C1C2999', + }, + }, + }); + break; + case 'memory': + this.categories = this.setCategories(101, 'С°'); + this.yMax = 100; + this.minTickInterval = 25; + this.plotBands = [ + { + color: '#F0AC0C1A', + dashStyle: 'solid', + from: this.notNormal, + to: this.critical, + }, + { + color: '#FF41411A', + dashStyle: 'solid', + from: this.critical, + to: this.warning, + }, + ]; + break; + case 'processors': + this.categories = this.setCategories(101, 'С°'); + this.yMax = 100; + this.minTickInterval = 25; + this.plotBands = [ + { + color: '#F0AC0C1A', + dashStyle: 'solid', + from: this.notNormal, + to: this.critical, + }, + { + color: '#FF41411A', + dashStyle: 'solid', + from: this.critical, + to: this.warning, + }, + ]; + break; + case 'motherboard': + this.categories = this.setCategories(101, 'С°'); + this.yMax = 100; + this.minTickInterval = 25; + break; + case 'power': + this.categories = this.setCategories(101, 'Вт'); + this.yMax = 100; + this.minTickInterval = 25; + this.plotLines.push({ + color: '#1A3E5B', + dashStyle: 'solid', + value: this.shutdown, + width: 2, + label: { + text: 'Пороговое значения отказ', + align: 'right', + style: { + fontFamily: 'Inter', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '16px', + color: '#0C1C2999', + }, + }, + }); + break; + } + }, + setCategories(count, desc) { + const arr = [...new Array(count)].map((i, k) => `${k} ${desc}`); + return arr; + }, + setSpeed(count) { + const arr = [...new Array(count)].map((i, k) => `${k}`); + return arr; + }, + }, +}; +</script> diff --git a/src/components/_sila/Global/Collapse.vue b/src/components/_sila/Global/Collapse.vue new file mode 100644 index 00000000..f004c955 --- /dev/null +++ b/src/components/_sila/Global/Collapse.vue @@ -0,0 +1,46 @@ +<template> + <div class="collapse-divider"> + <b-button + v-b-toggle="id" + variant="collapse" + class="d-flex flex-nowrap justify-content-start" + @click="onClick" + > + <slot name="image"></slot> + {{ title }} + <component :is="iconChevronUp" class="icon-expand" /> + </b-button> + <b-collapse :id="id"> + <slot></slot> + </b-collapse> + </div> +</template> +<script> +import iconChevronUp from '@carbon/icons-vue/es/chevron--up/20'; + +export default { + name: 'Collapse', + props: { + id: { + type: String, + default: null, + }, + title: { + type: String, + default: null, + }, + }, + data() { + return { + opened: false, + iconChevronUp: iconChevronUp, + }; + }, + methods: { + onClick() { + this.opened = !this.opened; + this.$emit('opened', this.opened); + }, + }, +}; +</script> diff --git a/src/components/_sila/Global/FormFile.vue b/src/components/_sila/Global/FormFile.vue new file mode 100644 index 00000000..ee93ba49 --- /dev/null +++ b/src/components/_sila/Global/FormFile.vue @@ -0,0 +1,145 @@ +<template> + <div class="custom-form-file-container"> + <label> + <b-modal + :id="`modal-${id}`" + body-class="p-0 justify-content-center" + :title="$t('global.fileUpload.modal')" + hide-footer + > + <b-col class="p-3 file-input_container"> + <b-form-file + :id="id" + v-model="file" + :accept="accept" + :disabled="disabled" + :state="state" + :placeholder="$t('global.fileUpload.placeholder')" + :drop-placeholder="$t('global.fileUpload.dropPlaceholder')" + @input="$emit('input', file)" + > + </b-form-file> + </b-col> + </b-modal> + <b-button + class="add-file-btn" + :class="{ + disabled, + 'btn-secondary': isSecondary, + 'btn-primary': !isSecondary, + }" + @click="$bvModal.show(`modal-${id}`)" + > + {{ $t('global.fileUpload.browseText') }} + </b-button> + <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'; + }, + }, + watch: { + file() { + if (this.file) { + this.$bvModal.hide(`modal-${this.id}`); + } else { + this.$emit('input', this.file); + } + }, + }, +}; +</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'); + } + } +} + +.file-input_container { + width: 100%; + height: 46vh; + background-color: $surface-secondary; + border-radius: 0 0 16px 16px; +} +</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..f86649fe --- /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; +} + +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..6f02ed8d --- /dev/null +++ b/src/components/_sila/Global/PageTitle.vue @@ -0,0 +1,38 @@ +<template> + <div class="page-title"> + <h1>{{ title }}</h1> + <p v-if="description">{{ description }}</p> + <div class="line"></div> + </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; + margin-bottom: 0; +} +.line { + border-bottom: 1px solid $gray-10; + margin: $spacer (-$spacer * 1.95) 0 (-$spacer * 2); +} +</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..77ff2976 --- /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--square/20'; +import IconCheckmark from '@carbon/icons-vue/es/checkmark--outline/20'; +import IconWarning from '@carbon/icons-vue/es/warning--alt/20'; +import IconError from '@carbon/icons-vue/es/error--outline/20'; +import IconMisuse from '@carbon/icons-vue/es/misuse--outline/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/TableDatePicker.vue b/src/components/_sila/Global/TableDatePicker.vue new file mode 100644 index 00000000..0eef0f8c --- /dev/null +++ b/src/components/_sila/Global/TableDatePicker.vue @@ -0,0 +1,50 @@ +<template> + <b-col class="d-flex align-items-center date-picker"> + <span + :class="{ 'switch-active': timeScale === 'hour' }" + @click="switchTimeScale('hour')" + >{{ $t('global.datePicker.lastHour') }}</span + > + <span + :class="{ 'switch-active': timeScale === 'day' }" + @click="switchTimeScale('day')" + >{{ $t('global.datePicker.lastDay') }}</span + > + </b-col> +</template> + +<script> +export default { + props: { + timeScale: { + type: String, + default: 'hour', + }, + switchTimeScale: { + type: Function, + required: true, + }, + }, +}; +</script> +<style lang="scss" scoped> +.date-picker { + height: 48px; + max-width: 150%; + width: auto; + margin: -2rem -1.95rem 0px -2rem; + padding-left: 2rem; + @include media-breakpoint-down(md) { + padding-left: 1rem; + padding-right: 1rem; + margin-left: -0.95rem; + } + border-bottom: 1px solid $gray-10; + gap: 24px; +} + +.switch-active { + color: $primary; + transition: ease-in 0.15s; +} +</style> 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/fonts/Inter/Inter-Black.woff2 b/src/env/assets/fonts/Inter/Inter-Black.woff2 Binary files differnew file mode 100644 index 00000000..0c0ce2a0 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Black.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-BlackItalic.woff2 b/src/env/assets/fonts/Inter/Inter-BlackItalic.woff2 Binary files differnew file mode 100644 index 00000000..f8b95db3 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-BlackItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Bold.woff2 b/src/env/assets/fonts/Inter/Inter-Bold.woff2 Binary files differnew file mode 100644 index 00000000..0e3afead --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Bold.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-BoldItalic.woff2 b/src/env/assets/fonts/Inter/Inter-BoldItalic.woff2 Binary files differnew file mode 100644 index 00000000..b222b218 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-BoldItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-ExtraBold.woff2 b/src/env/assets/fonts/Inter/Inter-ExtraBold.woff2 Binary files differnew file mode 100644 index 00000000..6ae29e8e --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-ExtraBold.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-ExtraBoldItalic.woff2 b/src/env/assets/fonts/Inter/Inter-ExtraBoldItalic.woff2 Binary files differnew file mode 100644 index 00000000..90d7f7f9 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-ExtraBoldItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-ExtraLight.woff2 b/src/env/assets/fonts/Inter/Inter-ExtraLight.woff2 Binary files differnew file mode 100644 index 00000000..e783e0db --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-ExtraLight.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-ExtraLightItalic.woff2 b/src/env/assets/fonts/Inter/Inter-ExtraLightItalic.woff2 Binary files differnew file mode 100644 index 00000000..807436e7 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-ExtraLightItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Italic.woff2 b/src/env/assets/fonts/Inter/Inter-Italic.woff2 Binary files differnew file mode 100644 index 00000000..180c134f --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Italic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Light.woff2 b/src/env/assets/fonts/Inter/Inter-Light.woff2 Binary files differnew file mode 100644 index 00000000..06318982 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Light.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-LightItalic.woff2 b/src/env/assets/fonts/Inter/Inter-LightItalic.woff2 Binary files differnew file mode 100644 index 00000000..923d3019 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-LightItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Medium.woff2 b/src/env/assets/fonts/Inter/Inter-Medium.woff2 Binary files differnew file mode 100644 index 00000000..4273a1b9 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Medium.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-MediumItalic.woff2 b/src/env/assets/fonts/Inter/Inter-MediumItalic.woff2 Binary files differnew file mode 100644 index 00000000..ab1ae164 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-MediumItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Regular.woff2 b/src/env/assets/fonts/Inter/Inter-Regular.woff2 Binary files differnew file mode 100644 index 00000000..d65a1258 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Regular.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-SemiBold.woff2 b/src/env/assets/fonts/Inter/Inter-SemiBold.woff2 Binary files differnew file mode 100644 index 00000000..0fbf61a3 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-SemiBold.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-SemiBoldItalic.woff2 b/src/env/assets/fonts/Inter/Inter-SemiBoldItalic.woff2 Binary files differnew file mode 100644 index 00000000..2121376f --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-SemiBoldItalic.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-Thin.woff2 b/src/env/assets/fonts/Inter/Inter-Thin.woff2 Binary files differnew file mode 100644 index 00000000..a74e113b --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-Thin.woff2 diff --git a/src/env/assets/fonts/Inter/Inter-ThinItalic.woff2 b/src/env/assets/fonts/Inter/Inter-ThinItalic.woff2 Binary files differnew file mode 100644 index 00000000..3bb9d9a8 --- /dev/null +++ b/src/env/assets/fonts/Inter/Inter-ThinItalic.woff2 diff --git a/src/env/assets/styles/_ibs.scss b/src/env/assets/styles/_ibs.scss new file mode 100644 index 00000000..a292b0f4 --- /dev/null +++ b/src/env/assets/styles/_ibs.scss @@ -0,0 +1,104 @@ +@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; + +// +// botstrap-vue overrides: +// +.table-responsive { + border-radius: 10px; + border: 1px solid rgba(12,62,91,.3); +} +[class*="table-responsive-"] { + overflow: auto; + border-radius: 10px; + border: 1px solid rgba(12,62,91,.3); +} diff --git a/src/env/assets/styles/_sila.scss b/src/env/assets/styles/_sila.scss new file mode 100644 index 00000000..ba4c3223 --- /dev/null +++ b/src/env/assets/styles/_sila.scss @@ -0,0 +1,95 @@ +@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: rgb(251, 251, 252); +$gray-5: rgba(26, 62, 91, 0.05); +$gray-5-hover: rgba(20, 39, 53, 0.1); +$gray-10: rgba(26, 62, 91, 0.1); +$gray-20: rgba(26, 62, 92, 0.2); +$gray-30: rgba(26, 62, 91, 0.3); +$red-40: rgba(225, 23, 23, 0.4); + +$surface-secondary: #f3f4f5; +$on-surface-secondary: #040a0f99; +$on-surface-tretiatry: #040a0f4d; + +$text-primary: rgb(12, 28, 41); +$text-secondary: rgba(12, 28, 41, 0.8); +$text-tretiatry: rgba(12, 28, 41, 0.6); +$text-quaternary: rgba(12, 28, 41, 0.3); + +$login-page-description-color: rgba(12, 28, 41, 0.6); + +$border-radius: 8px; + +$loading-color: #c11d1d; +$navbar-color: $dark; +$header-height: 56px; +$navigation-width: 280px; diff --git a/src/env/components/AppNavigation/ibs.js b/src/env/components/AppNavigation/ibs.js new file mode 100644 index 00000000..bbbbb1ee --- /dev/null +++ b/src/env/components/AppNavigation/ibs.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/components/AppNavigation/sila.js b/src/env/components/AppNavigation/sila.js new file mode 100644 index 00000000..3319368b --- /dev/null +++ b/src/env/components/AppNavigation/sila.js @@ -0,0 +1,216 @@ +import IconChevronUp from '@carbon/icons-vue/es/chevron--up/16'; + +const AppNavigationMixin = { + components: { + iconExpand: IconChevronUp, + }, + data() { + return { + navigationItems: [ + { + id: 'overview', + label: this.$t('appNavigation.system'), + children: [ + { + id: 'overview', + label: this.$t('appNavigation.overview'), + route: '/', + }, + { + id: 'inventory', + label: this.$t('appNavigation.inventory'), + route: '/hardware-inventory', + }, + { + id: 'network', + label: this.$t('appNavigation.network'), + route: '/network', + }, + { + id: 'date-time', + label: this.$t('appNavigation.dateTime'), + route: '/date-time', + }, + ], + }, + { + id: 'logs', + label: this.$t('appNavigation.logs'), + 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'), + children: [ + { + id: 'sensors', + label: this.$t('appNavigation.sensors'), + route: '/hardware-status/sensors', + }, + ], + }, + { + id: 'fans', + label: this.$t('appNavigation.fans'), + children: [ + { + id: 'fans/static', + label: this.$t('appNavigation.staticInfo'), + route: '/fans/static', + }, + { + id: 'fans/dynamic', + label: this.$t('appNavigation.dynamicInformation'), + route: '/fans/dynamic', + }, + ], + }, + { + id: 'motherboard', + label: this.$t('appNavigation.motherboard'), + children: [ + { + id: 'motherboard/dynamic', + label: this.$t('appNavigation.dynamicInformation'), + route: '/motherboard/dynamic', + }, + ], + }, + { + id: 'operations', + label: this.$t('appNavigation.operations'), + 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: 'memory', + label: this.$t('appNavigation.memory'), + children: [ + { + id: 'memory/dynamic', + label: this.$t('appNavigation.dynamicInformation'), + route: '/memory/dynamic', + }, + ], + }, + { + id: 'processors', + label: this.$t('appNavigation.processors'), + children: [ + { + id: 'processors/dynamic', + label: this.$t('appNavigation.dynamicInformation'), + route: '/processors/dynamic', + }, + ], + }, + { + id: 'settings', + label: this.$t('appNavigation.settings'), + children: [ + { + id: 'power-restore-policy', + label: this.$t('appNavigation.powerRestorePolicy'), + route: '/settings/power-restore-policy', + }, + ], + }, + { + id: 'security-and-access', + label: this.$t('appNavigation.securityAndAccess'), + 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'), + children: [ + { + id: 'power', + label: this.$t('appNavigation.power'), + route: '/resource-management/power', + }, + ], + }, + ], + }; + }, +}; + +export default AppNavigationMixin; diff --git a/src/env/router/ibs.js b/src/env/router/ibs.js new file mode 100644 index 00000000..1affac61 --- /dev/null +++ b/src/env/router/ibs.js @@ -0,0 +1,286 @@ +import AppLayout from '@/layouts/_ibs/AppLayout.vue'; +import ChangePassword from '@/views/_ibs/ChangePassword'; +import Sessions from '@/views/_ibs/SecurityAndAccess/Sessions'; +import ConsoleLayout from '@/layouts/_ibs/ConsoleLayout.vue'; +import DateTime from '@/views/_ibs/Settings/DateTime'; +import EventLogs from '@/views/_ibs/Logs/EventLogs'; +import Firmware from '@/views/_ibs/Operations/Firmware'; +import FactoryReset from '@/views/_ibs/Operations/FactoryReset'; +import Inventory from '@/views/_ibs/HardwareStatus/Inventory'; +import Kvm from '@/views/_ibs/Operations/Kvm'; +import KvmConsole from '@/views/_ibs/Operations/Kvm/KvmConsole'; +import Ldap from '@/views/_ibs/SecurityAndAccess/Ldap'; +import UserManagement from '@/views/_ibs/SecurityAndAccess/UserManagement'; +import Login from '@/views/_ibs/Login'; +import LoginLayout from '@/layouts/_ibs/LoginLayout'; +import Network from '@/views/_ibs/Settings/Network'; +import Overview from '@/views/_ibs/Overview'; +import PageNotFound from '@/views/_ibs/PageNotFound'; +import PostCodeLogs from '@/views/_ibs/Logs/PostCodeLogs'; +import PowerRestorePolicy from '@/views/_ibs/Settings/PowerRestorePolicy'; +import ProfileSettings from '@/views/_ibs/ProfileSettings'; +import RebootBmc from '@/views/_ibs/Operations/RebootBmc'; +import Policies from '@/views/_ibs/SecurityAndAccess/Policies'; +import Sensors from '@/views/_ibs/HardwareStatus/Sensors'; +import SerialOverLan from '@/views/_ibs/Operations/SerialOverLan'; +import SerialOverLanConsole from '@/views/_ibs/Operations/SerialOverLan/SerialOverLanConsole'; +import ServerPowerOperations from '@/views/_ibs/Operations/ServerPowerOperations'; +import KeyClear from '@/views/_ibs/Operations/KeyClear'; +import Certificates from '@/views/_ibs/SecurityAndAccess/Certificates'; +import VirtualMedia from '@/views/_ibs/Operations/VirtualMedia'; +import Power from '@/views/_ibs/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/router/sila.js b/src/env/router/sila.js new file mode 100644 index 00000000..dbf4369f --- /dev/null +++ b/src/env/router/sila.js @@ -0,0 +1,331 @@ +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/Overview/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/Overview/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/Overview/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 FansStatic from '@/views/_sila/Fans/Static'; +import ProcessorsDynamic from '@/views/_sila/Processors/Dynamic'; +import MemoryDynamic from '@/views/_sila/Memory/Dynamic'; +import FansDynamic from '@/views/_sila/Fans/Dynamic'; +import MotherboardDynamic from '@/views/_sila/Motherboard/Dynamic'; +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: '/network', + name: 'network', + component: Network, + meta: { + title: i18n.t('appPageTitle.network'), + }, + }, + { + path: '/date-time', + name: 'date-time', + component: DateTime, + meta: { + title: i18n.t('appPageTitle.dateTime'), + }, + }, + { + path: '/hardware-inventory', + name: 'inventory', + component: Inventory, + meta: { + title: i18n.t('appPageTitle.inventory'), + }, + }, + { + 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/sensors', + name: 'sensors', + component: Sensors, + meta: { + title: i18n.t('appPageTitle.sensors'), + }, + }, + { + path: 'fans/static', + name: 'fans-static', + component: FansStatic, + meta: { + title: i18n.t('appPageTitle.fans'), + }, + }, + { + path: 'memory/dynamic', + name: 'memory-dynamic', + component: MemoryDynamic, + meta: { + title: i18n.t('appPageTitle.memory'), + }, + }, + { + path: 'processors/dynamic', + name: 'processors-dynamic', + component: ProcessorsDynamic, + meta: { + title: i18n.t('appPageTitle.processors'), + }, + }, + { + path: 'fans/dynamic', + name: 'fans-dynamic', + component: FansDynamic, + meta: { + title: i18n.t('appPageTitle.fans'), + }, + }, + { + path: 'motherboard/dynamic', + name: 'motherboard-dynamic', + component: MotherboardDynamic, + meta: { + title: i18n.t('appPageTitle.motherboard'), + }, + }, + { + 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: '/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/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/ibs.js b/src/env/store/ibs.js new file mode 100644 index 00000000..d0834c3b --- /dev/null +++ b/src/env/store/ibs.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/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/_ibs/AppLayout.vue b/src/layouts/_ibs/AppLayout.vue new file mode 100644 index 00000000..ef3193db --- /dev/null +++ b/src/layouts/_ibs/AppLayout.vue @@ -0,0 +1,93 @@ +<template> + <div class="app-container"> + <app-header + ref="focusTarget" + class="app-header" + :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/_ibs/AppHeader'; +import AppNavigation from '@/components/_ibs/AppNavigation'; +import PageContainer from '@/components/_ibs/Global/PageContainer'; +import ButtonBackToTop from '@/components/_ibs/Global/ButtonBackToTop'; +import JumpLinkMixin from '@/components/_ibs/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; + overflow-x: auto; + min-height: 480px; +} +</style> diff --git a/src/layouts/_ibs/ConsoleLayout.vue b/src/layouts/_ibs/ConsoleLayout.vue new file mode 100644 index 00000000..9f8175bf --- /dev/null +++ b/src/layouts/_ibs/ConsoleLayout.vue @@ -0,0 +1,9 @@ +<template> + <router-view /> +</template> + +<script> +export default { + name: 'Console', +}; +</script> diff --git a/src/layouts/_ibs/LoginLayout.vue b/src/layouts/_ibs/LoginLayout.vue new file mode 100644 index 00000000..c0f56df1 --- /dev/null +++ b/src/layouts/_ibs/LoginLayout.vue @@ -0,0 +1,126 @@ +<template> + <main> + <div class="login-container"> + <div class="login-main"> + <div class="login-main__logo-vector"> + <img + height="100%" + src="@/assets/images/_ibs/login-main-vector.svg" + alt="SILA" + /> + </div> + <div> + <div class="login-brand mb-5"> + <img + width="60%" + src="@/assets/images/_ibs/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="32px" + src="@/assets/images/_ibs/login-aside-vector.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; + 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-main__logo-vector { + position: absolute; + top: 0; + left: 0; + height: $spacer * 3; +} + +.login-form { + @include media-breakpoint-up('md') { + width: 360px; + 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 * 3; + + @include media-breakpoint-up('md') { + min-height: 100vh; + padding-bottom: $spacer * 3; + 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/layouts/_sila/AppLayout.vue b/src/layouts/_sila/AppLayout.vue new file mode 100644 index 00000000..1c9c39c7 --- /dev/null +++ b/src/layouts/_sila/AppLayout.vue @@ -0,0 +1,91 @@ +<template> + <div class="app-container"> + <app-header + ref="focusTarget" + :key="routerKey" + class="app-header" + @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/_sila/AppHeader'; +import AppNavigation from '@/components/_sila/AppNavigation'; +import PageContainer from '@/components/_sila/Global/PageContainer'; +import ButtonBackToTop from '@/components/_sila/Global/ButtonBackToTop'; +import JumpLinkMixin from '@/components/_sila/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..f353a4bc --- /dev/null +++ b/src/layouts/_sila/LoginLayout.vue @@ -0,0 +1,57 @@ +<template> + <main> + <div class="login-container"> + <div class="login-aside"> + <img + src="@/assets/images/_sila/built-on-openbmc-logo.svg" + alt="Built on BMC" + /> + </div> + <div class="login-main"> + <h1>{{ $t('pageLogin.auth') }}</h1> + <h3 class="h5 mb-4">{{ $t('pageLogin.authDescription') }}</h3> + <router-view class="login=form form-background" /> + </div> + </div> + </main> +</template> + +<script> +export default { + name: 'LoginLayout', +}; +</script> + +<style lang="scss" scoped> +.login-container { + background: $white; + display: flex; + flex-direction: row; + min-width: 320px; + min-height: 100vh; + justify-content: flex-start; +} + +.login-aside { + min-height: 100vh; + & > img { + height: 100vh; + } +} + +.login-main { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + min-height: 100vh; + width: 30vw; + margin: auto; + padding: 0; +} +@media (max-width: 750px) { + .login-aside { + display: none; + } +} +</style> diff --git a/src/locales/en-US.json b/src/locales/en-US.json index e43167c6..c19c756f 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -98,6 +98,7 @@ "titleRefresh": "Refresh application data" }, "appNavigation": { + "system":"System", "resourceManagement": "Resource management", "securityAndAccess": "Security and access", "sessions": "@:appPageTitle.sessions", @@ -127,7 +128,13 @@ "certificates": "@:appPageTitle.certificates", "virtualMedia": "@:appPageTitle.virtualMedia", "power": "@:appPageTitle.power", - "keyClear": "@:appPageTitle.keyClear" + "keyClear": "@:appPageTitle.keyClear", + "staticInfo": "Static Information", + "fans": "@:appPageTitle.fans", + "processors": "@:appPageTitle.processors", + "dynamicInformation": "@:appPageTitle.dynamicInformation", + "memory": "@:appPageTitle.memory", + "motherboard": "@:appPageTitle.motherboard" }, "appPageTitle": { "changePassword": "Change password", @@ -137,6 +144,10 @@ "dumps": "Dumps", "eventLogs": "Event logs", "factoryReset": "Factory reset", + "fans": "Fans", + "processors": "Processors", + "memory": "Memory modules", + "motherboard": "Motherboard", "firmware": "Firmware", "inventory": "Inventory and LEDs", "kvm": "KVM", @@ -156,7 +167,9 @@ "serverPowerOperations": "Server power operations", "certificates": "Certificates", "virtualMedia": "Virtual media", - "keyClear": "Key clear" + "keyClear": "Key clear", + "specification": "Specification", + "dynamicInformation": "Dynamic information" }, "pageChangePassword": { "changePassword": "Change password", @@ -596,6 +609,8 @@ } }, "pageLogin": { + "auth": "Authorization", + "authDescription": "Enter your login information", "language": "Language", "logIn": "Log in", "password": "Password", @@ -810,6 +825,33 @@ "upperCritical": "Upper critical" } }, + "pageFans": { + "installedFans": "Installed Fans", + "table": { + "name": "Name", + "health": "Health", + "type": "Type", + "currentValue": "Current Speed, rpm" + } + }, + "pageProcessors": { + "temperature": "Temperature Display", + "power": "Power consumption indicators", + "labels": { + "notNormal": "Not regular", + "critical": "Critical", + "warning": "Warning" + }, + "table": { + "name": "Name", + "currentTemperature": "Current, С°", + "middleTemperature": "Middle, С°", + "minTemperature": "Min, С°", + "minDate": "Min Date", + "maxTemperature": "Max, С°", + "maxDate": "Max Date" + } + }, "pageSerialOverLan": { "alert": { "disconnectedAlertMessage" : "System must be powered on to connect" diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 62eb5359..8b801958 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -36,7 +36,10 @@ }, "fileUpload": { "browseText": "Выбрать файл", - "clearSelectedFile": "Очистить поле" + "clearSelectedFile": "Очистить поле", + "modal":"Выбор файла", + "placeholder": "Нажмите на область или перетащите в нее файл", + "dropPlaceholder": "Отпустите, чтобы добавить файл" }, "form": { "dateMustBeAfter": "Дата должна быть после %{date}", @@ -82,6 +85,10 @@ "toast": { "unAuthTitle": "Недоступно", "unAuthDescription": "Действие недоступно из текущей учётной записи. Свяжитесь с Вашим системным администратором для проверки прав доступа." + }, + "datePicker": { + "lastHour": "Последний час", + "lastDay": "Последние сутки" } }, "appHeader": { @@ -98,6 +105,7 @@ "titleRefresh": "Обновить данные веб-приложения" }, "appNavigation": { + "system": "Система", "resourceManagement": "Управление ресурсами", "securityAndAccess": "Безопасность и доступ", "sessions": "@:appPageTitle.sessions", @@ -119,6 +127,7 @@ "primaryNavigation": "Основная навигация", "postCodeLogs": "@:appPageTitle.postCodeLogs", "powerRestorePolicy": "@:appPageTitle.powerRestorePolicy", + "dynamicInformation": "@:appPageTitle.dynamicInformation", "rebootBmc": "@:appPageTitle.rebootBmc", "policies": "@:appPageTitle.policies", "sensors": "@:appPageTitle.sensors", @@ -127,7 +136,12 @@ "certificates": "@:appPageTitle.certificates", "virtualMedia": "@:appPageTitle.virtualMedia", "power": "@:appPageTitle.power", - "keyClear": "@:appPageTitle.keyClear" + "keyClear": "@:appPageTitle.keyClear", + "staticInfo": "Статическая информация", + "fans": "@:appPageTitle.fans", + "processors": "@:appPageTitle.processors", + "memory": "@:appPageTitle.memory", + "motherboard": "@:appPageTitle.motherboard" }, "appPageTitle": { "changePassword": "Изменить пароль", @@ -137,6 +151,10 @@ "dumps": "Дампы", "eventLogs": "Журнал событий", "factoryReset": "Сброс до заводских настроек", + "fans": "Вентиляторы", + "processors": "Процессоры", + "memory": "Модули памяти", + "motherboard": "Материнская плата", "firmware": "Встроенное ПО", "inventory": "Перечень оборудования и LED", "kvm": "KVM", @@ -147,6 +165,7 @@ "overview": "Обзор", "pageNotFound": "Страница не найдена", "postCodeLogs": "Логи POST кодов", + "dynamicInformation": "Динамическая информация", "powerRestorePolicy": "Действие при восстановлении питания", "profileSettings": "Настройки профиля", "rebootBmc": "Перезапуск BMC", @@ -156,7 +175,8 @@ "serverPowerOperations": "Управление питанием сервера", "certificates": "Сертификаты", "virtualMedia": "Виртуальные носители", - "keyClear": "Удаление ключей" + "keyClear": "Удаление ключей", + "specification": "Характеристики" }, "pageChangePassword": { "changePassword": "Изменить пароль", @@ -210,6 +230,7 @@ "successSaveDateTime": "Настройки даты и времени сохранены." } }, + "pageDumps": { "dumpsAvailableOnBmc": "Дампы доступные на BMC", "initiateDump": "Создать дамп", @@ -292,7 +313,7 @@ "modal": { "resetBiosTitle": "Сбросить настройки встроенного ПО сервера", "resetBiosHeader": "Вы хотите сбросить настройки встроенного ПО сервера?", - "resetBiosSubmitText": "Сбросить настройки встроенного ПО сервера", + "resetBiosSubmitText": "Сбросить настройки сервера", "resetBiosSettingsList": { "item1": "Все ручные настройки будут удалены.", "item2": "Конфигурация разделов и хранилище ключей могут быть восстановлены, если существует резервная копия." @@ -596,6 +617,8 @@ } }, "pageLogin": { + "auth": "Авторизация", + "authDescription": "Введите данные для входа в систему", "language": "Язык", "logIn": "Войти", "password": "Пароль", @@ -810,6 +833,73 @@ "upperCritical": "Макс. крит. значение" } }, + "pageFans": { + "installedFans": "Установленные вентиляторы", + "speed": "Показатели скорости", + "table": { + "name": "Имя модуля", + "health": "Состояние", + "type": "Тип устройства", + "current": "Текущая", + "middle": "Средняя", + "min": "Минимальная", + "minDate": "Дата минимальной", + "max": "Максимальная", + "maxDate": "Дата максимальной" + }, + "labels": { + "warning": "Значение предупреждения, об/мин", + "shutdown": "Значение отказа, об/мин" + } + }, + "pageProcessors": { + "temperature": "Показатели температуры", + "power": "Показатели потребляемой мощности", + "labels": { + "notNormal": "Не штатный режим", + "critical": "Критический режим", + "warning": "Значение предупреждения", + "shutdown": "Значение отказа" + }, + "table": { + "temperature": { + "name": "Имя модуля", + "current": "Текущее, С°", + "middle": "Среднее, С°", + "min": "Минимальное, С°", + "minDate": "Дата минимального", + "max": "Максимальное, С°", + "maxDate": "Дата максимального" + }, + "power": { + "name": "Имя модуля", + "current": "Текущее, Вт" + } + } + }, + "pageMemory": { + "temperature": "Показатели температуры", + "labels": { + "notNormal": "Не штатный режим", + "critical": "Критический режим", + "warning": "Значение предупреждения" + }, + "table": { + "name": "Имя модуля", + "currentTemperature": "Текущее, С°", + "middleTemperature": "Среднее, С°", + "minTemperature": "Минимальное, С°", + "minDate": "Дата минимального", + "maxTemperature": "Максимальное, С°", + "maxDate": "Дата максимального" + } + }, + "pageMotherboard": { + "temperature": "Показатели температуры", + "labels": { + "warning": "Значение предупреждения" + } + }, "pageSerialOverLan": { "alert": { "disconnectedAlertMessage" : "Система должна быть включена для соединения" diff --git a/src/main.js b/src/main.js index 0aae716f..8585f897 100644 --- a/src/main.js +++ b/src/main.js @@ -41,6 +41,8 @@ import { import Vuelidate from 'vuelidate'; import i18n from './i18n'; import { format } from 'date-fns-tz'; +import HighchartsVue from 'highcharts-vue'; +import VueRandomColor from 'vue-randomcolor'; // Filters Vue.filter('shortTimeZone', function (value) { @@ -55,7 +57,7 @@ Vue.filter('shortTimeZone', function (value) { Vue.filter('formatDate', function (value) { const isUtcDisplay = store.getters['global/isUtcDisplay']; - if (value instanceof Date) { + if (value instanceof Date && !isNaN(value)) { if (isUtcDisplay) { return value.toISOString().substring(0, 10); } @@ -128,6 +130,8 @@ Vue.use(TabsPlugin); Vue.use(ToastPlugin); Vue.use(TooltipPlugin); Vue.use(Vuelidate); +Vue.use(HighchartsVue); +Vue.use(VueRandomColor); new Vue({ router, diff --git a/src/store/index.js b/src/store/index.js index ba248c58..fa857b3a 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -19,7 +19,8 @@ import ServerLedStore from './modules/HardwareStatus/ServerLedStore'; import SystemStore from './modules/HardwareStatus/SystemStore'; import PowerSupplyStore from './modules/HardwareStatus/PowerSupplyStore'; import MemoryStore from './modules/HardwareStatus/MemoryStore'; -import FanStore from './modules/HardwareStatus/FanStore'; +import FanStore from './modules/_sila/HardwareStatus/FanStore'; +import MotherboardStore from './modules/_sila/HardwareStatus/MotherboardStore'; import ChassisStore from './modules/HardwareStatus/ChassisStore'; import BmcStore from './modules/HardwareStatus/BmcStore'; import ProcessorStore from './modules/HardwareStatus/ProcessorStore'; @@ -60,6 +61,7 @@ export default new Vuex.Store({ system: SystemStore, memory: MemoryStore, fan: FanStore, + motherboard: MotherboardStore, chassis: ChassisStore, bmc: BmcStore, processors: ProcessorStore, diff --git a/src/store/modules/GlobalStore.js b/src/store/modules/GlobalStore.js index 95d7a083..49283b03 100644 --- a/src/store/modules/GlobalStore.js +++ b/src/store/modules/GlobalStore.js @@ -95,8 +95,19 @@ const GlobalStore = { Status: { State } = {}, }, } = {}) => { - commit('setAssetTag', AssetTag); - commit('setSerialNumber', SerialNumber); + /* + Trim AssetTag and SerialNumber + */ + var s, tag, srn; + + s = AssetTag; + tag = s.replace(/\./g, ' ').trim(); + + s = SerialNumber; + srn = s.replace(/\./g, ' ').trim(); + + commit('setAssetTag', tag); + commit('setSerialNumber', srn); commit('setModelType', Model); if (State === 'Quiesced' || State === 'InTest') { // OpenBMC's host state interface is mapped to 2 Redfish diff --git a/src/store/modules/HardwareStatus/MemoryStore.js b/src/store/modules/HardwareStatus/MemoryStore.js index fd8f0a91..358ca725 100644 --- a/src/store/modules/HardwareStatus/MemoryStore.js +++ b/src/store/modules/HardwareStatus/MemoryStore.js @@ -5,9 +5,11 @@ const MemoryStore = { namespaced: true, state: { dimms: [], + dimmsLastHour: [], }, getters: { dimms: (state) => state.dimms, + dimmsLastHour: (state) => state.dimmsLastHour, }, mutations: { setMemoryInfo: (state, data) => { @@ -52,8 +54,36 @@ const MemoryStore = { }; }); }, + + setMemoryDynamic: (state, data) => { + state.dimms = data; + }, + + setMemoryDynamicLastHour: (state, data) => { + state.dimmsLastHour = data; + }, }, actions: { + async getMemoryDynamic({ commit }, { lastHour }) { + let url = null; + if (lastHour) { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=dimm_temp&period=last_hour'; + } else { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=dimm_temp'; + } + return await api + .get(url) + .then(({ data: { MetricValues = [] } }) => { + if (lastHour) { + commit('setMemoryDynamicLastHour', MetricValues); + } else { + commit('setMemoryDynamic', MetricValues); + } + }) + .catch((error) => console.log(error)); + }, async getDimms({ commit }) { return await api .get('/redfish/v1/Systems/system/Memory') diff --git a/src/store/modules/HardwareStatus/ProcessorStore.js b/src/store/modules/HardwareStatus/ProcessorStore.js index d4c99bce..5466b85e 100644 --- a/src/store/modules/HardwareStatus/ProcessorStore.js +++ b/src/store/modules/HardwareStatus/ProcessorStore.js @@ -4,14 +4,20 @@ import i18n from '@/i18n'; const ProcessorStore = { namespaced: true, state: { - processors: [], + cpuTemp: [], + cpuPower: [], + cpuTempLastHour: [], + cpuPowerLastHour: [], }, getters: { - processors: (state) => state.processors, + cpuTemp: (state) => state.cpuTemp, + cpuPower: (state) => state.cpuPower, + cpuTempLastHour: (state) => state.cpuTempLastHour, + cpuPowerLastHour: (state) => state.cpuPowerLastHour, }, mutations: { setProcessorsInfo: (state, data) => { - state.processors = data.map((processor) => { + state.cpuTemp = data.map((processor) => { const { Id, Status = {}, @@ -59,8 +65,60 @@ const ProcessorStore = { }; }); }, + + setCpuTempDynamic: (state, data) => { + state.cpuTemp = data; + }, + setCpuPowerDynamic: (state, data) => { + state.cpuPower = data; + }, + setCpuTempDynamicLastHour: (state, data) => { + state.cpuTempLastHour = data; + }, + setCpuPowerDynamicLastHour: (state, data) => { + state.cpuPowerLastHour = data; + }, }, actions: { + async getCpuPowerDynamic({ commit }, { lastHour }) { + let url = null; + if (lastHour) { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=power&period=last_hour'; + } else { + url = '/redfish/v1/TelemetryService/MetricReports/hour_data&id=power'; + } + return await api + .get(url) + .then(({ data: { MetricValues = [] } }) => { + if (lastHour) { + commit('setCpuPowerDynamicLastHour', MetricValues); + } else { + commit('setCpuPowerDynamic', MetricValues); + } + }) + .catch((error) => console.log(error)); + }, + async getCpuTempDynamic({ commit }, { lastHour }) { + let url = null; + if (lastHour) { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=cpu_temp&period=last_hour'; + } else { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=cpu_temp'; + } + return await api + .get(url) + .then(({ data: { MetricValues = [] } }) => { + if (lastHour) { + commit('setCpuTempDynamicLastHour', MetricValues); + } else { + commit('setCpuTempDynamic', MetricValues); + } + }) + .catch((error) => console.log(error)); + }, async getProcessorsInfo({ commit }) { return await api .get('/redfish/v1/Systems/system/Processors') diff --git a/src/store/modules/_sila/HardwareStatus/FanStore.js b/src/store/modules/_sila/HardwareStatus/FanStore.js new file mode 100644 index 00000000..fed3f742 --- /dev/null +++ b/src/store/modules/_sila/HardwareStatus/FanStore.js @@ -0,0 +1,81 @@ +import api from '@/store/api'; +import { fanType } from '@/utilities/_sila/tableParser'; + +const FanStore = { + namespaced: true, + state: { + fans: [], + fansLastHour: [], + }, + getters: { + fans: (state) => state.fans, + fansLastHour: (state) => state.fansLastHour, + }, + mutations: { + setFanInfo: (state, data) => { + state.fans = data.map((fan) => { + const { + IndicatorLED, + Location, + MemberId, + Name, + Reading, + ReadingUnits, + Status = {}, + PartNumber, + SerialNumber, + } = fan; + return { + id: MemberId, + health: Status.Health, + partNumber: PartNumber, + serialNumber: SerialNumber, + healthRollup: Status.HealthRollup, + identifyLed: IndicatorLED, + locationNumber: Location, + name: Name, + speed: Reading + ' ' + ReadingUnits, + speedRPM: Reading, + type: fanType(Name), + statusState: Status.State, + }; + }); + }, + + setFansDynamic: (state, data) => { + state.fans = data; + }, + setFansDynamicLastHour: (state, data) => { + state.fansLastHour = data; + }, + }, + actions: { + async getFansDynamic({ commit }, { lastHour }) { + let url = null; + if (lastHour) { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=fans&period=last_hour'; + } else { + url = '/redfish/v1/TelemetryService/MetricReports/hour_data&id=fans'; + } + return await api + .get(url) + .then(({ data: { MetricValues = [] } }) => { + if (lastHour) { + commit('setFansDynamicLastHour', MetricValues); + } else { + commit('setFansDynamic', MetricValues); + } + }) + .catch((error) => console.log(error)); + }, + async getFanInfo({ commit }) { + return await api + .get('/redfish/v1/Chassis/chassis/Thermal') + .then(({ data: { Fans = [] } }) => commit('setFanInfo', Fans)) + .catch((error) => console.log(error)); + }, + }, +}; + +export default FanStore; diff --git a/src/store/modules/_sila/HardwareStatus/MotherboardStore.js b/src/store/modules/_sila/HardwareStatus/MotherboardStore.js new file mode 100644 index 00000000..7b2fe032 --- /dev/null +++ b/src/store/modules/_sila/HardwareStatus/MotherboardStore.js @@ -0,0 +1,45 @@ +import api from '@/store/api'; + +const MotherboardStore = { + namespaced: true, + state: { + motherboard: [], + motherboardLastHour: [], + }, + getters: { + motherboard: (state) => state.motherboard, + motherboardLastHour: (state) => state.motherboardLastHour, + }, + mutations: { + setMotherboardDynamic: (state, data) => { + state.motherboard = data; + }, + setMotherboardDynamicLastHour: (state, data) => { + state.motherboardLastHour = data; + }, + }, + actions: { + async getMotherboardDynamic({ commit }, { lastHour }) { + let url = null; + if (lastHour) { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=other_temp&period=last_hour'; + } else { + url = + '/redfish/v1/TelemetryService/MetricReports/hour_data&id=other_temp'; + } + return await api + .get(url) + .then(({ data: { MetricValues = [] } }) => { + if (lastHour) { + commit('setMotherboardDynamicLastHour', MetricValues); + } else { + commit('setMotherboardDynamic', MetricValues); + } + }) + .catch((error) => console.log(error)); + }, + }, +}; + +export default MotherboardStore; diff --git a/src/utilities/_sila/metricProperties.js b/src/utilities/_sila/metricProperties.js new file mode 100644 index 00000000..32495605 --- /dev/null +++ b/src/utilities/_sila/metricProperties.js @@ -0,0 +1,82 @@ +export function getItems(data) { + let filteredData = data.filter((metric) => { + return metric.MetricValue !== 'nan'; + }); + + let transform = filteredData.map((metric) => { + let date = new Date(metric.Timestamp); + return { + ...metric, + Timestamp: { + time: + date.getHours() + ':' + String(date.getMinutes()).padStart(2, '0'), + date: formatDate(date), + }, + MetricValue: Math.round(metric.MetricValue), + }; + }); + + let group = transform.reduce(function (rv, x) { + (rv[x['MetricProperty']] = rv[x['MetricProperty']] || []).push(x); + return rv; + }, {}); + + return Object.keys(group).map((metric) => { + return { + name: metric, + current: group[metric][group[metric].length - 1].MetricValue, + middle: findAverage(group[metric]), + min: findMin(group[metric]), + minDate: findDateOfMin(group[metric]), + max: findMax(group[metric]), + maxDate: findDateOfMax(group[metric]), + }; + }); +} + +function padTo2Digits(num) { + return num.toString().padStart(2, '0'); +} + +function formatDate(date) { + return [ + padTo2Digits(date.getDate()), + padTo2Digits(date.getMonth() + 1), + date.getFullYear(), + ].join('.'); +} + +function findAverage(arr) { + const { length } = arr; + return Math.round( + arr.reduce((acc, val) => { + return acc + val.MetricValue / length; + }, 0) + ); +} + +function findMin(arr) { + return arr.reduce( + (min, p) => (p.MetricValue < min ? p.MetricValue : min), + arr[0].MetricValue + ); +} + +function findMax(arr) { + return arr.reduce( + (max, p) => (p.MetricValue > max ? p.MetricValue : max), + arr[0].MetricValue + ); +} + +function findDateOfMin(arr) { + return arr.reduce((res, obj) => + obj.MetricValue < res.MetricValue ? obj : res + ).Timestamp; +} + +function findDateOfMax(arr) { + return arr.reduce((res, obj) => + obj.MetricValue > res.MetricValue ? obj : res + ).Timestamp; +} diff --git a/src/utilities/_sila/tableParser.js b/src/utilities/_sila/tableParser.js new file mode 100644 index 00000000..9c85bfe5 --- /dev/null +++ b/src/utilities/_sila/tableParser.js @@ -0,0 +1,12 @@ +export function fanType(name) { + switch (true) { + case name.includes('CPU'): + return 'Процессоры'; + case name.includes('System'): + return 'Системная плата'; + case name.includes('Pwm'): + return 'Процессоры'; + default: + return 'Неизвестное расположение'; + } +} diff --git a/src/views/_ibs/ChangePassword/ChangePassword.vue b/src/views/_ibs/ChangePassword/ChangePassword.vue new file mode 100644 index 00000000..b2ebc3b0 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/Alert'; +import VuelidateMixin from '@/components/_ibs/Mixins/VuelidateMixin'; +import InputPasswordToggle from '@/components/_ibs/Global/InputPasswordToggle'; +import BVToastMixin from '@/components/_ibs/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/_ibs/ChangePassword/index.js b/src/views/_ibs/ChangePassword/index.js new file mode 100644 index 00000000..9de0af42 --- /dev/null +++ b/src/views/_ibs/ChangePassword/index.js @@ -0,0 +1,2 @@ +import ChangePassword from './ChangePassword.vue'; +export default ChangePassword; diff --git a/src/views/_ibs/HardwareStatus/Inventory/Inventory.vue b/src/views/_ibs/HardwareStatus/Inventory/Inventory.vue new file mode 100644 index 00000000..7c32eddc --- /dev/null +++ b/src/views/_ibs/HardwareStatus/Inventory/Inventory.vue @@ -0,0 +1,198 @@ +<template> + <b-container fluid="xl"> + <page-title /> + + <!-- Service indicators --> + <div class="service-indicators"> + <service-indicator /> + </div> + + <!-- 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/_ibs/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/_ibs/Mixins/LoadingBarMixin'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import JumpLink16 from '@carbon/icons-vue/es/jump-link/16'; +import JumpLinkMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryServiceIndicator.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryServiceIndicator.vue new file mode 100644 index 00000000..ba3f2735 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import BVToastMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableAssembly.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableAssembly.vue new file mode 100644 index 00000000..d1b2c6b9 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableBmcManager.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableBmcManager.vue new file mode 100644 index 00000000..f157f271 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableChassis.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableChassis.vue new file mode 100644 index 00000000..1165ec7b --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableDimmSlot.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableDimmSlot.vue new file mode 100644 index 00000000..1748ca16 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; + +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import Search from '@/components/_ibs/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableFans.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableFans.vue new file mode 100644 index 00000000..33077a5a --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; + +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import Search from '@/components/_ibs/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue new file mode 100644 index 00000000..e760e85e --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import Search from '@/components/_ibs/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableProcessors.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableProcessors.vue new file mode 100644 index 00000000..a4c6dbef --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import Search from '@/components/_ibs/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/InventoryTableSystem.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableSystem.vue new file mode 100644 index 00000000..6b028e3d --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/HardwareStatus/Inventory/index.js b/src/views/_ibs/HardwareStatus/Inventory/index.js new file mode 100644 index 00000000..c9fde8d2 --- /dev/null +++ b/src/views/_ibs/HardwareStatus/Inventory/index.js @@ -0,0 +1,2 @@ +import Inventory from './Inventory.vue'; +export default Inventory; diff --git a/src/views/_ibs/HardwareStatus/Sensors/Sensors.vue b/src/views/_ibs/HardwareStatus/Sensors/Sensors.vue new file mode 100644 index 00000000..1ef6bfdb --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import Search from '@/components/_ibs/Global/Search'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import TableFilter from '@/components/_ibs/Global/TableFilter'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import TableToolbarExport from '@/components/_ibs/Global/TableToolbarExport'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_ibs/Mixins/TableFilterMixin'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/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/_ibs/HardwareStatus/Sensors/index.js b/src/views/_ibs/HardwareStatus/Sensors/index.js new file mode 100644 index 00000000..fc71b611 --- /dev/null +++ b/src/views/_ibs/HardwareStatus/Sensors/index.js @@ -0,0 +1,2 @@ +import Sensors from './Sensors.vue'; +export default Sensors; diff --git a/src/views/_ibs/Login/Login.vue b/src/views/_ibs/Login/Login.vue new file mode 100644 index 00000000..4413b6bc --- /dev/null +++ b/src/views/_ibs/Login/Login.vue @@ -0,0 +1,142 @@ +<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/_ibs/Mixins/VuelidateMixin.js'; +import i18n from '@/i18n'; +import Alert from '@/components/_ibs/Global/Alert'; +import InputPasswordToggle from '@/components/_ibs/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: '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/_ibs/Login/index.js b/src/views/_ibs/Login/index.js new file mode 100644 index 00000000..8fe0250d --- /dev/null +++ b/src/views/_ibs/Login/index.js @@ -0,0 +1,2 @@ +import Login from './Login.vue'; +export default Login; diff --git a/src/views/_ibs/Logs/Dumps/Dumps.vue b/src/views/_ibs/Logs/Dumps/Dumps.vue new file mode 100644 index 00000000..6c7945b0 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageSection'; +import PageTitle from '@/components/_ibs/Global/PageTitle'; +import Search from '@/components/_ibs/Global/Search'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import TableDateFilter from '@/components/_ibs/Global/TableDateFilter'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_ibs/Mixins/BVPaginationMixin'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/Mixins/SearchFilterMixin'; +import TableFilter from '@/components/_ibs/Global/TableFilter'; +import TableFilterMixin from '@/components/_ibs/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/_ibs/Logs/Dumps/DumpsForm.vue b/src/views/_ibs/Logs/Dumps/DumpsForm.vue new file mode 100644 index 00000000..60c5219f --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/Alert'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/Logs/Dumps/DumpsModalConfirmation.vue b/src/views/_ibs/Logs/Dumps/DumpsModalConfirmation.vue new file mode 100644 index 00000000..9d9d3a6f --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/StatusIcon'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/Logs/Dumps/index.js b/src/views/_ibs/Logs/Dumps/index.js new file mode 100644 index 00000000..65525fb0 --- /dev/null +++ b/src/views/_ibs/Logs/Dumps/index.js @@ -0,0 +1,2 @@ +import Dumps from './Dumps.vue'; +export default Dumps; diff --git a/src/views/_ibs/Logs/EventLogs/EventLogs.vue b/src/views/_ibs/Logs/EventLogs/EventLogs.vue new file mode 100644 index 00000000..bb7d6726 --- /dev/null +++ b/src/views/_ibs/Logs/EventLogs/EventLogs.vue @@ -0,0 +1,604 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-end"> + <b-col sm="8" xl="6" class="d-sm-flex align-items-end"> + <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/_ibs/Global/PageTitle'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import Search from '@/components/_ibs/Global/Search'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import TableDateFilter from '@/components/_ibs/Global/TableDateFilter'; +import TableFilter from '@/components/_ibs/Global/TableFilter'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import TableToolbarExport from '@/components/_ibs/Global/TableToolbarExport'; + +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_ibs/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_ibs/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/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/_ibs/Logs/EventLogs/index.js b/src/views/_ibs/Logs/EventLogs/index.js new file mode 100644 index 00000000..521efde4 --- /dev/null +++ b/src/views/_ibs/Logs/EventLogs/index.js @@ -0,0 +1,2 @@ +import EventLogs from './EventLogs.vue'; +export default EventLogs; diff --git a/src/views/_ibs/Logs/PostCodeLogs/PostCodeLogs.vue b/src/views/_ibs/Logs/PostCodeLogs/PostCodeLogs.vue new file mode 100644 index 00000000..e1c24a5d --- /dev/null +++ b/src/views/_ibs/Logs/PostCodeLogs/PostCodeLogs.vue @@ -0,0 +1,347 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-end"> + <b-col sm="8" xl="6" class="d-sm-flex align-items-end"> + <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/_ibs/Global/PageTitle'; +import Search from '@/components/_ibs/Global/Search'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import TableDateFilter from '@/components/_ibs/Global/TableDateFilter'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import TableToolbarExport from '@/components/_ibs/Global/TableToolbarExport'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_ibs/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_ibs/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/_ibs/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_ibs/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/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/_ibs/Logs/PostCodeLogs/index.js b/src/views/_ibs/Logs/PostCodeLogs/index.js new file mode 100644 index 00000000..ab591124 --- /dev/null +++ b/src/views/_ibs/Logs/PostCodeLogs/index.js @@ -0,0 +1,2 @@ +import PostCodeLogs from './PostCodeLogs.vue'; +export default PostCodeLogs; diff --git a/src/views/_ibs/Operations/FactoryReset/FactoryReset.vue b/src/views/_ibs/Operations/FactoryReset/FactoryReset.vue new file mode 100644 index 00000000..4b454a42 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/Operations/FactoryReset/FactoryResetModal.vue b/src/views/_ibs/Operations/FactoryReset/FactoryResetModal.vue new file mode 100644 index 00000000..378dd671 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/StatusIcon'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/Operations/FactoryReset/index.js b/src/views/_ibs/Operations/FactoryReset/index.js new file mode 100644 index 00000000..eae747e0 --- /dev/null +++ b/src/views/_ibs/Operations/FactoryReset/index.js @@ -0,0 +1,2 @@ +import FactoryReset from './FactoryReset.vue'; +export default FactoryReset; diff --git a/src/views/_ibs/Operations/Firmware/Firmware.vue b/src/views/_ibs/Operations/Firmware/Firmware.vue new file mode 100644 index 00000000..8dbd206b --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/Firmware.vue @@ -0,0 +1,96 @@ +<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"> + <div class="firmware-update"> + <!-- Update form --> + <form-update + :is-server-off="isServerOff" + :is-page-disabled="isPageDisabled" + /> + </div> + </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/_ibs/Global/PageSection'; +import PageTitle from '@/components/_ibs/Global/PageTitle'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/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/_ibs/Operations/Firmware/FirmwareAlertServerPower.vue b/src/views/_ibs/Operations/Firmware/FirmwareAlertServerPower.vue new file mode 100644 index 00000000..6446cc07 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Operations/Firmware/FirmwareCardsBmc.vue b/src/views/_ibs/Operations/Firmware/FirmwareCardsBmc.vue new file mode 100644 index 00000000..bca682bd --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/FirmwareCardsBmc.vue @@ -0,0 +1,138 @@ +<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/_ibs/Global/PageSection'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/_ibs/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/_ibs/Operations/Firmware/FirmwareCardsHost.vue b/src/views/_ibs/Operations/Firmware/FirmwareCardsHost.vue new file mode 100644 index 00000000..a4af7310 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Operations/Firmware/FirmwareFormUpdate.vue b/src/views/_ibs/Operations/Firmware/FirmwareFormUpdate.vue new file mode 100644 index 00000000..c27bac26 --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/FirmwareFormUpdate.vue @@ -0,0 +1,189 @@ +<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/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_ibs/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/_ibs/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/_ibs/Operations/Firmware/FirmwareModalSwitchToRunning.vue b/src/views/_ibs/Operations/Firmware/FirmwareModalSwitchToRunning.vue new file mode 100644 index 00000000..dc4a4973 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Operations/Firmware/FirmwareModalUpdateFirmware.vue b/src/views/_ibs/Operations/Firmware/FirmwareModalUpdateFirmware.vue new file mode 100644 index 00000000..18355217 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Operations/Firmware/index.js b/src/views/_ibs/Operations/Firmware/index.js new file mode 100644 index 00000000..ad15cc03 --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/index.js @@ -0,0 +1,2 @@ +import Firmware from './Firmware.vue'; +export default Firmware; diff --git a/src/views/_ibs/Operations/KeyClear/KeyClear.vue b/src/views/_ibs/Operations/KeyClear/KeyClear.vue new file mode 100644 index 00000000..5a4280c5 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import Alert from '@/components/_ibs/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/_ibs/Operations/KeyClear/index.js b/src/views/_ibs/Operations/KeyClear/index.js new file mode 100644 index 00000000..56de8c4e --- /dev/null +++ b/src/views/_ibs/Operations/KeyClear/index.js @@ -0,0 +1,2 @@ +import KeyClear from './KeyClear.vue'; +export default KeyClear; diff --git a/src/views/_ibs/Operations/Kvm/Kvm.vue b/src/views/_ibs/Operations/Kvm/Kvm.vue new file mode 100644 index 00000000..0f1e8bfc --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Operations/Kvm/KvmConsole.vue b/src/views/_ibs/Operations/Kvm/KvmConsole.vue new file mode 100644 index 00000000..c671a4b9 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Operations/Kvm/index.js b/src/views/_ibs/Operations/Kvm/index.js new file mode 100644 index 00000000..ac4f9667 --- /dev/null +++ b/src/views/_ibs/Operations/Kvm/index.js @@ -0,0 +1,2 @@ +import Kvm from './Kvm.vue'; +export default Kvm; diff --git a/src/views/_ibs/Operations/RebootBmc/RebootBmc.vue b/src/views/_ibs/Operations/RebootBmc/RebootBmc.vue new file mode 100644 index 00000000..1e00d90d --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/Operations/RebootBmc/index.js b/src/views/_ibs/Operations/RebootBmc/index.js new file mode 100644 index 00000000..ac31417e --- /dev/null +++ b/src/views/_ibs/Operations/RebootBmc/index.js @@ -0,0 +1,2 @@ +import RebootBmc from './RebootBmc.vue'; +export default RebootBmc; diff --git a/src/views/_ibs/Operations/SerialOverLan/SerialOverLan.vue b/src/views/_ibs/Operations/SerialOverLan/SerialOverLan.vue new file mode 100644 index 00000000..48a68345 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Operations/SerialOverLan/SerialOverLanConsole.vue b/src/views/_ibs/Operations/SerialOverLan/SerialOverLanConsole.vue new file mode 100644 index 00000000..6393006a --- /dev/null +++ b/src/views/_ibs/Operations/SerialOverLan/SerialOverLanConsole.vue @@ -0,0 +1,174 @@ +<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/_ibs/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/_ibs/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({ + cols: 96, + rows: 44, + 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/_ibs/Operations/SerialOverLan/index.js b/src/views/_ibs/Operations/SerialOverLan/index.js new file mode 100644 index 00000000..7c8bc7c0 --- /dev/null +++ b/src/views/_ibs/Operations/SerialOverLan/index.js @@ -0,0 +1,2 @@ +import SerialOverLan from './SerialOverLan.vue'; +export default SerialOverLan; diff --git a/src/views/_ibs/Operations/ServerPowerOperations/BootSettings.vue b/src/views/_ibs/Operations/ServerPowerOperations/BootSettings.vue new file mode 100644 index 00000000..59b7b19c --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/Operations/ServerPowerOperations/ServerPowerOperations.vue b/src/views/_ibs/Operations/ServerPowerOperations/ServerPowerOperations.vue new file mode 100644 index 00000000..b017d24a --- /dev/null +++ b/src/views/_ibs/Operations/ServerPowerOperations/ServerPowerOperations.vue @@ -0,0 +1,262 @@ +<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"> + <div class="boot-settings"> + <page-section + :section-title="$t('pageServerPowerOperations.serverBootSettings')" + > + <boot-settings /> + </page-section> + </div> + </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/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import BootSettings from './BootSettings'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import Alert from '@/components/_ibs/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/_ibs/Operations/ServerPowerOperations/index.js b/src/views/_ibs/Operations/ServerPowerOperations/index.js new file mode 100644 index 00000000..10430047 --- /dev/null +++ b/src/views/_ibs/Operations/ServerPowerOperations/index.js @@ -0,0 +1,2 @@ +import ServerPowerOperations from './ServerPowerOperations.vue'; +export default ServerPowerOperations; diff --git a/src/views/_ibs/Operations/VirtualMedia/ModalConfigureConnection.vue b/src/views/_ibs/Operations/VirtualMedia/ModalConfigureConnection.vue new file mode 100644 index 00000000..fb079218 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Operations/VirtualMedia/VirtualMedia.vue b/src/views/_ibs/Operations/VirtualMedia/VirtualMedia.vue new file mode 100644 index 00000000..bfd87b8a --- /dev/null +++ b/src/views/_ibs/Operations/VirtualMedia/VirtualMedia.vue @@ -0,0 +1,235 @@ +<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"> + <div class="media-buttons-group"> + <div class="media-start"> + <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> + </div> + <div class="media-stop"> + <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> + </div> + </div> + </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" + > + <div class="media-legacy"> + <b-form-group + :label="device.id" + :label-for="device.id" + label-class="bold" + > + <div class="media-legacy-buttons-group"> + <div class="media-legacy-configure"> + <b-button + variant="primary" + :disabled="device.isActive" + @click="configureConnection(device)" + > + {{ $t('pageVirtualMedia.configureConnection') }} + </b-button> + </div> + + <div class="media-legacy-start"> + <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> + </div> + </div> + </b-form-group> + </div> + </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/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import ModalConfigureConnection from './ModalConfigureConnection'; +import NbdServer from '@/utilities/NBDServer'; +import FormFile from '@/components/_ibs/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/_ibs/Operations/VirtualMedia/index.js b/src/views/_ibs/Operations/VirtualMedia/index.js new file mode 100644 index 00000000..4573e865 --- /dev/null +++ b/src/views/_ibs/Operations/VirtualMedia/index.js @@ -0,0 +1,2 @@ +import VirtualMedia from './VirtualMedia.vue'; +export default VirtualMedia; diff --git a/src/views/_ibs/Overview/Overview.vue b/src/views/_ibs/Overview/Overview.vue new file mode 100644 index 00000000..df0a4f49 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Global/PageSection'; +import PageTitle from '@/components/_ibs/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/_ibs/Overview/OverviewCard.vue b/src/views/_ibs/Overview/OverviewCard.vue new file mode 100644 index 00000000..4fc0a031 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Overview/OverviewDumps.vue b/src/views/_ibs/Overview/OverviewDumps.vue new file mode 100644 index 00000000..e313c8e0 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/OverviewEvents.vue b/src/views/_ibs/Overview/OverviewEvents.vue new file mode 100644 index 00000000..3fd76690 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/StatusIcon'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/Overview/OverviewFirmware.vue b/src/views/_ibs/Overview/OverviewFirmware.vue new file mode 100644 index 00000000..d9244a21 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/OverviewInventory.vue b/src/views/_ibs/Overview/OverviewInventory.vue new file mode 100644 index 00000000..575cb7b7 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Overview/OverviewNetwork.vue b/src/views/_ibs/Overview/OverviewNetwork.vue new file mode 100644 index 00000000..eb9024c7 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/OverviewPower.vue b/src/views/_ibs/Overview/OverviewPower.vue new file mode 100644 index 00000000..8b084479 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/OverviewQuickLinks.vue b/src/views/_ibs/Overview/OverviewQuickLinks.vue new file mode 100644 index 00000000..0faab856 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/OverviewServer.vue b/src/views/_ibs/Overview/OverviewServer.vue new file mode 100644 index 00000000..f688dc64 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Overview/index.js b/src/views/_ibs/Overview/index.js new file mode 100644 index 00000000..8553ef3d --- /dev/null +++ b/src/views/_ibs/Overview/index.js @@ -0,0 +1,2 @@ +import Overview from './Overview.vue'; +export default Overview; diff --git a/src/views/_ibs/PageNotFound/PageNotFound.vue b/src/views/_ibs/PageNotFound/PageNotFound.vue new file mode 100644 index 00000000..287f5074 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +export default { + name: 'PageNotFound', + components: { PageTitle }, +}; +</script> diff --git a/src/views/_ibs/PageNotFound/index.js b/src/views/_ibs/PageNotFound/index.js new file mode 100644 index 00000000..ed1d519a --- /dev/null +++ b/src/views/_ibs/PageNotFound/index.js @@ -0,0 +1,2 @@ +import PageNotFound from './PageNotFound.vue'; +export default PageNotFound; diff --git a/src/views/_ibs/ProfileSettings/ProfileSettings.vue b/src/views/_ibs/ProfileSettings/ProfileSettings.vue new file mode 100644 index 00000000..79500ff3 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import InputPasswordToggle from '@/components/_ibs/Global/InputPasswordToggle'; +import { maxLength, minLength, sameAs } from 'vuelidate/lib/validators'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/_ibs/Mixins/LocalTimezoneLabelMixin'; +import PageTitle from '@/components/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/ProfileSettings/index.js b/src/views/_ibs/ProfileSettings/index.js new file mode 100644 index 00000000..d6589c72 --- /dev/null +++ b/src/views/_ibs/ProfileSettings/index.js @@ -0,0 +1,2 @@ +import ProfileSettings from './ProfileSettings.vue'; +export default ProfileSettings; diff --git a/src/views/_ibs/ResourceManagement/Power.vue b/src/views/_ibs/ResourceManagement/Power.vue new file mode 100644 index 00000000..b58c33ec --- /dev/null +++ b/src/views/_ibs/ResourceManagement/Power.vue @@ -0,0 +1,172 @@ +<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/_ibs/Global/PageTitle'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_ibs/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/_ibs/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/_ibs/ResourceManagement/index.js b/src/views/_ibs/ResourceManagement/index.js new file mode 100644 index 00000000..5882decd --- /dev/null +++ b/src/views/_ibs/ResourceManagement/index.js @@ -0,0 +1,2 @@ +import Power from './Power.vue'; +export default Power; diff --git a/src/views/_ibs/SecurityAndAccess/Certificates/Certificates.vue b/src/views/_ibs/SecurityAndAccess/Certificates/Certificates.vue new file mode 100644 index 00000000..761d09be --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import StatusIcon from '@/components/_ibs/Global/StatusIcon'; +import Alert from '@/components/_ibs/Global/Alert'; + +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/SecurityAndAccess/Certificates/CsrCountryCodes.js b/src/views/_ibs/SecurityAndAccess/Certificates/CsrCountryCodes.js new file mode 100644 index 00000000..a2d70007 --- /dev/null +++ b/src/views/_ibs/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/_ibs/SecurityAndAccess/Certificates/ModalGenerateCsr.vue b/src/views/_ibs/SecurityAndAccess/Certificates/ModalGenerateCsr.vue new file mode 100644 index 00000000..83e3110b --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/SecurityAndAccess/Certificates/ModalUploadCertificate.vue b/src/views/_ibs/SecurityAndAccess/Certificates/ModalUploadCertificate.vue new file mode 100644 index 00000000..f13c174d --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/_ibs/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/_ibs/SecurityAndAccess/Certificates/index.js b/src/views/_ibs/SecurityAndAccess/Certificates/index.js new file mode 100644 index 00000000..aff57b59 --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/Certificates/index.js @@ -0,0 +1,2 @@ +import Certificates from './Certificates.vue'; +export default Certificates; diff --git a/src/views/_ibs/SecurityAndAccess/Ldap/Ldap.vue b/src/views/_ibs/SecurityAndAccess/Ldap/Ldap.vue new file mode 100644 index 00000000..4fb3d818 --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/Ldap/Ldap.vue @@ -0,0 +1,439 @@ +<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="ldap-settings"> + <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> + </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/_ibs/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_ibs/Mixins/VuelidateMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/Mixins/LoadingBarMixin'; +import InputPasswordToggle from '@/components/_ibs/Global/InputPasswordToggle'; +import PageTitle from '@/components/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import InfoTooltip from '@/components/_ibs/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/_ibs/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue b/src/views/_ibs/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue new file mode 100644 index 00000000..ea35da04 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/SecurityAndAccess/Ldap/TableRoleGroups.vue b/src/views/_ibs/SecurityAndAccess/Ldap/TableRoleGroups.vue new file mode 100644 index 00000000..c5e08973 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/Alert'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import ModalAddRoleGroup from './ModalAddRoleGroup'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/SecurityAndAccess/Ldap/index.js b/src/views/_ibs/SecurityAndAccess/Ldap/index.js new file mode 100644 index 00000000..6ae3abfc --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/Ldap/index.js @@ -0,0 +1,2 @@ +import Ldap from './Ldap.vue'; +export default Ldap; diff --git a/src/views/_ibs/SecurityAndAccess/Policies/Policies.vue b/src/views/_ibs/SecurityAndAccess/Policies/Policies.vue new file mode 100644 index 00000000..99cc3722 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; + +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/_ibs/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/_ibs/SecurityAndAccess/Policies/index.js b/src/views/_ibs/SecurityAndAccess/Policies/index.js new file mode 100644 index 00000000..77023908 --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/Policies/index.js @@ -0,0 +1,2 @@ +import Policies from './Policies.vue'; +export default Policies; diff --git a/src/views/_ibs/SecurityAndAccess/Sessions/Sessions.vue b/src/views/_ibs/SecurityAndAccess/Sessions/Sessions.vue new file mode 100644 index 00000000..6e10c09c --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import Search from '@/components/_ibs/Global/Search'; +import TableCellCount from '@/components/_ibs/Global/TableCellCount'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; + +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_ibs/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_ibs/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/_ibs/SecurityAndAccess/Sessions/index.js b/src/views/_ibs/SecurityAndAccess/Sessions/index.js new file mode 100644 index 00000000..aa113aff --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/Sessions/index.js @@ -0,0 +1,2 @@ +import Sessions from './Sessions.vue'; +export default Sessions; diff --git a/src/views/_ibs/SecurityAndAccess/UserManagement/ModalSettings.vue b/src/views/_ibs/SecurityAndAccess/UserManagement/ModalSettings.vue new file mode 100644 index 00000000..4ba13f13 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/SecurityAndAccess/UserManagement/ModalUser.vue b/src/views/_ibs/SecurityAndAccess/UserManagement/ModalUser.vue new file mode 100644 index 00000000..823bf765 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/VuelidateMixin.js'; +import InputPasswordToggle from '@/components/_ibs/Global/InputPasswordToggle'; +import Alert from '@/components/_ibs/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/_ibs/SecurityAndAccess/UserManagement/TableRoles.vue b/src/views/_ibs/SecurityAndAccess/UserManagement/TableRoles.vue new file mode 100644 index 00000000..61ef1ee8 --- /dev/null +++ b/src/views/_ibs/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/_ibs/SecurityAndAccess/UserManagement/UserManagement.vue b/src/views/_ibs/SecurityAndAccess/UserManagement/UserManagement.vue new file mode 100644 index 00000000..623ca31f --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import TableRoles from './TableRoles'; +import TableToolbar from '@/components/_ibs/Global/TableToolbar'; +import TableRowAction from '@/components/_ibs/Global/TableRowAction'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_ibs/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_ibs/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/_ibs/SecurityAndAccess/UserManagement/index.js b/src/views/_ibs/SecurityAndAccess/UserManagement/index.js new file mode 100644 index 00000000..c3aebec3 --- /dev/null +++ b/src/views/_ibs/SecurityAndAccess/UserManagement/index.js @@ -0,0 +1,2 @@ +import UserManagement from './UserManagement.vue'; +export default UserManagement; diff --git a/src/views/_ibs/Settings/DateTime/DateTime.vue b/src/views/_ibs/Settings/DateTime/DateTime.vue new file mode 100644 index 00000000..0f905ccb --- /dev/null +++ b/src/views/_ibs/Settings/DateTime/DateTime.vue @@ -0,0 +1,419 @@ +<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/_ibs/Global/Alert'; +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import PageTitle from '@/components/_ibs/Global/PageTitle'; +import PageSection from '@/components/_ibs/Global/PageSection'; + +import BVToastMixin from '@/components/_ibs/Mixins/BVToastMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/_ibs/Mixins/LocalTimezoneLabelMixin'; +import VuelidateMixin from '@/components/_ibs/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/_ibs/Settings/DateTime/index.js b/src/views/_ibs/Settings/DateTime/index.js new file mode 100644 index 00000000..2df21eae --- /dev/null +++ b/src/views/_ibs/Settings/DateTime/index.js @@ -0,0 +1,2 @@ +import DateTime from './DateTime.vue'; +export default DateTime; diff --git a/src/views/_ibs/Settings/Network/ModalDns.vue b/src/views/_ibs/Settings/Network/ModalDns.vue new file mode 100644 index 00000000..9a370809 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Settings/Network/ModalHostname.vue b/src/views/_ibs/Settings/Network/ModalHostname.vue new file mode 100644 index 00000000..0567fe61 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Settings/Network/ModalIpv4.vue b/src/views/_ibs/Settings/Network/ModalIpv4.vue new file mode 100644 index 00000000..c3c7b311 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Settings/Network/ModalMacAddress.vue b/src/views/_ibs/Settings/Network/ModalMacAddress.vue new file mode 100644 index 00000000..ae30d17a --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Settings/Network/Network.vue b/src/views/_ibs/Settings/Network/Network.vue new file mode 100644 index 00000000..4229f82c --- /dev/null +++ b/src/views/_ibs/Settings/Network/Network.vue @@ -0,0 +1,169 @@ +<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/_ibs/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_ibs/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/_ibs/Global/PageSection'; +import PageTitle from '@/components/_ibs/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/_ibs/Settings/Network/NetworkGlobalSettings.vue b/src/views/_ibs/Settings/Network/NetworkGlobalSettings.vue new file mode 100644 index 00000000..3f35a4d9 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import DataFormatterMixin from '@/components/_ibs/Mixins/DataFormatterMixin'; +import PageSection from '@/components/_ibs/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/_ibs/Settings/Network/NetworkInterfaceSettings.vue b/src/views/_ibs/Settings/Network/NetworkInterfaceSettings.vue new file mode 100644 index 00000000..64a9c76c --- /dev/null +++ b/src/views/_ibs/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/_ibs/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import DataFormatterMixin from '@/components/_ibs/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/_ibs/Settings/Network/TableDns.vue b/src/views/_ibs/Settings/Network/TableDns.vue new file mode 100644 index 00000000..01252db3 --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Global/PageSection'; +import TableRowAction from '@/components/_ibs/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/_ibs/Settings/Network/TableIpv4.vue b/src/views/_ibs/Settings/Network/TableIpv4.vue new file mode 100644 index 00000000..a8a0486d --- /dev/null +++ b/src/views/_ibs/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/_ibs/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/_ibs/Mixins/LoadingBarMixin'; +import PageSection from '@/components/_ibs/Global/PageSection'; +import TableRowAction from '@/components/_ibs/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/_ibs/Settings/Network/index.js b/src/views/_ibs/Settings/Network/index.js new file mode 100644 index 00000000..97bf0397 --- /dev/null +++ b/src/views/_ibs/Settings/Network/index.js @@ -0,0 +1,2 @@ +import Network from './Network.vue'; +export default Network; diff --git a/src/views/_ibs/Settings/PowerRestorePolicy/PowerRestorePolicy.vue b/src/views/_ibs/Settings/PowerRestorePolicy/PowerRestorePolicy.vue new file mode 100644 index 00000000..2c0f70f4 --- /dev/null +++ b/src/views/_ibs/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/_ibs/Global/PageTitle'; +import LoadingBarMixin from '@/components/_ibs/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_ibs/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/_ibs/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/_ibs/Settings/PowerRestorePolicy/index.js b/src/views/_ibs/Settings/PowerRestorePolicy/index.js new file mode 100644 index 00000000..fab0d477 --- /dev/null +++ b/src/views/_ibs/Settings/PowerRestorePolicy/index.js @@ -0,0 +1,2 @@ +import PowerRestorePolicy from './PowerRestorePolicy.vue'; +export default PowerRestorePolicy; diff --git a/src/views/_sila/ChangePassword/ChangePassword.vue b/src/views/_sila/ChangePassword/ChangePassword.vue new file mode 100644 index 00000000..9bd243d1 --- /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/_sila/Global/Alert'; +import VuelidateMixin from '@/components/_sila/Mixins/VuelidateMixin'; +import InputPasswordToggle from '@/components/_sila/Global/InputPasswordToggle'; +import BVToastMixin from '@/components/_sila/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/Fans/Dynamic/FanSpeed.vue b/src/views/_sila/Fans/Dynamic/FanSpeed.vue new file mode 100644 index 00000000..b119c268 --- /dev/null +++ b/src/views/_sila/Fans/Dynamic/FanSpeed.vue @@ -0,0 +1,184 @@ +<template> + <div> + <b-col class="d-flex flex-nowrap align-items-center page-divider"> + <img src="@/assets/images/_sila/collapsed/fan.svg" /> + {{ $t('pageFans.speed') }} + </b-col> + <b-row class="align-items-end limit-container"> + <b-col xs="12" md="4" class="pt-2"> + <b-form-group :label="$t('pageFans.labels.warning')"> + <b-form-input v-model="warning" type="number" :min="0" :max="3150"> + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="4" class="pt-2"> + <b-form-group :label="$t('pageFans.labels.shutdown')"> + <b-form-input + v-model="shutdown" + :min="warning" + :max="4000" + type="number" + ></b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="4" class="pt-2"> + <b-button variant="primary" style="height: 35px"> + {{ 'Сохранить' }} + </b-button> + </b-col> + </b-row> + <chart + type="fans" + :colors="colors" + :time-scale="timeScale" + :data="filteredSensors" + :warning="warning" + :shutdown="shutdown" + ></chart> + + <b-table + v-if="items && items.length > 0" + responsive="md" + show-empty + table-variant="accessory" + hover + :items="items" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(name)="{ value, index }"> + <div + class="item-color" + :style="`background-color: ${colors[index]}`" + ></div> + {{ value }} + </template> + <template #cell(minDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + <template #cell(maxDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + </b-table> + </div> +</template> +<script> +import Chart from '@/components/_sila/Global/Chart'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import { getItems } from '@/utilities/_sila/metricProperties'; + +export default { + components: { Chart }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + props: { + timeScale: { + type: String, + default: 'hour', + }, + }, + data() { + return { + warning: 2450, + shutdown: 3150, + fields: [ + { + key: 'name', + label: this.$t('pageFans.table.name'), + }, + { + key: 'current', + label: this.$t('pageFans.table.current'), + }, + { + key: 'middle', + label: this.$t('pageFans.table.middle'), + }, + { + key: 'min', + label: this.$t('pageFans.table.min'), + }, + { + key: 'minDate', + label: this.$t('pageFans.table.minDate'), + }, + { + key: 'max', + label: this.$t('pageFans.table.max'), + }, + { + key: 'maxDate', + label: this.$t('pageFans.table.maxDate'), + }, + ], + }; + }, + + computed: { + items() { + return getItems(this.filteredSensors); + }, + + allSensors() { + return this.timeScale === 'hour' + ? this.$store.getters['fan/fansLastHour'] + : this.$store.getters['fan/fans']; + }, + + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + + colors() { + return this.$randomColor({ + count: this.items?.length, + hue: 'random', + luminosity: 'random', + }); + }, + }, + + watch: { + timeScale() { + this.loadData(); + }, + }, + + created() { + this.loadData(); + }, + + methods: { + onOpened(state) { + if (state) { + this.loadData(); + } + }, + + loadData() { + let payload = { lastHour: false }; + if (this.timeScale === 'hour') { + payload = { lastHour: true }; + } + + this.startLoader(); + this.$store.dispatch('fan/getFansDynamic', payload).finally(() => { + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Fans/Dynamic/FansDynamicPage.vue b/src/views/_sila/Fans/Dynamic/FansDynamicPage.vue new file mode 100644 index 00000000..8be848d5 --- /dev/null +++ b/src/views/_sila/Fans/Dynamic/FansDynamicPage.vue @@ -0,0 +1,37 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('appPageTitle.dynamicInformation')" /> + <table-date-picker + :switch-time-scale="switchTimeScale" + :time-scale="timeScale" + /> + <fan-speed :time-scale="timeScale"></fan-speed> + </b-container> +</template> + +<script> +import PageTitle from '@/components/_sila/Global/PageTitle'; +import TableDatePicker from '@/components/_sila/Global/TableDatePicker'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import FanSpeed from './FanSpeed'; + +export default { + components: { FanSpeed, PageTitle, TableDatePicker }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + data() { + return { + timeScale: 'hour', + }; + }, + + methods: { + switchTimeScale(period) { + this.timeScale = period; + }, + }, +}; +</script> diff --git a/src/views/_sila/Fans/Dynamic/index.js b/src/views/_sila/Fans/Dynamic/index.js new file mode 100644 index 00000000..a3dadd5a --- /dev/null +++ b/src/views/_sila/Fans/Dynamic/index.js @@ -0,0 +1,2 @@ +import FansDynamicPage from './FansDynamicPage.vue'; +export default FansDynamicPage; diff --git a/src/views/_sila/Fans/Static/FansStaticPage.vue b/src/views/_sila/Fans/Static/FansStaticPage.vue new file mode 100644 index 00000000..3ce9079e --- /dev/null +++ b/src/views/_sila/Fans/Static/FansStaticPage.vue @@ -0,0 +1,82 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('appPageTitle.specification')" /> + <page-section :section-small-title="$t('pageFans.installedFans')"> + <b-table + responsive="md" + show-empty + hover + :items="fans" + :busy="isBusy" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(health)="{ value }"> + <status-icon :status="statusIcon(value)" /> + {{ value }} + </template> + </b-table> + </page-section> + </b-container> +</template> + +<script> +import PageTitle from '@/components/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; + +import StatusIcon from '@/components/_sila//Global/StatusIcon'; + +export default { + components: { PageTitle, PageSection, StatusIcon }, + mixins: [DataFormatterMixin, LoadingBarMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'name', + label: this.$t('pageFans.table.name'), + formatter: this.dataFormatter, + thStyle: { width: '25%' }, + }, + { + key: 'health', + label: this.$t('pageFans.table.health'), + formatter: this.dataFormatter, + thStyle: { width: '25%' }, + tdClass: 'text-nowrap', + }, + { + key: 'type', + label: this.$t('pageFans.table.type'), + formatter: this.dataFormatter, + thStyle: { width: '25%' }, + }, + { + key: 'speedRPM', + label: this.$t('pageFans.table.currentValue'), + formatter: this.dataFormatter, + thStyle: { width: '25%' }, + }, + ], + }; + }, + + computed: { + fans() { + return this.$store.getters['fan/fans']; + }, + }, + + created() { + this.startLoader(); + this.$store.dispatch('fan/getFanInfo').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, +}; +</script> diff --git a/src/views/_sila/Fans/Static/index.js b/src/views/_sila/Fans/Static/index.js new file mode 100644 index 00000000..9a5d913d --- /dev/null +++ b/src/views/_sila/Fans/Static/index.js @@ -0,0 +1,2 @@ +import FansStaticPage from './FansStaticPage.vue'; +export default FansStaticPage; diff --git a/src/views/_sila/HardwareStatus/Sensors/Sensors.vue b/src/views/_sila/HardwareStatus/Sensors/Sensors.vue new file mode 100644 index 00000000..3f1eb59a --- /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="8" xl="6" class="search-block d-sm-flex align-items-end mb-4"> + <search + :placeholder="$t('pageSensors.searchForSensors')" + data-test-id="sensors-input-searchForSensors" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + <div class="ml-sm-4"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allSensors.length" + ></table-cell-count> + </div> + </b-col> + <b-col 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/_sila/Global/PageTitle'; +import Search from '@/components/_sila/Global/Search'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import TableFilter from '@/components/_sila/Global/TableFilter'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import TableToolbarExport from '@/components/_sila/Global/TableToolbarExport'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/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..e11f3468 --- /dev/null +++ b/src/views/_sila/Login/Login.vue @@ -0,0 +1,142 @@ +<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/_sila/Mixins/VuelidateMixin.js'; +import i18n from '@/i18n'; +import Alert from '@/components/_sila/Global/Alert'; +import InputPasswordToggle from '@/components/_sila/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: '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..523d36d0 --- /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/_sila/Global/PageSection'; +import PageTitle from '@/components/_sila/Global/PageTitle'; +import Search from '@/components/_sila/Global/Search'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import TableDateFilter from '@/components/_sila/Global/TableDateFilter'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_sila/Mixins/BVPaginationMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/Mixins/SearchFilterMixin'; +import TableFilter from '@/components/_sila/Global/TableFilter'; +import TableFilterMixin from '@/components/_sila/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..acd755c7 --- /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/_sila/Global/Alert'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_sila/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..49ef960e --- /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/_sila/Global/StatusIcon'; +import VuelidateMixin from '@/components/_sila/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..19a8b205 --- /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="search-block 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-row> + <b-row class="align-items-center date-filter"> + <b-col> + <table-date-filter @change="onChangeDateTimeFilter" /> + </b-col> + <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/_sila/Global/PageTitle'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import Search from '@/components/_sila/Global/Search'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import TableDateFilter from '@/components/_sila/Global/TableDateFilter'; +import TableFilter from '@/components/_sila/Global/TableFilter'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import TableToolbarExport from '@/components/_sila/Global/TableToolbarExport'; + +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_sila/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/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..80a837c7 --- /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="search-block 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-row> + <b-row class="align-items-center date-filter"> + <b-col> + <table-date-filter @change="onChangeDateTimeFilter" /> + </b-col> + <b-col 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/_sila/Global/PageTitle'; +import Search from '@/components/_sila/Global/Search'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import TableDateFilter from '@/components/_sila/Global/TableDateFilter'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import TableToolbarExport from '@/components/_sila/Global/TableToolbarExport'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_sila/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/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/Memory/Dynamic/MemoryDynamicPage.vue b/src/views/_sila/Memory/Dynamic/MemoryDynamicPage.vue new file mode 100644 index 00000000..b9360024 --- /dev/null +++ b/src/views/_sila/Memory/Dynamic/MemoryDynamicPage.vue @@ -0,0 +1,37 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('appPageTitle.dynamicInformation')" /> + <table-date-picker + :switch-time-scale="switchTimeScale" + :time-scale="timeScale" + /> + <memory-temp :time-scale="timeScale"></memory-temp> + </b-container> +</template> + +<script> +import PageTitle from '@/components/_sila/Global/PageTitle'; +import TableDatePicker from '@/components/_sila/Global/TableDatePicker'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import MemoryTemp from './MemoryTemp'; + +export default { + components: { MemoryTemp, PageTitle, TableDatePicker }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + data() { + return { + timeScale: 'hour', + }; + }, + + methods: { + switchTimeScale(period) { + this.timeScale = period; + }, + }, +}; +</script> diff --git a/src/views/_sila/Memory/Dynamic/MemoryTemp.vue b/src/views/_sila/Memory/Dynamic/MemoryTemp.vue new file mode 100644 index 00000000..695006ba --- /dev/null +++ b/src/views/_sila/Memory/Dynamic/MemoryTemp.vue @@ -0,0 +1,197 @@ +<template> + <div> + <b-col class="d-flex flex-nowrap align-items-center page-divider"> + <img src="@/assets/images/_sila/collapsed/temperature.svg" /> + {{ $t('pageMemory.temperature') }} + </b-col> + <b-row class="align-items-end limit-container"> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageMemory.labels.notNormal')"> + <b-form-input + v-model="notNormal" + type="number" + :min="0" + :max="critical" + ></b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageMemory.labels.critical')"> + <b-form-input + v-model="critical" + type="number" + :min="notNormal" + :max="warning" + > + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageMemory.labels.warning')"> + <b-form-input + v-model="warning" + type="number" + :min="critical" + :max="100" + > + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-button variant="primary" style="height: 35px"> + {{ 'Сохранить' }} + </b-button> + </b-col> + </b-row> + <b-col class="pl-4 pr-4"> + <chart + type="memory" + :colors="colors" + :time-scale="timeScale" + :data="filteredSensors" + :warning="warning" + :not-normal="notNormal" + :critical="critical" + ></chart> + <b-table + v-if="items && items.length > 0" + responsive="md" + show-empty + table-variant="accessory" + hover + :items="items" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(name)="{ value, index }"> + <div + class="item-color" + :style="`background-color: ${colors[index]}`" + ></div> + {{ value }} + </template> + <template #cell(minDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + <template #cell(maxDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + </b-table> + </b-col> + </div> +</template> +<script> +import Chart from '@/components/_sila/Global/Chart'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import { getItems } from '@/utilities/_sila/metricProperties'; + +export default { + components: { Chart }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + props: { + timeScale: { + type: String, + default: 'hour', + }, + }, + data() { + return { + warning: 72, + notNormal: 44, + critical: 55, + fields: [ + { + key: 'name', + label: this.$t('pageMemory.table.name'), + }, + { + key: 'current', + label: this.$t('pageMemory.table.currentTemperature'), + }, + { + key: 'middle', + label: this.$t('pageMemory.table.middleTemperature'), + }, + { + key: 'min', + label: this.$t('pageMemory.table.minTemperature'), + }, + { + key: 'minDate', + label: this.$t('pageMemory.table.minDate'), + }, + { + key: 'max', + label: this.$t('pageMemory.table.maxTemperature'), + }, + { + key: 'maxDate', + label: this.$t('pageMemory.table.maxDate'), + }, + ], + }; + }, + + computed: { + items() { + return getItems(this.filteredSensors); + }, + + allSensors() { + return this.timeScale === 'hour' + ? this.$store.getters['memory/dimmsLastHour'] + : this.$store.getters['memory/dimms']; + }, + + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + + colors() { + return this.$randomColor({ + count: this.items?.length, + hue: 'random', + luminosity: 'random', + }); + }, + }, + + watch: { + timeScale() { + this.loadData(); + }, + }, + + created() { + this.loadData(); + }, + + methods: { + loadData() { + let payload = { lastHour: false }; + if (this.timeScale === 'hour') { + payload = { lastHour: true }; + } + + this.startLoader(); + this.$store.dispatch('memory/getMemoryDynamic', payload).finally(() => { + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Memory/Dynamic/index.js b/src/views/_sila/Memory/Dynamic/index.js new file mode 100644 index 00000000..b840772c --- /dev/null +++ b/src/views/_sila/Memory/Dynamic/index.js @@ -0,0 +1,2 @@ +import MemoryDynamicPage from './MemoryDynamicPage.vue'; +export default MemoryDynamicPage; diff --git a/src/views/_sila/Motherboard/Dynamic/MotherboardDynamicPage.vue b/src/views/_sila/Motherboard/Dynamic/MotherboardDynamicPage.vue new file mode 100644 index 00000000..15768432 --- /dev/null +++ b/src/views/_sila/Motherboard/Dynamic/MotherboardDynamicPage.vue @@ -0,0 +1,37 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('appPageTitle.dynamicInformation')" /> + <table-date-picker + :switch-time-scale="switchTimeScale" + :time-scale="timeScale" + /> + <motherboard-temp :time-scale="timeScale"></motherboard-temp> + </b-container> +</template> + +<script> +import PageTitle from '@/components/_sila/Global/PageTitle'; +import TableDatePicker from '@/components/_sila/Global/TableDatePicker'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import MotherboardTemp from './MotherboardTemp'; + +export default { + components: { MotherboardTemp, PageTitle, TableDatePicker }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + data() { + return { + timeScale: 'hour', + }; + }, + + methods: { + switchTimeScale(period) { + this.timeScale = period; + }, + }, +}; +</script> diff --git a/src/views/_sila/Motherboard/Dynamic/MotherboardTemp.vue b/src/views/_sila/Motherboard/Dynamic/MotherboardTemp.vue new file mode 100644 index 00000000..35355edb --- /dev/null +++ b/src/views/_sila/Motherboard/Dynamic/MotherboardTemp.vue @@ -0,0 +1,174 @@ +<template> + <div> + <b-col class="d-flex flex-nowrap align-items-center page-divider"> + <img src="@/assets/images/_sila/collapsed/temperature.svg" /> + {{ $t('pageMotherboard.temperature') }} + </b-col> + + <b-row class="align-items-end limit-container"> + <b-col xs="12" sm="3" class="pt-4"> + <b-form-group :label="$t('pageMotherboard.labels.warning')"> + <b-form-input + v-model="warning" + type="number" + :min="0" + :max="100" + ></b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" sm="6" class="pt-2"> + <b-button variant="primary" style="height: 35px"> + {{ 'Сохранить' }} + </b-button> + </b-col> + </b-row> + <b-col class="pl-4 pr-4"> + <chart + type="motherboard" + :time-scale="timeScale" + :colors="colors" + :data="filteredSensors" + :warning="warning" + ></chart> + <b-table + v-if="items && items.length > 0" + responsive="md" + show-empty + table-variant="accessory" + hover + :items="items" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(name)="{ value, index }"> + <div + class="item-color" + :style="`background-color: ${colors[index]}`" + ></div> + {{ value }} + </template> + <template #cell(minDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + <template #cell(maxDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + </b-table> + </b-col> + </div> +</template> +<script> +import Chart from '@/components/_sila/Global/Chart'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import { getItems } from '@/utilities/_sila/metricProperties'; + +export default { + components: { Chart }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + props: { + timeScale: { + type: String, + default: 'hour', + }, + }, + data() { + return { + warning: 72, + fields: [ + { + key: 'name', + label: this.$t('pageMemory.table.name'), + }, + { + key: 'current', + label: this.$t('pageMemory.table.currentTemperature'), + }, + { + key: 'middle', + label: this.$t('pageMemory.table.middleTemperature'), + }, + { + key: 'min', + label: this.$t('pageMemory.table.minTemperature'), + }, + { + key: 'minDate', + label: this.$t('pageMemory.table.minDate'), + }, + { + key: 'max', + label: this.$t('pageMemory.table.maxTemperature'), + }, + { + key: 'maxDate', + label: this.$t('pageMemory.table.maxDate'), + }, + ], + }; + }, + + computed: { + items() { + return getItems(this.filteredSensors); + }, + + allSensors() { + return this.timeScale === 'hour' + ? this.$store.getters['motherboard/motherboardLastHour'] + : this.$store.getters['motherboard/motherboard']; + }, + + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + + colors() { + return this.$randomColor({ + count: this.items?.length, + hue: 'random', + luminosity: 'random', + }); + }, + }, + + watch: { + timeScale() { + this.loadData(); + }, + }, + + created() { + this.loadData(); + }, + + methods: { + loadData() { + let payload = { lastHour: false }; + if (this.timeScale === 'hour') { + payload = { lastHour: true }; + } + + this.startLoader(); + this.$store + .dispatch('motherboard/getMotherboardDynamic', payload) + .finally(() => { + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Motherboard/Dynamic/index.js b/src/views/_sila/Motherboard/Dynamic/index.js new file mode 100644 index 00000000..bd155997 --- /dev/null +++ b/src/views/_sila/Motherboard/Dynamic/index.js @@ -0,0 +1,2 @@ +import MotherboardDynamicPage from './MotherboardDynamicPage.vue'; +export default MotherboardDynamicPage; diff --git a/src/views/_sila/Operations/FactoryReset/FactoryReset.vue b/src/views/_sila/Operations/FactoryReset/FactoryReset.vue new file mode 100644 index 00000000..4e315619 --- /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/_sila/Global/PageTitle'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/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..be2927f3 --- /dev/null +++ b/src/views/_sila/Operations/FactoryReset/FactoryResetModal.vue @@ -0,0 +1,112 @@ +<template> + <b-modal + id="modal-reset" + ref="modal" + :title="$t(`pageFactoryReset.modal.${resetType}Title`)" + @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/_sila/Global/StatusIcon'; +import VuelidateMixin from '@/components/_sila/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..0497376d --- /dev/null +++ b/src/views/_sila/Operations/Firmware/Firmware.vue @@ -0,0 +1,95 @@ +<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/_sila/Global/PageSection'; +import PageTitle from '@/components/_sila/Global/PageTitle'; + +import LoadingBarMixin, { + loading, +} from '@/components/_sila/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..471ccfac --- /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/_sila/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..23c263d9 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareCardsBmc.vue @@ -0,0 +1,138 @@ +<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/_sila/Global/PageSection'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/_sila/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..03a25ee5 --- /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/_sila/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..23fe90f2 --- /dev/null +++ b/src/views/_sila/Operations/Firmware/FirmwareFormUpdate.vue @@ -0,0 +1,189 @@ +<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/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_sila/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/_sila/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..8955f6cd --- /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/_sila/Global/PageTitle'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import Alert from '@/components/_sila/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..ede24608 --- /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/_sila/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..50cbff7f --- /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/_sila/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..fa16f55e --- /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/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/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..22824772 --- /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/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/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..b1087805 --- /dev/null +++ b/src/views/_sila/Operations/SerialOverLan/SerialOverLanConsole.vue @@ -0,0 +1,174 @@ +<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/_sila/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/_sila/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({ + cols: 96, + rows: 44, + 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..8d74e381 --- /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/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/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..e848215f --- /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/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import BootSettings from './BootSettings'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import Alert from '@/components/_sila/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..9886eff5 --- /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/_sila/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..8b396f41 --- /dev/null +++ b/src/views/_sila/Operations/VirtualMedia/VirtualMedia.vue @@ -0,0 +1,231 @@ +<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" + :style="{ 'margin-bottom': dev.isActive ? '0' : '1rem' }" + > + <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> + + <div + v-if="dev.isActive && dev.file && dev.file.name" + class="clear-selected-file px-3" + :style="{ 'margin-bottom': '1rem' }" + > + {{ dev.file.name }} + </div> + + <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-form-group> + <b-button + v-if="!device.isActive" + variant="primary" + :disabled="!device.serverUri" + @click="startLegacy(device)" + > + {{ $t('pageVirtualMedia.start') }} + </b-button> + <b-button + v-if="device.isActive" + variant="primary" + @click="stopLegacy(device)" + > + {{ $t('pageVirtualMedia.stop') }} + </b-button> + </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/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import ModalConfigureConnection from './ModalConfigureConnection'; +import NbdServer from '@/utilities/NBDServer'; +import FormFile from '@/components/_sila/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/DateTime/DateTime.vue b/src/views/_sila/Overview/DateTime/DateTime.vue new file mode 100644 index 00000000..66871699 --- /dev/null +++ b/src/views/_sila/Overview/DateTime/DateTime.vue @@ -0,0 +1,419 @@ +<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/_sila/Global/Alert'; +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import PageTitle from '@/components/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; + +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/_sila/Mixins/LocalTimezoneLabelMixin'; +import VuelidateMixin from '@/components/_sila/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/Overview/DateTime/index.js b/src/views/_sila/Overview/DateTime/index.js new file mode 100644 index 00000000..2df21eae --- /dev/null +++ b/src/views/_sila/Overview/DateTime/index.js @@ -0,0 +1,2 @@ +import DateTime from './DateTime.vue'; +export default DateTime; diff --git a/src/views/_sila/Overview/Inventory/Inventory.vue b/src/views/_sila/Overview/Inventory/Inventory.vue new file mode 100644 index 00000000..41258597 --- /dev/null +++ b/src/views/_sila/Overview/Inventory/Inventory.vue @@ -0,0 +1,195 @@ +<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" + > + {{ 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/_sila/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/_sila/Mixins/LoadingBarMixin'; +import PageSection from '@/components/_sila/Global/PageSection'; +import JumpLinkMixin from '@/components/_sila/Mixins/JumpLinkMixin'; +import { chunk } from 'lodash'; + +export default { + components: { + PageTitle, + ServiceIndicator, + TableDimmSlot, + TablePowerSupplies, + TableSystem, + TableFans, + TableBmcManager, + TableChassis, + TableProcessors, + TableAssembly, + PageSection, + }, + 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() { + console.log(123456, process.env.VUE_APP_ENV_NAME); + 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/Overview/Inventory/InventoryServiceIndicator.vue b/src/views/_sila/Overview/Inventory/InventoryServiceIndicator.vue new file mode 100644 index 00000000..b4531be7 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import BVToastMixin from '@/components/_sila/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/Overview/Inventory/InventoryTableAssembly.vue b/src/views/_sila/Overview/Inventory/InventoryTableAssembly.vue new file mode 100644 index 00000000..9c284533 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_sila/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/Overview/Inventory/InventoryTableBmcManager.vue b/src/views/_sila/Overview/Inventory/InventoryTableBmcManager.vue new file mode 100644 index 00000000..e3375d57 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_sila/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/Overview/Inventory/InventoryTableChassis.vue b/src/views/_sila/Overview/Inventory/InventoryTableChassis.vue new file mode 100644 index 00000000..a5eb5ae6 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_sila/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/Overview/Inventory/InventoryTableDimmSlot.vue b/src/views/_sila/Overview/Inventory/InventoryTableDimmSlot.vue new file mode 100644 index 00000000..14160502 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import Search from '@/components/_sila/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/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/Overview/Inventory/InventoryTableFans.vue b/src/views/_sila/Overview/Inventory/InventoryTableFans.vue new file mode 100644 index 00000000..1012b8a9 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; + +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import Search from '@/components/_sila/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/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: 'name', + 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/Overview/Inventory/InventoryTablePowerSupplies.vue b/src/views/_sila/Overview/Inventory/InventoryTablePowerSupplies.vue new file mode 100644 index 00000000..a55b3e5e --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import Search from '@/components/_sila/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/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/Overview/Inventory/InventoryTableProcessors.vue b/src/views/_sila/Overview/Inventory/InventoryTableProcessors.vue new file mode 100644 index 00000000..c6c798cf --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/_sila/Mixins/TableSortMixin'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import Search from '@/components/_sila/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/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/Overview/Inventory/InventoryTableSystem.vue b/src/views/_sila/Overview/Inventory/InventoryTableSystem.vue new file mode 100644 index 00000000..eacc4a06 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Mixins/BVToastMixin'; +import PageSection from '@/components/_sila/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/_sila/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/_sila/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/_sila/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/Overview/Inventory/index.js b/src/views/_sila/Overview/Inventory/index.js new file mode 100644 index 00000000..c9fde8d2 --- /dev/null +++ b/src/views/_sila/Overview/Inventory/index.js @@ -0,0 +1,2 @@ +import Inventory from './Inventory.vue'; +export default Inventory; diff --git a/src/views/_sila/Overview/Network/ModalDns.vue b/src/views/_sila/Overview/Network/ModalDns.vue new file mode 100644 index 00000000..82edb1e8 --- /dev/null +++ b/src/views/_sila/Overview/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> + <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/_sila/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/Overview/Network/ModalHostname.vue b/src/views/_sila/Overview/Network/ModalHostname.vue new file mode 100644 index 00000000..3ad152c1 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/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/Overview/Network/ModalIpv4.vue b/src/views/_sila/Overview/Network/ModalIpv4.vue new file mode 100644 index 00000000..00742a11 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/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/Overview/Network/ModalMacAddress.vue b/src/views/_sila/Overview/Network/ModalMacAddress.vue new file mode 100644 index 00000000..98f4b019 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/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/Overview/Network/Network.vue b/src/views/_sila/Overview/Network/Network.vue new file mode 100644 index 00000000..2321b1bd --- /dev/null +++ b/src/views/_sila/Overview/Network/Network.vue @@ -0,0 +1,169 @@ +<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/_sila/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/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/_sila/Global/PageSection'; +import PageTitle from '@/components/_sila/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/Overview/Network/NetworkGlobalSettings.vue b/src/views/_sila/Overview/Network/NetworkGlobalSettings.vue new file mode 100644 index 00000000..44035ae6 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import PageSection from '@/components/_sila/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/Overview/Network/NetworkInterfaceSettings.vue b/src/views/_sila/Overview/Network/NetworkInterfaceSettings.vue new file mode 100644 index 00000000..657a2270 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import PageSection from '@/components/_sila/Global/PageSection'; +import DataFormatterMixin from '@/components/_sila/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/Overview/Network/TableDns.vue b/src/views/_sila/Overview/Network/TableDns.vue new file mode 100644 index 00000000..3b3cc4b4 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/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/_sila/Global/PageSection'; +import TableRowAction from '@/components/_sila/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/Overview/Network/TableIpv4.vue b/src/views/_sila/Overview/Network/TableIpv4.vue new file mode 100644 index 00000000..4f9f9df1 --- /dev/null +++ b/src/views/_sila/Overview/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/_sila/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/_sila/Mixins/LoadingBarMixin'; +import PageSection from '@/components/_sila/Global/PageSection'; +import TableRowAction from '@/components/_sila/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/Overview/Network/index.js b/src/views/_sila/Overview/Network/index.js new file mode 100644 index 00000000..97bf0397 --- /dev/null +++ b/src/views/_sila/Overview/Network/index.js @@ -0,0 +1,2 @@ +import Network from './Network.vue'; +export default Network; diff --git a/src/views/_sila/Overview/Overview.vue b/src/views/_sila/Overview/Overview.vue new file mode 100644 index 00000000..9f97fb3e --- /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/_sila/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/_sila/Global/PageSection'; +import PageTitle from '@/components/_sila/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..27f5067d --- /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/_sila/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..c54bc5b9 --- /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/_sila/Global/StatusIcon'; +import DataFormatterMixin from '@/components/_sila/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..9167c75c --- /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/_sila/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..7010b991 --- /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/_sila/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..ffda495f --- /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/_sila/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..6f74fd91 --- /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/_sila/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..7dded5ba --- /dev/null +++ b/src/views/_sila/Overview/OverviewServer.vue @@ -0,0 +1,47 @@ +<template> + <overview-card + :title="$t('pageOverview.serverInformation')" + :to="`/hardware-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/_sila/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..bce56016 --- /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/_sila/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/Processors/Dynamic/CpuPower.vue b/src/views/_sila/Processors/Dynamic/CpuPower.vue new file mode 100644 index 00000000..a5a2a98f --- /dev/null +++ b/src/views/_sila/Processors/Dynamic/CpuPower.vue @@ -0,0 +1,168 @@ +<template> + <collapse + id="collapse_power" + :title="$t('pageProcessors.power')" + @opened="onOpened" + > + <template #image> + <img src="@/assets/images/_sila/collapsed/power.svg" /> + </template> + <page-section> + <b-row class="align-items-end p-2"> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageProcessors.labels.warning')"> + <b-form-input + v-model="warning" + type="number" + :min="0" + :max="100" + ></b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageProcessors.labels.shutdown')"> + <b-form-input v-model="shutdown" type="number" :min="0" :max="100"> + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-button variant="primary" style="height: 35px"> + {{ 'Сохранить' }} + </b-button> + </b-col> + </b-row> + <chart + type="power" + :time-scale="timeScale" + :data="filteredSensors" + :colors="colors" + :warning="+warning" + :shutdown="+shutdown" + ></chart> + <b-table + v-if="items && items.length > 0" + responsive="md" + show-empty + table-variant="accessory" + hover + :items="items" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(name)="{ value, index }"> + <div + class="item-color" + :style="`background-color: ${colors[index]}`" + ></div> + {{ value }} + </template> + <template #cell(minDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + <template #cell(maxDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + </b-table> + </page-section> + </collapse> +</template> +<script> +import Chart from '@/components/_sila/Global/Chart'; +import PageSection from '@/components/Global/PageSection'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; +import Collapse from '@/components/_sila/Global/Collapse'; + +import { getItems } from '@/utilities/_sila/metricProperties'; + +export default { + components: { PageSection, Chart, Collapse }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + props: { + timeScale: { + type: String, + default: 'hour', + }, + }, + data() { + return { + warning: 66, + shutdown: 88, + fields: [ + { + key: 'name', + label: this.$t('pageProcessors.table.power.name'), + thStyle: { width: '25%' }, + }, + { + key: 'current', + label: this.$t('pageProcessors.table.power.current'), + }, + ], + }; + }, + + computed: { + items() { + return getItems(this.filteredSensors); + }, + + allSensors() { + return this.timeScale === 'hour' + ? this.$store.getters['processors/cpuPowerLastHour'] + : this.$store.getters['processors/cpuPower']; + }, + + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + + colors() { + return this.$randomColor({ + count: this.items?.length, + hue: 'random', + luminosity: 'random', + }); + }, + }, + watch: { + timeScale() { + this.loadData(); + }, + }, + + methods: { + onOpened(state) { + if (state) { + this.loadData(); + } + }, + + loadData() { + let payload = { lastHour: false }; + if (this.timeScale === 'hour') { + payload = { lastHour: true }; + } + + this.startLoader(); + this.$store + .dispatch('processors/getCpuPowerDynamic', payload) + .finally(() => { + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Processors/Dynamic/CpuTemp.vue b/src/views/_sila/Processors/Dynamic/CpuTemp.vue new file mode 100644 index 00000000..0adcae84 --- /dev/null +++ b/src/views/_sila/Processors/Dynamic/CpuTemp.vue @@ -0,0 +1,197 @@ +<template> + <collapse + id="collapse_temp" + :title="$t('pageProcessors.temperature')" + @opened="onOpened" + > + <template #image> + <img src="@/assets/images/_sila/collapsed/temperature.svg" /> + </template> + <page-section> + <b-row class="align-items-end p-2"> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageProcessors.labels.notNormal')"> + <b-form-input + v-model="notNormal" + type="number" + :min="0" + :max="100" + ></b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageProcessors.labels.critical')"> + <b-form-input v-model="critical" type="number" :min="0" :max="100"> + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-form-group :label="$t('pageProcessors.labels.warning')"> + <b-form-input v-model="warning" type="number" :min="0" :max="100"> + </b-form-input> + </b-form-group> + </b-col> + <b-col xs="12" md="6" lg="3" class="pt-2"> + <b-button variant="primary" style="height: 35px"> + {{ 'Сохранить' }} + </b-button> + </b-col> + </b-row> + <chart + type="processors" + :time-scale="timeScale" + :data="filteredSensors" + :colors="colors" + :warning="+warning" + :not-normal="+notNormal" + :critical="+critical" + ></chart> + <b-table + v-if="items && items.length > 0" + responsive="md" + show-empty + table-variant="accessory" + hover + :items="items" + :fields="fields" + :empty-text="$t('global.table.emptyMessage')" + > + <template #cell(name)="{ value, index }"> + <div + class="item-color" + :style="`background-color: ${colors[index]}`" + ></div> + {{ value }} + </template> + <template #cell(minDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + <template #cell(maxDate)="{ value }"> + <span style="color: rgb(12, 28, 41)"> + {{ value.time }} + </span> + <span> + {{ value.date }} + </span> + </template> + </b-table> + </page-section> + </collapse> +</template> +<script> +import Chart from '@/components/_sila/Global/Chart'; +import PageSection from '@/components/Global/PageSection'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; +import Collapse from '@/components/_sila/Global/Collapse'; + +import { getItems } from '@/utilities/_sila/metricProperties'; + +export default { + components: { PageSection, Chart, Collapse }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + props: { + timeScale: { + type: String, + default: 'hour', + }, + }, + data() { + return { + warning: 72, + notNormal: 44, + critical: 55, + fields: [ + { + key: 'name', + label: this.$t('pageProcessors.table.temperature.name'), + thStyle: { width: '25%' }, + }, + { + key: 'current', + label: this.$t('pageProcessors.table.temperature.current'), + }, + { + key: 'middle', + label: this.$t('pageProcessors.table.temperature.middle'), + }, + { + key: 'min', + label: this.$t('pageProcessors.table.temperature.min'), + }, + { + key: 'minDate', + label: this.$t('pageProcessors.table.temperature.minDate'), + }, + { + key: 'max', + label: this.$t('pageProcessors.table.temperature.max'), + }, + { + key: 'maxDate', + label: this.$t('pageProcessors.table.temperature.maxDate'), + }, + ], + }; + }, + + computed: { + items() { + return getItems(this.filteredSensors); + }, + + allSensors() { + return this.timeScale === 'hour' + ? this.$store.getters['processors/cpuTempLastHour'] + : this.$store.getters['processors/cpuTemp']; + }, + + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + + colors() { + return this.$randomColor({ + count: this.items?.length, + hue: 'random', + luminosity: 'random', + }); + }, + }, + + watch: { + timeScale() { + this.loadData(); + }, + }, + + methods: { + onOpened(state) { + if (state) { + this.loadData(); + } + }, + + loadData() { + let payload = { lastHour: false }; + if (this.timeScale === 'hour') { + payload = { lastHour: true }; + } + + this.startLoader(); + this.$store + .dispatch('processors/getCpuTempDynamic', payload) + .finally(() => { + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_sila/Processors/Dynamic/ProcessorsDynamicPage.vue b/src/views/_sila/Processors/Dynamic/ProcessorsDynamicPage.vue new file mode 100644 index 00000000..eef6fd0b --- /dev/null +++ b/src/views/_sila/Processors/Dynamic/ProcessorsDynamicPage.vue @@ -0,0 +1,43 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('appPageTitle.dynamicInformation')" /> + <table-date-picker + :switch-time-scale="switchTimeScale" + :time-scale="timeScale" + /> + <cpu-temp :time-scale="timeScale"></cpu-temp> + <cpu-power :time-scale="timeScale"></cpu-power> + </b-container> +</template> +<script> +import PageTitle from '@/components/_sila/Global/PageTitle'; + +import DataFormatterMixin from '@/components/_sila/Mixins/DataFormatterMixin'; +import TableDatePicker from '@/components/_sila/Global/TableDatePicker'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/_sila/Mixins/TableFilterMixin'; + +import CpuPower from './CpuPower'; +import CpuTemp from './CpuTemp'; + +export default { + components: { + CpuPower, + CpuTemp, + PageTitle, + TableDatePicker, + }, + mixins: [DataFormatterMixin, LoadingBarMixin, TableFilterMixin], + data() { + return { + timeScale: 'hour', + }; + }, + + methods: { + switchTimeScale(period) { + this.timeScale = period; + }, + }, +}; +</script> diff --git a/src/views/_sila/Processors/Dynamic/index.js b/src/views/_sila/Processors/Dynamic/index.js new file mode 100644 index 00000000..121c0316 --- /dev/null +++ b/src/views/_sila/Processors/Dynamic/index.js @@ -0,0 +1,2 @@ +import ProcessorsDynamicPage from './ProcessorsDynamicPage.vue'; +export default ProcessorsDynamicPage; diff --git a/src/views/_sila/ProfileSettings/ProfileSettings.vue b/src/views/_sila/ProfileSettings/ProfileSettings.vue new file mode 100644 index 00000000..bc2b4702 --- /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/_sila/Mixins/BVToastMixin'; +import InputPasswordToggle from '@/components/_sila/Global/InputPasswordToggle'; +import { maxLength, minLength, sameAs } from 'vuelidate/lib/validators'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/_sila/Mixins/LocalTimezoneLabelMixin'; +import PageTitle from '@/components/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; +import VuelidateMixin from '@/components/_sila/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..3c1e6412 --- /dev/null +++ b/src/views/_sila/ResourceManagement/Power.vue @@ -0,0 +1,172 @@ +<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="4"> + <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/_sila/Global/PageTitle'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_sila/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/_sila/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..27950b76 --- /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/_sila/Global/PageTitle'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import StatusIcon from '@/components/_sila/Global/StatusIcon'; +import Alert from '@/components/_sila/Global/Alert'; + +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/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..63258a40 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Certificates/ModalGenerateCsr.vue @@ -0,0 +1,493 @@ +<template> + <div> + <b-modal + id="generate-csr" + ref="modal" + 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="12"> + <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-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-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/_sila/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_sila/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..5d58018f --- /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/_sila/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/_sila/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..6800ead5 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Ldap/Ldap.vue @@ -0,0 +1,437 @@ +<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="4" lg="5" xl="7"> + <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/_sila/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/_sila/Mixins/VuelidateMixin'; +import LoadingBarMixin, { + loading, +} from '@/components/_sila/Mixins/LoadingBarMixin'; +import InputPasswordToggle from '@/components/_sila/Global/InputPasswordToggle'; +import PageTitle from '@/components/_sila/Global/PageTitle'; +import PageSection from '@/components/_sila/Global/PageSection'; +import InfoTooltip from '@/components/_sila/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..aa294ce3 --- /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/_sila/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..ca52da13 --- /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/_sila/Global/Alert'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import ModalAddRoleGroup from './ModalAddRoleGroup'; +import LoadingBarMixin from '@/components/_sila/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..0de80de2 --- /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="9"> + <b-row v-if="!modifySSHPolicyDisabled" class="setting-section"> + <b-col class="d-flex align-items-center justify-content"> + <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"> + <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"> + <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"> + <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/_sila/Global/PageTitle'; + +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/_sila/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..057fc827 --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/Sessions/Sessions.vue @@ -0,0 +1,298 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="align-items-center"> + <b-col sm="8" xl="6" class="search-block d-sm-flex align-items-end mb-4"> + <search + :placeholder="$t('pageSessions.table.searchSessions')" + data-test-id="sessions-input-searchSessions" + @change-search="onChangeSearchInput" + @clear-search="onClearSearchInput" + /> + <div class="ml-sm-4"> + <table-cell-count + :filtered-items-count="filteredRows" + :total-number-of-cells="allConnections.length" + ></table-cell-count> + </div> + </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 + no-border-collapse + 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 class="bootstrap-table__section"> + <b-col sm="6"> + <b-form-group + class="table-pagination-select" + :label="$t('global.table.itemsPerPage')" + label-class="semi-bold-16px" + label-for="pagination-items-per-page" + > + <b-form-select + id="pagination-items-per-page" + v-model="perPage" + class="select-per-page semi-bold-16px" + :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/_sila/Global/PageTitle'; +import Search from '@/components/_sila/Global/Search'; +import TableCellCount from '@/components/_sila/Global/TableCellCount'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; + +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/_sila/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/_sila/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', + thStyle: { width: '3%' }, + }, + { + 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..bbe3f53c --- /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/_sila/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..c7aa56bb --- /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/_sila/Mixins/VuelidateMixin.js'; +import InputPasswordToggle from '@/components/_sila/Global/InputPasswordToggle'; +import Alert from '@/components/_sila/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..b29fa20d --- /dev/null +++ b/src/views/_sila/SecurityAndAccess/UserManagement/TableRoles.vue @@ -0,0 +1,92 @@ +<template> + <b-table stacked="sm" hover :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..ae8c5209 --- /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="9"> + <b-button + v-b-toggle.collapse-role-table + data-test-id="userManagement-button-viewPrivilegeRoleDescriptions" + variant="link" + class="mt-3 pl-0 pr-1" + > + <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/_sila/Global/PageTitle'; +import TableRoles from './TableRoles'; +import TableToolbar from '@/components/_sila/Global/TableToolbar'; +import TableRowAction from '@/components/_sila/Global/TableRowAction'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/_sila/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/_sila/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/_sila/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/PowerRestorePolicy/PowerRestorePolicy.vue b/src/views/_sila/Settings/PowerRestorePolicy/PowerRestorePolicy.vue new file mode 100644 index 00000000..c357d47a --- /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/_sila/Global/PageTitle'; +import LoadingBarMixin from '@/components/_sila/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/_sila/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/_sila/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; |