Skip to content

Commit

Permalink
feat(billing): add workspace usage graph and connection table (#13586)
Browse files Browse the repository at this point in the history
Co-authored-by: Parker Mossman <parker@airbyte.io>
Co-authored-by: Tim Roes <tim@airbyte.io>
  • Loading branch information
3 people committed Sep 12, 2024
1 parent 12319bb commit b886328
Show file tree
Hide file tree
Showing 22 changed files with 544 additions and 98 deletions.
2 changes: 1 addition & 1 deletion airbyte-api/server-api/src/main/openapi/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7626,7 +7626,7 @@ components:
timeframeEnd:
type: string
quantity:
type: string
type: number
format: double
ConsumptionTimeWindow:
type: string
Expand Down
1 change: 1 addition & 0 deletions airbyte-webapp/src/core/api/hooks/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./dbtCloud";
export * from "./stripe";
export * from "./usePrefetchCloudWorkspaceData";
export * from "./users";
export * from "./useGetWorkspaceUsage";
16 changes: 16 additions & 0 deletions airbyte-webapp/src/core/api/hooks/cloud/useGetWorkspaceUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useCurrentWorkspaceId } from "area/workspace/utils";
import { getWorkspaceUsage } from "core/api/generated/AirbyteClient";
import { ConsumptionTimeWindow } from "core/api/generated/AirbyteClient.schemas";
import { useRequestOptions } from "core/api/useRequestOptions";
import { useSuspenseQuery } from "core/api/useSuspenseQuery";

import { workspaceKeys } from "../workspaces";

export const useGetWorkspaceUsage = ({ timeWindow }: { timeWindow: ConsumptionTimeWindow }) => {
const requestOptions = useRequestOptions();
const workspaceId = useCurrentWorkspaceId();

return useSuspenseQuery(workspaceKeys.usage(workspaceId, timeWindow), () =>
getWorkspaceUsage({ workspaceId, timeWindow }, requestOptions)
);
};
3 changes: 3 additions & 0 deletions airbyte-webapp/src/core/api/hooks/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "../generated/AirbyteClient";
import { SCOPE_USER, SCOPE_WORKSPACE } from "../scopes";
import {
ConsumptionTimeWindow,
WorkspaceCreate,
WorkspaceRead,
WorkspaceReadList,
Expand All @@ -33,6 +34,8 @@ export const workspaceKeys = {
listAccessUsers: (workspaceId: string) => [SCOPE_WORKSPACE, "users", "listAccessUsers", workspaceId] as const,
detail: (workspaceId: string) => [...workspaceKeys.all, "details", workspaceId] as const,
state: (workspaceId: string) => [...workspaceKeys.all, "state", workspaceId] as const,
usage: (workspaceId: string, timeWindow: ConsumptionTimeWindow) =>
[...workspaceKeys.all, "usage", workspaceId, timeWindow] as const,
};

export const useCurrentWorkspace = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ export interface Experiments {
"connectorBuilder.contributeEditsToMarketplace": boolean;
"connectorBuilder.aiAssist.enabled": boolean;
"billing.organizationBillingPage": boolean;
"billing.workspaceUsagePage": boolean;
}
7 changes: 7 additions & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,7 @@
"settings.instanceSettings": "Instance",
"settings.workspace.general.title": "General workspace settings",
"settings.workspace.usage.title": "Workspace usage",
"settings.workspace.usage.tooltip": "Workspace usage is measured in <lnk>credits</lnk>.",
"settings.organization.general.title": "General organization settings",
"settings.organization.members.title": "Organization members",
"settings.organization.billing.notSetUp": "Billing has not yet been set up for this organization. Start by adding a payment method.",
Expand All @@ -1029,6 +1030,11 @@
"settings.organization.billing.invoices.moreInvoices": "Only the most recent invoices are shown here. Click {viewAll} above to see all your invoices.",
"settings.organization.billing.paymentMethodError": "Error loading payment method",
"settings.organization.billing.paymentMethodUnknown": "Your payment method is set up.",
"settings.organization.billing.filter.lastThirtyDays": "Last 30 days",
"settings.organization.billing.filter.lastSixMonths": "Last 6 months",
"settings.organization.billing.filter.lastTwelveMonths": "Last 12 months",
"settings.organization.billing.filter.allSources": "All sources",
"settings.organization.billing.filter.allDestinations": "All destinations",
"settings.workspaceSettings.update.success": "Workspace settings have been updated!",
"settings.workspaceSettings.update.error": "Something went wrong while updating your workspace settings. Please try again.",
"settings.workspaceSettings.updateWorkspaceNameSuccess": "Workspace name has been updated!",
Expand Down Expand Up @@ -1969,6 +1975,7 @@
"credits.destination": "Destination",
"credits.schedule": "Schedule",
"credits.freeUsage": "Free",
"credits.internalUsage": "Free (internal)",
"credits.billedCost": "Billed",
"credits.totalUsage": "Total credits usage",
"credits.stripePortalLink": "Invoice history",
Expand Down
13 changes: 7 additions & 6 deletions airbyte-webapp/src/packages/cloud/cloudRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ const AdvancedSettingsPage = React.lazy(() => import("pages/SettingsPage/pages/A
const MainRoutes: React.FC = () => {
const workspace = useCurrentWorkspace();
const canViewOrgSettings = useIntent("ViewOrganizationSettings", { organizationId: workspace.organizationId });
const isBillingInArrearsActive = useExperiment("billing.organizationBillingPage", false);
const isOrganizationBillingPageVisible = useExperiment("billing.organizationBillingPage", false);
const isWorkspaceUsagePageVisible = useExperiment("billing.workspaceUsagePage", false);

useExperimentContext("organization", workspace.organizationId);

Expand Down Expand Up @@ -122,17 +123,17 @@ const MainRoutes: React.FC = () => {
{supportsCloudDbtIntegration && (
<Route path={CloudSettingsRoutePaths.DbtCloud} element={<DbtCloudSettingsView />} />
)}
{isWorkspaceUsagePageVisible && (
<Route path={CloudSettingsRoutePaths.Usage} element={<WorkspaceUsagePage />} />
)}
{canViewOrgSettings && (
<>
<Route path={CloudSettingsRoutePaths.Organization} element={<GeneralOrganizationSettingsPage />} />
<Route path={CloudSettingsRoutePaths.OrganizationMembers} element={<OrganizationMembersPage />} />
</>
)}
{isBillingInArrearsActive && (
<>
<Route path={CloudSettingsRoutePaths.Billing} element={<OrganizationBillingPage />} />
<Route path={CloudSettingsRoutePaths.Usage} element={<WorkspaceUsagePage />} />
</>
{canViewOrgSettings && isOrganizationBillingPageVisible && (
<Route path={CloudSettingsRoutePaths.Billing} element={<OrganizationBillingPage />} />
)}
<Route path={CloudSettingsRoutePaths.Advanced} element={<AdvancedSettingsPage />} />
<Route path="*" element={<Navigate to={CloudSettingsRoutePaths.Account} replace />} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { ConnectorIcon } from "components/ConnectorIcon";
import { FlexContainer, FlexItem } from "components/ui/Flex";
import { SupportLevelBadge } from "components/ui/SupportLevelBadge";
import { FlexContainer } from "components/ui/Flex";
import { Text } from "components/ui/Text";

import styles from "./ConnectorOptionLabel.module.scss";
import { AvailableDestination, AvailableSource } from "./CreditsUsageContext";

interface ConnectorOptionLabelProps {
connector: AvailableSource | AvailableDestination;
connector: AvailableSource | AvailableDestination | { name: string; icon: string };
}

export const ConnectorOptionLabel: React.FC<ConnectorOptionLabelProps> = ({ connector }) => (
Expand All @@ -16,8 +15,5 @@ export const ConnectorOptionLabel: React.FC<ConnectorOptionLabelProps> = ({ conn
<Text color="darkBlue" className={styles.connectorName}>
{connector.name}
</Text>
<FlexItem>
<SupportLevelBadge supportLevel={connector.supportLevel} custom={connector.custom} />
</FlexItem>
</FlexContainer>
);
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ import { UsagePerConnectionTable } from "./UsagePerConnectionTable";
import { UsagePerDayGraph } from "./UsagePerDayGraph";

export const CreditsUsage: React.FC = () => {
const { freeAndPaidUsageByTimeChunk, hasFreeUsage } = useCreditsContext();
const { freeAndPaidUsageByTimeChunk, hasFreeUsage, freeAndPaidUsageByConnection } = useCreditsContext();

useEffectOnce(() => {
trackTiming("CreditUsage");
});

return (
<Card className={styles.card}>
<Box pt="xl">
<Box pt="xl" px="lg">
<CreditsUsageFilters />
</Box>
{freeAndPaidUsageByTimeChunk.length > 0 ? (
Expand All @@ -43,7 +43,7 @@ export const CreditsUsage: React.FC = () => {
<FormattedMessage id="credits.usagePerConnection" />
</Heading>
</Box>
<UsagePerConnectionTable />
<UsagePerConnectionTable freeAndPaidUsageByConnection={freeAndPaidUsageByConnection} />
</Box>
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createContext, useContext, useMemo, useState } from "react";
import { Option } from "components/ui/ListBox";

import { useCurrentWorkspace, useFilters } from "core/api";
import { useGetCloudWorkspaceUsage } from "core/api/cloud";
import { useGetCloudWorkspaceUsage, useGetWorkspaceUsage } from "core/api/cloud";
import { DestinationId, SourceId, SupportLevel } from "core/api/types/AirbyteClient";
import { ConsumptionTimeWindow } from "core/api/types/CloudApi";

Expand All @@ -14,6 +14,8 @@ import {
UsagePerTimeChunk,
calculateFreeAndPaidUsageByTimeChunk,
calculateFreeAndPaidUsageByConnection,
getWorkspaceUsageByTimeChunk,
getWorkspaceUsageByConnection,
} from "./calculateUsageDataObjects";
import { ConnectorOptionLabel } from "./ConnectorOptionLabel";

Expand Down Expand Up @@ -47,6 +49,7 @@ interface CreditsUsageContext {
selectedTimeWindow: ConsumptionTimeWindow;
setSelectedTimeWindow: (timeWindow: ConsumptionTimeWindow) => void;
hasFreeUsage: boolean;
hasInternalUsage: boolean;
}

export const creditsUsageContext = createContext<CreditsUsageContext | null>(null);
Expand Down Expand Up @@ -153,9 +156,105 @@ export const CreditsUsageContextProvider: React.FC<React.PropsWithChildren<unkno
setSelectedTimeWindow: (selectedTimeWindow: ConsumptionTimeWindow) =>
setFilterValue("selectedTimeWindow", selectedTimeWindow),
hasFreeUsage,
// There is no internal usage in the old billing page, only the new workspace usage page
hasInternalUsage: false,
}}
>
{children}
</creditsUsageContext.Provider>
);
};

export const WorkspaceCreditUsageContextProvider: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const [filters, setFilterValue] = useFilters<FilterValues>({
selectedTimeWindow: ConsumptionTimeWindow.lastMonth,
selectedSource: null,
selectedDestination: null,
});
const { selectedTimeWindow, selectedSource, selectedDestination } = filters;
const workspaceUsage = useGetWorkspaceUsage({ timeWindow: selectedTimeWindow });

const sourceOptions = useMemo(() => {
const optionsMap = new Map<string, Option<string>>();
workspaceUsage.data.forEach((usage) => {
if (selectedDestination) {
// Only show sources that have a connection to the selected destination
if (selectedDestination === usage.destination.destinationId) {
optionsMap.set(usage.source.sourceId, {
label: <ConnectorOptionLabel connector={{ name: usage.source.name, icon: usage.source.icon ?? "" }} />,
value: usage.source.sourceId,
});
}
} else {
optionsMap.set(usage.source.sourceId, {
label: <ConnectorOptionLabel connector={{ name: usage.source.name, icon: usage.source.icon ?? "" }} />,
value: usage.source.sourceId,
});
}
});
return Array.from(optionsMap.values());
}, [workspaceUsage, selectedDestination]);

const destinationOptions = useMemo(() => {
const optionsMap = new Map<string, Option<string>>();
workspaceUsage.data.forEach((usage) => {
if (selectedSource) {
// Only show destinations that have a connection to the selected source
if (selectedSource === usage.source.sourceId) {
optionsMap.set(usage.destination.destinationId, {
label: (
<ConnectorOptionLabel connector={{ name: usage.destination.name, icon: usage.destination.icon ?? "" }} />
),
value: usage.destination.destinationId,
});
}
} else {
optionsMap.set(usage.destination.destinationId, {
label: (
<ConnectorOptionLabel connector={{ name: usage.destination.name, icon: usage.destination.icon ?? "" }} />
),
value: usage.destination.destinationId,
});
}
});
return Array.from(optionsMap.values());
}, [workspaceUsage, selectedSource]);

const filteredWorkspaceUsageData = useMemo(() => {
if (selectedSource && selectedDestination) {
return workspaceUsage.data.filter(
(consumption) =>
consumption.source.sourceId === selectedSource &&
consumption.destination.destinationId === selectedDestination
);
} else if (selectedSource) {
return workspaceUsage.data.filter((consumption) => consumption.source.sourceId === selectedSource);
} else if (selectedDestination) {
return workspaceUsage.data.filter((consumption) => consumption.destination.destinationId === selectedDestination);
}

return workspaceUsage.data;
}, [workspaceUsage, selectedDestination, selectedSource]);

const freeAndPaidUsageByTimeChunk = getWorkspaceUsageByTimeChunk(filteredWorkspaceUsageData, selectedTimeWindow);
const freeAndPaidUsageByConnection = getWorkspaceUsageByConnection(filteredWorkspaceUsageData, selectedTimeWindow);

const contextValue = {
freeAndPaidUsageByTimeChunk,
freeAndPaidUsageByConnection,
sourceOptions,
destinationOptions,
selectedSource,
setSelectedSource: (selectedSource: SourceId | null) => setFilterValue("selectedSource", selectedSource),
selectedDestination,
setSelectedDestination: (selectedDestination: SourceId | null) =>
setFilterValue("selectedDestination", selectedDestination),
selectedTimeWindow,
setSelectedTimeWindow: (selectedTimeWindow: ConsumptionTimeWindow) =>
setFilterValue("selectedTimeWindow", selectedTimeWindow),
hasFreeUsage: freeAndPaidUsageByTimeChunk.some((usage) => usage.freeUsage > 0),
hasInternalUsage: freeAndPaidUsageByTimeChunk.some((usage) => usage.internalUsage && usage.internalUsage > 0),
};

return <creditsUsageContext.Provider value={contextValue}>{children}</creditsUsageContext.Provider>;
};
Loading

0 comments on commit b886328

Please sign in to comment.