Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/grid_client/src/clients/tf-grid/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ export interface GetConsumptionOptions {
graphqlURL: string;
id: number;
}
export interface GetConsumptionsOptions {
graphqlURL: string;
contractIds: number[];
contractCreatedAt: Map<number, number>;
}
export interface Consumption {
amountBilled: number;
discountReceived: DiscountLevel;
Expand Down Expand Up @@ -342,6 +347,95 @@ class TFContracts extends Contracts {
}
}

private calculateConsumption(
reports: GqlContractBillReports[],
contractId: number,
contractCreatedAt: Map<number, number>,
): Consumption {
if (reports.length === 0) {
return { amountBilled: 0, discountReceived: "None" };
}

let duration = 1;
const amountBilled = new Decimal(reports[0].amountBilled);

if (reports.length === 2) {
duration = (reports[0].timestamp - reports[1].timestamp) / 3600; // one hour
} else {
const createdAt = contractCreatedAt.get(contractId);
if (createdAt) {
duration = (reports[0].timestamp - createdAt) / 3600;
}
}

return {
amountBilled: amountBilled
.div(duration || 1)
.div(10 ** 7)
.toNumber(),
discountReceived: reports[0].discountReceived,
};
}

/**
* Get consumption details for multiple contracts at once.
* This reduces the number of GraphQL queries by fetching all billing reports in a single request.
*
* @param {GetConsumptionsOptions} options
* @returns {Promise<Map<number, Consumption>>} A map of contract ID to consumption details
*/
async getConsumptions(options: GetConsumptionsOptions): Promise<Map<number, Consumption>> {
if (options.contractIds.length === 0) {
return new Map();
}

const gqlClient = new Graphql(options.graphqlURL);
const contractIds = options.contractIds.map(id => id.toString());
const whereClause = `{contractID_in: [${contractIds.join(", ")}]}`;
const body = `query getConsumptions {
contractBillReports(where: ${whereClause}, orderBy: timestamp_DESC) {
contractID
amountBilled
timestamp
discountReceived
}
}`;

try {
const response = await gqlClient.query(body);
const billReports: GqlContractBillReports[] = (
response["data"] as { contractBillReports: GqlContractBillReports[] }
).contractBillReports;

// Group reports by contract ID, keeping only the latest 2 per contract
const reportsByContract = new Map<number, GqlContractBillReports[]>();
for (const report of billReports) {
const contractId = +report.contractID;
const reports = reportsByContract.get(contractId) || [];
if (reports.length < 2) {
reports.push(report);
reportsByContract.set(contractId, reports);
}
}

// Calculate consumption for each contract
const consumptions = new Map<number, Consumption>();
for (const contractId of options.contractIds) {
const reports = reportsByContract.get(contractId) || [];
const consumption = this.calculateConsumption(reports, contractId, options.contractCreatedAt);
consumptions.set(contractId, consumption);
}

return consumptions;
} catch (err) {
(err as Error).message = formatErrorMessage(
`Error getting consumptions for contracts [${options.contractIds.join(", ")}].`,
err,
);
throw err;
}
}

async listContractsByAddress(options: ListContractByAddressOptions) {
const twinId = await this.client.twins.getTwinIdByAccountId({ accountId: options.accountId });
return await this.listContractsByTwinId({
Expand Down
25 changes: 25 additions & 0 deletions packages/grid_client/src/modules/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
BatchCancelContractsModel,
ContractCancelModel,
ContractConsumption,
ContractConsumptions,
ContractDiscountPackage,
ContractGetByNodeIdAndHashModel,
ContractGetModel,
Expand Down Expand Up @@ -555,6 +556,30 @@ class Contracts {
return this.client.contracts.getConsumption({ id: options.id, graphqlURL: this.config.graphqlURL });
}

/**
* Get consumption details for multiple contracts at once.
* This reduces the number of GraphQL queries by fetching all billing reports in a single request.
*
* @param {ContractConsumptions} options - The options containing contract IDs and their creation timestamps
* @returns {Promise<Map<number, Consumption>>} A map of contract ID to consumption details
* @decorators
* - `@expose`: Exposes the method for external use.
* - `@validateInput`: Validates the input options.
*/
@expose
@validateInput
async getConsumptions(options: ContractConsumptions): Promise<Map<number, Consumption>> {
const contractCreatedAt = new Map<number, number>();
Object.entries(options.contractCreatedAt).forEach(([key, value]) => {
contractCreatedAt.set(+key, value);
});
return this.client.contracts.getConsumptions({
graphqlURL: this.config.graphqlURL,
contractIds: options.contractIds,
contractCreatedAt,
});
}

/**
* Retrieves the deletion time of a contract based on the provided options.
*
Expand Down
8 changes: 8 additions & 0 deletions packages/grid_client/src/modules/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ExtrinsicResult } from "@threefold/tfchain_client";
import { default as AlgoSdk } from "algosdk";
import { Expose, Transform, Type } from "class-transformer";
import {
ArrayMinSize,
ArrayNotEmpty,
IsAlphanumeric,
IsArray,
Expand All @@ -12,6 +13,7 @@ import {
IsIP,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUrl,
Expand Down Expand Up @@ -390,6 +392,11 @@ class ContractConsumption {
@Expose() @IsInt() @Min(1) id: number;
}

class ContractConsumptions {
@Expose() @IsArray() @ArrayMinSize(0) contractIds: number[];
@Expose() @IsObject() contractCreatedAt: Record<string, number>;
}

class ContractDiscountPackage {
@Expose() @IsInt() @Min(1) id: number;
}
Expand Down Expand Up @@ -988,6 +995,7 @@ export {
ContractsByTwinId,
ContractsByAddress,
ContractConsumption,
ContractConsumptions,
ContractDiscountPackage,
ContractLockModel,
TwinCreateModel,
Expand Down
19 changes: 12 additions & 7 deletions packages/playground/src/utils/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function normalizeContract(
grid: GridClient,
c: { [key: string]: any },
type: ContractType,
consumption?: Consumption,
): Promise<NormalizedContract> {
const id = +c.contract_id;

Expand All @@ -53,11 +54,15 @@ export async function normalizeContract(
expiration = new Date(exp).toLocaleString();
}

let consumption: Consumption;
try {
consumption = await grid.contracts.getConsumption({ id });
} catch {
consumption = { amountBilled: 0, discountReceived: "None" };
let contractConsumption: Consumption;
if (consumption) {
contractConsumption = consumption;
} else {
try {
contractConsumption = await grid.contracts.getConsumption({ id });
} catch {
contractConsumption = { amountBilled: 0, discountReceived: "None" };
}
}

return {
Expand All @@ -75,8 +80,8 @@ export async function normalizeContract(
solutionName: data.name || "-",
solutionType: data.projectName || data.type || "-",
expiration,
consumption: consumption.amountBilled,
discountPackage: consumption.discountReceived,
consumption: contractConsumption.amountBilled,
discountPackage: contractConsumption.discountReceived,
};
}

Expand Down
103 changes: 83 additions & 20 deletions packages/playground/src/weblets/tf_contracts_list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
:disabled="loadingContracts || loadingTotalCost"
@click="
contractsTable.forEach(t => t.reset());
loadContracts();
loadContracts(undefined, undefined, true);
"
>
refresh
Expand Down Expand Up @@ -253,7 +253,7 @@
</template>

<script lang="ts" setup>
import type { ContractsOverdue, GridClient } from "@threefold/grid_client";
import type { Consumption, ContractsOverdue, GridClient } from "@threefold/grid_client";
import { type Contract, ContractState, NodeStatus, SortByContracts, SortOrder } from "@threefold/gridproxy_client";
import { DeploymentKeyDeletionError } from "@threefold/types";
import { computed, defineComponent, onMounted, type Ref, ref } from "vue";
Expand Down Expand Up @@ -318,18 +318,33 @@ const nodeIDs = computed(() => {
});
// To avoid multiple requests
const cachedNodeIDs = ref<number[]>([]);
// Cache to store consumption data (updates hourly, cache for 5 minutes)
const CONSUMPTION_CACHE: { [key: number]: { consumption: Consumption; timestamp: number } } = {};
const CONSUMPTION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let CONSUMPTION_CACHE_LOADED = false;

onMounted(() => {
loadContracts();
});

function getConsumptionFromCache(contractId: number): Consumption | undefined {
const cached = CONSUMPTION_CACHE[contractId];
if (cached && Date.now() - cached.timestamp <= CONSUMPTION_CACHE_TTL) {
return cached.consumption;
}
return undefined;
}

async function _normalizeContracts(
contracts: Contract[],
contractType: ContractType.Node | ContractType.Name | ContractType.Rent,
): Promise<NormalizedContract[]> {
const normalizedContracts = await Promise.all(
contracts.map(async contract => {
try {
return await normalizeContract(grid, contract, contractType);
const id = Number(contract.contract_id);
const consumption = getConsumptionFromCache(id);
return await normalizeContract(grid, contract, contractType, consumption);
} catch (error) {
failedContracts.value.push(contract.contract_id);
}
Expand All @@ -338,6 +353,48 @@ async function _normalizeContracts(
return normalizedContracts.filter(Boolean) as NormalizedContract[];
}

function extractContractData(contracts: any[]): { ids: number[]; createdAt: Record<string, number> } {
const ids: number[] = [];
const createdAt: Record<string, number> = {};
for (const contract of contracts) {
const id = Number(contract.contractID);
ids.push(id);
createdAt[id.toString()] = +contract.createdAt;
}
return { ids, createdAt };
}

async function loadAllConsumptions() {
try {
const allContracts = await grid.contracts.listMyContracts();
const allContractIds: number[] = [];
const allContractCreatedAt: Record<string, number> = {};

// Collect contract IDs from all types
const nameData = extractContractData(allContracts.nameContracts || []);
const nodeData = extractContractData(allContracts.nodeContracts || []);
const rentData = extractContractData(allContracts.rentContracts || []);

allContractIds.push(...nameData.ids, ...nodeData.ids, ...rentData.ids);
Object.assign(allContractCreatedAt, nameData.createdAt, nodeData.createdAt, rentData.createdAt);

if (allContractIds.length > 0) {
const consumptions = await grid.contracts.getConsumptions({
contractIds: allContractIds,
contractCreatedAt: allContractCreatedAt,
});

const now = Date.now();
for (const [contractId, consumption] of consumptions.entries()) {
CONSUMPTION_CACHE[contractId] = { consumption, timestamp: now };
}
CONSUMPTION_CACHE_LOADED = true;
}
} catch (error) {
console.warn("Failed to preload all consumptions", error);
}
}

async function loadContractsByType(
contractType: ContractType.Node | ContractType.Name | ContractType.Rent,
contractsRef: Ref<NormalizedContract[]>,
Expand Down Expand Up @@ -373,7 +430,11 @@ async function loadContractsByType(
}
}

async function loadContracts(type?: ContractType, options?: { sort: { key: string; order: "asc" | "desc" }[] }) {
async function loadContracts(
type?: ContractType,
options?: { sort: { key: string; order: "asc" | "desc" }[] },
clearCache = false,
) {
loadingContracts.value = true;
if (!type) {
lockedContracts.value = undefined;
Expand All @@ -384,26 +445,28 @@ async function loadContracts(type?: ContractType, options?: { sort: { key: strin
contracts.value = [];
cachedNodeIDs.value = [];
failedContracts.value = [];

// Clear consumption cache on manual refresh
if (clearCache) {
Object.keys(CONSUMPTION_CACHE).forEach(key => delete CONSUMPTION_CACHE[+key]);
CONSUMPTION_CACHE_LOADED = false;
}

try {
if (type) {
switch (type) {
case ContractType.Name:
await loadContractsByType(ContractType.Name, nameContracts, options);
break;
case ContractType.Node:
await loadContractsByType(ContractType.Node, nodeContracts, options);
break;
case ContractType.Rent:
await loadContractsByType(ContractType.Rent, rentContracts, options);
break;
}
const table = contractsTables.find(t => t.type === type);
if (table) await loadContractsByType(type, table.contracts, options);
} else {
await Promise.all([
loadContractsByType(ContractType.Name, nameContracts, options),
loadContractsByType(ContractType.Node, nodeContracts, options),
loadContractsByType(ContractType.Rent, rentContracts, options),
]);
if (!CONSUMPTION_CACHE_LOADED) {
await loadAllConsumptions();
}
await Promise.all(
contractsTables.map(async table => {
await loadContractsByType(table.type, table.contracts);
}),
);
}

const failedContractsLength = failedContracts.value.length;
if (failedContractsLength > 0)
loadingErrorMessage.value = `Failed to load details of the following contract${
Expand Down