diff --git a/README.md b/README.md index 8df85d8b..a0008fdf 100644 --- a/README.md +++ b/README.md @@ -1480,7 +1480,8 @@ There are also hidden flags for development and debugging (not listed in user-fa * `--api-base`: Sets the API base URL. * `--dashboard-base`: Sets the web dashboard base URL. -* `--console-base`: Sets the web console base URL. +* `--console-base`: Deprecated. Guest links now open Hookdeck Inspect; use `--inspect-base`. +* `--inspect-base`: Sets the Hookdeck Inspect base URL that `listen` links guest accounts to. * `--ws-base`: Sets the Websocket base URL. ## Troubleshooting diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 09ba2acf..966c9bbc 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -318,6 +318,7 @@ var flagNeedsNextArg = map[string]bool{ "outpost-api-base": true, "dashboard-base": true, "console-base": true, + "inspect-base": true, "ws-base": true, } @@ -401,7 +402,12 @@ func init() { rootCmd.PersistentFlags().MarkHidden("dashboard-base") rootCmd.PersistentFlags().StringVar(&Config.ConsoleBaseURL, "console-base", "", fmt.Sprintf("Sets the web console base URL (default \"%s\")", hookdeck.DefaultConsoleBaseURL)) - rootCmd.PersistentFlags().MarkHidden("console-base") + // Nothing reads it since guest links moved to Hookdeck Inspect. Kept so existing + // scripts don't fail on an unknown flag; cobra hides it and prints a warning. + rootCmd.PersistentFlags().MarkDeprecated("console-base", "guest links now open Hookdeck Inspect; use --inspect-base instead") + + rootCmd.PersistentFlags().StringVar(&Config.InspectBaseURL, "inspect-base", "", fmt.Sprintf("Sets the Hookdeck Inspect base URL, used for guest links (default \"%s\")", hookdeck.DefaultInspectBaseURL)) + rootCmd.PersistentFlags().MarkHidden("inspect-base") rootCmd.PersistentFlags().StringVar(&Config.WSBaseURL, "ws-base", "", fmt.Sprintf("Sets the Websocket base URL (default \"%s\")", hookdeck.DefaultWebsocektURL)) rootCmd.PersistentFlags().MarkHidden("ws-base") diff --git a/pkg/config/config.go b/pkg/config/config.go index 2b081ab2..465df217 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -39,6 +39,7 @@ type Config struct { OutpostAPIBaseURL string DashboardBaseURL string ConsoleBaseURL string + InspectBaseURL string WSBaseURL string Insecure bool @@ -404,6 +405,7 @@ func (c *Config) constructConfig() { c.OutpostAPIBaseURL = stringCoalesce(c.OutpostAPIBaseURL, c.viper.GetString(("outpost_api_base")), hookdeck.DefaultOutpostAPIBaseURL) c.DashboardBaseURL = stringCoalesce(c.DashboardBaseURL, c.viper.GetString(("dashboard_base")), hookdeck.DefaultDashboardBaseURL) c.ConsoleBaseURL = stringCoalesce(c.ConsoleBaseURL, c.viper.GetString(("console_base")), hookdeck.DefaultConsoleBaseURL) + c.InspectBaseURL = stringCoalesce(c.InspectBaseURL, c.viper.GetString(("inspect_base")), hookdeck.DefaultInspectBaseURL) c.WSBaseURL = stringCoalesce(c.WSBaseURL, c.viper.GetString(("ws_base")), hookdeck.DefaultWebsocektURL) c.Profile.Name = stringCoalesce(c.Profile.Name, c.viper.GetString(("profile")), hookdeck.DefaultProfileName) // Needs to support both profile-based config diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 8133d145..4fe23e42 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -34,8 +34,13 @@ const DefaultDashboardURL = "https://dashboard.hookdeck.com" // DefaultDashboardBaseURL is the default base URL for dashboard requests const DefaultDashboardBaseURL = "https://dashboard.hookdeck.com" +// DefaultConsoleBaseURL is the default base URL for Hookdeck Console. const DefaultConsoleBaseURL = "https://console.hookdeck.com" +// DefaultInspectBaseURL is the default base URL for Hookdeck Inspect, which +// listen links to for guest accounts (project type "console"). +const DefaultInspectBaseURL = "https://inspect.hookdeck.com" + const DefaultWebsocektURL = "wss://ws.hookdeck.com" const DefaultProfileName = "default" diff --git a/pkg/listen/links/links.go b/pkg/listen/links/links.go index 5e820d80..3d4a8c50 100644 --- a/pkg/listen/links/links.go +++ b/pkg/listen/links/links.go @@ -1,46 +1,131 @@ -// Package links builds dashboard/console deep-links for listen output. It is -// shared by the interactive TUI and the compact/quiet printer so the two -// output modes produce identical, correctly scoped URLs. +// Package links builds the web links listen prints: Event Gateway dashboard +// links for regular projects, and Hookdeck Inspect links for guest accounts +// (project type "console"). It is shared by the interactive TUI and the +// compact/quiet output so every output mode produces identical, correctly +// scoped URLs. package links -// DashboardHome returns the dashboard (or console) events link for the -// session, omitting the team_id parameter when the project id is unknown so -// links never render with an empty value. -func DashboardHome(dashboardBaseURL, consoleBaseURL, projectType, projectID string) string { - if projectType == "console" { - if projectID == "" { - return consoleBaseURL - } - return consoleBaseURL + "?team_id=" + projectID +import ( + "net/url" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// Target describes where a listen session's links point. +type Target struct { + // DashboardBaseURL is the Event Gateway dashboard, used for every project + // type except console. + DashboardBaseURL string + // InspectBaseURL is Hookdeck Inspect, used for guest (console) projects. + InspectBaseURL string + // ProjectType is the profile's project type (config.ProjectType*). + ProjectType string + // ProjectID is the project's team id. Dashboard links are scoped by it, + // and Inspect event links carry it so Inspect can check the visitor is in + // that project. + ProjectID string +} + +func (t Target) inspect() bool { + return t.ProjectType == config.ProjectTypeConsole +} + +// Name returns what the session's links open, for the labels shown next to +// them: "Hookdeck Inspect" for guest accounts, "dashboard" otherwise. +func (t Target) Name() string { + if t.inspect() { + return "Hookdeck Inspect" + } + return "dashboard" +} + +// HomeHint returns the sentence printed before the Home link. The dashboard +// wording is unchanged; Inspect shows the requests a Source received. +func (t Target) HomeHint(dashboardHint string) string { + if t.inspect() { + return "View your requests in Hookdeck Inspect" + } + return dashboardHint +} + +// Home returns the link to the session's events. For the dashboard that is +// the CLI events view, scoped by team_id when the project id is known. For +// Inspect it is the page of sourceID, or the Inspect home page when sourceID +// is empty (a session listening to several Sources has no single page). +func (t Target) Home(sourceID string) string { + if t.inspect() { + return inspectSource(t.InspectBaseURL, sourceID) } - if projectID == "" { - return dashboardBaseURL + "/events/cli" + if t.ProjectID == "" { + return t.DashboardBaseURL + "/events/cli" } - return dashboardBaseURL + "/events/cli?team_id=" + projectID + return t.DashboardBaseURL + "/events/cli?team_id=" + t.ProjectID } -// DashboardHomeDisplay returns the display text for the DashboardHome link: -// the same destination without the team_id query parameter. -func DashboardHomeDisplay(dashboardBaseURL, consoleBaseURL, projectType string) string { - if projectType == "console" { - return consoleBaseURL +// HomeDisplay returns the display text for the Home link: the same +// destination without the team_id query parameter. +func (t Target) HomeDisplay(sourceID string) string { + if t.inspect() { + return inspectSource(t.InspectBaseURL, sourceID) } - return dashboardBaseURL + "/events/cli" + return t.DashboardBaseURL + "/events/cli" } -// Event returns the dashboard (or console) deep-link for a single event, -// omitting the team_id parameter when the project id is unknown. -func Event(dashboardBaseURL, consoleBaseURL, projectType, projectID, eventID string) string { - if projectType == "console" { - url := consoleBaseURL + "/?event_id=" + eventID - if projectID != "" { - url += "&team_id=" + projectID - } - return url +// EventURL returns the deep-link for a single event. For the dashboard that is +// the event page, scoped by team_id when the project id is known. For Inspect +// it is the home page with ?event_id= and ?team_id=: Inspect looks the event +// up and opens the request that created it on its Source's page. +func (t Target) EventURL(eventID string) string { + if t.inspect() { + return inspectEvent(t.InspectBaseURL, eventID, t.ProjectID) } - url := dashboardBaseURL + "/events/" + eventID - if projectID != "" { - url += "?team_id=" + projectID + link := t.DashboardBaseURL + "/events/" + eventID + if t.ProjectID != "" { + link += "?team_id=" + t.ProjectID + } + return link +} + +// EventDisplay returns the display text for the EventURL link, as printed at +// the end of each event log line. The dashboard shows the event page without +// team_id. Inspect shows the full link, team_id included, because Inspect +// needs both ids to open the event. +func (t Target) EventDisplay(eventID string) string { + if t.inspect() { + return t.EventURL(eventID) + } + return t.DashboardBaseURL + "/events/" + eventID +} + +// inspectSource builds an Inspect Source page link. Inspect addresses a Source +// by its id without the "src_" prefix. Without a Source it falls back to the +// Inspect home page. +func inspectSource(inspectBaseURL, sourceID string) string { + bare := strings.TrimPrefix(sourceID, "src_") + if bare == "" { + return inspectBaseURL + } + return strings.TrimSuffix(inspectBaseURL, "/") + "/" + url.PathEscape(bare) +} + +// inspectEvent builds an Inspect event link: the home page with event_id and, +// when known, team_id. These are the parameters Console's event links used. +func inspectEvent(inspectBaseURL, eventID, teamID string) string { + query := url.Values{} + query.Set("event_id", eventID) + if teamID != "" { + query.Set("team_id", teamID) + } + return strings.TrimSuffix(inspectBaseURL, "/") + "/?" + query.Encode() +} + +// SessionSourceID returns the id of the one Source a session listens to, or +// "" when it listens to none or to several. +func SessionSourceID(sources []*hookdeck.Source) string { + if len(sources) != 1 || sources[0] == nil { + return "" } - return url + return sources[0].ID } diff --git a/pkg/listen/links/links_test.go b/pkg/listen/links/links_test.go index 93afb158..ece0012a 100644 --- a/pkg/listen/links/links_test.go +++ b/pkg/listen/links/links_test.go @@ -4,55 +4,110 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) const ( dashboardBase = "https://dashboard.hookdeck.com" - consoleBase = "https://console.hookdeck.com" + inspectBase = "https://inspect.hookdeck.com" ) -func TestDashboardHome(t *testing.T) { +func dashboardTarget(projectID string) Target { + return Target{DashboardBaseURL: dashboardBase, InspectBaseURL: inspectBase, ProjectType: "gateway", ProjectID: projectID} +} + +func inspectTarget(projectID string) Target { + return Target{DashboardBaseURL: dashboardBase, InspectBaseURL: inspectBase, ProjectType: "console", ProjectID: projectID} +} + +func TestHome(t *testing.T) { t.Run("dashboard with project id", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", DashboardHome(dashboardBase, consoleBase, "inbound", "tm_123")) + assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", dashboardTarget("tm_123").Home("src_abc123def456")) }) t.Run("dashboard without project id omits team_id", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/cli", DashboardHome(dashboardBase, consoleBase, "inbound", "")) + assert.Equal(t, "https://dashboard.hookdeck.com/events/cli", dashboardTarget("").Home("")) + }) + + t.Run("inspect with one source links to the source page without src_ or team_id", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", inspectTarget("tm_123").Home("src_abc123def456")) }) - t.Run("console with project id", func(t *testing.T) { - assert.Equal(t, "https://console.hookdeck.com?team_id=tm_123", DashboardHome(dashboardBase, consoleBase, "console", "tm_123")) + t.Run("inspect without a single source links to inspect home", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com", inspectTarget("tm_123").Home("")) }) - t.Run("console without project id omits team_id", func(t *testing.T) { - assert.Equal(t, "https://console.hookdeck.com", DashboardHome(dashboardBase, consoleBase, "console", "")) + t.Run("inspect base with trailing slash", func(t *testing.T) { + target := inspectTarget("") + target.InspectBaseURL = "https://inspect-staging.hookdeck.com/" + assert.Equal(t, "https://inspect-staging.hookdeck.com/abc123def456", target.Home("src_abc123def456")) }) } -func TestDashboardHomeDisplay(t *testing.T) { - t.Run("dashboard shows events path", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/cli", DashboardHomeDisplay(dashboardBase, consoleBase, "inbound")) +func TestName(t *testing.T) { + assert.Equal(t, "Hookdeck Inspect", inspectTarget("tm_123").Name()) + assert.Equal(t, "dashboard", dashboardTarget("tm_123").Name()) +} + +func TestHomeHint(t *testing.T) { + assert.Equal(t, "View your requests in Hookdeck Inspect", inspectTarget("").HomeHint("Open dashboard")) + assert.Equal(t, "Open dashboard", dashboardTarget("").HomeHint("Open dashboard")) +} + +func TestHomeDisplay(t *testing.T) { + t.Run("dashboard shows events path without team_id", func(t *testing.T) { + assert.Equal(t, "https://dashboard.hookdeck.com/events/cli", dashboardTarget("tm_123").HomeDisplay("src_abc123def456")) }) - t.Run("console shows console base", func(t *testing.T) { - assert.Equal(t, "https://console.hookdeck.com", DashboardHomeDisplay(dashboardBase, consoleBase, "console")) + t.Run("inspect shows the source page", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", inspectTarget("tm_123").HomeDisplay("src_abc123def456")) + }) + + t.Run("inspect without a single source shows inspect home", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com", inspectTarget("tm_123").HomeDisplay("")) }) } -func TestEvent(t *testing.T) { +func TestEventURL(t *testing.T) { t.Run("dashboard with project id", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", Event(dashboardBase, consoleBase, "inbound", "tm_123", "evt_1")) + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", dashboardTarget("tm_123").EventURL("evt_1")) }) t.Run("dashboard without project id omits team_id", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1", Event(dashboardBase, consoleBase, "inbound", "", "evt_1")) + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1", dashboardTarget("").EventURL("evt_1")) + }) + + t.Run("inspect links to the event by event_id and team_id", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123", inspectTarget("tm_123").EventURL("evt_1")) }) - t.Run("console with project id", func(t *testing.T) { - assert.Equal(t, "https://console.hookdeck.com/?event_id=evt_1&team_id=tm_123", Event(dashboardBase, consoleBase, "console", "tm_123", "evt_1")) + t.Run("inspect without project id omits team_id", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/?event_id=evt_1", inspectTarget("").EventURL("evt_1")) }) - t.Run("console without project id omits team_id", func(t *testing.T) { - assert.Equal(t, "https://console.hookdeck.com/?event_id=evt_1", Event(dashboardBase, consoleBase, "console", "", "evt_1")) + t.Run("inspect base with trailing slash", func(t *testing.T) { + target := inspectTarget("tm_123") + target.InspectBaseURL = "https://inspect-staging.hookdeck.com/" + assert.Equal(t, "https://inspect-staging.hookdeck.com/?event_id=evt_1&team_id=tm_123", target.EventURL("evt_1")) + }) +} + +func TestEventDisplay(t *testing.T) { + t.Run("dashboard shows the event page without team_id", func(t *testing.T) { + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1", dashboardTarget("tm_123").EventDisplay("evt_1")) }) + + t.Run("inspect shows the same link it opens, team_id included", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123", inspectTarget("tm_123").EventDisplay("evt_1")) + }) +} + +func TestSessionSourceID(t *testing.T) { + one := &hookdeck.Source{ID: "src_1"} + two := &hookdeck.Source{ID: "src_2"} + + assert.Equal(t, "src_1", SessionSourceID([]*hookdeck.Source{one})) + assert.Equal(t, "", SessionSourceID([]*hookdeck.Source{one, two}), "several sources have no single page") + assert.Equal(t, "", SessionSourceID(nil)) } diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 90c27ad1..5f16ece0 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -174,7 +174,7 @@ Specify a single destination to update the path. For example, pass a connection ProjectType: config.Profile.ProjectType, APIBaseURL: config.APIBaseURL, DashboardBaseURL: config.DashboardBaseURL, - ConsoleBaseURL: config.ConsoleBaseURL, + InspectBaseURL: config.InspectBaseURL, WSBaseURL: config.WSBaseURL, NoWSS: flags.NoWSS, NoHealthcheck: flags.NoHealthcheck, @@ -194,7 +194,7 @@ Specify a single destination to update the path. For example, pass a connection APIKey: config.Profile.APIKey, APIBaseURL: config.APIBaseURL, DashboardBaseURL: config.DashboardBaseURL, - ConsoleBaseURL: config.ConsoleBaseURL, + InspectBaseURL: config.InspectBaseURL, ProjectType: config.Profile.ProjectType, ProjectID: projectID, GuestURL: guestURL, diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index dbf16f74..8bd9fbfa 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -95,13 +95,21 @@ func printSourcesWithConnections(config *config.Config, projectID string, source } } - // Print dashboard hint + // Print the sign-up hint, or the link to the session's events fmt.Println() if guestURL != "" { fmt.Printf("💡 Sign up to make your webhook URL permanent: %s\n", guestURL) } else { - url := links.DashboardHome(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType, projectID) - displayURL := links.DashboardHomeDisplay(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType) - fmt.Printf("💡 Open dashboard to inspect, retry & bookmark events: %s\n", hyperlink(url, displayURL, os.Stdout)) + target := links.Target{ + DashboardBaseURL: config.DashboardBaseURL, + InspectBaseURL: config.InspectBaseURL, + ProjectType: config.Profile.ProjectType, + ProjectID: projectID, + } + sourceID := links.SessionSourceID(sources) + url := target.Home(sourceID) + displayURL := target.HomeDisplay(sourceID) + hint := target.HomeHint("Open dashboard to inspect, retry & bookmark events") + fmt.Printf("💡 %s: %s\n", hint, hyperlink(url, displayURL, os.Stdout)) } } diff --git a/pkg/listen/printer_test.go b/pkg/listen/printer_test.go index 672f384c..b893ea60 100644 --- a/pkg/listen/printer_test.go +++ b/pkg/listen/printer_test.go @@ -46,7 +46,7 @@ func printerFixture(t *testing.T, numConnections int) (*config.Config, []*hookde targetURL, err := url.Parse("http://localhost:3030") require.NoError(t, err) - source := &hookdeck.Source{ID: "src_1", Name: "my-source", URL: "https://hkdk.events/src_1"} + source := &hookdeck.Source{ID: "src_abc123def456", Name: "my-source", URL: "https://hkdk.events/src_abc123def456"} connections := make([]*hookdeck.Connection, 0, numConnections) for i := 0; i < numConnections; i++ { @@ -61,7 +61,7 @@ func printerFixture(t *testing.T, numConnections int) (*config.Config, []*hookde cfg := &config.Config{ DashboardBaseURL: "https://dashboard.hookdeck.com", - ConsoleBaseURL: "https://console.hookdeck.com", + InspectBaseURL: "https://inspect.hookdeck.com", } return cfg, []*hookdeck.Source{source}, connections, targetURL @@ -184,3 +184,36 @@ func TestDashboardLinkHyperlinksOnlyOnATerminal(t *testing.T) { assert.Equal(t, 0, strings.Count(out, "\x1b]8;;")) }) } + +// TestConsoleProjectLinksToInspect covers guest accounts (project type +// console) that print the events hint: the link goes to the Source's Inspect +// page, with no team_id, which Inspect does not read. +func TestConsoleProjectLinksToInspect(t *testing.T) { + t.Run("one source links to its Inspect page", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 1) + cfg.Profile.ProjectType = config.ProjectTypeConsole + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Contains(t, out, "💡 View your requests in Hookdeck Inspect: https://inspect.hookdeck.com/abc123def456\n") + assert.NotContains(t, out, "dashboard") + assert.NotContains(t, out, "console.hookdeck.com") + assert.NotContains(t, out, "team_id") + }) + + t.Run("several sources link to Inspect home", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 1) + cfg.Profile.ProjectType = config.ProjectTypeConsole + sources = append(sources, &hookdeck.Source{ID: "src_other000000", Name: "other", URL: "https://hkdk.events/src_other000000"}) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Contains(t, out, "Hookdeck Inspect: https://inspect.hookdeck.com\n") + }) +} diff --git a/pkg/listen/proxy/proxy.go b/pkg/listen/proxy/proxy.go index d47b5756..de68df22 100644 --- a/pkg/listen/proxy/proxy.go +++ b/pkg/listen/proxy/proxy.go @@ -51,7 +51,7 @@ type Config struct { URL *url.URL APIBaseURL string DashboardBaseURL string - ConsoleBaseURL string + InspectBaseURL string WSBaseURL string Log *log.Logger // Force use of unencrypted ws:// protocol instead of wss:// diff --git a/pkg/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go index ca58e9ff..59f9b551 100644 --- a/pkg/listen/proxy/renderer.go +++ b/pkg/listen/proxy/renderer.go @@ -6,6 +6,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/links" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -62,7 +63,7 @@ type RendererConfig struct { APIKey string APIBaseURL string DashboardBaseURL string - ConsoleBaseURL string + InspectBaseURL string ProjectType string ProjectID string GuestURL string @@ -75,6 +76,21 @@ type RendererConfig struct { AppConfig *config.Config } +// linkTarget returns where this session's links point. +func (c *RendererConfig) linkTarget() links.Target { + return links.Target{ + DashboardBaseURL: c.DashboardBaseURL, + InspectBaseURL: c.InspectBaseURL, + ProjectType: c.ProjectType, + ProjectID: c.ProjectID, + } +} + +// eventLinkDisplay returns the link printed at the end of an event's log line. +func (c *RendererConfig) eventLinkDisplay(eventID string) string { + return c.linkTarget().EventDisplay(eventID) +} + // NewRenderer creates the appropriate renderer based on output mode func NewRenderer(cfg *RendererConfig) Renderer { switch cfg.Output { diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index c6b3dd84..aeb68fcd 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -10,7 +10,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -51,7 +50,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { APIKey: cfg.APIKey, APIBaseURL: cfg.APIBaseURL, DashboardBaseURL: cfg.DashboardBaseURL, - ConsoleBaseURL: cfg.ConsoleBaseURL, + InspectBaseURL: cfg.InspectBaseURL, ProjectType: cfg.ProjectType, ProjectID: cfg.ProjectID, GuestURL: cfg.GuestURL, @@ -145,12 +144,7 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket localTime := eventTime.Format(interactiveTimeLayout) color := ansi.Color(os.Stdout) - var displayURL string - if r.cfg.ProjectType == config.ProjectTypeConsole { - displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID - } else { - displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID - } + displayURL := r.cfg.eventLinkDisplay(eventID) durationMs := response.Duration.Milliseconds() diff --git a/pkg/listen/proxy/renderer_links_test.go b/pkg/listen/proxy/renderer_links_test.go new file mode 100644 index 00000000..ac0e9539 --- /dev/null +++ b/pkg/listen/proxy/renderer_links_test.go @@ -0,0 +1,88 @@ +package proxy + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// Both renderers used to build the event link by hand, apart from +// pkg/listen/links. These tests pin the log line each renderer prints to the +// shared builder's output. + +func linksRendererConfig(t *testing.T, projectType string) *RendererConfig { + t.Helper() + target, err := url.Parse("http://localhost:3000") + require.NoError(t, err) + + return &RendererConfig{ + DashboardBaseURL: "https://dashboard.hookdeck.com", + InspectBaseURL: "https://inspect.hookdeck.com", + ProjectType: projectType, + ProjectID: "tm_123", + TargetURL: target, + } +} + +func linksTestAttempt() *websocket.Attempt { + return &websocket.Attempt{Body: websocket.AttemptBody{ + Path: "/webhooks", + EventID: "evt_1", + AttemptId: "atm_1", + ConnectionId: "web_1", + Request: websocket.AttemptRequest{Method: "POST"}, + }} +} + +func TestSimpleRendererEventLink(t *testing.T) { + response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond} + + for _, tc := range []struct { + name string + projectType string + want string + }{ + {"dashboard is unchanged", "gateway", "→ https://dashboard.hookdeck.com/events/evt_1\n"}, + {"guest links to the event in Inspect", "console", "→ https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := NewSimpleRenderer(linksRendererConfig(t, tc.projectType), false) + out := captureStdout(t, func() { + r.OnEventComplete("evt_1", linksTestAttempt(), response, time.Now()) + }) + assert.Contains(t, out, tc.want) + assert.NotContains(t, out, "console.hookdeck.com") + }) + } +} + +func TestInteractiveRendererEventLink(t *testing.T) { + response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond} + + for _, tc := range []struct { + name string + projectType string + want string + }{ + {"dashboard is unchanged", "gateway", "https://dashboard.hookdeck.com/events/evt_1"}, + {"guest links to the event in Inspect", "console", "https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123"}, + } { + t.Run(tc.name, func(t *testing.T) { + r, sent := recordingInteractiveRenderer() + r.cfg = linksRendererConfig(t, tc.projectType) + + r.OnEventComplete("evt_1", linksTestAttempt(), response, time.Now()) + + require.Len(t, *sent, 1) + update, ok := (*sent)[0].(tui.UpdateEventMsg) + require.True(t, ok, "unexpected message %T", (*sent)[0]) + assert.Contains(t, update.LogLine, tc.want) + }) + } +} diff --git a/pkg/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go index ec022ede..db6bb52a 100644 --- a/pkg/listen/proxy/renderer_simple.go +++ b/pkg/listen/proxy/renderer_simple.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -142,13 +141,7 @@ func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Atte localTime := time.Now().Format(simpleTimeLayout) color := ansi.Color(os.Stdout) - // Build display URL - var displayURL string - if r.cfg.ProjectType == config.ProjectTypeConsole { - displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID - } else { - displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID - } + displayURL := r.cfg.eventLinkDisplay(eventID) durationMs := response.Duration.Milliseconds() diff --git a/pkg/listen/tui/links.go b/pkg/listen/tui/links.go index 3099261e..1aacf4ac 100644 --- a/pkg/listen/tui/links.go +++ b/pkg/listen/tui/links.go @@ -2,14 +2,26 @@ package tui import "github.com/hookdeck/hookdeck-cli/pkg/listen/links" -// dashboardHomeURL returns the dashboard (or console) events link for the -// session. See pkg/listen/links for the shared construction rules. -func dashboardHomeURL(cfg *Config) string { - return links.DashboardHome(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectType, cfg.ProjectID) +// linkTarget returns where this session's links point. See pkg/listen/links +// for the shared construction rules. +func linkTarget(cfg *Config) links.Target { + return links.Target{ + DashboardBaseURL: cfg.DashboardBaseURL, + InspectBaseURL: cfg.InspectBaseURL, + ProjectType: cfg.ProjectType, + ProjectID: cfg.ProjectID, + } } -// eventDashboardURL returns the dashboard (or console) deep-link for a single -// event. See pkg/listen/links for the shared construction rules. -func eventDashboardURL(cfg *Config, eventID string) string { - return links.Event(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectType, cfg.ProjectID, eventID) +// homeURL returns the link to the session's events: the dashboard +// events view, or for guest accounts the Inspect page of the session's Source +// (Inspect home when it listens to several). +func homeURL(cfg *Config) string { + return linkTarget(cfg).Home(links.SessionSourceID(cfg.Sources)) +} + +// eventURL returns the deep-link for a single event: the dashboard +// event page, or for guest accounts the Inspect link that opens the event. +func eventURL(cfg *Config, eventID string) string { + return linkTarget(cfg).EventURL(eventID) } diff --git a/pkg/listen/tui/links_test.go b/pkg/listen/tui/links_test.go index 2a165158..f6ae6143 100644 --- a/pkg/listen/tui/links_test.go +++ b/pkg/listen/tui/links_test.go @@ -4,31 +4,37 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) // Construction rules are covered in pkg/listen/links; these tests only guard // the wiring from Config fields to the shared helpers. -func TestDashboardHomeURLUsesConfigFields(t *testing.T) { - cfg := &Config{ +func linksTestConfig() *Config { + return &Config{ DashboardBaseURL: "https://dashboard.hookdeck.com", - ConsoleBaseURL: "https://console.hookdeck.com", + InspectBaseURL: "https://inspect.hookdeck.com", ProjectID: "tm_123", + Sources: []*hookdeck.Source{{ID: "src_abc123def456"}}, } - assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", dashboardHomeURL(cfg)) +} + +func TestDashboardHomeURLUsesConfigFields(t *testing.T) { + cfg := linksTestConfig() + assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", homeURL(cfg)) cfg.ProjectType = "console" - assert.Equal(t, "https://console.hookdeck.com?team_id=tm_123", dashboardHomeURL(cfg)) + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", homeURL(cfg)) + + cfg.Sources = append(cfg.Sources, &hookdeck.Source{ID: "src_other000000"}) + assert.Equal(t, "https://inspect.hookdeck.com", homeURL(cfg)) } func TestEventDashboardURLUsesConfigFields(t *testing.T) { - cfg := &Config{ - DashboardBaseURL: "https://dashboard.hookdeck.com", - ConsoleBaseURL: "https://console.hookdeck.com", - ProjectID: "tm_123", - } - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, "evt_1")) + cfg := linksTestConfig() + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventURL(cfg, "evt_1")) cfg.ProjectType = "console" - assert.Equal(t, "https://console.hookdeck.com/?event_id=evt_1&team_id=tm_123", eventDashboardURL(cfg, "evt_1")) + assert.Equal(t, "https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123", eventURL(cfg, "evt_1")) } diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index 4334d7c3..a253b25b 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -116,7 +116,7 @@ type Config struct { APIKey string APIBaseURL string DashboardBaseURL string - ConsoleBaseURL string + InspectBaseURL string ProjectType string ProjectID string GuestURL string diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index 99506d77..d7a36d3b 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -288,7 +288,8 @@ func (m Model) retrySelectedEvent() tea.Cmd { } } -// openSelectedEventInBrowser opens the event in the dashboard +// openSelectedEventInBrowser opens the event in the dashboard, or in Hookdeck +// Inspect for guest accounts func (m Model) openSelectedEventInBrowser() tea.Cmd { selectedEvent := m.GetSelectedEvent() if selectedEvent == nil || selectedEvent.ID == "" { @@ -296,10 +297,10 @@ func (m Model) openSelectedEventInBrowser() tea.Cmd { } return func() tea.Msg { - eventURL := eventDashboardURL(m.cfg, selectedEvent.ID) + link := eventURL(m.cfg, selectedEvent.ID) // Open in browser - err := openBrowser(eventURL) + err := openBrowser(link) return openBrowserResultMsg{err: err} } } diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index 59a873ed..61a705b5 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -294,8 +294,8 @@ func (m Model) renderStatusBar() string { eventStatusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [r] [o] [d] [q]", checkmark, eventType, selectedEvent.Status) } else { - eventStatusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in dashboard • [d] Show data • [q] Quit", - checkmark, eventType, selectedEvent.Status) + eventStatusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in %s • [d] Show data • [q] Quit", + checkmark, eventType, selectedEvent.Status, linkTarget(m.cfg).Name()) } } else { // Error status @@ -322,51 +322,14 @@ func (m Model) renderStatusBar() string { xmark, eventType, selectedEvent.Status) } } else { - eventStatusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in dashboard • [d] Show event data • [q] Quit", - xmark, eventType, statusText) + eventStatusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in %s • [d] Show event data • [q] Quit", + xmark, eventType, statusText, linkTarget(m.cfg).Name()) } } return statusBarStyle.Width(m.width).Render(connStatus + " " + eventStatusMsg) } -// FormatEventLog formats an event into a log line matching the current style -func FormatEventLog(event EventInfo, dashboardURL, consoleURL, projectMode string) string { - localTime := event.Time.Format(timeLayout) - - // Build event URL - var url string - if projectMode == "console" { - url = consoleURL + "/?event_id=" + event.ID - } else { - url = dashboardURL + "/events/" + event.ID - } - - // Format based on whether request failed or succeeded - if event.ResponseStatus == 0 && !event.Success { - // Request failed completely (no response) - return fmt.Sprintf("%s [%s] Failed to %s: network error", - faintStyle.Render(localTime), - redStyle.Render("ERROR"), - event.Data.Body.Request.Method, - ) - } - - // Format normal response - durationMs := event.ResponseDuration.Milliseconds() - requestURL := fmt.Sprintf("http://localhost%s", event.Data.Body.Path) // Simplified for now - - return fmt.Sprintf("%s [%s] %s %s %s %s %s", - faintStyle.Render(localTime), - ColorizeStatus(event.ResponseStatus), - event.Data.Body.Request.Method, - requestURL, - faintStyle.Render(fmt.Sprintf("(%dms)", durationMs)), - faintStyle.Render("→"), - faintStyle.Render(url), - ) -} - // renderConnectionInfo renders the sources and connections header func (m Model) renderConnectionInfo() string { // If header is collapsed, show compact view @@ -518,8 +481,8 @@ func (m Model) renderConnectionInfo() string { s.WriteString("💡 Sign up to make your webhook URL permanent: ") s.WriteString(m.cfg.GuestURL) } else { - displayURL := dashboardHomeURL(m.cfg) - s.WriteString("💡 View dashboard to inspect, retry & bookmark events: ") + displayURL := homeURL(m.cfg) + s.WriteString("💡 " + linkTarget(m.cfg).HomeHint("View dashboard to inspect, retry & bookmark events") + ": ") s.WriteString(displayURL) } s.WriteString("\n")