diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c25d8d4d1..47973cc09 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -83,6 +83,7 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; @@ -303,6 +304,7 @@ declare module "fastify" { bus: TEventBusService; sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; + offlineUsageReport: TOfflineUsageReportServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 891fa208f..c156f2c7d 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -722,6 +722,16 @@ export const licenseServiceFactory = ({ await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); }; + const getCustomerId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.customerId; + }; + + const getLicenseId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.licenseId; + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -736,6 +746,8 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + getCustomerId, + getLicenseId, invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c5387a0f6..9af4f6ade 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -291,6 +291,8 @@ import { TSmtpService } from "@app/services/smtp/smtp-service"; import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; +import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; +import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue"; import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; @@ -385,6 +387,7 @@ export const registerRoutes = async ( const reminderRecipientDAL = reminderRecipientDALFactory(db); const integrationDAL = integrationDALFactory(db); + const offlineUsageReportDAL = offlineUsageReportDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db); const webhookDAL = webhookDALFactory(db); const serviceTokenDAL = serviceTokenDALFactory(db); @@ -847,6 +850,11 @@ export const registerRoutes = async ( tokenService }); + const offlineUsageReportService = offlineUsageReportServiceFactory({ + offlineUsageReportDAL, + licenseService + }); + const orgAdminService = orgAdminServiceFactory({ smtpService, projectDAL, @@ -2005,6 +2013,7 @@ export const registerRoutes = async ( apiKey: apiKeyService, authToken: tokenService, superAdmin: superAdminService, + offlineUsageReport: offlineUsageReportService, project: projectService, projectMembership: projectMembershipService, projectKey: projectKeyService, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index d4f5eb8b0..7f5a2f374 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -54,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { defaultAuthOrgAuthMethod: z.string().nullish(), isSecretScanningDisabled: z.boolean(), kubernetesAutoFetchServiceAccountToken: z.boolean(), - paramsFolderSecretDetectionEnabled: z.boolean() + paramsFolderSecretDetectionEnabled: z.boolean(), + isOfflineUsageReportsEnabled: z.boolean() }) }) } @@ -70,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, - paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED + paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED, + isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE } }; } @@ -926,4 +928,35 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { return { organizationMembership }; } }); + + server.route({ + method: "POST", + url: "/usage-report/generate", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + csvContent: z.string(), + signature: z.string(), + filename: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const result = await server.services.offlineUsageReport.generateUsageReportCSV(); + + return { + csvContent: result.csvContent, + signature: result.signature, + filename: result.filename + }; + } + }); }; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-dal.ts b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts new file mode 100644 index 000000000..109d90da8 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts @@ -0,0 +1,208 @@ +import { TDbClient } from "@app/db"; +import { ProjectType, TableName } from "@app/db/schemas"; + +export type TOfflineUsageReportDALFactory = ReturnType; + +export const offlineUsageReportDALFactory = (db: TDbClient) => { + const getUserMetrics = async () => { + // Get total users and admin users + const userMetrics = (await db + .from(TableName.Users) + .select( + db.raw( + ` + COUNT(*) as total_users, + COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users + ` + ) + ) + .where({ isGhost: false }) + .first()) as { total_users: string; admin_users: string } | undefined; + + // Get users by auth method + const authMethodStats = (await db + .from(TableName.Users) + .select( + db.raw(` + unnest("authMethods") as auth_method, + COUNT(*) as count + `) + ) + .where({ isGhost: false }) + .whereNotNull("authMethods") + .groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>; + + const usersByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { auth_method: string; count: string }) => { + acc[row.auth_method] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalUsers: parseInt(userMetrics?.total_users || "0", 10), + adminUsers: parseInt(userMetrics?.admin_users || "0", 10), + usersByAuthMethod + }; + }; + + const getMachineIdentityMetrics = async () => { + // Get total machine identities + const identityMetrics = (await db + .from(TableName.Identity) + .select( + db.raw( + ` + COUNT(*) as total_identities + ` + ) + ) + .first()) as { total_identities: string } | undefined; + + // Get identities by auth method + const authMethodStats = (await db + .from(TableName.Identity) + .select("authMethod") + .count("* as count") + .whereNotNull("authMethod") + .groupBy("authMethod")) as Array<{ authMethod: string; count: string }>; + + const machineIdentitiesByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { authMethod: string; count: string }) => { + acc[row.authMethod] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10), + machineIdentitiesByAuthMethod + }; + }; + + const getProjectMetrics = async () => { + // Get total projects and projects by type + const projectMetrics = (await db + .from(TableName.Project) + .select("type") + .count("* as count") + .groupBy("type")) as Array<{ type: string; count: string }>; + + const totalProjects = projectMetrics.reduce( + (sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ); + const projectsByType = projectMetrics.reduce( + (acc: Record, row: { type: string; count: string }) => { + acc[row.type] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + // Calculate average secrets per project + const secretsPerProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId") + .count("s.id as count") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id") + .whereNotNull("p.id")) as Array<{ projectId: string; count: string }>; + + const averageSecretsPerProject = + secretsPerProject.length > 0 + ? secretsPerProject.reduce( + (sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ) / secretsPerProject.length + : 0; + + return { + totalProjects, + projectsByType, + averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100 + }; + }; + + const getSecretMetrics = async () => { + // Get total secrets + const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as + | { count: string } + | undefined; + + const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10); + + // Get secrets by project + const secretsByProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId", "p.name as projectName") + .count("s.id as secretCount") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id", "p.name") + .whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>; + + return { + totalSecrets, + secretsByProject: secretsByProject.map( + (row: { projectId: string; projectName: string; secretCount: string }) => ({ + projectId: row.projectId, + projectName: row.projectName, + secretCount: parseInt(row.secretCount, 10) + }) + ) + }; + }; + + const getSecretSyncMetrics = async () => { + const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10) + }; + }; + + const getDynamicSecretMetrics = async () => { + const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10) + }; + }; + + const getSecretRotationMetrics = async () => { + // Check both v1 and v2 secret rotation tables + const [v1RotationsResult, v2RotationsResult] = await Promise.all([ + db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>, + db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined> + ]); + + const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10); + const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10); + + return { + totalSecretRotations: totalV1Rotations + totalV2Rotations + }; + }; + + return { + getUserMetrics, + getMachineIdentityMetrics, + getProjectMetrics, + getSecretMetrics, + getSecretSyncMetrics, + getDynamicSecretMetrics, + getSecretRotationMetrics + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-service.ts b/backend/src/services/offline-usage-report/offline-usage-report-service.ts new file mode 100644 index 000000000..179232aa4 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-service.ts @@ -0,0 +1,133 @@ +import crypto from "crypto"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal"; + +type TOfflineUsageReportServiceFactoryDep = { + offlineUsageReportDAL: TOfflineUsageReportDALFactory; + licenseService: Pick; +}; + +export type TOfflineUsageReportServiceFactory = ReturnType; + +export const offlineUsageReportServiceFactory = ({ + offlineUsageReportDAL, + licenseService +}: TOfflineUsageReportServiceFactoryDep) => { + const signReportContent = (content: string, licenseId: string): string => { + const contentHash = crypto.createHash("sha256").update(content).digest("hex"); + const hmac = crypto.createHmac("sha256", licenseId); + hmac.update(contentHash); + return hmac.digest("hex"); + }; + + const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => { + const expectedSignature = signReportContent(content, licenseId); + return signature === expectedSignature; + }; + + const generateUsageReportCSV = async () => { + const cfg = getConfig(); + if (!cfg.LICENSE_KEY_OFFLINE) { + throw new BadRequestError({ + message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured." + }); + } + + const customerId = licenseService.getCustomerId() as string; + const licenseId = licenseService.getLicenseId(); + + const [ + userMetrics, + machineIdentityMetrics, + projectMetrics, + secretMetrics, + secretSyncMetrics, + dynamicSecretMetrics, + secretRotationMetrics + ] = await Promise.all([ + offlineUsageReportDAL.getUserMetrics(), + offlineUsageReportDAL.getMachineIdentityMetrics(), + offlineUsageReportDAL.getProjectMetrics(), + offlineUsageReportDAL.getSecretMetrics(), + offlineUsageReportDAL.getSecretSyncMetrics(), + offlineUsageReportDAL.getDynamicSecretMetrics(), + offlineUsageReportDAL.getSecretRotationMetrics() + ]); + + const headers = [ + "Total Users", + "Admin Users", + "Total Identities", + "Total Projects", + "Total Secrets", + "Total Secret Syncs", + "Total Dynamic Secrets", + "Total Secret Rotations", + "Avg Secrets Per Project" + ]; + + const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod); + allUserAuthMethods.forEach((method) => { + headers.push(`Users Auth ${method}`); + }); + + const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod); + allIdentityAuthMethods.forEach((method) => { + headers.push(`Identities Auth ${method}`); + }); + + const allProjectTypes = Object.keys(projectMetrics.projectsByType); + allProjectTypes.forEach((type) => { + headers.push(`Projects ${type}`); + }); + + headers.push("Signature"); + + const dataRow: (string | number)[] = [ + userMetrics.totalUsers, + userMetrics.adminUsers, + machineIdentityMetrics.totalMachineIdentities, + projectMetrics.totalProjects, + secretMetrics.totalSecrets, + secretSyncMetrics.totalSecretSyncs, + dynamicSecretMetrics.totalDynamicSecrets, + secretRotationMetrics.totalSecretRotations, + projectMetrics.averageSecretsPerProject + ]; + + allUserAuthMethods.forEach((method) => { + dataRow.push(userMetrics.usersByAuthMethod[method] || 0); + }); + allIdentityAuthMethods.forEach((method) => { + dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0); + }); + + allProjectTypes.forEach((type) => { + dataRow.push(projectMetrics.projectsByType[type] || 0); + }); + + const headersWithoutSignature = headers.slice(0, -1); + const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n"); + + const signature = signReportContent(contentWithoutSignature, licenseId); + dataRow.push(signature); + + const csvContent = [headers.join(","), dataRow.join(",")].join("\n"); + + return { + csvContent, + signature, + filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv` + }; + }; + + return { + generateUsageReportCSV, + verifyReportSignature: (csvContent: string, signature: string, licenseId: string) => + verifyReportContent(csvContent, signature, licenseId) + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-types.ts b/backend/src/services/offline-usage-report/offline-usage-report-types.ts new file mode 100644 index 000000000..614d5ccce --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-types.ts @@ -0,0 +1,42 @@ +export interface TUsageMetrics { + // User metrics + totalUsers: number; + usersByAuthMethod: Record; + adminUsers: number; + + // Machine identity metrics + totalMachineIdentities: number; + machineIdentitiesByAuthMethod: Record; + + // Project metrics + totalProjects: number; + projectsByType: Record; + averageSecretsPerProject: number; + + // Secret metrics + totalSecrets: number; + totalSecretSyncs: number; + totalDynamicSecrets: number; + totalSecretRotations: number; +} + +export interface TUsageReportMetadata { + generatedAt: string; + instanceId: string; + reportVersion: string; +} + +export interface TUsageReport { + metadata: TUsageReportMetadata; + metrics: TUsageMetrics; + signature?: string; +} + +export interface TGenerateUsageReportDTO { + includeSignature?: boolean; +} + +export interface TVerifyUsageReportDTO { + reportData: string; + signature: string; +} diff --git a/frontend/src/helpers/download.ts b/frontend/src/helpers/download.ts index 50adf4fe2..d1659a956 100644 --- a/frontend/src/helpers/download.ts +++ b/frontend/src/helpers/download.ts @@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, filename); }; + +export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +}; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b7ab0ea6f..918a0ecd5 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -13,7 +13,8 @@ import { TInvalidateCacheDTO, TResendOrgInviteDTO, TServerConfig, - TUpdateServerConfigDTO + TUpdateServerConfigDTO, + TUsageReportResponse } from "./types"; export const useCreateAdminUser = () => { @@ -240,3 +241,14 @@ export const useServerAdminAccessOrg = () => { } }); }; + +export const useGenerateUsageReport = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post( + "/api/v1/admin/usage-report/generate" + ); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index f6c2f1a23..f8336fcac 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -56,6 +56,7 @@ export type TServerConfig = { fipsEnabled: boolean; envOverrides?: Record; paramsFolderSecretDetectionEnabled: boolean; + isOfflineUsageReportsEnabled: boolean; }; export type TUpdateServerConfigDTO = { @@ -146,6 +147,12 @@ export interface TGetEnvOverrides { }; } +export type TUsageReportResponse = { + filename: string; + csvContent: string; + signature: string; +}; + export type TCreateOrganizationDTO = { name: string; inviteAdminEmails: string[]; diff --git a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx index c9215a165..e5a76e93c 100644 --- a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx +++ b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx @@ -2,11 +2,13 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; +import { useGetServerConfig } from "@app/hooks/api/admin"; -import { GeneralPageForm } from "./components"; +import { GeneralPageForm, UsageReportSection } from "./components"; export const GeneralPage = () => { const { t } = useTranslation(); + const { data: serverConfig } = useGetServerConfig(); return (
@@ -19,7 +21,10 @@ export const GeneralPage = () => { title="General" description="Manage general settings for your Infisical instance." /> - +
+ + {serverConfig?.isOfflineUsageReportsEnabled && } +
diff --git a/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx new file mode 100644 index 000000000..d05800996 --- /dev/null +++ b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx @@ -0,0 +1,53 @@ +import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Card, CardTitle } from "@app/components/v2"; +import { downloadFile } from "@app/helpers/download"; +import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation"; + +export const UsageReportSection = () => { + const generateUsageReport = useGenerateUsageReport(); + + const handleGenerateReport = async () => { + try { + const response = await generateUsageReport.mutateAsync(); + const { csvContent, filename } = response; + + downloadFile(csvContent, filename, "text/csv"); + + createNotification({ + text: `Usage report downloaded: "${filename}"`, + type: "success" + }); + } catch (error) { + console.error("Failed to generate usage report:", error); + createNotification({ + text: "Failed to generate usage report. Please try again.", + type: "error" + }); + } + }; + + return ( + + + + Offline Usage Reports + + +
+ Generate secure usage reports for offline license compliance and billing verification. +
+ + +
+ ); +}; diff --git a/frontend/src/pages/admin/GeneralPage/components/index.ts b/frontend/src/pages/admin/GeneralPage/components/index.ts index 15ea2f5ba..2ec57dadf 100644 --- a/frontend/src/pages/admin/GeneralPage/components/index.ts +++ b/frontend/src/pages/admin/GeneralPage/components/index.ts @@ -1 +1,2 @@ export { GeneralPageForm } from "./GeneralPageForm"; +export { UsageReportSection } from "./UsageReportSection";