diff options
Diffstat (limited to 'src/views')
101 files changed, 12892 insertions, 0 deletions
diff --git a/src/views/_ibs/ChangePassword/ChangePassword.vue b/src/views/_ibs/ChangePassword/ChangePassword.vue new file mode 100644 index 00000000..2440ace1 --- /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/Global/Alert'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'ChangePassword', + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin, BVToastMixin], + data() { + return { + form: { + password: null, + passwordConfirm: null, + }, + username: this.$store.getters['global/username'], + changePasswordError: false, + }; + }, + validations() { + return { + form: { + password: { required }, + passwordConfirm: { + required, + sameAsPassword: sameAs('password'), + }, + }, + }; + }, + methods: { + goBack() { + // Remove session created if navigating back to the Login page + this.$store.dispatch('authentication/logout'); + }, + changePassword() { + this.$v.$touch(); + if (this.$v.$invalid) return; + let data = { + originalUsername: this.username, + password: this.form.password, + }; + + this.$store + .dispatch('userManagement/updateUser', data) + .then(() => this.$router.push('/')) + .catch(() => (this.changePasswordError = true)); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.change-password__form-container { + @include media-breakpoint-up('md') { + max-width: 360px; + } +} +</style> diff --git a/src/views/_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..0cc542e7 --- /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/Global/PageTitle'; +import ServiceIndicator from './InventoryServiceIndicator'; +import TableSystem from './InventoryTableSystem'; +import TablePowerSupplies from './InventoryTablePowerSupplies'; +import TableDimmSlot from './InventoryTableDimmSlot'; +import TableFans from './InventoryTableFans'; +import TableBmcManager from './InventoryTableBmcManager'; +import TableChassis from './InventoryTableChassis'; +import TableProcessors from './InventoryTableProcessors'; +import TableAssembly from './InventoryTableAssembly'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import PageSection from '@/components/Global/PageSection'; +import JumpLink16 from '@carbon/icons-vue/es/jump-link/16'; +import JumpLinkMixin from '@/components/Mixins/JumpLinkMixin'; +import { chunk } from 'lodash'; + +export default { + components: { + PageTitle, + ServiceIndicator, + TableDimmSlot, + TablePowerSupplies, + TableSystem, + TableFans, + TableBmcManager, + TableChassis, + TableProcessors, + TableAssembly, + PageSection, + JumpLink: JumpLink16, + }, + mixins: [LoadingBarMixin, JumpLinkMixin], + beforeRouteLeave(to, from, next) { + // Hide loader if user navigates away from page + // before requests complete + this.hideLoader(); + next(); + }, + data() { + return { + links: [ + { + id: 'system', + dataRef: 'system', + href: '#system', + linkText: this.$t('pageInventory.system'), + }, + { + id: 'bmc', + dataRef: 'bmc', + href: '#bmc', + linkText: this.$t('pageInventory.bmcManager'), + }, + { + id: 'chassis', + dataRef: 'chassis', + href: '#chassis', + linkText: this.$t('pageInventory.chassis'), + }, + { + id: 'dimms', + dataRef: 'dimms', + href: '#dimms', + linkText: this.$t('pageInventory.dimmSlot'), + }, + { + id: 'fans', + dataRef: 'fans', + href: '#fans', + linkText: this.$t('pageInventory.fans'), + }, + { + id: 'powerSupply', + dataRef: 'powerSupply', + href: '#powerSupply', + linkText: this.$t('pageInventory.powerSupplies'), + }, + { + id: 'processors', + dataRef: 'processors', + href: '#processors', + linkText: this.$t('pageInventory.processors'), + }, + { + id: 'assembly', + dataRef: 'assembly', + href: '#assembly', + linkText: this.$t('pageInventory.assemblies'), + }, + ], + }; + }, + computed: { + quicklinkColumns() { + // Chunk links array to 3 array's to display 3 items per column + return chunk(this.links, 3); + }, + }, + created() { + this.startLoader(); + const bmcManagerTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-bmc-manager-complete', () => resolve()); + }); + const chassisTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-chassis-complete', () => resolve()); + }); + const dimmSlotTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-dimm-slot-complete', () => resolve()); + }); + const fansTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-fans-complete', () => resolve()); + }); + const powerSuppliesTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-power-supplies-complete', () => + resolve() + ); + }); + const processorsTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-processors-complete', () => resolve()); + }); + const serviceIndicatorPromise = new Promise((resolve) => { + this.$root.$on('hardware-status-service-complete', () => resolve()); + }); + const systemTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-system-complete', () => resolve()); + }); + const assemblyTablePromise = new Promise((resolve) => { + this.$root.$on('hardware-status-assembly-complete', () => resolve()); + }); + // Combine all child component Promises to indicate + // when page data load complete + Promise.all([ + bmcManagerTablePromise, + chassisTablePromise, + dimmSlotTablePromise, + fansTablePromise, + powerSuppliesTablePromise, + processorsTablePromise, + serviceIndicatorPromise, + systemTablePromise, + assemblyTablePromise, + ]).finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryServiceIndicator.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryServiceIndicator.vue new file mode 100644 index 00000000..01f4a446 --- /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/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + components: { PageSection }, + mixins: [BVToastMixin], + computed: { + systems() { + let systemData = this.$store.getters['system/systems'][0]; + return systemData ? systemData : {}; + }, + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + powerStatus() { + if (this.serverStatus === 'unreachable') { + return `global.status.off`; + } + return `global.status.${this.serverStatus}`; + }, + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-service-complete'); + }); + }, + methods: { + toggleIdentifyLedSwitch(state) { + this.$store + .dispatch('system/changeIdentifyLedState', state) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableAssembly.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableAssembly.vue new file mode 100644 index 00000000..b4010bfe --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'name', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'partNumber', + label: this.$t('pageInventory.table.partNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + assemblies() { + return this.$store.getters['assemblies/assemblies']; + }, + items() { + if (this.assemblies) { + return this.assemblies; + } else { + return []; + } + }, + }, + created() { + this.$store.dispatch('assemblies/getAssemblyInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-assembly-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('assemblies/updateIdentifyLedValue', { + uri: row.uri, + memberId: row.id, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableBmcManager.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableBmcManager.vue new file mode 100644 index 00000000..48b914f4 --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/Global/StatusIcon'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + bmc() { + return this.$store.getters['bmc/bmc']; + }, + items() { + if (this.bmc) { + return [this.bmc]; + } else { + return []; + } + }, + }, + created() { + this.$store.dispatch('bmc/getBmcInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-bmc-manager-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('bmc/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: remove hasIdentifyLed method once the following story is merged: + // https://gerrit.openbmc-project.xyz/c/openbmc/bmcweb/+/43179 + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableChassis.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableChassis.vue new file mode 100644 index 00000000..b49cec7f --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import StatusIcon from '@/components/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + chassis() { + return this.$store.getters['chassis/chassis']; + }, + }, + created() { + this.$store.dispatch('chassis/getChassisInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-chassis-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedValue(row) { + this.$store + .dispatch('chassis/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: Remove this method when the LocationIndicatorActive is added from backend. + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableDimmSlot.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableDimmSlot.vue new file mode 100644 index 00000000..65994810 --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.dimms.length; + }, + dimms() { + return this.$store.getters['memory/dimms']; + }, + }, + created() { + this.$store.dispatch('memory/getDimms').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-dimm-slot-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + toggleIdentifyLedValue(row) { + this.$store + .dispatch('memory/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableFans.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableFans.vue new file mode 100644 index 00000000..fe788c53 --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'partNumber', + label: this.$t('pageInventory.table.partNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'serialNumber', + label: this.$t('pageInventory.table.serialNumber'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.fans.length; + }, + fans() { + return this.$store.getters['fan/fans']; + }, + }, + created() { + this.$store.dispatch('fan/getFanInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-fans-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTablePowerSupplies.vue new file mode 100644 index 00000000..aed7871a --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.powerSupplies.length; + }, + powerSupplies() { + return this.$store.getters['powerSupply/powerSupplies']; + }, + }, + created() { + this.$store.dispatch('powerSupply/getAllPowerSupplies').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-power-supplies-complete'); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'health') { + return this.sortStatus(a, b, key); + } + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableProcessors.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableProcessors.vue new file mode 100644 index 00000000..7d5dd700 --- /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/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; +import StatusIcon from '@/components/Global/StatusIcon'; +import TableCellCount from '@/components/Global/TableCellCount'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import Search from '@/components/Global/Search'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon, Search, TableCellCount }, + mixins: [ + BVToastMixin, + TableRowExpandMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + sortable: true, + }, + { + key: 'identifyLed', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + sortable: false, + }, + ], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + expandRowLabel: expandRowLabel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.processors.length; + }, + processors() { + return this.$store.getters['processors/processors']; + }, + }, + created() { + this.$store.dispatch('processors/getProcessorsInfo').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-processors-complete'); + this.isBusy = false; + }); + }, + methods: { + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + toggleIdentifyLedValue(row) { + this.$store + .dispatch('processors/updateIdentifyLedValue', { + uri: row.uri, + identifyLed: row.identifyLed, + }) + .catch(({ message }) => this.errorToast(message)); + }, + // TO DO: remove hasIdentifyLed when the following is merged: + // https://gerrit.openbmc-project.xyz/c/openbmc/bmcweb/+/37045 + hasIdentifyLed(identifyLed) { + return typeof identifyLed === 'boolean'; + }, + }, +}; +</script> diff --git a/src/views/_ibs/HardwareStatus/Inventory/InventoryTableSystem.vue b/src/views/_ibs/HardwareStatus/Inventory/InventoryTableSystem.vue new file mode 100644 index 00000000..cf2cf020 --- /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/Mixins/BVToastMixin'; +import PageSection from '@/components/Global/PageSection'; +import IconChevron from '@carbon/icons-vue/es/chevron--down/20'; + +import StatusIcon from '@/components/Global/StatusIcon'; + +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + components: { IconChevron, PageSection, StatusIcon }, + mixins: [BVToastMixin, TableRowExpandMixin, DataFormatterMixin], + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'id', + label: this.$t('pageInventory.table.id'), + formatter: this.dataFormatter, + }, + { + key: 'hardwareType', + label: this.$t('pageInventory.table.hardwareType'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'health', + label: this.$t('pageInventory.table.health'), + formatter: this.dataFormatter, + tdClass: 'text-nowrap', + }, + { + key: 'locationNumber', + label: this.$t('pageInventory.table.locationNumber'), + formatter: this.dataFormatter, + }, + { + key: 'locationIndicatorActive', + label: this.$t('pageInventory.table.identifyLed'), + formatter: this.dataFormatter, + }, + ], + expandRowLabel: expandRowLabel, + }; + }, + computed: { + systems() { + return this.$store.getters['system/systems']; + }, + }, + created() { + this.$store.dispatch('system/getSystem').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('hardware-status-system-complete'); + this.isBusy = false; + }); + }, + methods: { + toggleIdentifyLedSwitch(state) { + this.$store + .dispatch('system/changeIdentifyLedState', state) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_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..6329d9d8 --- /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/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import StatusIcon from '@/components/Global/StatusIcon'; +import TableFilter from '@/components/Global/TableFilter'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; +import TableCellCount from '@/components/Global/TableCellCount'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + name: 'Sensors', + components: { + PageTitle, + Search, + StatusIcon, + TableCellCount, + TableFilter, + TableToolbar, + TableToolbarExport, + }, + mixins: [ + TableFilterMixin, + BVTableSelectableMixin, + LoadingBarMixin, + DataFormatterMixin, + TableSortMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + label: '', + }, + { + key: 'name', + sortable: true, + label: this.$t('pageSensors.table.name'), + }, + { + key: 'status', + sortable: true, + label: this.$t('pageSensors.table.status'), + tdClass: 'text-nowrap', + }, + { + key: 'lowerCritical', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.lowerCritical'), + }, + { + key: 'lowerCaution', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.lowerWarning'), + }, + + { + key: 'currentValue', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.currentValue'), + }, + { + key: 'upperCaution', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.upperWarning'), + }, + { + key: 'upperCritical', + formatter: this.dataFormatter, + label: this.$t('pageSensors.table.upperCritical'), + }, + ], + tableFilters: [ + { + key: 'status', + label: this.$t('pageSensors.table.status'), + values: ['OK', 'Warning', 'Critical'], + }, + ], + activeFilters: [], + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + allSensors() { + return this.$store.getters['sensors/sensors']; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredSensors.length; + }, + filteredSensors() { + return this.getFilteredTableData(this.allSensors, this.activeFilters); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('sensors/getAllSensors').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + sortCompare(a, b, key) { + if (key === 'status') { + return this.sortStatus(a, b, key); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeSearchInput(event) { + this.searchFilter = event; + }, + exportFileNameByDate() { + // Create export file name based on date + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + return this.$t('pageSensors.exportFilePrefix') + date; + }, + }, +}; +</script> diff --git a/src/views/_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..bd79d09b --- /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/Mixins/VuelidateMixin.js'; +import i18n from '@/i18n'; +import Alert from '@/components/Global/Alert'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; + +export default { + name: 'Login', + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin], + data() { + return { + userInfo: { + username: null, + password: null, + }, + disableSubmitButton: false, + languages: [ + { + value: 'en-US', + text: 'English', + }, + { + value: '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..81c9de04 --- /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/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; +import TableFilter from '@/components/Global/TableFilter'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; + +export default { + components: { + DumpsForm, + IconDelete, + IconDownload, + PageSection, + PageTitle, + Search, + TableCellCount, + TableDateFilter, + TableRowAction, + TableToolbar, + TableFilter, + }, + mixins: [ + BVTableSelectableMixin, + BVToastMixin, + BVPaginationMixin, + LoadingBarMixin, + SearchFilterMixin, + TableFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'dateTime', + label: this.$t('pageDumps.table.dateAndTime'), + sortable: true, + }, + { + key: 'dumpType', + label: this.$t('pageDumps.table.dumpType'), + sortable: true, + }, + { + key: 'id', + label: this.$t('pageDumps.table.id'), + sortable: true, + }, + { + key: 'size', + label: this.$t('pageDumps.table.size'), + sortable: true, + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + tableFilters: [ + { + key: 'dumpType', + label: this.$t('pageDumps.table.dumpType'), + values: [ + 'BMC Dump Entry', + 'Hostboot Dump Entry', + 'Resource Dump Entry', + 'System Dump Entry', + ], + }, + ], + activeFilters: [], + currentPage: currentPage, + filterEndDate: null, + filterStartDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter, + searchTotalFilteredRows: 0, + selectedRows, + tableHeaderCheckboxIndeterminate, + tableHeaderCheckboxModel, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredDumps.length; + }, + allDumps() { + return this.$store.getters['dumps/allDumps'].map((item) => { + return { + ...item, + actions: [ + { + value: 'download', + title: this.$t('global.action.download'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + }; + }); + }, + filteredDumpsByDate() { + return this.getFilteredTableDataByDate( + this.allDumps, + this.filterStartDate, + this.filterEndDate, + 'dateTime' + ); + }, + filteredDumps() { + return this.getFilteredTableData( + this.filteredDumpsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('dumps/getBmcDumpEntries').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + convertBytesToMegabytes(bytes) { + return parseFloat((bytes / 1000000).toFixed(3)); + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onTableRowAction(action, dump) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageDumps.modal.deleteDumpConfirmation'), { + title: this.$tc('pageDumps.modal.deleteDump'), + okTitle: this.$tc('pageDumps.modal.deleteDump'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfrimed) => { + if (deleteConfrimed) { + this.$store + .dispatch('dumps/deleteDumps', [dump]) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + } + }); + } + }, + onTableBatchAction(action) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageDumps.modal.deleteDumpConfirmation', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageDumps.modal.deleteDump', + this.selectedRows.length + ), + okTitle: this.$tc( + 'pageDumps.modal.deleteDump', + this.selectedRows.length + ), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfrimed) => { + if (deleteConfrimed) { + if (this.selectedRows.length === this.dumps.length) { + this.$store + .dispatch('dumps/deleteAllDumps') + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)); + } else { + this.$store + .dispatch('dumps/deleteDumps', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + } + } + }); + } + }, + exportFileName(row) { + let filename = row.item.dumpType + '_' + row.item.id + '.tar.xz'; + filename = filename.replace(RegExp(' ', 'g'), '_'); + return filename; + }, + }, +}; +</script> diff --git a/src/views/_ibs/Logs/Dumps/DumpsForm.vue b/src/views/_ibs/Logs/Dumps/DumpsForm.vue new file mode 100644 index 00000000..07f4a060 --- /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/Global/Alert'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + components: { Alert, ModalConfirmation }, + mixins: [BVToastMixin, VuelidateMixin], + data() { + return { + selectedDumpType: null, + dumpTypeOptions: [ + { value: 'bmc', text: this.$t('pageDumps.form.bmcDump') }, + { value: 'system', text: this.$t('pageDumps.form.systemDump') }, + ], + }; + }, + validations() { + return { + selectedDumpType: { required }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + + // System dump initiation + if (this.selectedDumpType === 'system') { + this.showConfirmationModal(); + } + // BMC dump initiation + else if (this.selectedDumpType === 'bmc') { + this.$store + .dispatch('dumps/createBmcDump') + .then(() => + this.infoToast(this.$t('pageDumps.toast.successStartBmcDump'), { + title: this.$t('pageDumps.toast.successStartBmcDumpTitle'), + timestamp: true, + }) + ) + .catch(({ message }) => this.errorToast(message)); + } + }, + showConfirmationModal() { + this.$bvModal.show('modal-confirmation'); + }, + createSystemDump() { + this.$store + .dispatch('dumps/createSystemDump') + .then(() => + this.infoToast(this.$t('pageDumps.toast.successStartSystemDump'), { + title: this.$t('pageDumps.toast.successStartSystemDumpTitle'), + timestamp: true, + }) + ) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Logs/Dumps/DumpsModalConfirmation.vue b/src/views/_ibs/Logs/Dumps/DumpsModalConfirmation.vue new file mode 100644 index 00000000..f8e20cfd --- /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/Global/StatusIcon'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + components: { StatusIcon }, + mixins: [VuelidateMixin], + data() { + return { + confirmed: false, + }; + }, + validations: { + confirmed: { + mustBeTrue: (value) => value === true, + }, + }, + methods: { + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok'); + this.closeModal(); + }, + resetForm() { + this.confirmed = false; + this.$v.$reset(); + }, + }, +}; +</script> diff --git a/src/views/_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..e7fc507b --- /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/Global/PageTitle'; +import StatusIcon from '@/components/Global/StatusIcon'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableFilter from '@/components/Global/TableFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + IconDelete, + IconExport, + IconTrashcan, + IconChevron, + IconDownload, + PageTitle, + Search, + StatusIcon, + TableCellCount, + TableFilter, + TableRowAction, + TableToolbar, + TableToolbarExport, + TableDateFilter, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + TableFilterMixin, + DataFormatterMixin, + TableSortMixin, + TableRowExpandMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'expandRow', + label: '', + tdClass: 'table-row-expand', + }, + { + key: 'checkbox', + sortable: false, + }, + { + key: 'id', + label: this.$t('pageEventLogs.table.id'), + sortable: true, + }, + { + key: 'severity', + label: this.$t('pageEventLogs.table.severity'), + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'date', + label: this.$t('pageEventLogs.table.date'), + sortable: true, + tdClass: 'text-nowrap', + }, + { + key: 'description', + label: this.$t('pageEventLogs.table.description'), + tdClass: 'text-break', + }, + { + key: 'status', + label: this.$t('pageEventLogs.table.status'), + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + tableFilters: [ + { + key: 'severity', + label: this.$t('pageEventLogs.table.severity'), + values: ['OK', 'Warning', 'Critical'], + }, + { + key: 'filterByStatus', + label: this.$t('pageEventLogs.table.status'), + values: ['Resolved', 'Unresolved'], + }, + ], + expandRowLabel, + activeFilters: [], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + currentPage: currentPage, + filterStartDate: null, + filterEndDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + href() { + return `data:text/json;charset=utf-8,${this.exportAllLogs()}`; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredLogs.length; + }, + allLogs() { + return this.$store.getters['eventLog/allEvents'].map((event) => { + return { + ...event, + actions: [ + { + value: 'export', + title: this.$t('global.action.export'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + }; + }); + }, + batchExportData() { + return this.selectedRows.map((row) => omit(row, 'actions')); + }, + filteredLogsByDate() { + return this.getFilteredTableDataByDate( + this.allLogs, + this.filterStartDate, + this.filterEndDate + ); + }, + filteredLogs() { + return this.getFilteredTableData( + this.filteredLogsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('eventLog/getEventLogData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + changelogStatus(row) { + this.$store + .dispatch('eventLog/updateEventLogStatus', { + uri: row.uri, + status: row.status, + }) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => this.errorToast(message)); + }, + deleteAllLogs() { + this.$bvModal + .msgBoxConfirm(this.$t('pageEventLogs.modal.deleteAllMessage'), { + title: this.$t('pageEventLogs.modal.deleteAllTitle'), + okTitle: this.$t('global.action.delete'), + okVariant: 'danger', + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.$store + .dispatch('eventLog/deleteAllEventLogs', this.allLogs) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + } + }); + }, + deleteLogs(uris) { + this.$store + .dispatch('eventLog/deleteEventLogs', uris) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + exportAllLogs() { + { + return this.$store.getters['eventLog/allEvents'].map((eventLogs) => { + const allEventLogsString = JSON.stringify(eventLogs); + return allEventLogsString; + }); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onSortCompare(a, b, key) { + if (key === 'severity') { + return this.sortStatus(a, b, key); + } + }, + onTableRowAction(action, { uri }) { + if (action === 'delete') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageEventLogs.modal.deleteMessage'), { + title: this.$tc('pageEventLogs.modal.deleteTitle'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.deleteLogs([uri]); + }); + } + }, + onBatchAction(action) { + if (action === 'delete') { + const uris = this.selectedRows.map((row) => row.uri); + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageEventLogs.modal.deleteMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageEventLogs.modal.deleteTitle', + this.selectedRows.length + ), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + if (this.selectedRows.length === this.allLogs.length) { + this.$store + .dispatch( + 'eventLog/deleteAllEventLogs', + this.selectedRows.length + ) + .then(() => { + this.successToast( + this.$tc('pageEventLogs.toast.successDelete', uris.length) + ); + }) + .catch(({ message }) => this.errorToast(message)); + } else { + this.deleteLogs(uris); + } + } + }); + } + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + // Create export file name based on date + exportFileNameByDate(value) { + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName; + if (value === 'export') { + fileName = 'event_log_'; + } else { + fileName = 'all_event_logs_'; + } + return fileName + date; + }, + resolveLogs() { + this.$store + .dispatch('eventLog/resolveEventLogs', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + unresolveLogs() { + this.$store + .dispatch('eventLog/unresolveEventLogs', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + }, +}; +</script> diff --git a/src/views/_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..ea3d54ab --- /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/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableDateFilter from '@/components/Global/TableDateFilter'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableToolbarExport from '@/components/Global/TableToolbarExport'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import TableFilterMixin from '@/components/Mixins/TableFilterMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import TableSortMixin from '@/components/Mixins/TableSortMixin'; +import TableRowExpandMixin, { + expandRowLabel, +} from '@/components/Mixins/TableRowExpandMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + IconExport, + IconDownload, + PageTitle, + Search, + TableCellCount, + TableRowAction, + TableToolbar, + TableToolbarExport, + TableDateFilter, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + TableFilterMixin, + TableSortMixin, + TableRowExpandMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'date', + label: this.$t('pagePostCodeLogs.table.created'), + sortable: true, + }, + { + key: 'timeStampOffset', + label: this.$t('pagePostCodeLogs.table.timeStampOffset'), + }, + { + key: 'bootCount', + label: this.$t('pagePostCodeLogs.table.bootCount'), + }, + { + key: 'postCode', + label: this.$t('pagePostCodeLogs.table.postCode'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + expandRowLabel, + activeFilters: [], + currentPage: currentPage, + filterStartDate: null, + filterEndDate: null, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + searchFilter: searchFilter, + searchTotalFilteredRows: 0, + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + href() { + return `data:text/json;charset=utf-8,${this.exportAllLogsString()}`; + }, + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.filteredLogs.length; + }, + allLogs() { + return this.$store.getters['postCodeLogs/allPostCodes'].map( + (postCodes) => { + return { + ...postCodes, + actions: [ + { + value: 'export', + title: this.$t('pagePostCodeLogs.action.exportLogs'), + }, + { + value: 'download', + title: this.$t('pagePostCodeLogs.action.downloadDetails'), + }, + ], + }; + } + ); + }, + batchExportData() { + return this.selectedRows.map((row) => omit(row, 'actions')); + }, + filteredLogsByDate() { + return this.getFilteredTableDataByDate( + this.allLogs, + this.filterStartDate, + this.filterEndDate + ); + }, + filteredLogs() { + return this.getFilteredTableData( + this.filteredLogsByDate, + this.activeFilters + ); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('postCodeLogs/getPostCodesLogData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + exportAllLogsString() { + { + return this.$store.getters['postCodeLogs/allPostCodes'].map( + (postCodes) => { + const allLogsString = JSON.stringify(postCodes); + return allLogsString; + } + ); + } + }, + onFilterChange({ activeFilters }) { + this.activeFilters = activeFilters; + }, + onChangeDateTimeFilter({ fromDate, toDate }) { + this.filterStartDate = fromDate; + this.filterEndDate = toDate; + }, + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + // Create export file name based on date and action + exportFileNameByDate(value) { + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName; + if (value === 'download') { + fileName = this.$t('pagePostCodeLogs.downloadFilePrefix'); + } else if (value === 'export') { + fileName = this.$t('pagePostCodeLogs.exportFilePrefix'); + } else { + fileName = this.$t('pagePostCodeLogs.allExportFilePrefix'); + } + return fileName + date; + }, + }, +}; +</script> diff --git a/src/views/_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..897348fc --- /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/Global/PageTitle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import ModalReset from './FactoryResetModal'; + +export default { + name: 'FactoryReset', + components: { PageTitle, ModalReset }, + mixins: [LoadingBarMixin, BVToastMixin], + data() { + return { + resetOption: 'resetBios', + }; + }, + created() { + this.hideLoader(); + }, + methods: { + onResetSubmit() { + this.$bvModal.show('modal-reset'); + }, + onOkConfirm() { + if (this.resetOption == 'resetBios') { + this.onResetBiosConfirm(); + } else { + this.onResetToDefaultsConfirm(); + } + }, + onResetBiosConfirm() { + this.$store + .dispatch('factoryReset/resetBios') + .then((title) => { + this.successToast('', { + title, + }); + }) + .catch(({ message }) => { + this.errorToast('', { + title: message, + }); + }); + }, + onResetToDefaultsConfirm() { + this.$store + .dispatch('factoryReset/resetToDefaults') + .then((title) => { + this.successToast('', { + title, + }); + }) + .catch(({ message }) => { + this.errorToast('', { + title: message, + }); + }); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Operations/FactoryReset/FactoryResetModal.vue b/src/views/_ibs/Operations/FactoryReset/FactoryResetModal.vue new file mode 100644 index 00000000..170bf284 --- /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/Global/StatusIcon'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; + +export default { + components: { StatusIcon }, + mixins: [VuelidateMixin], + props: { + resetType: { + type: String, + default: null, + }, + }, + data() { + return { + confirm: false, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isServerOff() { + return this.serverStatus === 'off' ? true : false; + }, + }, + validations: { + confirm: { + mustBeTrue: function (value) { + return this.isServerOff || value === true; + }, + }, + }, + methods: { + handleConfirm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('okConfirm'); + this.$nextTick(() => this.$refs.modal.hide()); + this.resetConfirm(); + }, + resetConfirm() { + this.confirm = false; + this.$v.$reset(); + }, + }, +}; +</script> diff --git a/src/views/_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..b4024323 --- /dev/null +++ b/src/views/_ibs/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"> + <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/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; + +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'FirmwareSingleImage', + components: { + AlertsServerPower, + BmcCards, + FormUpdate, + HostCards, + PageSection, + PageTitle, + }, + mixins: [LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + loading, + isServerPowerOffRequired: + process.env.VUE_APP_SERVER_OFF_REQUIRED === 'true', + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isServerOff() { + return this.serverStatus === 'off' ? true : false; + }, + isSingleFileUploadEnabled() { + return this.$store.getters['firmware/isSingleFileUploadEnabled']; + }, + isPageDisabled() { + if (this.isServerPowerOffRequired) { + return !this.isServerOff || this.loading || this.isOperationInProgress; + } + return this.loading || this.isOperationInProgress; + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('firmware/getFirmwareInformation') + .finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_ibs/Operations/Firmware/FirmwareAlertServerPower.vue b/src/views/_ibs/Operations/Firmware/FirmwareAlertServerPower.vue new file mode 100644 index 00000000..24aa1d69 --- /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/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..d79a8769 --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/FirmwareCardsBmc.vue @@ -0,0 +1,136 @@ +<template> + <div> + <page-section :section-title="sectionTitle"> + <b-card-group deck> + <!-- Running image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleRunning') }} + </p> + </template> + <dl class="mb-0"> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd class="mb-0">{{ runningVersion }}</dd> + </dl> + </b-card> + + <!-- Backup image --> + <b-card> + <template #header> + <p class="font-weight-bold m-0"> + {{ $t('pageFirmware.cardTitleBackup') }} + </p> + </template> + <dl> + <dt>{{ $t('pageFirmware.cardBodyVersion') }}</dt> + <dd> + <status-icon v-if="showBackupImageStatus" status="danger" /> + <span v-if="showBackupImageStatus" class="sr-only"> + {{ backupStatus }} + </span> + {{ backupVersion }} + </dd> + </dl> + <b-btn + v-if="!switchToBackupImageDisabled" + v-b-modal.modal-switch-to-running + data-test-id="firmware-button-switchToRunning" + variant="link" + size="sm" + class="py-0 px-1 mt-2" + :disabled="isPageDisabled || !backup" + > + <icon-switch class="d-none d-sm-inline-block" /> + {{ $t('pageFirmware.cardActionSwitchToRunning') }} + </b-btn> + </b-card> + </b-card-group> + </page-section> + <modal-switch-to-running :backup="backupVersion" @ok="switchToRunning" /> + </div> +</template> + +<script> +import IconSwitch from '@carbon/icons-vue/es/arrows--horizontal/20'; +import PageSection from '@/components/Global/PageSection'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +import ModalSwitchToRunning from './FirmwareModalSwitchToRunning'; + +export default { + components: { IconSwitch, ModalSwitchToRunning, PageSection }, + mixins: [BVToastMixin, LoadingBarMixin], + props: { + isPageDisabled: { + required: true, + type: Boolean, + default: false, + }, + }, + data() { + return { + loading, + switchToBackupImageDisabled: + process.env.VUE_APP_SWITCH_TO_BACKUP_IMAGE_DISABLED === 'true', + }; + }, + computed: { + isSingleFileUploadEnabled() { + return this.$store.getters['firmware/isSingleFileUploadEnabled']; + }, + sectionTitle() { + if (this.isSingleFileUploadEnabled) { + return this.$t('pageFirmware.sectionTitleBmcCardsCombined'); + } + return this.$t('pageFirmware.sectionTitleBmcCards'); + }, + running() { + return this.$store.getters['firmware/activeBmcFirmware']; + }, + backup() { + return this.$store.getters['firmware/backupBmcFirmware']; + }, + runningVersion() { + return this.running?.version || '--'; + }, + backupVersion() { + return this.backup?.version || '--'; + }, + backupStatus() { + return this.backup?.status || null; + }, + showBackupImageStatus() { + return ( + this.backupStatus === 'Critical' || this.backupStatus === 'Warning' + ); + }, + }, + methods: { + switchToRunning() { + this.startLoader(); + const timerId = setTimeout(() => { + this.endLoader(); + this.infoToast(this.$t('pageFirmware.toast.verifySwitchMessage'), { + title: this.$t('pageFirmware.toast.verifySwitch'), + refreshAction: true, + }); + }, 60000); + + this.$store + .dispatch('firmware/switchBmcFirmwareAndReboot') + .then(() => + this.infoToast(this.$t('pageFirmware.toast.rebootStartedMessage'), { + title: this.$t('pageFirmware.toast.rebootStarted'), + }) + ) + .catch(({ message }) => { + this.errorToast(message); + clearTimeout(timerId); + this.endLoader(); + }); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Operations/Firmware/FirmwareCardsHost.vue b/src/views/_ibs/Operations/Firmware/FirmwareCardsHost.vue new file mode 100644 index 00000000..b4a8e90d --- /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/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..ac4b23fc --- /dev/null +++ b/src/views/_ibs/Operations/Firmware/FirmwareFormUpdate.vue @@ -0,0 +1,187 @@ +<template> + <div> + <div class="form-background p-3"> + <b-form @submit.prevent="onSubmitUpload"> + <b-form-group + v-if="isTftpUploadAvailable" + :label="$t('pageFirmware.form.updateFirmware.fileSource')" + :disabled="isPageDisabled" + > + <b-form-radio v-model="isWorkstationSelected" :value="true"> + {{ $t('pageFirmware.form.updateFirmware.workstation') }} + </b-form-radio> + <b-form-radio v-model="isWorkstationSelected" :value="false"> + {{ $t('pageFirmware.form.updateFirmware.tftpServer') }} + </b-form-radio> + </b-form-group> + + <!-- Workstation Upload --> + <template v-if="isWorkstationSelected"> + <b-form-group + :label="$t('pageFirmware.form.updateFirmware.imageFile')" + label-for="image-file" + > + <form-file + id="image-file" + :disabled="isPageDisabled" + :state="getValidationState($v.file)" + aria-describedby="image-file-help-block" + @input="onFileUpload($event)" + > + <template #invalid> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.required') }} + </b-form-invalid-feedback> + </template> + </form-file> + </b-form-group> + </template> + + <!-- TFTP Server Upload --> + <template v-else> + <b-form-group + :label="$t('pageFirmware.form.updateFirmware.fileAddress')" + label-for="tftp-address" + > + <b-form-input + id="tftp-address" + v-model="tftpFileAddress" + type="text" + :state="getValidationState($v.tftpFileAddress)" + :disabled="isPageDisabled" + @input="$v.tftpFileAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + {{ $t('global.form.fieldRequired') }} + </b-form-invalid-feedback> + </b-form-group> + </template> + <b-btn + data-test-id="firmware-button-startUpdate" + type="submit" + variant="primary" + :disabled="isPageDisabled" + > + {{ $t('pageFirmware.form.updateFirmware.startUpdate') }} + </b-btn> + </b-form> + </div> + + <!-- Modals --> + <modal-update-firmware @ok="updateFirmware" /> + </div> +</template> + +<script> +import { requiredIf } from 'vuelidate/lib/validators'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/Global/FormFile'; +import ModalUpdateFirmware from './FirmwareModalUpdateFirmware'; + +export default { + components: { FormFile, ModalUpdateFirmware }, + mixins: [BVToastMixin, LoadingBarMixin, VuelidateMixin], + props: { + isPageDisabled: { + required: true, + type: Boolean, + default: false, + }, + isServerOff: { + required: true, + type: Boolean, + }, + }, + data() { + return { + loading, + isWorkstationSelected: true, + file: null, + tftpFileAddress: null, + isServerPowerOffRequired: + process.env.VUE_APP_SERVER_OFF_REQUIRED === 'true', + }; + }, + computed: { + isTftpUploadAvailable() { + return this.$store.getters['firmware/isTftpUploadAvailable']; + }, + }, + watch: { + isWorkstationSelected: function () { + this.$v.$reset(); + this.file = null; + this.tftpFileAddress = null; + }, + }, + validations() { + return { + file: { + required: requiredIf(function () { + return this.isWorkstationSelected; + }), + }, + tftpFileAddress: { + required: requiredIf(function () { + return !this.isWorkstationSelected; + }), + }, + }; + }, + created() { + this.$store.dispatch('firmware/getUpdateServiceSettings'); + }, + methods: { + updateFirmware() { + this.startLoader(); + const timerId = setTimeout(() => { + this.endLoader(); + this.infoToast(this.$t('pageFirmware.toast.verifyUpdateMessage'), { + title: this.$t('pageFirmware.toast.verifyUpdate'), + refreshAction: true, + }); + }, 360000); + this.infoToast(this.$t('pageFirmware.toast.updateStartedMessage'), { + title: this.$t('pageFirmware.toast.updateStarted'), + timestamp: true, + }); + if (this.isWorkstationSelected) { + this.dispatchWorkstationUpload(timerId); + } else { + this.dispatchTftpUpload(timerId); + } + }, + dispatchWorkstationUpload(timerId) { + this.$store + .dispatch('firmware/uploadFirmware', this.file) + .catch(({ message }) => { + this.endLoader(); + this.errorToast(message); + clearTimeout(timerId); + }); + }, + dispatchTftpUpload(timerId) { + this.$store + .dispatch('firmware/uploadFirmwareTFTP', this.tftpFileAddress) + .catch(({ message }) => { + this.endLoader(); + this.errorToast(message); + clearTimeout(timerId); + }); + }, + onSubmitUpload() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$bvModal.show('modal-update-firmware'); + }, + onFileUpload(file) { + this.file = file; + this.$v.file.$touch(); + }, + }, +}; +</script> diff --git a/src/views/_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..2524da10 --- /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/Global/PageTitle'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import Alert from '@/components/Global/Alert'; + +export default { + name: 'KeyClear', + components: { PageTitle, Alert }, + mixins: [LoadingBarMixin, BVToastMixin], + data() { + return { + keyOption: 'NONE', + username: this.$store.getters['global/username'], + }; + }, + created() { + this.hideLoader(); + }, + methods: { + onKeyClearSubmit(valueSelected) { + this.$bvModal + .msgBoxConfirm(this.$t('pageKeyClear.modal.clearAllMessage'), { + title: this.$t('pageKeyClear.modal.clearAllTitle'), + okTitle: this.$t('pageKeyClear.modal.clear'), + okVariant: 'danger', + cancelTitle: this.$t('global.action.cancel'), + }) + .then((clearConfirmed) => { + if (clearConfirmed) { + this.$store + .dispatch('keyClear/clearEncryptionKeys', valueSelected) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + } + }); + }, + }, +}; +</script> diff --git a/src/views/_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..1a41baaf --- /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/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..c028a9fc --- /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/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..900619cd --- /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/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'RebootBmc', + components: { PageTitle, PageSection }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + computed: { + lastBmcRebootTime() { + return this.$store.getters['controls/lastBmcRebootTime']; + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('controls/getLastBmcRebootTime') + .finally(() => this.endLoader()); + }, + methods: { + onClick() { + this.$bvModal + .msgBoxConfirm(this.$t('pageRebootBmc.modal.confirmMessage'), { + title: this.$t('pageRebootBmc.modal.confirmTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((confirmed) => { + if (confirmed) this.rebootBmc(); + }); + }, + rebootBmc() { + this.$store + .dispatch('controls/rebootBmc') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> + +<style lang="scss" scoped></style> diff --git a/src/views/_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..cb462a59 --- /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/Global/Alert'; +import { AttachAddon } from 'xterm-addon-attach'; +import { FitAddon } from 'xterm-addon-fit'; +import { Terminal } from 'xterm'; +import { throttle } from 'lodash'; +import IconLaunch from '@carbon/icons-vue/es/launch/20'; +import StatusIcon from '@/components/Global/StatusIcon'; + +export default { + name: 'SerialOverLanConsole', + components: { + Alert, + IconLaunch, + StatusIcon, + }, + props: { + isFullWindow: { + type: Boolean, + default: true, + }, + }, + data() { + return { + resizeConsoleWindow: null, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + serverStatusIcon() { + return this.serverStatus === 'on' ? 'success' : 'danger'; + }, + connectionStatus() { + return this.serverStatus === 'on' + ? this.$t('pageSerialOverLan.connected') + : this.$t('pageSerialOverLan.disconnected'); + }, + }, + created() { + this.$store.dispatch('global/getServerStatus'); + }, + mounted() { + this.openTerminal(); + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeConsoleWindow); + this.closeTerminal(); + }, + methods: { + openTerminal() { + const token = this.$store.getters['authentication/token']; + + this.ws = new WebSocket(`wss://${window.location.host}/console0`, [ + token, + ]); + + // Refer https://github.com/xtermjs/xterm.js/ for xterm implementation and addons. + + this.term = new Terminal({ + 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..4896286b --- /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/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'BootSettings', + mixins: [BVToastMixin, LoadingBarMixin], + data() { + return { + form: { + bootOption: this.$store.getters['serverBootSettings/bootSource'], + oneTimeBoot: this.$store.getters['serverBootSettings/overrideEnabled'], + tpmPolicyOn: this.$store.getters['serverBootSettings/tpmEnabled'], + }, + }; + }, + computed: { + ...mapState('serverBootSettings', [ + 'bootSourceOptions', + 'bootSource', + 'overrideEnabled', + 'tpmEnabled', + ]), + }, + watch: { + bootSource: function (value) { + this.form.bootOption = value; + }, + overrideEnabled: function (value) { + this.form.oneTimeBoot = value; + }, + tpmEnabled: function (value) { + this.form.tpmPolicyOn = value; + }, + }, + validations: { + // Empty validations to leverage vuelidate form states + // to check for changed values + form: { + bootOption: {}, + oneTimeBoot: {}, + tpmPolicyOn: {}, + }, + }, + created() { + this.$store + .dispatch('serverBootSettings/getTpmPolicy') + .finally(() => + this.$root.$emit('server-power-operations-boot-settings-complete') + ); + }, + methods: { + handleSubmit() { + this.startLoader(); + const tpmPolicyChanged = this.$v.form.tpmPolicyOn.$dirty; + let settings; + let bootSource = this.form.bootOption; + let overrideEnabled = this.form.oneTimeBoot; + let tpmEnabled = null; + + if (tpmPolicyChanged) tpmEnabled = this.form.tpmPolicyOn; + settings = { bootSource, overrideEnabled, tpmEnabled }; + + this.$store + .dispatch('serverBootSettings/saveSettings', settings) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.$v.form.$reset(); + this.endLoader(); + }); + }, + onChangeSelect(selectedOption) { + this.$v.form.bootOption.$touch(); + // Disable one time boot if selected boot option is 'None' + if (selectedOption === 'None') this.form.oneTimeBoot = false; + }, + }, +}; +</script> diff --git a/src/views/_ibs/Operations/ServerPowerOperations/ServerPowerOperations.vue b/src/views/_ibs/Operations/ServerPowerOperations/ServerPowerOperations.vue new file mode 100644 index 00000000..e6b685f4 --- /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/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import BootSettings from './BootSettings'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import Alert from '@/components/Global/Alert'; + +export default { + name: 'ServerPowerOperations', + components: { PageTitle, PageSection, BootSettings, Alert }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + form: { + rebootOption: 'orderly', + shutdownOption: 'orderly', + }, + }; + }, + computed: { + serverStatus() { + return this.$store.getters['global/serverStatus']; + }, + isOperationInProgress() { + return this.$store.getters['controls/isOperationInProgress']; + }, + lastPowerOperationTime() { + return this.$store.getters['controls/lastPowerOperationTime']; + }, + oneTimeBootEnabled() { + return this.$store.getters['serverBootSettings/overrideEnabled']; + }, + hasBootSourceOptions() { + let bootOptions = this.$store.getters[ + 'serverBootSettings/bootSourceOptions' + ]; + return bootOptions.length !== 0; + }, + }, + created() { + this.startLoader(); + const bootSettingsPromise = new Promise((resolve) => { + this.$root.$on('server-power-operations-boot-settings-complete', () => + resolve() + ); + }); + Promise.all([ + this.$store.dispatch('serverBootSettings/getBootSettings'), + this.$store.dispatch('controls/getLastPowerOperationTime'), + bootSettingsPromise, + ]).finally(() => this.endLoader()); + }, + methods: { + powerOn() { + this.$store.dispatch('controls/serverPowerOn'); + }, + rebootServer() { + const modalMessage = this.$t( + 'pageServerPowerOperations.modal.confirmRebootMessage' + ); + const modalOptions = { + title: this.$t('pageServerPowerOperations.modal.confirmRebootTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }; + + if (this.form.rebootOption === 'orderly') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverSoftReboot'); + }); + } else if (this.form.rebootOption === 'immediate') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverHardReboot'); + }); + } + }, + shutdownServer() { + const modalMessage = this.$t( + 'pageServerPowerOperations.modal.confirmShutdownMessage' + ); + const modalOptions = { + title: this.$t('pageServerPowerOperations.modal.confirmShutdownTitle'), + okTitle: this.$t('global.action.confirm'), + cancelTitle: this.$t('global.action.cancel'), + }; + + if (this.form.shutdownOption === 'orderly') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverSoftPowerOff'); + }); + } + if (this.form.shutdownOption === 'immediate') { + this.$bvModal + .msgBoxConfirm(modalMessage, modalOptions) + .then((confirmed) => { + if (confirmed) this.$store.dispatch('controls/serverHardPowerOff'); + }); + } + }, + }, +}; +</script> diff --git a/src/views/_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..b0bcfb2b --- /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/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..fc8369d4 --- /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/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import ModalConfigureConnection from './ModalConfigureConnection'; +import NbdServer from '@/utilities/NBDServer'; +import FormFile from '@/components/Global/FormFile'; + +export default { + name: 'VirtualMedia', + components: { PageTitle, PageSection, ModalConfigureConnection, FormFile }, + mixins: [BVToastMixin, LoadingBarMixin], + data() { + return { + modalConfigureConnection: null, + loadImageFromExternalServer: + process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true' + ? true + : false, + }; + }, + computed: { + proxyDevices() { + return this.$store.getters['virtualMedia/proxyDevices']; + }, + legacyDevices() { + return this.$store.getters['virtualMedia/legacyDevices']; + }, + }, + created() { + if (this.proxyDevices.length > 0 || this.legacyDevices.length > 0) return; + this.startLoader(); + this.$store + .dispatch('virtualMedia/getData') + .finally(() => this.endLoader()); + }, + methods: { + startVM(device) { + const token = this.$store.getters['authentication/token']; + device.nbd = new NbdServer( + `wss://${window.location.host}${device.websocket}`, + device.file, + device.id, + token + ); + device.nbd.socketStarted = () => + this.successToast(this.$t('pageVirtualMedia.toast.serverRunning')); + device.nbd.errorReadingFile = () => + this.errorToast(this.$t('pageVirtualMedia.toast.errorReadingFile')); + device.nbd.socketClosed = (code) => { + if (code === 1000) + this.successToast( + this.$t('pageVirtualMedia.toast.serverClosedSuccessfully') + ); + else + this.errorToast( + this.$t('pageVirtualMedia.toast.serverClosedWithErrors') + ); + device.file = null; + device.isActive = false; + }; + + device.nbd.start(); + device.isActive = true; + }, + stopVM(device) { + device.nbd.stop(); + }, + startLegacy(connectionData) { + var data = {}; + data.Image = connectionData.serverUri; + data.UserName = connectionData.username; + data.Password = connectionData.password; + data.WriteProtected = !connectionData.isRW; + this.startLoader(); + this.$store + .dispatch('virtualMedia/mountImage', { + id: connectionData.id, + data: data, + }) + .then(() => { + this.successToast( + this.$t('pageVirtualMedia.toast.serverConnectionEstablished') + ); + connectionData.isActive = true; + }) + .catch(() => { + this.errorToast(this.$t('pageVirtualMedia.toast.errorMounting')); + this.isActive = false; + }) + .finally(() => this.endLoader()); + }, + stopLegacy(connectionData) { + this.$store + .dispatch('virtualMedia/unmountImage', connectionData.id) + .then(() => { + this.successToast( + this.$t('pageVirtualMedia.toast.serverClosedSuccessfully') + ); + connectionData.isActive = false; + }) + .catch(() => + this.errorToast(this.$t('pageVirtualMedia.toast.errorUnmounting')) + ) + .finally(() => this.endLoader()); + }, + saveConnection(connectionData) { + this.modalConfigureConnection.serverUri = connectionData.serverUri; + this.modalConfigureConnection.username = connectionData.username; + this.modalConfigureConnection.password = connectionData.password; + this.modalConfigureConnection.isRW = connectionData.isRW; + }, + configureConnection(connectionData) { + this.modalConfigureConnection = connectionData; + this.$bvModal.show('configure-connection'); + }, + concatId(val) { + return val.split(' ').join('_').toLowerCase(); + }, + }, +}; +</script> diff --git a/src/views/_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..9960f373 --- /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/Mixins/LoadingBarMixin'; +import OverviewDumps from './OverviewDumps.vue'; +import OverviewEvents from './OverviewEvents.vue'; +import OverviewFirmware from './OverviewFirmware.vue'; +import OverviewInventory from './OverviewInventory.vue'; +import OverviewNetwork from './OverviewNetwork'; +import OverviewPower from './OverviewPower'; +import OverviewQuickLinks from './OverviewQuickLinks'; +import OverviewServer from './OverviewServer'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; + +export default { + name: 'Overview', + components: { + OverviewDumps, + OverviewEvents, + OverviewFirmware, + OverviewInventory, + OverviewNetwork, + OverviewPower, + OverviewQuickLinks, + OverviewServer, + PageSection, + PageTitle, + }, + mixins: [LoadingBarMixin], + data() { + return { + showDumps: process.env.VUE_APP_ENV_NAME === 'ibm', + }; + }, + created() { + this.startLoader(); + const dumpsPromise = new Promise((resolve) => { + this.$root.$on('overview-dumps-complete', () => resolve()); + }); + const eventsPromise = new Promise((resolve) => { + this.$root.$on('overview-events-complete', () => resolve()); + }); + const firmwarePromise = new Promise((resolve) => { + this.$root.$on('overview-firmware-complete', () => resolve()); + }); + const inventoryPromise = new Promise((resolve) => { + this.$root.$on('overview-inventory-complete', () => resolve()); + }); + const networkPromise = new Promise((resolve) => { + this.$root.$on('overview-network-complete', () => resolve()); + }); + const powerPromise = new Promise((resolve) => { + this.$root.$on('overview-power-complete', () => resolve()); + }); + const quicklinksPromise = new Promise((resolve) => { + this.$root.$on('overview-quicklinks-complete', () => resolve()); + }); + const serverPromise = new Promise((resolve) => { + this.$root.$on('overview-server-complete', () => resolve()); + }); + + Promise.all([ + dumpsPromise, + eventsPromise, + firmwarePromise, + inventoryPromise, + networkPromise, + powerPromise, + quicklinksPromise, + serverPromise, + ]).finally(() => this.endLoader()); + }, +}; +</script> diff --git a/src/views/_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..a2ae4e4e --- /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/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..b73c0b48 --- /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/Global/StatusIcon'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; + +export default { + name: 'Events', + components: { OverviewCard, StatusIcon }, + mixins: [DataFormatterMixin], + computed: { + eventLogData() { + return this.$store.getters['eventLog/allEvents']; + }, + criticalEvents() { + return this.eventLogData + .filter((log) => log.severity === 'Critical') + .map((log) => { + return log; + }); + }, + warningEvents() { + return this.eventLogData + .filter((log) => log.severity === 'Warning') + .map((log) => { + return log; + }); + }, + }, + created() { + this.$store.dispatch('eventLog/getEventLogData').finally(() => { + this.$root.$emit('overview-events-complete'); + }); + }, + methods: { + exportFileNameByDate() { + // Create export file name based on date + let date = new Date(); + date = + date.toISOString().slice(0, 10) + + '_' + + date.toString().split(':').join('-').split(' ')[4]; + let fileName = 'all_event_logs_'; + return fileName + date; + }, + }, +}; +</script> + +<style lang="scss" scoped> +.status-icon { + vertical-align: text-top; +} +</style> diff --git a/src/views/_ibs/Overview/OverviewFirmware.vue b/src/views/_ibs/Overview/OverviewFirmware.vue new file mode 100644 index 00000000..f1f9ce53 --- /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/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..b81e5c73 --- /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/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..0d84c76c --- /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/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..bc579b03 --- /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/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..d066d391 --- /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/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..91341dbb --- /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/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..8f01c59b --- /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/Mixins/BVToastMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import { maxLength, minLength, sameAs } from 'vuelidate/lib/validators'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/Mixins/LocalTimezoneLabelMixin'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + name: 'ProfileSettings', + components: { InputPasswordToggle, PageSection, PageTitle }, + mixins: [ + BVToastMixin, + LocalTimezoneLabelMixin, + LoadingBarMixin, + VuelidateMixin, + ], + data() { + return { + form: { + newPassword: '', + confirmPassword: '', + isUtcDisplay: this.$store.getters['global/isUtcDisplay'], + }, + }; + }, + computed: { + username() { + return this.$store.getters['global/username']; + }, + passwordRequirements() { + return this.$store.getters['userManagement/accountPasswordRequirements']; + }, + timezone() { + return this.localOffset(); + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('userManagement/getAccountSettings') + .finally(() => this.endLoader()); + }, + validations() { + return { + form: { + newPassword: { + minLength: minLength(this.passwordRequirements.minLength), + maxLength: maxLength(this.passwordRequirements.maxLength), + }, + confirmPassword: { + sameAsPassword: sameAs('newPassword'), + }, + }, + }; + }, + methods: { + saveNewPasswordInputData() { + this.$v.form.confirmPassword.$touch(); + this.$v.form.newPassword.$touch(); + if (this.$v.$invalid) return; + let userData = { + originalUsername: this.username, + password: this.form.newPassword, + }; + + this.$store + .dispatch('userManagement/updateUser', userData) + .then((message) => { + (this.form.newPassword = ''), (this.form.confirmPassword = ''); + this.$v.$reset(); + this.successToast(message); + }) + .catch(({ message }) => this.errorToast(message)); + }, + saveTimeZonePrefrenceData() { + localStorage.setItem('storedUtcDisplay', this.form.isUtcDisplay); + this.$store.commit('global/setUtcTime', this.form.isUtcDisplay); + this.successToast( + this.$t('pageProfileSettings.toast.successUpdatingTimeZone') + ); + }, + submitForm() { + if (this.form.confirmPassword || this.form.newPassword) { + this.saveNewPasswordInputData(); + } + this.saveTimeZonePrefrenceData(); + }, + }, +}; +</script> diff --git a/src/views/_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..cc0cc993 --- /dev/null +++ b/src/views/_ibs/ResourceManagement/Power.vue @@ -0,0 +1,170 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pagePower.description')" /> + + <b-row> + <b-col sm="8" md="6" xl="12"> + <dl> + <dt>{{ $t('pagePower.powerConsumption') }}</dt> + <dd> + {{ + powerConsumptionValue + ? `${powerConsumptionValue} W` + : $t('global.status.notAvailable') + }} + </dd> + </dl> + </b-col> + </b-row> + + <b-form @submit.prevent="submitForm"> + <b-form-group :disabled="loading"> + <b-row> + <b-col sm="8" md="6" xl="12"> + <b-form-group :label="$t('pagePower.powerCapSettingLabel')"> + <b-form-checkbox + v-model="isPowerCapFieldEnabled" + data-test-id="power-checkbox-togglePowerCapField" + name="power-cap-setting" + > + {{ $t('pagePower.powerCapSettingData') }} + </b-form-checkbox> + </b-form-group> + </b-col> + </b-row> + + <b-row> + <b-col sm="8" md="6" xl="3"> + <b-form-group + id="input-group-1" + :label="$t('pagePower.powerCapLabel')" + label-for="input-1" + > + <b-form-text id="power-help-text"> + {{ + $t('pagePower.powerCapLabelTextInfo', { + min: 1, + max: 10000, + }) + }} + </b-form-text> + + <b-form-input + id="input-1" + v-model.number="powerCapValue" + :disabled="!isPowerCapFieldEnabled" + data-test-id="power-input-powerCapValue" + type="number" + aria-describedby="power-help-text" + :state="getValidationState($v.powerCapValue)" + ></b-form-input> + + <b-form-invalid-feedback id="input-live-feedback" role="alert"> + <template v-if="!$v.powerCapValue.required"> + {{ $t('global.form.fieldRequired') }} + </template> + <template v-else-if="!$v.powerCapValue.between"> + {{ $t('global.form.invalidValue') }} + </template> + </b-form-invalid-feedback> + </b-form-group> + </b-col> + </b-row> + + <b-button + variant="primary" + type="submit" + data-test-id="power-button-savePowerCapValue" + > + {{ $t('global.action.save') }} + </b-button> + </b-form-group> + </b-form> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import { requiredIf, between } from 'vuelidate/lib/validators'; +import { mapGetters } from 'vuex'; + +export default { + name: 'Power', + components: { PageTitle }, + mixins: [VuelidateMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + loading, + }; + }, + computed: { + ...mapGetters({ + powerConsumptionValue: 'powerControl/powerConsumptionValue', + }), + + /** + Computed property isPowerCapFieldEnabled is used to enable or disable the input field. + The input field is enabled when the powercapValue property is not null. + **/ + isPowerCapFieldEnabled: { + get() { + return this.powerCapValue !== null; + }, + set(value) { + this.$v.$reset(); + let newValue = null; + if (value) { + if (this.powerCapValue) { + newValue = this.powerCapValue; + } else { + newValue = ''; + } + } + this.$store.dispatch('powerControl/setPowerCapUpdatedValue', newValue); + }, + }, + powerCapValue: { + get() { + return this.$store.getters['powerControl/powerCapValue']; + }, + set(value) { + this.$v.$touch(); + this.$store.dispatch('powerControl/setPowerCapUpdatedValue', value); + }, + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('powerControl/getPowerControl') + .finally(() => this.endLoader()); + }, + validations: { + powerCapValue: { + between: between(1, 10000), + required: requiredIf(function () { + return this.isPowerCapFieldEnabled; + }), + }, + }, + methods: { + submitForm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.startLoader(); + this.$store + .dispatch('powerControl/setPowerControl', this.powerCapValue) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> diff --git a/src/views/_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..0113b80a --- /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/Global/PageTitle'; +import TableRowAction from '@/components/Global/TableRowAction'; +import StatusIcon from '@/components/Global/StatusIcon'; +import Alert from '@/components/Global/Alert'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'Certificates', + components: { + Alert, + IconAdd, + IconReplace, + IconTrashcan, + ModalGenerateCsr, + ModalUploadCertificate, + PageTitle, + StatusIcon, + TableRowAction, + }, + mixins: [BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + modalCertificate: null, + fields: [ + { + key: 'certificate', + label: this.$t('pageCertificates.table.certificate'), + }, + { + key: 'issuedBy', + label: this.$t('pageCertificates.table.issuedBy'), + }, + { + key: 'issuedTo', + label: this.$t('pageCertificates.table.issuedTo'), + }, + { + key: 'validFrom', + label: this.$t('pageCertificates.table.validFrom'), + }, + { + key: 'validUntil', + label: this.$t('pageCertificates.table.validUntil'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + }; + }, + computed: { + certificates() { + return this.$store.getters['certificates/allCertificates']; + }, + tableItems() { + return this.certificates.map((certificate) => { + return { + ...certificate, + actions: [ + { + value: 'replace', + title: this.$t('pageCertificates.replaceCertificate'), + }, + { + value: 'delete', + title: this.$t('pageCertificates.deleteCertificate'), + enabled: + certificate.type === 'TrustStore Certificate' ? true : false, + }, + ], + }; + }); + }, + certificatesForUpload() { + return this.$store.getters['certificates/availableUploadTypes']; + }, + bmcTime() { + return this.$store.getters['global/bmcTime']; + }, + expiredCertificateTypes() { + return this.certificates.reduce((acc, val) => { + const daysUntilExpired = this.getDaysUntilExpired(val.validUntil); + if (daysUntilExpired < 1) { + acc.push(val.certificate); + } + return acc; + }, []); + }, + expiringCertificateTypes() { + return this.certificates.reduce((acc, val) => { + const daysUntilExpired = this.getDaysUntilExpired(val.validUntil); + if (daysUntilExpired < 31 && daysUntilExpired > 0) { + acc.push(val.certificate); + } + return acc; + }, []); + }, + }, + async created() { + this.startLoader(); + await this.$store.dispatch('global/getBmcTime'); + this.$store.dispatch('certificates/getCertificates').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + onTableRowAction(event, rowItem) { + switch (event) { + case 'replace': + this.initModalUploadCertificate(rowItem); + break; + case 'delete': + this.initModalDeleteCertificate(rowItem); + break; + default: + break; + } + }, + initModalUploadCertificate(certificate = null) { + this.modalCertificate = certificate; + this.$bvModal.show('upload-certificate'); + }, + initModalDeleteCertificate(certificate) { + this.$bvModal + .msgBoxConfirm( + this.$t('pageCertificates.modal.deleteConfirmMessage', { + issuedBy: certificate.issuedBy, + certificate: certificate.certificate, + }), + { + title: this.$t('pageCertificates.deleteCertificate'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.deleteCertificate(certificate); + }); + }, + onModalOk({ addNew, file, type, location }) { + if (addNew) { + // Upload a new certificate + this.addNewCertificate(file, type); + } else { + // Replace an existing certificate + this.replaceCertificate(file, type, location); + } + }, + addNewCertificate(file, type) { + this.startLoader(); + this.$store + .dispatch('certificates/addNewCertificate', { file, type }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + replaceCertificate(file, type, location) { + this.startLoader(); + const reader = new FileReader(); + reader.readAsBinaryString(file); + reader.onloadend = (event) => { + const certificateString = event.target.result; + this.$store + .dispatch('certificates/replaceCertificate', { + certificateString, + type, + location, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }; + }, + deleteCertificate({ type, location }) { + this.startLoader(); + this.$store + .dispatch('certificates/deleteCertificate', { + type, + location, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + getDaysUntilExpired(date) { + if (this.bmcTime) { + const validUntilMs = date.getTime(); + const currentBmcTimeMs = this.bmcTime.getTime(); + const oneDayInMs = 24 * 60 * 60 * 1000; + return Math.round((validUntilMs - currentBmcTimeMs) / oneDayInMs); + } + return new Date(); + }, + getIconStatus(date) { + const daysUntilExpired = this.getDaysUntilExpired(date); + if (daysUntilExpired < 1) { + return 'danger'; + } else if (daysUntilExpired < 31) { + return 'warning'; + } + }, + }, +}; +</script> diff --git a/src/views/_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..d76f9fe1 --- /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/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +export default { + name: 'ModalGenerateCsr', + components: { IconAdd, IconCheckmark }, + mixins: [BVToastMixin, VuelidateMixin], + data() { + return { + form: { + certificateType: null, + country: null, + state: null, + city: null, + companyName: null, + companyUnit: null, + commonName: null, + challengePassword: null, + contactPerson: null, + emailAddress: null, + alternateName: [], + keyPairAlgorithm: null, + keyCurveId: null, + keyBitLength: null, + }, + certificateOptions: CERTIFICATE_TYPES.reduce((arr, cert) => { + if (cert.type === 'TrustStore Certificate') return arr; + arr.push({ + text: cert.label, + value: cert.type, + }); + return arr; + }, []), + countryOptions: COUNTRY_LIST.map((country) => ({ + text: country.label, + value: country.code, + })), + keyPairAlgorithmOptions: ['EC', 'RSA'], + keyCurveIdOptions: ['prime256v1', 'secp521r1', 'secp384r1'], + keyBitLengthOptions: [2048], + csrString: '', + csrStringCopied: false, + }; + }, + validations: { + form: { + certificateType: { required }, + country: { required }, + state: { required }, + city: { required }, + companyName: { required }, + companyUnit: { required }, + commonName: { required }, + challengePassword: {}, + contactPerson: {}, + emailAddress: {}, + alternateName: {}, + keyPairAlgorithm: { required }, + keyCurveId: { + reuired: requiredIf(function (form) { + return form.keyPairAlgorithm === 'EC'; + }), + }, + keyBitLength: { + reuired: requiredIf(function (form) { + return form.keyPairAlgorithm === 'RSA'; + }), + }, + }, + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$store + .dispatch('certificates/generateCsr', this.form) + .then(({ data: { CSRString } }) => { + this.csrString = CSRString; + this.$bvModal.show('csr-string'); + this.$v.$reset(); + }); + }, + resetForm() { + for (let key of Object.keys(this.form)) { + if (key === 'alternateName') { + this.form[key] = []; + } else { + this.form[key] = null; + } + } + }, + onOkGenerateCsrModal(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + onHiddenCsrStringModal() { + this.csrString = ''; + this.resetForm(); + }, + copyCsrString(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + navigator.clipboard.writeText(this.csrString).then(() => { + // Show copied text for 5 seconds + this.csrStringCopied = true; + setTimeout(() => { + this.csrStringCopied = false; + }, 5000 /*5 seconds*/); + }); + }, + }, +}; +</script> diff --git a/src/views/_ibs/SecurityAndAccess/Certificates/ModalUploadCertificate.vue b/src/views/_ibs/SecurityAndAccess/Certificates/ModalUploadCertificate.vue new file mode 100644 index 00000000..f4db7a26 --- /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/Mixins/VuelidateMixin.js'; + +import FormFile from '@/components/Global/FormFile'; + +export default { + components: { FormFile }, + mixins: [VuelidateMixin], + props: { + certificate: { + type: Object, + default: null, + validator: (prop) => { + if (prop === null) return true; + return ( + Object.prototype.hasOwnProperty.call(prop, 'type') && + Object.prototype.hasOwnProperty.call(prop, 'certificate') + ); + }, + }, + }, + data() { + return { + form: { + certificateType: null, + file: null, + }, + }; + }, + computed: { + certificateTypes() { + return this.$store.getters['certificates/availableUploadTypes']; + }, + certificateOptions() { + return this.certificateTypes.map(({ type, label }) => { + return { + text: label, + value: type, + }; + }); + }, + }, + watch: { + certificateOptions: function (options) { + if (options.length) { + this.form.certificateType = options[0].value; + } + }, + }, + validations() { + return { + form: { + certificateType: { + required: requiredIf(function () { + return !this.certificate; + }), + }, + file: { + required, + }, + }, + }; + }, + methods: { + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.$emit('ok', { + addNew: !this.certificate, + file: this.form.file, + location: this.certificate ? this.certificate.location : null, + type: this.certificate + ? this.certificate.type + : this.form.certificateType, + }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.certificateType = this.certificateOptions.length + ? this.certificateOptions[0].value + : null; + this.form.file = null; + this.$v.$reset(); + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_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..b4eba281 --- /dev/null +++ b/src/views/_ibs/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="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/Mixins/BVToastMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import InfoTooltip from '@/components/Global/InfoTooltip'; +import TableRoleGroups from './TableRoleGroups'; + +export default { + name: 'Ldap', + components: { + InfoTooltip, + InputPasswordToggle, + PageTitle, + PageSection, + TableRoleGroups, + }, + mixins: [BVToastMixin, VuelidateMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + form: { + ldapAuthenticationEnabled: this.$store.getters['ldap/isServiceEnabled'], + secureLdapEnabled: false, + activeDirectoryEnabled: this.$store.getters[ + 'ldap/isActiveDirectoryEnabled' + ], + serverUri: '', + bindDn: '', + bindPassword: '', + baseDn: '', + userIdAttribute: '', + groupIdAttribute: '', + loading, + }, + }; + }, + computed: { + ...mapGetters('ldap', [ + 'isServiceEnabled', + 'isActiveDirectoryEnabled', + 'ldap', + 'activeDirectory', + ]), + sslCertificates() { + return this.$store.getters['certificates/allCertificates']; + }, + caCertificateExpiration() { + const caCertificate = find(this.sslCertificates, { + type: 'TrustStore Certificate', + }); + if (caCertificate === undefined) return null; + return caCertificate.validUntil; + }, + ldapCertificateExpiration() { + const ldapCertificate = find(this.sslCertificates, { + type: 'LDAP Certificate', + }); + if (ldapCertificate === undefined) return null; + return ldapCertificate.validUntil; + }, + ldapProtocol() { + return this.form.secureLdapEnabled ? 'ldaps://' : 'ldap://'; + }, + }, + watch: { + isServiceEnabled: function (value) { + this.form.ldapAuthenticationEnabled = value; + }, + isActiveDirectoryEnabled: function (value) { + this.form.activeDirectoryEnabled = value; + this.setFormValues(); + }, + }, + validations: { + form: { + ldapAuthenticationEnabled: {}, + secureLdapEnabled: {}, + activeDirectoryEnabled: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + serverUri: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + bindDn: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + bindPassword: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + baseDn: { + required: requiredIf(function () { + return this.form.ldapAuthenticationEnabled; + }), + }, + userIdAttribute: {}, + groupIdAttribute: {}, + }, + }, + created() { + this.startLoader(); + this.$store + .dispatch('ldap/getAccountSettings') + .finally(() => this.endLoader()); + this.$store + .dispatch('certificates/getCertificates') + .finally(() => this.endLoader()); + this.setFormValues(); + }, + methods: { + setFormValues(serviceType) { + if (!serviceType) { + serviceType = this.isActiveDirectoryEnabled + ? this.activeDirectory + : this.ldap; + } + const { + serviceAddress = '', + bindDn = '', + baseDn = '', + userAttribute = '', + groupsAttribute = '', + } = serviceType; + const secureLdap = + serviceAddress && serviceAddress.includes('ldaps://') ? true : false; + const serverUri = serviceAddress + ? serviceAddress.replace(/ldaps?:\/\//, '') + : ''; + this.form.secureLdapEnabled = secureLdap; + this.form.serverUri = serverUri; + this.form.bindDn = bindDn; + this.form.bindPassword = ''; + this.form.baseDn = baseDn; + this.form.userIdAttribute = userAttribute; + this.form.groupIdAttribute = groupsAttribute; + }, + handleSubmit() { + this.$v.$touch(); + if (this.$v.$invalid) return; + const data = { + serviceEnabled: this.form.ldapAuthenticationEnabled, + activeDirectoryEnabled: this.form.activeDirectoryEnabled, + serviceAddress: `${this.ldapProtocol}${this.form.serverUri}`, + bindDn: this.form.bindDn, + bindPassword: this.form.bindPassword, + baseDn: this.form.baseDn, + userIdAttribute: this.form.userIdAttribute, + groupIdAttribute: this.form.groupIdAttribute, + }; + this.startLoader(); + this.$store + .dispatch('ldap/saveAccountSettings', data) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => { + this.errorToast(message); + }) + .finally(() => { + this.form.bindPassword = ''; + this.$v.form.$reset(); + this.endLoader(); + }); + }, + onChangeServiceType(isActiveDirectoryEnabled) { + this.$v.form.activeDirectoryEnabled.$touch(); + const serviceType = isActiveDirectoryEnabled + ? this.activeDirectory + : this.ldap; + // Set form values according to user selected + // service type + this.setFormValues(serviceType); + }, + onChangeldapAuthenticationEnabled(isServiceEnabled) { + this.$v.form.ldapAuthenticationEnabled.$touch(); + if (!isServiceEnabled) { + // Request will fail if sent with empty values. + // The frontend only checks for required fields + // when the service is enabled. This is to prevent + // an error if a user clears any properties then + // disables the service. + this.setFormValues(); + } + }, + }, +}; +</script> diff --git a/src/views/_ibs/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue b/src/views/_ibs/SecurityAndAccess/Ldap/ModalAddRoleGroup.vue new file mode 100644 index 00000000..6ea2561a --- /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/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..5ae3e3d1 --- /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/Global/Alert'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableRowAction from '@/components/Global/TableRowAction'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import ModalAddRoleGroup from './ModalAddRoleGroup'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + components: { + Alert, + IconAdd, + IconEdit, + IconTrashcan, + ModalAddRoleGroup, + TableRowAction, + TableToolbar, + }, + mixins: [BVTableSelectableMixin, BVToastMixin, LoadingBarMixin], + data() { + return { + isBusy: true, + activeRoleGroup: null, + fields: [ + { + key: 'checkbox', + sortable: false, + }, + { + key: 'groupName', + sortable: true, + label: this.$t('pageLdap.tableRoleGroups.groupName'), + }, + { + key: 'groupPrivilege', + sortable: true, + label: this.$t('pageLdap.tableRoleGroups.groupPrivilege'), + }, + { + key: 'actions', + sortable: false, + label: '', + tdClass: 'text-right', + }, + ], + batchActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + ], + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + ...mapGetters('ldap', ['isServiceEnabled', 'enabledRoleGroups']), + tableItems() { + return this.enabledRoleGroups.map(({ LocalRole, RemoteGroup }) => { + return { + groupName: RemoteGroup, + groupPrivilege: LocalRole, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + enabled: this.isServiceEnabled, + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + enabled: this.isServiceEnabled, + }, + ], + }; + }); + }, + }, + created() { + this.$store.dispatch('userManagement/getAccountRoles').finally(() => { + this.isBusy = false; + }); + }, + methods: { + onBatchAction() { + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageLdap.modal.deleteRoleGroupBatchConfirmMessage', + this.selectedRows.length + ), + { + title: this.$t('pageLdap.modal.deleteRoleGroup'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('ldap/deleteRoleGroup', { + roleGroups: this.selectedRows, + }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }); + }, + onTableRowAction(action, row) { + switch (action) { + case 'edit': + this.initRoleGroupModal(row); + break; + case 'delete': + this.$bvModal + .msgBoxConfirm( + this.$t('pageLdap.modal.deleteRoleGroupConfirmMessage', { + groupName: row.groupName, + }), + { + title: this.$t('pageLdap.modal.deleteRoleGroup'), + okTitle: this.$t('global.action.delete'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('ldap/deleteRoleGroup', { roleGroups: [row] }) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }); + break; + } + }, + initRoleGroupModal(roleGroup) { + this.activeRoleGroup = roleGroup; + this.$bvModal.show('modal-role-group'); + }, + saveRoleGroup({ addNew, groupName, groupPrivilege }) { + this.activeRoleGroup = null; + const data = { groupName, groupPrivilege }; + this.startLoader(); + if (addNew) { + this.$store + .dispatch('ldap/addNewRoleGroup', data) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } else { + this.$store + .dispatch('ldap/saveRoleGroup', data) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }, + }, +}; +</script> diff --git a/src/views/_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..1dc197c7 --- /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/Global/PageTitle'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'Policies', + components: { PageTitle }, + mixins: [LoadingBarMixin, BVToastMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + modifySSHPolicyDisabled: + process.env.VUE_APP_MODIFY_SSH_POLICY_DISABLED === 'true', + }; + }, + computed: { + sshProtocolState: { + get() { + return this.$store.getters['policies/sshProtocolEnabled']; + }, + set(newValue) { + return newValue; + }, + }, + ipmiProtocolState: { + get() { + return this.$store.getters['policies/ipmiProtocolEnabled']; + }, + set(newValue) { + return newValue; + }, + }, + rtadState: { + get() { + if (this.$store.getters['policies/rtadEnabled'] === 'Enabled') { + return true; + } else { + return false; + } + }, + set(newValue) { + return newValue; + }, + }, + vtpmState: { + get() { + if (this.$store.getters['policies/vtpmEnabled'] === 'Enabled') { + return true; + } else { + return false; + } + }, + set(newValue) { + return newValue; + }, + }, + }, + created() { + this.startLoader(); + Promise.all([ + this.$store.dispatch('policies/getBiosStatus'), + this.$store.dispatch('policies/getNetworkProtocolStatus'), + ]).finally(() => this.endLoader()); + }, + methods: { + changeIpmiProtocolState(state) { + this.$store + .dispatch('policies/saveIpmiProtocolState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeSshProtocolState(state) { + this.$store + .dispatch('policies/saveSshProtocolState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeRtadState(state) { + this.$store + .dispatch('policies/saveRtadState', state ? 'Enabled' : 'Disabled') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeVtpmState(state) { + this.$store + .dispatch('policies/saveVtpmState', state ? 'Enabled' : 'Disabled') + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.setting-section { + border-bottom: 1px solid gray('300'); +} +</style> diff --git a/src/views/_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..07ee725d --- /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/Global/PageTitle'; +import Search from '@/components/Global/Search'; +import TableCellCount from '@/components/Global/TableCellCount'; +import TableRowAction from '@/components/Global/TableRowAction'; +import TableToolbar from '@/components/Global/TableToolbar'; + +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import BVPaginationMixin, { + currentPage, + perPage, + itemsPerPageOptions, +} from '@/components/Mixins/BVPaginationMixin'; +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import SearchFilterMixin, { + searchFilter, +} from '@/components/Mixins/SearchFilterMixin'; + +export default { + components: { + PageTitle, + Search, + TableCellCount, + TableRowAction, + TableToolbar, + }, + mixins: [ + BVPaginationMixin, + BVTableSelectableMixin, + BVToastMixin, + LoadingBarMixin, + SearchFilterMixin, + ], + beforeRouteLeave(to, from, next) { + // Hide loader if the user navigates to another page + // before request is fulfilled. + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + fields: [ + { + key: 'checkbox', + }, + { + key: 'clientID', + label: this.$t('pageSessions.table.clientID'), + }, + { + key: 'username', + label: this.$t('pageSessions.table.username'), + }, + { + key: 'ipAddress', + label: this.$t('pageSessions.table.ipAddress'), + }, + { + key: 'actions', + label: '', + }, + ], + batchActions: [ + { + value: 'disconnect', + label: this.$t('pageSessions.action.disconnect'), + }, + ], + currentPage: currentPage, + itemsPerPageOptions: itemsPerPageOptions, + perPage: perPage, + selectedRows: selectedRows, + searchTotalFilteredRows: 0, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + searchFilter: searchFilter, + }; + }, + computed: { + filteredRows() { + return this.searchFilter + ? this.searchTotalFilteredRows + : this.allConnections.length; + }, + allConnections() { + return this.$store.getters['sessions/allConnections'].map((session) => { + return { + ...session, + actions: [ + { + value: 'disconnect', + title: this.$t('pageSessions.action.disconnect'), + }, + ], + }; + }); + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('sessions/getSessionsData').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + }, + methods: { + onFiltered(filteredItems) { + this.searchTotalFilteredRows = filteredItems.length; + }, + onChangeSearchInput(event) { + this.searchFilter = event; + }, + disconnectSessions(uris) { + this.$store + .dispatch('sessions/disconnectSessions', uris) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') { + this.successToast(message); + } else if (type === 'error') { + this.errorToast(message); + } + }); + }); + }, + onTableRowAction(action, { uri }) { + if (action === 'disconnect') { + this.$bvModal + .msgBoxConfirm(this.$tc('pageSessions.modal.disconnectMessage'), { + title: this.$tc('pageSessions.modal.disconnectTitle'), + okTitle: this.$t('pageSessions.action.disconnect'), + cancelTitle: this.$t('global.action.cancel'), + }) + .then((deleteConfirmed) => { + if (deleteConfirmed) this.disconnectSessions([uri]); + }); + } + }, + onBatchAction(action) { + if (action === 'disconnect') { + const uris = this.selectedRows.map((row) => row.uri); + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageSessions.modal.disconnectMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageSessions.modal.disconnectTitle', + this.selectedRows.length + ), + okTitle: this.$t('pageSessions.action.disconnect'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.disconnectSessions(uris); + } + }); + } + }, + }, +}; +</script> +<style lang="scss"> +#table-session-logs { + td .btn-link { + width: auto !important; + } +} +</style> diff --git a/src/views/_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..0f05123c --- /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/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..0f8757ce --- /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/Mixins/VuelidateMixin.js'; +import InputPasswordToggle from '@/components/Global/InputPasswordToggle'; +import Alert from '@/components/Global/Alert'; + +export default { + components: { Alert, InputPasswordToggle }, + mixins: [VuelidateMixin], + props: { + user: { + type: Object, + default: null, + }, + passwordRequirements: { + type: Object, + required: true, + }, + }, + data() { + return { + originalUsername: '', + form: { + status: true, + username: '', + privilege: null, + password: '', + passwordConfirmation: '', + manualUnlock: false, + }, + }; + }, + computed: { + newUser() { + return this.user ? false : true; + }, + accountSettings() { + return this.$store.getters['userManagement/accountSettings']; + }, + manualUnlockPolicy() { + return !this.accountSettings.accountLockoutDuration; + }, + privilegeTypes() { + return this.$store.getters['userManagement/accountRoles']; + }, + }, + watch: { + user: function (value) { + if (value === null) return; + this.originalUsername = value.username; + this.form.username = value.username; + this.form.status = value.Enabled; + this.form.privilege = value.privilege; + }, + }, + validations() { + return { + form: { + status: { + required, + }, + username: { + required, + maxLength: maxLength(16), + pattern: helpers.regex('pattern', /^([a-zA-Z_][a-zA-Z0-9_]*)/), + }, + privilege: { + required, + }, + password: { + required: requiredIf(function () { + return this.requirePassword(); + }), + minLength: minLength(this.passwordRequirements.minLength), + maxLength: maxLength(this.passwordRequirements.maxLength), + }, + passwordConfirmation: { + required: requiredIf(function () { + return this.requirePassword(); + }), + sameAsPassword: sameAs('password'), + }, + manualUnlock: {}, + }, + }; + }, + methods: { + handleSubmit() { + let userData = {}; + + if (this.newUser) { + this.$v.$touch(); + if (this.$v.$invalid) return; + userData.username = this.form.username; + userData.status = this.form.status; + userData.privilege = this.form.privilege; + userData.password = this.form.password; + } else { + if (this.$v.$invalid) return; + userData.originalUsername = this.originalUsername; + if (this.$v.form.status.$dirty) { + userData.status = this.form.status; + } + if (this.$v.form.username.$dirty) { + userData.username = this.form.username; + } + if (this.$v.form.privilege.$dirty) { + userData.privilege = this.form.privilege; + } + if (this.$v.form.password.$dirty) { + userData.password = this.form.password; + } + if (this.$v.form.manualUnlock.$dirty) { + // If form manualUnlock control $dirty then + // set user Locked property to false + userData.locked = false; + } + if (Object.entries(userData).length === 1) { + this.closeModal(); + return; + } + } + + this.$emit('ok', { isNewUser: this.newUser, userData }); + this.closeModal(); + }, + closeModal() { + this.$nextTick(() => { + this.$refs.modal.hide(); + }); + }, + resetForm() { + this.form.originalUsername = ''; + this.form.status = true; + this.form.username = ''; + this.form.privilege = null; + this.form.password = ''; + this.form.passwordConfirmation = ''; + this.$v.$reset(); + this.$emit('hidden'); + }, + requirePassword() { + if (this.newUser) return true; + if (this.$v.form.password.$dirty) return true; + if (this.$v.form.passwordConfirmation.$dirty) return true; + return false; + }, + onOk(bvModalEvt) { + // prevent modal close + bvModalEvt.preventDefault(); + this.handleSubmit(); + }, + }, +}; +</script> diff --git a/src/views/_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..c6c556c8 --- /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/Global/PageTitle'; +import TableRoles from './TableRoles'; +import TableToolbar from '@/components/Global/TableToolbar'; +import TableRowAction from '@/components/Global/TableRowAction'; + +import BVTableSelectableMixin, { + selectedRows, + tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate, +} from '@/components/Mixins/BVTableSelectableMixin'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; + +export default { + name: 'UserManagement', + components: { + IconAdd, + IconChevron, + IconEdit, + IconSettings, + IconTrashcan, + ModalSettings, + ModalUser, + PageTitle, + TableRoles, + TableRowAction, + TableToolbar, + }, + mixins: [BVTableSelectableMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + isBusy: true, + activeUser: null, + fields: [ + { + key: 'checkbox', + }, + { + key: 'username', + label: this.$t('pageUserManagement.table.username'), + }, + { + key: 'privilege', + label: this.$t('pageUserManagement.table.privilege'), + }, + { + key: 'status', + label: this.$t('pageUserManagement.table.status'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right text-nowrap', + }, + ], + tableToolbarActions: [ + { + value: 'delete', + label: this.$t('global.action.delete'), + }, + { + value: 'enable', + label: this.$t('global.action.enable'), + }, + { + value: 'disable', + label: this.$t('global.action.disable'), + }, + ], + selectedRows: selectedRows, + tableHeaderCheckboxModel: tableHeaderCheckboxModel, + tableHeaderCheckboxIndeterminate: tableHeaderCheckboxIndeterminate, + }; + }, + computed: { + allUsers() { + return this.$store.getters['userManagement/allUsers']; + }, + tableItems() { + // transform user data to table data + return this.allUsers.map((user) => { + return { + username: user.UserName, + privilege: user.RoleId, + status: user.Locked + ? 'Locked' + : user.Enabled + ? 'Enabled' + : 'Disabled', + actions: [ + { + value: 'edit', + enabled: true, + title: this.$t('pageUserManagement.editUser'), + }, + { + value: 'delete', + enabled: user.UserName === 'root' ? false : true, + title: this.$tc('pageUserManagement.deleteUser'), + }, + ], + ...user, + }; + }); + }, + settings() { + return this.$store.getters['userManagement/accountSettings']; + }, + passwordRequirements() { + return this.$store.getters['userManagement/accountPasswordRequirements']; + }, + }, + created() { + this.startLoader(); + this.$store.dispatch('userManagement/getUsers').finally(() => { + this.endLoader(); + this.isBusy = false; + }); + this.$store.dispatch('userManagement/getAccountSettings'); + this.$store.dispatch('userManagement/getAccountRoles'); + }, + methods: { + initModalUser(user) { + this.activeUser = user; + this.$bvModal.show('modal-user'); + }, + initModalDelete(user) { + this.$bvModal + .msgBoxConfirm( + this.$t('pageUserManagement.modal.deleteConfirmMessage', { + user: user.username, + }), + { + title: this.$tc('pageUserManagement.deleteUser'), + okTitle: this.$tc('pageUserManagement.deleteUser'), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.deleteUser(user); + } + }); + }, + initModalSettings() { + this.$bvModal.show('modal-settings'); + }, + saveUser({ isNewUser, userData }) { + this.startLoader(); + if (isNewUser) { + this.$store + .dispatch('userManagement/createUser', userData) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } else { + this.$store + .dispatch('userManagement/updateUser', userData) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + } + }, + deleteUser({ username }) { + this.startLoader(); + this.$store + .dispatch('userManagement/deleteUser', username) + .then((success) => this.successToast(success)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + onBatchAction(action) { + switch (action) { + case 'delete': + this.$bvModal + .msgBoxConfirm( + this.$tc( + 'pageUserManagement.modal.batchDeleteConfirmMessage', + this.selectedRows.length + ), + { + title: this.$tc( + 'pageUserManagement.deleteUser', + this.selectedRows.length + ), + okTitle: this.$tc( + 'pageUserManagement.deleteUser', + this.selectedRows.length + ), + cancelTitle: this.$t('global.action.cancel'), + } + ) + .then((deleteConfirmed) => { + if (deleteConfirmed) { + this.startLoader(); + this.$store + .dispatch('userManagement/deleteUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + } + }); + break; + case 'enable': + this.startLoader(); + this.$store + .dispatch('userManagement/enableUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + break; + case 'disable': + this.startLoader(); + this.$store + .dispatch('userManagement/disableUsers', this.selectedRows) + .then((messages) => { + messages.forEach(({ type, message }) => { + if (type === 'success') this.successToast(message); + if (type === 'error') this.errorToast(message); + }); + }) + .finally(() => this.endLoader()); + break; + } + }, + onTableRowAction(action, row) { + switch (action) { + case 'edit': + this.initModalUser(row); + break; + case 'delete': + this.initModalDelete(row); + break; + default: + break; + } + }, + saveAccountSettings(settings) { + this.startLoader(); + this.$store + .dispatch('userManagement/saveAccountSettings', settings) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.btn.collapsed { + svg { + transform: rotate(180deg); + } +} +</style> diff --git a/src/views/_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..f5e063d1 --- /dev/null +++ b/src/views/_ibs/Settings/DateTime/DateTime.vue @@ -0,0 +1,417 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row> + <b-col md="8" xl="6"> + <alert variant="info" class="mb-4"> + <span> + {{ $t('pageDateTime.alert.message') }} + <b-link to="/profile-settings"> + {{ $t('pageDateTime.alert.link') }}</b-link + > + </span> + </alert> + </b-col> + </b-row> + <page-section> + <b-row> + <b-col lg="3"> + <dl> + <dt>{{ $t('pageDateTime.form.date') }}</dt> + <dd v-if="bmcTime">{{ bmcTime | formatDate }}</dd> + <dd v-else>--</dd> + </dl> + </b-col> + <b-col lg="3"> + <dl> + <dt>{{ $t('pageDateTime.form.time.label') }}</dt> + <dd v-if="bmcTime">{{ bmcTime | formatTime }}</dd> + <dd v-else>--</dd> + </dl> + </b-col> + </b-row> + </page-section> + <page-section :section-title="$t('pageDateTime.configureSettings')"> + <b-form novalidate @submit.prevent="submitForm"> + <b-form-group + label="Configure date and time" + :disabled="loading" + label-sr-only + > + <b-form-radio + v-model="form.configurationSelected" + value="manual" + data-test-id="dateTime-radio-configureManual" + > + {{ $t('pageDateTime.form.manual') }} + </b-form-radio> + <b-row class="mt-3 ml-3"> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.date')" + label-for="input-manual-date" + > + <b-form-text id="date-format-help">YYYY-MM-DD</b-form-text> + <b-input-group> + <b-form-input + id="input-manual-date" + v-model="form.manual.date" + :state="getValidationState($v.form.manual.date)" + :disabled="ntpOptionSelected" + data-test-id="dateTime-input-manualDate" + class="form-control-with-button" + @blur="$v.form.manual.date.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.manual.date.pattern"> + {{ $t('global.form.invalidFormat') }} + </div> + <div v-if="!$v.form.manual.date.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + <b-form-datepicker + v-model="form.manual.date" + class="btn-datepicker btn-icon-only" + button-only + right + :hide-header="true" + :locale="locale" + :label-help=" + $t('global.calendar.useCursorKeysToNavigateCalendarDates') + " + :title="$t('global.calendar.selectDate')" + :disabled="ntpOptionSelected" + button-variant="link" + aria-controls="input-manual-date" + > + <template #button-content> + <icon-calendar /> + <span class="sr-only"> + {{ $t('global.calendar.selectDate') }} + </span> + </template> + </b-form-datepicker> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.time.timezone', { timezone })" + label-for="input-manual-time" + > + <b-form-text id="time-format-help">HH:MM</b-form-text> + <b-input-group> + <b-form-input + id="input-manual-time" + v-model="form.manual.time" + :state="getValidationState($v.form.manual.time)" + :disabled="ntpOptionSelected" + data-test-id="dateTime-input-manualTime" + @blur="$v.form.manual.time.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.manual.time.pattern"> + {{ $t('global.form.invalidFormat') }} + </div> + <div v-if="!$v.form.manual.time.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + </b-input-group> + </b-form-group> + </b-col> + </b-row> + <b-form-radio + v-model="form.configurationSelected" + value="ntp" + data-test-id="dateTime-radio-configureNTP" + > + NTP + </b-form-radio> + <b-row class="mt-3 ml-3"> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server1')" + label-for="input-ntp-1" + > + <b-input-group> + <b-form-input + id="input-ntp-1" + v-model="form.ntp.firstAddress" + :state="getValidationState($v.form.ntp.firstAddress)" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer1" + @blur="$v.form.ntp.firstAddress.$touch()" + /> + <b-form-invalid-feedback role="alert"> + <div v-if="!$v.form.ntp.firstAddress.required"> + {{ $t('global.form.fieldRequired') }} + </div> + </b-form-invalid-feedback> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server2')" + label-for="input-ntp-2" + > + <b-input-group> + <b-form-input + id="input-ntp-2" + v-model="form.ntp.secondAddress" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer2" + /> + </b-input-group> + </b-form-group> + </b-col> + <b-col sm="6" lg="4" xl="3"> + <b-form-group + :label="$t('pageDateTime.form.ntpServers.server3')" + label-for="input-ntp-3" + > + <b-input-group> + <b-form-input + id="input-ntp-3" + v-model="form.ntp.thirdAddress" + :disabled="manualOptionSelected" + data-test-id="dateTime-input-ntpServer3" + /> + </b-input-group> + </b-form-group> + </b-col> + </b-row> + <b-button + variant="primary" + type="submit" + data-test-id="dateTime-button-saveSettings" + > + {{ $t('global.action.saveSettings') }} + </b-button> + </b-form-group> + </b-form> + </page-section> + </b-container> +</template> + +<script> +import Alert from '@/components/Global/Alert'; +import IconCalendar from '@carbon/icons-vue/es/calendar/20'; +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; + +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import LocalTimezoneLabelMixin from '@/components/Mixins/LocalTimezoneLabelMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; + +import { mapState } from 'vuex'; +import { requiredIf, helpers } from 'vuelidate/lib/validators'; + +const isoDateRegex = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/; +const isoTimeRegex = /^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/; + +export default { + name: 'DateTime', + components: { Alert, IconCalendar, PageTitle, PageSection }, + mixins: [ + BVToastMixin, + LoadingBarMixin, + LocalTimezoneLabelMixin, + VuelidateMixin, + ], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + locale: this.$store.getters['global/languagePreference'], + form: { + configurationSelected: 'manual', + manual: { + date: '', + time: '', + }, + ntp: { firstAddress: '', secondAddress: '', thirdAddress: '' }, + }, + loading, + }; + }, + validations() { + return { + form: { + manual: { + date: { + required: requiredIf(function () { + return this.form.configurationSelected === 'manual'; + }), + pattern: helpers.regex('pattern', isoDateRegex), + }, + time: { + required: requiredIf(function () { + return this.form.configurationSelected === 'manual'; + }), + pattern: helpers.regex('pattern', isoTimeRegex), + }, + }, + ntp: { + firstAddress: { + required: requiredIf(function () { + return this.form.configurationSelected === 'ntp'; + }), + }, + }, + }, + }; + }, + computed: { + ...mapState('dateTime', ['ntpServers', 'isNtpProtocolEnabled']), + bmcTime() { + return this.$store.getters['global/bmcTime']; + }, + ntpOptionSelected() { + return this.form.configurationSelected === 'ntp'; + }, + manualOptionSelected() { + return this.form.configurationSelected === 'manual'; + }, + isUtcDisplay() { + return this.$store.getters['global/isUtcDisplay']; + }, + timezone() { + if (this.isUtcDisplay) { + return 'UTC'; + } + return this.localOffset(); + }, + }, + watch: { + ntpServers() { + this.setNtpValues(); + }, + manualDate() { + this.emitChange(); + }, + bmcTime() { + this.form.manual.date = this.$options.filters.formatDate( + this.$store.getters['global/bmcTime'] + ); + this.form.manual.time = this.$options.filters + .formatTime(this.$store.getters['global/bmcTime']) + .slice(0, 5); + }, + }, + created() { + this.startLoader(); + this.setNtpValues(); + Promise.all([ + this.$store.dispatch('global/getBmcTime'), + this.$store.dispatch('dateTime/getNtpData'), + ]).finally(() => this.endLoader()); + }, + methods: { + emitChange() { + if (this.$v.$invalid) return; + this.$v.$reset(); //reset to re-validate on blur + this.$emit('change', { + manualDate: this.manualDate ? new Date(this.manualDate) : null, + }); + }, + setNtpValues() { + this.form.configurationSelected = this.isNtpProtocolEnabled + ? 'ntp' + : 'manual'; + [ + this.form.ntp.firstAddress = '', + this.form.ntp.secondAddress = '', + this.form.ntp.thirdAddress = '', + ] = [this.ntpServers[0], this.ntpServers[1], this.ntpServers[2]]; + }, + submitForm() { + this.$v.$touch(); + if (this.$v.$invalid) return; + this.startLoader(); + + let dateTimeForm = {}; + let isNTPEnabled = this.form.configurationSelected === 'ntp'; + + if (!isNTPEnabled) { + const isUtcDisplay = this.$store.getters['global/isUtcDisplay']; + let date; + + dateTimeForm.ntpProtocolEnabled = false; + + if (isUtcDisplay) { + // Create UTC Date + date = this.getUtcDate(this.form.manual.date, this.form.manual.time); + } else { + // Create local Date + date = new Date(`${this.form.manual.date} ${this.form.manual.time}`); + } + + dateTimeForm.updatedDateTime = date.toISOString(); + } else { + dateTimeForm.ntpProtocolEnabled = true; + + const ntpArray = [ + this.form.ntp.firstAddress, + this.form.ntp.secondAddress, + this.form.ntp.thirdAddress, + ]; + + // Filter the ntpArray to remove empty strings, + // per Redfish spec there should be no empty strings or null on the ntp array. + const ntpArrayFiltered = ntpArray.filter((x) => x); + + dateTimeForm.ntpServersArray = [...ntpArrayFiltered]; + + [this.ntpServers[0], this.ntpServers[1], this.ntpServers[2]] = [ + ...dateTimeForm.ntpServersArray, + ]; + + this.setNtpValues(); + } + + this.$store + .dispatch('dateTime/updateDateTime', dateTimeForm) + .then((success) => { + this.successToast(success); + if (!isNTPEnabled) return; + // Shift address up if second address is empty + // to avoid refreshing after delay when updating NTP + if (!this.form.ntp.secondAddress && this.form.ntp.thirdAddres) { + this.form.ntp.secondAddress = this.form.ntp.thirdAddres; + this.form.ntp.thirdAddress = ''; + } + }) + .then(() => { + this.$store.dispatch('global/getBmcTime'); + }) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.$v.form.$reset(); + this.endLoader(); + }); + }, + getUtcDate(date, time) { + // Split user input string values to create + // a UTC Date object + const datesArray = date.split('-'); + const timeArray = time.split(':'); + let utcDate = Date.UTC( + datesArray[0], // User input year + //UTC expects zero-index month value 0-11 (January-December) + //for reference https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC#Parameters + parseInt(datesArray[1]) - 1, // User input month + datesArray[2], // User input day + timeArray[0], // User input hour + timeArray[1] // User input minute + ); + return new Date(utcDate); + }, + }, +}; +</script> diff --git a/src/views/_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..7f127173 --- /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/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..f3221ec7 --- /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/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..dcf4a579 --- /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/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..d563f4ce --- /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/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..2abbcd7a --- /dev/null +++ b/src/views/_ibs/Settings/Network/Network.vue @@ -0,0 +1,167 @@ +<template> + <b-container fluid="xl"> + <page-title :description="$t('pageNetwork.pageDescription')" /> + <!-- Global settings for all interfaces --> + <network-global-settings /> + <!-- Interface tabs --> + <page-section v-show="ethernetData"> + <b-row> + <b-col> + <b-card no-body> + <b-tabs + active-nav-item-class="font-weight-bold" + card + content-class="mt-3" + > + <b-tab + v-for="(data, index) in ethernetData" + :key="data.Id" + :title="data.Id" + @click="getTabIndex(index)" + > + <!-- Interface settings --> + <network-interface-settings :tab-index="tabIndex" /> + <!-- IPV4 table --> + <table-ipv-4 :tab-index="tabIndex" /> + <!-- Static DNS table --> + <table-dns :tab-index="tabIndex" /> + </b-tab> + </b-tabs> + </b-card> + </b-col> + </b-row> + </page-section> + <!-- Modals --> + <modal-ipv4 :default-gateway="defaultGateway" @ok="saveIpv4Address" /> + <modal-dns @ok="saveDnsAddress" /> + <modal-hostname :hostname="currentHostname" @ok="saveSettings" /> + <modal-mac-address :mac-address="currentMacAddress" @ok="saveSettings" /> + </b-container> +</template> + +<script> +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import LoadingBarMixin, { loading } from '@/components/Mixins/LoadingBarMixin'; +import ModalMacAddress from './ModalMacAddress.vue'; +import ModalHostname from './ModalHostname.vue'; +import ModalIpv4 from './ModalIpv4.vue'; +import ModalDns from './ModalDns.vue'; +import NetworkGlobalSettings from './NetworkGlobalSettings.vue'; +import NetworkInterfaceSettings from './NetworkInterfaceSettings.vue'; +import PageSection from '@/components/Global/PageSection'; +import PageTitle from '@/components/Global/PageTitle'; +import TableIpv4 from './TableIpv4.vue'; +import TableDns from './TableDns.vue'; +import { mapState } from 'vuex'; + +export default { + name: 'Network', + components: { + ModalHostname, + ModalMacAddress, + ModalIpv4, + ModalDns, + NetworkGlobalSettings, + NetworkInterfaceSettings, + PageSection, + PageTitle, + TableDns, + TableIpv4, + }, + mixins: [BVToastMixin, DataFormatterMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + currentHostname: '', + currentMacAddress: '', + defaultGateway: '', + loading, + tabIndex: 0, + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + ethernetData() { + this.getModalInfo(); + }, + }, + created() { + this.startLoader(); + const globalSettings = new Promise((resolve) => { + this.$root.$on('network-global-settings-complete', () => resolve()); + }); + const interfaceSettings = new Promise((resolve) => { + this.$root.$on('network-interface-settings-complete', () => resolve()); + }); + const networkTableDns = new Promise((resolve) => { + this.$root.$on('network-table-dns-complete', () => resolve()); + }); + const networkTableIpv4 = new Promise((resolve) => { + this.$root.$on('network-table-ipv4-complete', () => resolve()); + }); + // Combine all child component Promises to indicate + // when page data load complete + Promise.all([ + this.$store.dispatch('network/getEthernetData'), + globalSettings, + interfaceSettings, + networkTableDns, + networkTableIpv4, + ]).finally(() => this.endLoader()); + }, + methods: { + getModalInfo() { + this.defaultGateway = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].defaultGateway; + + this.currentHostname = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].hostname; + + this.currentMacAddress = this.$store.getters[ + 'network/globalNetworkSettings' + ][this.tabIndex].macAddress; + }, + getTabIndex(selectedIndex) { + this.tabIndex = selectedIndex; + this.$store.dispatch('network/setSelectedTabIndex', this.tabIndex); + this.$store.dispatch( + 'network/setSelectedTabId', + this.ethernetData[selectedIndex].Id + ); + this.getModalInfo(); + }, + saveIpv4Address(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveIpv4Address', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + saveDnsAddress(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveDnsAddress', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + saveSettings(modalFormData) { + this.startLoader(); + this.$store + .dispatch('network/saveSettings', modalFormData) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => this.endLoader()); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Settings/Network/NetworkGlobalSettings.vue b/src/views/_ibs/Settings/Network/NetworkGlobalSettings.vue new file mode 100644 index 00000000..30287673 --- /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/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import PageSection from '@/components/Global/PageSection'; +import { mapState } from 'vuex'; + +export default { + name: 'GlobalNetworkSettings', + components: { IconEdit, PageSection }, + mixins: [BVToastMixin, DataFormatterMixin], + + data() { + return { + hostname: '', + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + firstInterface() { + return this.$store.getters['network/globalNetworkSettings'][0]; + }, + useDomainNameState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useDomainNameEnabled; + }, + set(newValue) { + return newValue; + }, + }, + useDnsState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useDnsEnabled; + }, + set(newValue) { + return newValue; + }, + }, + useNtpState: { + get() { + return this.$store.getters['network/globalNetworkSettings'][0] + .useNtpEnabled; + }, + set(newValue) { + return newValue; + }, + }, + }, + created() { + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-global-settings-complete'); + }); + }, + methods: { + changeDomainNameState(state) { + this.$store + .dispatch('network/saveDomainNameState', state) + .then((success) => { + this.successToast(success); + }) + .catch(({ message }) => this.errorToast(message)); + }, + changeDnsState(state) { + this.$store + .dispatch('network/saveDnsState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + changeNtpState(state) { + this.$store + .dispatch('network/saveNtpState', state) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initSettingsModal() { + this.$bvModal.show('modal-hostname'); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Settings/Network/NetworkInterfaceSettings.vue b/src/views/_ibs/Settings/Network/NetworkInterfaceSettings.vue new file mode 100644 index 00000000..023d29bc --- /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/Mixins/BVToastMixin'; +import IconEdit from '@carbon/icons-vue/es/edit/16'; +import PageSection from '@/components/Global/PageSection'; +import DataFormatterMixin from '@/components/Mixins/DataFormatterMixin'; +import { mapState } from 'vuex'; + +export default { + name: 'Ipv4Table', + components: { + IconEdit, + PageSection, + }, + mixins: [BVToastMixin, DataFormatterMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + selectedInterface: '', + linkStatus: '', + linkSpeed: '', + fqdn: '', + macAddress: '', + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getSettings(); + }, + }, + created() { + this.getSettings(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-interface-settings-complete'); + }); + }, + methods: { + getSettings() { + this.selectedInterface = this.tabIndex; + this.linkStatus = this.ethernetData[this.selectedInterface].LinkStatus; + this.linkSpeed = this.ethernetData[this.selectedInterface].SpeedMbps; + this.fqdn = this.ethernetData[this.selectedInterface].FQDN; + this.macAddress = this.ethernetData[this.selectedInterface].MACAddress; + }, + initMacAddressModal() { + this.$bvModal.show('modal-mac-address'); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Settings/Network/TableDns.vue b/src/views/_ibs/Settings/Network/TableDns.vue new file mode 100644 index 00000000..569109f1 --- /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/Mixins/BVToastMixin'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import PageSection from '@/components/Global/PageSection'; +import TableRowAction from '@/components/Global/TableRowAction'; +import { mapState } from 'vuex'; + +export default { + name: 'DNSTable', + components: { + IconAdd, + IconEdit, + IconTrashcan, + PageSection, + TableRowAction, + }, + mixins: [BVToastMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + form: { + dnsStaticTableItems: [], + }, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + dnsTableFields: [ + { + key: 'address', + label: this.$t('pageNetwork.table.ipAddress'), + }, + { key: 'actions', label: '', tdClass: 'text-right' }, + ], + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getStaticDnsItems(); + }, + ethernetData() { + this.getStaticDnsItems(); + }, + }, + created() { + this.getStaticDnsItems(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-table-dns-complete'); + }); + }, + methods: { + getStaticDnsItems() { + const index = this.tabIndex; + const dns = this.ethernetData[index].StaticNameServers || []; + this.form.dnsStaticTableItems = dns.map((server) => { + return { + address: server, + actions: [ + { + value: 'delete', + title: this.$t('pageNetwork.table.deleteDns'), + }, + ], + }; + }); + }, + onDnsTableAction(action, $event, index) { + if ($event === 'delete') { + this.deleteDnsTableRow(index); + } + }, + deleteDnsTableRow(index) { + this.form.dnsStaticTableItems.splice(index, 1); + const newDnsArray = this.form.dnsStaticTableItems.map((dns) => { + return dns.address; + }); + this.$store + .dispatch('network/editDnsAddress', newDnsArray) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initDnsModal() { + this.$bvModal.show('modal-dns'); + }, + }, +}; +</script> diff --git a/src/views/_ibs/Settings/Network/TableIpv4.vue b/src/views/_ibs/Settings/Network/TableIpv4.vue new file mode 100644 index 00000000..75870031 --- /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/Mixins/BVToastMixin'; +import IconAdd from '@carbon/icons-vue/es/add--alt/20'; +import IconEdit from '@carbon/icons-vue/es/edit/20'; +import IconTrashcan from '@carbon/icons-vue/es/trash-can/20'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import PageSection from '@/components/Global/PageSection'; +import TableRowAction from '@/components/Global/TableRowAction'; +import { mapState } from 'vuex'; + +export default { + name: 'Ipv4Table', + components: { + IconAdd, + IconEdit, + IconTrashcan, + PageSection, + TableRowAction, + }, + mixins: [BVToastMixin, LoadingBarMixin], + props: { + tabIndex: { + type: Number, + default: 0, + }, + }, + data() { + return { + form: { + ipv4TableItems: [], + }, + actions: [ + { + value: 'edit', + title: this.$t('global.action.edit'), + }, + { + value: 'delete', + title: this.$t('global.action.delete'), + }, + ], + ipv4TableFields: [ + { + key: 'Address', + label: this.$t('pageNetwork.table.ipAddress'), + }, + { + key: 'Gateway', + label: this.$t('pageNetwork.table.gateway'), + }, + { + key: 'SubnetMask', + label: this.$t('pageNetwork.table.subnet'), + }, + { + key: 'AddressOrigin', + label: this.$t('pageNetwork.table.addressOrigin'), + }, + { key: 'actions', label: '', tdClass: 'text-right' }, + ], + }; + }, + computed: { + ...mapState('network', ['ethernetData']), + }, + watch: { + // Watch for change in tab index + tabIndex() { + this.getIpv4TableItems(); + }, + ethernetData() { + this.getIpv4TableItems(); + }, + }, + created() { + this.getIpv4TableItems(); + this.$store.dispatch('network/getEthernetData').finally(() => { + // Emit initial data fetch complete to parent component + this.$root.$emit('network-table-ipv4-complete'); + }); + }, + methods: { + getIpv4TableItems() { + const index = this.tabIndex; + const addresses = this.ethernetData[index].IPv4Addresses || []; + this.form.ipv4TableItems = addresses.map((ipv4) => { + return { + Address: ipv4.Address, + SubnetMask: ipv4.SubnetMask, + Gateway: ipv4.Gateway, + AddressOrigin: ipv4.AddressOrigin, + actions: [ + { + value: 'delete', + title: this.$t('pageNetwork.table.deleteIpv4'), + }, + ], + }; + }); + }, + onIpv4TableAction(action, $event, index) { + if ($event === 'delete') { + this.deleteIpv4TableRow(index); + } + }, + deleteIpv4TableRow(index) { + this.form.ipv4TableItems.splice(index, 1); + const newIpv4Array = this.form.ipv4TableItems.map((ipv4) => { + const { Address, SubnetMask, Gateway } = ipv4; + return { + Address, + SubnetMask, + Gateway, + }; + }); + this.$store + .dispatch('network/editIpv4Address', newIpv4Array) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)); + }, + initAddIpv4Address() { + this.$bvModal.show('modal-add-ipv4'); + }, + }, +}; +</script> diff --git a/src/views/_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..06e30f3e --- /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/Global/PageTitle'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; + +export default { + name: 'PowerRestorePolicy', + components: { PageTitle }, + mixins: [VuelidateMixin, BVToastMixin, LoadingBarMixin], + beforeRouteLeave(to, from, next) { + this.hideLoader(); + next(); + }, + data() { + return { + policyValue: null, + options: [], + }; + }, + computed: { + powerRestorePolicies() { + return this.$store.getters['powerPolicy/powerRestorePolicies']; + }, + currentPowerRestorePolicy: { + get() { + return this.$store.getters['powerPolicy/powerRestoreCurrentPolicy']; + }, + set(policy) { + this.policyValue = policy; + }, + }, + }, + created() { + this.startLoader(); + this.renderPowerRestoreSettings(); + }, + methods: { + renderPowerRestoreSettings() { + Promise.all([ + this.$store.dispatch('powerPolicy/getPowerRestorePolicies'), + this.$store.dispatch('powerPolicy/getPowerRestoreCurrentPolicy'), + ]).finally(() => { + this.options.length = 0; + this.powerRestorePolicies.map((item) => { + this.options.push({ + text: this.$t(`pagePowerRestorePolicy.policiesDesc.${item.state}`), + value: `${item.state}`, + }); + }); + this.endLoader(); + }); + }, + submitForm() { + this.startLoader(); + this.$store + .dispatch( + 'powerPolicy/setPowerRestorePolicy', + this.policyValue || this.currentPowerRestorePolicy + ) + .then((message) => this.successToast(message)) + .catch(({ message }) => this.errorToast(message)) + .finally(() => { + this.renderPowerRestoreSettings(); + }); + }, + }, +}; +</script> diff --git a/src/views/_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; |