mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-04 05:06:05 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b754457ec | ||
|
|
2cb838eded | ||
|
|
bdcb34ab14 | ||
|
|
8daf44b3f6 | ||
|
|
8044e74102 | ||
|
|
264d65cc6d | ||
|
|
342a82c8e0 | ||
|
|
e7cd5cc4b0 | ||
|
|
cf405be71e | ||
|
|
911d30d7ba | ||
|
|
1b4bfa0962 | ||
|
|
d0f6f18a8a |
@@ -1,4 +1,4 @@
|
|||||||
name: "[PR Management] Auto Assign Reviewer"
|
name: "[PR Management] Auto Assign Reviewer & Assignee"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@@ -9,6 +9,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
auto-assign:
|
auto-assign:
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: rancher/gh-issue-mgr/auto-assign-action@main
|
- uses: rancher/gh-issue-mgr/auto-assign-action@main
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
name: "[PR Management] Add Labels"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'release-harvester-v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add-require-backport-label:
|
||||||
|
if: github.event.pull_request.draft == false &&
|
||||||
|
github.event.pull_request.base.ref == 'main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.base_ref }}
|
||||||
|
|
||||||
|
- name: Fetch release branches and PR labels
|
||||||
|
id: fetch_info
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="${{ github.repository }}"
|
||||||
|
pr_number=${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
|
release_branches=$(gh api "repos/${repo}/branches" --paginate --jq '.[].name' | grep -E '^release-harvester-v[0-9]+\.[0-9]+$' || true)
|
||||||
|
|
||||||
|
if [[ -z "$release_branches" ]]; then
|
||||||
|
echo "should_label=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
latest_branch=$(echo "$release_branches" | sort -Vr | head -n1)
|
||||||
|
version="${latest_branch#release-harvester-v}"
|
||||||
|
release_tag="v${version}.0"
|
||||||
|
|
||||||
|
tags=$(gh api "repos/${repo}/releases" --paginate --jq '.[].tag_name')
|
||||||
|
if echo "$tags" | grep -Fxq "$release_tag"; then
|
||||||
|
echo "should_label=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
label="require backport/v${version}"
|
||||||
|
echo "should_label=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "backport_label=$label" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
pr_labels=$(gh pr view "$pr_number" --repo "$repo" --json labels --jq '.labels[].name' || echo "")
|
||||||
|
pr_labels_csv=$(echo "$pr_labels" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
echo "pr_labels=$pr_labels_csv" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Add label if needed
|
||||||
|
if: steps.fetch_info.outputs.should_label == 'true' && !contains(steps.fetch_info.outputs.pr_labels, steps.fetch_info.outputs.backport_label)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "Adding label: ${{ steps.fetch_info.outputs.backport_label }}"
|
||||||
|
gh pr edit ${{ github.event.pull_request.number }} \
|
||||||
|
--repo ${{ github.repository }} \
|
||||||
|
--add-label "${{ steps.fetch_info.outputs.backport_label }}"
|
||||||
|
|
||||||
|
add-backport-label:
|
||||||
|
if: github.event.pull_request.draft == false &&
|
||||||
|
startsWith(github.event.pull_request.base.ref, 'release-harvester-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check conditions for backport label
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
IS_MERGIFY=$(echo '${{ github.event.pull_request.user.login }}' | grep -iq 'mergify' && echo true || echo false)
|
||||||
|
TARGET_BRANCH=${{ github.event.pull_request.base.ref }}
|
||||||
|
|
||||||
|
echo "IS_MERGIFY=$IS_MERGIFY" >> $GITHUB_OUTPUT
|
||||||
|
echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Add label if needed
|
||||||
|
if: steps.check.outputs.IS_MERGIFY == 'true' && startsWith(steps.check.outputs.TARGET_BRANCH, 'release-harvester-v')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TARGET_BRANCH="${{ steps.check.outputs.TARGET_BRANCH }}"
|
||||||
|
version="${TARGET_BRANCH#release-harvester-v}"
|
||||||
|
label="backport/v${version}"
|
||||||
|
echo "Adding label $label"
|
||||||
|
gh pr edit ${{ github.event.pull_request.number }} \
|
||||||
|
--repo ${{ github.repository }} \
|
||||||
|
--add-label "$label"
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Determine release tag
|
- name: Determine release tag
|
||||||
id: determine_tag
|
id: determine_tag
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.ref_name }}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
if [[ "${{ github.event.release.tag_name }}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
RELEASE_TAG="harvester-${BASH_REMATCH[1]}"
|
RELEASE_TAG="harvester-${BASH_REMATCH[1]}"
|
||||||
echo "${RELEASE_TAG}"
|
echo "${RELEASE_TAG}"
|
||||||
echo "release_tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT
|
echo "release_tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ on:
|
|||||||
- v[1-9].*
|
- v[1-9].*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-validation:
|
|
||||||
name: Build Test
|
|
||||||
uses: ./.github/workflows/run-lint.yaml
|
|
||||||
build:
|
build:
|
||||||
name: Build and Upload Package
|
name: Build and Upload Package
|
||||||
uses: ./.github/workflows/build-and-publish-standalone.yaml
|
uses: ./.github/workflows/build-and-publish-standalone.yaml
|
||||||
needs:
|
|
||||||
- build-validation
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ To validate changes in a release series, switch to the appropriate `vX.Y-head` b
|
|||||||
- Examples:
|
- Examples:
|
||||||
- Test `1.0.x` series → `v1.0-head`
|
- Test `1.0.x` series → `v1.0-head`
|
||||||
- Test `1.5.x` series → `v1.5-head`
|
- Test `1.5.x` series → `v1.5-head`
|
||||||
|
- Test `1.6.x` series → `v1.6-head`
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. Navigate to **Rancher UI** → **Local** → **App** → **Repositories**
|
1. Navigate to **Rancher UI** → **Local** → **App** → **Repositories**
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
'type-case': [2, 'always', 'lower-case'],
|
'type-case': [2, 'always', 'lower-case'],
|
||||||
'type-empty': [2, 'never'],
|
'type-empty': [2, 'never'],
|
||||||
'subject-case': [2, 'always', 'lower-case'],
|
'subject-case': [0, 'never'],
|
||||||
'subject-empty': [2, 'never'],
|
'subject-empty': [2, 'never'],
|
||||||
'subject-full-stop': [2, 'never', '.'],
|
'subject-full-stop': [2, 'never', '.'],
|
||||||
'subject-max-length': [2, 'always', 72],
|
'subject-max-length': [2, 'always', 72],
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester-ui-extension",
|
"name": "harvester-ui-extension",
|
||||||
"version": "1.6.0-dev",
|
"version": "1.6.0-rc1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { mapGetters } from 'vuex';
|
|||||||
import ActionMenu from '@shell/components/ActionMenuShell';
|
import ActionMenu from '@shell/components/ActionMenuShell';
|
||||||
import { Banner } from '@components/Banner';
|
import { Banner } from '@components/Banner';
|
||||||
import AsyncButton from '@shell/components/AsyncButton';
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
import { HCI_ALLOWED_SETTINGS, HCI_SETTING } from '../config/settings';
|
import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings';
|
||||||
|
import { DOC } from '../config/doc-links';
|
||||||
|
import { docLink } from '../utils/feature-flags';
|
||||||
|
|
||||||
const CATEGORY = {
|
const CATEGORY = {
|
||||||
ui: [
|
ui: [
|
||||||
@@ -96,7 +98,7 @@ export default {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = this.t(setting.description, {}, true)?.toLowerCase() || '';
|
const description = this.t(setting.description, this.getDocLinkParams(setting) || {}, true)?.toLowerCase() || '';
|
||||||
|
|
||||||
// filter by description
|
// filter by description
|
||||||
if (description.includes(searchQuery)) {
|
if (description.includes(searchQuery)) {
|
||||||
@@ -193,6 +195,19 @@ export default {
|
|||||||
}
|
}
|
||||||
buttonDone(false);
|
buttonDone(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getDocLinkParams(setting) {
|
||||||
|
const settingConfig = HCI_ALLOWED_SETTINGS[setting.id] || HCI_SINGLE_CLUSTER_ALLOWED_SETTING[setting.id];
|
||||||
|
|
||||||
|
if (settingConfig?.docPath) {
|
||||||
|
const version = this.$store.getters['harvester-common/getServerVersion']();
|
||||||
|
const url = docLink(DOC[settingConfig.docPath], version);
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -223,7 +238,7 @@ export default {
|
|||||||
Experimental
|
Experimental
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<h2 v-clean-html="t(setting.description, {}, true)">
|
<h2 v-clean-html="t(setting.description, getDocLinkParams(setting) || {}, true)">
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { NAMESPACE } from '@shell/config/types';
|
|||||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
|
||||||
|
const SELECT_ALL = 'select_all';
|
||||||
|
const UNSELECT_ALL = 'unselect_all';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HarvesterBundleNamespaces',
|
name: 'HarvesterBundleNamespaces',
|
||||||
|
|
||||||
@@ -11,47 +14,76 @@ export default {
|
|||||||
mixins: [CreateEditView],
|
mixins: [CreateEditView],
|
||||||
|
|
||||||
async fetch() {
|
async fetch() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
|
await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle');
|
||||||
|
const response = await this.$store.dispatch('harvester/request', { url });
|
||||||
|
|
||||||
|
this.defaultNamespaces = response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
this.defaultNamespaces = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
let namespaces = [];
|
|
||||||
const namespacesStr = this.value?.value || this.value?.default || '';
|
const namespacesStr = this.value?.value || this.value?.default || '';
|
||||||
|
const namespaces = namespacesStr ? namespacesStr.split(',') : [];
|
||||||
|
|
||||||
if (namespacesStr) {
|
return {
|
||||||
namespaces = namespacesStr.split(',');
|
namespaces,
|
||||||
}
|
defaultNamespaces: [],
|
||||||
|
loading: true
|
||||||
return { namespaces };
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
allNamespaces() {
|
||||||
|
return this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredNamespaces() {
|
||||||
|
const defaultIds = this.defaultNamespaces.map((ns) => ns.id);
|
||||||
|
|
||||||
|
return this.allNamespaces.filter((ns) => !defaultIds.includes(ns));
|
||||||
|
},
|
||||||
|
|
||||||
namespaceOptions() {
|
namespaceOptions() {
|
||||||
return this.$store.getters['harvester/all'](NAMESPACE).map((N) => {
|
if (this.availableNamespaces.length === 0) return [];
|
||||||
return {
|
|
||||||
label: N.id,
|
const allSelected =
|
||||||
value: N.id
|
this.namespaces.length === this.filteredNamespaces.length &&
|
||||||
};
|
this.filteredNamespaces.every((ns) => this.namespaces.includes(ns));
|
||||||
});
|
|
||||||
|
const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL };
|
||||||
|
|
||||||
|
return [controlOption, ...this.filteredNamespaces];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
update() {
|
update(selected) {
|
||||||
const namespaceStr = this.namespaces.join(',');
|
if (selected.includes(SELECT_ALL)) {
|
||||||
|
this.namespaces = [...this.filteredNamespaces];
|
||||||
|
} else if (selected.includes(UNSELECT_ALL)) {
|
||||||
|
this.namespaces = [];
|
||||||
|
} else {
|
||||||
|
this.namespaces = selected.filter((val) => val !== SELECT_ALL && val !== UNSELECT_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
this.value['value'] = namespaceStr;
|
this.value.value = this.namespaces.join(',');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
'value.value': {
|
'value.value'(newVal) {
|
||||||
handler(neu) {
|
const raw = newVal || this.value.default || '';
|
||||||
if (neu === this.value.default || !neu) {
|
|
||||||
this.namespaces = [];
|
this.namespaces = raw ? raw.split(',') : [];
|
||||||
}
|
|
||||||
},
|
|
||||||
deep: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -62,6 +94,7 @@ export default {
|
|||||||
<div class="col span-12">
|
<div class="col span-12">
|
||||||
<LabeledSelect
|
<LabeledSelect
|
||||||
v-model:value="namespaces"
|
v-model:value="namespaces"
|
||||||
|
:loading="loading"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
label-key="nameNsDescription.namespace.label"
|
label-key="nameNsDescription.namespace.label"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script>
|
||||||
|
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { RadioGroup } from '@components/Form/Radio';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HarvesterUpgradeConfig',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
|
RadioGroup
|
||||||
|
},
|
||||||
|
mixins: [CreateEditView],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
let parseDefaultValue = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseDefaultValue = this.value.value ? JSON.parse(this.value.value) : JSON.parse(this.value.default);
|
||||||
|
} catch (error) {
|
||||||
|
parseDefaultValue = JSON.parse(this.value.default);
|
||||||
|
}
|
||||||
|
parseDefaultValue = this.normalizeValue(parseDefaultValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parseDefaultValue,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapGetters({ t: 'i18n/t' }),
|
||||||
|
strategyOptions() {
|
||||||
|
return [
|
||||||
|
{ value: 'sequential', label: 'sequential' },
|
||||||
|
{ value: 'skip', label: 'skip' },
|
||||||
|
{ value: 'parallel', label: 'parallel' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
normalizeValue(obj) {
|
||||||
|
if (!obj.imagePreloadOption) {
|
||||||
|
obj.imagePreloadOption = { strategy: { type: 'sequential' } };
|
||||||
|
}
|
||||||
|
if (!obj.imagePreloadOption.strategy) {
|
||||||
|
obj.imagePreloadOption.strategy = { type: 'sequential' };
|
||||||
|
}
|
||||||
|
if (!obj.imagePreloadOption.strategy.type) {
|
||||||
|
obj.imagePreloadOption.strategy.type = 'sequential';
|
||||||
|
}
|
||||||
|
// Only set concurrency if type is 'parallel'
|
||||||
|
if (obj.imagePreloadOption.strategy.type === 'parallel') {
|
||||||
|
if (typeof obj.imagePreloadOption.strategy.concurrency !== 'number') {
|
||||||
|
obj.imagePreloadOption.strategy.concurrency = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete obj.imagePreloadOption.strategy.concurrency;
|
||||||
|
}
|
||||||
|
if (typeof obj.restoreVM !== 'boolean') {
|
||||||
|
obj.restoreVM = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
update() {
|
||||||
|
try {
|
||||||
|
// Clone to avoid mutating the form state
|
||||||
|
const valueToSave = JSON.parse(JSON.stringify(this.parseDefaultValue));
|
||||||
|
|
||||||
|
if (valueToSave.imagePreloadOption && valueToSave.imagePreloadOption.strategy) {
|
||||||
|
if (valueToSave.imagePreloadOption.strategy.type !== 'parallel') {
|
||||||
|
delete valueToSave.imagePreloadOption.strategy.concurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value['value'] = JSON.stringify(valueToSave, null, 2);
|
||||||
|
this.errors = [];
|
||||||
|
} catch (e) {
|
||||||
|
this.errors = ['Invalid JSON'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(neu) {
|
||||||
|
let parseDefaultValue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseDefaultValue = JSON.parse(neu.value);
|
||||||
|
} catch (err) {
|
||||||
|
parseDefaultValue = JSON.parse(this.value.default);
|
||||||
|
}
|
||||||
|
parseDefaultValue = this.normalizeValue(parseDefaultValue);
|
||||||
|
this['parseDefaultValue'] = parseDefaultValue;
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
@input="update"
|
||||||
|
>
|
||||||
|
<div class="col span-12">
|
||||||
|
<label class="mb-5"><b>{{ t('harvester.setting.upgrade.imagePreloadStrategy') }}</b></label>
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="parseDefaultValue.imagePreloadOption.strategy.type"
|
||||||
|
class="mb-20"
|
||||||
|
:mode="mode"
|
||||||
|
:label="t('harvester.setting.upgrade.strategyType')"
|
||||||
|
:options="strategyOptions"
|
||||||
|
@update:value="update"
|
||||||
|
/>
|
||||||
|
<LabeledInput
|
||||||
|
v-if="parseDefaultValue.imagePreloadOption.strategy.type === 'parallel'"
|
||||||
|
v-model:value.number="parseDefaultValue.imagePreloadOption.strategy.concurrency"
|
||||||
|
class="mb-20"
|
||||||
|
:mode="mode"
|
||||||
|
:label="t('harvester.setting.upgrade.concurrency')"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<label class="mb-5"><b>{{ t('harvester.setting.upgrade.restoreVM') }}</b></label>
|
||||||
|
<RadioGroup
|
||||||
|
v-model:value="parseDefaultValue.restoreVM"
|
||||||
|
class="mb-20"
|
||||||
|
name="restoreVM"
|
||||||
|
:options="[true, false]"
|
||||||
|
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||||
|
@update:value="update"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="errors.length"
|
||||||
|
class="error"
|
||||||
|
>
|
||||||
|
{{ errors[0] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error {
|
||||||
|
color: #d9534f;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
export const DOC = {
|
export const DOC = {
|
||||||
CONSOLE_URL: `/host/#remote-console`,
|
CONSOLE_URL: `/host/#remote-console`,
|
||||||
RANCHER_INTEGRATION_URL: `/rancher/rancher-integration`,
|
RANCHER_INTEGRATION_URL: `/rancher/rancher-integration`,
|
||||||
STORAGE_NETWORK_EXAMPLE: `/advanced/storagenetwork#configuration-example`,
|
|
||||||
KSMTUNED_MODE: `/host/#ksmtuned-mode`,
|
KSMTUNED_MODE: `/host/#ksmtuned-mode`,
|
||||||
UPGRADE_URL: `/upgrade/index`
|
UPGRADE_URL: `/upgrade/index`,
|
||||||
|
UPGRADE_CONFIG_URL: `/advanced/index#upgrade-config`,
|
||||||
|
STORAGE_NETWORK_EXAMPLE: `/advanced/storagenetwork#configuration-example`,
|
||||||
|
SUPPORT_BUNDLE_NAMESPACES: `/advanced/index/#support-bundle-namespaces`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const FEATURE_FLAGS = {
|
|||||||
'vmSnapshotQuota',
|
'vmSnapshotQuota',
|
||||||
'longhornV2LVMSupport',
|
'longhornV2LVMSupport',
|
||||||
'improveMaintenanceMode',
|
'improveMaintenanceMode',
|
||||||
|
'upgradeConfigSetting'
|
||||||
],
|
],
|
||||||
'v1.4.1': [],
|
'v1.4.1': [],
|
||||||
'v1.4.2': [
|
'v1.4.2': [
|
||||||
@@ -38,7 +39,8 @@ const FEATURE_FLAGS = {
|
|||||||
],
|
],
|
||||||
'v1.5.1': [],
|
'v1.5.1': [],
|
||||||
'v1.6.0': [
|
'v1.6.0': [
|
||||||
'vmMachineTypes'
|
'vmMachineTypes',
|
||||||
|
'customSupportBundle'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,5 +69,6 @@ export const HCI = {
|
|||||||
DISABLE_LONGHORN_V2_ENGINE: 'node.longhorn.io/disable-v2-data-engine',
|
DISABLE_LONGHORN_V2_ENGINE: 'node.longhorn.io/disable-v2-data-engine',
|
||||||
K8S_ARCH: 'kubernetes.io/arch',
|
K8S_ARCH: 'kubernetes.io/arch',
|
||||||
IMAGE_DISPLAY_NAME: 'harvesterhci.io/imageDisplayName',
|
IMAGE_DISPLAY_NAME: 'harvesterhci.io/imageDisplayName',
|
||||||
CUSTOM_IP: 'harvesterhci.io/custom-ip'
|
CUSTOM_IP: 'harvesterhci.io/custom-ip',
|
||||||
|
IMPORTED_IMAGE: 'migration.harvesterhci.io/imported'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const HCI_SETTING = {
|
|||||||
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
|
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
|
||||||
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
|
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
|
||||||
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
|
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
|
||||||
|
UPGRADE_CONFIG: 'upgrade-config',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HCI_ALLOWED_SETTINGS = {
|
export const HCI_ALLOWED_SETTINGS = {
|
||||||
@@ -96,6 +97,12 @@ export const HCI_ALLOWED_SETTINGS = {
|
|||||||
featureFlag: 'longhornV2LVMSupport'
|
featureFlag: 'longhornV2LVMSupport'
|
||||||
},
|
},
|
||||||
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
|
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
|
||||||
|
[HCI_SETTING.UPGRADE_CONFIG]: {
|
||||||
|
kind: 'json',
|
||||||
|
from: 'import',
|
||||||
|
featureFlag: 'upgradeConfigSetting',
|
||||||
|
docPath: 'UPGRADE_CONFIG_URL'
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = {
|
export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { NAMESPACE } from '@shell/config/types';
|
||||||
import { randomStr } from '@shell/utils/string';
|
import { randomStr } from '@shell/utils/string';
|
||||||
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
||||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
@@ -6,7 +7,15 @@ import AsyncButton from '@shell/components/AsyncButton';
|
|||||||
import GraphCircle from '@shell/components/graph/Circle';
|
import GraphCircle from '@shell/components/graph/Circle';
|
||||||
import { Banner } from '@components/Banner';
|
import { Banner } from '@components/Banner';
|
||||||
import AppModal from '@shell/components/AppModal';
|
import AppModal from '@shell/components/AppModal';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import UnitInput from '@shell/components/form/UnitInput';
|
||||||
import { HCI } from '../types';
|
import { HCI } from '../types';
|
||||||
|
import { HCI_SETTING } from '../config/settings';
|
||||||
|
import { DOC } from '../config/doc-links';
|
||||||
|
import { docLink } from '../utils/feature-flags';
|
||||||
|
|
||||||
|
const SELECT_ALL = 'select_all';
|
||||||
|
const UNSELECT_ALL = 'unselect_all';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SupportBundle',
|
name: 'SupportBundle',
|
||||||
@@ -17,14 +26,36 @@ export default {
|
|||||||
AsyncButton,
|
AsyncButton,
|
||||||
Banner,
|
Banner,
|
||||||
AppModal,
|
AppModal,
|
||||||
|
LabeledSelect,
|
||||||
|
UnitInput
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch() {
|
||||||
|
await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle');
|
||||||
|
const response = await this.$store.dispatch('harvester/request', { url });
|
||||||
|
|
||||||
|
this.defaultNamespaces = response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
this.defaultNamespaces = [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
errors: [],
|
||||||
|
version: '',
|
||||||
|
clusterName: '',
|
||||||
url: '',
|
url: '',
|
||||||
description: '',
|
description: '',
|
||||||
errors: [],
|
namespaces: [],
|
||||||
isOpen: false,
|
defaultNamespaces: [],
|
||||||
|
timeout: '',
|
||||||
|
expiration: '',
|
||||||
|
nodeTimeout: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,23 +70,51 @@ export default {
|
|||||||
|
|
||||||
percentage() {
|
percentage() {
|
||||||
return this.$store.getters['harvester-common/getBundlePercentage'];
|
return this.$store.getters['harvester-common/getBundlePercentage'];
|
||||||
}
|
},
|
||||||
|
|
||||||
|
availableNamespaces() {
|
||||||
|
const allNamespaces = this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id);
|
||||||
|
const defaultNamespacesIds = this.defaultNamespaces.map((ns) => ns.id);
|
||||||
|
|
||||||
|
return allNamespaces.filter((ns) => !defaultNamespacesIds.includes(ns) || this.namespaces.includes(ns));
|
||||||
|
},
|
||||||
|
|
||||||
|
namespaceOptions() {
|
||||||
|
if (this.availableNamespaces.length === 0) return [];
|
||||||
|
|
||||||
|
const allSelected = this.namespaces.length === this.availableNamespaces.length &&
|
||||||
|
this.availableNamespaces.every((ns) => this.namespaces.includes(ns));
|
||||||
|
|
||||||
|
const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL };
|
||||||
|
|
||||||
|
return [controlOption, ...this.availableNamespaces];
|
||||||
|
},
|
||||||
|
|
||||||
|
docLink() {
|
||||||
|
const version = this.$store.getters['harvester-common/getServerVersion']();
|
||||||
|
|
||||||
|
return docLink(DOC.SUPPORT_BUNDLE_NAMESPACES, version);
|
||||||
|
},
|
||||||
|
|
||||||
|
customSupportBundleFeatureEnabled() {
|
||||||
|
return this.$store.getters['harvester-common/getFeatureEnabled']('customSupportBundle');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
isShowBundleModal: {
|
isShowBundleModal: {
|
||||||
|
immediate: true,
|
||||||
handler(show) {
|
handler(show) {
|
||||||
if (show) {
|
this.isOpen = show;
|
||||||
this.$nextTick(() => {
|
|
||||||
this.isOpen = true;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.isOpen = false;
|
|
||||||
this.url = '';
|
|
||||||
this.description = '';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
immediate: true
|
|
||||||
|
isOpen(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.loadDefaultSettings();
|
||||||
|
} else {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -65,33 +124,93 @@ export default {
|
|||||||
close() {
|
close() {
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
this.$store.commit('harvester-common/toggleBundleModal', false);
|
this.$store.commit('harvester-common/toggleBundleModal', false);
|
||||||
this.backUpName = '';
|
},
|
||||||
|
|
||||||
|
loadDefaultSettings() {
|
||||||
|
const cluster = this.$store.getters['currentCluster'];
|
||||||
|
const versionSetting = this.$store.getters['harvester/byId'](HCI.SETTING, HCI_SETTING.SERVER_VERSION);
|
||||||
|
const namespacesSetting = this.$store.getters['harvester/byId'](HCI.SETTING, HCI_SETTING.SUPPORT_BUNDLE_NAMESPACES);
|
||||||
|
const timeoutSetting = this.$store.getters['harvester/byId'](HCI.SETTING, HCI_SETTING.SUPPORT_BUNDLE_TIMEOUT);
|
||||||
|
const expirationSetting = this.$store.getters['harvester/byId'](HCI.SETTING, HCI_SETTING.SUPPORT_BUNDLE_EXPIRATION);
|
||||||
|
const nodeTimeoutSetting = this.$store.getters['harvester/byId'](HCI.SETTING, HCI_SETTING.SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT);
|
||||||
|
|
||||||
|
this.version = versionSetting?.currentVersion || '';
|
||||||
|
this.clusterName = cluster?.id || '';
|
||||||
|
this.namespaces = (namespacesSetting?.value ?? namespacesSetting?.default ?? '').split(',').map((ns) => ns.trim()).filter((ns) => ns);
|
||||||
|
this.timeout = timeoutSetting?.value ?? timeoutSetting?.default ?? '';
|
||||||
|
this.expiration = expirationSetting?.value ?? expirationSetting?.default ?? '';
|
||||||
|
this.nodeTimeout = nodeTimeoutSetting?.value ?? nodeTimeoutSetting?.default ?? '';
|
||||||
|
this.url = '';
|
||||||
|
this.description = '';
|
||||||
|
this.errors = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.url = '';
|
||||||
|
this.description = '';
|
||||||
|
this.namespaces = [];
|
||||||
|
this.timeout = '';
|
||||||
|
this.expiration = '';
|
||||||
|
this.nodeTimeout = '';
|
||||||
|
this.errors = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNamespaces(selected) {
|
||||||
|
if (selected.includes(SELECT_ALL)) {
|
||||||
|
this.namespaces = [...this.availableNamespaces];
|
||||||
|
} else if (selected.includes(UNSELECT_ALL)) {
|
||||||
|
this.namespaces = [];
|
||||||
|
} else {
|
||||||
|
this.namespaces = selected.filter((val) => val !== SELECT_ALL && val !== UNSELECT_ALL);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNumberValue(field, value) {
|
||||||
|
if (value === '' || value === null || isNaN(value)) {
|
||||||
|
this[field] = '';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(value);
|
||||||
|
const isValid = Number.isInteger(num) && num >= 0;
|
||||||
|
|
||||||
|
this[field] = isValid ? String(num) : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(e) {
|
||||||
|
if (['e', 'E', '+', '-', '.'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async save(buttonCb) {
|
async save(buttonCb) {
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
|
|
||||||
const name = `bundle-${ randomStr(5).toLowerCase() }`;
|
const name = `bundle-${ this.clusterName }-${ this.version }-${ randomStr(5).toLowerCase() }`;
|
||||||
const namespace = 'harvester-system';
|
const namespace = 'harvester-system';
|
||||||
|
|
||||||
|
const spec = {
|
||||||
|
description: this.description.trim(),
|
||||||
|
...(this.url.trim() && { issueURL: this.url.trim() }),
|
||||||
|
...(this.namespaces.length > 0 && { extraCollectionNamespaces: this.namespaces }),
|
||||||
|
...(this.timeout !== '' && { timeout: Number(this.timeout) }),
|
||||||
|
...(this.expiration !== '' && { expiration: Number(this.expiration) }),
|
||||||
|
...(this.nodeTimeout !== '' && { nodeTimeout: Number(this.nodeTimeout) }),
|
||||||
|
};
|
||||||
|
|
||||||
const bundleCrd = {
|
const bundleCrd = {
|
||||||
apiVersion: 'harvesterhci.io/v1beta1',
|
apiVersion: 'harvesterhci.io/v1beta1',
|
||||||
type: HCI.SUPPORT_BUNDLE,
|
type: HCI.SUPPORT_BUNDLE,
|
||||||
kind: 'SupportBundle',
|
kind: 'SupportBundle',
|
||||||
metadata: {
|
metadata: { name, namespace },
|
||||||
name,
|
spec,
|
||||||
namespace
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
issueURL: this.url,
|
|
||||||
description: this.description
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
const bundleValue = await this.$store.dispatch(`${ inStore }/create`, bundleCrd);
|
const bundleValue = await this.$store.dispatch(`${ inStore }/create`, bundleCrd);
|
||||||
|
|
||||||
try {
|
|
||||||
await bundleValue.save();
|
await bundleValue.save();
|
||||||
|
|
||||||
this.$store.commit('harvester-common/setLatestBundleId', `${ namespace }/${ name }`, { root: true });
|
this.$store.commit('harvester-common/setLatestBundleId', `${ namespace }/${ name }`, { root: true });
|
||||||
@@ -118,44 +237,86 @@ export default {
|
|||||||
@close="close"
|
@close="close"
|
||||||
>
|
>
|
||||||
<div class="p-20">
|
<div class="p-20">
|
||||||
<h2>
|
<h2>{{ t('harvester.modal.bundle.title') }}</h2>
|
||||||
{{ t('harvester.modal.bundle.title') }}
|
<div class="content">
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!bundlePending"
|
v-if="bundlePending"
|
||||||
class="content"
|
class="circle mb-20"
|
||||||
>
|
>
|
||||||
<LabeledInput
|
|
||||||
v-model:value="url"
|
|
||||||
:label="t('harvester.modal.bundle.url')"
|
|
||||||
class="mb-20"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LabeledInput
|
|
||||||
v-model:value="description"
|
|
||||||
:label="t('harvester.modal.bundle.description')"
|
|
||||||
type="multiline"
|
|
||||||
:min-height="120"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="content"
|
|
||||||
>
|
|
||||||
<div class="circle">
|
|
||||||
<GraphCircle
|
<GraphCircle
|
||||||
primary-stroke-color="green"
|
primary-stroke-color="green"
|
||||||
secondary-stroke-color="white"
|
secondary-stroke-color="lightgrey"
|
||||||
:stroke-width="6"
|
:stroke-width="6"
|
||||||
:percentage="percentage"
|
:percentage="percentage"
|
||||||
:show-text="true"
|
:show-text="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<template v-else>
|
||||||
|
<p
|
||||||
|
v-if="customSupportBundleFeatureEnabled"
|
||||||
|
v-clean-html="t('harvester.modal.bundle.tip', { doc: docLink }, true)"
|
||||||
|
class="mb-20"
|
||||||
|
/>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="url"
|
||||||
|
:label="t('harvester.modal.bundle.url')"
|
||||||
|
class="mb-10"
|
||||||
|
/>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="description"
|
||||||
|
required
|
||||||
|
:label="t('harvester.modal.bundle.description')"
|
||||||
|
type="multiline"
|
||||||
|
:min-height="80"
|
||||||
|
class="mb-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="customSupportBundleFeatureEnabled">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="namespaces"
|
||||||
|
:label="t('harvester.modal.bundle.namespaces.label')"
|
||||||
|
:clearable="true"
|
||||||
|
:multiple="true"
|
||||||
|
:options="namespaceOptions"
|
||||||
|
class="mb-10 label-select"
|
||||||
|
:tooltip="t('harvester.modal.bundle.namespaces.tooltip', _, true)"
|
||||||
|
@update:value="updateNamespaces"
|
||||||
|
/>
|
||||||
|
<UnitInput
|
||||||
|
v-model:value="timeout"
|
||||||
|
:label="t('harvester.modal.bundle.timeout.label')"
|
||||||
|
class="mb-10"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
:tooltip="t('harvester.modal.bundle.timeout.tooltip', _, true)"
|
||||||
|
:suffix="timeout > 1 ? 'Minutes' : 'Minute'"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
@update:value="val => updateNumberValue('timeout', val)"
|
||||||
|
/>
|
||||||
|
<UnitInput
|
||||||
|
v-model:value="expiration"
|
||||||
|
:label="t('harvester.modal.bundle.expiration.label')"
|
||||||
|
class="mb-10"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
:tooltip="t('harvester.modal.bundle.expiration.tooltip', _, true)"
|
||||||
|
:suffix="expiration > 1 ? 'Minutes' : 'Minute'"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
@update:value="val => updateNumberValue('expiration', val)"
|
||||||
|
/>
|
||||||
|
<UnitInput
|
||||||
|
v-model:value="nodeTimeout"
|
||||||
|
:label="t('harvester.modal.bundle.nodeTimeout.label')"
|
||||||
|
class="mb-10"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
:tooltip="t('harvester.modal.bundle.nodeTimeout.tooltip', _, true)"
|
||||||
|
:suffix="nodeTimeout > 1 ? 'Minutes' : 'Minute'"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
@update:value="val => updateNumberValue('nodeTimeout', val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-for="(err, idx) in errors"
|
v-for="(err, idx) in errors"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@@ -165,7 +326,6 @@ export default {
|
|||||||
:label="stringify(err)"
|
:label="stringify(err)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer mt-20">
|
<div class="footer mt-20">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm role-secondary mr-10"
|
class="btn btn-sm role-secondary mr-10"
|
||||||
@@ -173,7 +333,6 @@ export default {
|
|||||||
>
|
>
|
||||||
{{ t('generic.close') }}
|
{{ t('generic.close') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
type="submit"
|
type="submit"
|
||||||
mode="generate"
|
mode="generate"
|
||||||
@@ -183,6 +342,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</app-modal>
|
</app-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -194,6 +354,10 @@ export default {
|
|||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.labeled-select.taggable ::v-deep(.vs__selected-options .vs__selected.vs__selected > button) {
|
||||||
|
margin: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.bundle {
|
.bundle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
@@ -204,10 +368,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
height: 218px;
|
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
padding-top: 20px;
|
padding: 10px 0;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ export default {
|
|||||||
|
|
||||||
data.spec.clusterNetwork = this.clusterNetwork;
|
data.spec.clusterNetwork = this.clusterNetwork;
|
||||||
|
|
||||||
|
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl'](`v1/harvester/${ HCI.VLAN_CONFIG }s/${ data.id }`);
|
||||||
|
|
||||||
await this.$store.dispatch('harvester/request', {
|
await this.$store.dispatch('harvester/request', {
|
||||||
url: `/v1/harvester/${ HCI.VLAN_CONFIG }s/${ data.id }`,
|
url,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export default {
|
|||||||
this.value['_type'] = TYPES.OPAQUE;
|
this.value['_type'] = TYPES.OPAQUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( this.realMode === _CLONE) {
|
||||||
|
this.value['_type'] = this.liveValue._type;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isCloud,
|
isCloud,
|
||||||
nodeDrivers: null,
|
nodeDrivers: null,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
|
|||||||
import { TextAreaAutoGrow } from '@components/Form/TextArea';
|
import { TextAreaAutoGrow } from '@components/Form/TextArea';
|
||||||
import UnitInput from '@shell/components/form/UnitInput';
|
import UnitInput from '@shell/components/form/UnitInput';
|
||||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||||
|
import { DOC } from '../config/doc-links';
|
||||||
|
import { docLink } from '../utils/feature-flags';
|
||||||
|
|
||||||
import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings';
|
import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings';
|
||||||
|
|
||||||
@@ -58,7 +60,13 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setting,
|
setting,
|
||||||
description: isHarvester ? t(`advancedSettings.descriptions.harv-${ this.value.id }`) : t(`advancedSettings.descriptions.${ this.value.id }`),
|
description: isHarvester ? t(
|
||||||
|
`advancedSettings.descriptions.harv-${ this.value.id }`,
|
||||||
|
this.getDocLinkParams()
|
||||||
|
) : t(
|
||||||
|
`advancedSettings.descriptions.${ this.value.id }`,
|
||||||
|
this.getDocLinkParams()
|
||||||
|
),
|
||||||
editHelp: t(`advancedSettings.editHelp.${ this.value.id }`),
|
editHelp: t(`advancedSettings.editHelp.${ this.value.id }`),
|
||||||
enumOptions,
|
enumOptions,
|
||||||
canReset,
|
canReset,
|
||||||
@@ -171,6 +179,18 @@ export default {
|
|||||||
if (typeof this.$refs.settingComp?.useDefault === 'function') {
|
if (typeof this.$refs.settingComp?.useDefault === 'function') {
|
||||||
this.$refs.settingComp.useDefault();
|
this.$refs.settingComp.useDefault();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
getDocLinkParams() {
|
||||||
|
const setting = HCI_ALLOWED_SETTINGS[this.value.id] || HCI_SINGLE_CLUSTER_ALLOWED_SETTING[this.value.id];
|
||||||
|
|
||||||
|
if (setting?.docPath) {
|
||||||
|
const version = this.$store.getters['harvester-common/getServerVersion']();
|
||||||
|
const url = docLink(DOC[setting.docPath], version);
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -304,6 +304,10 @@ export default {
|
|||||||
return ['ReadWriteMany'];
|
return ['ReadWriteMany'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
|
||||||
|
return this.value.spec.accessModes;
|
||||||
|
}
|
||||||
|
|
||||||
const storageClassName = this.value.spec.storageClassName;
|
const storageClassName = this.value.spec.storageClassName;
|
||||||
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
|
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export default {
|
|||||||
.map((image) => ({
|
.map((image) => ({
|
||||||
label: this.imageOptionLabel(image),
|
label: this.imageOptionLabel(image),
|
||||||
value: image.id,
|
value: image.id,
|
||||||
|
disabled: image.isImportedImage
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,24 @@ harvester:
|
|||||||
delete: Delete
|
delete: Delete
|
||||||
bundle:
|
bundle:
|
||||||
title: Generate a Support Bundle
|
title: Generate a Support Bundle
|
||||||
|
titleDescription: Collect system-related logs in Harvester to assist with troubleshooting and support.
|
||||||
|
tip: Each field below specifies an aspect of the support bundle. For detailed explanations, please refer to the <a href="{doc}" target="_blank">documentation</a>.
|
||||||
url: Issue URL
|
url: Issue URL
|
||||||
description: Description
|
description: Description
|
||||||
requiredDesc: Description is required!
|
namespaces:
|
||||||
titleDescription: Collect system-related logs in Harvester to help with troubleshooting and support.
|
label: Namespaces
|
||||||
|
tooltip: Select additional namespaces to include in the support bundle.<br/>See docs support-bundle-namespaces for detail.
|
||||||
|
selectAll: 'Select All'
|
||||||
|
unselectAll: 'Unselect All'
|
||||||
|
timeout:
|
||||||
|
label: Timeout
|
||||||
|
tooltip: Minutes allows for completion of the support bundle generation process.<br/>See docs support-bundle-timeout for detail.
|
||||||
|
expiration:
|
||||||
|
label: Expiration
|
||||||
|
tooltip: Minutes before deleting packaged but not downloaded support bundle.<br/>See docs support-bundle-expiration for detail.
|
||||||
|
nodeTimeout:
|
||||||
|
label: Node Collection Timeout
|
||||||
|
tooltip: Minutes allowed for collecting logs/configurations on nodes.<br/>See docs support-bundle-node-collection-timeout for detail.
|
||||||
hotplug:
|
hotplug:
|
||||||
success: 'Volume { diskName } is mounted to the virtual machine { vm }.'
|
success: 'Volume { diskName } is mounted to the virtual machine { vm }.'
|
||||||
title: Add Volume
|
title: Add Volume
|
||||||
@@ -680,7 +694,7 @@ harvester:
|
|||||||
network:
|
network:
|
||||||
label: Network Data Template
|
label: Network Data Template
|
||||||
title: "Network Data:"
|
title: "Network Data:"
|
||||||
tip: "The network-data configuration allows you to customize the instance’s networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
||||||
scheduling:
|
scheduling:
|
||||||
affinity:
|
affinity:
|
||||||
anyNode: 'Run virtual machine on any available node'
|
anyNode: 'Run virtual machine on any available node'
|
||||||
@@ -864,6 +878,8 @@ harvester:
|
|||||||
uploading: "{name} is uploading, please do not refresh or close the page."
|
uploading: "{name} is uploading, please do not refresh or close the page."
|
||||||
checksum: Checksum
|
checksum: Checksum
|
||||||
checksumTip: Validate the image using the SHA512 checksum, if specified.
|
checksumTip: Validate the image using the SHA512 checksum, if specified.
|
||||||
|
tooltip:
|
||||||
|
imported: Created automatically by the vm-import-controller
|
||||||
|
|
||||||
vmTemplate:
|
vmTemplate:
|
||||||
label: Templates
|
label: Templates
|
||||||
@@ -1114,6 +1130,10 @@ harvester:
|
|||||||
uploadSuccess: "{name} uploaded successfully. Press Upgrade button to start the cluster upgrade process."
|
uploadSuccess: "{name} uploaded successfully. Press Upgrade button to start the cluster upgrade process."
|
||||||
deleteImage: Please select an image to delete.
|
deleteImage: Please select an image to delete.
|
||||||
deleteSuccess: "{name} deleted successfully."
|
deleteSuccess: "{name} deleted successfully."
|
||||||
|
imagePreloadStrategy: Image Preload Strategy
|
||||||
|
restoreVM: Restore VM
|
||||||
|
strategyType: Strategy Type
|
||||||
|
concurrency: Concurrency
|
||||||
harvesterMonitoring:
|
harvesterMonitoring:
|
||||||
label: Harvester Monitoring
|
label: Harvester Monitoring
|
||||||
section:
|
section:
|
||||||
@@ -1597,7 +1617,7 @@ advancedSettings:
|
|||||||
'harv-vm-force-reset-policy': Configuration for the force-reset action when a virtual machine is stuck on a node that is down.
|
'harv-vm-force-reset-policy': Configuration for the force-reset action when a virtual machine is stuck on a node that is down.
|
||||||
'harv-ssl-parameters': Custom SSL Parameters for TLS validation.
|
'harv-ssl-parameters': Custom SSL Parameters for TLS validation.
|
||||||
'harv-storage-network': 'Longhorn storage-network setting.'
|
'harv-storage-network': 'Longhorn storage-network setting.'
|
||||||
'harv-support-bundle-namespaces': Specify resources in other namespaces to be collected by the support package.
|
'harv-support-bundle-namespaces': Select additional namespaces to include in the support bundle.
|
||||||
'harv-auto-disk-provision-paths': Specify the disks(using glob pattern) that Harvester will automatically add as virtual machine storage.
|
'harv-auto-disk-provision-paths': Specify the disks(using glob pattern) that Harvester will automatically add as virtual machine storage.
|
||||||
'harv-support-bundle-image': Support bundle image configuration. Find different versions in <a href="https://hub.docker.com/r/rancher/support-bundle-kit/tags" target="_blank">rancher/support-bundle-kit</a>.
|
'harv-support-bundle-image': Support bundle image configuration. Find different versions in <a href="https://hub.docker.com/r/rancher/support-bundle-kit/tags" target="_blank">rancher/support-bundle-kit</a>.
|
||||||
'harv-release-download-url': This setting allows you to configure the <code>upgrade release download</code> URL address. Harvester will get the ISO URL and checksum value from the (<code>$URL</code>/<code>$VERSION</code>/version.yaml) file hosted by the configured URL.
|
'harv-release-download-url': This setting allows you to configure the <code>upgrade release download</code> URL address. Harvester will get the ISO URL and checksum value from the (<code>$URL</code>/<code>$VERSION</code>/version.yaml) file hosted by the configured URL.
|
||||||
@@ -1607,6 +1627,7 @@ advancedSettings:
|
|||||||
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
|
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
|
||||||
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
|
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
|
||||||
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
|
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
|
||||||
|
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
|
||||||
|
|
||||||
typeLabel:
|
typeLabel:
|
||||||
kubevirt.io.virtualmachine: |-
|
kubevirt.io.virtualmachine: |-
|
||||||
|
|||||||
@@ -116,7 +116,15 @@ export default {
|
|||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
description: isHarvester ? `advancedSettings.descriptions.harv-${ s.id }` : `advancedSettings.descriptions.${ s.id }`,
|
description: isHarvester ? `advancedSettings.descriptions.harv-${ s.id }` : `advancedSettings.descriptions.${ s.id }`,
|
||||||
customized: (!s.readOnly && s.data.value && s.data.value !== s.data.default) || s.data.hasCustomized
|
customized: (!s.readOnly && s.data.value && (
|
||||||
|
s.kind === 'json' ? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(s.data.value)) !== JSON.stringify(JSON.parse(s.data.default));
|
||||||
|
} catch {
|
||||||
|
return s.data.value !== s.data.default;
|
||||||
|
}
|
||||||
|
})() : s.data.value !== s.data.default
|
||||||
|
)) || s.data.hasCustomized
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export default {
|
|||||||
v-if="row.isEncrypted"
|
v-if="row.isEncrypted"
|
||||||
class="icon icon-lock"
|
class="icon icon-lock"
|
||||||
/>
|
/>
|
||||||
|
<i
|
||||||
|
v-if="row.isImportedImage"
|
||||||
|
v-clean-tooltip="t('harvester.image.tooltip.imported')"
|
||||||
|
class="icon icon-info"
|
||||||
|
/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ row.nameDisplay }}
|
{{ row.nameDisplay }}
|
||||||
|
|||||||
@@ -82,15 +82,17 @@ export default class HciVmImage extends HarvesterResource {
|
|||||||
}
|
}
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
let filteredOut;
|
// imported image only allow download, download yaml and delete actions
|
||||||
|
if (this.isImportedImage) {
|
||||||
|
const custom = customActions.find((a) => a.action === 'imageDownload');
|
||||||
|
const filtered = out.filter(({ action }) => ['download', 'promptRemove'].includes(action));
|
||||||
|
|
||||||
if (customActions.length > 0) {
|
return custom ? [custom, { divider: true }, ...filtered] : filtered;
|
||||||
filteredOut = out;
|
|
||||||
} else {
|
|
||||||
// if the first item is a divider, remove it from the array
|
|
||||||
filteredOut = out[0]?.divider ? out.slice(1) : out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the first item is a divider, remove it from the array
|
||||||
|
const filteredOut = customActions.length > 0 ? out : (out[0]?.divider ? out.slice(1) : out);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...customActions,
|
...customActions,
|
||||||
...filteredOut
|
...filteredOut
|
||||||
@@ -211,6 +213,10 @@ export default class HciVmImage extends HarvesterResource {
|
|||||||
!!this.spec.securityParameters?.sourceImageNamespace;
|
!!this.spec.securityParameters?.sourceImageNamespace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isImportedImage() {
|
||||||
|
return (this.metadata?.labels?.[HCI_ANNOTATIONS.IMPORTED_IMAGE]) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
get displayNameWithNamespace() {
|
get displayNameWithNamespace() {
|
||||||
return `${ this.metadata.namespace }/${ this.spec.displayName }`;
|
return `${ this.metadata.namespace }/${ this.spec.displayName }`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester",
|
"name": "harvester",
|
||||||
"description": "Rancher UI Extension for Harvester",
|
"description": "Rancher UI Extension for Harvester",
|
||||||
"version": "1.6.0-dev",
|
"version": "1.6.0-rc1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"rancher": {
|
"rancher": {
|
||||||
"annotations": {
|
"annotations": {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script>
|
||||||
|
import { resourceNames } from '@shell/utils/string';
|
||||||
|
import { mapGetters, mapState } from 'vuex';
|
||||||
|
import { BACKUP_TYPE } from '@pkg/harvester/config/types';
|
||||||
|
import { HCI } from '../types';
|
||||||
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PromptRemoveVMBackupDialog',
|
||||||
|
|
||||||
|
emits: ['errors'],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
names: {
|
||||||
|
type: Array,
|
||||||
|
default: () => {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
close: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
doneLocation: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return { errors: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState('action-menu', ['toRemove']),
|
||||||
|
...mapGetters({ t: 'i18n/t' }),
|
||||||
|
resourceType() {
|
||||||
|
const isSnapshot = this.value?.[0]?.spec?.type === BACKUP_TYPE.SNAPSHOT;
|
||||||
|
const count = this.names?.length || 1;
|
||||||
|
|
||||||
|
if (isSnapshot) {
|
||||||
|
return this.t(`typeLabel."${ HCI.VM_SNAPSHOT }"`, { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.t(`typeLabel."${ HCI.BACKUP }"`, { count });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resourceNames,
|
||||||
|
async remove(buttonDone) {
|
||||||
|
try {
|
||||||
|
await Promise.all(this.value.map((resource) => resource.remove()));
|
||||||
|
this.close(buttonDone);
|
||||||
|
} catch (err) {
|
||||||
|
this.errors = exceptionToErrorsArray(err);
|
||||||
|
buttonDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-10">
|
||||||
|
<div class="mb-5">
|
||||||
|
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }} <span
|
||||||
|
v-clean-html="resourceNames(names, null, t)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Banner
|
||||||
|
v-for="(error, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
class=""
|
||||||
|
color="error"
|
||||||
|
:label="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user