-
Notifications
You must be signed in to change notification settings - Fork 23
feat: show remaining CDN & cache-miss egress #538
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
fd491d0
9aa16b7
2132a05
f029bc4
b1f0f04
10c3811
c0bbd0e
ac134f7
ad49466
164bc70
725f20b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' | ||
| import { useDeletePiece } from '@filoz/synapse-react' | ||
| import { useDeletePiece, useEgressQuota } from '@filoz/synapse-react' | ||
| import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' | ||
| import { useState } from 'react' | ||
| import { toast } from 'sonner' | ||
| import { toastError } from '@/lib/utils.ts' | ||
| import { formatBytes, toastError } from '@/lib/utils.ts' | ||
| import { ButtonLoading } from '../custom-ui/button-loading.tsx' | ||
| import { ExplorerLink } from '../explorer-link.tsx' | ||
| import { PDPDatasetLink, PDPPieceLink, PDPProviderLink } from '../pdp-link.tsx' | ||
|
|
@@ -14,6 +14,21 @@ import { Skeleton } from '../ui/skeleton.tsx' | |
| import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' | ||
| import { CreateDataSetDialog } from './create-data-set.tsx' | ||
|
|
||
| function EgressQuotaDisplay({ dataSetId }: { dataSetId: bigint }) { | ||
| const { data: egressQuota } = useEgressQuota({ dataSetId }) | ||
|
|
||
| if (!egressQuota) { | ||
| return null | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery {' · '} | ||
| {formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| export function DataSetsSection({ | ||
| dataSets, | ||
| providers, | ||
|
|
@@ -110,14 +125,17 @@ export function DataSetsSection({ | |
| </TooltipContent> | ||
| </Tooltip> | ||
| {dataSet.cdn && ( | ||
| <Tooltip> | ||
| <TooltipTrigger> | ||
| <Globe className="w-4" /> | ||
| </TooltipTrigger> | ||
| <TooltipContent> | ||
| <p>This data set is using CDN</p> | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| <span className="flex items-center gap-1 text-sm text-muted-foreground"> | ||
| <Tooltip> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i would make a custom tooltip component wrapping the globe icon with all the cdn info, using a hook to fetch the data. check the other hooks that use tanstack query to wrap
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Since we are fetching That's what I implemented in the new version.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OTOH, I can see how it would make sense to move the entire {dataSet.cdn && <CdnDetails dataSetId={dataSet.dataSetId}/>}Is that what you had in mind? Can you suggest a better name for this new component? |
||
| <TooltipTrigger> | ||
| <Globe className="w-4" /> | ||
| </TooltipTrigger> | ||
| <TooltipContent> | ||
| <p>This data set is using CDN</p> | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| <EgressQuotaDisplay dataSetId={dataSet.dataSetId} /> | ||
| </span> | ||
| )} | ||
| </p> | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { isSynapseError, SynapseError } from './base.ts' | ||
|
|
||
| export class GetDataSetStatsError extends SynapseError { | ||
| override name: 'GetDataSetStatsError' = 'GetDataSetStatsError' | ||
|
|
||
| constructor(message: string, details?: string) { | ||
| super(message, { details }) | ||
| } | ||
|
|
||
| static override is(value: unknown): value is GetDataSetStatsError { | ||
| return isSynapseError(value) && value.name === 'GetDataSetStatsError' | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| /** | ||
| * FilBeam API | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import * as FilBeam from '@filoz/synapse-core/filbeam' | ||
| * ``` | ||
| * | ||
| * @module filbeam | ||
| */ | ||
| export * from './stats.ts' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /** | ||
| * FilBeam Stats API | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { getDataSetStats } from '@filoz/synapse-core/filbeam' | ||
| * ``` | ||
| * | ||
| * @module filbeam | ||
| */ | ||
|
|
||
| import { HttpError, request } from 'iso-web/http' | ||
| import { GetDataSetStatsError } from '../errors/filbeam.ts' | ||
|
|
||
| /** | ||
| * Data set statistics from FilBeam. | ||
| * | ||
| * These quotas represent the remaining pay-per-byte allocation available for data retrieval | ||
| * through FilBeam's trusted measurement layer. The values decrease as data is served and | ||
| * represent how many bytes can still be retrieved before needing to add more credits. | ||
| */ | ||
| export interface DataSetStats { | ||
| /** The remaining CDN egress quota for cache hits (data served directly from FilBeam's cache) in bytes */ | ||
| cdnEgressQuota: bigint | ||
| /** The remaining egress quota for cache misses (data retrieved from storage providers) in bytes */ | ||
| cacheMissEgressQuota: bigint | ||
| } | ||
|
|
||
| /** | ||
| * Get the base stats URL for a given chain ID | ||
| */ | ||
| export function getStatsBaseUrl(chainId: number): string { | ||
| return chainId === 314 ? 'https://stats.filbeam.com' : 'https://calibration.stats.filbeam.com' | ||
| } | ||
|
|
||
| /** | ||
| * Validates that a string can be converted to a valid BigInt | ||
| */ | ||
| function parseBigInt(value: string, fieldName: string): bigint { | ||
| // Check if the string is a valid integer format (optional minus sign followed by digits) | ||
| if (!/^-?\d+$/.test(value)) { | ||
| throw new GetDataSetStatsError('Invalid response format', `${fieldName} is not a valid integer: "${value}"`) | ||
| } | ||
| return BigInt(value) | ||
| } | ||
|
|
||
| /** | ||
| * Validates the response from FilBeam stats API and returns DataSetStats | ||
| */ | ||
| export function validateStatsResponse(data: unknown): DataSetStats { | ||
| if (typeof data !== 'object' || data === null) { | ||
| throw new GetDataSetStatsError('Invalid response format', 'Response is not an object') | ||
| } | ||
|
|
||
| const response = data as Record<string, unknown> | ||
|
|
||
| if (typeof response.cdnEgressQuota !== 'string') { | ||
| throw new GetDataSetStatsError('Invalid response format', 'cdnEgressQuota must be a string') | ||
| } | ||
|
|
||
| if (typeof response.cacheMissEgressQuota !== 'string') { | ||
| throw new GetDataSetStatsError('Invalid response format', 'cacheMissEgressQuota must be a string') | ||
| } | ||
|
|
||
| return { | ||
| cdnEgressQuota: parseBigInt(response.cdnEgressQuota, 'cdnEgressQuota'), | ||
| cacheMissEgressQuota: parseBigInt(response.cacheMissEgressQuota, 'cacheMissEgressQuota'), | ||
| } | ||
| } | ||
|
|
||
| export interface GetDataSetStatsOptions { | ||
| /** The chain ID (314 for mainnet, 314159 for calibration) */ | ||
| chainId: number | ||
| /** The data set ID to query */ | ||
| dataSetId: bigint | number | string | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves remaining pay-per-byte statistics for a specific data set from FilBeam. | ||
| * | ||
| * Fetches the remaining CDN and cache miss egress quotas for a data set. These quotas | ||
| * track how many bytes can still be retrieved through FilBeam's trusted measurement layer | ||
| * before needing to add more credits: | ||
| * | ||
| * - **CDN Egress Quota**: Remaining bytes that can be served from FilBeam's cache (fast, direct delivery) | ||
| * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) | ||
| * | ||
| * @param options - The options for fetching data set stats | ||
| * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values | ||
| * | ||
| * @throws {GetDataSetStatsError} If the data set is not found, the API returns an invalid response, or network errors occur | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const stats = await getDataSetStats({ chainId: 314, dataSetId: 12345n }) | ||
| * console.log(`Remaining CDN Egress: ${stats.cdnEgressQuota} bytes`) | ||
| * console.log(`Remaining Cache Miss: ${stats.cacheMissEgressQuota} bytes`) | ||
| * ``` | ||
| */ | ||
| export async function getDataSetStats(options: GetDataSetStatsOptions): Promise<DataSetStats> { | ||
| const baseUrl = getStatsBaseUrl(options.chainId) | ||
| const url = `${baseUrl}/data-set/${options.dataSetId}` | ||
|
|
||
| const response = await request.json.get<unknown>(url) | ||
|
|
||
| if (response.error) { | ||
| if (HttpError.is(response.error)) { | ||
| const status = response.error.response.status | ||
| if (status === 404) { | ||
| throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`) | ||
| } | ||
| const errorText = await response.error.response.text().catch(() => 'Unknown error') | ||
| throw new GetDataSetStatsError( | ||
| `Failed to fetch data set stats`, | ||
| `HTTP ${status} ${response.error.response.statusText}: ${errorText}` | ||
| ) | ||
| } | ||
| throw response.error | ||
| } | ||
|
|
||
| return validateStatsResponse(response.result) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import assert from 'assert' | ||
| import { getStatsBaseUrl, validateStatsResponse } from '../src/filbeam/stats.ts' | ||
|
|
||
| function isInvalidResponseFormatError(err: unknown): boolean { | ||
| if (!(err instanceof Error)) return false | ||
| if (err.name !== 'GetDataSetStatsError') return false | ||
| if (!err.message.includes('Invalid response format')) return false | ||
| return true | ||
| } | ||
|
|
||
| describe('FilBeam Stats', () => { | ||
| describe('getStatsBaseUrl', () => { | ||
| it('should return mainnet URL for chainId 314', () => { | ||
| assert.equal(getStatsBaseUrl(314), 'https://stats.filbeam.com') | ||
| }) | ||
|
|
||
| it('should return calibration URL for chainId 314159', () => { | ||
| assert.equal(getStatsBaseUrl(314159), 'https://calibration.stats.filbeam.com') | ||
| }) | ||
|
|
||
| it('should return calibration URL for unknown chain IDs', () => { | ||
| assert.equal(getStatsBaseUrl(1), 'https://calibration.stats.filbeam.com') | ||
| assert.equal(getStatsBaseUrl(0), 'https://calibration.stats.filbeam.com') | ||
| }) | ||
| }) | ||
|
|
||
| describe('validateStatsResponse', () => { | ||
| it('should return valid DataSetStats for correct input', () => { | ||
| const result = validateStatsResponse({ | ||
| cdnEgressQuota: '1000000', | ||
| cacheMissEgressQuota: '500000', | ||
| }) | ||
| assert.deepStrictEqual(result, { cdnEgressQuota: 1000000n, cacheMissEgressQuota: 500000n }) | ||
| }) | ||
|
|
||
| it('should handle large number strings', () => { | ||
| const result = validateStatsResponse({ | ||
| cdnEgressQuota: '9999999999999999999999999999', | ||
| cacheMissEgressQuota: '1234567890123456789012345678901234567890', | ||
| }) | ||
| assert.deepStrictEqual(result, { | ||
| cdnEgressQuota: 9999999999999999999999999999n, | ||
| cacheMissEgressQuota: 1234567890123456789012345678901234567890n, | ||
| }) | ||
| }) | ||
|
|
||
| it('should handle zero values', () => { | ||
| const result = validateStatsResponse({ | ||
| cdnEgressQuota: '0', | ||
| cacheMissEgressQuota: '0', | ||
| }) | ||
| assert.deepStrictEqual(result, { cdnEgressQuota: 0n, cacheMissEgressQuota: 0n }) | ||
| }) | ||
|
|
||
| const invalidInputCases: Record<string, unknown> = { | ||
| 'response is null': null, | ||
| 'response is a string': 'string', | ||
| 'response is an array': [1, 2, 3], | ||
| 'response is a number': 123, | ||
| 'cdnEgressQuota is missing': { cacheMissEgressQuota: '1000' }, | ||
| 'cacheMissEgressQuota is missing': { cdnEgressQuota: '1000' }, | ||
| 'cdnEgressQuota is a number': { cdnEgressQuota: 1000, cacheMissEgressQuota: '500' }, | ||
| 'cdnEgressQuota is an object': { cdnEgressQuota: { value: 1000 }, cacheMissEgressQuota: '500' }, | ||
| 'cdnEgressQuota is a decimal': { cdnEgressQuota: '12.5', cacheMissEgressQuota: '500' }, | ||
| 'cdnEgressQuota is non-numeric': { cdnEgressQuota: 'abc', cacheMissEgressQuota: '500' }, | ||
| 'cacheMissEgressQuota is a number': { cdnEgressQuota: '1000', cacheMissEgressQuota: 500 }, | ||
| 'cacheMissEgressQuota is scientific notation': { cdnEgressQuota: '1000', cacheMissEgressQuota: '1e10' }, | ||
| 'cacheMissEgressQuota is empty string': { cdnEgressQuota: '1000', cacheMissEgressQuota: '' }, | ||
| } | ||
| for (const [name, input] of Object.entries(invalidInputCases)) { | ||
| it(`should throw error when ${name}`, () => { | ||
| assert.throws(() => validateStatsResponse(input), isInvalidResponseFormatError) | ||
| }) | ||
| } | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we find a better name for this hook? E.g.
useFetchEgressQuotas?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is a fetch egress quota? I don't think I've heard the term before
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The proposed name is a combination of the "use" prefix, followed by a verb ("to fetch"), followed by the object ("egress quota").
Similarly to how `useDeletePiece" is a combination of the "use" prefix, the verb "to delete", and the object "piece".
How about
useGetEgressQuotas- is that easier to understand?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see! I thought this was about a type of egress quota. I don't think we need a verb here, like there's also
useState, and notuseSetAndRetrieveState. I'd sayuseDeletePieceis a bit odd, but there the verb makes sense, since it's only one particular piece action.