From 886b977e16ca40c5944c85899b499aab55cd54c0 Mon Sep 17 00:00:00 2001 From: Bastian Hofmann Date: Sat, 24 Oct 2026 12:12:17 +0200 Subject: [PATCH 1/3] Add cluster and node metrics to CLI --- docs/reference/qcloud_cluster.md | 3 + .../qcloud_cluster_cluster-metrics-history.md | 63 ++++ docs/reference/qcloud_cluster_metrics.md | 56 +++ .../qcloud_cluster_node-metrics-history.md | 65 ++++ go.mod | 19 +- go.sum | 43 ++- internal/cmd/cluster/cluster.go | 3 + .../cmd/cluster/cluster_metrics_history.go | 137 +++++++ .../cluster/cluster_metrics_history_test.go | 172 +++++++++ internal/cmd/cluster/metrics.go | 342 +++++++++++++++++ .../cmd/cluster/metrics_history_helpers.go | 155 ++++++++ internal/cmd/cluster/metrics_test.go | 273 ++++++++++++++ internal/cmd/cluster/node_metrics_history.go | 300 +++++++++++++++ .../cmd/cluster/node_metrics_history_test.go | 348 ++++++++++++++++++ internal/cmd/output/bar.go | 100 +++++ internal/cmd/output/bar_test.go | 68 ++++ internal/cmd/output/linechart.go | 249 +++++++++++++ internal/cmd/output/linechart_test.go | 188 ++++++++++ internal/testutil/fake_monitoring.go | 18 +- 19 files changed, 2596 insertions(+), 6 deletions(-) create mode 100644 docs/reference/qcloud_cluster_cluster-metrics-history.md create mode 100644 docs/reference/qcloud_cluster_metrics.md create mode 100644 docs/reference/qcloud_cluster_node-metrics-history.md create mode 100644 internal/cmd/cluster/cluster_metrics_history.go create mode 100644 internal/cmd/cluster/cluster_metrics_history_test.go create mode 100644 internal/cmd/cluster/metrics.go create mode 100644 internal/cmd/cluster/metrics_history_helpers.go create mode 100644 internal/cmd/cluster/metrics_test.go create mode 100644 internal/cmd/cluster/node_metrics_history.go create mode 100644 internal/cmd/cluster/node_metrics_history_test.go create mode 100644 internal/cmd/output/bar.go create mode 100644 internal/cmd/output/bar_test.go create mode 100644 internal/cmd/output/linechart.go create mode 100644 internal/cmd/output/linechart_test.go 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..c6583ba --- /dev/null +++ b/docs/reference/qcloud_cluster_node-metrics-history.md @@ -0,0 +1,65 @@ +## 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. + +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..2bd94b6 --- /dev/null +++ b/internal/cmd/cluster/cluster_metrics_history.go @@ -0,0 +1,137 @@ +package cluster + +import ( + "errors" + "fmt" + "io" + "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)) + + charts := []metricsChart{ + { + title: "Requests", + base: h.RequestsPerSecond, + series: []output.ChartSeries{{Name: "requests per second", Color: output.ChartColorRequests, Points: stack(h.RequestsPerSecond, 0)}}, + yLabel: output.Rate, + }, + { + title: "Latency", + base: h.LatencyMs, + series: []output.ChartSeries{{Name: "latency", Color: output.ChartColorLatency, Points: stack(h.LatencyMs, 0)}}, + yLabel: output.Milliseconds, + }, + } + for _, c := range charts { + printMetricsChart(w, c, 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..92e0716 --- /dev/null +++ b/internal/cmd/cluster/cluster_metrics_history_test.go @@ -0,0 +1,172 @@ +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.00/s│") + assert.Contains(t, stdout, "20.0ms│") + 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) +} diff --git a/internal/cmd/cluster/metrics.go b/internal/cmd/cluster/metrics.go new file mode 100644 index 0000000..6a25b92 --- /dev/null +++ b/internal/cmd/cluster/metrics.go @@ -0,0 +1,342 @@ +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) + } + + // Hybrid Cloud clusters do not report the RSS of the database process + // separately, so use the RSS of the whole node there. + 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 most recent average and the total capacity of a +// metric. 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 + } + + rowsPerNode := make([][]metricsRow, len(m.Nodes)) + amountWidth := 0 + for i, n := range m.Nodes { + rowsPerNode[i] = metricsRows(n, color) + for _, r := range rowsPerNode[i] { + amountWidth = max(amountWidth, len(r.amount)) + } + } + + 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 rowsPerNode[i] { + if r.total == 0 { + fmt.Fprintf(w, " %-4s no data\n", r.label) + continue + } + + line := fmt.Sprintf(" %-4s %s %5.1f%% %-*s", r.label, output.UsageBar(r.segments, r.total, metricsBarWidth, color), r.percent, amountWidth, r.amount) + if len(r.details) > 0 { + line += " " + strings.Join(r.details, " ") + } + + fmt.Fprintln(w, strings.TrimRight(line, " ")) + } + } +} + +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), + }, + }, + } +} + +func formatVCPUs(v float64) string { + 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..9f711e2 --- /dev/null +++ b/internal/cmd/cluster/metrics_history_helpers.go @@ -0,0 +1,155 @@ +package cluster + +import ( + "errors" + "fmt" + "io" + "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 + 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. +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 + } + + until := now + if untilTS != nil { + 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 +} + +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()}) + } + + return points +} + +func pointsByTime(points []historyPoint) map[int64]float64 { + byTime := make(map[int64]float64, len(points)) + for _, p := range points { + byTime[p.Timestamp.Unix()] = p.Value + } + + return byTime +} + +// stack returns the points of base with the value at the same time in each of +// layers, and offset, added on top. +func stack(base []historyPoint, offset float64, layers ...[]historyPoint) []output.TimePoint { + lookups := make([]map[int64]float64, len(layers)) + for i, l := range layers { + lookups[i] = pointsByTime(l) + } + + points := make([]output.TimePoint, 0, len(base)) + for _, p := range base { + v := offset + p.Value + for _, l := range lookups { + v += l[p.Timestamp.Unix()] + } + + 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 []historyPoint + total float64 + series []output.ChartSeries + 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(output.TerminalWidth(w, metricsChartDefaultWidth), metricsChartMaxWidth) +} + +// printMetricsChart prints the title, legend and chart of c, with a capacity +// line when c has a total. +func printMetricsChart(w io.Writer, c metricsChart, since, until time.Time, width int, color bool) { + fmt.Fprintln(w) + fmt.Fprintln(w, c.title) + if len(c.base) == 0 { + fmt.Fprintln(w, "no data") + return + } + + 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: constant(c.base, c.total), + }) + } + + series = append(series, c.series...) + + fmt.Fprintln(w, output.ChartLegend(series, color)) + fmt.Fprintln(w, output.TimeChart(series, output.TimeChartOptions{ + Since: since, + Until: until, + YMax: c.total, + 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..a65d2ec --- /dev/null +++ b/internal/cmd/cluster/metrics_test.go @@ -0,0 +1,273 @@ +package cluster_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "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, "system 0.2 usage 0.5 available 1.3") + // 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_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) +} diff --git a/internal/cmd/cluster/node_metrics_history.go b/internal/cmd/cluster/node_metrics_history.go new file mode 100644 index 0000000..1e4d080 --- /dev/null +++ b/internal/cmd/cluster/node_metrics_history.go @@ -0,0 +1,300 @@ +package cluster + +import ( + "fmt" + "io" + "slices" + "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"` +} + +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"` +} + +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. + +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()) + + // Hybrid Cloud clusters do not report the RSS of the database process + // separately, so use the RSS of the whole node there. + 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. + cacheAt := pointsByTime(cache) + for _, p := range historyPoints(usage.GetRam()) { + data = append(data, historyPoint{Timestamp: p.Timestamp, Value: max(p.Value-cacheAt[p.Timestamp.Unix()], 0)}) + } + } + + return &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()), + }, + }, 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)) + + for _, c := range nodeMetricsHistoryCharts(*h) { + printMetricsChart(w, c, 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}, + ) + + return []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, + }, + } +} 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..ece0d7a --- /dev/null +++ b/internal/cmd/cluster/node_metrics_history_test.go @@ -0,0 +1,348 @@ +package cluster_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "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_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()) +} diff --git a/internal/cmd/output/bar.go b/internal/cmd/output/bar.go new file mode 100644 index 0000000..4740245 --- /dev/null +++ b/internal/cmd/output/bar.go @@ -0,0 +1,100 @@ +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 +) + +// 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 { + 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..c0dab16 --- /dev/null +++ b/internal/cmd/output/bar_test.go @@ -0,0 +1,68 @@ +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() +} diff --git a/internal/cmd/output/linechart.go b/internal/cmd/output/linechart.go new file mode 100644 index 0000000..78e8b30 --- /dev/null +++ b/internal/cmd/output/linechart.go @@ -0,0 +1,249 @@ +package output + +import ( + "io" + "math" + "os" + "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" +) + +// chartYStep is the number of rows between two labels on the Y axis. +const chartYStep = 3 + +// 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. + YMax 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 + Color bool +} + +// TimeChart renders series as braille line charts over the time range in opts, +// drawn in order, so a later series is drawn over an earlier one where they +// meet. Series without points are skipped. +func TimeChart(series []ChartSeries, opts TimeChartOptions) string { + 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) + + timeFormat := "01/02 15:04" + if opts.Until.Sub(opts.Since) <= 24*time.Hour { + timeFormat = "15:04" + } + + chart := timeserieslinechart.New(opts.Width, 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 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() +} + +// 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 := len(fills) - 1; i >= 0; i-- { + style := barStyle(fills[i].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(fills[i].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..79cc55f --- /dev/null +++ b/internal/cmd/output/linechart_test.go @@ -0,0 +1,188 @@ +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) +} 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) From 166ab01bdaf43f532e3837fdceaa3b4c7fa378e5 Mon Sep 17 00:00:00 2001 From: Bastian Hofmann Date: Sat, 24 Oct 2026 12:26:11 +0200 Subject: [PATCH 2/3] Fix lint --- internal/cmd/output/linechart.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/cmd/output/linechart.go b/internal/cmd/output/linechart.go index 78e8b30..63133b8 100644 --- a/internal/cmd/output/linechart.go +++ b/internal/cmd/output/linechart.go @@ -4,6 +4,7 @@ import ( "io" "math" "os" + "slices" "strconv" "strings" "time" @@ -150,8 +151,8 @@ func drawAreas(chart *timeserieslinechart.Model, fills []ChartSeries, since, unt bottom := chart.Origin().Y - 1 columnDuration := until.Sub(since) / time.Duration(width) - for i := len(fills) - 1; i >= 0; i-- { - style := barStyle(fills[i].Color, true) + 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()) @@ -159,7 +160,7 @@ func drawAreas(chart *timeserieslinechart.Model, fills []ChartSeries, since, unt for col := range width { from := since.Add(time.Duration(col) * columnDuration) - v, ok := columnValue(fills[i].Points, from, from.Add(columnDuration)) + v, ok := columnValue(fill.Points, from, from.Add(columnDuration)) if !ok { continue } From 78061eb5b0e09a048e743be864b235d9d749c692 Mon Sep 17 00:00:00 2001 From: Bastian Hofmann Date: Sat, 24 Oct 2026 13:08:52 +0200 Subject: [PATCH 3/3] Adjust because of review feedback --- .../qcloud_cluster_node-metrics-history.md | 3 +- .../cmd/cluster/cluster_metrics_history.go | 27 ++- .../cluster/cluster_metrics_history_test.go | 55 +++++- internal/cmd/cluster/metrics.go | 36 ++-- .../cmd/cluster/metrics_history_helpers.go | 112 ++++++++---- internal/cmd/cluster/metrics_test.go | 35 +++- internal/cmd/cluster/node_metrics_history.go | 69 ++++++-- .../cmd/cluster/node_metrics_history_test.go | 167 ++++++++++++++++++ internal/cmd/output/bar.go | 5 + internal/cmd/output/bar_test.go | 7 + internal/cmd/output/format.go | 16 ++ internal/cmd/output/linechart.go | 118 +++++++++---- internal/cmd/output/linechart_test.go | 58 ++++++ internal/cmd/output/output_test.go | 20 +++ 14 files changed, 619 insertions(+), 109 deletions(-) diff --git a/docs/reference/qcloud_cluster_node-metrics-history.md b/docs/reference/qcloud_cluster_node-metrics-history.md index c6583ba..2595c2f 100644 --- a/docs/reference/qcloud_cluster_node-metrics-history.md +++ b/docs/reference/qcloud_cluster_node-metrics-history.md @@ -11,7 +11,8 @@ 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. +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 diff --git a/internal/cmd/cluster/cluster_metrics_history.go b/internal/cmd/cluster/cluster_metrics_history.go index 2bd94b6..2b3486c 100644 --- a/internal/cmd/cluster/cluster_metrics_history.go +++ b/internal/cmd/cluster/cluster_metrics_history.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "slices" "time" "github.com/spf13/cobra" @@ -117,21 +118,29 @@ qcloud cluster cluster-metrics-history 7b2ea926-724b-4de2-b73a-8675c42a6ebe --js 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: h.RequestsPerSecond, - series: []output.ChartSeries{{Name: "requests per second", Color: output.ChartColorRequests, Points: stack(h.RequestsPerSecond, 0)}}, - yLabel: output.Rate, + 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: h.LatencyMs, - series: []output.ChartSeries{{Name: "latency", Color: output.ChartColorLatency, Points: stack(h.LatencyMs, 0)}}, - yLabel: output.Milliseconds, + 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" }, }, } - for _, c := range charts { - printMetricsChart(w, c, h.Since, h.Until, width, color) - } + 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 index 92e0716..4358870 100644 --- a/internal/cmd/cluster/cluster_metrics_history_test.go +++ b/internal/cmd/cluster/cluster_metrics_history_test.go @@ -44,8 +44,8 @@ func TestClusterMetricsHistory_TextOutput(t *testing.T) { 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.00/s│") - assert.Contains(t, stdout, "20.0ms│") + assert.Contains(t, stdout, "40/s│") + assert.Contains(t, stdout, "20ms│") assert.NotContains(t, stdout, "capacity") assert.NotContains(t, stdout, "\x1b[") } @@ -170,3 +170,54 @@ func TestClusterMetricsHistory_MissingArg(t *testing.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 index 6a25b92..4030256 100644 --- a/internal/cmd/cluster/metrics.go +++ b/internal/cmd/cluster/metrics.go @@ -157,8 +157,8 @@ func buildClusterMetrics(cluster *clusterv1.Cluster, nodes []*monitoringv1.Clust nm.CPU.UsedPercent = percent(cpuUsed+reservedVCPUs, cpuTotal) } - // Hybrid Cloud clusters do not report the RSS of the database process - // separately, so use the RSS of the whole node there. + // 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() @@ -205,8 +205,9 @@ func compareNodes(a, b string) int { return cmp.Compare(a, b) } -// metricOverview returns the most recent average and the total capacity of a -// metric. Both are 0 when the node has not reported the metric yet. +// 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 @@ -243,15 +244,6 @@ func printClusterMetrics(w io.Writer, m *clusterMetrics, color bool) { return } - rowsPerNode := make([][]metricsRow, len(m.Nodes)) - amountWidth := 0 - for i, n := range m.Nodes { - rowsPerNode[i] = metricsRows(n, color) - for _, r := range rowsPerNode[i] { - amountWidth = max(amountWidth, len(r.amount)) - } - } - for i, n := range m.Nodes { if i > 0 { fmt.Fprintln(w) @@ -266,18 +258,16 @@ func printClusterMetrics(w io.Writer, m *clusterMetrics, color bool) { fmt.Fprintln(w, strings.Join(header, " ")) - for _, r := range rowsPerNode[i] { + for _, r := range metricsRows(n, color) { if r.total == 0 { fmt.Fprintf(w, " %-4s no data\n", r.label) continue } - line := fmt.Sprintf(" %-4s %s %5.1f%% %-*s", r.label, output.UsageBar(r.segments, r.total, metricsBarWidth, color), r.percent, amountWidth, r.amount) - if len(r.details) > 0 { - line += " " + strings.Join(r.details, " ") - } - - fmt.Fprintln(w, strings.TrimRight(line, " ")) + // 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, " ")) } } } @@ -333,7 +323,13 @@ func metricsRows(n nodeMetrics, color bool) []metricsRow { } } +// 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) } diff --git a/internal/cmd/cluster/metrics_history_helpers.go b/internal/cmd/cluster/metrics_history_helpers.go index 9f711e2..05960e3 100644 --- a/internal/cmd/cluster/metrics_history_helpers.go +++ b/internal/cmd/cluster/metrics_history_helpers.go @@ -1,9 +1,11 @@ package cluster import ( + "cmp" "errors" "fmt" "io" + "slices" "time" "github.com/spf13/cobra" @@ -18,6 +20,7 @@ const ( defaultMetricsHistoryRange = 7 * 24 * time.Hour metricsChartHeight = 14 metricsChartDefaultWidth = 100 + metricsChartMinWidth = 40 metricsChartMaxWidth = 200 ) @@ -32,15 +35,21 @@ func addMetricsHistoryRangeFlags(cmd *cobra.Command) { } // readMetricsHistoryRange reads --since and --until, defaulting to the 7 days -// before --until, and --until to now. +// 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 { + if untilTS != nil && untilTS.AsTime().Before(now) { until = untilTS.AsTime() } @@ -56,37 +65,46 @@ func readMetricsHistoryRange(cmd *cobra.Command, now time.Time) (time.Time, time 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 } -func pointsByTime(points []historyPoint) map[int64]float64 { - byTime := make(map[int64]float64, len(points)) - for _, p := range points { - byTime[p.Timestamp.Unix()] = p.Value +// 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 } - - return byTime } -// stack returns the points of base with the value at the same time in each of -// layers, and offset, added on top. +// 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 { - lookups := make([]map[int64]float64, len(layers)) - for i, l := range layers { - lookups[i] = pointsByTime(l) - } - points := make([]output.TimePoint, 0, len(base)) for _, p := range base { v := offset + p.Value - for _, l := range lookups { - v += l[p.Timestamp.Unix()] + for _, l := range layers { + v += valueAt(l, p.Timestamp) } points = append(points, output.TimePoint{Time: p.Timestamp, Value: v}) @@ -106,10 +124,16 @@ func constant(base []historyPoint, v float64) []output.TimePoint { } type metricsChart struct { - title string - base []historyPoint + 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 } @@ -117,39 +141,61 @@ type metricsChart struct { // 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(output.TerminalWidth(w, metricsChartDefaultWidth), metricsChartMaxWidth) + return min(max(output.TerminalWidth(w, metricsChartDefaultWidth), metricsChartMinWidth), metricsChartMaxWidth) } -// printMetricsChart prints the title, legend and chart of c, with a capacity -// line when c has a total. -func printMetricsChart(w io.Writer, c metricsChart, since, until time.Time, width int, color bool) { - fmt.Fprintln(w) - fmt.Fprintln(w, c.title) - if len(c.base) == 0 { - fmt.Fprintln(w, "no data") - return +// 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: constant(c.base, c.total), + Points: []output.TimePoint{{Time: since, Value: c.total}, {Time: until, Value: c.total}}, }) } - series = append(series, c.series...) + return append(series, c.series...) +} - fmt.Fprintln(w, output.ChartLegend(series, color)) - fmt.Fprintln(w, output.TimeChart(series, output.TimeChartOptions{ +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 index a65d2ec..9dcc979 100644 --- a/internal/cmd/cluster/metrics_test.go +++ b/internal/cmd/cluster/metrics_test.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "testing" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,7 +89,9 @@ func TestClusterMetrics_TextOutput(t *testing.T) { // 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, "system 0.2 usage 0.5 available 1.3") + 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") @@ -100,6 +103,17 @@ func TestClusterMetrics_TextOutput(t *testing.T) { 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)) @@ -271,3 +285,22 @@ func TestClusterMetrics_MissingArg(t *testing.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 index 1e4d080..895a53e 100644 --- a/internal/cmd/cluster/node_metrics_history.go +++ b/internal/cmd/cluster/node_metrics_history.go @@ -3,7 +3,9 @@ package cluster import ( "fmt" "io" + "math" "slices" + "strconv" "strings" "time" @@ -32,6 +34,9 @@ type nodeMetricsHistory struct { 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 { @@ -52,6 +57,15 @@ type diskHistory struct { 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 ", @@ -63,7 +77,8 @@ 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. +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 @@ -165,8 +180,8 @@ func buildNodeMetricsHistory(cluster *clusterv1.Cluster, nodes []*monitoringv1.C resources := cluster.GetState().GetResources() cache := historyPoints(usage.GetRamCache()) - // Hybrid Cloud clusters do not report the RSS of the database process - // separately, so use the RSS of the whole node there. + // 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() @@ -176,13 +191,12 @@ func buildNodeMetricsHistory(cluster *clusterv1.Cluster, nodes []*monitoringv1.C if len(data) == 0 { // Without an RSS series, derive the data from the RAM including the // cache, like the Qdrant Cloud console does. - cacheAt := pointsByTime(cache) for _, p := range historyPoints(usage.GetRam()) { - data = append(data, historyPoint{Timestamp: p.Timestamp, Value: max(p.Value-cacheAt[p.Timestamp.Unix()], 0)}) + data = append(data, historyPoint{Timestamp: p.Timestamp, Value: max(p.Value-valueAt(cache, p.Timestamp), 0)}) } } - return &nodeMetricsHistory{ + history := &nodeMetricsHistory{ ClusterID: cluster.GetId(), Node: nodeNumber(cluster.GetId(), usage.GetNodeId()), NodeID: usage.GetNodeId(), @@ -203,7 +217,14 @@ func buildNodeMetricsHistory(cluster *clusterv1.Cluster, nodes []*monitoringv1.C TotalBytes: resourceTotal(resources.GetDisk()) * bytesPerGiB, Used: historyPoints(usage.GetDisk()), }, - }, nil + } + + 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. @@ -253,9 +274,7 @@ func resourceTotal(r *clusterv1.ClusterNodeResources) float64 { 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)) - for _, c := range nodeMetricsHistoryCharts(*h) { - printMetricsChart(w, c, h.Since, h.Until, width, color) - } + printMetricsCharts(w, nodeMetricsHistoryCharts(*h), h.Since, h.Until, width, color) } func nodeMetricsHistoryCharts(n nodeMetricsHistory) []metricsChart { @@ -285,7 +304,7 @@ func nodeMetricsHistoryCharts(n nodeMetricsHistory) []metricsChart { output.ChartSeries{Name: "+ data", Color: output.BarColorData, Points: stack(ram.Data, ram.ReservedBytes, ram.Cache), Fill: true}, ) - return []metricsChart{ + 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}, { @@ -297,4 +316,32 @@ func nodeMetricsHistoryCharts(n nodeMetricsHistory) []metricsChart { 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 index ece0d7a..84788f7 100644 --- a/internal/cmd/cluster/node_metrics_history_test.go +++ b/internal/cmd/cluster/node_metrics_history_test.go @@ -3,9 +3,12 @@ package cluster_test import ( "encoding/json" "fmt" + "regexp" + "slices" "strings" "testing" "time" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -244,6 +247,38 @@ func TestNodeMetricsHistory_DataFallsBackToRAMWithoutCache(t *testing.T) { 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")) @@ -346,3 +381,135 @@ func TestNodeMetricsHistory_MissingArgs(t *testing.T) { 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 index 4740245..a09ef7a 100644 --- a/internal/cmd/output/bar.go +++ b/internal/cmd/output/bar.go @@ -18,6 +18,9 @@ const ( 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 @@ -35,6 +38,8 @@ func ColorEnabled(w io.Writer) bool { // 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 } diff --git a/internal/cmd/output/bar_test.go b/internal/cmd/output/bar_test.go index c0dab16..c823843 100644 --- a/internal/cmd/output/bar_test.go +++ b/internal/cmd/output/bar_test.go @@ -66,3 +66,10 @@ func stripANSI(s string) string { 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 index 63133b8..428100d 100644 --- a/internal/cmd/output/linechart.go +++ b/internal/cmd/output/linechart.go @@ -1,6 +1,7 @@ package output import ( + "fmt" "io" "math" "os" @@ -15,8 +16,13 @@ import ( "github.com/charmbracelet/x/term" ) -// chartYStep is the number of rows between two labels on the Y axis. -const chartYStep = 3 +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 ( @@ -47,8 +53,12 @@ type ChartSeries struct { 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. + // 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 @@ -56,43 +66,26 @@ type TimeChartOptions struct { Height int // YLabel formats the values on the Y axis. YLabel func(float64) string - Color bool + // 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 as braille line charts over the time range in opts, -// drawn in order, so a later series is drawn over an earlier one where they -// meet. Series without points are skipped. +// 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 { - 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) + 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(opts.Width, plotHeight+2, + chart := timeserieslinechart.New(max(opts.Width, minTimeChartWidth), plotHeight+2, timeserieslinechart.WithTimeRange(opts.Since, opts.Until), timeserieslinechart.WithYRange(0, yRange), timeserieslinechart.WithXYSteps(4, chartYStep), @@ -100,7 +93,7 @@ func TimeChart(series []ChartSeries, opts TimeChartOptions) string { return time.UnixMilli(int64(v * 1e3)).UTC().Format(timeFormat) }), timeserieslinechart.WithYLabelFormatter(func(_ int, v float64) string { - return opts.YLabel(v) + return fmt.Sprintf("%*s", opts.YLabelWidth, opts.YLabel(v)) }), ) @@ -135,6 +128,67 @@ func TimeChart(series []ChartSeries, opts TimeChartOptions) string { 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. diff --git a/internal/cmd/output/linechart_test.go b/internal/cmd/output/linechart_test.go index 79cc55f..cba6348 100644 --- a/internal/cmd/output/linechart_test.go +++ b/internal/cmd/output/linechart_test.go @@ -186,3 +186,61 @@ func TestTimeChart_FillWithoutColorDrawsLines(t *testing.T) { 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) + } +}