-
Notifications
You must be signed in to change notification settings - Fork 611
feat: Eng 1849 track spent credits #3798
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
Changes from all commits
75fc6bd
fb0a944
29fb440
1da9d5e
7be0410
fb3edeb
e07ad6a
d25729c
bc6254f
cada511
68944cf
0cb1148
f73acd8
29708d8
0769b40
06bb283
f5a4eb5
9ee41f3
e947742
ba4e514
7e534ce
c65ded2
b89664f
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 |
|---|---|---|
|
|
@@ -15,12 +15,14 @@ type ApiRequestV1 struct { | |
| } | ||
|
|
||
| type KeyVerificationRequestV1 struct { | ||
| RequestID string `ch:"request_id"` | ||
| Time int64 `ch:"time"` | ||
| WorkspaceID string `ch:"workspace_id"` | ||
| KeySpaceID string `ch:"key_space_id"` | ||
| KeyID string `ch:"key_id"` | ||
| Region string `ch:"region"` | ||
| Outcome string `ch:"outcome"` | ||
| IdentityID string `ch:"identity_id"` | ||
| RequestID string `ch:"request_id"` | ||
| Time int64 `ch:"time"` | ||
| WorkspaceID string `ch:"workspace_id"` | ||
| KeySpaceID string `ch:"key_space_id"` | ||
| KeyID string `ch:"key_id"` | ||
| Region string `ch:"region"` | ||
| Outcome string `ch:"outcome"` | ||
| IdentityID string `ch:"identity_id"` | ||
| SpentCredits int64 `ch:"spent_credits"` | ||
| Tags []string `ch:"tags"` | ||
| } | ||
|
Comment on lines
+18
to
28
Contributor
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. 🧹 Nitpick (assertive) 🛠️ Refactor suggestion Add upstream tag normalization before insertion Adding Tags []string is good, but duplicates and order variance will fragment aggregated tables. Normalize before writing: unique + stable sort. Example helper: // CanonicalizeTags returns a sorted, de-duplicated copy.
func CanonicalizeTags(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, t := range in {
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
sort.Strings(out)
return out
}Ensure the writer populating KeyVerificationRequestV1 uses Tags: CanonicalizeTags(tags). This complements the DB-side MV normalization and keeps raw writes tidy. Would you like me to locate the writer(s) and open a follow-up patch? 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,7 @@ type NotFoundResponse = { | |
| api?: never; | ||
| ratelimit?: never; | ||
| remaining?: never; | ||
| spentCredits?: number; | ||
| }; | ||
|
Comment on lines
+59
to
60
Contributor
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. 🧹 Nitpick (assertive) Consider making spentCredits required in all API responses You already populate spentCredits on every code path (0 or cost). Making it non-optional in the types tightens the contract and simplifies consumers. Apply this diff: - spentCredits?: number;
+ spentCredits: number;Repeat for NotFoundResponse, InvalidResponse, and ValidResponse. Also applies to: 88-89, 114-115 🤖 Prompt for AI Agents |
||
|
|
||
| type InvalidResponse = { | ||
|
|
@@ -84,6 +85,7 @@ type InvalidResponse = { | |
| permissions: string[]; | ||
| roles: string[]; | ||
| message?: string; | ||
| spentCredits?: number; | ||
| }; | ||
|
|
||
| type ValidResponse = { | ||
|
|
@@ -109,6 +111,7 @@ type ValidResponse = { | |
| authorizedWorkspaceId: string; | ||
| permissions: string[]; | ||
| roles: string[]; | ||
| spentCredits?: number; | ||
| }; | ||
| type VerifyKeyResult = NotFoundResponse | InvalidResponse | ValidResponse; | ||
|
|
||
|
|
@@ -396,7 +399,7 @@ export class KeyService { | |
| } | ||
|
|
||
| if (!data) { | ||
| return Ok({ valid: false, code: "NOT_FOUND" }); | ||
| return Ok({ valid: false, code: "NOT_FOUND", spentCredits: 0 }); | ||
| } | ||
|
|
||
| // Quick fix | ||
|
|
@@ -436,6 +439,7 @@ export class KeyService { | |
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| message: "the key is disabled", | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -449,6 +453,7 @@ export class KeyService { | |
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| message: `the key does not belong to ${req.apiId}`, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -469,6 +474,7 @@ export class KeyService { | |
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| message: `the key has expired on ${new Date(expires).toISOString()}`, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -485,6 +491,7 @@ export class KeyService { | |
| code: "FORBIDDEN", | ||
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -498,6 +505,7 @@ export class KeyService { | |
| code: "FORBIDDEN", | ||
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -542,6 +550,7 @@ export class KeyService { | |
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| message: rbacResp.val.message, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -612,10 +621,13 @@ export class KeyService { | |
| ratelimit, | ||
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| spentCredits: 0, | ||
| }); | ||
| } | ||
|
|
||
| let remaining: number | undefined = undefined; | ||
| let spentCredits = 0; | ||
|
|
||
| if (data.key.remaining !== null) { | ||
| const t0 = performance.now(); | ||
| const cost = req.remaining?.cost ?? DEFAULT_REMAINING_COST; | ||
|
|
@@ -636,7 +648,10 @@ export class KeyService { | |
| }); | ||
|
|
||
| remaining = limited.remaining; | ||
| if (!limited.valid) { | ||
| if (limited.valid) { | ||
| // Credits were successfully spent | ||
| spentCredits = cost; | ||
| } else { | ||
| return Ok({ | ||
| key: data.key, | ||
| api: data.api, | ||
|
|
@@ -653,6 +668,7 @@ export class KeyService { | |
| authorizedWorkspaceId: data.key.forWorkspaceId ?? data.key.workspaceId, | ||
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| spentCredits: 0, // No credits spent if usage exceeded | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -672,6 +688,7 @@ export class KeyService { | |
| authorizedWorkspaceId: data.key.forWorkspaceId ?? data.key.workspaceId, | ||
| permissions: data.permissions, | ||
| roles: data.roles, | ||
| spentCredits, | ||
| }); | ||
|
Comment on lines
+691
to
692
Contributor
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. 🧹 Nitpick (assertive) VALID response returns spentCredits — consider also emitting it in metrics You already emit metric.credits.spent with cost/deducted earlier. Adding spentCredits to metric.key.verification (or another dedicated metric) could simplify downstream observability. If you decide to do it, append spentCredits to the metrics.emit call near Lines 188-195. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,149 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; | ||||||||||||||||||||||||||||||||||||||||||
| import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; | ||||||||||||||||||||||||||||||||||||||||||
| import { trpc } from "@/lib/trpc/client"; | ||||||||||||||||||||||||||||||||||||||||||
| import { useQueryTime } from "@/providers/query-time-provider"; | ||||||||||||||||||||||||||||||||||||||||||
| import { useMemo } from "react"; | ||||||||||||||||||||||||||||||||||||||||||
| import { keysOverviewFilterFieldConfig } from "../../../../filters.schema"; | ||||||||||||||||||||||||||||||||||||||||||
| import { useFilters } from "../../../../hooks/use-filters"; | ||||||||||||||||||||||||||||||||||||||||||
| import type { KeysOverviewQueryTimeseriesPayload } from "../../bar-chart/query-timeseries.schema"; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export const useFetchCreditSpendTimeseries = (apiId: string | null) => { | ||||||||||||||||||||||||||||||||||||||||||
| const { filters } = useFilters(); | ||||||||||||||||||||||||||||||||||||||||||
| const { queryTime: timestamp } = useQueryTime(); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const queryParams = useMemo(() => { | ||||||||||||||||||||||||||||||||||||||||||
| const params: KeysOverviewQueryTimeseriesPayload = { | ||||||||||||||||||||||||||||||||||||||||||
| startTime: timestamp - HISTORICAL_DATA_WINDOW, | ||||||||||||||||||||||||||||||||||||||||||
| endTime: timestamp, | ||||||||||||||||||||||||||||||||||||||||||
| keyIds: { filters: [] }, | ||||||||||||||||||||||||||||||||||||||||||
| outcomes: { filters: [] }, | ||||||||||||||||||||||||||||||||||||||||||
| names: { filters: [] }, | ||||||||||||||||||||||||||||||||||||||||||
| identities: { filters: [] }, | ||||||||||||||||||||||||||||||||||||||||||
| tags: null, | ||||||||||||||||||||||||||||||||||||||||||
| apiId: apiId ?? "", | ||||||||||||||||||||||||||||||||||||||||||
| since: "", | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!apiId) { | ||||||||||||||||||||||||||||||||||||||||||
| return params; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| filters.forEach((filter) => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!(filter.field in keysOverviewFilterFieldConfig)) { | ||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const fieldConfig = keysOverviewFilterFieldConfig[filter.field]; | ||||||||||||||||||||||||||||||||||||||||||
| const validOperators = fieldConfig.operators; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const operator = validOperators.includes(filter.operator) | ||||||||||||||||||||||||||||||||||||||||||
| ? filter.operator | ||||||||||||||||||||||||||||||||||||||||||
| : validOperators[0]; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| switch (filter.field) { | ||||||||||||||||||||||||||||||||||||||||||
| case "startTime": | ||||||||||||||||||||||||||||||||||||||||||
| case "endTime": { | ||||||||||||||||||||||||||||||||||||||||||
| const numValue = | ||||||||||||||||||||||||||||||||||||||||||
| typeof filter.value === "number" | ||||||||||||||||||||||||||||||||||||||||||
| ? filter.value | ||||||||||||||||||||||||||||||||||||||||||
| : typeof filter.value === "string" | ||||||||||||||||||||||||||||||||||||||||||
| ? Number(filter.value) | ||||||||||||||||||||||||||||||||||||||||||
| : Number.NaN; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isNaN(numValue)) { | ||||||||||||||||||||||||||||||||||||||||||
| params[filter.field] = numValue; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| case "since": { | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof filter.value === "string") { | ||||||||||||||||||||||||||||||||||||||||||
| params.since = filter.value; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| case "keyIds": { | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof filter.value === "string" && filter.value.trim()) { | ||||||||||||||||||||||||||||||||||||||||||
| const keyIdOperator = operator === "is" || operator === "contains" ? operator : "is"; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| params.keyIds?.filters?.push({ | ||||||||||||||||||||||||||||||||||||||||||
| operator: keyIdOperator, | ||||||||||||||||||||||||||||||||||||||||||
| value: filter.value, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+75
Contributor
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. 🧹 Nitpick (assertive) Normalize keyIds by trimming whitespace Prevents mismatched filters and duplicate cache keys from accidental spaces. - params.keyIds?.filters?.push({
+ params.keyIds?.filters?.push({
operator: keyIdOperator,
- value: filter.value,
+ value: filter.value.trim(),
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| case "names": | ||||||||||||||||||||||||||||||||||||||||||
| case "identities": { | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof filter.value === "string" && filter.value.trim()) { | ||||||||||||||||||||||||||||||||||||||||||
| params[filter.field]?.filters?.push({ | ||||||||||||||||||||||||||||||||||||||||||
| operator, | ||||||||||||||||||||||||||||||||||||||||||
| value: filter.value, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+78
to
+86
Contributor
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. 🧹 Nitpick (assertive) Normalize names and identities by trimming Same rationale as keyIds — normalize inputs early. - params[filter.field]?.filters?.push({
+ params[filter.field]?.filters?.push({
operator,
- value: filter.value,
+ value: filter.value.trim(),
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| case "outcomes": { | ||||||||||||||||||||||||||||||||||||||||||
| // For credit spend, we might want to include all outcomes to show credit consumption patterns | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof filter.value === "string") { | ||||||||||||||||||||||||||||||||||||||||||
| params.outcomes?.filters?.push({ | ||||||||||||||||||||||||||||||||||||||||||
| operator: "is", | ||||||||||||||||||||||||||||||||||||||||||
| value: filter.value as | ||||||||||||||||||||||||||||||||||||||||||
| | "VALID" | ||||||||||||||||||||||||||||||||||||||||||
| | "INSUFFICIENT_PERMISSIONS" | ||||||||||||||||||||||||||||||||||||||||||
| | "RATE_LIMITED" | ||||||||||||||||||||||||||||||||||||||||||
| | "FORBIDDEN" | ||||||||||||||||||||||||||||||||||||||||||
| | "DISABLED" | ||||||||||||||||||||||||||||||||||||||||||
| | "EXPIRED" | ||||||||||||||||||||||||||||||||||||||||||
| | "USAGE_EXCEEDED" | ||||||||||||||||||||||||||||||||||||||||||
| | "", | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+89
to
+105
Contributor
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. 💡 Verification agent 🧩 Analysis chainValidate outcomes against the allowed set to avoid invalid server filters Currently any string is pushed (including ""), which can create invalid or no‑op filters. Align with the allowed outcome set (same constant used elsewhere). +import { KEY_VERIFICATION_OUTCOMES } from "@unkey/clickhouse/src/keys/keys";
...
- case "outcomes": {
- // For credit spend, we might want to include all outcomes to show credit consumption patterns
- if (typeof filter.value === "string") {
- params.outcomes?.filters?.push({
- operator: "is",
- value: filter.value as
- | "VALID"
- | "INSUFFICIENT_PERMISSIONS"
- | "RATE_LIMITED"
- | "FORBIDDEN"
- | "DISABLED"
- | "EXPIRED"
- | "USAGE_EXCEEDED"
- | "",
- });
- }
- break;
- }
+ case "outcomes": {
+ if (typeof filter.value === "string" && filter.value) {
+ type ValidOutcome = (typeof KEY_VERIFICATION_OUTCOMES)[number];
+ if (KEY_VERIFICATION_OUTCOMES.includes(filter.value as ValidOutcome)) {
+ params.outcomes?.filters?.push({
+ operator: "is",
+ value: filter.value as ValidOutcome,
+ });
+ }
+ }
+ break;
+ }Please confirm that the import path for KEY_VERIFICATION_OUTCOMES is correct in this package. If not, point me to the shared constant for outcomes and I’ll update the patch. 🏁 Script executed: #!/bin/bash
# Search for the declaration or usages of KEY_VERIFICATION_OUTCOMES in the repository
rg -n "KEY_VERIFICATION_OUTCOMES"Length of output: 8391 🏁 Script executed: #!/bin/bash
# Display the top lines of the target file to inspect existing imports
head -n 20 apps/dashboard/app/\(app\)/apis/\[apiId\]/_overview/components/charts/credit-spend-chart/hooks/use-fetch-timeseries.tsLength of output: 1093 Validate outcomes against the canonical set The import path for • Add the import at the top of import { KEY_VERIFICATION_OUTCOMES } from "@unkey/clickhouse/src/keys/keys";• Replace the existing case "outcomes": {
- // For credit spend, we might want to include all outcomes to show credit consumption patterns
- if (typeof filter.value === "string") {
- params.outcomes?.filters?.push({
- operator: "is",
- value: filter.value as
- | "VALID"
- | "INSUFFICIENT_PERMISSIONS"
- | "RATE_LIMITED"
- | "FORBIDDEN"
- | "DISABLED"
- | "EXPIRED"
- | "USAGE_EXCEEDED"
- | "",
- });
- }
+ if (typeof filter.value === "string" && filter.value) {
+ type ValidOutcome = (typeof KEY_VERIFICATION_OUTCOMES)[number];
+ if (KEY_VERIFICATION_OUTCOMES.includes(filter.value as ValidOutcome)) {
+ params.outcomes?.filters?.push({
+ operator: "is",
+ value: filter.value as ValidOutcome,
+ });
+ }
+ }
break;
}This ensures only legitimate outcomes (and no empty strings) are sent to the server. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| case "tags": { | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof filter.value === "string" && filter.value.trim()) { | ||||||||||||||||||||||||||||||||||||||||||
| params.tags = { | ||||||||||||||||||||||||||||||||||||||||||
| operator, | ||||||||||||||||||||||||||||||||||||||||||
| value: filter.value, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+108
to
+114
Contributor
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. 🧹 Nitpick (assertive) Trim tag values to avoid backend mismatches Keep consistent with other filters; send trimmed values only. - params.tags = {
+ params.tags = {
operator,
- value: filter.value,
+ value: filter.value.trim(),
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return params; | ||||||||||||||||||||||||||||||||||||||||||
| }, [filters, timestamp, apiId]); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const { data, isLoading, isError } = trpc.api.keys.timeseries.useQuery(queryParams, { | ||||||||||||||||||||||||||||||||||||||||||
| refetchInterval: queryParams.endTime === timestamp ? 10_000 : false, | ||||||||||||||||||||||||||||||||||||||||||
| enabled: Boolean(apiId), | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const timeseries = useMemo(() => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!data?.timeseries) { | ||||||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return data.timeseries.map((ts) => { | ||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||
| displayX: formatTimestampForChart(ts.x, data.granularity), | ||||||||||||||||||||||||||||||||||||||||||
| originalTimestamp: ts.x, | ||||||||||||||||||||||||||||||||||||||||||
| spent_credits: ts.y.spent_credits ?? 0, | ||||||||||||||||||||||||||||||||||||||||||
| total: ts.y.spent_credits ?? 0, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| }, [data]); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||
| timeseries: timeseries || [], | ||||||||||||||||||||||||||||||||||||||||||
| isLoading, | ||||||||||||||||||||||||||||||||||||||||||
| isError, | ||||||||||||||||||||||||||||||||||||||||||
| granularity: data?.granularity, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.