diff --git a/docs/reference/qcloud_cluster.md b/docs/reference/qcloud_cluster.md index 3acfa1e..260584f 100644 --- a/docs/reference/qcloud_cluster.md +++ b/docs/reference/qcloud_cluster.md @@ -25,6 +25,7 @@ Manage Qdrant Cloud clusters * [qcloud](qcloud.md) - Qdrant Cloud CLI * [qcloud cluster alert](qcloud_cluster_alert.md) - Show alerts of clusters +* [qcloud cluster cluster-metrics-history](qcloud_cluster_cluster-metrics-history.md) - Show the request rate and latency of a cluster over time * [qcloud cluster create](qcloud_cluster_create.md) - Create a new cluster * [qcloud cluster create-from-backup](qcloud_cluster_create-from-backup.md) - Create a new cluster from a backup * [qcloud cluster dashboard](qcloud_cluster_dashboard.md) - Open a cluster's dashboard in your browser @@ -33,6 +34,8 @@ Manage Qdrant Cloud clusters * [qcloud cluster key](qcloud_cluster_key.md) - Manage API keys for a cluster * [qcloud cluster list](qcloud_cluster_list.md) - List all clusters * [qcloud cluster logs](qcloud_cluster_logs.md) - Retrieve logs for a cluster +* [qcloud cluster metrics](qcloud_cluster_metrics.md) - Show the current CPU, RAM and disk usage of each cluster node +* [qcloud cluster node-metrics-history](qcloud_cluster_node-metrics-history.md) - Show the CPU, RAM and disk usage of a cluster node over time * [qcloud cluster restart](qcloud_cluster_restart.md) - Restart a cluster * [qcloud cluster scale](qcloud_cluster_scale.md) - Scales the resources of a cluster * [qcloud cluster suspend](qcloud_cluster_suspend.md) - Suspend a cluster diff --git a/docs/reference/qcloud_cluster_cluster-metrics-history.md b/docs/reference/qcloud_cluster_cluster-metrics-history.md new file mode 100644 index 0000000..cc8e8d9 --- /dev/null +++ b/docs/reference/qcloud_cluster_cluster-metrics-history.md @@ -0,0 +1,63 @@ +## qcloud cluster cluster-metrics-history + +Show the request rate and latency of a cluster over time + +### Synopsis + +Show the request rate and latency of a cluster over time. + +The requests per second and the request latency in milliseconds across all +nodes of the cluster are drawn as separate line charts. Request metrics are +measured at the ingress of Qdrant Cloud, so they are not available for Hybrid +Cloud clusters. + +By default, the last 7 days are shown. --since and --until accept an RFC3339 +timestamp, a YYYY-MM-DD date in UTC, or a duration ago such as 6h or 7d. When +only --until is given, the range covers the 7 days before it. Times on the +charts are in UTC. + +``` +qcloud cluster cluster-metrics-history [flags] +``` + +### Examples + +``` +# Show the request rate and latency over the last 7 days +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Show the request rate and latency over the last 6 hours +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --since 6h + +# Show the request rate and latency in a specific time range +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --since 2026-10-01 --until 2026-10-07 + +# Output the time series as JSON +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json +``` + +### Options + +``` + -h, --help help for cluster-metrics-history + -s, --since string Start of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 24h or 7d; default: 7 days before --until) + -u, --until string End of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 1h; default: now) +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud cluster](qcloud_cluster.md) - Manage Qdrant Cloud clusters + diff --git a/docs/reference/qcloud_cluster_metrics.md b/docs/reference/qcloud_cluster_metrics.md new file mode 100644 index 0000000..c2f2ead --- /dev/null +++ b/docs/reference/qcloud_cluster_metrics.md @@ -0,0 +1,56 @@ +## qcloud cluster metrics + +Show the current CPU, RAM and disk usage of each cluster node + +### Synopsis + +Show the current CPU, RAM and disk usage of each node in a cluster. + +Each resource is drawn as a bar relative to the capacity of the node, followed +by the usage in percent and in absolute values. CPU and RAM include the +resources reserved for the system, which the database cannot use. RAM is further +split into the page cache and the memory used by the database itself. + +The values are short-term averages reported by the cluster's monitoring, so a +node that has just started may show no data yet. + +``` +qcloud cluster metrics [flags] +``` + +### Examples + +``` +# Show resource usage for every node of a cluster +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Output the usage as JSON +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json + +# List the RAM usage in percent per node +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json | jq -r '.nodes[] | "\(.node) \(.ram.usedPercent)"' +``` + +### Options + +``` + -h, --help help for metrics +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud cluster](qcloud_cluster.md) - Manage Qdrant Cloud clusters + diff --git a/docs/reference/qcloud_cluster_node-metrics-history.md b/docs/reference/qcloud_cluster_node-metrics-history.md new file mode 100644 index 0000000..2595c2f --- /dev/null +++ b/docs/reference/qcloud_cluster_node-metrics-history.md @@ -0,0 +1,66 @@ +## qcloud cluster node-metrics-history + +Show the CPU, RAM and disk usage of a cluster node over time + +### Synopsis + +Show the CPU, RAM and disk usage of a cluster node over time. + +The node is given by its number, such as 0, as shown by "qcloud cluster metrics", +or by its full node ID. Each resource is drawn as a stacked area chart, with a +red line marking the capacity of the node: CPU shows the resources reserved for +the system with the usage of the database on top, and RAM shows the reserved +memory, then the page cache, then the memory used by the database. Without +color, the areas are drawn as stacked lines instead. For a cluster with GPUs, +the GPU usage in percent and the GPU RAM in use are shown as well. + +By default, the usage over the last 7 days is shown. --since and --until accept +an RFC3339 timestamp, a YYYY-MM-DD date in UTC, or a duration ago such as 6h or +7d. When only --until is given, the range covers the 7 days before it. Times on +the charts are in UTC. + +``` +qcloud cluster node-metrics-history [flags] +``` + +### Examples + +``` +# Show the usage of node 0 over the last 7 days +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 + +# Show the usage of node 1 over the last 24 hours +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 1 --since 24h + +# Show the usage in a specific time range +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 --since 2026-10-01 --until 2026-10-07 + +# Output the time series as JSON +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 --json +``` + +### Options + +``` + -h, --help help for node-metrics-history + -s, --since string Start of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 24h or 7d; default: 7 days before --until) + -u, --until string End of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 1h; default: now) +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud cluster](qcloud_cluster.md) - Manage Qdrant Cloud clusters + diff --git a/go.mod b/go.mod index 4a66ab7..c60182a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module github.com/qdrant/qcloud-cli go 1.27.3 require ( + charm.land/lipgloss/v2 v2.0.6 + github.com/NimbleMarkets/ntcharts/v2 v2.8.0 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/term v0.2.2 github.com/creativeprojects/go-selfupdate v1.7.0 github.com/dustin/go-humanize v1.1.0 github.com/google/uuid v1.6.0 @@ -22,10 +26,18 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + charm.land/bubbles/v2 v2.2.1 // indirect + charm.land/bubbletea/v2 v2.0.10 // indirect code.gitea.io/sdk/gitea v0.25.1 // indirect github.com/42wim/httpsig v1.3.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/ProtonMail/go-crypto v1.5.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.5 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect @@ -38,7 +50,10 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -47,11 +62,13 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/ulikunitz/xz v0.5.17 // indirect + github.com/xo/terminfo v1.2.0 // indirect gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect diff --git a/go.sum b/go.sum index e54d5b5..2b46d30 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,41 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= github.com/42wim/httpsig v1.3.0 h1:HK2bieeZ99X9IvOoc1ST6yrhLek0f4MqlEGES2JCP3g= github.com/42wim/httpsig v1.3.0/go.mod h1:lgC3Yrvp4lxi1odEF1ge3kQBu/VM+SRcZQu5YwiR3dU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/NimbleMarkets/ntcharts/v2 v2.8.0 h1:80ticj4+1zmJKswSd2QJHS/n022BaW8jio4ZGJrhcmc= +github.com/NimbleMarkets/ntcharts/v2 v2.8.0/go.mod h1:w+5yLPICduptuEeOk8ogPi2gYU9qmaim1Elol3TUI1k= github.com/ProtonMail/go-crypto v1.5.2 h1:cucYnvqcY7UOXVD//mSyjeaPY0SSN3v5cDkYPxumINk= github.com/ProtonMail/go-crypto v1.5.2/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25 h1:LhbbjWaAXqCMLcb/44togwDILxAMCnBAfs/o0H3rxAw= +github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= @@ -59,19 +87,24 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/qdrant/qdrant-cloud-public-api v0.218.0 h1:PWASBdLV2Nmc9DJ/H+yxkjMvF+sdETWk7ZVxMmuYXyY= github.com/qdrant/qdrant-cloud-public-api v0.218.0/go.mod h1:5UX30P+AlnkZADr1Z18PdEzEWEu1Tzw0VbjnBZnkJYM= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -99,6 +132,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulikunitz/xz v0.5.17 h1:flR0y/x1hgM8EGV1AW3Xll6T413G0glV8UfBwR617V4= github.com/ulikunitz/xz v0.5.17/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -115,6 +150,8 @@ golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/cmd/cluster/cluster.go b/internal/cmd/cluster/cluster.go index 910ea0e..7ef6947 100644 --- a/internal/cmd/cluster/cluster.go +++ b/internal/cmd/cluster/cluster.go @@ -28,6 +28,9 @@ func NewCommand(s *state.State) *cobra.Command { newKeyCommand(s), newScaleCommand(s), newLogsCommand(s), + newMetricsCommand(s), + newNodeMetricsHistoryCommand(s), + newClusterMetricsHistoryCommand(s), newDashboardCommand(s), newAlertCommand(s), ) diff --git a/internal/cmd/cluster/cluster_metrics_history.go b/internal/cmd/cluster/cluster_metrics_history.go new file mode 100644 index 0000000..2b3486c --- /dev/null +++ b/internal/cmd/cluster/cluster_metrics_history.go @@ -0,0 +1,146 @@ +package cluster + +import ( + "errors" + "fmt" + "io" + "slices" + "time" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/timestamppb" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/state" +) + +type clusterMetricsHistory struct { + ClusterID string `json:"clusterId"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + RequestsPerSecond []historyPoint `json:"requestsPerSecond"` + LatencyMs []historyPoint `json:"latencyMs"` +} + +func newClusterMetricsHistoryCommand(s *state.State) *cobra.Command { + cmd := base.DescribeCmd[*clusterMetricsHistory]{ + Use: "cluster-metrics-history ", + Short: "Show the request rate and latency of a cluster over time", + Long: `Show the request rate and latency of a cluster over time. + +The requests per second and the request latency in milliseconds across all +nodes of the cluster are drawn as separate line charts. Request metrics are +measured at the ingress of Qdrant Cloud, so they are not available for Hybrid +Cloud clusters. + +By default, the last 7 days are shown. --since and --until accept an RFC3339 +timestamp, a YYYY-MM-DD date in UTC, or a duration ago such as 6h or 7d. When +only --until is given, the range covers the 7 days before it. Times on the +charts are in UTC.`, + Example: `# Show the request rate and latency over the last 7 days +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Show the request rate and latency over the last 6 hours +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --since 6h + +# Show the request rate and latency in a specific time range +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --since 2026-10-01 --until 2026-10-07 + +# Output the time series as JSON +qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json`, + Args: util.ExactArgs(1, "a cluster ID"), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*clusterMetricsHistory, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + since, until, err := readMetricsHistoryRange(cmd, time.Now().Truncate(time.Second)) + if err != nil { + return nil, err + } + + clusterResp, err := client.Cluster().GetCluster(ctx, &clusterv1.GetClusterRequest{ + AccountId: accountID, + ClusterId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster: %w", err) + } + + if clusterResp.GetCluster().GetCloudProviderId() == qcloudapi.HybridCloudProviderID { + return nil, errors.New("request metrics are not available for Hybrid Cloud clusters") + } + + metricsResp, err := client.Monitoring().GetClusterUsageMetrics(ctx, &monitoringv1.GetClusterUsageMetricsRequest{ + AccountId: accountID, + ClusterId: args[0], + Since: timestamppb.New(since), + Until: timestamppb.New(until), + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster metrics: %w", err) + } + + return &clusterMetricsHistory{ + ClusterID: args[0], + Since: since.UTC(), + Until: until.UTC(), + RequestsPerSecond: historyPoints(metricsResp.GetRps()), + LatencyMs: historyPoints(metricsResp.GetLatency()), + }, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, h *clusterMetricsHistory) error { + printClusterMetricsHistory(w, h, metricsChartWidth(w), output.ColorEnabled(w)) + return nil + }, + ValidArgsFunction: completion.ClusterIDCompletion(s), + }.CobraCommand(s) + + addMetricsHistoryRangeFlags(cmd) + + return cmd +} + +func printClusterMetricsHistory(w io.Writer, h *clusterMetricsHistory, width int, color bool) { + fmt.Fprintf(w, "Cluster %s %s - %s\n", h.ClusterID, output.FullDateTime(h.Since), output.FullDateTime(h.Until)) + + // The API fills the request series with zeros where there were no + // requests, so all zeros means there is nothing to draw. The latency of + // no requests is no measurement either. + requests, latency, empty := h.RequestsPerSecond, h.LatencyMs, "" + if !slices.ContainsFunc(requests, func(p historyPoint) bool { return p.Value != 0 }) { + requests, latency, empty = nil, nil, "no requests in this time range" + } + + charts := []metricsChart{ + { + title: "Requests", + base: requests, + empty: empty, + series: []output.ChartSeries{{Name: "requests per second", Color: output.ChartColorRequests, Points: stack(requests, 0)}}, + yLabel: func(v float64) string { return output.Significant(v, 3) + "/s" }, + }, + { + title: "Latency", + base: latency, + empty: empty, + series: []output.ChartSeries{{Name: "latency", Color: output.ChartColorLatency, Points: stack(latency, 0)}}, + yLabel: func(v float64) string { return output.Significant(v, 3) + "ms" }, + }, + } + printMetricsCharts(w, charts, h.Since, h.Until, width, color) +} diff --git a/internal/cmd/cluster/cluster_metrics_history_test.go b/internal/cmd/cluster/cluster_metrics_history_test.go new file mode 100644 index 0000000..4358870 --- /dev/null +++ b/internal/cmd/cluster/cluster_metrics_history_test.go @@ -0,0 +1,223 @@ +package cluster_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func setupClusterHistory(env *testutil.TestEnv, cloudProvider string, resp *monitoringv1.GetClusterUsageMetricsResponse) { + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: historyCluster(cloudProvider)}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(resp, nil) +} + +func requestMetrics() *monitoringv1.GetClusterUsageMetricsResponse { + return &monitoringv1.GetClusterUsageMetricsResponse{ + Rps: series(10, 35, 20), + Latency: series(12, 18, 15), + Nodes: []*monitoringv1.ClusterNodeUsageMetrics{usageNode("0")}, + } +} + +func TestClusterMetricsHistory_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", requestMetrics()) + + stdout, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, + "--since", "2026-10-20T00:00:00Z", "--until", "2026-10-20T02:00:00Z") + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(stdout, + fmt.Sprintf("Cluster %s 2026-10-20 00:00:00 UTC - 2026-10-20 02:00:00 UTC\n", metricsClusterID)), stdout) + assert.Contains(t, stdout, "\nRequests\nrequests per second\n") + assert.Contains(t, stdout, "\nLatency\nlatency\n") + assert.Less(t, strings.Index(stdout, "Requests"), strings.Index(stdout, "Latency")) + // 35/s and 18ms with 5% headroom round up to 4 steps of 10/s and 5ms. + assert.Contains(t, stdout, "40/s│") + assert.Contains(t, stdout, "20ms│") + assert.NotContains(t, stdout, "capacity") + assert.NotContains(t, stdout, "\x1b[") +} + +func TestClusterMetricsHistory_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", requestMetrics()) + + stdout, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, "--json") + require.NoError(t, err) + + var result struct { + ClusterID string `json:"clusterId"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + RequestsPerSecond []struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` + } `json:"requestsPerSecond"` + LatencyMs []struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` + } `json:"latencyMs"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + + assert.Equal(t, metricsClusterID, result.ClusterID) + assert.Equal(t, 7*24*time.Hour, result.Until.Sub(result.Since)) + require.Len(t, result.RequestsPerSecond, 3) + assert.True(t, result.RequestsPerSecond[1].Timestamp.Equal(historyStart.Add(time.Hour))) + assert.InDelta(t, 35, result.RequestsPerSecond[1].Value, 1e-9) + require.Len(t, result.LatencyMs, 3) + assert.InDelta(t, 18, result.LatencyMs[1].Value, 1e-9) + assert.NotContains(t, stdout, "nodes") +} + +func TestClusterMetricsHistory_Requests(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", requestMetrics()) + + before := time.Now().Truncate(time.Second) + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.NoError(t, err) + after := time.Now() + + clusterReq, ok := env.Server.GetClusterCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", clusterReq.GetAccountId()) + assert.Equal(t, metricsClusterID, clusterReq.GetClusterId()) + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, metricsClusterID, req.GetClusterId()) + until := req.GetUntil().AsTime() + assert.False(t, until.Before(before) || until.After(after), "until defaults to now") + assert.Equal(t, 7*24*time.Hour, until.Sub(req.GetSince().AsTime())) + assert.Nil(t, req.Aggregator) +} + +func TestClusterMetricsHistory_TimeRange(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", requestMetrics()) + + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, "--until", "2026-10-10T00:00:00Z") + require.NoError(t, err) + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), req.GetSince().AsTime()) + assert.Equal(t, time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), req.GetUntil().AsTime()) + + _, _, err = testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, "--since", "2026-10-20", "--until", "2026-10-01") + require.Error(t, err) + assert.Contains(t, err.Error(), "--since must be before --until") +} + +func TestClusterMetricsHistory_NoData(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", &monitoringv1.GetClusterUsageMetricsResponse{Rps: series(1, 2)}) + + stdout, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "\nRequests\nrequests per second\n") + assert.Contains(t, stdout, "\nLatency\nno data\n") +} + +func TestClusterMetricsHistory_HybridCluster(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, qcloudapi.HybridCloudProviderID, requestMetrics()) + + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.Error(t, err) + assert.Contains(t, err.Error(), "request metrics are not available for Hybrid Cloud clusters") + assert.Equal(t, 0, env.MonitoringServer.GetClusterUsageMetricsCalls.Count()) +} + +func TestClusterMetricsHistory_MetricsError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: historyCluster("aws")}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(nil, fmt.Errorf("metrics unavailable")) + + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster metrics") +} + +func TestClusterMetricsHistory_ClusterError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster") + assert.Equal(t, 0, env.MonitoringServer.GetClusterUsageMetricsCalls.Count()) +} + +func TestClusterMetricsHistory_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history") + require.Error(t, err) +} + +func TestClusterMetricsHistory_LowRateLabels(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", &monitoringv1.GetClusterUsageMetricsResponse{ + Rps: series(0, 0.0016, 0), + Latency: series(0, 0.04, 0), + }) + + stdout, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "0.002/s│") + assert.Contains(t, stdout, "0.001/s│") + assert.Contains(t, stdout, "0.06ms│") + assert.Contains(t, stdout, "0.015ms│") + assert.NotContains(t, stdout, "0.00/s") +} + +func TestClusterMetricsHistory_NoRequests(t *testing.T) { + env := testutil.NewTestEnv(t) + // The API fills the request series with zeros where there are none. + setupClusterHistory(env, "aws", &monitoringv1.GetClusterUsageMetricsResponse{ + Rps: series(0, 0, 0), + Latency: series(0, 0, 0), + }) + + stdout, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "\nRequests\nno requests in this time range\n") + assert.Contains(t, stdout, "\nLatency\nno requests in this time range\n") +} + +func TestClusterMetricsHistory_FutureRange(t *testing.T) { + env := testutil.NewTestEnv(t) + setupClusterHistory(env, "aws", requestMetrics()) + + before := time.Now() + _, _, err := testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, + "--since", "24h", "--until", before.Add(48*time.Hour).UTC().Format(time.RFC3339)) + require.NoError(t, err) + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.False(t, req.GetUntil().AsTime().After(time.Now()), "--until is cut to now") + + _, _, err = testutil.Exec(t, env, "cluster", "cluster-metrics-history", metricsClusterID, + "--since", before.Add(time.Hour).UTC().Format(time.RFC3339)) + require.Error(t, err) + assert.Contains(t, err.Error(), "--since must be in the past") +} diff --git a/internal/cmd/cluster/metrics.go b/internal/cmd/cluster/metrics.go new file mode 100644 index 0000000..4030256 --- /dev/null +++ b/internal/cmd/cluster/metrics.go @@ -0,0 +1,338 @@ +package cluster + +import ( + "cmp" + "fmt" + "io" + "math" + "slices" + "strconv" + "strings" + + "github.com/spf13/cobra" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/state" +) + +const metricsBarWidth = 30 + +// The reserved RAM of a cluster is given in decimal gigabytes, unlike the +// configured RAM, which is in GiB. This matches the Qdrant Cloud console. +const bytesPerGB = 1000 * 1000 * 1000 + +type clusterMetrics struct { + ClusterID string `json:"clusterId"` + Nodes []nodeMetrics `json:"nodes"` +} + +type nodeMetrics struct { + Node string `json:"node"` + NodeID string `json:"nodeId"` + State string `json:"state,omitempty"` + Version string `json:"version,omitempty"` + AvailabilityZone string `json:"availabilityZone,omitempty"` + CPU cpuUsage `json:"cpu"` + RAM memoryUsage `json:"ram"` + Disk diskUsage `json:"disk"` +} + +type cpuUsage struct { + UsedVCPUs float64 `json:"usedVcpus"` + ReservedVCPUs float64 `json:"reservedVcpus"` + AvailableVCPUs float64 `json:"availableVcpus"` + TotalVCPUs float64 `json:"totalVcpus"` + UsedPercent float64 `json:"usedPercent"` +} + +type memoryUsage struct { + DataBytes float64 `json:"dataBytes"` + CacheBytes float64 `json:"cacheBytes"` + ReservedBytes float64 `json:"reservedBytes"` + AvailableBytes float64 `json:"availableBytes"` + TotalBytes float64 `json:"totalBytes"` + UsedPercent float64 `json:"usedPercent"` +} + +type diskUsage struct { + UsedBytes float64 `json:"usedBytes"` + AvailableBytes float64 `json:"availableBytes"` + TotalBytes float64 `json:"totalBytes"` + UsedPercent float64 `json:"usedPercent"` +} + +func newMetricsCommand(s *state.State) *cobra.Command { + return base.DescribeCmd[*clusterMetrics]{ + Use: "metrics ", + Short: "Show the current CPU, RAM and disk usage of each cluster node", + Long: `Show the current CPU, RAM and disk usage of each node in a cluster. + +Each resource is drawn as a bar relative to the capacity of the node, followed +by the usage in percent and in absolute values. CPU and RAM include the +resources reserved for the system, which the database cannot use. RAM is further +split into the page cache and the memory used by the database itself. + +The values are short-term averages reported by the cluster's monitoring, so a +node that has just started may show no data yet.`, + Example: `# Show resource usage for every node of a cluster +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Output the usage as JSON +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json + +# List the RAM usage in percent per node +qcloud cluster metrics 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json | jq -r '.nodes[] | "\(.node) \(.ram.usedPercent)"'`, + Args: util.ExactArgs(1, "a cluster ID"), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*clusterMetrics, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + clusterResp, err := client.Cluster().GetCluster(ctx, &clusterv1.GetClusterRequest{ + AccountId: accountID, + ClusterId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster: %w", err) + } + + metricsResp, err := client.Monitoring().GetClusterSummaryMetrics(ctx, &monitoringv1.GetClusterSummaryMetricsRequest{ + AccountId: accountID, + ClusterId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster metrics: %w", err) + } + + return buildClusterMetrics(clusterResp.GetCluster(), metricsResp.GetNodes()), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, m *clusterMetrics) error { + printClusterMetrics(w, m, output.ColorEnabled(w)) + return nil + }, + ValidArgsFunction: completion.ClusterIDCompletion(s), + }.CobraCommand(s) +} + +func buildClusterMetrics(cluster *clusterv1.Cluster, nodes []*monitoringv1.ClusterNodeMetrics) *clusterMetrics { + resources := cluster.GetState().GetResources() + reservedVCPUs := resources.GetCpu().GetReserved() / 1000 + reservedRAM := resources.GetRam().GetReserved() * bytesPerGB + isHybrid := cluster.GetCloudProviderId() == qcloudapi.HybridCloudProviderID + + result := &clusterMetrics{ClusterID: cluster.GetId(), Nodes: make([]nodeMetrics, 0, len(nodes))} + for _, n := range nodes { + nm := nodeMetrics{ + Node: nodeNumber(cluster.GetId(), n.GetNodeId()), + NodeID: n.GetNodeId(), + } + + for _, info := range cluster.GetState().GetNodes() { + if info.GetName() == n.GetNodeId() { + nm.State = output.ClusterNodeState(info.GetState()) + nm.Version = info.GetVersion() + nm.AvailabilityZone = info.GetAvailabilityZone() + } + } + + cpuUsed, cpuTotal := metricOverview(n.GetCpu()) + nm.CPU = cpuUsage{UsedVCPUs: cpuUsed, TotalVCPUs: cpuTotal} + if cpuTotal > 0 { + nm.CPU.ReservedVCPUs = reservedVCPUs + nm.CPU.AvailableVCPUs = available(cpuUsed+reservedVCPUs, cpuTotal) + nm.CPU.UsedPercent = percent(cpuUsed+reservedVCPUs, cpuTotal) + } + + // Like the Qdrant Cloud console, use the RSS of the database process, + // or of the whole node on Hybrid Cloud. + ramMetric := n.GetRamQdrantRss() + if isHybrid { + ramMetric = n.GetRamRss() + } + + ramData, ramTotal := metricOverview(ramMetric) + ramCache, _ := metricOverview(n.GetRamCache()) + nm.RAM = memoryUsage{DataBytes: ramData, CacheBytes: ramCache, TotalBytes: ramTotal} + if ramTotal > 0 { + nm.RAM.ReservedBytes = reservedRAM + nm.RAM.AvailableBytes = available(ramData+ramCache+reservedRAM, ramTotal) + nm.RAM.UsedPercent = percent(ramData+ramCache+reservedRAM, ramTotal) + } + + diskUsed, diskTotal := metricOverview(n.GetDisk()) + nm.Disk = diskUsage{ + UsedBytes: diskUsed, + AvailableBytes: available(diskUsed, diskTotal), + TotalBytes: diskTotal, + UsedPercent: percent(diskUsed, diskTotal), + } + + result.Nodes = append(result.Nodes, nm) + } + + slices.SortStableFunc(result.Nodes, func(a, b nodeMetrics) int { return compareNodes(a.Node, b.Node) }) + + return result +} + +// nodeNumber returns the short name of a node, such as "0", from its full ID. +func nodeNumber(clusterID, nodeID string) string { + return strings.TrimPrefix(nodeID, "qdrant-"+clusterID+"-") +} + +// compareNodes orders node numbers numerically, falling back to text. +func compareNodes(a, b string) int { + an, aErr := strconv.Atoi(a) + bn, bErr := strconv.Atoi(b) + if aErr == nil && bErr == nil { + return cmp.Compare(an, bn) + } + + return cmp.Compare(a, b) +} + +// metricOverview returns the first interval average and the total capacity of +// a metric, like the Qdrant Cloud console. Both are 0 when the node has not +// reported the metric yet. +func metricOverview(m *monitoringv1.ClusterMetricOverview) (float64, float64) { + if len(m.GetAvg()) == 0 || m.GetTotal().GetValue() == 0 { + return 0, 0 + } + + return m.GetAvg()[0].GetValue(), m.GetTotal().GetValue() +} + +// available returns the capacity left on top of used, which is never negative. +func available(used, total float64) float64 { + return max(total-used, 0) +} + +func percent(v, total float64) float64 { + if total == 0 { + return 0 + } + + return math.Round(v/total*1000) / 10 +} + +type metricsRow struct { + label string + segments []output.BarSegment + total float64 + percent float64 + amount string + details []string +} + +func printClusterMetrics(w io.Writer, m *clusterMetrics, color bool) { + if len(m.Nodes) == 0 { + fmt.Fprintln(w, "No metrics available for this cluster.") + return + } + + for i, n := range m.Nodes { + if i > 0 { + fmt.Fprintln(w) + } + + header := []string{"Node " + n.Node} + for _, v := range []string{n.State, n.Version, n.AvailabilityZone} { + if v != "" { + header = append(header, v) + } + } + + fmt.Fprintln(w, strings.Join(header, " ")) + + for _, r := range metricsRows(n, color) { + if r.total == 0 { + fmt.Fprintf(w, " %-4s no data\n", r.label) + continue + } + + // The breakdown goes on its own line, indented to the bar, so the + // rows fit into a terminal of 80 columns. + fmt.Fprintf(w, " %-4s %s %5.1f%% %s\n", r.label, output.UsageBar(r.segments, r.total, metricsBarWidth, color), r.percent, r.amount) + fmt.Fprintf(w, " %s\n", strings.Join(r.details, " ")) + } + } +} + +func metricsRows(n nodeMetrics, color bool) []metricsRow { + cpu, ram, disk := n.CPU, n.RAM, n.Disk + return []metricsRow{ + { + label: "CPU", + segments: []output.BarSegment{ + {Value: cpu.ReservedVCPUs, Color: output.BarColorSystem}, + {Value: cpu.UsedVCPUs, Color: output.BarColorData}, + }, + total: cpu.TotalVCPUs, + percent: cpu.UsedPercent, + amount: fmt.Sprintf("%s of %s vCPU", formatVCPUs(cpu.ReservedVCPUs+cpu.UsedVCPUs), formatVCPUs(cpu.TotalVCPUs)), + details: []string{ + output.BarLegend("system "+formatVCPUs(cpu.ReservedVCPUs), output.BarColorSystem, color), + output.BarLegend("usage "+formatVCPUs(cpu.UsedVCPUs), output.BarColorData, color), + output.BarLegend("available "+formatVCPUs(cpu.AvailableVCPUs), output.BarColorFree, color), + }, + }, + { + label: "RAM", + segments: []output.BarSegment{ + {Value: ram.ReservedBytes, Color: output.BarColorSystem}, + {Value: ram.CacheBytes, Color: output.BarColorCache}, + {Value: ram.DataBytes, Color: output.BarColorData}, + }, + total: ram.TotalBytes, + percent: ram.UsedPercent, + amount: fmt.Sprintf("%s of %s", formatBytes(ram.ReservedBytes+ram.CacheBytes+ram.DataBytes), formatBytes(ram.TotalBytes)), + details: []string{ + output.BarLegend("system "+formatBytes(ram.ReservedBytes), output.BarColorSystem, color), + output.BarLegend("cache "+formatBytes(ram.CacheBytes), output.BarColorCache, color), + output.BarLegend("data "+formatBytes(ram.DataBytes), output.BarColorData, color), + output.BarLegend("available "+formatBytes(ram.AvailableBytes), output.BarColorFree, color), + }, + }, + { + label: "Disk", + segments: []output.BarSegment{ + {Value: disk.UsedBytes, Color: output.BarColorData}, + }, + total: disk.TotalBytes, + percent: disk.UsedPercent, + amount: fmt.Sprintf("%s of %s", formatBytes(disk.UsedBytes), formatBytes(disk.TotalBytes)), + details: []string{ + output.BarLegend("used "+formatBytes(disk.UsedBytes), output.BarColorData, color), + output.BarLegend("available "+formatBytes(disk.AvailableBytes), output.BarColorFree, color), + }, + }, + } +} + +// formatVCPUs formats a number of vCPUs with two decimals, or two significant +// digits below 0.1, so the small usage of an idle node does not show as 0. +func formatVCPUs(v float64) string { + if math.Abs(v) < 0.1 { + return output.Significant(v, 2) + } + + return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) +} + +func formatBytes(v float64) string { + return output.Bytes(uint64(max(v, 0))) +} diff --git a/internal/cmd/cluster/metrics_history_helpers.go b/internal/cmd/cluster/metrics_history_helpers.go new file mode 100644 index 0000000..05960e3 --- /dev/null +++ b/internal/cmd/cluster/metrics_history_helpers.go @@ -0,0 +1,201 @@ +package cluster + +import ( + "cmp" + "errors" + "fmt" + "io" + "slices" + "time" + + "github.com/spf13/cobra" + + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" +) + +const ( + defaultMetricsHistoryRange = 7 * 24 * time.Hour + metricsChartHeight = 14 + metricsChartDefaultWidth = 100 + metricsChartMinWidth = 40 + metricsChartMaxWidth = 200 +) + +type historyPoint struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` +} + +func addMetricsHistoryRangeFlags(cmd *cobra.Command) { + cmd.Flags().StringP("since", "s", "", "Start of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 24h or 7d; default: 7 days before --until)") + cmd.Flags().StringP("until", "u", "", "End of the time range (RFC3339, YYYY-MM-DD, or a duration ago such as 1h; default: now)") +} + +// readMetricsHistoryRange reads --since and --until, defaulting to the 7 days +// before --until, and --until to now. An --until in the future is cut to now: +// the API fills some series with zeros up to any time, which would read as +// measured values. +func readMetricsHistoryRange(cmd *cobra.Command, now time.Time) (time.Time, time.Time, error) { + sinceTS, untilTS, err := util.ReadTimeRange(cmd, now) + if err != nil { + return time.Time{}, time.Time{}, err + } + + if sinceTS != nil && !sinceTS.AsTime().Before(now) { + return time.Time{}, time.Time{}, errors.New("--since must be in the past") + } + + until := now + if untilTS != nil && untilTS.AsTime().Before(now) { + until = untilTS.AsTime() + } + + since := until.Add(-defaultMetricsHistoryRange) + if sinceTS != nil { + since = sinceTS.AsTime() + } + + if !since.Before(until) { + return time.Time{}, time.Time{}, errors.New("--since must be before --until") + } + + return since, until, nil +} + +// historyPoints converts metrics to points in chronological order. +func historyPoints(metrics []*monitoringv1.Metric) []historyPoint { + points := make([]historyPoint, 0, len(metrics)) + for _, m := range metrics { + points = append(points, historyPoint{Timestamp: m.GetTimestamp().AsTime().UTC(), Value: m.GetValue()}) + } + + slices.SortStableFunc(points, func(a, b historyPoint) int { return a.Timestamp.Compare(b.Timestamp) }) + + return points +} + +// valueAt returns the value of the point closest to t in points, which are in +// chronological order, or 0 when there are none. Series of the same query +// share timestamps, but taking the closest point keeps a stack intact when +// they do not. +func valueAt(points []historyPoint, t time.Time) float64 { + i, _ := slices.BinarySearchFunc(points, t, func(p historyPoint, t time.Time) int { return p.Timestamp.Compare(t) }) + switch { + case len(points) == 0: + return 0 + case i == 0: + return points[0].Value + case i == len(points): + return points[i-1].Value + case t.Sub(points[i-1].Timestamp) <= points[i].Timestamp.Sub(t): + return points[i-1].Value + default: + return points[i].Value + } +} + +// stack returns the points of base with offset, and the value at the same +// time in each of layers, added on top. +func stack(base []historyPoint, offset float64, layers ...[]historyPoint) []output.TimePoint { + points := make([]output.TimePoint, 0, len(base)) + for _, p := range base { + v := offset + p.Value + for _, l := range layers { + v += valueAt(l, p.Timestamp) + } + + points = append(points, output.TimePoint{Time: p.Timestamp, Value: v}) + } + + return points +} + +// constant returns a point with value v at the time of every point in base. +func constant(base []historyPoint, v float64) []output.TimePoint { + points := make([]output.TimePoint, 0, len(base)) + for _, p := range base { + points = append(points, output.TimePoint{Time: p.Timestamp, Value: v}) + } + + return points +} + +type metricsChart struct { + title string + // base is the series the chart is drawn for. The chart shows empty, or + // "no data" when empty is not set, when base has no points. + base []historyPoint + empty string + // total is the capacity, drawn as a line when set. + total float64 + series []output.ChartSeries + // yTop is the least top of the Y axis, such as 100 for a percentage. + yTop float64 + yLabel func(float64) string + yUnit float64 +} + +// metricsChartWidth returns the width of the charts written to w: the width +// of the terminal, within limits. +func metricsChartWidth(w io.Writer) int { + return min(max(output.TerminalWidth(w, metricsChartDefaultWidth), metricsChartMinWidth), metricsChartMaxWidth) +} + +// printMetricsCharts prints the title, legend and chart of each of charts, with +// the labels on their Y axes padded to the same width, so they line up. +func printMetricsCharts(w io.Writer, charts []metricsChart, since, until time.Time, width int, color bool) { + labelWidth := 0 + for _, c := range charts { + if len(c.base) > 0 { + labelWidth = max(labelWidth, output.TimeChartYLabelWidth(c.chartSeries(since, until), c.chartOptions(since, until, width, color))) + } + } + + for _, c := range charts { + fmt.Fprintln(w) + fmt.Fprintln(w, c.title) + if len(c.base) == 0 { + fmt.Fprintln(w, cmp.Or(c.empty, "no data")) + continue + } + + series := c.chartSeries(since, until) + opts := c.chartOptions(since, until, width, color) + opts.YLabelWidth = labelWidth + + fmt.Fprintln(w, output.ChartLegend(series, color)) + fmt.Fprintln(w, output.TimeChart(series, opts)) + } +} + +// chartSeries returns the series of c, with a capacity line across the whole +// time range when c has a total. +func (c metricsChart) chartSeries(since, until time.Time) []output.ChartSeries { + series := make([]output.ChartSeries, 0, len(c.series)+1) + if c.total > 0 { + series = append(series, output.ChartSeries{ + Name: "capacity " + c.yLabel(c.total), + Color: output.ChartColorCapacity, + Points: []output.TimePoint{{Time: since, Value: c.total}, {Time: until, Value: c.total}}, + }) + } + + return append(series, c.series...) +} + +func (c metricsChart) chartOptions(since, until time.Time, width int, color bool) output.TimeChartOptions { + return output.TimeChartOptions{ + Since: since, + Until: until, + YMax: c.total, + YTop: c.yTop, + YUnit: c.yUnit, + Width: width, + Height: metricsChartHeight, + YLabel: c.yLabel, + Color: color, + } +} diff --git a/internal/cmd/cluster/metrics_test.go b/internal/cmd/cluster/metrics_test.go new file mode 100644 index 0000000..9dcc979 --- /dev/null +++ b/internal/cmd/cluster/metrics_test.go @@ -0,0 +1,306 @@ +package cluster_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +const ( + metricsClusterID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe" + gib = 1 << 30 +) + +func overview(avg, total float64, unit string) *monitoringv1.ClusterMetricOverview { + return &monitoringv1.ClusterMetricOverview{ + Avg: []*monitoringv1.IntervalAverage{{Value: avg}}, + Total: &monitoringv1.ResourceValue{Value: total, Unit: unit}, + } +} + +func nodeMetrics(node string, cpu, ramQdrant, ramRss, cache, disk float64) *monitoringv1.ClusterNodeMetrics { + return &monitoringv1.ClusterNodeMetrics{ + NodeId: fmt.Sprintf("qdrant-%s-%s", metricsClusterID, node), + Cpu: overview(cpu, 2, "vcpu"), + Ram: overview(ramRss+cache, 8*gib, "bytes"), + RamCache: overview(cache, 8*gib, "bytes"), + RamRss: overview(ramRss, 8*gib, "bytes"), + RamQdrantRss: overview(ramQdrant, 8*gib, "bytes"), + Disk: overview(disk, 32*gib, "bytes"), + } +} + +func metricsCluster(cloudProvider string) *clusterv1.Cluster { + return &clusterv1.Cluster{ + Id: metricsClusterID, + CloudProviderId: cloudProvider, + State: &clusterv1.ClusterState{ + Resources: &clusterv1.ClusterNodeResourcesSummary{ + Cpu: &clusterv1.ClusterNodeResources{Base: 2000, Reserved: 200}, + Ram: &clusterv1.ClusterNodeResources{Base: 8, Reserved: 0.5}, + Disk: &clusterv1.ClusterNodeResources{Base: 32}, + }, + Nodes: []*clusterv1.ClusterNodeInfo{ + { + Name: fmt.Sprintf("qdrant-%s-0", metricsClusterID), + State: clusterv1.ClusterNodeState_CLUSTER_NODE_STATE_HEALTHY, + Version: "v1.16.3", + AvailabilityZone: new("us-east-1a"), + }, + { + Name: fmt.Sprintf("qdrant-%s-1", metricsClusterID), + State: clusterv1.ClusterNodeState_CLUSTER_NODE_STATE_RECOVERING, + Version: "v1.16.3", + }, + }, + }, + } +} + +func setupMetrics(env *testutil.TestEnv, cloudProvider string, nodes ...*monitoringv1.ClusterNodeMetrics) { + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: metricsCluster(cloudProvider)}, nil) + env.MonitoringServer.GetClusterSummaryMetricsCalls.Returns(&monitoringv1.GetClusterSummaryMetricsResponse{Nodes: nodes}, nil) +} + +func TestClusterMetrics_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", + nodeMetrics("1", 0.3, 3*gib, 4*gib, 1*gib, 4*gib), + nodeMetrics("0", 0.5, 2*gib, 3*gib, 1.5*gib, 8*gib), + ) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "Node 0 HEALTHY v1.16.3 us-east-1a") + assert.Contains(t, stdout, "Node 1 RECOVERING v1.16.3") + assert.Less(t, strings.Index(stdout, "Node 0"), strings.Index(stdout, "Node 1"), "nodes are sorted by number") + + // CPU: 0.2 reserved + 0.5 used of 2 vCPU. + assert.Contains(t, stdout, " 35.0% 0.7 of 2 vCPU") + assert.Contains(t, stdout, "\n system 0.2 usage 0.5 available 1.3\n") + // Small usage keeps two significant digits instead of rounding to 0. + assert.Contains(t, stdout, "\n system 0.2 usage 0.3 available 1.5\n") + // RAM: 0.5 GB (477 MiB) reserved + 1.5 GiB cache + 2 GiB data of 8 GiB. + assert.Contains(t, stdout, " 49.6% 4.0 GiB of 8.0 GiB") + assert.Contains(t, stdout, "system 477 MiB cache 1.5 GiB data 2.0 GiB available 4.0 GiB") + // Disk: 8 GiB of 32 GiB. + assert.Contains(t, stdout, " 25.0% 8.0 GiB of 32 GiB") + assert.Contains(t, stdout, "used 8.0 GiB available 24 GiB") + // Bars are bracketed without color. + assert.Contains(t, stdout, "CPU [") + assert.NotContains(t, stdout, "\x1b[") +} + +func TestClusterMetrics_SmallCPUUsage(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", nodeMetrics("0", 0.0044613, 2*gib, 3*gib, 1.5*gib, 8*gib)) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, " usage 0.0045 ") + assert.Contains(t, stdout, "0.2 of 2 vCPU") +} + +func TestClusterMetrics_HybridUsesNodeRSS(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, qcloudapi.HybridCloudProviderID, nodeMetrics("0", 0.5, 2*gib, 3*gib, 1.5*gib, 8*gib)) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "data 3.0 GiB") + assert.Contains(t, stdout, " 62.1% 5.0 GiB of 8.0 GiB") +} + +func TestClusterMetrics_NoData(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", &monitoringv1.ClusterNodeMetrics{ + NodeId: fmt.Sprintf("qdrant-%s-0", metricsClusterID), + Cpu: &monitoringv1.ClusterMetricOverview{Total: &monitoringv1.ResourceValue{Value: 2, Unit: "vcpu"}}, + Disk: overview(1*gib, 32*gib, "bytes"), + }) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "CPU no data") + assert.Contains(t, stdout, "RAM no data") + assert.Contains(t, stdout, "1.0 GiB of 32 GiB") +} + +func TestClusterMetrics_NoNodes(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws") + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + assert.Contains(t, stdout, "No metrics available for this cluster.") +} + +func TestClusterMetrics_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", nodeMetrics("0", 0.5, 2*gib, 3*gib, 1.5*gib, 8*gib)) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID, "--json") + require.NoError(t, err) + + var result struct { + ClusterID string `json:"clusterId"` + Nodes []struct { + Node string `json:"node"` + NodeID string `json:"nodeId"` + State string `json:"state"` + AvailabilityZone string `json:"availabilityZone"` + CPU struct { + UsedVCPUs float64 `json:"usedVcpus"` + ReservedVCPUs float64 `json:"reservedVcpus"` + AvailableVCPUs float64 `json:"availableVcpus"` + TotalVCPUs float64 `json:"totalVcpus"` + UsedPercent float64 `json:"usedPercent"` + } `json:"cpu"` + RAM struct { + DataBytes float64 `json:"dataBytes"` + CacheBytes float64 `json:"cacheBytes"` + ReservedBytes float64 `json:"reservedBytes"` + AvailableBytes float64 `json:"availableBytes"` + TotalBytes float64 `json:"totalBytes"` + UsedPercent float64 `json:"usedPercent"` + } `json:"ram"` + Disk struct { + UsedBytes float64 `json:"usedBytes"` + AvailableBytes float64 `json:"availableBytes"` + TotalBytes float64 `json:"totalBytes"` + UsedPercent float64 `json:"usedPercent"` + } `json:"disk"` + } `json:"nodes"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + + assert.Equal(t, metricsClusterID, result.ClusterID) + require.Len(t, result.Nodes, 1) + n := result.Nodes[0] + assert.Equal(t, "0", n.Node) + assert.Equal(t, fmt.Sprintf("qdrant-%s-0", metricsClusterID), n.NodeID) + assert.Equal(t, "HEALTHY", n.State) + assert.Equal(t, "us-east-1a", n.AvailabilityZone) + assert.InDelta(t, 0.5, n.CPU.UsedVCPUs, 1e-9) + assert.InDelta(t, 0.2, n.CPU.ReservedVCPUs, 1e-9) + assert.InDelta(t, 1.3, n.CPU.AvailableVCPUs, 1e-9) + assert.InDelta(t, 2, n.CPU.TotalVCPUs, 1e-9) + assert.InDelta(t, 35, n.CPU.UsedPercent, 1e-9) + assert.InDelta(t, 2*gib, n.RAM.DataBytes, 1) + assert.InDelta(t, 1.5*gib, n.RAM.CacheBytes, 1) + assert.InDelta(t, 0.5e9, n.RAM.ReservedBytes, 1) + assert.InDelta(t, 4.5*gib-0.5e9, n.RAM.AvailableBytes, 1) + assert.InDelta(t, 8*gib, n.RAM.TotalBytes, 1) + assert.InDelta(t, 49.6, n.RAM.UsedPercent, 1e-9) + assert.InDelta(t, 8*gib, n.Disk.UsedBytes, 1) + assert.InDelta(t, 24*gib, n.Disk.AvailableBytes, 1) + assert.InDelta(t, 32*gib, n.Disk.TotalBytes, 1) + assert.InDelta(t, 25, n.Disk.UsedPercent, 1e-9) +} + +func TestClusterMetrics_AvailableNeverNegative(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", nodeMetrics("0", 1.9, 7.8*gib, 7.8*gib, 0, 33*gib)) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID, "--json") + require.NoError(t, err) + + var result struct { + Nodes []struct { + CPU struct { + AvailableVCPUs float64 `json:"availableVcpus"` + } `json:"cpu"` + RAM struct { + AvailableBytes float64 `json:"availableBytes"` + } `json:"ram"` + Disk struct { + AvailableBytes float64 `json:"availableBytes"` + } `json:"disk"` + } `json:"nodes"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Nodes, 1) + assert.Zero(t, result.Nodes[0].CPU.AvailableVCPUs) + assert.Zero(t, result.Nodes[0].RAM.AvailableBytes) + assert.Zero(t, result.Nodes[0].Disk.AvailableBytes) +} + +func TestClusterMetrics_Requests(t *testing.T) { + env := testutil.NewTestEnv(t) + setupMetrics(env, "aws", nodeMetrics("0", 0.5, 2*gib, 3*gib, 1.5*gib, 8*gib)) + + _, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + clusterReq, ok := env.Server.GetClusterCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", clusterReq.GetAccountId()) + assert.Equal(t, metricsClusterID, clusterReq.GetClusterId()) + + metricsReq, ok := env.MonitoringServer.GetClusterSummaryMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", metricsReq.GetAccountId()) + assert.Equal(t, metricsClusterID, metricsReq.GetClusterId()) +} + +func TestClusterMetrics_MetricsError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: metricsCluster("aws")}, nil) + env.MonitoringServer.GetClusterSummaryMetricsCalls.Returns(nil, fmt.Errorf("metrics unavailable")) + + _, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster metrics") +} + +func TestClusterMetrics_ClusterError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster") + assert.Equal(t, 0, env.MonitoringServer.GetClusterSummaryMetricsCalls.Count()) +} + +func TestClusterMetrics_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "cluster", "metrics") + require.Error(t, err) +} + +func TestClusterMetrics_FitsIn80Columns(t *testing.T) { + env := testutil.NewTestEnv(t) + node := nodeMetrics("0", 1.25, 12*gib, 12*gib, 9*gib, 100*gib) + node.Cpu = overview(1.25, 16, "vcpu") + node.RamCache = overview(9*gib, 128*gib, "bytes") + node.RamQdrantRss = overview(12*gib, 128*gib, "bytes") + node.Disk = overview(100*gib, 1024*gib, "bytes") + setupMetrics(env, "aws", node) + + stdout, _, err := testutil.Exec(t, env, "cluster", "metrics", metricsClusterID) + require.NoError(t, err) + + for line := range strings.SplitSeq(stdout, "\n") { + assert.LessOrEqual(t, utf8.RuneCountInString(line), 80, line) + } + + assert.Contains(t, stdout, "\n system 477 MiB cache 9.0 GiB data 12 GiB available 107 GiB\n") +} diff --git a/internal/cmd/cluster/node_metrics_history.go b/internal/cmd/cluster/node_metrics_history.go new file mode 100644 index 0000000..895a53e --- /dev/null +++ b/internal/cmd/cluster/node_metrics_history.go @@ -0,0 +1,347 @@ +package cluster + +import ( + "fmt" + "io" + "math" + "slices" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/timestamppb" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/state" +) + +const bytesPerGiB = 1 << 30 + +type nodeMetricsHistory struct { + ClusterID string `json:"clusterId"` + Node string `json:"node"` + NodeID string `json:"nodeId"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + CPU cpuHistory `json:"cpu"` + RAM ramHistory `json:"ram"` + Disk diskHistory `json:"disk"` + // GPU and GPURAM are only set for a cluster with GPUs. + GPU *gpuHistory `json:"gpu,omitempty"` + GPURAM *gpuRAMHistory `json:"gpuRam,omitempty"` +} + +type cpuHistory struct { + ReservedVCPUs float64 `json:"reservedVcpus"` + TotalVCPUs float64 `json:"totalVcpus"` + Usage []historyPoint `json:"usage"` +} + +type ramHistory struct { + ReservedBytes float64 `json:"reservedBytes"` + TotalBytes float64 `json:"totalBytes"` + Cache []historyPoint `json:"cache"` + Data []historyPoint `json:"data"` +} + +type diskHistory struct { + TotalBytes float64 `json:"totalBytes"` + Used []historyPoint `json:"used"` +} + +type gpuHistory struct { + // UsagePercent is the GPU usage in percent. + UsagePercent []historyPoint `json:"usagePercent"` +} + +type gpuRAMHistory struct { + UsedBytes []historyPoint `json:"usedBytes"` +} + +func newNodeMetricsHistoryCommand(s *state.State) *cobra.Command { + cmd := base.DescribeCmd[*nodeMetricsHistory]{ + Use: "node-metrics-history ", + Short: "Show the CPU, RAM and disk usage of a cluster node over time", + Long: `Show the CPU, RAM and disk usage of a cluster node over time. + +The node is given by its number, such as 0, as shown by "qcloud cluster metrics", +or by its full node ID. Each resource is drawn as a stacked area chart, with a +red line marking the capacity of the node: CPU shows the resources reserved for +the system with the usage of the database on top, and RAM shows the reserved +memory, then the page cache, then the memory used by the database. Without +color, the areas are drawn as stacked lines instead. For a cluster with GPUs, +the GPU usage in percent and the GPU RAM in use are shown as well. + +By default, the usage over the last 7 days is shown. --since and --until accept +an RFC3339 timestamp, a YYYY-MM-DD date in UTC, or a duration ago such as 6h or +7d. When only --until is given, the range covers the 7 days before it. Times on +the charts are in UTC.`, + Example: `# Show the usage of node 0 over the last 7 days +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 + +# Show the usage of node 1 over the last 24 hours +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 1 --since 24h + +# Show the usage in a specific time range +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 --since 2026-10-01 --until 2026-10-07 + +# Output the time series as JSON +qcloud cluster node-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe 0 --json`, + Args: util.ExactArgs(2, "a cluster ID and a node"), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*nodeMetricsHistory, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + since, until, err := readMetricsHistoryRange(cmd, time.Now().Truncate(time.Second)) + if err != nil { + return nil, err + } + + clusterResp, err := client.Cluster().GetCluster(ctx, &clusterv1.GetClusterRequest{ + AccountId: accountID, + ClusterId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster: %w", err) + } + + metricsResp, err := client.Monitoring().GetClusterUsageMetrics(ctx, &monitoringv1.GetClusterUsageMetricsRequest{ + AccountId: accountID, + ClusterId: args[0], + Since: timestamppb.New(since), + Until: timestamppb.New(until), + }) + if err != nil { + return nil, fmt.Errorf("failed to get cluster metrics: %w", err) + } + + return buildNodeMetricsHistory(clusterResp.GetCluster(), metricsResp.GetNodes(), args[1], since, until) + }, + PrintText: func(_ *cobra.Command, w io.Writer, h *nodeMetricsHistory) error { + printNodeMetricsHistory(w, h, metricsChartWidth(w), output.ColorEnabled(w)) + return nil + }, + ValidArgsFunction: clusterNodeCompletion(s), + }.CobraCommand(s) + + addMetricsHistoryRangeFlags(cmd) + + return cmd +} + +// buildNodeMetricsHistory returns the usage of node, given by number or full +// ID. A node of the cluster without metrics in the time range has empty series. +func buildNodeMetricsHistory(cluster *clusterv1.Cluster, nodes []*monitoringv1.ClusterNodeUsageMetrics, node string, since, until time.Time) (*nodeMetricsHistory, error) { + var usage *monitoringv1.ClusterNodeUsageMetrics + var known []string + matches := func(nodeID string) bool { + known = append(known, nodeNumber(cluster.GetId(), nodeID)) + return nodeID == node || nodeNumber(cluster.GetId(), nodeID) == node + } + + for _, n := range nodes { + if usage == nil && matches(n.GetNodeId()) { + usage = n + } + } + + for _, info := range cluster.GetState().GetNodes() { + if usage == nil && matches(info.GetName()) { + usage = &monitoringv1.ClusterNodeUsageMetrics{NodeId: info.GetName()} + } + } + + if usage == nil { + slices.SortFunc(known, compareNodes) + known = slices.Compact(known) + if len(known) == 0 { + return nil, fmt.Errorf("node %q not found: the cluster has no nodes", node) + } + + return nil, fmt.Errorf("node %q not found, available nodes: %s", node, strings.Join(known, ", ")) + } + + resources := cluster.GetState().GetResources() + cache := historyPoints(usage.GetRamCache()) + + // Like the Qdrant Cloud console, use the RSS of the database process, or + // of the whole node on Hybrid Cloud. + ramMetric := usage.GetRamQdrantRss() + if cluster.GetCloudProviderId() == qcloudapi.HybridCloudProviderID { + ramMetric = usage.GetRamRss() + } + + data := historyPoints(ramMetric) + if len(data) == 0 { + // Without an RSS series, derive the data from the RAM including the + // cache, like the Qdrant Cloud console does. + for _, p := range historyPoints(usage.GetRam()) { + data = append(data, historyPoint{Timestamp: p.Timestamp, Value: max(p.Value-valueAt(cache, p.Timestamp), 0)}) + } + } + + history := &nodeMetricsHistory{ + ClusterID: cluster.GetId(), + Node: nodeNumber(cluster.GetId(), usage.GetNodeId()), + NodeID: usage.GetNodeId(), + Since: since.UTC(), + Until: until.UTC(), + CPU: cpuHistory{ + ReservedVCPUs: resources.GetCpu().GetReserved() / 1000, + TotalVCPUs: resourceTotal(resources.GetCpu()) / 1000, + Usage: historyPoints(usage.GetCpu()), + }, + RAM: ramHistory{ + ReservedBytes: resources.GetRam().GetReserved() * bytesPerGB, + TotalBytes: resourceTotal(resources.GetRam()) * bytesPerGiB, + Cache: cache, + Data: data, + }, + Disk: diskHistory{ + TotalBytes: resourceTotal(resources.GetDisk()) * bytesPerGiB, + Used: historyPoints(usage.GetDisk()), + }, + } + + if resources.GetGpu() != nil || len(usage.GetGpu()) > 0 || len(usage.GetGpuRam()) > 0 { + history.GPU = &gpuHistory{UsagePercent: historyPoints(usage.GetGpu())} + history.GPURAM = &gpuRAMHistory{UsedBytes: historyPoints(usage.GetGpuRam())} + } + + return history, nil +} + +// clusterNodeCompletion completes a cluster ID, then the numbers of its nodes. +func clusterNodeCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + clusterIDs := completion.ClusterIDCompletion(s) + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return clusterIDs(cmd, args, toComplete) + case 1: + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + resp, err := client.Cluster().GetCluster(ctx, &clusterv1.GetClusterRequest{AccountId: accountID, ClusterId: args[0]}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + nodes := make([]string, 0, len(resp.GetCluster().GetState().GetNodes())) + for _, n := range resp.GetCluster().GetState().GetNodes() { + nodes = append(nodes, nodeNumber(args[0], n.GetName())) + } + + slices.SortFunc(nodes, compareNodes) + + return nodes, cobra.ShellCompDirectiveNoFileComp + default: + return nil, cobra.ShellCompDirectiveNoFileComp + } + } +} + +// resourceTotal returns the capacity of a node resource, which is the sum of +// its base, complimentary and additional parts. +func resourceTotal(r *clusterv1.ClusterNodeResources) float64 { + return r.GetBase() + r.GetComplimentary() + r.GetAdditional() +} + +func printNodeMetricsHistory(w io.Writer, h *nodeMetricsHistory, width int, color bool) { + fmt.Fprintf(w, "Node %s %s - %s\n", h.Node, output.FullDateTime(h.Since), output.FullDateTime(h.Until)) + + printMetricsCharts(w, nodeMetricsHistoryCharts(*h), h.Since, h.Until, width, color) +} + +func nodeMetricsHistoryCharts(n nodeMetricsHistory) []metricsChart { + cpu, ram, disk := n.CPU, n.RAM, n.Disk + vcpus := func(v float64) string { return formatVCPUs(v) + " vCPU" } + + cpuSeries := []output.ChartSeries{} + if cpu.ReservedVCPUs > 0 { + cpuSeries = append(cpuSeries, output.ChartSeries{ + Name: "system " + vcpus(cpu.ReservedVCPUs), Color: output.BarColorSystem, Points: constant(cpu.Usage, cpu.ReservedVCPUs), Fill: true, + }) + } + + cpuSeries = append(cpuSeries, output.ChartSeries{ + Name: "+ usage", Color: output.BarColorData, Points: stack(cpu.Usage, cpu.ReservedVCPUs), Fill: true, + }) + + ramSeries := []output.ChartSeries{} + if ram.ReservedBytes > 0 { + ramSeries = append(ramSeries, output.ChartSeries{ + Name: "system " + formatBytes(ram.ReservedBytes), Color: output.BarColorSystem, Points: constant(ram.Data, ram.ReservedBytes), Fill: true, + }) + } + + ramSeries = append(ramSeries, + output.ChartSeries{Name: "+ cache", Color: output.BarColorCache, Points: stack(ram.Cache, ram.ReservedBytes), Fill: true}, + output.ChartSeries{Name: "+ data", Color: output.BarColorData, Points: stack(ram.Data, ram.ReservedBytes, ram.Cache), Fill: true}, + ) + + charts := []metricsChart{ + {title: "CPU", base: cpu.Usage, total: cpu.TotalVCPUs, series: cpuSeries, yLabel: vcpus}, + {title: "RAM", base: ram.Data, total: ram.TotalBytes, series: ramSeries, yLabel: formatBytes, yUnit: bytesPerGiB}, + { + title: "Disk", + base: disk.Used, + total: disk.TotalBytes, + series: []output.ChartSeries{{Name: "used", Color: output.BarColorData, Points: stack(disk.Used, 0), Fill: true}}, + yLabel: formatBytes, + yUnit: bytesPerGiB, + }, + } + + // The GPU charts have no capacity line, like in the Qdrant Cloud console: + // the usage can exceed 100% on a node with several GPUs. + if n.GPU != nil { + charts = append(charts, metricsChart{ + title: "GPU", + base: n.GPU.UsagePercent, + series: []output.ChartSeries{{Name: "usage", Color: output.BarColorData, Points: stack(n.GPU.UsagePercent, 0), Fill: true}}, + yTop: 100, + yLabel: formatPercent, + }) + } + + if n.GPURAM != nil { + charts = append(charts, metricsChart{ + title: "GPU RAM", + base: n.GPURAM.UsedBytes, + series: []output.ChartSeries{{Name: "used", Color: output.BarColorData, Points: stack(n.GPURAM.UsedBytes, 0), Fill: true}}, + yLabel: formatBytes, + yUnit: bytesPerGiB, + }) + } + + return charts +} + +func formatPercent(v float64) string { + return strconv.FormatFloat(math.Round(v*10)/10, 'f', -1, 64) + "%" +} diff --git a/internal/cmd/cluster/node_metrics_history_test.go b/internal/cmd/cluster/node_metrics_history_test.go new file mode 100644 index 0000000..84788f7 --- /dev/null +++ b/internal/cmd/cluster/node_metrics_history_test.go @@ -0,0 +1,515 @@ +package cluster_test + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" + monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" + + "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +var historyStart = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + +func series(values ...float64) []*monitoringv1.Metric { + metrics := make([]*monitoringv1.Metric, 0, len(values)) + for i, v := range values { + metrics = append(metrics, &monitoringv1.Metric{Timestamp: timestamppb.New(historyStart.Add(time.Duration(i) * time.Hour)), Value: v}) + } + + return metrics +} + +func usageNode(node string) *monitoringv1.ClusterNodeUsageMetrics { + return &monitoringv1.ClusterNodeUsageMetrics{ + NodeId: fmt.Sprintf("qdrant-%s-%s", metricsClusterID, node), + Cpu: series(0.4, 0.6, 0.5), + Ram: series(4*gib, 4.5*gib, 5*gib), + RamCache: series(1*gib, 1.5*gib, 2*gib), + RamRss: series(3*gib, 3*gib, 3*gib), + RamQdrantRss: series(2*gib, 2.5*gib, 2.5*gib), + Disk: series(8*gib, 9*gib, 10*gib), + } +} + +func historyCluster(cloudProvider string) *clusterv1.Cluster { + return &clusterv1.Cluster{ + Id: metricsClusterID, + CloudProviderId: cloudProvider, + State: &clusterv1.ClusterState{ + Resources: &clusterv1.ClusterNodeResourcesSummary{ + Cpu: &clusterv1.ClusterNodeResources{Base: 1500, Additional: 500, Reserved: 200}, + Ram: &clusterv1.ClusterNodeResources{Base: 6, Complimentary: 2, Reserved: 0.5}, + Disk: &clusterv1.ClusterNodeResources{Base: 32}, + }, + Nodes: []*clusterv1.ClusterNodeInfo{ + {Name: fmt.Sprintf("qdrant-%s-0", metricsClusterID)}, + {Name: fmt.Sprintf("qdrant-%s-1", metricsClusterID)}, + {Name: fmt.Sprintf("qdrant-%s-2", metricsClusterID)}, + }, + }, + } +} + +func setupHistory(env *testutil.TestEnv, cloudProvider string, nodes ...*monitoringv1.ClusterNodeUsageMetrics) { + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: historyCluster(cloudProvider)}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(&monitoringv1.GetClusterUsageMetricsResponse{Nodes: nodes}, nil) +} + +type historyJSON struct { + ClusterID string `json:"clusterId"` + Node string `json:"node"` + NodeID string `json:"nodeId"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + CPU struct { + ReservedVCPUs float64 `json:"reservedVcpus"` + TotalVCPUs float64 `json:"totalVcpus"` + Usage []struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` + } `json:"usage"` + } `json:"cpu"` + RAM struct { + ReservedBytes float64 `json:"reservedBytes"` + TotalBytes float64 `json:"totalBytes"` + Cache []struct { + Value float64 `json:"value"` + } `json:"cache"` + Data []struct { + Value float64 `json:"value"` + } `json:"data"` + } `json:"ram"` + Disk struct { + TotalBytes float64 `json:"totalBytes"` + Used []struct { + Value float64 `json:"value"` + } `json:"used"` + } `json:"disk"` +} + +func execHistoryJSON(t *testing.T, env *testutil.TestEnv, node string, args ...string) historyJSON { + t.Helper() + stdout, _, err := testutil.Exec(t, env, append([]string{"cluster", "node-metrics-history", metricsClusterID, node, "--json"}, args...)...) + require.NoError(t, err) + + var result historyJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + + return result +} + +func TestNodeMetricsHistory_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("1"), usageNode("0")) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "1", + "--since", "2026-10-20T00:00:00Z", "--until", "2026-10-20T02:00:00Z") + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(stdout, "Node 1 2026-10-20 00:00:00 UTC - 2026-10-20 02:00:00 UTC\n"), stdout) + assert.NotContains(t, stdout, "Node 0") + assert.Contains(t, stdout, "capacity 2 vCPU system 0.2 vCPU + usage") + assert.Contains(t, stdout, "capacity 8.0 GiB system 477 MiB + cache + data") + assert.Contains(t, stdout, "capacity 32 GiB used") + // The Y axes are rounded to even steps. + assert.Contains(t, stdout, "2.4 vCPU│") + assert.Contains(t, stdout, "10 GiB│") + assert.Contains(t, stdout, "40 GiB│") + // A range of up to a day shows times only. + assert.Contains(t, stdout, "00:00") + assert.NotContains(t, stdout, "10/20") + assert.NotContains(t, stdout, "\x1b[") +} + +func TestNodeMetricsHistory_DefaultRange(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + before := time.Now().Truncate(time.Second) + result := execHistoryJSON(t, env, "0") + after := time.Now() + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, metricsClusterID, req.GetClusterId()) + until := req.GetUntil().AsTime() + assert.False(t, until.Before(before) || until.After(after), "until defaults to now") + assert.Equal(t, 7*24*time.Hour, until.Sub(req.GetSince().AsTime())) + assert.Nil(t, req.Aggregator) + + assert.True(t, result.Until.Equal(until)) + assert.True(t, result.Since.Equal(req.GetSince().AsTime())) +} + +func TestNodeMetricsHistory_UntilOnly(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + execHistoryJSON(t, env, "0", "--until", "2026-10-10T00:00:00Z") + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), req.GetSince().AsTime()) + assert.Equal(t, time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), req.GetUntil().AsTime()) +} + +func TestNodeMetricsHistory_SinceAndUntil(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + execHistoryJSON(t, env, "0", "--since", "2026-10-01", "--until", "2026-10-02") + + req, ok := env.MonitoringServer.GetClusterUsageMetricsCalls.Last() + require.True(t, ok) + assert.Equal(t, time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), req.GetSince().AsTime()) + assert.Equal(t, time.Date(2026, 10, 2, 23, 59, 59, 0, time.UTC), req.GetUntil().AsTime()) +} + +func TestNodeMetricsHistory_InvalidRange(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--since", "1h", "--until", "2h") + require.Error(t, err) + assert.Contains(t, err.Error(), "--since must be before --until") + + _, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--since", "2026-10-20", "--until", "2026-10-01") + require.Error(t, err) + + _, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--since", "yesterday") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --since") + assert.Equal(t, 0, env.MonitoringServer.GetClusterUsageMetricsCalls.Count()) +} + +func TestNodeMetricsHistory_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + result := execHistoryJSON(t, env, "0") + + assert.Equal(t, metricsClusterID, result.ClusterID) + n := result + assert.Equal(t, "0", n.Node) + assert.Equal(t, fmt.Sprintf("qdrant-%s-0", metricsClusterID), n.NodeID) + + assert.InDelta(t, 0.2, n.CPU.ReservedVCPUs, 1e-9) + assert.InDelta(t, 2, n.CPU.TotalVCPUs, 1e-9) + require.Len(t, n.CPU.Usage, 3) + assert.True(t, n.CPU.Usage[1].Timestamp.Equal(historyStart.Add(time.Hour))) + assert.InDelta(t, 0.6, n.CPU.Usage[1].Value, 1e-9) + + assert.InDelta(t, 0.5e9, n.RAM.ReservedBytes, 1) + assert.InDelta(t, 8*gib, n.RAM.TotalBytes, 1) + require.Len(t, n.RAM.Cache, 3) + assert.InDelta(t, 1.5*gib, n.RAM.Cache[1].Value, 1) + require.Len(t, n.RAM.Data, 3) + assert.InDelta(t, 2.5*gib, n.RAM.Data[1].Value, 1, "data is the RSS of the database") + + assert.InDelta(t, 32*gib, n.Disk.TotalBytes, 1) + require.Len(t, n.Disk.Used, 3) + assert.InDelta(t, 10*gib, n.Disk.Used[2].Value, 1) +} + +func TestNodeMetricsHistory_HybridUsesNodeRSS(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, qcloudapi.HybridCloudProviderID, usageNode("0")) + + result := execHistoryJSON(t, env, "0") + + require.Len(t, result.RAM.Data, 3) + assert.InDelta(t, 3*gib, result.RAM.Data[1].Value, 1) +} + +func TestNodeMetricsHistory_DataFallsBackToRAMWithoutCache(t *testing.T) { + env := testutil.NewTestEnv(t) + node := usageNode("0") + node.RamQdrantRss = nil + setupHistory(env, "aws", node) + + result := execHistoryJSON(t, env, "0") + + require.Len(t, result.RAM.Data, 3) + assert.InDelta(t, 3*gib, result.RAM.Data[1].Value, 1, "RAM 4.5 GiB less cache 1.5 GiB") +} + +func TestNodeMetricsHistory_FallbackMatchesNearestCachePoint(t *testing.T) { + env := testutil.NewTestEnv(t) + node := usageNode("0") + node.RamQdrantRss = nil + // The cache is sampled 30 seconds after the RAM. + for _, m := range node.RamCache { + m.Timestamp = timestamppb.New(m.GetTimestamp().AsTime().Add(30 * time.Second)) + } + + setupHistory(env, "aws", node) + + result := execHistoryJSON(t, env, "0") + + require.Len(t, result.RAM.Data, 3) + assert.InDelta(t, 3*gib, result.RAM.Data[0].Value, 1, "RAM 4 GiB less cache 1 GiB") + assert.InDelta(t, 3*gib, result.RAM.Data[1].Value, 1, "RAM 4.5 GiB less cache 1.5 GiB") + assert.InDelta(t, 3*gib, result.RAM.Data[2].Value, 1, "RAM 5 GiB less cache 2 GiB") +} + +func TestNodeMetricsHistory_PointsAreSorted(t *testing.T) { + env := testutil.NewTestEnv(t) + node := usageNode("0") + slices.Reverse(node.Cpu) + setupHistory(env, "aws", node) + + result := execHistoryJSON(t, env, "0") + + require.Len(t, result.CPU.Usage, 3) + assert.True(t, result.CPU.Usage[0].Timestamp.Equal(historyStart)) + assert.InDelta(t, 0.4, result.CPU.Usage[0].Value, 1e-9) +} + +func TestNodeMetricsHistory_NodeByNumberOrID(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0"), usageNode("1"), usageNode("2")) + + result := execHistoryJSON(t, env, "1") + assert.Equal(t, "1", result.Node) + assert.Equal(t, fmt.Sprintf("qdrant-%s-1", metricsClusterID), result.NodeID) + + result = execHistoryJSON(t, env, fmt.Sprintf("qdrant-%s-2", metricsClusterID)) + assert.Equal(t, "2", result.Node) +} + +func TestNodeMetricsHistory_UnknownNode(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("1"), usageNode("0"), usageNode("3")) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "5") + require.Error(t, err) + assert.Contains(t, err.Error(), `node "5" not found, available nodes: 0, 1, 2, 3`) +} + +func TestNodeMetricsHistory_NoNodes(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: &clusterv1.Cluster{Id: metricsClusterID}}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(&monitoringv1.GetClusterUsageMetricsResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.Error(t, err) + assert.Contains(t, err.Error(), `node "0" not found: the cluster has no nodes`) +} + +func TestNodeMetricsHistory_NodeWithoutMetrics(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "2") + require.NoError(t, err) + + assert.Contains(t, stdout, "Node 2 ") + assert.Contains(t, stdout, "CPU\nno data") + assert.Contains(t, stdout, "RAM\nno data") + assert.Contains(t, stdout, "Disk\nno data") +} + +func TestNodeMetricsHistory_PartialData(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", &monitoringv1.ClusterNodeUsageMetrics{ + NodeId: fmt.Sprintf("qdrant-%s-0", metricsClusterID), + Disk: series(8*gib, 9*gib), + }) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + + assert.Contains(t, stdout, "CPU\nno data") + assert.Contains(t, stdout, "RAM\nno data") + assert.Contains(t, stdout, "capacity 32 GiB used") +} + +func TestNodeMetricsHistory_NodeCompletion(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws") + + stdout, _, err := testutil.Exec(t, env, "__complete", "cluster", "node-metrics-history", metricsClusterID, "") + require.NoError(t, err) + assert.Equal(t, []string{"0", "1", "2", ":4"}, strings.Fields(strings.Split(stdout, "Completion ended")[0])) + + req, ok := env.Server.GetClusterCalls.Last() + require.True(t, ok) + assert.Equal(t, metricsClusterID, req.GetClusterId()) +} + +func TestNodeMetricsHistory_MetricsError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: historyCluster("aws")}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(nil, fmt.Errorf("metrics unavailable")) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster metrics") +} + +func TestNodeMetricsHistory_ClusterError(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cluster") + assert.Equal(t, 0, env.MonitoringServer.GetClusterUsageMetricsCalls.Count()) +} + +func TestNodeMetricsHistory_MissingArgs(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history") + require.Error(t, err) + + _, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID) + require.Error(t, err) + assert.Equal(t, 0, env.MonitoringServer.GetClusterUsageMetricsCalls.Count()) +} + +func gpuCluster() *clusterv1.Cluster { + cluster := historyCluster("aws") + cluster.State.Resources.Gpu = &clusterv1.ClusterNodeResources{Base: 1000} + return cluster +} + +func TestNodeMetricsHistory_GPU(t *testing.T) { + env := testutil.NewTestEnv(t) + node := usageNode("0") + node.Gpu = series(40, 80, 60) + node.GpuRam = series(10*gib, 12*gib, 11*gib) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: gpuCluster()}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(&monitoringv1.GetClusterUsageMetricsResponse{ + Nodes: []*monitoringv1.ClusterNodeUsageMetrics{node}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + + assert.Regexp(t, "\nGPU\nusage\n *100%│", stdout, "the GPU axis ends at 100% without a capacity line") + assert.Regexp(t, "\nGPU RAM\nused\n *16 GiB│", stdout) + assert.Less(t, strings.Index(stdout, "\nDisk\n"), strings.Index(stdout, "\nGPU\n")) + + stdout, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--json") + require.NoError(t, err) + + var result struct { + GPU struct { + UsagePercent []struct { + Value float64 `json:"value"` + } `json:"usagePercent"` + } `json:"gpu"` + GPURAM struct { + UsedBytes []struct { + Value float64 `json:"value"` + } `json:"usedBytes"` + } `json:"gpuRam"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.GPU.UsagePercent, 3) + assert.InDelta(t, 80, result.GPU.UsagePercent[1].Value, 1e-9) + require.Len(t, result.GPURAM.UsedBytes, 3) + assert.InDelta(t, 12*gib, result.GPURAM.UsedBytes[1].Value, 1) +} + +func TestNodeMetricsHistory_GPUClusterWithoutData(t *testing.T) { + env := testutil.NewTestEnv(t) + env.Server.GetClusterCalls.Returns(&clusterv1.GetClusterResponse{Cluster: gpuCluster()}, nil) + env.MonitoringServer.GetClusterUsageMetricsCalls.Returns(&monitoringv1.GetClusterUsageMetricsResponse{ + Nodes: []*monitoringv1.ClusterNodeUsageMetrics{usageNode("0")}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + + assert.Contains(t, stdout, "\nGPU\nno data\n") + assert.Contains(t, stdout, "\nGPU RAM\nno data\n") + + stdout, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--json") + require.NoError(t, err) + assert.Contains(t, stdout, `"usagePercent": []`) + assert.Contains(t, stdout, `"usedBytes": []`) +} + +func TestNodeMetricsHistory_GPUDataWithoutGPUResources(t *testing.T) { + env := testutil.NewTestEnv(t) + node := usageNode("0") + node.Gpu = series(40, 80, 60) + setupHistory(env, qcloudapi.HybridCloudProviderID, node) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + + assert.Contains(t, stdout, "\nGPU\nusage\n") + assert.Contains(t, stdout, "\nGPU RAM\nno data\n") +} + +func TestNodeMetricsHistory_NoGPU(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + assert.NotContains(t, stdout, "GPU") + + stdout, _, err = testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--json") + require.NoError(t, err) + assert.NotContains(t, stdout, "gpu") +} + +func TestNodeMetricsHistory_CapacitySpansRange(t *testing.T) { + env := testutil.NewTestEnv(t) + // The node only has data in the last hour of the default 7 days. + recent := func(v float64) []*monitoringv1.Metric { + return []*monitoringv1.Metric{{Timestamp: timestamppb.New(time.Now().Add(-30 * time.Minute)), Value: v}} + } + setupHistory(env, "aws", &monitoringv1.ClusterNodeUsageMetrics{ + NodeId: fmt.Sprintf("qdrant-%s-0", metricsClusterID), + Cpu: recent(0.5), + Disk: recent(8 * gib), + }) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0") + require.NoError(t, err) + + notBraille := regexp.MustCompile("[^\u2801-\u28ff]") + disk := stdout[strings.Index(stdout, "\nDisk\n"):] + widest := 0 + for line := range strings.SplitSeq(disk, "\n") { + widest = max(widest, utf8.RuneCountInString(notBraille.ReplaceAllString(line, ""))) + } + + assert.Greater(t, widest, 80, "the capacity line runs across the chart") +} + +func TestNodeMetricsHistory_ChartsLineUp(t *testing.T) { + env := testutil.NewTestEnv(t) + setupHistory(env, "aws", usageNode("0")) + + stdout, _, err := testutil.Exec(t, env, "cluster", "node-metrics-history", metricsClusterID, "0", "--since", "2026-10-20") + require.NoError(t, err) + + columns := map[int]bool{} + for line := range strings.SplitSeq(stdout, "\n") { + if i := strings.IndexAny(line, "│└"); i >= 0 { + columns[utf8.RuneCountInString(line[:i])] = true + } + } + + assert.Len(t, columns, 1, "the Y axes of all charts are in the same column") +} diff --git a/internal/cmd/output/bar.go b/internal/cmd/output/bar.go new file mode 100644 index 0000000..a09ef7a --- /dev/null +++ b/internal/cmd/output/bar.go @@ -0,0 +1,105 @@ +package output + +import ( + "io" + "os" + "strings" + + "charm.land/lipgloss/v2" + "github.com/NimbleMarkets/ntcharts/v2/barchart" + "github.com/charmbracelet/colorprofile" +) + +// ANSI colors used for the segments of a usage bar. +const ( + BarColorSystem = "4" // blue + BarColorCache = "3" // yellow + BarColorData = "2" // green + BarColorFree = "8" // gray +) + +// minUsageBarWidth is the narrowest a usage bar is drawn. +const minUsageBarWidth = 3 + +// BarSegment is one stacked part of a usage bar. +type BarSegment struct { + Value float64 + Color string +} + +// ColorEnabled reports whether w is a terminal that supports colors, honoring +// NO_COLOR and TERM=dumb. +func ColorEnabled(w io.Writer) bool { + return colorprofile.Detect(w, os.Environ()) >= colorprofile.ANSI +} + +// UsageBar renders segments as a horizontal stacked bar of width cells, scaled +// so that a full bar equals total. With color, every segment has its own color +// and the remaining capacity is drawn as a gray track. Without color, the used +// part is drawn inside brackets, since uncolored segments look the same. +func UsageBar(segments []BarSegment, total float64, width int, color bool) string { + // Leave room for at least one cell between the brackets of an uncolored bar. + width = max(width, minUsageBarWidth) + if !color { + width -= 2 + } + + values := make([]barchart.BarValue, 0, len(segments)+1) + used := 0.0 + for _, s := range segments { + if s.Value <= 0 { + continue + } + + values = append(values, barchart.BarValue{Value: s.Value, Style: barStyle(s.Color, color)}) + used += s.Value + } + + if !color { + // Uncolored segments are indistinguishable, and their boundaries would + // show up as partial blocks in the middle of the bar. + values = []barchart.BarValue{{Value: used}} + } else if total > used { + values = append(values, barchart.BarValue{Value: total - used, Style: barStyle(BarColorFree, color)}) + } + + // A partially filled cell shows the next segment's color behind it, so + // adjacent segments meet without a gap. + for i := 0; color && i < len(values)-1; i++ { + values[i].Style = values[i].Style.Background(values[i+1].Style.GetForeground()) + } + + chart := barchart.New(width, 1, + barchart.WithHorizontalBars(), + barchart.WithNoAxis(), + barchart.WithNoAutoMaxValue(), + barchart.WithMaxValue(total), + ) + chart.Push(barchart.BarData{Values: values}) + chart.Draw() + bar := chart.View() + + if !color { + return "[" + bar + strings.Repeat(" ", max(width-lipgloss.Width(bar), 0)) + "]" + } + + return bar +} + +// BarLegend renders a colored swatch followed by label, or just the label +// without color. +func BarLegend(label, barColor string, color bool) string { + if !color { + return label + } + + return lipgloss.NewStyle().Foreground(lipgloss.Color(barColor)).Render("■") + " " + label +} + +func barStyle(barColor string, color bool) lipgloss.Style { + if !color { + return lipgloss.NewStyle() + } + + return lipgloss.NewStyle().Foreground(lipgloss.Color(barColor)) +} diff --git a/internal/cmd/output/bar_test.go b/internal/cmd/output/bar_test.go new file mode 100644 index 0000000..c823843 --- /dev/null +++ b/internal/cmd/output/bar_test.go @@ -0,0 +1,75 @@ +package output_test + +import ( + "bytes" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + + "github.com/qdrant/qcloud-cli/internal/cmd/output" +) + +func TestUsageBar_Plain(t *testing.T) { + bar := output.UsageBar([]output.BarSegment{ + {Value: 1, Color: output.BarColorSystem}, + {Value: 4, Color: output.BarColorData}, + }, 10, 12, false) + + assert.Equal(t, "[█████ ]", bar) +} + +func TestUsageBar_PlainOverCapacity(t *testing.T) { + bar := output.UsageBar([]output.BarSegment{{Value: 15, Color: output.BarColorData}}, 10, 12, false) + + assert.Equal(t, "[██████████]", bar) +} + +func TestUsageBar_Color(t *testing.T) { + bar := output.UsageBar([]output.BarSegment{ + {Value: 2, Color: output.BarColorSystem}, + {Value: 0, Color: output.BarColorCache}, + {Value: 3, Color: output.BarColorData}, + }, 10, 10, true) + + assert.Equal(t, 10, lipgloss.Width(bar)) + assert.Equal(t, strings.Repeat("█", 10), stripANSI(bar)) + assert.Contains(t, bar, "\x1b[34", "system segment is blue") + assert.Contains(t, bar, "\x1b[32", "data segment is green") + assert.Contains(t, bar, "\x1b[90", "free capacity is gray") + assert.NotContains(t, bar, "\x1b[33", "empty cache segment is not drawn") +} + +func TestBarLegend(t *testing.T) { + assert.Equal(t, "cache 1 GiB", output.BarLegend("cache 1 GiB", output.BarColorCache, false)) + assert.Equal(t, "■ cache 1 GiB", stripANSI(output.BarLegend("cache 1 GiB", output.BarColorCache, true))) +} + +func TestColorEnabled_NotATerminal(t *testing.T) { + assert.False(t, output.ColorEnabled(&bytes.Buffer{})) +} + +func stripANSI(s string) string { + var b strings.Builder + inEscape := false + for _, r := range s { + switch { + case r == '\x1b': + inEscape = true + case inEscape && r == 'm': + inEscape = false + case !inEscape: + b.WriteRune(r) + } + } + + return b.String() +} + +func TestUsageBar_NarrowWidth(t *testing.T) { + for _, width := range []int{-1, 0, 1, 2} { + assert.Equal(t, "[█]", output.UsageBar([]output.BarSegment{{Value: 5}}, 5, width, false)) + assert.NotPanics(t, func() { output.UsageBar([]output.BarSegment{{Value: 5}}, 5, width, true) }) + } +} diff --git a/internal/cmd/output/format.go b/internal/cmd/output/format.go index 6cf113b..c3b26e0 100644 --- a/internal/cmd/output/format.go +++ b/internal/cmd/output/format.go @@ -2,6 +2,7 @@ package output import ( "fmt" + "math" "reflect" "strconv" "strings" @@ -44,6 +45,21 @@ func Rate(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) + "/s" } +// Significant formats v rounded to the given number of significant digits, +// without trailing zeros or an exponent (e.g. 0.0016, 1.25, 1300). +func Significant(v float64, digits int) string { + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return strconv.FormatFloat(v, 'f', -1, 64) + } + + // Rounding to digits significant digits keeps that many digits after the + // first non-zero one. + decimals := max(digits-1-int(math.Floor(math.Log10(math.Abs(v)))), 0) + scale := math.Pow(10, float64(decimals)) + + return strconv.FormatFloat(math.Round(v*scale)/scale, 'f', -1, 64) +} + // Milliseconds formats a value in milliseconds with one decimal (e.g. "12.3ms"). func Milliseconds(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + "ms" diff --git a/internal/cmd/output/linechart.go b/internal/cmd/output/linechart.go new file mode 100644 index 0000000..428100d --- /dev/null +++ b/internal/cmd/output/linechart.go @@ -0,0 +1,304 @@ +package output + +import ( + "fmt" + "io" + "math" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/NimbleMarkets/ntcharts/v2/canvas" + "github.com/NimbleMarkets/ntcharts/v2/canvas/graph" + "github.com/NimbleMarkets/ntcharts/v2/linechart/timeserieslinechart" + "github.com/charmbracelet/x/term" +) + +const ( + // chartYStep is the number of rows between two labels on the Y axis. + chartYStep = 3 + // minTimeChartWidth is the narrowest a time chart is drawn, so it keeps + // room for its labels and plot. + minTimeChartWidth = 20 +) + +// Colors of the lines in a time chart. +const ( + ChartColorCapacity = "1" // red + ChartColorRequests = "3" // yellow + ChartColorLatency = "1" // red +) + +// TimePoint is a value at a point in time. +type TimePoint struct { + Time time.Time + Value float64 +} + +// ChartSeries is one line of a time chart. +type ChartSeries struct { + Name string + Color string + Points []TimePoint + // Fill draws the area below the line instead of the line. Filled series + // are stacked: each one must include the values of the filled series + // before it, so it is drawn on top of them. Without color, a filled series + // is drawn as a line, since areas of the same look would hide each other. + Fill bool +} + +// TimeChartOptions configures TimeChart. +type TimeChartOptions struct { + Since, Until time.Time + // YMax is the highest value the Y axis must show. It is raised to fit + // every value, then rounded up to a value that gives even labels, with + // some room above it. + YMax float64 + // YTop is the least top of the Y axis, such as 100 for a percentage. + // Unlike YMax, it gets no room above it. + YTop float64 + // YUnit is the unit labels are rounded to, such as 1 GiB for bytes. + // It defaults to 1. + YUnit float64 + Width int + Height int + // YLabel formats the values on the Y axis. + YLabel func(float64) string + // YLabelWidth pads the labels on the Y axis to at least this width, so + // charts printed below each other line up. See TimeChartYLabelWidth. + YLabelWidth int + Color bool +} + +// TimeChart renders series over the time range in opts. Line series are drawn +// as braille lines in order, so a later one is drawn over an earlier one where +// they meet, and filled series as stacked areas on top of them. Points outside +// the time range and series without points are left out. +func TimeChart(series []ChartSeries, opts TimeChartOptions) string { + series = clipToRange(series, opts.Since, opts.Until) + plotHeight, yRange := timeChartYAxis(series, opts) + + timeFormat := "01/02 15:04" + if opts.Until.Sub(opts.Since) <= 24*time.Hour { + timeFormat = "15:04" + } + + chart := timeserieslinechart.New(max(opts.Width, minTimeChartWidth), plotHeight+2, + timeserieslinechart.WithTimeRange(opts.Since, opts.Until), + timeserieslinechart.WithYRange(0, yRange), + timeserieslinechart.WithXYSteps(4, chartYStep), + timeserieslinechart.WithXLabelFormatter(func(_ int, v float64) string { + return time.UnixMilli(int64(v * 1e3)).UTC().Format(timeFormat) + }), + timeserieslinechart.WithYLabelFormatter(func(_ int, v float64) string { + return fmt.Sprintf("%*s", opts.YLabelWidth, opts.YLabel(v)) + }), + ) + + lines := make([]string, 0, len(series)) + var fills []ChartSeries + for _, s := range series { + if len(s.Points) == 0 { + continue + } + + if s.Fill && opts.Color { + fills = append(fills, s) + continue + } + + chart.SetDataSetStyle(s.Name, barStyle(s.Color, opts.Color)) + for _, p := range s.Points { + chart.PushDataSet(s.Name, timeserieslinechart.TimePoint{Time: p.Time, Value: p.Value}) + } + + lines = append(lines, s.Name) + } + + if len(lines) > 0 { + chart.DrawBrailleDataSets(lines) + } else { + chart.DrawXYAxisAndLabel() + } + + drawAreas(&chart, fills, opts.Since, opts.Until, yRange) + + return chart.View() +} + +// TimeChartYLabelWidth returns the width of the widest label on the Y axis of +// the chart TimeChart draws for series and opts. +func TimeChartYLabelWidth(series []ChartSeries, opts TimeChartOptions) int { + plotHeight, yRange := timeChartYAxis(clipToRange(series, opts.Since, opts.Until), opts) + + width := 0 + for row := 0; row <= plotHeight; row += chartYStep { + width = max(width, len(opts.YLabel(yRange*float64(row)/float64(plotHeight)))) + } + + return width +} + +// timeChartYAxis returns the height of the plot area and the top of the Y +// axis of a chart. +func timeChartYAxis(series []ChartSeries, opts TimeChartOptions) (int, float64) { + yMax := opts.YMax + for _, s := range series { + for _, p := range s.Points { + yMax = max(yMax, p.Value) + } + } + + if yMax <= 0 { + yMax = 1 + } + + yUnit := opts.YUnit + if yUnit <= 0 { + yUnit = 1 + } + + // The plot area is the height less the X axis and its labels. Labels sit + // on every chartYStep-th row, so a plot area of a multiple of chartYStep + // rows has labels at even fractions of the range. + plotHeight := max((opts.Height-2)/chartYStep*chartYStep, chartYStep) + // Leave some room above the highest value, so a line at the top, such as + // the capacity, does not merge with the edge of the chart. + yRange := niceAxisMax(yMax*1.05, yUnit, plotHeight/chartYStep) + if opts.YTop > 0 { + yRange = max(yRange, niceAxisMax(opts.YTop, yUnit, plotHeight/chartYStep)) + } + + return plotHeight, yRange +} + +// clipToRange returns series with only the points within [since, until]. The +// chart widens its time range to fit every point, which would move it away +// from the range the areas are drawn in. +func clipToRange(series []ChartSeries, since, until time.Time) []ChartSeries { + clipped := make([]ChartSeries, len(series)) + for i, s := range series { + clipped[i] = s + clipped[i].Points = slices.DeleteFunc(slices.Clone(s.Points), func(p TimePoint) bool { + return p.Time.Before(since) || p.Time.After(until) + }) + } + + return clipped +} + +// drawAreas fills the area below each series as columns of block runes, one +// per column of the chart. The outermost series is drawn first, and each one +// inside it over its lower part, so the stack shows every series in its color. +func drawAreas(chart *timeserieslinechart.Model, fills []ChartSeries, since, until time.Time, yRange float64) { + width, height := chart.GraphWidth(), chart.GraphHeight() + if len(fills) == 0 || width <= 0 || height <= 0 { + return + } + + startX := chart.Origin().X + if chart.YStep() > 0 { + startX++ + } + + bottom := chart.Origin().Y - 1 + columnDuration := until.Sub(since) / time.Duration(width) + for i, fill := range slices.Backward(fills) { + style := barStyle(fill.Color, true) + if i+1 < len(fills) { + // A partially filled top cell shows the series above behind it. + style = style.Background(barStyle(fills[i+1].Color, true).GetForeground()) + } + + for col := range width { + from := since.Add(time.Duration(col) * columnDuration) + v, ok := columnValue(fill.Points, from, from.Add(columnDuration)) + if !ok { + continue + } + + rows := math.Min(v/yRange*float64(height), float64(height)) + graph.DrawColumnBottomToTop(&chart.Canvas, canvas.Point{X: startX + col, Y: bottom}, rows, style) + } + } +} + +// columnValue returns the average of the points within [from, to), or when +// there are none, the value interpolated between the points around the middle +// of the range. It reports false outside of the points. +func columnValue(points []TimePoint, from, to time.Time) (float64, bool) { + sum, n := 0.0, 0 + for _, p := range points { + if !p.Time.Before(from) && p.Time.Before(to) { + sum += p.Value + n++ + } + } + + if n > 0 { + return sum / float64(n), true + } + + mid := from.Add(to.Sub(from) / 2) + for i := 1; i < len(points); i++ { + a, b := points[i-1], points[i] + if a.Time.After(mid) || b.Time.Before(mid) { + continue + } + + span := b.Time.Sub(a.Time) + if span <= 0 { + return b.Value, true + } + + return a.Value + (b.Value-a.Value)*float64(mid.Sub(a.Time))/float64(span), true + } + + return 0, false +} + +// niceAxisMax returns the smallest value of at least v that splits into +// intervals equal steps of 1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten +// units. +func niceAxisMax(v, unit float64, intervals int) float64 { + step := v / unit / float64(intervals) + magnitude := math.Pow(10, math.Floor(math.Log10(step))) + for _, f := range []float64{1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10} { + if f*magnitude >= step*(1-1e-9) { + // Drop the floating point noise of the multiplications, such as + // in 0.6 * 4 = 2.4000000000000004. + v, _ := strconv.ParseFloat(strconv.FormatFloat(f*magnitude*float64(intervals), 'g', 12, 64), 64) + return v * unit + } + } + + return v +} + +// TerminalWidth returns the width of the terminal w writes to, or fallback +// when w is not a terminal. +func TerminalWidth(w io.Writer, fallback int) int { + f, ok := w.(*os.File) + if !ok || !term.IsTerminal(f.Fd()) { + return fallback + } + + width, _, err := term.GetSize(f.Fd()) + if err != nil || width <= 0 { + return fallback + } + + return width +} + +// ChartLegend renders the legend entries of a chart on a single line. +func ChartLegend(series []ChartSeries, color bool) string { + entries := make([]string, 0, len(series)) + for _, s := range series { + entries = append(entries, BarLegend(s.Name, s.Color, color)) + } + + return strings.Join(entries, " ") +} diff --git a/internal/cmd/output/linechart_test.go b/internal/cmd/output/linechart_test.go new file mode 100644 index 0000000..cba6348 --- /dev/null +++ b/internal/cmd/output/linechart_test.go @@ -0,0 +1,246 @@ +package output_test + +import ( + "bytes" + "math" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/qdrant/qcloud-cli/internal/cmd/output" +) + +func chartPoints(since time.Time, values ...float64) []output.TimePoint { + points := make([]output.TimePoint, 0, len(values)) + for i, v := range values { + points = append(points, output.TimePoint{Time: since.Add(time.Duration(i) * time.Hour), Value: v}) + } + + return points +} + +func chartOpts(since time.Time, hours int, yMax float64) output.TimeChartOptions { + return output.TimeChartOptions{ + Since: since, + Until: since.Add(time.Duration(hours) * time.Hour), + YMax: yMax, + Width: 60, + Height: 14, + YLabel: func(v float64) string { return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) }, + } +} + +func TestTimeChart_RoundsYAxis(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + chart := output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 1, 2, 1.5)}}, chartOpts(since, 2, 2)) + + lines := strings.Split(chart, "\n") + assert.Len(t, lines, 14) + // 2 with 5% headroom is 2.1, which rounds up to 4 steps of 0.6. + assert.True(t, strings.HasPrefix(lines[0], "2.4│"), lines[0]) + assert.Contains(t, chart, "1.8│") + assert.Contains(t, chart, "1.2│") + assert.Contains(t, chart, "0.6│") + assert.Contains(t, chart, "0└") +} + +func TestTimeChart_RaisesYAxisToValues(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + chart := output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 1, 30)}}, chartOpts(since, 1, 2)) + + // 30 with 5% headroom is 31.5, which rounds up to 4 steps of 8. + assert.True(t, strings.HasPrefix(chart, "32│"), chart) +} + +func TestTimeChart_YUnit(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 1, 8*(1<<30)) + opts.YUnit = 1 << 30 + opts.YLabel = func(v float64) string { return output.Bytes(uint64(v)) } + + chart := output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 1<<30)}}, opts) + + assert.True(t, strings.HasPrefix(chart, " 10 GiB│"), chart) + assert.Contains(t, chart, "7.5 GiB│") +} + +func TestTimeChart_TimeLabels(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + series := []output.ChartSeries{{Name: "a", Points: chartPoints(since, 1, 2)}} + + day := output.TimeChart(series, chartOpts(since, 24, 2)) + assert.Contains(t, day, "00:00") + assert.NotContains(t, day, "10/20") + + week := output.TimeChart(series, chartOpts(since, 7*24, 2)) + assert.Contains(t, week, "10/20 00:00") +} + +func TestTimeChart_SkipsEmptySeries(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + withEmpty := output.TimeChart([]output.ChartSeries{ + {Name: "empty"}, + {Name: "a", Points: chartPoints(since, 1, 2, 1.5)}, + }, chartOpts(since, 2, 2)) + without := output.TimeChart([]output.ChartSeries{ + {Name: "a", Points: chartPoints(since, 1, 2, 1.5)}, + }, chartOpts(since, 2, 2)) + + assert.Equal(t, without, withEmpty) + assert.Regexp(t, "[⠁-⣿]", withEmpty, "the line is drawn") +} + +func TestTimeChart_Color(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 2, 2) + opts.Color = true + + chart := output.TimeChart([]output.ChartSeries{ + {Name: "capacity", Color: output.ChartColorCapacity, Points: chartPoints(since, 2, 2, 2)}, + {Name: "a", Color: output.BarColorData, Points: chartPoints(since, 1, 1.5, 1)}, + }, opts) + + assert.Contains(t, chart, "\x1b[31", "capacity is red") + assert.Contains(t, chart, "\x1b[32", "data is green") +} + +func TestChartLegend(t *testing.T) { + series := []output.ChartSeries{ + {Name: "capacity 2 vCPU", Color: output.ChartColorCapacity}, + {Name: "+ usage", Color: output.BarColorData}, + } + + assert.Equal(t, "capacity 2 vCPU + usage", output.ChartLegend(series, false)) + assert.Equal(t, "■ capacity 2 vCPU ■ + usage", stripANSI(output.ChartLegend(series, true))) +} + +func TestTerminalWidth_NotATerminal(t *testing.T) { + assert.Equal(t, 100, output.TerminalWidth(&bytes.Buffer{}, 100)) +} + +func TestTimeChart_FillWithColor(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 2, 4) + opts.Color = true + + chart := output.TimeChart([]output.ChartSeries{ + {Name: "capacity", Color: output.ChartColorCapacity, Points: chartPoints(since, 4, 4, 4)}, + {Name: "system", Color: output.BarColorSystem, Points: chartPoints(since, 1, 1, 1), Fill: true}, + {Name: "+ data", Color: output.BarColorData, Points: chartPoints(since, 2, 3, 2.5), Fill: true}, + }, opts) + + plain := stripANSI(chart) + assert.Contains(t, plain, "█", "areas are filled with blocks") + assert.Regexp(t, "[⠁-⣿]", plain, "the capacity is still a line") + assert.Contains(t, chart, "\x1b[34", "system area is blue") + assert.Contains(t, chart, "\x1b[32", "data area is green") + assert.Contains(t, chart, "\x1b[31", "capacity line is red") + + // Every column of the plot is filled, since the bottom row lies within the + // system area everywhere. + lines := strings.Split(plain, "\n") + _, area, _ := strings.Cut(lines[len(lines)-3], "│") + assert.NotEmpty(t, area) + assert.Equal(t, strings.Repeat("█", len([]rune(area))), area) +} + +func TestTimeChart_FillSparsePointsHasNoGaps(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 1, 2) + opts.Color = true + + // Two points an hour apart, over a chart dozens of columns wide. + chart := stripANSI(output.TimeChart([]output.ChartSeries{ + {Name: "a", Color: output.BarColorData, Points: chartPoints(since, 1, 2), Fill: true}, + }, opts)) + + lines := strings.Split(chart, "\n") + _, area, _ := strings.Cut(lines[len(lines)-3], "│") + assert.NotContains(t, area, " ") +} + +func TestTimeChart_FillOnlyDrawsAxes(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 2, 2) + opts.Color = true + + chart := stripANSI(output.TimeChart([]output.ChartSeries{ + {Name: "a", Color: output.BarColorData, Points: chartPoints(since, 1, 2, 1.5), Fill: true}, + }, opts)) + + assert.Contains(t, chart, "2.4│") + assert.Contains(t, chart, "0└") + assert.Contains(t, chart, "█") +} + +func TestTimeChart_FillWithoutColorDrawsLines(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + + chart := output.TimeChart([]output.ChartSeries{ + {Name: "a", Points: chartPoints(since, 1, 2, 1.5), Fill: true}, + }, chartOpts(since, 2, 2)) + + assert.NotContains(t, chart, "█") + assert.Regexp(t, "[⠁-⣿]", chart) +} + +func TestTimeChart_LeavesOutPointsOutsideRange(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + inside := chartPoints(since, 1, 2, 1.5) + outside := append([]output.TimePoint{{Time: since.Add(-time.Hour), Value: 1}}, inside...) + outside = append(outside, output.TimePoint{Time: since.Add(5 * time.Hour), Value: 1}) + + for _, fill := range []bool{false, true} { + opts := chartOpts(since, 2, 2) + opts.Color = true + + want := output.TimeChart([]output.ChartSeries{{Name: "a", Points: inside, Fill: fill}}, opts) + got := output.TimeChart([]output.ChartSeries{{Name: "a", Points: outside, Fill: fill}}, opts) + + assert.Equal(t, want, got) + assert.Contains(t, stripANSI(got), "00:00", "the time axis starts at since") + } +} + +func TestTimeChart_NarrowWidth(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + for _, color := range []bool{false, true} { + opts := chartOpts(since, 2, 2) + opts.Width, opts.Color = 1, color + + assert.NotPanics(t, func() { + output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 1, 2), Fill: true}}, opts) + }) + } +} + +func TestTimeChart_YTop(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + opts := chartOpts(since, 2, 0) + opts.YTop = 100 + + // The axis ends at YTop without room above it. + chart := output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 40, 80)}}, opts) + assert.True(t, strings.HasPrefix(chart, "100│"), chart) + + // Values close to YTop still get room above them. + chart = output.TimeChart([]output.ChartSeries{{Name: "a", Points: chartPoints(since, 40, 99)}}, opts) + assert.True(t, strings.HasPrefix(chart, "120│"), chart) +} + +func TestTimeChartYLabelWidth(t *testing.T) { + since := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + series := []output.ChartSeries{{Name: "a", Points: chartPoints(since, 1, 2)}} + opts := chartOpts(since, 2, 2) + + // The labels are 0, 0.6, 1.2, 1.8 and 2.4. + assert.Equal(t, 3, output.TimeChartYLabelWidth(series, opts)) + + opts.YLabelWidth = 6 + chart := output.TimeChart(series, opts) + assert.True(t, strings.HasPrefix(chart, " 2.4│"), chart) + assert.Contains(t, chart, " 0└") +} diff --git a/internal/cmd/output/output_test.go b/internal/cmd/output/output_test.go index 981e890..501a773 100644 --- a/internal/cmd/output/output_test.go +++ b/internal/cmd/output/output_test.go @@ -143,3 +143,23 @@ func TestBytes(t *testing.T) { assert.Equal(t, "0 B", output.Bytes(0)) assert.Equal(t, "1.5 GiB", output.Bytes(3<<29)) } + +func TestSignificant(t *testing.T) { + for _, tc := range []struct { + v float64 + digits int + want string + }{ + {0, 3, "0"}, + {0.0015855, 2, "0.0016"}, + {0.0015855, 3, "0.00159"}, + {0.0005, 3, "0.0005"}, + {0.46, 2, "0.46"}, + {1.25, 3, "1.25"}, + {14.55, 3, "14.6"}, + {1300, 2, "1300"}, + {-0.0042, 2, "-0.0042"}, + } { + assert.Equal(t, tc.want, output.Significant(tc.v, tc.digits), "%v with %d digits", tc.v, tc.digits) + } +} diff --git a/internal/testutil/fake_monitoring.go b/internal/testutil/fake_monitoring.go index 51c3436..8186ee2 100644 --- a/internal/testutil/fake_monitoring.go +++ b/internal/testutil/fake_monitoring.go @@ -11,8 +11,10 @@ import ( type FakeMonitoringService struct { monitoringv1.UnimplementedMonitoringServiceServer - GetClusterLogsCalls MethodSpy[*monitoringv1.GetClusterLogsRequest, *monitoringv1.GetClusterLogsResponse] - ListClusterAlertsCalls MethodSpy[*monitoringv1.ListClusterAlertsRequest, *monitoringv1.ListClusterAlertsResponse] + GetClusterLogsCalls MethodSpy[*monitoringv1.GetClusterLogsRequest, *monitoringv1.GetClusterLogsResponse] + GetClusterUsageMetricsCalls MethodSpy[*monitoringv1.GetClusterUsageMetricsRequest, *monitoringv1.GetClusterUsageMetricsResponse] + GetClusterSummaryMetricsCalls MethodSpy[*monitoringv1.GetClusterSummaryMetricsRequest, *monitoringv1.GetClusterSummaryMetricsResponse] + ListClusterAlertsCalls MethodSpy[*monitoringv1.ListClusterAlertsRequest, *monitoringv1.ListClusterAlertsResponse] } // GetClusterLogs records the call and dispatches via GetClusterLogsCalls. @@ -21,6 +23,18 @@ func (f *FakeMonitoringService) GetClusterLogs(ctx context.Context, req *monitor return f.GetClusterLogsCalls.dispatch(ctx, req, f.UnimplementedMonitoringServiceServer.GetClusterLogs) } +// GetClusterSummaryMetrics records the call and dispatches via GetClusterSummaryMetricsCalls. +func (f *FakeMonitoringService) GetClusterSummaryMetrics(ctx context.Context, req *monitoringv1.GetClusterSummaryMetricsRequest) (*monitoringv1.GetClusterSummaryMetricsResponse, error) { + f.GetClusterSummaryMetricsCalls.record(req) + return f.GetClusterSummaryMetricsCalls.dispatch(ctx, req, f.UnimplementedMonitoringServiceServer.GetClusterSummaryMetrics) +} + +// GetClusterUsageMetrics records the call and dispatches via GetClusterUsageMetricsCalls. +func (f *FakeMonitoringService) GetClusterUsageMetrics(ctx context.Context, req *monitoringv1.GetClusterUsageMetricsRequest) (*monitoringv1.GetClusterUsageMetricsResponse, error) { + f.GetClusterUsageMetricsCalls.record(req) + return f.GetClusterUsageMetricsCalls.dispatch(ctx, req, f.UnimplementedMonitoringServiceServer.GetClusterUsageMetrics) +} + // ListClusterAlerts records the call and dispatches via ListClusterAlertsCalls. func (f *FakeMonitoringService) ListClusterAlerts(ctx context.Context, req *monitoringv1.ListClusterAlertsRequest) (*monitoringv1.ListClusterAlertsResponse, error) { f.ListClusterAlertsCalls.record(req)