diff --git a/README.md b/README.md index da6306a..7c0152b 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Homebrew installs completions for you. ``` fopost auth login · status · logout fopost workspaces list · get · create -fopost accounts list · get · rename · move · health · validate · refresh +fopost accounts list · get · rename · move · health · metrics · validate · refresh telegram connect-code · connect-status telegram commands get · set · clear slack channels · members · identity · set-identity diff --git a/go.mod b/go.mod index cedb9aa..87c8b0c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fopost/fopost-cli go 1.22 require ( - github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a + github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 0d462e3..65f5873 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/fopost/fopost-go v0.2.1-0.20260920161845-0d662e46400a h1:RG/LXPHro7c2 github.com/fopost/fopost-go v0.2.1-0.20260920161845-0d662e46400a/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a h1:fnSVISo/7C+RU4yYk/9OaTqY1qvstY+0KV4rSBzUJ/w= github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638 h1:HyHOHnuVqcv8PVp2VtX2OT1Q/kfhxWZI+gnIfH5Re2Y= +github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index 9c7455a..5027d15 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -1,6 +1,9 @@ package cmd import ( + "encoding/json" + "fmt" + "strconv" "strings" fopost "github.com/fopost/fopost-go" @@ -23,6 +26,7 @@ func newAccountsCmd(state *State) *cobra.Command { newAccountsRenameCmd(state), newAccountsMoveCmd(state), newAccountsHealthCmd(state), + newAccountsMetricsCmd(state), newAccountsValidateCmd(state), newAccountsRefreshCmd(state), newAccountsTelegramCmd(state), @@ -245,6 +249,74 @@ func newAccountsHealthCmd(state *State) *cobra.Command { return cmd } +func newAccountsMetricsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "metrics ", + Short: "Show the numbers this account's own network reports", + Long: "Shows the metrics only this account's network reports, in its own vocabulary:\n" + + "ad-break earnings, story taps, a retention curve, the search terms behind a\n" + + "listing. Read from the newest collected snapshot, never fetched live.\n\n" + + "A network whose metric access has not been granted yet answers 503.", + Example: strings.Join([]string{ + " fopost accounts metrics acc_1", + " fopost accounts metrics acc_1 --output json", + }, "\n"), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + printer := state.Printer() + + metrics, err := client.Accounts.PlatformMetrics(cmd.Context(), args[0]) + if err != nil { + return err + } + return printer.Value(metrics, func() { + printer.Line("%s · collected %s", metrics.Platform, + output.Stamp(metrics.Account.FetchedAt.Time, metrics.Account.FetchedAt.Raw)) + printer.Line("") + printMetricRows(printer, "Account", metrics.Account.Metrics) + if len(metrics.Post.Metrics) > 0 { + printer.Line("") + label := "Latest Post" + if metrics.Post.ExternalPostID != "" { + label += " " + metrics.Post.ExternalPostID + } + printMetricRows(printer, label, metrics.Post.Metrics) + } + }) + }, + } +} + +// A series is an array rather than a number, so it is summarised by its length +// instead of printed inline; --output json carries the points themselves. +func printMetricRows(printer *output.Printer, heading string, rows []fopost.PlatformMetricRow) { + if len(rows) == 0 { + printer.Line("%s: no metrics collected yet", heading) + return + } + printer.Line("%s", heading) + table := make([][]string, 0, len(rows)) + for _, row := range rows { + table = append(table, []string{row.Key, row.Label, metricValue(row)}) + } + printer.Table([]string{"key", "label", "value"}, table) +} + +func metricValue(row fopost.PlatformMetricRow) string { + if number, ok := row.Number(); ok { + return strconv.FormatFloat(number, 'f', -1, 64) + } + var points []json.RawMessage + if err := json.Unmarshal(row.Value, &points); err == nil { + return fmt.Sprintf("%d points", len(points)) + } + return strings.TrimSpace(string(row.Value)) +} + func newAccountsValidateCmd(state *State) *cobra.Command { return &cobra.Command{ Use: "validate ", diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go index 094dc86..eae623e 100644 --- a/internal/cmd/cli_test.go +++ b/internal/cmd/cli_test.go @@ -1031,3 +1031,56 @@ func TestAccountsYouTubeSetDefaultPlaylistRefusesAMissingID(t *testing.T) { t.Fatalf("body = %v", sent.Body) } } + +func TestAccountsMetricsAsksForRawAndPrintsBothBlocks(t *testing.T) { + isolate(t) + var path, query string + api := newFakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + path, query = r.URL.Path, r.URL.RawQuery + io.WriteString(w, `{"data":{"platform":"facebook","account":{"fetched_at":"2026-09-20T02:00:00.000Z","metrics":[{"key":"page_daily_video_ad_break_earnings","label":"Ad Break Earnings","kind":"currency_usd","value":42.15},{"key":"daily_views","label":"Views by Day","kind":"series","value":[{"day":"2026-09-19"},{"day":"2026-09-20"}]}]},"post":{"external_post_id":"123_456","fetched_at":"2026-09-20T02:00:00.000Z","metrics":[{"key":"post_impressions_paid","label":"Paid Impressions","kind":"count","value":1500}]}}}`) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + stdout, stderr, code := run(t, "", "accounts", "metrics", "acc_1") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + if path != "/accounts/acc_1/insights" || query != "raw=true" { + t.Fatalf("request = %s?%s", path, query) + } + for _, want := range []string{ + "facebook", + "page_daily_video_ad_break_earnings", + "42.15", + // A series is summarised rather than printed inline. + "2 points", + "Latest Post 123_456", + "post_impressions_paid", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout missing %q:\n%s", want, stdout) + } + } +} + +func TestAccountsMetricsSurfacesAPendingGrant(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + io.WriteString(w, `{"error":"platform_metrics_unavailable","message":"google-business metrics are not available on this deployment yet."}`) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + _, stderr, code := run(t, "", "accounts", "metrics", "acc_1") + if code == ExitOK { + t.Fatal("a pending grant must not exit 0") + } + if !strings.Contains(stderr, "platform_metrics_unavailable") && + !strings.Contains(stderr, "not available") { + t.Fatalf("stderr = %q", stderr) + } +}