From fa10af43883e5c63af01e42939895e8dfe774362 Mon Sep 17 00:00:00 2001 From: Pranay Sanghvi Date: Thu, 29 Jan 2026 04:15:33 -0500 Subject: [PATCH 01/36] Update applications.vue --- dashboard/pkg/epinio/detail/applications.vue | 56 ++++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/dashboard/pkg/epinio/detail/applications.vue b/dashboard/pkg/epinio/detail/applications.vue index b4d0a98a..535a33f5 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 = 1000; // 1s; 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; From a119b684eade87c2e451e92fb23ac3fd3d389949 Mon Sep 17 00:00:00 2001 From: Pranay Sanghvi Date: Fri, 30 Jan 2026 01:36:36 -0500 Subject: [PATCH 02/36] download buton added in system->about --- dashboard/pkg/epinio/l10n/en-us.yaml | 7 ++ .../pkg/epinio/pages/c/_cluster/about.vue | 95 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/dashboard/pkg/epinio/l10n/en-us.yaml b/dashboard/pkg/epinio/l10n/en-us.yaml index 71e55b1d..af25bd08 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: Download report + action: Download Report + success: 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. diff --git a/dashboard/pkg/epinio/pages/c/_cluster/about.vue b/dashboard/pkg/epinio/pages/c/_cluster/about.vue index 956ad8c7..371f5e33 100644 --- a/dashboard/pkg/epinio/pages/c/_cluster/about.vue +++ b/dashboard/pkg/epinio/pages/c/_cluster/about.vue @@ -19,6 +19,10 @@ const supportBundleLoading = ref(false); const supportBundleError = ref(''); const supportBundleSuccess = ref(''); +const reportLoading = ref(false); +const reportError = ref(''); +const reportSuccess = ref(''); + const t = store.getters['i18n/t']; const aboutVersionsComponentString = computed(() => t('about.versions.component')); @@ -106,6 +110,46 @@ const bundleFileName = () => { return `epinio-support-bundle-${ now.getFullYear() }-${ pad(now.getMonth() + 1) }-${ pad(now.getDate()) }-${ pad(now.getHours()) }-${ pad(now.getMinutes()) }-${ pad(now.getSeconds()) }.tar.gz`; }; +const reportFileName = () => { + const now = new Date(); + const pad = (val: number) => val.toString().padStart(2, '0'); + + return `epinio-report-${ now.getFullYear() }-${ pad(now.getMonth() + 1) }-${ pad(now.getDate()) }-${ pad(now.getHours()) }-${ pad(now.getMinutes()) }-${ pad(now.getSeconds()) }.txt`; +}; + +const downloadReport = async() => { + reportError.value = ''; + reportSuccess.value = ''; + reportLoading.value = true; + + try { + const res = await store.dispatch('epinio/request', { + opt: { + url: '/api/v1/report/nodes', + method: 'get', + params: { format: 'text' }, + responseType: 'blob', + timeout: 60000 + } + }); + + const blob = res?.data; + const contentType = res?.headers?.['content-type'] || 'text/plain; charset=utf-8'; + + await downloadFile(reportFileName(), blob, contentType); + + reportSuccess.value = t('epinio.downloadReport.success'); + } catch (err: any) { + const status = err?._status || err?.status || err?.response?.status; + + reportError.value = status === 403 + ? t('epinio.downloadReport.errors.unauthorized') + : t('epinio.downloadReport.errors.failed'); + } finally { + reportLoading.value = false; + } +}; + const downloadSupportBundle = async() => { const safeTail = sanitizeTail(tailLines.value); @@ -213,6 +257,38 @@ const downloadSupportBundle = async() => { +
+

{{ t('epinio.downloadReport.title') }}

+ +
+ +
+ + + +
+
{ } } +.download-report { + margin-top: 40px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--border-radius); + background: var(--default); + + &__actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + } + + .banner { + margin-top: 10px; + } +} + .support-bundle { margin-top: 40px; padding: 16px; From 99edfd08774b3060b520ce4f830e52fad4e138b1 Mon Sep 17 00:00:00 2001 From: Pranay Sanghvi Date: Thu, 5 Feb 2026 01:50:29 -0500 Subject: [PATCH 03/36] Update ExportAppDialog.vue --- .../pkg/epinio/dialog/ExportAppDialog.vue | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/dashboard/pkg/epinio/dialog/ExportAppDialog.vue b/dashboard/pkg/epinio/dialog/ExportAppDialog.vue index 42de74a3..9a34d2cd 100644 --- a/dashboard/pkg/epinio/dialog/ExportAppDialog.vue +++ b/dashboard/pkg/epinio/dialog/ExportAppDialog.vue @@ -32,7 +32,9 @@ const genericPrompt = ref(null); const partsWeight = { [APPLICATION_PARTS.VALUES]: 0.1, [APPLICATION_PARTS.CHART]: 0.1, - [APPLICATION_PARTS.IMAGE]: 0.7 + [APPLICATION_PARTS.IMAGE]: 0.7, + zip: 0.1, + archive: 1 }; const zipParts = props.resources[0]?.applicationParts.filter( @@ -78,10 +80,16 @@ const exportApplicationManifest = async () => { zip.file(`${ fileName }.${ extension[fileName] }`, files[fileName]); } - const contents = await zip.generateAsync({ - type: 'blob', - compression: 'STORE', - }); + percentages.value.zip = 0; + const contents = await zip.generateAsync( + { + type: 'blob', + compression: 'STORE', + }, + (metadata) => { + percentages.value.zip = metadata.percent; + } + ); await downloadFile( `${ resource.meta.name }-helm-chart.zip`, @@ -93,6 +101,20 @@ const exportApplicationManifest = async () => { if (store.$router.currentRoute._value.hash === '#manifest') { await resource.createManifest(); } else { + // Prefer server-side archive (one download, no client zip) when backend supports it + const archiveBlob = await fetchPartArchive(resource); + if (archiveBlob) { + await downloadFile( + `${ resource.meta.name }-helm-chart.zip`, + archiveBlob, + 'application/zip', + ); + progressBar.value = 100; + await delayBeforeClose(1500); + return; + } + + // Fallback: fetch three parts and zip in browser (slower, especially in Rancher extension) const partsData = await zipParts.reduce(async(acc, part) => ({ ...await acc, [part]: await fetchPart(resource, part), @@ -121,6 +143,30 @@ const getCancelToken = () => { return store.$axios.CancelToken; } +// Fetches server-side archive (one zip). Returns blob or null if backend does not support it. +const fetchPartArchive = async (resource) => { + toggleStep('archive', true); + cancelTokenSources.archive = getCancelToken().source(); + try { + const blob = await resource.fetchPart('archive', { + onDownloadProgress: (progressEvent) => { + const total = progressEvent.event?.srcElement?.getResponseHeader?.('content-length') || + progressEvent.event?.srcElement?.getResponseHeader?.('proxy-content-length'); + if (total) { + percentages.value.archive = Math.round(progressEvent.loaded * 100 / total); + } + if (progressEvent.loaded > 0) { + toggleStep('archive'); + } + }, + cancelToken: cancelTokenSources.archive?.token, + }); + return blob; + } catch (e) { + return null; + } +}; + const fetchPart = async (resource, part) => { toggleStep(part, true); cancelTokenSources[part] = getCancelToken().source(); @@ -159,8 +205,7 @@ const fetchPart = async (resource, part) => { const fetchCancel = () => { // Cancel pending api requests, see https://axios-http.com/docs/cancellation Object.keys(cancelTokenSources).forEach( - (part) => cancelTokenSources[part]. - cancel(`${ part } part: download cancelled.`) + (part) => cancelTokenSources[part]?.cancel?.(`${ part } part: download cancelled.`) ); } @@ -252,7 +297,12 @@ const toggleStep = (part, isPreparing = false) => { class="progress-info text info mb-10 mt-20" > - {{ t(`epinio.applications.export.chartValuesImages.steps.${ step }`) }} + {{ step === 'zip' && typeof percentages.zip === 'number' + ? t('epinio.applications.export.chartValuesImages.steps.zip') + ` (${ Math.round(percentages.zip) }%)` + : step === 'download.archive' || step === 'preparing.archive' + ? t(`epinio.applications.export.chartValuesImages.steps.${ step }`) + (typeof percentages.archive === 'number' ? ` (${ Math.round(percentages.archive) }%)` : '') + : t(`epinio.applications.export.chartValuesImages.steps.${ step }`) + }} Date: Thu, 5 Feb 2026 01:52:51 -0500 Subject: [PATCH 04/36] Update en-us.yaml --- dashboard/pkg/epinio/l10n/en-us.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dashboard/pkg/epinio/l10n/en-us.yaml b/dashboard/pkg/epinio/l10n/en-us.yaml index 9f23fb68..f2547fff 100644 --- a/dashboard/pkg/epinio/l10n/en-us.yaml +++ b/dashboard/pkg/epinio/l10n/en-us.yaml @@ -195,10 +195,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: From fdd2398bb08c9d4548169d36425ed7460bb0a316 Mon Sep 17 00:00:00 2001 From: David Johnson Date: Tue, 10 Feb 2026 13:13:55 -0500 Subject: [PATCH 05/36] change debounce from 1 to 2 --- dashboard/pkg/epinio/detail/applications.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/pkg/epinio/detail/applications.vue b/dashboard/pkg/epinio/detail/applications.vue index 535a33f5..90db09fe 100644 --- a/dashboard/pkg/epinio/detail/applications.vue +++ b/dashboard/pkg/epinio/detail/applications.vue @@ -126,7 +126,7 @@ const commitActions = [{ }]; // Debounce settings for scaling instances -const UPDATE_INSTANCES_DEBOUNCE_MS = 1000; // 1s; adjust as needed +const UPDATE_INSTANCES_DEBOUNCE_MS = 2000; // 2s; adjust as needed let updateInstancesTimeout: number | null = null; onMounted(async () => { From 60e604bd1e1ea53038df81bc939bd1d8a2e65007 Mon Sep 17 00:00:00 2001 From: Pranay Sanghvi Date: Wed, 11 Feb 2026 03:28:39 -0500 Subject: [PATCH 06/36] Add RBAC permissions and enforce UI actions Introduce client-side RBAC support and apply it across the Epinio dashboard. Add utils/permissions.ts to build a flat permission map from /api/v1/me roles, and new EpinioMe/EpinioRole types. Extend the epinio store with a me action, permissions state, a can getter helper, and related mutations/reset. Fetch /me on relevant pages (namespaces, applications) and conditionally show Create buttons only when appropriate permissions are present (with sensible defaults while permissions are not yet loaded). Update resource models (applications, namespaces, services, configurations) to filter available actions based on the can getter and prune orphaned dividers for application actions. Overall this enforces UI-level RBAC while still relying on the API for authoritative enforcement. --- dashboard/pkg/epinio/list/namespaces.vue | 14 +- dashboard/pkg/epinio/models/applications.js | 85 +++++++++++- dashboard/pkg/epinio/models/configurations.js | 20 ++- dashboard/pkg/epinio/models/namespaces.js | 25 ++++ dashboard/pkg/epinio/models/services.js | 25 ++++ .../pages/c/_cluster/applications/index.vue | 20 +++ .../pkg/epinio/store/epinio-store/actions.ts | 15 +- .../pkg/epinio/store/epinio-store/getters.ts | 11 ++ .../epinio/store/epinio-store/mutations.ts | 18 ++- dashboard/pkg/epinio/types.ts | 15 ++ dashboard/pkg/epinio/utils/permissions.ts | 130 ++++++++++++++++++ 11 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 dashboard/pkg/epinio/utils/permissions.ts diff --git a/dashboard/pkg/epinio/list/namespaces.vue b/dashboard/pkg/epinio/list/namespaces.vue index 4cd71b96..f66e04dd 100644 --- a/dashboard/pkg/epinio/list/namespaces.vue +++ b/dashboard/pkg/epinio/list/namespaces.vue @@ -38,6 +38,16 @@ const showPromptRemove = computed(() => { return store.state['action-menu'].showPromptRemove }); +const canCreateNamespace = computed(() => { + const can = store.getters['epinio/can']; + + if (!can) { + return false; + } + + return can('namespace_write') || can('namespace'); +}); + const validationPassed = computed(() => { // Add here fields that need validation if (!creatingNamespace.value) { @@ -48,7 +58,8 @@ const validationPassed = computed(() => { return errors.value?.length === 0; }); -onMounted(() => { +onMounted(async() => { + await store.dispatch('epinio/me'); // Opens the create namespace modal if the query is passed as query param if (store.$router.currentRoute._value.query.mode === 'openModal') { openCreateModal(); @@ -174,6 +185,7 @@ const columns: DataTableColumn[] = [ >