- |
+ |
{{ searchQuery ? 'No results found' : 'No data available' }}
@@ -465,6 +590,12 @@ defineExpose({
width: 40px;
padding: 0.75rem 0.5rem;
}
+
+ &--select {
+ width: 40px;
+ text-align: center;
+ padding: 0.75rem 0.5rem;
+ }
}
&__th-content {
@@ -515,6 +646,13 @@ defineExpose({
vertical-align: middle;
}
+ &--select {
+ width: 40px;
+ padding: 0.5rem;
+ text-align: center;
+ vertical-align: middle;
+ }
+
// Badge state styling
:deep(.badge-state) {
display: inline-block;
diff --git a/dashboard/pkg/epinio/l10n/en-us.yaml b/dashboard/pkg/epinio/l10n/en-us.yaml
index 52c9bf3e..370f410e 100644
--- a/dashboard/pkg/epinio/l10n/en-us.yaml
+++ b/dashboard/pkg/epinio/l10n/en-us.yaml
@@ -402,6 +402,21 @@ epinio:
chartVersion: Chart Version
appVersion: Version
helmChart: Helm Chart
+ bulkDelete:
+ button: "Delete Selected ({count})"
+ deletingButton: "Deleting..."
+ deletingAction: "Deleting..."
+ confirmAction: "Delete {count} Item(s)"
+ descriptions:
+ applications: "The following applications will be deleted:"
+ services: "The following services will be deleted:"
+ namespaces: "The following namespaces will be deleted:"
+ configurations: "The following configurations will be deleted:"
+ titles:
+ applications: "Delete Selected Applications ({count})"
+ services: "Delete Selected Services ({count})"
+ namespaces: "Delete Selected Namespaces ({count})"
+ configurations: "Delete Selected Configurations ({count})"
warnings:
noNamespace: There are no namespaces. Please create one before proceeding
login:
diff --git a/dashboard/pkg/epinio/list/configurations.vue b/dashboard/pkg/epinio/list/configurations.vue
index fb3f5870..665e7aca 100644
--- a/dashboard/pkg/epinio/list/configurations.vue
+++ b/dashboard/pkg/epinio/list/configurations.vue
@@ -6,6 +6,7 @@ import { createEpinioRoute } from '../utils/custom-routing';
import LinkDetail from '@shell/components/formatter/LinkDetail.vue';
import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter.vue';
import Masthead from '@shell/components/ResourceList/Masthead';
+import BulkDeleteModal from '../components/BulkDeleteModal.vue';
import { useStore } from 'vuex';
import { ref, computed, onMounted, onUnmounted } from 'vue';
@@ -37,6 +38,17 @@ const canCreateConfiguration = computed(() => {
return can('configuration_write') || can('configuration');
});
+const canDeleteConfiguration = computed(() => {
+ const can = store.getters['epinio/can'];
+ const perms = store.getters['epinio/permissions']?.();
+
+ if (!can || !perms || Object.keys(perms).length === 0) {
+ return false;
+ }
+
+ return can('configuration_write') || can('configuration');
+});
+
const pending = ref(true);
onMounted(async () => {
@@ -68,6 +80,68 @@ onUnmounted(() => {
const rows = computed(() => {
return store.getters['epinio/all'](EPINIO_TYPES.CONFIGURATION);
});
+const selectedConfigurationIds = ref([]);
+const showDeleteModal = ref(false);
+const deletingSelected = ref(false);
+const selectedCount = computed(() => selectedConfigurations.value.length);
+
+function canDeleteConfigurationRow(config: any): boolean {
+ return !!config?._canDelete;
+}
+
+function configKey(config: any): string {
+ return String(config?.id || `${ config?.meta?.namespace || 'default' }/${ config?.meta?.name || '' }`);
+}
+
+const selectedConfigurations = computed(() => {
+ const selectedSet = new Set(selectedConfigurationIds.value);
+ return rows.value.filter((config: any) => canDeleteConfigurationRow(config) && selectedSet.has(configKey(config)));
+});
+
+const selectedConfigurationLabels = computed(() => {
+ return selectedConfigurations.value.map((config: any) => `${ config.meta?.namespace }/${ config.meta?.name }`);
+});
+
+function selectedKeysForRows(items: any[]) {
+ const selectedSet = new Set(selectedConfigurationIds.value);
+ return items
+ .filter((item) => canDeleteConfigurationRow(item))
+ .map((item) => configKey(item))
+ .filter((id) => selectedSet.has(id));
+}
+
+function onSelectionChange(selectedRows: any[]) {
+ selectedConfigurationIds.value = selectedRows.map((row: any) => configKey(row));
+}
+
+function openDeleteModal() {
+ if (!canDeleteConfiguration.value || selectedCount.value === 0 || deletingSelected.value) {
+ return;
+ }
+ showDeleteModal.value = true;
+}
+
+function closeDeleteModal() {
+ showDeleteModal.value = false;
+}
+
+async function deleteSelectedConfigurations() {
+ if (!selectedConfigurations.value.length || deletingSelected.value) {
+ return;
+ }
+
+ deletingSelected.value = true;
+ try {
+ await selectedConfigurations.value[0].bulkRemove(selectedConfigurations.value, {});
+ selectedConfigurationIds.value = [];
+ closeDeleteModal();
+ await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.CONFIGURATION, opt: { force: true } });
+ } catch (e: any) {
+ await store.dispatch('growl/fromError', { title: t('generic.notification.error'), err: e }, { root: true });
+ } finally {
+ deletingSelected.value = false;
+ }
+}
const columns: DataTableColumn[] = [
{
@@ -106,6 +180,14 @@ const columns: DataTableColumn[] = [
:resource="resource"
>
+
+
+
+
diff --git a/dashboard/pkg/epinio/list/namespaces.vue b/dashboard/pkg/epinio/list/namespaces.vue
index f66e04dd..36f8ecad 100644
--- a/dashboard/pkg/epinio/list/namespaces.vue
+++ b/dashboard/pkg/epinio/list/namespaces.vue
@@ -14,8 +14,9 @@ import { epinioExceptionToErrorsArray } from '../utils/errors';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import { validateKubernetesName } from '@shell/utils/validators/kubernetes-name';
import { startPolling, stopPolling } from '../utils/polling';
+import BulkDeleteModal from '../components/BulkDeleteModal.vue';
-defineProps<{
+const props = defineProps<{
schema: object,
rows: Array,
}>();
@@ -48,6 +49,22 @@ const canCreateNamespace = computed(() => {
return can('namespace_write') || can('namespace');
});
+const canDeleteNamespace = computed(() => {
+ const can = store.getters['epinio/can'];
+ const perms = store.getters['epinio/permissions']?.();
+
+ if (!can || !perms || Object.keys(perms).length === 0) {
+ return false;
+ }
+
+ return can('namespace_write') || can('namespace');
+});
+
+const selectedNamespaceIds = ref([]);
+const showDeleteModal = ref(false);
+const deletingSelected = ref(false);
+const selectedCount = computed(() => selectedNamespaceIds.value.length);
+
const validationPassed = computed(() => {
// Add here fields that need validation
if (!creatingNamespace.value) {
@@ -156,6 +173,57 @@ function getNamespaceErrors(name) {
return [];
}
+function namespaceKey(ns: any): string {
+ return String(ns?._key || ns?.id || ns?.meta?.name || ns?.name || '');
+}
+
+const selectedNamespaces = computed(() => {
+ const selectedSet = new Set(selectedNamespaceIds.value);
+ return (props.rows || []).filter((ns: any) => selectedSet.has(namespaceKey(ns)));
+});
+
+const selectedNamespaceLabels = computed(() => {
+ return selectedNamespaces.value.map((ns: any) => ns.meta?.name || ns.name || ns.id);
+});
+
+function onNamespaceSelectionChange(selectedRows: any[]) {
+ selectedNamespaceIds.value = selectedRows.map((row: any) => namespaceKey(row));
+}
+
+function selectedKeysForRows(items: any[]) {
+ const selectedSet = new Set(selectedNamespaceIds.value);
+ return (items || []).map((item: any) => namespaceKey(item)).filter((id: string) => selectedSet.has(id));
+}
+
+function openDeleteModal() {
+ if (!canDeleteNamespace.value || selectedCount.value === 0 || deletingSelected.value) {
+ return;
+ }
+ showDeleteModal.value = true;
+}
+
+function closeDeleteModal() {
+ showDeleteModal.value = false;
+}
+
+async function deleteSelectedNamespaces() {
+ if (!selectedNamespaces.value.length || deletingSelected.value) {
+ return;
+ }
+
+ deletingSelected.value = true;
+ try {
+ await Promise.all(selectedNamespaces.value.map((ns: any) => ns.remove()));
+ selectedNamespaceIds.value = [];
+ closeDeleteModal();
+ await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.NAMESPACE, opt: { force: true } });
+ } catch (e: any) {
+ await store.dispatch('growl/fromError', { title: t('generic.notification.error'), err: e }, { root: true });
+ } finally {
+ deletingSelected.value = false;
+ }
+}
+
const columns: DataTableColumn[] = [
{
field: 'meta.name',
@@ -180,10 +248,18 @@ const columns: DataTableColumn[] = [
+
+
diff --git a/dashboard/pkg/epinio/list/services.vue b/dashboard/pkg/epinio/list/services.vue
index 02e38a8c..ae373df4 100644
--- a/dashboard/pkg/epinio/list/services.vue
+++ b/dashboard/pkg/epinio/list/services.vue
@@ -10,6 +10,7 @@ import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter
import Masthead from '@shell/components/ResourceList/Masthead';
import { useStore } from 'vuex';
import { startPolling, stopPolling } from '../utils/polling';
+import BulkDeleteModal from '../components/BulkDeleteModal.vue';
const pending = ref(true);
const store = useStore();
@@ -38,6 +39,17 @@ const canCreateService = computed(() => {
return can('service_write') || can('service');
});
+const canDeleteService = computed(() => {
+ const can = store.getters['epinio/can'];
+ const perms = store.getters['epinio/permissions']?.();
+
+ if (!can || !perms || Object.keys(perms).length === 0) {
+ return false;
+ }
+
+ return can('service_write') || can('service');
+});
+
onMounted(async () => {
await store.dispatch('epinio/me');
await Promise.all([
@@ -59,6 +71,59 @@ onUnmounted(() => {
const rows = computed(() => {
return store.getters['epinio/all'](EPINIO_TYPES.SERVICE_INSTANCE);
});
+const selectedServiceIds = ref([]);
+const showDeleteModal = ref(false);
+const deletingSelected = ref(false);
+const selectedCount = computed(() => selectedServiceIds.value.length);
+
+function serviceKey(service: any): string {
+ return String(service?.id || `${ service?.meta?.namespace || 'default' }/${ service?.meta?.name || '' }`);
+}
+
+const selectedServices = computed(() => {
+ const selectedSet = new Set(selectedServiceIds.value);
+ return rows.value.filter((service: any) => selectedSet.has(serviceKey(service)));
+});
+
+const selectedServiceLabels = computed(() => selectedServices.value.map((service: any) => `${ service.meta?.namespace }/${ service.meta?.name }`));
+
+function selectedKeysForRows(items: any[]) {
+ const selectedSet = new Set(selectedServiceIds.value);
+ return items.map((item) => serviceKey(item)).filter((id) => selectedSet.has(id));
+}
+
+function onSelectionChange(selectedRows: any[]) {
+ selectedServiceIds.value = selectedRows.map((row: any) => serviceKey(row));
+}
+
+function openDeleteModal() {
+ if (!canDeleteService.value || selectedCount.value === 0 || deletingSelected.value) {
+ return;
+ }
+ showDeleteModal.value = true;
+}
+
+function closeDeleteModal() {
+ showDeleteModal.value = false;
+}
+
+async function deleteSelectedServices() {
+ if (!selectedServices.value.length || deletingSelected.value) {
+ return;
+ }
+
+ deletingSelected.value = true;
+ try {
+ await selectedServices.value[0].bulkRemove(selectedServices.value, {});
+ selectedServiceIds.value = [];
+ closeDeleteModal();
+ await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.SERVICE_INSTANCE, opt: { force: true } });
+ } catch (e: any) {
+ await store.dispatch('growl/fromError', { title: t('generic.notification.error'), err: e }, { root: true });
+ } finally {
+ deletingSelected.value = false;
+ }
+}
const columns: DataTableColumn[] = [
{
@@ -97,6 +162,14 @@ const columns: DataTableColumn[] = [
:resource="resource"
>
+
+
+
diff --git a/dashboard/pkg/epinio/pages/c/_cluster/applications/index.vue b/dashboard/pkg/epinio/pages/c/_cluster/applications/index.vue
index abf9a866..10310328 100644
--- a/dashboard/pkg/epinio/pages/c/_cluster/applications/index.vue
+++ b/dashboard/pkg/epinio/pages/c/_cluster/applications/index.vue
@@ -8,6 +8,7 @@ import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import LinkDetail from '@shell/components/formatter/LinkDetail.vue';
import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter.vue';
+import BulkDeleteModal from '../../../../components/BulkDeleteModal.vue';
import { EPINIO_TYPES } from '../../../../types';
import { createEpinioRoute } from '../../../../utils/custom-routing';
@@ -42,8 +43,24 @@ const canCreateApp = computed(() => {
return can('app_create') || can('app_write') || can('app');
});
+const canDeleteApp = computed(() => {
+ const can = store.getters['epinio/can'];
+ const perms = store.getters['epinio/permissions']?.();
+
+ if (!can || !perms || Object.keys(perms).length === 0) {
+ return false;
+ }
+
+ return can('app_delete') || can('app_write') || can('app');
+});
+
const rows = computed(() => store.getters['epinio/all'](resource));
const allNamespaces = computed(() => store.getters['epinio/all'](EPINIO_TYPES.NAMESPACE) || []);
+const selectedAppIds = ref([]);
+const deletingSelected = ref(false);
+const showDeleteModal = ref(false);
+const deleteImage = ref(false);
+const selectedCount = computed(() => selectedAppIds.value.length);
// Group applications by namespace. Include every namespace so the applications table
// is shown even when a namespace has no applications.
@@ -160,6 +177,77 @@ onUnmounted(() => {
'services'
]);
});
+
+function appKey(app: any): string {
+ return String(app?.id || `${ app?.meta?.namespace || 'default' }/${ app?.meta?.name || '' }`);
+}
+
+function selectedKeysForRows(apps: any[]) {
+ const selectedSet = new Set(selectedAppIds.value);
+
+ return apps.map((app) => appKey(app)).filter((id) => selectedSet.has(id));
+}
+
+function onSelectionChangeForNamespace(namespaceApps: any[], selectedAppsInNamespace: any[]) {
+ const namespaceKeys = new Set(namespaceApps.map((app) => appKey(app)));
+ const selectedNamespaceKeys = new Set(selectedAppsInNamespace.map((app) => appKey(app)));
+ const remaining = selectedAppIds.value.filter((id) => !namespaceKeys.has(id));
+
+ selectedAppIds.value = [...remaining, ...Array.from(selectedNamespaceKeys)];
+}
+
+const selectedApps = computed(() => {
+ const selectedSet = new Set(selectedAppIds.value);
+ return rows.value.filter((app: any) => selectedSet.has(appKey(app)));
+});
+const selectedAppLabels = computed(() => selectedApps.value.map((app: any) => `${ app.meta?.namespace }/${ app.meta?.name }`));
+
+function openDeleteModal() {
+ if (!canDeleteApp.value || selectedCount.value === 0 || deletingSelected.value) {
+ return;
+ }
+ showDeleteModal.value = true;
+}
+
+function closeDeleteModal() {
+ showDeleteModal.value = false;
+}
+
+async function deleteSelectedApps(payload?: { deleteImage: boolean }) {
+ if (!selectedAppIds.value.length || deletingSelected.value) {
+ return;
+ }
+
+ const appsToDelete = [...selectedApps.value];
+
+ if (!appsToDelete.length) {
+ selectedAppIds.value = [];
+ closeDeleteModal();
+ return;
+ }
+
+ deletingSelected.value = true;
+ try {
+ deleteImage.value = !!payload?.deleteImage;
+ appsToDelete.forEach((app: any) => {
+ app._deleteImage = deleteImage.value;
+ });
+ await appsToDelete[0].bulkRemove(appsToDelete, {});
+ selectedAppIds.value = [];
+ closeDeleteModal();
+ await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.APP, opt: { force: true } });
+ } catch (e: any) {
+ await store.dispatch('growl/fromError', {
+ title: t('generic.notification.error'),
+ err: e
+ }, { root: true });
+ } finally {
+ appsToDelete.forEach((app: any) => {
+ delete app._deleteImage;
+ });
+ deletingSelected.value = false;
+ }
+}
@@ -170,6 +258,14 @@ onUnmounted(() => {
:resource="resource"
>
+
+
+
@@ -305,4 +415,21 @@ onUnmounted(() => {
.route {
word-break: break-word;
}
+
+.bulk-delete-btn:disabled {
+ background-color: var(--disabled-bg) !important;
+ border-color: var(--border) !important;
+ color: var(--disabled-text) !important;
+ opacity: 1;
+}
+
+.bulk-delete-btn:not(:disabled) {
+ background-color: var(--error) !important;
+ border-color: var(--error) !important;
+ color: var(--error-contrast, #fff) !important;
+}
+
+.bulk-delete-btn:not(:disabled):hover {
+ filter: brightness(0.92);
+}
|