From 5a0dc0fcc6894622c77c8ca7c3445ad90cb99e88 Mon Sep 17 00:00:00 2001 From: jer3k <99355997+jer3k@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:44:03 -0800 Subject: [PATCH 1/3] feat: GEO-1199 Load PowerBi by report name (#839) --- .../src/components/AnalyticsPage.vue | 60 ++++++++++++------- backend/src/external/services/powerbi-api.ts | 19 +++++- .../src/external/services/powerbi-service.ts | 60 ++++++++++++++++++- .../src/v1/services/analytic-service.spec.ts | 10 ++-- backend/src/v1/services/analytic-service.ts | 8 +-- 5 files changed, 121 insertions(+), 36 deletions(-) diff --git a/admin-frontend/src/components/AnalyticsPage.vue b/admin-frontend/src/components/AnalyticsPage.vue index 177ac3442..c2304feae 100644 --- a/admin-frontend/src/components/AnalyticsPage.vue +++ b/admin-frontend/src/components/AnalyticsPage.vue @@ -1,7 +1,7 @@ <template> <div v-if="isAnalyticsAvailable" class="w-100 overflow-x-auto"> <div - v-for="[name, details] in resourceDetails" + v-for="[name, details] in powerBiDetailsPerResource" :key="name" class="powerbi-container" > @@ -36,12 +36,12 @@ type PowerBiDetails = { const isAnalyticsAvailable = (window as any).config?.IS_ADMIN_ANALYTICS_AVAILABLE?.toUpperCase() == 'TRUE'; -const resourceDetails = createDefaultPowerBiDetailsMap([ +const powerBiDetailsPerResource = createDefaultPowerBiDetailsMap([ POWERBI_RESOURCE.ANALYTICS, ]); if (isAnalyticsAvailable) { - getPowerBiAccessToken(resourceDetails); + getPowerBiAccessToken(powerBiDetailsPerResource); } /** Create a Map containing the details of the resources. */ @@ -60,31 +60,13 @@ function createDefaultPowerBiDetailsMap( accessToken: undefined, tokenType: models.TokenType.Embed, }, - css: { width: '200px', height: '400px' }, + css: { width: '1280px', height: '720px' }, // eventHandlersMap - https://learn.microsoft.com/en-us/javascript/api/overview/powerbi/handle-events#report-events eventHandlersMap: new Map([ [ 'loaded', // The loaded event is raised when the report initializes. () => { - /** Set the css size of the report to be the size of the maximum page of all pages. */ - const setCssSize = async () => { - const pages = await resourceDetails.get(name)!.report?.getPages(); - if (pages) { - const sizes = pages.reduce( - (prev, current) => ({ - width: Math.max(prev.width, current.defaultSize.width ?? 0), - height: Math.max( - prev.height, - current.defaultSize.height ?? 0, - ), - }), - { width: 0, height: 0 }, - ); - resourceDetails.get(name)!.css.width = sizes.width + 'px'; - resourceDetails.get(name)!.css.height = sizes.height + 'px'; - } - }; - setCssSize(); + setCssSize(name); }, ], ]), @@ -126,6 +108,38 @@ async function getPowerBiAccessToken( const msToExpiry = Duration.between(now, expiry).minusMinutes(1).toMillis(); setTimeout(getPowerBiAccessToken, msToExpiry); } + +/** + * Set the css size of the report to be the size of the maximum page of all pages. + * Warning: The navigation pane is not redrawn when increasing the display size which + * leaves a gray area where there should be the navigation pane. You can manually refresh + * the iframe by right clicking on the gray area and selecting 'refresh frame'. + * @param name + * @param refresh Reloads the iframe. Loading takes longer, but the navigation pane will be drawn correctly + */ +async function setCssSize(name: POWERBI_RESOURCE, refresh: boolean = false) { + const details = powerBiDetailsPerResource.get(name)!; + if (!details.report) return; + const pages = await details.report.getPages(); + if (pages) { + const sizes = pages.reduce( + (prev, current) => ({ + width: Math.max(prev.width, current.defaultSize.width ?? 0), + height: Math.max(prev.height, current.defaultSize.height ?? 0), + }), + { width: 0, height: 0 }, + ); + + if ( + details.css.width != sizes.width + 'px' || + details.css.height != sizes.height + 'px' + ) { + details.css.width = sizes.width + 'px'; + details.css.height = sizes.height + 'px'; + if (refresh) details.report.iframe.src += ''; // Allegedly, this is how to refresh an iframe + } + } +} </script> <style lang="scss"> diff --git a/backend/src/external/services/powerbi-api.ts b/backend/src/external/services/powerbi-api.ts index a91fbeaa8..588acd04a 100644 --- a/backend/src/external/services/powerbi-api.ts +++ b/backend/src/external/services/powerbi-api.ts @@ -53,10 +53,21 @@ export class Api { body, config, ); + + /** https://learn.microsoft.com/en-us/rest/api/power-bi/reports/get-reports */ + public getReports = ( + url: Group_Url, + config: AxiosRequestConfig, + ): Promise<AxiosResponse<Reports>> => + this.api.get<Reports>( + `/v1.0/myorg/groups/${url.workspaceId}/reports`, + config, + ); } type DashboardInGroup_Url = { workspaceId: string; dashboardId: string }; type ReportInGroup_Url = { workspaceId: string; reportId: string }; +type Group_Url = { workspaceId: string }; /** https://learn.microsoft.com/en-us/rest/api/power-bi/embed-token/dashboards-generate-token-in-group#request-body */ export type GenerateTokenForDashboardInGroup_Body = { @@ -82,7 +93,6 @@ export type GenerateToken_Body = { /** https://learn.microsoft.com/en-us/rest/api/power-bi/embed-token/generate-token#embedtoken */ export type EmbedToken = { - '@odata.context': string; token: string; tokenId: string; expiration: string; @@ -90,7 +100,6 @@ export type EmbedToken = { /** https://learn.microsoft.com/en-us/rest/api/power-bi/reports/get-report-in-group#report */ export type Report = { - '@odata.context': string; id: string; reportType: string; name: string; @@ -104,9 +113,13 @@ export type Report = { subscriptions: []; }; +/** https://learn.microsoft.com/en-us/rest/api/power-bi/reports/get-reports#reports */ +export type Reports = { + value: Report[]; +}; + /** https://learn.microsoft.com/en-us/rest/api/power-bi/dashboards/get-dashboard-in-group#dashboard */ export type Dashboard = { - '@odata.context': string; id: string; displayName: string; isReadOnly: boolean; diff --git a/backend/src/external/services/powerbi-service.ts b/backend/src/external/services/powerbi-service.ts index 24e6a4412..c8ce5fde8 100644 --- a/backend/src/external/services/powerbi-service.ts +++ b/backend/src/external/services/powerbi-service.ts @@ -23,6 +23,7 @@ type EmbedConfig = { }; export type ReportInWorkspace = { workspaceId: string; reportId: string }; +export type ReportNameInWorkspace = { workspaceId: string; reportName: string }; /** * Class to authenticate and make use of the PowerBi REST API. @@ -39,6 +40,63 @@ export class PowerBiService { this.powerBiApi = new PowerBi.Api(powerBiUrl); } + /** + * Get embed params for multiple report in multiple workspace. Search reports by name + * https://learn.microsoft.com/en-us/rest/api/power-bi/reports/get-report-in-group + * @return EmbedConfig object + */ + public async getEmbedParamsForReportsByName( + reportNameInWorkspace: ReportNameInWorkspace[], + ): Promise<EmbedConfig> { + try { + const header = await this.getEntraAuthorizationHeader(); + + const workspaces = uniq(reportNameInWorkspace.map((x) => x.workspaceId)); + + const reportsPerWorkspace: Record<string, PowerBi.Report[]> = {}; + + // Get all the reports in each workspace by calling the PowerBI REST API + await Promise.all( + workspaces.map(async (id) => { + const reports = await this.powerBiApi.getReports( + { workspaceId: id }, + { + headers: header, + }, + ); + reportsPerWorkspace[id] = reports.data.value; + }), + ); + + // Limit the found reports to only the ones requested + const reports = reportNameInWorkspace.map((res) => + reportsPerWorkspace[res.workspaceId].find( + (x) => x.name == res.reportName, + ), + ); + + // Get Embed token multiple resources + const embedToken = await this.getEmbedTokenForV2Workspace( + uniq(reports.map((report) => report.id)), + uniq(reports.map((report) => report.datasetId)), + uniq(reportNameInWorkspace.map((res) => res.workspaceId)), + ); + + // Add report data for embedding + const reportDetails: PowerBiResource[] = reports.map((report) => ({ + id: report.id, + name: report.name, + embedUrl: report.embedUrl, + })); + + return { embedToken: embedToken, resources: reportDetails }; + } catch (err) { + if (err instanceof AxiosError && err?.response?.data) + err.message = JSON.stringify(err.response.data); + throw err; + } + } + /** * https://learn.microsoft.com/en-us/rest/api/power-bi/dashboards/get-dashboard-in-group */ @@ -77,7 +135,7 @@ export class PowerBiService { } /** - * Get embed params for a single report for a single workspace + * Get embed params for multiple reports in multiple workspace * https://learn.microsoft.com/en-us/rest/api/power-bi/reports/get-report-in-group * @return EmbedConfig object */ diff --git a/backend/src/v1/services/analytic-service.spec.ts b/backend/src/v1/services/analytic-service.spec.ts index dba6d9854..2b6713e27 100644 --- a/backend/src/v1/services/analytic-service.spec.ts +++ b/backend/src/v1/services/analytic-service.spec.ts @@ -4,14 +4,14 @@ import { PowerBiResourceName, } from './analytic-service'; -const mockGetEmbedParamsForReports = jest.fn(); +const mockgetEmbedParamsForReportsByName = jest.fn(); jest.mock('../../external/services/powerbi-service', () => { const actual = jest.requireActual('../../external/services/powerbi-service'); return { ...actual, PowerBiService: jest.fn().mockImplementation(() => { return { - getEmbedParamsForReports: mockGetEmbedParamsForReports, + getEmbedParamsForReportsByName: mockgetEmbedParamsForReportsByName, }; }), }; @@ -40,7 +40,7 @@ describe('getEmbedInfo', () => { expiry: '2024', }; - mockGetEmbedParamsForReports.mockResolvedValue({ + mockgetEmbedParamsForReportsByName.mockResolvedValue({ resources: [ { id: output.resources[0].id, embedUrl: output.resources[0].embedUrl }, { id: output.resources[1].id, embedUrl: output.resources[1].embedUrl }, @@ -51,7 +51,7 @@ describe('getEmbedInfo', () => { PowerBiResourceName.Analytics, PowerBiResourceName.Analytics, ]); - expect(mockGetEmbedParamsForReports).toHaveBeenCalledTimes(1); + expect(mockgetEmbedParamsForReportsByName).toHaveBeenCalledTimes(1); expect(json).toMatchObject(output); }); @@ -62,6 +62,6 @@ describe('getEmbedInfo', () => { 'invalid' as never, ]), ).rejects.toThrow('Invalid resource names'); - expect(mockGetEmbedParamsForReports).not.toHaveBeenCalled(); + expect(mockgetEmbedParamsForReportsByName).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/v1/services/analytic-service.ts b/backend/src/v1/services/analytic-service.ts index 59c1cfb8b..33d895b50 100644 --- a/backend/src/v1/services/analytic-service.ts +++ b/backend/src/v1/services/analytic-service.ts @@ -1,7 +1,7 @@ import { config } from '../../config'; import { PowerBiService, - ReportInWorkspace, + ReportNameInWorkspace, } from '../../external/services/powerbi-service'; // Embed info for Reports, Dashboards, and other resources @@ -15,10 +15,10 @@ export enum PowerBiResourceName { Analytics = 'Analytics', } -const resourceIds: Record<PowerBiResourceName, ReportInWorkspace> = { +const resourceIds: Record<PowerBiResourceName, ReportNameInWorkspace> = { Analytics: { workspaceId: config.get('powerbi:analytics:workspaceId'), - reportId: config.get('powerbi:analytics:analyticsId'), + reportName: config.get('powerbi:analytics:analyticsId'), }, }; @@ -46,7 +46,7 @@ export const analyticsService = { config.get('entra:tenantId'), ); - const embedParams = await powerBi.getEmbedParamsForReports( + const embedParams = await powerBi.getEmbedParamsForReportsByName( resourceNames.map((name) => resourceIds[name]), ); From c3c353cc50b27b943e4ca6546cc7e5bf1150bd3d Mon Sep 17 00:00:00 2001 From: jer3k <99355997+jer3k@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:49:30 -0800 Subject: [PATCH 2/3] feat: GEO-381 Snowplow analytics button (#838) --- .github/workflows/.deploy.yml | 1 + admin-frontend/Caddyfile | 3 ++- admin-frontend/src/App.vue | 4 ++++ admin-frontend/src/components/AnalyticsPage.vue | 14 ++++++++++++++ .../admin-frontend/templates/deployment.yaml | 2 ++ charts/fin-pay-transparency/templates/secret.yaml | 1 + charts/fin-pay-transparency/values-dev.yaml | 1 + 7 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/.deploy.yml b/.github/workflows/.deploy.yml index d5cdacb0f..6cc93001d 100644 --- a/.github/workflows/.deploy.yml +++ b/.github/workflows/.deploy.yml @@ -168,6 +168,7 @@ jobs: --set-string crunchy.pgBackRest.s3.accessKey="${{ secrets.S3_ACCESS_KEY }}" \ --set-string crunchy.pgBackRest.s3.secretKey="${{ secrets.S3_SECRET_ACCESS_KEY }}" \ --set-string global.secrets.clamavApiKey="${{ secrets.CLAMAV_API_KEY }}" \ + --set-string global.secrets.snowplowUrl="${{ secrets.SNOWPLOW_URL }}" \ ${{ inputs.params }} \ --timeout "$DEPLOY_TIMEOUT"m ./${{ github.event.repository.name }}-${{ steps.vars.outputs.semver }}.tgz diff --git a/admin-frontend/Caddyfile b/admin-frontend/Caddyfile index c83f7f573..6f6390bba 100644 --- a/admin-frontend/Caddyfile +++ b/admin-frontend/Caddyfile @@ -20,7 +20,8 @@ } respond `window.config = { "IS_ADMIN_DASHBOARD_AVAILABLE":"{$IS_ADMIN_DASHBOARD_AVAILABLE}", - "IS_ADMIN_ANALYTICS_AVAILABLE":"{$IS_ADMIN_ANALYTICS_AVAILABLE}" + "IS_ADMIN_ANALYTICS_AVAILABLE":"{$IS_ADMIN_ANALYTICS_AVAILABLE}", + "SNOWPLOW_URL":"{$SNOWPLOW_URL}" };` } root * /app/dist diff --git a/admin-frontend/src/App.vue b/admin-frontend/src/App.vue index 3cdd83af1..661c88c5e 100644 --- a/admin-frontend/src/App.vue +++ b/admin-frontend/src/App.vue @@ -118,6 +118,10 @@ a:hover { background-color: transparent !important; } +.v-card-title { + font-size: 1.35rem !important; //default is 1.25, but BCSans font looks wonky at that size. +} + .theme--light.application { background: #f1f1f1; } diff --git a/admin-frontend/src/components/AnalyticsPage.vue b/admin-frontend/src/components/AnalyticsPage.vue index c2304feae..a326e23e4 100644 --- a/admin-frontend/src/components/AnalyticsPage.vue +++ b/admin-frontend/src/components/AnalyticsPage.vue @@ -1,4 +1,15 @@ <template> + <v-btn + v-tooltip:bottom-end="'Only authorized users can access this data'" + append-icon="mdi-open-in-new" + class="ml-auto btn-primary" + style="margin-top: -40px" + rel="noopener" + target="_blank" + :href="sanitizeUrl(snowplowUrl)" + >Web Traffic Analytics</v-btn + > + <div v-if="isAnalyticsAvailable" class="w-100 overflow-x-auto"> <div v-for="[name, details] in powerBiDetailsPerResource" @@ -25,6 +36,7 @@ import ApiService from '../services/apiService'; import { ZonedDateTime, Duration } from '@js-joda/core'; import { POWERBI_RESOURCE } from '../utils/constant'; import { NotificationService } from '../services/notificationService'; +import { sanitizeUrl } from '@braintree/sanitize-url'; type PowerBiDetails = { config: IReportEmbedConfiguration; @@ -33,6 +45,8 @@ type PowerBiDetails = { eventHandlersMap: Map<string, EventHandler>; }; +const snowplowUrl = (window as any).config?.SNOWPLOW_URL; + const isAnalyticsAvailable = (window as any).config?.IS_ADMIN_ANALYTICS_AVAILABLE?.toUpperCase() == 'TRUE'; diff --git a/charts/fin-pay-transparency/charts/admin-frontend/templates/deployment.yaml b/charts/fin-pay-transparency/charts/admin-frontend/templates/deployment.yaml index 82654bcfd..57e2c2c8c 100644 --- a/charts/fin-pay-transparency/charts/admin-frontend/templates/deployment.yaml +++ b/charts/fin-pay-transparency/charts/admin-frontend/templates/deployment.yaml @@ -49,6 +49,8 @@ spec: value: "{{ .Values.env.isAdminDashboardAvailable }}" - name: IS_ADMIN_ANALYTICS_AVAILABLE value: "{{ .Values.env.isAdminAnalyticsAvailable }}" + - name: SNOWPLOW_URL + value: "{{ .Values.global.secrets.snowplowUrl }}" - name: CLAMAV_API_KEY valueFrom: secretKeyRef: diff --git a/charts/fin-pay-transparency/templates/secret.yaml b/charts/fin-pay-transparency/templates/secret.yaml index 6de223119..16da11ac8 100644 --- a/charts/fin-pay-transparency/templates/secret.yaml +++ b/charts/fin-pay-transparency/templates/secret.yaml @@ -62,4 +62,5 @@ data: S3_BUCKET_NAME: {{ .Values.global.secrets.s3Bucket | b64enc | quote }} S3_ENDPOINT: {{ .Values.global.secrets.s3Endpoint | b64enc | quote }} CLAMAV_API_KEY: {{ .Values.global.secrets.clamavApiKey | b64enc | quote }} + SNOWPLOW_URL: {{ .Values.global.secrets.snowplowUrl | b64enc | quote }} diff --git a/charts/fin-pay-transparency/values-dev.yaml b/charts/fin-pay-transparency/values-dev.yaml index 3005fde8b..e4c490f3f 100644 --- a/charts/fin-pay-transparency/values-dev.yaml +++ b/charts/fin-pay-transparency/values-dev.yaml @@ -158,6 +158,7 @@ admin-frontend: env: isAdminDashboardAvailable: true isAdminAnalyticsAvailable: true + isAdminAnalyticsAvailable: true database: enabled: false crunchy: From 61307fb053e2360ecca5f0a39f822371ee268d31 Mon Sep 17 00:00:00 2001 From: jer3k <99355997+jer3k@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:14:59 -0800 Subject: [PATCH 3/3] fix: GEO-1007 s3 now reports deleted objects (#841) --- backend/src/external/services/s3-api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/external/services/s3-api.ts b/backend/src/external/services/s3-api.ts index 33b12220c..18a09bcbb 100644 --- a/backend/src/external/services/s3-api.ts +++ b/backend/src/external/services/s3-api.ts @@ -124,7 +124,7 @@ export const deleteFiles = async (ids: string[]): Promise<Set<string>> => { try { // Get all the files stored under each id const filesPerId = await Promise.all( - ids.map((id) => getFileList(s3Client, id)), //TODO: try deleting the folders instead of each individual file. https://stackoverflow.com/a/73367823 + ids.map((id) => getFileList(s3Client, id)), ); const idsWithNoFiles = ids.filter( (id, index) => filesPerId[index].length === 0, @@ -150,7 +150,7 @@ export const deleteFiles = async (ids: string[]): Promise<Set<string>> => { // report any errors responsePerGroup.forEach((r) => - r.Errors.forEach((e) => { + r.Errors?.forEach((e) => { if (e.Code == 'NoSuchKey') idsWithNoFiles.push(getIdFromKey(e.Key)); logger.error(e.Message); }), @@ -158,7 +158,7 @@ export const deleteFiles = async (ids: string[]): Promise<Set<string>> => { // Return the id of all successful deleted const successfulIds = responsePerGroup.flatMap((r) => - r.Deleted.reduce((acc, x) => { + r.Deleted?.reduce((acc, x) => { acc.push(getIdFromKey(x.Key)); return acc; }, [] as string[]),