diff --git a/.github/workflows/dashboard-build-extension.yml b/.github/workflows/dashboard-build-extension.yml index f53d29c1..438a3bba 100644 --- a/.github/workflows/dashboard-build-extension.yml +++ b/.github/workflows/dashboard-build-extension.yml @@ -105,7 +105,7 @@ jobs: - name: Upload charts artifact if: github.ref_type == 'tag' || (github.ref == 'refs/heads/main' && github.event_name != 'pull_request') || inputs.is_test == 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: charts path: ./dashboard/tmp @@ -134,7 +134,7 @@ jobs: git config user.email 'github-actions[bot]@users.noreply.github.com' - name: Download build artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: charts path: ./dashboard diff --git a/.github/workflows/release-next.yml b/.github/workflows/release-next.yml index 7a9c648b..4da30a16 100644 --- a/.github/workflows/release-next.yml +++ b/.github/workflows/release-next.yml @@ -38,14 +38,14 @@ jobs: name: Install yarn run: npm install --global yarn - - uses: anchore/sbom-action/download-syft@v0.21.1 + uses: anchore/sbom-action/download-syft@v0.23.0 - uses: sigstore/cosign-installer@v4.0.0 with: cosign-release: 'v2.5.1' - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Docker Registry uses: docker/login-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9132ae97..672aca00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,14 +38,14 @@ jobs: name: Install yarn run: npm install --global yarn - - uses: anchore/sbom-action/download-syft@v0.21.1 + uses: anchore/sbom-action/download-syft@v0.23.0 - uses: sigstore/cosign-installer@v4.0.0 with: cosign-release: 'v2.5.1' - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Docker Registry uses: docker/login-action@v3 diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.mjs similarity index 100% rename from dashboard/eslint.config.js rename to dashboard/eslint.config.mjs diff --git a/dashboard/package.json b/dashboard/package.json index 1e4b481a..2285e7d9 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "epinio", - "version": "v1.13.9", + "version": "v1.13.10", "engines": { "node": ">=20" }, diff --git a/dashboard/pkg/epinio/config/epinio.ts b/dashboard/pkg/epinio/config/epinio.ts index b79eb840..de30b327 100644 --- a/dashboard/pkg/epinio/config/epinio.ts +++ b/dashboard/pkg/epinio/config/epinio.ts @@ -137,14 +137,15 @@ export function init($plugin: any, store: any) { customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.APP_CHARTS }), }); - // Configuration resource + // Configuration resource (isCreatable: false so shell doesn't show Create; list shows it only when canCreateConfiguration) configureType(EPINIO_TYPES.CONFIGURATION, { - isCreatable: true, - isEditable: true, - isRemovable: true, - showState: false, - canYaml: false, - customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.CONFIGURATION }), + isCreatable: true, + isEditable: true, + isRemovable: true, + showState: false, + canYaml: false, + customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.CONFIGURATION }), + showListMasthead: false }); // Groups @@ -152,14 +153,15 @@ export function init($plugin: any, store: any) { const SERVICE_GROUP = 'Services'; const ABOUT = 'System'; - // Service Instance + // Service Instance (isCreatable: false so shell doesn't show Create; our list/services.vue shows it only when canCreateService) configureType(EPINIO_TYPES.SERVICE_INSTANCE, { - isCreatable: true, + isCreatable: false, isEditable: true, isRemovable: true, showState: true, canYaml: false, customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.SERVICE_INSTANCE }), + showListMasthead: false, // Custom masthead with RBAC-gated Create button }); // Catalog Service diff --git a/dashboard/pkg/epinio/detail/applications.vue b/dashboard/pkg/epinio/detail/applications.vue index b4d0a98a..90db09fe 100644 --- a/dashboard/pkg/epinio/detail/applications.vue +++ b/dashboard/pkg/epinio/detail/applications.vue @@ -34,7 +34,8 @@ const store = useStore(); const t = store.getters['i18n/t']; -const saving = ref(false); +const scalingInFlight = ref(false); +const debouncePending = ref(false); const gitSource = ref(null); const gitDeployment = ref({ deployedCommit: { short: '', long: '' }, @@ -124,6 +125,10 @@ const commitActions = [{ enabled: true }]; +// Debounce settings for scaling instances +const UPDATE_INSTANCES_DEBOUNCE_MS = 2000; // 2s; adjust as needed +let updateInstancesTimeout: number | null = null; + onMounted(async () => { await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.SERVICE_INSTANCE }); await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.CONFIGURATION }); @@ -135,17 +140,35 @@ onMounted(async () => { }); async function updateInstances(newInstances: number) { - saving.value = true; - try { - props.value.configuration.instances = newInstances; - await props.value.update(); - await props.value.forceFetch(); - } catch (err) { - console.error(`Failed to scale Application: `, epinioExceptionToErrorsArray(err)); + // Update desired and configured instances immediately so the UI reflects the target + props.value.desiredInstances = newInstances; + props.value.configuration.instances = newInstances; + + // Debounce the API call so rapid clicks collapse into one request + if (updateInstancesTimeout !== null) { + clearTimeout(updateInstancesTimeout); } - saving.value = false; + debouncePending.value = true; + + updateInstancesTimeout = window.setTimeout(async () => { + debouncePending.value = false; + scalingInFlight.value = true; + + try { + await props.value.update(); + await props.value.forceFetch(); + } catch (err) { + console.error('[Epinio instances] Failed to scale Application', epinioExceptionToErrorsArray(err)); + } finally { + scalingInFlight.value = false; + debouncePending.value = false; + updateInstancesTimeout = null; + } + }, UPDATE_INSTANCES_DEBOUNCE_MS); } +const showScaleSpinner = computed(() => debouncePending.value || scalingInFlight.value); + function formatURL(str: string) { const matchGit = str.match('^(https|git)(:\/\/|@)([^\/:]+)[\/:]([^\/:]+)\/(.+)(.git)*$'); // eslint-disable-line no-useless-escape return `${matchGit?.[4]}/${matchGit?.[5]}`; @@ -333,10 +356,16 @@ const commitPosition = computed(() => { +
+ +
@@ -746,6 +775,13 @@ const commitPosition = computed(() => { } } +.scale-instances__spinner { + display: inline-flex; + align-items: center; + font-size: 12px; + color: var(--muted-text); +} + .deployment__origin__list { ul { margin: 0; diff --git a/dashboard/pkg/epinio/dialog/ExportAppDialog.vue b/dashboard/pkg/epinio/dialog/ExportAppDialog.vue index 42de74a3..7fc55cd9 100644 --- a/dashboard/pkg/epinio/dialog/ExportAppDialog.vue +++ b/dashboard/pkg/epinio/dialog/ExportAppDialog.vue @@ -1,6 +1,6 @@ + + + + diff --git a/dashboard/pkg/epinio/edit/applications.vue b/dashboard/pkg/epinio/edit/applications.vue index 15c6dae7..c3fdb3b9 100644 --- a/dashboard/pkg/epinio/edit/applications.vue +++ b/dashboard/pkg/epinio/edit/applications.vue @@ -87,6 +87,19 @@ const shouldShowButtons = computed( const showSourceTab = computed(() => { return props.mode === _EDIT }); + +// Hide "Edit Config" in any mode for users without configuration write (e.g. view_only) +const canEditConfig = 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'); +}); +// If the user lacks config write perms, never show the primary "Edit Config" footer button, +// regardless of how this dialog was opened (view or edit route). +const hideEditConfigButton = computed(() => !canEditConfig.value); const validationPassed = computed(() => !Object.values(tabErrors).find((error) => error)); const done = () => { @@ -186,7 +199,7 @@ function validate(value: boolean, tab: string) { diff --git a/dashboard/pkg/epinio/index.ts b/dashboard/pkg/epinio/index.ts index 59740b78..f386f19d 100644 --- a/dashboard/pkg/epinio/index.ts +++ b/dashboard/pkg/epinio/index.ts @@ -1,3 +1,4 @@ +import { markRaw } from 'vue'; import { importTypes } from '@rancher/auto-import'; import { ActionLocation, ActionOpts, IPlugin, OnNavAwayFromPackage, OnNavToPackage @@ -61,16 +62,20 @@ export default function(plugin: IPlugin) { // Add Vue Routes plugin.addRoutes(epinioRoutes); - // Add theme toggle to header + // Add theme toggle to header (markRaw avoids Vue reactive-object warning) const ThemeToggle = require('./components/ThemeToggle.vue'); // eslint-disable-line @typescript-eslint/no-require-imports - plugin.register('component', 'NavHeaderRight', ThemeToggle.default || ThemeToggle); + plugin.register('component', 'NavHeaderRight', markRaw(ThemeToggle.default || ThemeToggle)); // Add hooks to Vue navigation world plugin.addNavHooks(onEnter, onLeave); - // Register unsaved changes dialog + // Register unsaved changes dialog (markRaw avoids Vue reactive-object warning) const UnsavedChangesDialog = require('./dialog/UnsavedChangesDialog.vue'); // eslint-disable-line @typescript-eslint/no-require-imports - plugin.register('component', 'UnsavedChangesDialog', UnsavedChangesDialog.default || UnsavedChangesDialog); + plugin.register('component', 'UnsavedChangesDialog', markRaw(UnsavedChangesDialog.default || UnsavedChangesDialog)); + + // Register install Epinio dialog + const InstallDialog = require('./dialog/InstallDialog.vue'); // eslint-disable-line @typescript-eslint/no-require-imports + plugin.register('component', 'InstallDialog', InstallDialog.default || InstallDialog); // Add action button in the menu of each object belonging to Epinio's applications plugin.addAction( diff --git a/dashboard/pkg/epinio/l10n/en-us.yaml b/dashboard/pkg/epinio/l10n/en-us.yaml index 9f23fb68..52c9bf3e 100644 --- a/dashboard/pkg/epinio/l10n/en-us.yaml +++ b/dashboard/pkg/epinio/l10n/en-us.yaml @@ -65,6 +65,13 @@ epinio: about: allPackages: See all packages getBinaries: Get binaries + downloadReport: + title: Cluster Metrics Report + action: Download + success: Metrics report downloaded successfully + errors: + unauthorized: You need admin permissions to download the report + failed: Failed to download report. Please try again or contact support supportBundle: title: Download Support Bundle description: Collect a troubleshooting bundle for support. @@ -72,7 +79,7 @@ epinio: label: Log lines per component help: Defaults to 1000 lines. Maximum 10000. includeApps: Include application logs - action: Download Support Bundle + action: Download collecting: Collecting logs from Epinio components... success: Support bundle downloaded successfully errors: @@ -195,10 +202,12 @@ epinio: values: "Preparing download: Values" chart: "Preparing download: Chart" image: "Preparing download: Image" + archive: "Preparing download: Chart and Images" download: values: "Downloading: Values" chart: "Downloading: Chart" image: "Downloading: Image" + archive: "Downloading: Chart and Images" zip: Compressing Files error: Server Error, download of { part } part failed. steps: diff --git a/dashboard/pkg/epinio/list/configurations.vue b/dashboard/pkg/epinio/list/configurations.vue index 1a1c57a5..fb3f5870 100644 --- a/dashboard/pkg/epinio/list/configurations.vue +++ b/dashboard/pkg/epinio/list/configurations.vue @@ -2,20 +2,45 @@ import DataTable from '../components/tables/DataTable.vue'; import type { DataTableColumn } from '../components/tables/types'; import { EPINIO_TYPES } from '../types'; +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 { useStore } from 'vuex'; import { ref, computed, onMounted, onUnmounted } from 'vue'; import { startPolling, stopPolling } from '../utils/polling'; const store = useStore(); +const t = store.getters['i18n/t']; defineProps<{ schema: object }>(); // Keep for compatibility +const resource = EPINIO_TYPES.CONFIGURATION; + +const createLocation = computed(() => + createEpinioRoute('c-cluster-resource-create', { + cluster: store.getters['clusterId'], + resource: EPINIO_TYPES.CONFIGURATION, + }) +); + +// Strict RBAC: only show Create when user has configuration write (hides for view_only) +const canCreateConfiguration = 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 () => { + await store.dispatch('epinio/me'); store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.APP }); store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.SERVICE_INSTANCE }); await store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.CONFIGURATION }); @@ -76,6 +101,20 @@ const columns: DataTableColumn[] = [