@@ -13,6 +13,7 @@ import (
1313 "sort"
1414 "strconv"
1515 "strings"
16+ "time"
1617
1718 tea "github.com/charmbracelet/bubbletea"
1819 "github.com/charmbracelet/lipgloss"
@@ -251,6 +252,166 @@ func (m Model) createDBTopic(name string, minInsyncReplicas, partitions, replica
251252 }
252253}
253254
255+ // ── Metrics ──────────────────────────────────────────────────────────────────
256+
257+ // dbMetricNamesMsg is sent after listing available metric names.
258+ type dbMetricNamesMsg struct {
259+ names []string
260+ err error
261+ }
262+
263+ // dbMetricDataMsg is sent after fetching one metric's time-series data.
264+ type dbMetricDataMsg struct {
265+ name string
266+ series []dbMetricSeries
267+ err error
268+ }
269+
270+ // dbMetricSeries holds one named time-series returned by the API (one per node/disk/etc.).
271+ type dbMetricSeries struct {
272+ Name string
273+ Points []dbMetricPoint
274+ }
275+
276+ type dbMetricPoint struct {
277+ T time.Time
278+ V float64
279+ }
280+
281+ // fetchDBMetricNames lists available metric names for the current service.
282+ func (m Model ) fetchDBMetricNames () tea.Cmd {
283+ return func () tea.Msg {
284+ engine := getStringValue (m .detailData , "engine" , "" )
285+ serviceId := getStringValue (m .detailData , "id" , "" )
286+ if engine == "" || serviceId == "" {
287+ return dbMetricNamesMsg {err : fmt .Errorf ("missing engine or service ID" )}
288+ }
289+ endpoint := fmt .Sprintf ("/v1/cloud/project/%s/database/%s/%s/metric" ,
290+ m .cloudProject , url .PathEscape (engine ), url .PathEscape (serviceId ))
291+ var names []string
292+ if err := httpLib .Client .Get (endpoint , & names ); err != nil {
293+ return dbMetricNamesMsg {err : err }
294+ }
295+ sort .Strings (names )
296+ return dbMetricNamesMsg {names : names }
297+ }
298+ }
299+
300+ // dbNodeNamesMsg carries the ordered list of node names for the current service.
301+ type dbNodeNamesMsg struct {
302+ names []string
303+ err error
304+ }
305+
306+ // fetchDBNodeNames fetches the node list and returns names sorted alphabetically.
307+ func (m Model ) fetchDBNodeNames () tea.Cmd {
308+ return func () tea.Msg {
309+ engine := getStringValue (m .detailData , "engine" , "" )
310+ serviceId := getStringValue (m .detailData , "id" , "" )
311+ if engine == "" || serviceId == "" {
312+ return dbNodeNamesMsg {err : fmt .Errorf ("missing engine or service ID" )}
313+ }
314+ endpoint := fmt .Sprintf ("/v1/cloud/project/%s/database/%s/%s/node" ,
315+ m .cloudProject , url .PathEscape (engine ), url .PathEscape (serviceId ))
316+ var ids []string
317+ if err := httpLib .Client .Get (endpoint , & ids ); err != nil {
318+ return dbNodeNamesMsg {err : err }
319+ }
320+ type nodeDetail struct {
321+ Name string `json:"name"`
322+ }
323+ var names []string
324+ for _ , id := range ids {
325+ var nd nodeDetail
326+ detailEp := fmt .Sprintf ("%s/%s" , endpoint , url .PathEscape (id ))
327+ if err := httpLib .Client .Get (detailEp , & nd ); err == nil && nd .Name != "" {
328+ names = append (names , nd .Name )
329+ } else {
330+ names = append (names , id )
331+ }
332+ }
333+ sort .Strings (names )
334+ return dbNodeNamesMsg {names : names }
335+ }
336+ }
337+
338+ // fetchDBMetric fetches metric data for the given metric name and period.
339+ func (m Model ) fetchDBMetric (metricName , period string ) tea.Cmd {
340+ return func () tea.Msg {
341+ engine := getStringValue (m .detailData , "engine" , "" )
342+ serviceId := getStringValue (m .detailData , "id" , "" )
343+ if engine == "" || serviceId == "" {
344+ return dbMetricDataMsg {name : metricName , err : fmt .Errorf ("missing engine or service ID" )}
345+ }
346+ endpoint := fmt .Sprintf ("/v1/cloud/project/%s/database/%s/%s/metric/%s?period=%s" ,
347+ m .cloudProject , url .PathEscape (engine ), url .PathEscape (serviceId ),
348+ url .PathEscape (metricName ), url .QueryEscape (period ))
349+ // Capture raw JSON so we can inspect the actual field names for debugging
350+ var rawResult json.RawMessage
351+ if err := httpLib .Client .Get (endpoint , & rawResult ); err != nil {
352+ return dbMetricDataMsg {name : metricName , err : err }
353+ }
354+ var result struct {
355+ Metrics []struct {
356+ Name string `json:"name"`
357+ Host string `json:"host"`
358+ NodeID string `json:"nodeId"`
359+ Labels map [string ]string `json:"labels"`
360+ Tags map [string ]string `json:"tags"`
361+ DataPoints []struct {
362+ Timestamp float64 `json:"timestamp"`
363+ Value * float64 `json:"value"`
364+ } `json:"dataPoints"`
365+ } `json:"metrics"`
366+ }
367+ if err := json .Unmarshal (rawResult , & result ); err != nil {
368+ return dbMetricDataMsg {name : metricName , err : err }
369+ }
370+ var series []dbMetricSeries
371+ for i , metric := range result .Metrics {
372+ // Try all possible label fields the API might provide
373+ sName := metric .Name
374+ if sName == "" {
375+ sName = metric .Host
376+ }
377+ if sName == "" {
378+ sName = metric .NodeID
379+ }
380+ if sName == "" && len (metric .Labels ) > 0 {
381+ for k , v := range metric .Labels {
382+ sName = k + "=" + v
383+ break
384+ }
385+ }
386+ if sName == "" && len (metric .Tags ) > 0 {
387+ for k , v := range metric .Tags {
388+ sName = k + "=" + v
389+ break
390+ }
391+ }
392+ if sName == "" {
393+ sName = fmt .Sprintf ("node %d" , i + 1 )
394+ }
395+ var pts []dbMetricPoint
396+ for _ , dp := range metric .DataPoints {
397+ if dp .Value == nil || dp .Timestamp <= 0 {
398+ continue // skip null/gap entries
399+ }
400+ pts = append (pts , dbMetricPoint {
401+ T : time .Unix (int64 (dp .Timestamp ), 0 ),
402+ V : * dp .Value ,
403+ })
404+ }
405+ if len (pts ) == 0 {
406+ continue
407+ }
408+ sort .Slice (pts , func (i , j int ) bool { return pts [i ].T .Before (pts [j ].T ) })
409+ series = append (series , dbMetricSeries {Name : sName , Points : pts })
410+ }
411+ return dbMetricDataMsg {name : metricName , series : series }
412+ }
413+ }
414+
254415// fetchDBACL fetches the ACL list for the current analytics service.
255416func (m Model ) fetchDBACL () tea.Cmd {
256417 return func () tea.Msg {
0 commit comments