diff --git a/cmd/pnf/main.go b/cmd/pnf/main.go index a71cbac..f242b15 100644 --- a/cmd/pnf/main.go +++ b/cmd/pnf/main.go @@ -76,6 +76,7 @@ Commands: Examples: pnf tui --logdir ./logs + pnf tui --url https://api.example.com --api-key p95_xxx --logdir ./logs pnf ls --logdir ./logs pnf ls --logdir ./logs --project demo-project pnf show --logdir ./logs @@ -83,6 +84,8 @@ Examples: Options: --logdir Directory containing logs (default: ~/.p95/logs) + --url Remote API base URL (tui command; fallback: P95_URL) + --api-key Remote API key (tui command; fallback: P95_API_KEY) --help Show this help message`) } @@ -336,12 +339,20 @@ func showCmd(args []string) { func tuiCmd(args []string) { fs := flag.NewFlagSet("tui", flag.ExitOnError) logdir := fs.String("logdir", "", "Directory containing logs") + remoteURL := fs.String("url", "", "Remote API base URL") + apiKey := fs.String("api-key", "", "Remote API key") fs.Parse(args) if *logdir == "" { *logdir = defaultLogDir() } *logdir = expandPath(*logdir) + if *remoteURL == "" { + *remoteURL = os.Getenv("P95_URL") + } + if *apiKey == "" { + *apiKey = os.Getenv("P95_API_KEY") + } // Disable all logging - it breaks the TUI log.SetOutput(io.Discard) @@ -370,8 +381,13 @@ func tuiCmd(args []string) { // Wait for server to be ready time.Sleep(100 * time.Millisecond) - // Create API client pointing at local server - apiClient := client.New(fmt.Sprintf("http://%s", addr)) + // Create API clients + localClient := client.New(fmt.Sprintf("http://%s", addr)) + var apiClient client.API = localClient + if *remoteURL != "" { + remoteClient := client.NewWithAPIKey(*remoteURL, *apiKey) + apiClient = client.NewComposite(localClient, remoteClient) + } // Create and run TUI app := tui.New(apiClient) diff --git a/internal/tui/app.go b/internal/tui/app.go index f400e77..5cc09f0 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -17,7 +17,7 @@ import ( // App is the main TUI application model type App struct { - client *client.Client + client client.API width int height int @@ -30,7 +30,7 @@ type App struct { } // New creates a new TUI application -func New(apiClient *client.Client) App { +func New(apiClient client.API) App { zone.NewGlobal() return App{ client: apiClient, diff --git a/internal/tui/views/dashboard.go b/internal/tui/views/dashboard.go index 2d5bcd0..c4fe693 100644 --- a/internal/tui/views/dashboard.go +++ b/internal/tui/views/dashboard.go @@ -21,7 +21,7 @@ const ( // DashboardModel is the dashboard view model type DashboardModel struct { - client *client.Client + client client.API width int height int @@ -39,7 +39,7 @@ type DashboardModel struct { } // NewDashboard creates a new dashboard model -func NewDashboard(c *client.Client) DashboardModel { +func NewDashboard(c client.API) DashboardModel { return DashboardModel{ client: c, focus: FocusTeams, diff --git a/internal/tui/views/main.go b/internal/tui/views/main.go index b51d9a1..b121644 100644 --- a/internal/tui/views/main.go +++ b/internal/tui/views/main.go @@ -37,7 +37,7 @@ const ( // MainModel is the unified main view model with lazygit-style layout type MainModel struct { - client *client.Client + client client.API width int height int @@ -102,7 +102,7 @@ type MainModel struct { } // NewMain creates a new main model -func NewMain(c *client.Client) MainModel { +func NewMain(c client.API) MainModel { return MainModel{ client: c, zoneID: zone.NewPrefix(), diff --git a/internal/tui/views/run_detail.go b/internal/tui/views/run_detail.go index 4d63ada..d28fe02 100644 --- a/internal/tui/views/run_detail.go +++ b/internal/tui/views/run_detail.go @@ -34,7 +34,7 @@ type MetricInfo struct { // RunDetailModel is the run detail view model type RunDetailModel struct { - client *client.Client + client client.API width int height int @@ -68,7 +68,7 @@ type RunDetailModel struct { } // NewRunDetail creates a new run detail model -func NewRunDetail(c *client.Client) RunDetailModel { +func NewRunDetail(c client.API) RunDetailModel { return RunDetailModel{ client: c, charts: make(map[string]*components.Chart), diff --git a/internal/tui/views/runs_list.go b/internal/tui/views/runs_list.go index 614ec89..c1c8150 100644 --- a/internal/tui/views/runs_list.go +++ b/internal/tui/views/runs_list.go @@ -14,7 +14,7 @@ import ( // RunsListModel is the runs list view model type RunsListModel struct { - client *client.Client + client client.API width int height int @@ -28,7 +28,7 @@ type RunsListModel struct { } // NewRunsList creates a new runs list model -func NewRunsList(c *client.Client) RunsListModel { +func NewRunsList(c client.API) RunsListModel { return RunsListModel{ client: c, } diff --git a/pkg/client/client.go b/pkg/client/client.go index 4b06492..9e62bca 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" "github.com/google/uuid" @@ -15,13 +16,21 @@ import ( // Client is the API client for the TUI type Client struct { baseURL string + apiKey string httpClient *http.Client } // New creates a new API client func New(baseURL string) *Client { + return NewWithAPIKey(baseURL, "") +} + +// NewWithAPIKey creates a new API client with optional bearer auth. +func NewWithAPIKey(baseURL, apiKey string) *Client { + baseURL = strings.TrimRight(baseURL, "/") return &Client{ baseURL: baseURL, + apiKey: apiKey, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -45,6 +54,9 @@ func (c *Client) request(method, path string, body any) ([]byte, error) { } req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } resp, err := c.httpClient.Do(req) if err != nil { diff --git a/pkg/client/composite.go b/pkg/client/composite.go new file mode 100644 index 0000000..a792a6e --- /dev/null +++ b/pkg/client/composite.go @@ -0,0 +1,256 @@ +package client + +import ( + "fmt" + "strings" + "sync" + + "github.com/google/uuid" +) + +const ( + localTeamSlug = "__local__" + remoteTeamPref = "remote:" +) + +// API is the minimal interface used by the TUI. +type API interface { + GetTeams() ([]Team, error) + GetApps(teamSlug string) ([]App, error) + GetRuns(teamSlug, appSlug string) ([]Run, error) + GetRun(runID uuid.UUID, includeMetrics bool) (*Run, error) + GetMetricSeries(runID uuid.UUID, metricName string, maxPoints int) ([]Metric, error) + GetLatestMetrics(runID uuid.UUID) (map[string]float64, error) + GetMetricNames(runID uuid.UUID) ([]string, error) + GetContinuations(runID uuid.UUID) ([]Continuation, error) +} + +type runSource int + +const ( + runSourceRemote runSource = iota + runSourceLocal +) + +// CompositeClient merges remote service data and local data for the TUI. +type CompositeClient struct { + local API + remote API + + mu sync.RWMutex + runOwners map[uuid.UUID]runSource +} + +// NewComposite creates a client that can browse both remote and local data. +func NewComposite(local API, remote API) *CompositeClient { + return &CompositeClient{ + local: local, + remote: remote, + runOwners: make(map[uuid.UUID]runSource), + } +} + +func isRemoteTeamSlug(slug string) bool { + return strings.HasPrefix(slug, remoteTeamPref) +} + +func encodeRemoteTeamSlug(slug string) string { + return remoteTeamPref + slug +} + +func decodeRemoteTeamSlug(slug string) string { + return strings.TrimPrefix(slug, remoteTeamPref) +} + +func (c *CompositeClient) teamSource(teamSlug string) runSource { + if teamSlug == localTeamSlug { + return runSourceLocal + } + if isRemoteTeamSlug(teamSlug) { + return runSourceRemote + } + if c.remote != nil { + return runSourceRemote + } + return runSourceLocal +} + +func (c *CompositeClient) GetTeams() ([]Team, error) { + teams := make([]Team, 0, 4) + + if c.remote != nil { + remoteTeams, err := c.remote.GetTeams() + if err == nil { + for _, t := range remoteTeams { + t.Slug = encodeRemoteTeamSlug(t.Slug) + teams = append(teams, t) + } + } + } + + teams = append(teams, Team{ + ID: uuid.Nil, + Name: "Local", + Slug: localTeamSlug, + Description: "Local runs", + Role: "owner", + }) + + return teams, nil +} + +func (c *CompositeClient) GetApps(teamSlug string) ([]App, error) { + src := c.teamSource(teamSlug) + switch src { + case runSourceRemote: + if c.remote == nil { + return nil, fmt.Errorf("remote client not configured") + } + return c.remote.GetApps(decodeRemoteTeamSlug(teamSlug)) + default: + if c.local == nil { + return nil, fmt.Errorf("local client not configured") + } + return c.local.GetApps("local") + } +} + +func (c *CompositeClient) GetRuns(teamSlug, appSlug string) ([]Run, error) { + src := c.teamSource(teamSlug) + + var ( + runs []Run + err error + ) + + switch src { + case runSourceRemote: + if c.remote == nil { + return nil, fmt.Errorf("remote client not configured") + } + runs, err = c.remote.GetRuns(decodeRemoteTeamSlug(teamSlug), appSlug) + case runSourceLocal: + if c.local == nil { + return nil, fmt.Errorf("local client not configured") + } + runs, err = c.local.GetRuns("local", appSlug) + } + if err != nil { + return nil, err + } + + c.mu.Lock() + for _, r := range runs { + c.runOwners[r.ID] = src + } + c.mu.Unlock() + + return runs, nil +} + +func (c *CompositeClient) sourceForRun(runID uuid.UUID) (runSource, bool) { + c.mu.RLock() + src, ok := c.runOwners[runID] + c.mu.RUnlock() + return src, ok +} + +func (c *CompositeClient) GetRun(runID uuid.UUID, includeMetrics bool) (*Run, error) { + if src, ok := c.sourceForRun(runID); ok { + if src == runSourceRemote { + return c.remote.GetRun(runID, includeMetrics) + } + return c.local.GetRun(runID, includeMetrics) + } + + if c.remote != nil { + run, err := c.remote.GetRun(runID, includeMetrics) + if err == nil { + return run, nil + } + } + if c.local != nil { + return c.local.GetRun(runID, includeMetrics) + } + return nil, fmt.Errorf("no client configured") +} + +func (c *CompositeClient) GetMetricSeries(runID uuid.UUID, metricName string, maxPoints int) ([]Metric, error) { + if src, ok := c.sourceForRun(runID); ok { + if src == runSourceRemote { + return c.remote.GetMetricSeries(runID, metricName, maxPoints) + } + return c.local.GetMetricSeries(runID, metricName, maxPoints) + } + + if c.remote != nil { + points, err := c.remote.GetMetricSeries(runID, metricName, maxPoints) + if err == nil { + return points, nil + } + } + if c.local != nil { + return c.local.GetMetricSeries(runID, metricName, maxPoints) + } + return nil, fmt.Errorf("no client configured") +} + +func (c *CompositeClient) GetLatestMetrics(runID uuid.UUID) (map[string]float64, error) { + if src, ok := c.sourceForRun(runID); ok { + if src == runSourceRemote { + return c.remote.GetLatestMetrics(runID) + } + return c.local.GetLatestMetrics(runID) + } + + if c.remote != nil { + metrics, err := c.remote.GetLatestMetrics(runID) + if err == nil { + return metrics, nil + } + } + if c.local != nil { + return c.local.GetLatestMetrics(runID) + } + return nil, fmt.Errorf("no client configured") +} + +func (c *CompositeClient) GetMetricNames(runID uuid.UUID) ([]string, error) { + if src, ok := c.sourceForRun(runID); ok { + if src == runSourceRemote { + return c.remote.GetMetricNames(runID) + } + return c.local.GetMetricNames(runID) + } + + if c.remote != nil { + names, err := c.remote.GetMetricNames(runID) + if err == nil { + return names, nil + } + } + if c.local != nil { + return c.local.GetMetricNames(runID) + } + return nil, fmt.Errorf("no client configured") +} + +func (c *CompositeClient) GetContinuations(runID uuid.UUID) ([]Continuation, error) { + if src, ok := c.sourceForRun(runID); ok { + if src == runSourceRemote { + return c.remote.GetContinuations(runID) + } + return c.local.GetContinuations(runID) + } + + if c.remote != nil { + continuations, err := c.remote.GetContinuations(runID) + if err == nil { + return continuations, nil + } + } + if c.local != nil { + return c.local.GetContinuations(runID) + } + return nil, fmt.Errorf("no client configured") +} diff --git a/pkg/client/composite_test.go b/pkg/client/composite_test.go new file mode 100644 index 0000000..4d84de7 --- /dev/null +++ b/pkg/client/composite_test.go @@ -0,0 +1,173 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestCompositeGetTeamsAndRouting(t *testing.T) { + remoteRunID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + localRunID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + + var remoteHits, localHits []string + + remoteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + remoteHits = append(remoteHits, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + + switch { + case r.URL.Path == "/api/v1/teams": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "name": "Remote Team", + "slug": "acme", + "description": "", + "role": "owner", + }}) + case r.URL.Path == "/api/v1/teams/acme/apps": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "name": "Remote App", + "slug": "remote-app", + "description": "", + "run_count": 1, + }}) + case r.URL.Path == "/api/v1/teams/acme/apps/remote-app/runs": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": remoteRunID, + "name": "remote-run", + "status": "completed", + "started_at": time.Now().UTC().Format(time.RFC3339), + }}) + case r.URL.Path == "/api/v1/runs/"+remoteRunID.String(): + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": remoteRunID, + "name": "remote-run", + "status": "completed", + "started_at": time.Now().UTC().Format(time.RFC3339), + }) + default: + http.NotFound(w, r) + } + })) + defer remoteSrv.Close() + + localSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + localHits = append(localHits, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + + switch { + case r.URL.Path == "/api/v1/teams/local/apps": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "name": "Local App", + "slug": "local-app", + "description": "", + "run_count": 1, + }}) + case r.URL.Path == "/api/v1/teams/local/apps/local-app/runs": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": localRunID, + "name": "local-run", + "status": "completed", + "started_at": time.Now().UTC().Format(time.RFC3339), + }}) + case r.URL.Path == "/api/v1/runs/"+localRunID.String(): + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": localRunID, + "name": "local-run", + "status": "completed", + "started_at": time.Now().UTC().Format(time.RFC3339), + }) + default: + http.NotFound(w, r) + } + })) + defer localSrv.Close() + + cc := NewComposite(New(localSrv.URL), New(remoteSrv.URL)) + + teams, err := cc.GetTeams() + if err != nil { + t.Fatalf("GetTeams() error = %v", err) + } + if len(teams) != 2 { + t.Fatalf("GetTeams() len = %d, want 2", len(teams)) + } + if teams[0].Slug != "remote:acme" { + t.Fatalf("remote team slug = %q, want %q", teams[0].Slug, "remote:acme") + } + if teams[1].Slug != "__local__" { + t.Fatalf("local team slug = %q, want %q", teams[1].Slug, "__local__") + } + + if _, err := cc.GetApps("remote:acme"); err != nil { + t.Fatalf("GetApps(remote) error = %v", err) + } + if _, err := cc.GetApps("__local__"); err != nil { + t.Fatalf("GetApps(local) error = %v", err) + } + if _, err := cc.GetRuns("remote:acme", "remote-app"); err != nil { + t.Fatalf("GetRuns(remote) error = %v", err) + } + if _, err := cc.GetRuns("__local__", "local-app"); err != nil { + t.Fatalf("GetRuns(local) error = %v", err) + } + + if _, err := cc.GetRun(remoteRunID, true); err != nil { + t.Fatalf("GetRun(remote) error = %v", err) + } + if _, err := cc.GetRun(localRunID, true); err != nil { + t.Fatalf("GetRun(local) error = %v", err) + } + + if !containsPath(remoteHits, "/api/v1/teams/acme/apps") { + t.Fatalf("remote server did not receive remote apps request, hits=%v", remoteHits) + } + if !containsPath(localHits, "/api/v1/teams/local/apps") { + t.Fatalf("local server did not receive local apps request, hits=%v", localHits) + } + if !containsPath(remoteHits, "/api/v1/runs/"+remoteRunID.String()) { + t.Fatalf("remote server did not receive remote run detail request, hits=%v", remoteHits) + } + if !containsPath(localHits, "/api/v1/runs/"+localRunID.String()) { + t.Fatalf("local server did not receive local run detail request, hits=%v", localHits) + } +} + +func TestCompositeGetTeamsFallsBackToLocalWhenRemoteUnavailable(t *testing.T) { + remoteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "remote down", http.StatusInternalServerError) + })) + defer remoteSrv.Close() + + localSrv := httptest.NewServer(http.NotFoundHandler()) + defer localSrv.Close() + + cc := NewComposite(New(localSrv.URL), New(remoteSrv.URL)) + teams, err := cc.GetTeams() + if err != nil { + t.Fatalf("GetTeams() error = %v", err) + } + if len(teams) != 1 { + t.Fatalf("GetTeams() len = %d, want 1", len(teams)) + } + if teams[0].Slug != "__local__" { + t.Fatalf("local team slug = %q, want %q", teams[0].Slug, "__local__") + } +} + +func containsPath(hits []string, want string) bool { + for _, h := range hits { + if strings.SplitN(h, "?", 2)[0] == want { + return true + } + } + return false +}