From 24413b5df22dd2937cae0af0d36079bd04ae8bf6 Mon Sep 17 00:00:00 2001 From: michaeljguarino Date: Fri, 20 Dec 2024 23:08:53 -0500 Subject: [PATCH] Final pre-release touches (#1705) --- .../ClusterUsagesTableCols.tsx | 45 +++++++++++ .../cost-management/CostManagement.tsx | 9 ++- .../CostManagementDetailsNamespaces.tsx | 7 +- assets/src/generated/graphql.ts | 59 ++++++++++++++- assets/src/graph/costManagement.graphql | 5 ++ config/prod.exs | 2 + go/client/models_gen.go | 35 +++++++++ lib/console/cost/cron.ex | 27 +++++++ lib/console/cost/ingester.ex | 2 +- lib/console/graphql/deployments/cluster.ex | 61 +++++++++++---- .../graphql/resolvers/deployments/cluster.ex | 7 ++ lib/console/schema/cluster_namespace_usage.ex | 2 + lib/console/schema/cluster_usage.ex | 6 ++ lib/console/schema/cluster_usage_history.ex | 75 +++++++++++++++++++ .../20241220191210_add_storage_cost.exs | 13 ++++ .../20241220202012_cluster_usage_history.exs | 36 +++++++++ schema/schema.graphql | 41 ++++++++++ test/console/cost/cron_test.exs | 35 +++++++++ .../deployments/cluster_queries_test.exs | 11 +++ test/support/factory.ex | 11 +++ 20 files changed, 464 insertions(+), 25 deletions(-) create mode 100644 lib/console/cost/cron.ex create mode 100644 lib/console/schema/cluster_usage_history.ex create mode 100644 priv/repo/migrations/20241220191210_add_storage_cost.exs create mode 100644 priv/repo/migrations/20241220202012_cluster_usage_history.exs create mode 100644 test/console/cost/cron_test.exs diff --git a/assets/src/components/cost-management/ClusterUsagesTableCols.tsx b/assets/src/components/cost-management/ClusterUsagesTableCols.tsx index 2fe03d9bc..635e40448 100644 --- a/assets/src/components/cost-management/ClusterUsagesTableCols.tsx +++ b/assets/src/components/cost-management/ClusterUsagesTableCols.tsx @@ -15,6 +15,8 @@ const columnHelper = createColumnHelper< ClusterUsageTinyFragment | ClusterNamespaceUsageFragment >() +const dollarize = (cost) => (cost ? `$${cost.toFixed(3)}` : '--') + export const ColCluster = ( columnHelper as ColumnHelper ).accessor(({ cluster }) => cluster?.name, { @@ -82,6 +84,49 @@ export const ColCpuCost = columnHelper.accessor(({ cpuCost }) => cpuCost, { }, }) +export const ColStorageCost = columnHelper.accessor(({ storage }) => storage, { + id: 'storageCost', + header: 'Storage cost', + cell: function Cell({ getValue }) { + const storage = getValue() + + return {dollarize(storage)} + }, +}) + +export const ColLoadBalancerCost = columnHelper.accessor( + ({ loadBalancerCost }) => loadBalancerCost, + { + id: 'loadBalancerCost', + header: 'Load balancer cost', + cell: function Cell({ getValue }) { + const loadBalancerCost = getValue() + + return ( + {dollarize(loadBalancerCost)} + ) + }, + } +) + +export const ColNetworkCost = columnHelper.accessor( + ({ ingressCost, egressCost }) => + (ingressCost || egressCost) && { ingressCost, egressCost }, + { + id: 'networkCost', + header: 'Network cost', + cell: function Cell({ row: { original } }) { + const { ingressCost, egressCost } = original + + return ( + + {dollarize(ingressCost)} / {dollarize(egressCost)} + + ) + }, + } +) + export const ColCpuEfficiency = columnHelper.accessor( (usage) => { const efficiency = (usage.cpuUtil ?? NaN) / (usage.cpu ?? NaN) diff --git a/assets/src/components/cost-management/CostManagement.tsx b/assets/src/components/cost-management/CostManagement.tsx index 7b1d46c72..2d781cd74 100644 --- a/assets/src/components/cost-management/CostManagement.tsx +++ b/assets/src/components/cost-management/CostManagement.tsx @@ -31,9 +31,10 @@ import { ColCluster, ColCpuCost, ColCpuEfficiency, + ColLoadBalancerCost, ColMemoryCost, ColMemoryEfficiency, - ColNodeCost, + ColNetworkCost, } from './ClusterUsagesTableCols' import { CostManagementTreeMap, @@ -183,10 +184,12 @@ const WrapperSC = styled.div(({ theme }) => ({ const cols = [ ColCluster, - ColNodeCost, ColCpuCost, - ColCpuEfficiency, ColMemoryCost, + // ColStorageCost, + ColLoadBalancerCost, + ColNetworkCost, ColMemoryEfficiency, + ColCpuEfficiency, ColActions, ] diff --git a/assets/src/components/cost-management/details/CostManagementDetailsNamespaces.tsx b/assets/src/components/cost-management/details/CostManagementDetailsNamespaces.tsx index b9318a440..052287997 100644 --- a/assets/src/components/cost-management/details/CostManagementDetailsNamespaces.tsx +++ b/assets/src/components/cost-management/details/CostManagementDetailsNamespaces.tsx @@ -21,9 +21,11 @@ import { useTheme } from 'styled-components' import { ColCpuCost, ColCpuEfficiency, + ColLoadBalancerCost, ColMemoryCost, ColMemoryEfficiency, ColNamespace, + ColNetworkCost, } from '../ClusterUsagesTableCols' import { CM_TREE_MAP_CARD_HEIGHT } from '../CostManagement' import { @@ -157,7 +159,10 @@ export function CostManagementDetailsNamespaces() { const cols = [ ColNamespace, ColCpuCost, - ColCpuEfficiency, ColMemoryCost, + // ColStorageCost, + ColLoadBalancerCost, + ColNetworkCost, + ColCpuEfficiency, ColMemoryEfficiency, ] diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 5c2db1670..49ac7258c 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1391,6 +1391,9 @@ export type ClusterNamespaceUsage = { memory?: Maybe; memoryCost?: Maybe; namespace?: Maybe; + /** the amount of storage used by this namespace */ + storage?: Maybe; + storageCost?: Maybe; updatedAt?: Maybe; }; @@ -1658,6 +1661,7 @@ export type ClusterUsage = { egressCost?: Maybe; gpu?: Maybe; gpuCost?: Maybe; + history?: Maybe; id: Scalars['ID']['output']; ingressCost?: Maybe; insertedAt?: Maybe; @@ -1669,10 +1673,21 @@ export type ClusterUsage = { namespaces?: Maybe; nodeCost?: Maybe; recommendations?: Maybe; + /** the amount of storage used by this cluster */ + storage?: Maybe; + storageCost?: Maybe; updatedAt?: Maybe; }; +export type ClusterUsageHistoryArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + + export type ClusterUsageNamespacesArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1703,6 +1718,36 @@ export type ClusterUsageEdge = { node?: Maybe; }; +export type ClusterUsageHistory = { + __typename?: 'ClusterUsageHistory'; + cluster?: Maybe; + controlPlaneCost?: Maybe; + cpuCost?: Maybe; + egressCost?: Maybe; + gpuCost?: Maybe; + id: Scalars['ID']['output']; + ingressCost?: Maybe; + insertedAt?: Maybe; + loadBalancerCost?: Maybe; + memoryCost?: Maybe; + nodeCost?: Maybe; + storageCost?: Maybe; + timestamp: Scalars['DateTime']['output']; + updatedAt?: Maybe; +}; + +export type ClusterUsageHistoryConnection = { + __typename?: 'ClusterUsageHistoryConnection'; + edges?: Maybe>>; + pageInfo: PageInfo; +}; + +export type ClusterUsageHistoryEdge = { + __typename?: 'ClusterUsageHistoryEdge'; + cursor?: Maybe; + node?: Maybe; +}; + export type Command = { __typename?: 'Command'; build?: Maybe; @@ -2070,6 +2115,7 @@ export type CostAttributes = { namespace?: InputMaybe; nodeCost?: InputMaybe; storage?: InputMaybe; + storageCost?: InputMaybe; }; export type CostIngestAttributes = { @@ -11412,9 +11458,9 @@ export type ComponentTreeQueryVariables = Exact<{ export type ComponentTreeQuery = { __typename?: 'RootQueryType', componentTree?: { __typename?: 'ComponentTree', root?: { __typename?: 'KubernetesUnstructured', raw?: Record | null, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null, edges?: Array<{ __typename?: 'ResourceEdge', from: string, to: string } | null> | null, certificates?: Array<{ __typename?: 'Certificate', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, configmaps?: Array<{ __typename?: 'ConfigMap', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, cronjobs?: Array<{ __typename?: 'CronJob', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, daemonsets?: Array<{ __typename?: 'DaemonSet', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, deployments?: Array<{ __typename?: 'Deployment', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, ingresses?: Array<{ __typename?: 'Ingress', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, secrets?: Array<{ __typename?: 'Secret', metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, services?: Array<{ __typename?: 'Service', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null, statefulsets?: Array<{ __typename?: 'StatefulSet', raw: string, metadata: { __typename?: 'Metadata', uid?: string | null, name: string, namespace?: string | null, creationTimestamp?: string | null, labels?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null, annotations?: Array<{ __typename?: 'LabelPair', name?: string | null, value?: string | null } | null> | null } } | null> | null } | null }; -export type ClusterUsageTinyFragment = { __typename?: 'ClusterUsage', id: string, cpu?: number | null, memory?: number | null, gpu?: number | null, cpuUtil?: number | null, memUtil?: number | null, cpuCost?: number | null, memoryCost?: number | null, nodeCost?: number | null, controlPlaneCost?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, project?: { __typename?: 'Project', id: string, name: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null }; +export type ClusterUsageTinyFragment = { __typename?: 'ClusterUsage', id: string, cpu?: number | null, memory?: number | null, gpu?: number | null, storage?: number | null, cpuUtil?: number | null, memUtil?: number | null, cpuCost?: number | null, memoryCost?: number | null, nodeCost?: number | null, controlPlaneCost?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, project?: { __typename?: 'Project', id: string, name: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null }; -export type ClusterNamespaceUsageFragment = { __typename?: 'ClusterNamespaceUsage', id: string, namespace?: string | null, cpuCost?: number | null, cpuUtil?: number | null, cpu?: number | null, memoryCost?: number | null, memUtil?: number | null, memory?: number | null }; +export type ClusterNamespaceUsageFragment = { __typename?: 'ClusterNamespaceUsage', id: string, namespace?: string | null, storage?: number | null, cpuCost?: number | null, cpuUtil?: number | null, cpu?: number | null, memoryCost?: number | null, memUtil?: number | null, memory?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null }; export type ClusterScalingRecommendationFragment = { __typename?: 'ClusterScalingRecommendation', id: string, namespace?: string | null, name?: string | null, container?: string | null, cpuCost?: number | null, cpuRequest?: number | null, cpuRecommendation?: number | null, memoryCost?: number | null, memoryRequest?: number | null, memoryRecommendation?: number | null, type?: ScalingRecommendationType | null }; @@ -11429,7 +11475,7 @@ export type ClusterUsagesQueryVariables = Exact<{ }>; -export type ClusterUsagesQuery = { __typename?: 'RootQueryType', clusterUsages?: { __typename?: 'ClusterUsageConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterUsageEdge', node?: { __typename?: 'ClusterUsage', id: string, cpu?: number | null, memory?: number | null, gpu?: number | null, cpuUtil?: number | null, memUtil?: number | null, cpuCost?: number | null, memoryCost?: number | null, nodeCost?: number | null, controlPlaneCost?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, project?: { __typename?: 'Project', id: string, name: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null } | null> | null } | null }; +export type ClusterUsagesQuery = { __typename?: 'RootQueryType', clusterUsages?: { __typename?: 'ClusterUsageConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterUsageEdge', node?: { __typename?: 'ClusterUsage', id: string, cpu?: number | null, memory?: number | null, gpu?: number | null, storage?: number | null, cpuUtil?: number | null, memUtil?: number | null, cpuCost?: number | null, memoryCost?: number | null, nodeCost?: number | null, controlPlaneCost?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, project?: { __typename?: 'Project', id: string, name: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null } | null> | null } | null }; export type ClusterUsageNamespacesQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -11441,7 +11487,7 @@ export type ClusterUsageNamespacesQueryVariables = Exact<{ }>; -export type ClusterUsageNamespacesQuery = { __typename?: 'RootQueryType', clusterUsage?: { __typename?: 'ClusterUsage', id: string, cluster?: { __typename?: 'Cluster', id: string, name: string } | null, namespaces?: { __typename?: 'ClusterNamespaceUsageConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterNamespaceUsageEdge', node?: { __typename?: 'ClusterNamespaceUsage', id: string, namespace?: string | null, cpuCost?: number | null, cpuUtil?: number | null, cpu?: number | null, memoryCost?: number | null, memUtil?: number | null, memory?: number | null } | null } | null> | null } | null } | null }; +export type ClusterUsageNamespacesQuery = { __typename?: 'RootQueryType', clusterUsage?: { __typename?: 'ClusterUsage', id: string, cluster?: { __typename?: 'Cluster', id: string, name: string } | null, namespaces?: { __typename?: 'ClusterNamespaceUsageConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterNamespaceUsageEdge', node?: { __typename?: 'ClusterNamespaceUsage', id: string, namespace?: string | null, storage?: number | null, cpuCost?: number | null, cpuUtil?: number | null, cpu?: number | null, memoryCost?: number | null, memUtil?: number | null, memory?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null } | null } | null> | null } | null } | null }; export type ClusterUsageScalingRecommendationsQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -14206,6 +14252,7 @@ export const ClusterUsageTinyFragmentDoc = gql` cpu memory gpu + storage cpuUtil memUtil cpuCost @@ -14228,12 +14275,16 @@ export const ClusterNamespaceUsageFragmentDoc = gql` fragment ClusterNamespaceUsage on ClusterNamespaceUsage { id namespace + storage cpuCost cpuUtil cpu memoryCost memUtil memory + ingressCost + loadBalancerCost + egressCost } `; export const ClusterScalingRecommendationFragmentDoc = gql` diff --git a/assets/src/graph/costManagement.graphql b/assets/src/graph/costManagement.graphql index 50f1fb84f..4a49cd1df 100644 --- a/assets/src/graph/costManagement.graphql +++ b/assets/src/graph/costManagement.graphql @@ -3,6 +3,7 @@ fragment ClusterUsageTiny on ClusterUsage { cpu memory gpu + storage cpuUtil memUtil cpuCost @@ -24,12 +25,16 @@ fragment ClusterUsageTiny on ClusterUsage { fragment ClusterNamespaceUsage on ClusterNamespaceUsage { id namespace + storage cpuCost cpuUtil cpu memoryCost memUtil memory + ingressCost + loadBalancerCost + egressCost } fragment ClusterScalingRecommendation on ClusterScalingRecommendation { diff --git a/config/prod.exs b/config/prod.exs index db7b68cdb..d9527cbcd 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -65,6 +65,8 @@ config :console, Console.Cron.Scheduler, {"@daily", {Console.Cron.Jobs, :prune_alerts, []}}, {"@daily", {Console.AI.Cron, :trim, []}}, {"@daily", {Console.AI.Cron, :trim_threads, []}}, + {"@daily", {Console.Cost.Cron, :history, []}}, + {"@daily", {Console.Cost.Cron, :prune, []}}, {"0 0 * * 0", {Console.AI.Cron, :chats, []}} ] diff --git a/go/client/models_gen.go b/go/client/models_gen.go index 1c140606a..f4df421ca 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -1081,6 +1081,8 @@ type ClusterNamespaceUsage struct { CPU *float64 `json:"cpu,omitempty"` Memory *float64 `json:"memory,omitempty"` Gpu *float64 `json:"gpu,omitempty"` + // the amount of storage used by this namespace + Storage *float64 `json:"storage,omitempty"` // the amount of cpu utilized CPUUtil *float64 `json:"cpuUtil,omitempty"` // the amount of memory utilized @@ -1089,6 +1091,7 @@ type ClusterNamespaceUsage struct { MemoryCost *float64 `json:"memoryCost,omitempty"` GpuCost *float64 `json:"gpuCost,omitempty"` IngressCost *float64 `json:"ingressCost,omitempty"` + StorageCost *float64 `json:"storageCost,omitempty"` LoadBalancerCost *float64 `json:"loadBalancerCost,omitempty"` EgressCost *float64 `json:"egressCost,omitempty"` Cluster *Cluster `json:"cluster,omitempty"` @@ -1329,6 +1332,8 @@ type ClusterUsage struct { CPU *float64 `json:"cpu,omitempty"` Memory *float64 `json:"memory,omitempty"` Gpu *float64 `json:"gpu,omitempty"` + // the amount of storage used by this cluster + Storage *float64 `json:"storage,omitempty"` // the amount of cpu utilized CPUUtil *float64 `json:"cpuUtil,omitempty"` // the amount of memory utilized @@ -1340,10 +1345,12 @@ type ClusterUsage struct { LoadBalancerCost *float64 `json:"loadBalancerCost,omitempty"` EgressCost *float64 `json:"egressCost,omitempty"` NodeCost *float64 `json:"nodeCost,omitempty"` + StorageCost *float64 `json:"storageCost,omitempty"` ControlPlaneCost *float64 `json:"controlPlaneCost,omitempty"` Cluster *Cluster `json:"cluster,omitempty"` Namespaces *ClusterNamespaceUsageConnection `json:"namespaces,omitempty"` Recommendations *ClusterScalingRecommendationConnection `json:"recommendations,omitempty"` + History *ClusterUsageHistoryConnection `json:"history,omitempty"` InsertedAt *string `json:"insertedAt,omitempty"` UpdatedAt *string `json:"updatedAt,omitempty"` } @@ -1358,6 +1365,33 @@ type ClusterUsageEdge struct { Cursor *string `json:"cursor,omitempty"` } +type ClusterUsageHistory struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` + CPUCost *float64 `json:"cpuCost,omitempty"` + MemoryCost *float64 `json:"memoryCost,omitempty"` + GpuCost *float64 `json:"gpuCost,omitempty"` + IngressCost *float64 `json:"ingressCost,omitempty"` + LoadBalancerCost *float64 `json:"loadBalancerCost,omitempty"` + EgressCost *float64 `json:"egressCost,omitempty"` + NodeCost *float64 `json:"nodeCost,omitempty"` + StorageCost *float64 `json:"storageCost,omitempty"` + ControlPlaneCost *float64 `json:"controlPlaneCost,omitempty"` + Cluster *Cluster `json:"cluster,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` +} + +type ClusterUsageHistoryConnection struct { + PageInfo PageInfo `json:"pageInfo"` + Edges []*ClusterUsageHistoryEdge `json:"edges,omitempty"` +} + +type ClusterUsageHistoryEdge struct { + Node *ClusterUsageHistory `json:"node,omitempty"` + Cursor *string `json:"cursor,omitempty"` +} + type Command struct { ID string `json:"id"` Command string `json:"command"` @@ -1664,6 +1698,7 @@ type CostAttributes struct { EgressCost *float64 `json:"egressCost,omitempty"` NodeCost *float64 `json:"nodeCost,omitempty"` ControlPlaneCost *float64 `json:"controlPlaneCost,omitempty"` + StorageCost *float64 `json:"storageCost,omitempty"` } type CostIngestAttributes struct { diff --git a/lib/console/cost/cron.ex b/lib/console/cost/cron.ex new file mode 100644 index 000000000..bcb2808be --- /dev/null +++ b/lib/console/cost/cron.ex @@ -0,0 +1,27 @@ +defmodule Console.Cost.Cron do + import Console.Services.Base + import Console.Cost.Utils, only: [batch_insert: 2] + alias Console.Repo + alias Console.Schema.{ClusterUsage, ClusterUsageHistory} + + def history() do + timestamp = Timex.now() + |> DateTime.truncate(:second) + |> truncate() + + ClusterUsage + |> ClusterUsage.ordered(asc: :id) + |> Repo.stream(method: :keyset) + |> Stream.map(&Map.take(&1, ClusterUsageHistory.fields())) + |> Stream.map(×tamped/1) + |> Stream.map(&Map.put(&1, :timestamp, timestamp)) + |> batch_insert(ClusterUsageHistory) + end + + def prune() do + ClusterUsageHistory.expired() + |> Repo.delete_all() + end + + defp truncate(%DateTime{} = dt), do: %{dt | minute: 0, hour: 0, microsecond: {0, 6}, second: 0} +end diff --git a/lib/console/cost/ingester.ex b/lib/console/cost/ingester.ex index 3493a1fd5..77d1b6530 100644 --- a/lib/console/cost/ingester.ex +++ b/lib/console/cost/ingester.ex @@ -1,7 +1,7 @@ defmodule Console.Cost.Ingester do - alias Console.Repo import Console.Services.Base import Console.Cost.Utils, only: [batch_insert: 2] + alias Console.Repo alias Console.Deployments.Settings alias Console.Schema.{DeploymentSettings, Cluster, ClusterUsage, ClusterNamespaceUsage, ClusterScalingRecommendation} diff --git a/lib/console/graphql/deployments/cluster.ex b/lib/console/graphql/deployments/cluster.ex index 0732ddacf..adcfafb7c 100644 --- a/lib/console/graphql/deployments/cluster.ex +++ b/lib/console/graphql/deployments/cluster.ex @@ -245,22 +245,23 @@ defmodule Console.GraphQl.Deployments.Cluster do end input_object :cost_attributes do - field :namespace, :string, description: "leave null if cluster scoped" - field :memory, :float - field :cpu, :float - field :gpu, :float - field :storage, :float - field :memory_util, :float, description: "the historical memory utilization for this scope" - field :cpu_util, :float, description: "the historical cpu utilization for this scope" - field :gpu_util, :float, description: "the historical gpu utilization for this scope" - field :cpu_cost, :float, description: "the historical cpu cost for this scope" - field :memory_cost, :float, description: "the historical memory cost for this scope" - field :gpu_cost, :float, description: "the historical gpu cost for this scope" - field :ingress_cost, :float - field :load_balancer_cost, :float - field :egress_cost, :float - field :node_cost, :float - field :control_plane_cost, :float + field :namespace, :string, description: "leave null if cluster scoped" + field :memory, :float + field :cpu, :float + field :gpu, :float + field :storage, :float + field :memory_util, :float, description: "the historical memory utilization for this scope" + field :cpu_util, :float, description: "the historical cpu utilization for this scope" + field :gpu_util, :float, description: "the historical gpu utilization for this scope" + field :cpu_cost, :float, description: "the historical cpu cost for this scope" + field :memory_cost, :float, description: "the historical memory cost for this scope" + field :gpu_cost, :float, description: "the historical gpu cost for this scope" + field :ingress_cost, :float + field :load_balancer_cost, :float + field :egress_cost, :float + field :node_cost, :float + field :control_plane_cost, :float + field :storage_cost, :float end input_object :cluster_recommendation_attributes do @@ -748,6 +749,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :cpu, :float field :memory, :float field :gpu, :float + field :storage, :float, description: "the amount of storage used by this cluster" field :cpu_util, :float, description: "the amount of cpu utilized" field :mem_util, :float, description: "the amount of memory utilized" @@ -758,6 +760,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :load_balancer_cost, :float field :egress_cost, :float field :node_cost, :float + field :storage_cost, :float field :control_plane_cost, :float field :cluster, :cluster, resolve: dataloader(Deployments) @@ -773,6 +776,29 @@ defmodule Console.GraphQl.Deployments.Cluster do resolve &Deployments.list_scaling_recommendations/3 end + connection field :history, node_type: :cluster_usage_history do + resolve &Deployments.list_cluster_usage_history/3 + end + + timestamps() + end + + object :cluster_usage_history do + field :id, non_null(:id) + field :timestamp, non_null(:datetime) + + field :cpu_cost, :float + field :memory_cost, :float + field :gpu_cost, :float + field :ingress_cost, :float + field :load_balancer_cost, :float + field :egress_cost, :float + field :node_cost, :float + field :storage_cost, :float + field :control_plane_cost, :float + + field :cluster, :cluster, resolve: dataloader(Deployments) + timestamps() end @@ -782,6 +808,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :cpu, :float field :memory, :float field :gpu, :float + field :storage, :float, description: "the amount of storage used by this namespace" field :cpu_util, :float, description: "the amount of cpu utilized" field :mem_util, :float, description: "the amount of memory utilized" @@ -789,6 +816,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :memory_cost, :float field :gpu_cost, :float field :ingress_cost, :float + field :storage_cost, :float field :load_balancer_cost, :float field :egress_cost, :float @@ -826,6 +854,7 @@ defmodule Console.GraphQl.Deployments.Cluster do connection node_type: :cluster_usage connection node_type: :cluster_namespace_usage connection node_type: :cluster_scaling_recommendation + connection node_type: :cluster_usage_history delta :cluster delta :cluster_provider diff --git a/lib/console/graphql/resolvers/deployments/cluster.ex b/lib/console/graphql/resolvers/deployments/cluster.ex index 350c9bd72..65de3ab11 100644 --- a/lib/console/graphql/resolvers/deployments/cluster.ex +++ b/lib/console/graphql/resolvers/deployments/cluster.ex @@ -8,6 +8,7 @@ defmodule Console.GraphQl.Resolvers.Deployments.Cluster do ClusterRevision, PinnedCustomResource, ClusterUsage, + ClusterUsageHistory, ClusterNamespaceUsage, ClusterScalingRecommendation } @@ -111,6 +112,12 @@ defmodule Console.GraphQl.Resolvers.Deployments.Cluster do |> paginate(args) end + def list_cluster_usage_history(%ClusterUsage{cluster_id: cluster_id}, args, _) do + ClusterUsageHistory.for_cluster(cluster_id) + |> ClusterUsageHistory.ordered() + |> paginate(args) + end + def list_namespace_usage(%ClusterUsage{cluster_id: cluster_id}, args, _) do ClusterNamespaceUsage.for_cluster(cluster_id) |> maybe_search(ClusterNamespaceUsage, args) diff --git a/lib/console/schema/cluster_namespace_usage.ex b/lib/console/schema/cluster_namespace_usage.ex index 2a0174e3f..fabbe1bbb 100644 --- a/lib/console/schema/cluster_namespace_usage.ex +++ b/lib/console/schema/cluster_namespace_usage.ex @@ -19,6 +19,7 @@ defmodule Console.Schema.ClusterNamespaceUsage do field :load_balancer_cost, :float field :ingress_cost, :float field :egress_cost, :float + field :storage_cost, :float belongs_to :cluster, Cluster @@ -57,6 +58,7 @@ defmodule Console.Schema.ClusterNamespaceUsage do load_balancer_cost ingress_cost egress_cost + storage_cost )a def changeset(model, attrs \\ %{}) do diff --git a/lib/console/schema/cluster_usage.ex b/lib/console/schema/cluster_usage.ex index 831b565e2..fc485a2cf 100644 --- a/lib/console/schema/cluster_usage.ex +++ b/lib/console/schema/cluster_usage.ex @@ -13,6 +13,7 @@ defmodule Console.Schema.ClusterUsage do field :gpu_cost, :float field :node_cost, :float field :control_plane_cost, :float + field :storage_cost, :float field :memory_util, :float field :cpu_util, :float @@ -55,6 +56,10 @@ defmodule Console.Schema.ClusterUsage do from(cu in query, preload: ^preloads) end + def ordered(query, order) do + from(cu in query, order_by: ^order) + end + def ordered(query \\ __MODULE__) do from(cu in query, order_by: [asc: :cluster_id]) end @@ -75,6 +80,7 @@ defmodule Console.Schema.ClusterUsage do load_balancer_cost ingress_cost egress_cost + storage_cost )a def changeset(model, attrs \\ %{}) do diff --git a/lib/console/schema/cluster_usage_history.ex b/lib/console/schema/cluster_usage_history.ex new file mode 100644 index 000000000..bd9605c10 --- /dev/null +++ b/lib/console/schema/cluster_usage_history.ex @@ -0,0 +1,75 @@ +defmodule Console.Schema.ClusterUsageHistory do + use Piazza.Ecto.Schema + alias Console.Schema.Cluster + + schema "cluster_usage_history" do + field :timestamp, :utc_datetime_usec + field :memory, :float + field :cpu, :float + field :storage, :float + field :gpu, :float + + field :cpu_cost, :float + field :memory_cost, :float + field :gpu_cost, :float + field :node_cost, :float + field :control_plane_cost, :float + field :storage_cost, :float + + field :memory_util, :float + field :cpu_util, :float + field :gpu_util, :float + + field :load_balancer_cost, :float + field :ingress_cost, :float + field :egress_cost, :float + + belongs_to :cluster, Cluster + + timestamps() + end + + def for_cluster(query \\ __MODULE__, cid) do + from(cu in query, + where: cu.cluster_id == ^cid + ) + end + + def ordered(query \\ __MODULE__, order \\ [asc: :timestamp]) do + from(cu in query, order_by: ^order) + end + + def expired(query \\ __MODULE__) do + expiry = Timex.now() |> Timex.shift(days: -14) + from(cu in query, where: cu.timestamp <= ^expiry) + end + + @valid ~w( + timestamp + memory + cpu + gpu + storage + cluster_id + memory_util + cpu_util + gpu_util + cpu_cost + memory_cost + node_cost + control_plane_cost + gpu_cost + load_balancer_cost + ingress_cost + egress_cost + storage_cost + )a + + def fields(), do: @valid + + def changeset(model, attrs \\ %{}) do + model + |> cast(attrs, @valid) + |> unique_constraint(:cluster_id) + end +end diff --git a/priv/repo/migrations/20241220191210_add_storage_cost.exs b/priv/repo/migrations/20241220191210_add_storage_cost.exs new file mode 100644 index 000000000..7679e15db --- /dev/null +++ b/priv/repo/migrations/20241220191210_add_storage_cost.exs @@ -0,0 +1,13 @@ +defmodule Console.Repo.Migrations.AddStorageCost do + use Ecto.Migration + + def change do + alter table(:cluster_usage) do + add :storage_cost, :float + end + + alter table(:cluster_namespace_usage) do + add :storage_cost, :float + end + end +end diff --git a/priv/repo/migrations/20241220202012_cluster_usage_history.exs b/priv/repo/migrations/20241220202012_cluster_usage_history.exs new file mode 100644 index 000000000..d73792946 --- /dev/null +++ b/priv/repo/migrations/20241220202012_cluster_usage_history.exs @@ -0,0 +1,36 @@ +defmodule Console.Repo.Migrations.ClusterUsageHistory do + use Ecto.Migration + + def change do + create table(:cluster_usage_history, primary_key: false) do + add :id, :uuid + add :timestamp, :utc_datetime_usec + add :cluster_id, references(:clusters, type: :uuid, on_delete: :delete_all) + + add :memory, :float + add :cpu, :float + add :storage, :float + add :gpu, :float + + add :cpu_cost, :float + add :memory_cost, :float + add :gpu_cost, :float + add :node_cost, :float + add :control_plane_cost, :float + add :storage_cost, :float + + add :memory_util, :float + add :cpu_util, :float + add :gpu_util, :float + + add :load_balancer_cost, :float + add :ingress_cost, :float + add :egress_cost, :float + + timestamps() + end + + create index(:cluster_usage_history, [:cluster_id]) + create index(:cluster_usage_history, [:cluster_id, :timestamp]) + end +end diff --git a/schema/schema.graphql b/schema/schema.graphql index 18a4c4870..7e35e7d6a 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -4663,6 +4663,8 @@ input CostAttributes { nodeCost: Float controlPlaneCost: Float + + storageCost: Float } input ClusterRecommendationAttributes { @@ -5317,6 +5319,9 @@ type ClusterUsage { gpu: Float + "the amount of storage used by this cluster" + storage: Float + "the amount of cpu utilized" cpuUtil: Float @@ -5337,6 +5342,8 @@ type ClusterUsage { nodeCost: Float + storageCost: Float + controlPlaneCost: Float cluster: Cluster @@ -5345,11 +5352,30 @@ type ClusterUsage { recommendations(after: String, first: Int, before: String, last: Int, type: ScalingRecommendationType, q: String): ClusterScalingRecommendationConnection + history(after: String, first: Int, before: String, last: Int): ClusterUsageHistoryConnection + insertedAt: DateTime updatedAt: DateTime } +type ClusterUsageHistory { + id: ID! + timestamp: DateTime! + cpuCost: Float + memoryCost: Float + gpuCost: Float + ingressCost: Float + loadBalancerCost: Float + egressCost: Float + nodeCost: Float + storageCost: Float + controlPlaneCost: Float + cluster: Cluster + insertedAt: DateTime + updatedAt: DateTime +} + type ClusterNamespaceUsage { id: ID! @@ -5361,6 +5387,9 @@ type ClusterNamespaceUsage { gpu: Float + "the amount of storage used by this namespace" + storage: Float + "the amount of cpu utilized" cpuUtil: Float @@ -5375,6 +5404,8 @@ type ClusterNamespaceUsage { ingressCost: Float + storageCost: Float + loadBalancerCost: Float egressCost: Float @@ -5440,6 +5471,11 @@ type ClusterScalingRecommendationConnection { edges: [ClusterScalingRecommendationEdge] } +type ClusterUsageHistoryConnection { + pageInfo: PageInfo! + edges: [ClusterUsageHistoryEdge] +} + enum AuthMethod { BASIC SSH @@ -8647,6 +8683,11 @@ type GitRepositoryEdge { cursor: String } +type ClusterUsageHistoryEdge { + node: ClusterUsageHistory + cursor: String +} + type ClusterScalingRecommendationEdge { node: ClusterScalingRecommendation cursor: String diff --git a/test/console/cost/cron_test.exs b/test/console/cost/cron_test.exs new file mode 100644 index 000000000..ac184ede7 --- /dev/null +++ b/test/console/cost/cron_test.exs @@ -0,0 +1,35 @@ +defmodule Console.Cost.CronTest do + use Console.DataCase, async: true + alias Console.Cost.Cron + alias Console.Schema.ClusterUsageHistory + + describe "#history/0" do + test "it can upsert history records from cluster usage" do + usages = insert_list(3, :cluster_usage) + + Cron.history() + + history = Console.Repo.all(ClusterUsageHistory) + + assert length(history) == 3 + + assert MapSet.new(usages, & &1.cluster_id) + |> MapSet.equal?(MapSet.new(history, & &1.cluster_id)) + end + end + + describe "#prune/0" do + test "it can prune old history records" do + hist = insert_list(3, :cluster_usage_history, timestamp: Timex.now() |> Timex.shift(days: -30)) + keep = insert_list(3, :cluster_usage_history) + + Cron.prune() + + for h <- hist, + do: refute refetch(h) + + for h <- keep, + do: assert refetch(h) + end + end +end diff --git a/test/console/graphql/queries/deployments/cluster_queries_test.exs b/test/console/graphql/queries/deployments/cluster_queries_test.exs index daec6435a..06919dc72 100644 --- a/test/console/graphql/queries/deployments/cluster_queries_test.exs +++ b/test/console/graphql/queries/deployments/cluster_queries_test.exs @@ -692,6 +692,12 @@ defmodule Console.GraphQl.Deployments.ClusterQueriesTest do usage = insert(:cluster_usage) nsu = insert_list(4, :cluster_namespace_usage, cluster: usage.cluster) sr = insert_list(3, :cluster_scaling_recommendation, cluster: usage.cluster) + hist = for i <- 1..3 do + insert(:cluster_usage_history, + cluster: usage.cluster, + timestamp: Timex.now() |> Timex.shift(days: -i) + ) + end {:ok, %{data: %{"clusterUsage" => found}}} = run_query(""" query Usage($id: ID!) { @@ -703,6 +709,9 @@ defmodule Console.GraphQl.Deployments.ClusterQueriesTest do recommendations(first: 5) { edges { node { id } } } + history(first: 5) { + edges { node { id } } + } } } """, %{"id" => usage.id}, %{current_user: admin_user()}) @@ -712,6 +721,8 @@ defmodule Console.GraphQl.Deployments.ClusterQueriesTest do |> ids_equal(nsu) assert from_connection(found["recommendations"]) |> ids_equal(sr) + assert from_connection(found["history"]) + |> ids_equal(hist) end end end diff --git a/test/support/factory.ex b/test/support/factory.ex index d3bca4fc0..6f09fde3b 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -769,6 +769,17 @@ defmodule Console.Factory do } end + def cluster_usage_history_factory do + %Schema.ClusterUsageHistory{ + timestamp: Timex.now(), + cluster: build(:cluster), + cpu: 100, + memory: 100, + cpu_util: 50, + memory_util: 50 + } + end + def cluster_namespace_usage_factory do %Schema.ClusterNamespaceUsage{ cluster: build(:cluster),