diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 656994a0a2..85a1e2f4b5 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -24,8 +24,8 @@ @import "./global/labeled-input"; @import "./global/tooltip"; @import "./global/table"; +@import "./global/select"; -/* @import 'vue-select/src/scss/vue-select.scss'; */ @import "./vendor/vue-select"; @import "./vendor/vue-js-modal"; @import '@/node_modules/xterm/css/xterm.css'; diff --git a/assets/styles/base/_basic.scss b/assets/styles/base/_basic.scss index 45cc2d5e32..936da64619 100644 --- a/assets/styles/base/_basic.scss +++ b/assets/styles/base/_basic.scss @@ -16,7 +16,7 @@ BODY { margin: 0; scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); - + &.overflow-hidden { overflow: hidden; } @@ -70,6 +70,8 @@ TEXTAREA, BUTTON, .btn, .labeled-input, +.labeled-select, +.unlabeled-select, .checkbox-custom, .radio-custom { &:focus, &.focused { diff --git a/assets/styles/global/_button.scss b/assets/styles/global/_button.scss index e0adb11c35..5f56f55b22 100644 --- a/assets/styles/global/_button.scss +++ b/assets/styles/global/_button.scss @@ -24,7 +24,7 @@ button, color: white; &:hover { - text-decoration: none; + text-decoration: none; } &.bg-transparent { @@ -49,10 +49,11 @@ button, //btn sizes .btn-xs, .btn-group-xs > .btn, -.btn-xs .btn-label { - padding: $xs-padding; - font-size: .65em; - line-height: 1.5em; +.btn-xs .btn-label, +.btn-xs .dd-button > .vs__dropdown-toggle { + padding: $xs-padding; + font-size: 0.65em; + line-height: 1.5em; } .btn-group-xs > .btn { @@ -62,8 +63,7 @@ button, .btn-sm, .btn-group-sm > .btn, .btn-sm .btn-label { - padding: $sm-padding; - + padding: $sm-padding; } .btn-group-sm > .btn { @@ -74,8 +74,8 @@ button, .btn-lg, .btn-group-lg > .btn, .btn-lg .btn-label { - padding: $lg-padding; - font-size: 1.2em; + padding: $lg-padding; + font-size: 1.2em; } //btn roles @@ -113,7 +113,6 @@ button, line-height: 0; } - .icon-group i { font-size: 1.5em; } diff --git a/assets/styles/global/_form.scss b/assets/styles/global/_form.scss index 880ea7fb06..708dfba410 100644 --- a/assets/styles/global/_form.scss +++ b/assets/styles/global/_form.scss @@ -6,7 +6,7 @@ INPUT[type='password'], INPUT[type='number'], INPUT[type='date'], INPUT[type='email'], -INPUT[type='search'], +INPUT[type='search']:not(.vs__search), INPUT[type='tel'], INPUT[type='url'], SELECT, @@ -26,7 +26,7 @@ TEXTAREA, &:not(.focused) { &.success { border: solid 1px var(--success); - input, .selected { + input, .selected { color: var(--success); } @@ -34,10 +34,10 @@ TEXTAREA, color: var(--success); } } - + &.warning { border: solid 1px var(--warning); - input, .selected { + input, .selected { color: var(--warning); } @@ -45,10 +45,10 @@ TEXTAREA, color: var(--warning); } } - + &.error { border: solid 1px var(--error); - input, .selected { + input, .selected { color: var(--error); } diff --git a/assets/styles/global/_labeled-input.scss b/assets/styles/global/_labeled-input.scss index 18286827d3..b54ed5903b 100644 --- a/assets/styles/global/_labeled-input.scss +++ b/assets/styles/global/_labeled-input.scss @@ -13,7 +13,6 @@ transition-timing-function: ease-in-out; color: var(--input-label); pointer-events: none; - z-index: z-index('overContent'); i { pointer-events: initial; } diff --git a/assets/styles/global/_select.scss b/assets/styles/global/_select.scss new file mode 100644 index 0000000000..60fce919b5 --- /dev/null +++ b/assets/styles/global/_select.scss @@ -0,0 +1,116 @@ +.labeled-select { + cursor: text; + padding: 0; + width: 100%; + + .selected { + padding-top: 17px; + } +} + +.labeled-select, +.unlabeled-select { + min-width: 75px; + height: 100%; + + .v-select { + &.inline { + .vs__search { + background-color: transparent; + } + + .vs__dropdown-toggle, + .vs__dropdown-toggle > * { + background-color: transparent; + border: transparent; + } + + .vs__dropdown-menu { + outline: none; + } + + .selected { + position: relative; + top: 1.4em; + } + } + } + .v-select.inline.vs--single { + &.vs--searching .vs__selected { + display: none; + } + &:not(.vs--searching) { + .vs__selected-options { + overflow: hidden; + flex-wrap: nowrap; + .vs__selected { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block; + } + } + } + } + + .v-select.inline:not(.vs--single) { + margin-bottom: -4px; // targets multi-select tag boxes to make the same size as rows next to it + min-height: 30px; + + .vs__selected { + min-height: 25px; + padding: 0 7px; + + &:not(:only-child) { + margin-bottom: 3px; + } + } + } + + &.focused { + outline: none; + border: var(--outline-width) solid var(--outline); + box-shadow: none; + .v-select { + // Can toggle this to get full width dd - maybe make an option? + .vs__dropdown-menu { + min-width: max-content; + } + } + } +} + +.unlabeled-select { + background-color: var(--input-bg); + border-radius: var(--border-radius); + border: solid var(--outline-width) var(--input-border); + color: var(--input-text); + + .vs--single .vs__selected-options { + flex-wrap: nowrap; + } + + .v-select { + &.inline { + height: 100%; + + .vs__dropdown-toggle { + height: 100%; + } + .vs__actions { + width: auto; + } + } + } + + &:not(.view) { + background-color: var(--input-bg); + + &:hover { + &, + .vs__dropdown-menu { + background: var(--input-hover-bg); + } + } + } +} diff --git a/assets/styles/global/_tooltip.scss b/assets/styles/global/_tooltip.scss index 72fa9b9b1c..aa9a761342 100644 --- a/assets/styles/global/_tooltip.scss +++ b/assets/styles/global/_tooltip.scss @@ -180,134 +180,3 @@ transition: opacity .15s; } } - -// load here instead of component so SSR render isn't all wonky -.dropdown-button-group { - // this matches the top/bottom padding of the default button - $trigger-padding: 15px 10px 15px 10px; - $xs-trigger-padding: 2px 4px 4px 4px; - $sm-trigger-padding: 8px 10px 10px 10px; - $lg-trigger-padding: 18px 10px 10px 10px; - - .v-popover { - .text-right { - margin-top: 5px; - } - .trigger { - height: 100%; - .icon-container { - height: 100%; - padding: $trigger-padding; - i { - transform: scale(1.5); - } - &.btn-xs { - padding: $xs-trigger-padding; - } - &.btn-sm { - padding: $sm-trigger-padding; - } - &.btn-lg { - padding: $lg-trigger-padding; - } - &:focus { - outline-style: none; - box-shadow: none; - border-color: transparent; - } - } - } - } - - .dropdown-button { - background: var(--tooltip-bg); - border: solid thin var(--link-text); - color: var(--link-text); - padding: 0; - display: inline-flex; - - &>*, .icon-chevron-down { - color: var(--primary); - background-color: rgba(0,0,0,0); - } - - .button-divider { - border-right: 1px solid var(--link-text); - } - - &.bg-primary:hover { - background: var(--accent-btn-hover); - } - - &.one-action { - position: relative; - &>.btn { - padding: 15px 35px 15px 15px; - } - .v-popover{ - .trigger{ - position: absolute; - top: 0px; - right: 0px; - left: 0px; - bottom: 0px; - BUTTON { - position: absolute; - right: 0px; - } - } - } - } - } - .popover { - border: none; - } - .tooltip { - &[x-placement^="bottom"] { - .tooltip-arrow { - border-bottom-color: var(--dropdown-border); - - &:after { - border-bottom-color: var(--dropdown-bg); - } - } - } - - .tooltip-inner { - color: var(--dropdown-text); - background-color: var(--dropdown-bg); - border: 1px solid var(--dropdown-border); - padding: 0px; - text-align: left; - - LI { - padding: 10px 50px 10px 20px; - - &.divider { - padding-top: 0px; - padding-bottom: 0px; - - > .divider-inner { - padding: 0; - border-bottom: 1px solid var(--dropdown-divider); - width: 125%; - margin: 0 auto; - } - } - - &:not(.divider):hover { - background-color: var(--dropdown-hover-bg); - color: var(--dropdown-hover-text); - cursor: pointer; - } - } - - } - } - - //header - .user-info { - border-bottom: 1px solid var(--border); - display: block; - } -} diff --git a/assets/styles/themes/_dark.scss b/assets/styles/themes/_dark.scss index 61ac38fe1b..fddcc10f8b 100644 --- a/assets/styles/themes/_dark.scss +++ b/assets/styles/themes/_dark.scss @@ -54,7 +54,7 @@ --body-gradient: linear-gradient(180deg, #{$dark} 0%, #{$darkest} 100%); --body-text: #{$lightest}; --scrollbar-thumb: #{$medium}; - --scrollbar-thumb-dropdown: #{$darker}; + --scrollbar-thumb-dropdown: #{$medium}; --nav-bg: #{$darkest}; --nav-active: #{rgba($primary, 0.3)}; diff --git a/assets/styles/vendor/vue-select.scss b/assets/styles/vendor/vue-select.scss index 8015b66db7..8353485f92 100644 --- a/assets/styles/vendor/vue-select.scss +++ b/assets/styles/vendor/vue-select.scss @@ -9,7 +9,6 @@ } } - .v-select, .v-select * { box-sizing: border-box; @@ -23,7 +22,6 @@ .vs__dropdown-toggle, .vs__clear, .vs__search, - // .vs__selected, .vs__open-indicator { cursor: not-allowed; color: var(--dropdown-disabled-text); @@ -33,24 +31,19 @@ .vs__dropdown-menu { display: block; position: absolute; - top: calc(100% - 1px); - left: 0; + left: -2px; z-index: z-index('dropdownContent'); padding: 5px 0; margin: 0; - width: 100%; + width: calc(100% + 4px); max-height: 350px; min-width: 160px; overflow-y: auto; border: 1px solid var(--dropdown-border); - border-top-style: none; border-radius: var(--border-radius); - border-top-left-radius: 0; - border-top-right-radius: 0; text-align: left; list-style: none; background: var(--dropdown-bg); - box-shadow: 0px 8px 16px 0px var(--shadow); &::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-dropdown) !important; @@ -60,7 +53,6 @@ &[data-popper-placement='top'] { border-radius: 4px 4px 0 0; border-top-style: solid; - border-bottom-style: none; box-shadow: 0px -8px 16px 0px var(--shadow); } } @@ -89,8 +81,8 @@ color: var(--dropdown-disabled-text); cursor: not-allowed; - HR { - cursor: default + hr { + cursor: default; } } @@ -104,7 +96,7 @@ color: var(--dropdown-hover-text); background: var(--dropdown-hover-bg); - A { + a { color: var(--dropdown-hover-text); text-decoration: none; } @@ -114,7 +106,6 @@ .vs__dropdown-toggle { appearance: none; display: flex; - // padding: 0 0 4px 0; background: var(--input-bg); border: 1px solid var(--dropdown-border); border-radius: var(--border-radius); @@ -139,7 +130,9 @@ align-items: center; pointer-events: none; position: relative; - left: -2px; + width: 100%; + justify-content: flex-end; + flex-shrink: 8; svg { display: none; @@ -161,32 +154,17 @@ cursor: pointer; } -.vs--unsearchable .vs__search { - background-color: var(--default-text); - width: 0px; - padding: 0; - align-self: center; - border: 0; - display: none; -} - -.vs--open .vs__dropdown-toggle { - border-bottom-color: transparent; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -$transition-timing-function: cubic-bezier(1.000, -0.115, 0.975, 0.855); +$transition-timing-function: cubic-bezier(1, -0.115, 0.975, 0.855); $transition-duration: 150ms; -.vs__open-indicator { +.vs__action:after { fill: var(--dropdown-disabled-text); transform: scale(1); transition: transform $transition-duration $transition-timing-function; transition-timing-function: $transition-timing-function; } -.vs--open .vs__open-indicator { +.vs--open .vs__actions:after { transform: rotate(180deg) scale(1); } @@ -199,7 +177,7 @@ $transition-duration: 150ms; * below, the cancel button will still appear in chrome. * If it's up here on it's own, it'll hide it. */ - .vs__search::-webkit-search-cancel-button { +.vs__search::-webkit-search-cancel-button { display: none; } @@ -237,12 +215,11 @@ $transition-duration: 150ms; } } .vs--single.vs--searching:not(.vs--open):not(.vs--loading) { - .vs__search { - opacity: .2; - } + .vs__search { + opacity: 0.2; + } } - /* States */ .vs--single { @@ -263,7 +240,7 @@ $transition-duration: 150ms; border: 1px solid var(--primary); border-radius: 3px; color: var(--link-text); - padding: 6px; + margin-left: 7px; &:not(:last-of-type) { margin-right: 2px; @@ -296,47 +273,49 @@ $transition-duration: 150ms; .v-select.inline { background-color: transparent; - &.vs--single .vs__selected { - color: var(--input-text); - position: absolute; - top: 0; - bottom:0; - align-self: start; - margin: 4px; + &.vs--single { + min-height: 25px; + &.vs--open { + .vs__selected { + position: absolute; + opacity: 0.4; + } + .vs__search { + margin-left: 7px;; + } + } + .vs__selected { + color: var(--input-text); + } } .vs__dropdown-menu { - min-width: 0px; + min-width: 0px; + margin-top: 2px; } .vs__dropdown-toggle { background-color: var(--input-bg); - border:none; - height: 100%; + border: none; padding: none; border-radius: var(--border-radius); border: 1px solid var(--dropdown-border); } &.vs--single .vs__selected-options { - display: flex; - flex-direction: column; align-items: center; - width: 100%; } .vs__search { - background-color: rgba(0,0,0,0); - width: 100%; - &:hover{ - background-color: rgba(0,0,0,0); + background-color: rgba(0, 0, 0, 0); + &:hover { + background-color: rgba(0, 0, 0, 0); } } - .vs__open-indicator{ + .vs__open-indicator { fill: var(--input-label); } .vs__clear { - display:none; + display: none; } - } .v-select.mini { @@ -351,15 +330,17 @@ $transition-duration: 150ms; margin: 0; } - INPUT { + input { padding: 0; } } -.vs__selected-options INPUT { - width: auto; +.vs__selected-options input { + width: 0; display: inline-block; border: 0; + background-color: var(--input-bg); + color: var(--input-text); } header .vs-select .vs__dropdown-toggle { diff --git a/assets/translations/en-us.yaml b/assets/translations/en-us.yaml index 6043761732..6c085e5742 100644 --- a/assets/translations/en-us.yaml +++ b/assets/translations/en-us.yaml @@ -109,6 +109,7 @@ assignTo: =1 { Assign Cluster To… } other { Assign {count} Clusters To… } } + workspace: Workspace asyncButton: default: @@ -327,21 +328,41 @@ chartHeading: cis: addTest: Add Test ID + alertNeeded: To receive alerts, please ensure Rancher's Monitoring and Alerting app is installed and the Receivers and Routes are configured to send out alerts. + alertNotFound: "It looks like alerting isn't configured." + alertOnComplete: Alert on scan completion + alertOnFailure: Alert on scan failure benchmarkVersion: Benchmark Version + cronSchedule: + label: Schedule + placeholder: "e.g. 0 * * * *" + deleteProfileWarning: |- + {count, plural, + =1 { Any scheduled scans using this profile will no longer work. } + other { Any scheduled scans using either of these profiles will no longer work. } + } + downloadAllReports: Download All Saved Reports + downloadLatestReport: Download Latest Report noProfiles: There are no valid ClusterScanProfiles for this cluster type to select. profile: Profile + retention: Retention Count testID: Test ID testsToSkip: Tests to Skip + testsSkipped: Tests Skipped scan: description: Description - failed: Failed + fail: Fail lastScanTime: Last Scan Time notApplicable: 'N/A' number: Number - passed: Passed + pass: Pass scanReport: Scan Report - skipped: Skipped + skip: Skip total: Total + warn: Warn + scoreWarning: + label: Scan state for "warn" results + protip: Scans with no failures will be marked "Pass" by default even if some of the tests generate "warn" output. This behavior can be changed by selecting the "fail" option from this section. cluster: nodeDriver: @@ -871,6 +892,24 @@ monitoring: fields: name: Name +monitoringRoute: + groups: + label: Group By + info: This is the top-level Route used by Alertmanager as the default destination for any Alerts that do not match any other Routes. This Route must exist and cannot be deleted. + interval: + label: Group Interval + matching: + info: The root route has to match everything so matching can't be configured. + label: Match + receiver: + label: Receiver + regex: + label: Match Regex + repeatInterval: + label: Repeat Interval + wait: + label: Group Wait + nameNsDescription: name: label: Name @@ -887,6 +926,16 @@ nameNsDescription: namespace: containerResourceLimit: Container Resource Limit + project: + label: Project + +namespaceFilter: + selected: + label: "{total} items selected" + +namespaceList: + selectLabel: Namespace + addLabel: Add Namespace node: detail: @@ -957,6 +1006,39 @@ prefs: hideDesc: label: Hide All Type Description Boxes +probe: + checkInterval: + label: Check Interval + placeholder: 'Default: 10' + command: + label: Command to run + placeholder: e.g. cat /tmp/health + failureThreshold: + label: Failure Threshold + placeholder: 'Default: 3' + httpGet: + headers: + label: Request Headers + path: + label: Request Path + placeholder: e.g. /healthz + port: + label: Check Port + placeholder: e.g. 80 + placeholderDuex: e.g. 25 + initialDelay: + label: Initial Delay + placeholder: 'Default: 0' + successThreshold: + label: Success Threshold + placeholder: 'Default: 1' + timeout: + label: Timeout + placeholder: 'Default: 3' + type: + label: Type + placeholder: Select a check type + prometheusRule: alertingRules: addLabel: Add Alert @@ -1053,7 +1135,7 @@ resourceDetail: project: Project yaml: YAML managedWarning: This {type} is managed by the {managedBy} app {appName}; changes made here will likely be overwritten the next time the app is changed. - show: Show + show: Show hide: Hide resourceList: @@ -1321,6 +1403,18 @@ tableHeaders: version: Version weight: Weight +target: + router: + label: Router + placeholder: Select a router + service: + label: Service + placeholder: Select a service + title: Target + version: + label: Version + placeholder: Select a version + validation: arrayLength: between: '"{key}" should contain between {min} and {max} {max, plural, =1 {item} other {items}}' @@ -1475,7 +1569,7 @@ workload: key: label: Key placeholder: "e.g. metadata.labels['']" - name: + name: label: Variable Name placeholder: "e.g. FOO" prefix: Prefix @@ -1486,9 +1580,10 @@ workload: configMap: ConfigMap containerName: Container Name type: Type - value: + value: label: Value placeholder: e.g. BAR + tty: TTY workingDir: WorkingDir stdin: Stdin healthCheck: @@ -1531,7 +1626,7 @@ workload: security: addCapabilities: Add Capabilities addGroupIDs: Add Group IDs - allowPrivilegeEscalation: + allowPrivilegeEscalation: label: Privilege Escalation 'false': No 'true': "Yes: container can gain more privileges than its parent process" @@ -1543,12 +1638,12 @@ workload: label: Privileged 'false': No 'true': "Yes: container has full access to the host" - readOnlyRootFilesystem: + readOnlyRootFilesystem: label: Read-Only Root Filesystem 'false': No 'true': "Yes: container has a read-only root filesystem" runAsGroup: Run as Group ID - runAsNonRoot: + runAsNonRoot: label: Run as Non-Root 'false': No 'true': "Yes: container must run as a non-root user" @@ -1669,6 +1764,7 @@ workload: inNamespaces: "Pods in these namespaces:" key: Key lessThan: < + namespaces: Namespaces notIn: ≠ operator: Operator value: Value @@ -1758,6 +1854,7 @@ workload: storagePolicyName: Storage Policy Name volumePath: Volume Path defaultMode: Default Mode + driver: driver hostPath: label: The Path on the Node must be options: @@ -2046,3 +2143,7 @@ workloadPorts: podAffinity: addLabel: Add Pod Selector + +keyValue: + keyPlaceholder: e.g. foo + valuePlaceholder: e.g. bar diff --git a/assets/translations/zh-hans.yaml b/assets/translations/zh-hans.yaml index d426ab1043..c33a15da0d 100644 --- a/assets/translations/zh-hans.yaml +++ b/assets/translations/zh-hans.yaml @@ -962,7 +962,7 @@ servicePorts: label: 协议 target: label: 目标端口 - placeholder: 例如:80 or http + placeholder: 例如:80 或 http serviceTypes: clusterip: 集群IP地址 @@ -1130,6 +1130,23 @@ tableHeaders: value: 值 version: 版本号 weight: 权重 ## doublecheck,这里的权重有什么特殊意义吗? + Capacity: 容量 + 'Access Modes': 访问模式 + 'Reclaim Policy': 重声明策略 + Status: 状态 + Claim: 声明 + StorageClass: 存储类 + Reason: 原因 + VolumeMode: 存储卷模式 + Targets: 目标 + MinPods: 最小Pod数量 + MaxPods: 最大Pod数量 + Replicas: 副本数 + Provisioner: 提供商 + ReclaimPolicy: 重声明策略 + Role: 角色 + Users: 用户 + Groups: 用户组 validation: arrayLength: @@ -1659,6 +1676,16 @@ typeLabel: persistentvolume: 持久卷 service: 服务 node: 节点 + autoscaling.horizontalpodautoscaler: Pod水平自动伸缩 + networking.k8s.io.ingress: Ingress + networking.k8s.io.networkpolicy: 网络策略 + persistentvolumeclaim: 持久卷声明 + storage.k8s.io.storageclass: 存储类 + secret: 密钥 + rbac.authorization.k8s.io.clusterrolebinding: 集群角色绑定 + rbac.authorization.k8s.io.clusterrole: 集群角色 + rbac.authorization.k8s.io.rolebinding: 角色绑定 + rbac.authorization.k8s.io.role: 角色 action: download: 下载YAML @@ -1684,3 +1711,7 @@ workloadPorts: podAffinity: addLabel: 添加 Pod Selector + +keyValue: + keyPlaceholder: '例如: foo' + valuePlaceholder: '例如: bar' diff --git a/components/AssignTo.vue b/components/AssignTo.vue index 634689b4a5..228771deee 100644 --- a/components/AssignTo.vue +++ b/components/AssignTo.vue @@ -121,7 +121,7 @@ export default {
@@ -141,7 +141,7 @@ export default {
+import { get } from '@/utils/object'; +import isString from 'lodash/isString'; +import VueSelectOverrides from '@/mixins/vue-select-overrides'; + export default { - props: { - size: { + mixins: [VueSelectOverrides], + props: { + buttonLabel: { + default: '', type: String, - default: '' // possible values are xs, sm, lg. empty is default .btn }, - // whether this is a button and dropdown (default) or dropdown that looks like a button/dropdown - dualAction: { + closeOnSelect: { + default: true, + type: Boolean + }, + disabled: { + default: false, type: Boolean, - default: true - } + }, + // array of option objects containing at least a label and link, but also icon and action are available + dropdownOptions: { + // required: true, + default: () => [], + type: Array, + }, + optionKey: { + default: null, + type: String, + }, + optionLabel: { + default: 'label', + type: String, + }, + // sm, null(med), lg - no xs...its so small + size: { + default: null, + type: String, + }, + value: { + default: null, + type: String, + }, }, - - computed: { - buttonSize() { - const { size } = this; - let out; - - switch (size) { - case '': - default: - out = 'btn'; - break; - case 'xs': - out = 'btn btn-xs'; - break; - case 'sm': - out = 'btn btn-sm'; - break; - case 'lg': - out = 'btn btn-lg'; - break; - } - - return out; - } + data() { + return { focused: false }; }, methods: { - hasSlot(name = 'default') { - return !!this.$slots[name] || !!this.$scopedSlots[name]; + ddButtonAction(option) { + this.focusSearch(); + this.$emit('dd-button-action', option); + }, + getOptionLabel(option) { + if (isString(option)) { + return option; + } + + if (this.$attrs['get-option-label']) { + return this.$attrs['get-option-label'](option); + } + + if (get(option, this.optionLabel)) { + if (this.localizedLabel) { + return this.$store.getters['i18n/t'](get(option, this.optionLabel)); + } else { + return get(option, this.optionLabel); + } + } else { + return option; + } }, - // allows parent components to programmatically open the dropdown - togglePopover() { - this.$refs.popoverButton.click(); + onFocus() { + return this.onFocusLabeled(); }, - } + + onFocusLabeled() { + this.focused = true; + }, + + onBlur() { + return this.onBlurLabeled(); + }, + + onBlurLabeled() { + this.focused = false; + }, + + focusSearch() { + this.$nextTick(() => { + this.$refs['button-dropdown'].searchEl.focus(); + }); + }, + get, + }, }; + + + diff --git a/components/PromptRemove.vue b/components/PromptRemove.vue index d314f4b00d..76e97a43a6 100644 --- a/components/PromptRemove.vue +++ b/components/PromptRemove.vue @@ -2,15 +2,17 @@ import { mapState, mapGetters } from 'vuex'; import { get, isEmpty } from '@/utils/object'; import { NAMESPACE, RIO } from '@/config/types'; -import Card from '@/components/Card'; +import InfoBox from '@/components/InfoBox'; import { alternateLabel } from '@/utils/platform'; import LinkDetail from '@/components/formatter/LinkDetail'; import { uniq } from '@/utils/array'; export default { - components: { Card, LinkDetail }, + components: { InfoBox, LinkDetail }, data() { - return { confirmName: '', error: '' }; + return { + confirmName: '', error: '', warning: '', preventDelete: false + }; }, computed: { names() { @@ -54,20 +56,6 @@ export default { return (type === NAMESPACE || type === RIO.STACK) && this.toRemove.length === 1; }, - preventDeletionMessage() { - const toRemoveWithWarning = this.toRemove.filter(tr => tr?.preventDeletionMessage); - - if (toRemoveWithWarning.length === 0) { - return null; - } - - return toRemoveWithWarning[0].preventDeletionMessage; - }, - - isDeleteDisabled() { - return !!this.preventDeletionMessage; - }, - plusMore() { const remaining = this.toRemove.length - this.names.length; @@ -126,6 +114,31 @@ export default { } else { this.$modal.hide('promptRemove'); } + }, + + // check for any resources with a deletion prevention message, + // if none found (delete is allowed), then check for any resources with a warning message + toRemove(neu) { + let message; + const preventDeletionMessages = neu.filter(item => item.preventDeletionMessage); + + if (!!preventDeletionMessages.length) { + this.preventDelete = true; + message = preventDeletionMessages[0].preventDeletionMessage; + } else { + const warnDeletionMessages = neu.filter(item => item.warnDeletionMessage); + + if (!!warnDeletionMessages.length) { + message = warnDeletionMessages[0].warnDeletionMessage; + } + } + if (typeof message === 'function' ) { + this.warning = message(this.toRemove); + } else if (!!message) { + this.warning = message; + } else { + this.warning = ''; + } } }, @@ -219,7 +232,7 @@ export default { height="auto" styles="background-color: var(--nav-bg); border-radius: var(--border-radius); max-height: 100vh;" > - +

Are you sure?

@@ -231,10 +244,13 @@ export default { {{ plusMore }}{{ i === toRemove.length-2 ? ', and ' : ', ' }} + + {{ warning }} + Re-enter its name below to confirm:
- {{ preventDeletionMessage }} + {{ error }} {{ protip }} @@ -242,11 +258,11 @@ export default { - - + diff --git a/components/ResourceList/Masthead.vue b/components/ResourceList/Masthead.vue index edd73f8e4d..4bd9fc7534 100644 --- a/components/ResourceList/Masthead.vue +++ b/components/ResourceList/Masthead.vue @@ -1,31 +1,30 @@ - diff --git a/components/form/KeyValue.vue b/components/form/KeyValue.vue index e2e9070943..ffa25c72c9 100644 --- a/components/form/KeyValue.vue +++ b/components/form/KeyValue.vue @@ -11,7 +11,6 @@ import ClickExpand from '@/components/formatter/ClickExpand'; import { get } from '@/utils/object'; import CodeMirror from '@/components/CodeMirror'; import { mapGetters } from 'vuex'; -import ButtonDropdown from '@/components/ButtonDropdown'; import FileSelector from '@/components/form/FileSelector'; import { HIDE_SENSITIVE } from '@/store/prefs'; @@ -22,7 +21,6 @@ export default { TextAreaAutoGrow, ClickExpand, CodeMirror, - ButtonDropdown, FileSelector }, @@ -74,8 +72,10 @@ export default { }, }, keyPlaceholder: { - type: String, - default: 'e.g. foo' + type: String, + default() { + return this.$store.getters['i18n/t']('keyValue.valuePlaceholder'); + }, }, separatorLabel: { @@ -95,8 +95,10 @@ export default { }, }, valuePlaceholder: { - type: String, - default: 'e.g. bar' + type: String, + default() { + return this.$store.getters['i18n/t']('keyValue.valuePlaceholder'); + }, }, valueCanBeEmpty: { type: Boolean, @@ -524,21 +526,10 @@ export default { @@ -566,7 +557,7 @@ export default { -ms-word-break: break-all; word-break: break-word; display:flex; - align-items:start; + align-items:flex-start; } &.extra-column { @@ -581,7 +572,7 @@ export default { width: 100%; margin: 10px 0px 10px 0px; &.key { - align-self: start; + align-self: flex-start; } .text-monospace:not(.conceal) { diff --git a/components/form/LabeledSelect.vue b/components/form/LabeledSelect.vue index 3241c8fd06..81d051c897 100644 --- a/components/form/LabeledSelect.vue +++ b/components/form/LabeledSelect.vue @@ -4,10 +4,11 @@ import LabeledFormElement from '@/mixins/labeled-form-element'; import { findBy } from '@/utils/array'; import { get } from '@/utils/object'; import LabeledTooltip from '@/components/form/LabeledTooltip'; +import VueSelectOverrides from '@/mixins/vue-select-overrides'; export default { components: { LabeledTooltip }, - mixins: [LabeledFormElement], + mixins: [LabeledFormElement, VueSelectOverrides], props: { value: { @@ -24,15 +25,15 @@ export default { }, disabled: { type: Boolean, - default: false + default: false, }, optionKey: { type: String, - default: null + default: null, }, optionLabel: { type: String, - default: 'label' + default: 'label', }, placement: { type: String, @@ -40,30 +41,34 @@ export default { }, tooltip: { type: String, - default: null + default: null, }, hoverTooltip: { type: Boolean, - default: false + default: false, }, localizedLabel: { type: Boolean, - default: false + default: false, + }, + searchable: { + default: false, + type: Boolean, }, status: { - type: String, - default: null + type: String, + default: null, }, reduce: { - type: Function, + type: Function, default: (e) => { - if ( e && typeof e === 'object' && e.value !== undefined ) { + if (e && typeof e === 'object' && e.value !== undefined) { return e.value; } return e; - } - } + }, + }, }, data() { @@ -74,15 +79,15 @@ export default { currentLabel() { let entry; - if ( this.grouped ) { - for ( let i = 0 ; i < this.options.length && !entry ; i++ ) { + if (this.grouped) { + for (let i = 0; i < this.options.length && !entry; i++) { entry = findBy(this.options[i].items || [], 'value', this.value); } } else { entry = findBy(this.options || [], 'value', this.value); } - if ( entry ) { + if (entry) { return entry.label; } @@ -91,6 +96,11 @@ export default { }, methods: { + focusSearch() { + this.$nextTick(() => { + this.$refs.input.searchEl.focus(); + }); + }, onFocus() { this.selectedVisibility = 'hidden'; this.onFocusLabeled(); @@ -102,6 +112,9 @@ export default { }, getOptionLabel(option) { + if (!option) { + return; + } if (this.$attrs['get-option-label']) { return this.$attrs['get-option-label'](option); } @@ -138,7 +151,7 @@ export default { modifiers: [ { name: 'offset', - options: { offset: [0, -1] } + options: { offset: [0, 2] }, }, { name: 'toggleClass', @@ -147,7 +160,8 @@ export default { fn({ state }) { component.$el.setAttribute('x-placement', state.placement); }, - }] + }, + ], }); /** @@ -163,26 +177,42 @@ export default { input.open = true; } }, - get + get, }, }; - diff --git a/components/form/Probe.vue b/components/form/Probe.vue index 0266c698cc..e514c3ebe1 100644 --- a/components/form/Probe.vue +++ b/components/form/Probe.vue @@ -156,9 +156,9 @@ export default { v-if="!isView || kind!=='none'" v-model="kind" :mode="mode" - :label="t('generic.type')" + :label="t('probe.type.label')" :options="kindOptions" - placeholder="Select a check type" + :placeholder="t('probe.type.placeholder')" />
@@ -170,8 +170,8 @@ export default { min="1" max="65535" :mode="mode" - :label="t('workload.container.healthCheck.httpGet.port')" - placeholder="e.g. 80" + :label="t('probe.httpGet.port.label')" + :placeholder="t('probe.httpGet.port.placeholder')" />
@@ -179,8 +179,8 @@ export default {
@@ -191,8 +191,8 @@ export default { min="1" max="65535" :mode="mode" - :label="t('workload.container.healthCheck.httpGet.port')" - placeholder="e.g. 25" + :label="t('probe.httpGet.port.label')" + :placeholder="t('probe.httpGet.port.placeholderDuex')" />
@@ -201,8 +201,8 @@ export default {
@@ -219,30 +219,30 @@ export default {
@@ -256,8 +256,8 @@ export default { type="number" min="1" :mode="mode" - :label="t('workload.container.healthCheck.successThreshold')" - placeholder="Default: 1" + :label="t('probe.successThreshold.label')" + :placeholder="t('probe.successThreshold.placeholder')" />
@@ -266,8 +266,8 @@ export default { type="number" min="1" :mode="mode" - :label="t('workload.container.healthCheck.failureThreshold')" - placeholder="Default: 3" + :label="t('probe.failureThreshold.label')" + :placeholder="t('probe.failureThreshold.label')" />
@@ -284,14 +284,14 @@ export default { :pad-left="false" :as-map="false" :read-allowed="false" - :title="t('workload.container.healthCheck.httpGet.headers')" + :title="t('probe.httpGet.headers.label')" :key-label="t('generic.name')" :value-label="t('generic.value')" :add-label="t('generic.add')" > @@ -308,5 +308,8 @@ export default { .title { margin-bottom: 10px; } + ::v-deep .labeled-select { + height: auto; + } diff --git a/components/form/RuleSelector.vue b/components/form/RuleSelector.vue index e0b660c692..a8c52bc359 100644 --- a/components/form/RuleSelector.vue +++ b/components/form/RuleSelector.vue @@ -2,7 +2,7 @@ import { _VIEW } from '@/config/query-params'; import ArrayList from '@/components/form/ArrayList'; import LabeledInput from '@/components/form/LabeledInput'; -import LabeledSelect from '@/components/form/LabeledSelect'; +import Select from '@/components/form/Select'; const OPERATOR_VALUES = { IS_SET: 'Exists', @@ -15,7 +15,7 @@ export default { components: { ArrayList, LabeledInput, - LabeledSelect + Select }, props: { @@ -132,7 +132,7 @@ export default {
- - diff --git a/components/form/ValueFromResource.vue b/components/form/ValueFromResource.vue index 3cf5d070b6..eee1fe9755 100644 --- a/components/form/ValueFromResource.vue +++ b/components/form/ValueFromResource.vue @@ -258,6 +258,8 @@ export default { :mode="mode" :multiple="false" :options="typeOpts" + option-label="label" + :searchable="false" :reduce="e=>e.value" :label="t('workload.container.command.fromResource.type')" @input="updateRow" diff --git a/components/form/WorkloadPorts.vue b/components/form/WorkloadPorts.vue index 2cf59b1a12..1b7bc24326 100644 --- a/components/form/WorkloadPorts.vue +++ b/components/form/WorkloadPorts.vue @@ -36,7 +36,11 @@ export default { // show host port column if existing port data has any host ports defined const showHostPorts = !!rows.filter(row => !!row.hostPort).length; - return { rows, showHostPorts }; + return { + rows, + showHostPorts, + workloadPortOptions: ['TCP', 'UDP'] + }; }, computed: { @@ -188,9 +192,8 @@ export default { diff --git a/components/formatter/LiveDate.vue b/components/formatter/LiveDate.vue index 354370843a..2a98381e92 100644 --- a/components/formatter/LiveDate.vue +++ b/components/formatter/LiveDate.vue @@ -12,18 +12,27 @@ export default { type: String, default: '' }, + addSuffix: { type: Boolean, default: false, }, + + addPrefix: { + type: Boolean, + default: true + }, + suffix: { type: String, default: 'ago', }, + tooltipPlacement: { type: String, default: 'auto' }, + showTooltip: { type: Boolean, default: true @@ -84,7 +93,7 @@ export default { const value = day(this.value); const now = day(); let diff = value.diff(now, 'seconds'); - const prefix = (diff < 0 ? '' : '-'); + const prefix = (diff < 0 || !this.addPrefix ? '' : '-'); const suffix = ''; diff = Math.abs(diff); diff --git a/components/nav/ClusterSwitcher.vue b/components/nav/ClusterSwitcher.vue index edc52e1b1e..9803472a65 100644 --- a/components/nav/ClusterSwitcher.vue +++ b/components/nav/ClusterSwitcher.vue @@ -3,17 +3,23 @@ import { MANAGEMENT } from '@/config/types'; import { sortBy } from '@/utils/sort'; import { findBy } from '@/utils/array'; import { mapState } from 'vuex'; +import Select from '@/components/form/Select'; export default { - computed: { + components: { Select }, + computed: { ...mapState(['isMultiCluster']), value: { get() { const options = this.options; - const existing = findBy(options, 'id', this.$store.getters['clusterId']); + const existing = findBy( + options, + 'id', + this.$store.getters['clusterId'] + ); - if ( existing ) { + if (existing) { return existing; } @@ -22,7 +28,7 @@ export default { set(neu) { this.$router.push({ name: 'c-cluster', params: { cluster: neu.id } }); - } + }, }, options() { @@ -43,22 +49,20 @@ export default { methods: { focus() { this.$refs.select.$refs.search.focus(); - } + }, }, }; - diff --git a/components/nav/WindowManager/ContainerLogs.vue b/components/nav/WindowManager/ContainerLogs.vue index a41b0b0320..463e80ec5f 100644 --- a/components/nav/WindowManager/ContainerLogs.vue +++ b/components/nav/WindowManager/ContainerLogs.vue @@ -446,7 +446,7 @@ export default { v-if="containerChoices.length > 0" v-model="container" :disabled="containerChoices.length === 1" - class="containerPicker auto-width" + class="containerPicker" :options="containerChoices" :clearable="false" placement="top" @@ -560,11 +560,11 @@ export default { } } - .containerPicker ::v-deep .vs__search, - .range ::v-deep .vs__search { - width: 0; - padding: 0; - margin: 0; - opacity: 0; + .containerPicker { + ::v-deep &.unlabeled-select { + display: inline-block; + min-width: 200px; + } } + diff --git a/components/nav/WindowManager/ContainerShell.vue b/components/nav/WindowManager/ContainerShell.vue index ebdf93d4ae..406893aa53 100644 --- a/components/nav/WindowManager/ContainerShell.vue +++ b/components/nav/WindowManager/ContainerShell.vue @@ -268,8 +268,8 @@ export default { :disabled="containerChoices.length === 1" class="containerPicker auto-width" :options="containerChoices" - :searchable="false" :clearable="false" + placement="top" @input="switchTo($event)" > diff --git a/mixins/labeled-form-element.js b/mixins/labeled-form-element.js index fd88058821..414b0aff7f 100644 --- a/mixins/labeled-form-element.js +++ b/mixins/labeled-form-element.js @@ -54,10 +54,23 @@ export default { notView() { return (this.mode !== _VIEW); }, + + isSearchable() { + const { searchable } = this; + const options = ( this.options || [] ); + + if (searchable || options.length >= 10) { + return true; + } + + return false; + }, }, methods: { onFocus() { + this.$emit('on-focus'); + return this.onFocusLabeled(); }, @@ -67,6 +80,8 @@ export default { }, onBlur() { + this.$emit('on-blur'); + return this.onBlurLabeled(); }, diff --git a/mixins/vue-select-overrides.js b/mixins/vue-select-overrides.js new file mode 100644 index 0000000000..5938a6eb43 --- /dev/null +++ b/mixins/vue-select-overrides.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; + +export default { + methods: { + mappedKeys(map, vm) { + // Defaults found at - https://github.com/sagalbot/vue-select/blob/master/src/components/Select.vue#L947 + const out = { ...map }; + + // tab + (out[9] = (e) => { + e.preventDefault(); + + const optsLen = vm.filteredOptions.length; + const typeAheadPointer = vm.typeAheadPointer; + + if (e.shiftKey) { + if (typeAheadPointer === 0) { + vm.$refs.search.focus(); + + return vm.onEscape(); + } + + return vm.typeAheadUp(); + } + if (typeAheadPointer + 1 === optsLen) { + $(vm.$el).next().focus(); + + return vm.onEscape(); + } + + return vm.typeAheadDown(); + }); + + return out; + }, + } +}; diff --git a/models/cis.cattle.io.clusterscan.js b/models/cis.cattle.io.clusterscan.js index 8c25c87172..b3695a2656 100644 --- a/models/cis.cattle.io.clusterscan.js +++ b/models/cis.cattle.io.clusterscan.js @@ -1,9 +1,11 @@ import { CIS } from '@/config/types'; -import { downloadFile } from '@/utils/download'; +import { findBy } from '@/utils/array'; +import { downloadFile, generateZip } from '@/utils/download'; +import { isEmpty, set } from '@/utils/object'; +import { sortBy } from '@/utils/sort'; export default { _availableActions() { - this.getReport(); let out = this._standardActions; const toFilter = ['cloneYaml', 'goToEditYaml', 'download']; @@ -14,36 +16,67 @@ export default { } }); + const t = this.$rootGetters['i18n/t']; + const downloadReport = { - action: 'downloadReport', + action: 'downloadLatestReport', enabled: this.hasReport, - icon: 'icon icon-fw icon-chevron-right', - label: 'Download Report', + icon: 'icon icon-fw icon-download', + label: t('cis.downloadLatestReport'), total: 1, }; - out.unshift({ divider: true }); - out.unshift(downloadReport); + const downloadAllReports = { + action: 'downloadAllReports', + enabled: this.hasReport, + icon: 'icon icon-fw icon-download', + label: t('cis.downloadAllReports'), + total: 1, + }; + + if (this.hasReports) { + out.unshift({ divider: true }); + if (this.spec?.cronSchedule) { + out.unshift(downloadAllReports); + } + out.unshift(downloadReport); + } return out; }, - hasReport: false, + applyDefaults() { + return () => { + const spec = this.spec || {}; - getReport() { - return async() => { - const owned = await this.getOwned(); - const reportCRD = owned.filter(each => each.type === CIS.REPORT)[0]; - - this.hasReport = !!reportCRD; - - return reportCRD; + spec.scanProfileName = null; + spec.scanAlertRule = {}; + spec.scoreWarning = 'pass'; + set(this, 'spec', spec); }; }, - downloadReport() { + hasReports() { + const { relationships = [] } = this.metadata; + + const reportRel = findBy(relationships, 'toType', CIS.REPORT); + + return !!reportRel; + }, + + getReports() { return async() => { - const report = await this.getReport(); + const owned = await this.getOwned(); + const reportCRDs = owned.filter(each => each.type === CIS.REPORT); + + return reportCRDs; + }; + }, + + downloadLatestReport() { + return async() => { + const reports = await this.getReports() || []; + const report = sortBy(reports, 'metadata.creationTimestamp', true)[0]; const Papa = await import(/* webpackChunkName: "cis" */'papaparse'); try { @@ -56,4 +89,29 @@ export default { } }; }, + + downloadAllReports() { + return async() => { + const toZip = {}; + const reports = await this.getReports() || []; + const Papa = await import(/* webpackChunkName: "cis" */'papaparse'); + + reports.forEach((report) => { + try { + const testResults = report.aggregatedTests; + const csv = Papa.unparse(testResults); + + toZip[`${ report.id }.csv`] = csv; + } catch (err) { + this.$dispatch('growl/fromError', { title: 'Error downloading file', err }, { root: true }); + } + }); + if (!isEmpty(toZip)) { + generateZip(toZip).then((zip) => { + downloadFile(`${ this.id }-reports`, zip, 'application/zip'); + }); + } + }; + } + }; diff --git a/models/cis.cattle.io.clusterscanprofile.js b/models/cis.cattle.io.clusterscanprofile.js new file mode 100644 index 0000000000..24c7a19ac9 --- /dev/null +++ b/models/cis.cattle.io.clusterscanprofile.js @@ -0,0 +1,14 @@ + +export default { + warnDeletionMessage() { + return (toRemove = []) => { + return this.$rootGetters['i18n/t']('cis.deleteProfileWarning', { count: toRemove.length }); + }; + }, + + numberTestsSkipped() { + const { skipTests = [] } = this.spec; + + return skipTests.length; + } +}; diff --git a/pages/c/_cluster/apps/install.vue b/pages/c/_cluster/apps/install.vue index 79096d1aaf..999b3168c0 100644 --- a/pages/c/_cluster/apps/install.vue +++ b/pages/c/_cluster/apps/install.vue @@ -803,7 +803,7 @@ export default {
{ const attributes = schema.attributes || {}; const columns = attributes.columns || []; @@ -790,7 +790,7 @@ export const getters = { if ( typeof entry === 'string' ) { const col = findBy(columns, 'name', entry); if ( col ) { - return fromSchema(col); + return fromSchema(col, rootGetters); } else { return null; } @@ -812,7 +812,7 @@ export const getters = { out.push(NAMESPACE); } } else { - out.push(fromSchema(col)); + out.push(fromSchema(col, rootGetters)); } } @@ -831,7 +831,7 @@ export const getters = { return out; - function fromSchema(col) { + function fromSchema(col, rootGetters) { let formatter, width, formatterOpts; if ( (col.format === '' || col.format == 'date') && col.name === 'Age' ) { @@ -848,9 +848,13 @@ export const getters = { formatter = 'Number'; } + const exists = rootGetters['i18n/exists'] + const t = rootGetters['i18n/t'] + const labelKey = `tableHeaders.${col.name}` + return { name: col.name.toLowerCase(), - label: col.name, + label: exists(labelKey) ? t(labelKey) : col.name, value: col.field.startsWith('.') ? `$${ col.field }` : col.field, sort: [col.field], formatter, diff --git a/utils/download.js b/utils/download.js index cd790c1ad2..65b0adbcad 100644 --- a/utils/download.js +++ b/utils/download.js @@ -7,15 +7,13 @@ export async function downloadFile(fileName, content, contentType = 'text/plain; return saveAs(blob, fileName); } -// [{name: 'file1', file: 'data'}, {name: 'file2', file: 'data2'}] +// {[fileName1]:data1, [fileName2]:data2} export function generateZip(files) { // Moving this to a dynamic const JSZip = import('jszip') didn't work... figure out later const zip = new JSZip(); for ( const fileName in files) { - const file = files[fileName]; - - zip.file(fileName, file.data); + zip.file(fileName, files[fileName]); } return zip.generateAsync({ type: 'blob' }).then((contents) => {