From ba444a39db0d40c4cb8c43334e358972d5369f6d Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 4 Nov 2026 09:30:59 +0000 Subject: [PATCH 1/4] feat(listen): link guest accounts to Hookdeck Inspect Guest accounts (project type console) were linked to Hookdeck Console with ?event_id= and ?team_id=, which mean nothing to Hookdeck Inspect. listen now links guests to Inspect directly: - Event links go to the event's Source page in Inspect, /. The Source is looked up from the attempt's webhook_id. The link adds ?request= when the attempt carries one, and falls back to Inspect home when the Source is unknown. - The home link is the Source page when the session listens to exactly one Source, otherwise Inspect home. Guest links no longer carry team_id. - New optional AttemptBody.RequestID (request_id, omitempty), ready for when core adds it to the attempt payload. - New Inspect base URL, default https://inspect.hookdeck.com, which can be overridden with the hidden --inspect-base flag or inspect_base. - Every link goes through pkg/listen/links. The compact and interactive renderers no longer build URLs by hand, and the unused tui.FormatEventLog is removed. Dashboard links for non-guest projects are unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + pkg/cmd/root.go | 4 + pkg/config/config.go | 2 + pkg/hookdeck/client.go | 5 + pkg/listen/links/links.go | 164 ++++++++++++++++++----- pkg/listen/links/links_test.go | 109 ++++++++++++--- pkg/listen/listen.go | 4 +- pkg/listen/printer.go | 11 +- pkg/listen/printer_test.go | 36 ++++- pkg/listen/proxy/proxy.go | 2 +- pkg/listen/proxy/renderer.go | 18 ++- pkg/listen/proxy/renderer_interactive.go | 10 +- pkg/listen/proxy/renderer_links_test.go | 95 +++++++++++++ pkg/listen/proxy/renderer_simple.go | 9 +- pkg/listen/tui/links.go | 26 +++- pkg/listen/tui/links_test.go | 36 +++-- pkg/listen/tui/model.go | 2 +- pkg/listen/tui/update.go | 2 +- pkg/listen/tui/view.go | 37 ----- pkg/websocket/attempt_messages.go | 13 +- pkg/websocket/attempt_messages_test.go | 26 ++++ 21 files changed, 473 insertions(+), 139 deletions(-) create mode 100644 pkg/listen/proxy/renderer_links_test.go create mode 100644 pkg/websocket/attempt_messages_test.go diff --git a/README.md b/README.md index 8df85d8b..42e4653b 100644 --- a/README.md +++ b/README.md @@ -1481,6 +1481,7 @@ 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. +* `--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..2a28a0b3 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, } @@ -403,6 +404,9 @@ func init() { rootCmd.PersistentFlags().StringVar(&Config.ConsoleBaseURL, "console-base", "", fmt.Sprintf("Sets the web console base URL (default \"%s\")", hookdeck.DefaultConsoleBaseURL)) rootCmd.PersistentFlags().MarkHidden("console-base") + 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..873c6359 100644 --- a/pkg/listen/links/links.go +++ b/pkg/listen/links/links.go @@ -1,46 +1,142 @@ -// 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" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// 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 scopes dashboard links with team_id. Inspect links ignore it: + // an Inspect page is addressed by its Source alone. + ProjectID string +} + +// Event identifies one event a link points at. SourceID and RequestID only +// matter for Inspect, which has no event page: an event is shown as the +// request that created it, on its Source's page. +type Event struct { + ID string + SourceID string + RequestID string +} + +func (t Target) inspect() bool { + return t.ProjectType == config.ProjectTypeConsole +} + +// 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 event's Source page, selecting its request when the request id is +// known, and the Inspect home page when the Source is unknown. +func (t Target) EventURL(event Event) string { + if t.inspect() { + return inspectSource(t.InspectBaseURL, event.SourceID, event.RequestID) + } + link := t.DashboardBaseURL + "/events/" + event.ID + 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 same destination without the team_id +// query parameter. +func (t Target) EventDisplay(event Event) string { + if t.inspect() { + return t.EventURL(event) } - url := dashboardBaseURL + "/events/" + eventID - if projectID != "" { - url += "?team_id=" + projectID + return t.DashboardBaseURL + "/events/" + event.ID +} + +// inspectSource builds an Inspect Source page link. Inspect addresses a Source +// by its id without the "src_" prefix, and selects a request with ?request=. +// Without a Source it falls back to the Inspect home page. +func inspectSource(inspectBaseURL, sourceID, requestID string) string { + bare := strings.TrimPrefix(sourceID, "src_") + if bare == "" { + return inspectBaseURL + } + link := strings.TrimSuffix(inspectBaseURL, "/") + "/" + url.PathEscape(bare) + if requestID != "" { + link += "?request=" + url.QueryEscape(requestID) + } + return link +} + +// 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 sources[0].ID +} + +// AttemptEvent returns the Event an attempt delivers: its event id, the +// Source of its connection (resolved from the connections the session +// loaded), and its request id when the server sent one. +func AttemptEvent(eventID string, attempt *websocket.Attempt, connections []*hookdeck.Connection) Event { + event := Event{ID: eventID} + if attempt == nil { + return event + } + event.SourceID = ConnectionSourceID(connections, attempt.Body.ConnectionId) + event.RequestID = attempt.Body.RequestID + return event +} + +// ConnectionSourceID returns the id of the Source that connectionID belongs +// to, from the connections the session loaded, or "" when it is not among +// them. An attempt names its connection (webhook_id) but not its Source. +func ConnectionSourceID(connections []*hookdeck.Connection, connectionID string) string { + if connectionID == "" { + return "" + } + for _, connection := range connections { + if connection != nil && connection.ID == connectionID && connection.Source != nil { + return connection.Source.ID + } } - return url + return "" } diff --git a/pkg/listen/links/links_test.go b/pkg/listen/links/links_test.go index 93afb158..9a51f002 100644 --- a/pkg/listen/links/links_test.go +++ b/pkg/listen/links/links_test.go @@ -4,55 +4,124 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) 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 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) { + event := Event{ID: "evt_1", SourceID: "src_abc123def456", RequestID: "req_1"} + 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(event)) }) 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(event)) + }) + + t.Run("inspect with source and request selects the request", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456?request=req_1", inspectTarget("tm_123").EventURL(event)) + }) + + t.Run("inspect without request id links to the source page", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", inspectTarget("tm_123").EventURL(Event{ID: "evt_1", SourceID: "src_abc123def456"})) + }) + + t.Run("inspect with unknown source falls back to inspect home", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com", inspectTarget("tm_123").EventURL(Event{ID: "evt_1", RequestID: "req_1"})) + }) +} + +func TestEventDisplay(t *testing.T) { + event := Event{ID: "evt_1", SourceID: "src_abc123def456", RequestID: "req_1"} + + 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(event)) + }) + + t.Run("inspect shows the same link it opens", func(t *testing.T) { + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456?request=req_1", inspectTarget("tm_123").EventDisplay(event)) + }) +} + +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)) +} + +func TestAttemptEvent(t *testing.T) { + connections := []*hookdeck.Connection{ + {ID: "web_a", Source: &hookdeck.Source{ID: "src_a"}}, + {ID: "web_b", Source: &hookdeck.Source{ID: "src_b"}}, + } + + t.Run("resolves the source from webhook_id and carries request_id", func(t *testing.T) { + attempt := &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_b", RequestID: "req_1"}} + assert.Equal(t, Event{ID: "evt_1", SourceID: "src_b", RequestID: "req_1"}, AttemptEvent("evt_1", attempt, connections)) }) - 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("unknown connection leaves the source empty", func(t *testing.T) { + attempt := &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_zzz"}} + assert.Equal(t, Event{ID: "evt_1"}, AttemptEvent("evt_1", attempt, connections)) }) - 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("nil attempt", func(t *testing.T) { + assert.Equal(t, Event{ID: "evt_1"}, AttemptEvent("evt_1", nil, connections)) }) } 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..a32ddfc3 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -100,8 +100,15 @@ func printSourcesWithConnections(config *config.Config, projectID string, source 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) + 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) fmt.Printf("💡 Open dashboard to inspect, retry & bookmark events: %s\n", hyperlink(url, displayURL, os.Stdout)) } } diff --git a/pkg/listen/printer_test.go b/pkg/listen/printer_test.go index 672f384c..426f27b7 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,35 @@ 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, "https://inspect.hookdeck.com/abc123def456\n") + 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, "events: 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..d106fd62 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, attempt *websocket.Attempt) string { + return c.linkTarget().EventDisplay(links.AttemptEvent(eventID, attempt, c.Connections)) +} + // 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..f09dca9d 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, attempt) 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..a33c4405 --- /dev/null +++ b/pkg/listen/proxy/renderer_links_test.go @@ -0,0 +1,95 @@ +package proxy + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "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, so guest links stayed +// on Console after pkg/listen/links moved. 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) + + source := &hookdeck.Source{ID: "src_abc123def456"} + return &RendererConfig{ + DashboardBaseURL: "https://dashboard.hookdeck.com", + InspectBaseURL: "https://inspect.hookdeck.com", + ProjectType: projectType, + ProjectID: "tm_123", + TargetURL: target, + Sources: []*hookdeck.Source{source}, + Connections: []*hookdeck.Connection{{ID: "web_1", Source: source}}, + } +} + +func linksTestAttempt(requestID string) *websocket.Attempt { + return &websocket.Attempt{Body: websocket.AttemptBody{ + Path: "/webhooks", + EventID: "evt_1", + AttemptId: "atm_1", + ConnectionId: "web_1", + RequestID: requestID, + 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 + requestID string + want string + }{ + {"dashboard is unchanged", "gateway", "req_1", "→ https://dashboard.hookdeck.com/events/evt_1\n"}, + {"guest links to the Inspect source page and request", "console", "req_1", "→ https://inspect.hookdeck.com/abc123def456?request=req_1\n"}, + {"guest without request_id links to the source page", "console", "", "→ https://inspect.hookdeck.com/abc123def456\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(tc.requestID), 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 Inspect source page and request", "console", "https://inspect.hookdeck.com/abc123def456?request=req_1"}, + } { + t.Run(tc.name, func(t *testing.T) { + r, sent := recordingInteractiveRenderer() + r.cfg = linksRendererConfig(t, tc.projectType) + + r.OnEventComplete("evt_1", linksTestAttempt("req_1"), 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..ac32918f 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, attempt) durationMs := response.Duration.Milliseconds() diff --git a/pkg/listen/tui/links.go b/pkg/listen/tui/links.go index 3099261e..bc7f8692 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. +// 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, + } +} + +// dashboardHomeURL 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 dashboardHomeURL(cfg *Config) string { - return links.DashboardHome(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectType, cfg.ProjectID) + return linkTarget(cfg).Home(links.SessionSourceID(cfg.Sources)) } -// 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) +// eventDashboardURL returns the deep-link for a single event: the dashboard +// event page, or for guest accounts the event's Source page in Inspect. +func eventDashboardURL(cfg *Config, event EventInfo) string { + return linkTarget(cfg).EventURL(links.AttemptEvent(event.ID, event.Data, cfg.Connections)) } diff --git a/pkg/listen/tui/links_test.go b/pkg/listen/tui/links_test.go index 2a165158..6c6cb3b2 100644 --- a/pkg/listen/tui/links_test.go +++ b/pkg/listen/tui/links_test.go @@ -4,31 +4,47 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) // 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 { + source := &hookdeck.Source{ID: "src_abc123def456"} + return &Config{ DashboardBaseURL: "https://dashboard.hookdeck.com", - ConsoleBaseURL: "https://console.hookdeck.com", + InspectBaseURL: "https://inspect.hookdeck.com", ProjectID: "tm_123", + Sources: []*hookdeck.Source{source}, + Connections: []*hookdeck.Connection{{ID: "web_1", Source: source}}, } +} + +func TestDashboardHomeURLUsesConfigFields(t *testing.T) { + cfg := linksTestConfig() assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", dashboardHomeURL(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", dashboardHomeURL(cfg)) + + cfg.Sources = append(cfg.Sources, &hookdeck.Source{ID: "src_other000000"}) + assert.Equal(t, "https://inspect.hookdeck.com", dashboardHomeURL(cfg)) } func TestEventDashboardURLUsesConfigFields(t *testing.T) { - cfg := &Config{ - DashboardBaseURL: "https://dashboard.hookdeck.com", - ConsoleBaseURL: "https://console.hookdeck.com", - ProjectID: "tm_123", + cfg := linksTestConfig() + event := EventInfo{ + ID: "evt_1", + Data: &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_1", RequestID: "req_1"}}, } - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, "evt_1")) + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, event)) 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/abc123def456?request=req_1", eventDashboardURL(cfg, event)) + + event.Data.Body.RequestID = "" + assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", eventDashboardURL(cfg, event)) } 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..7223df87 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -296,7 +296,7 @@ func (m Model) openSelectedEventInBrowser() tea.Cmd { } return func() tea.Msg { - eventURL := eventDashboardURL(m.cfg, selectedEvent.ID) + eventURL := eventDashboardURL(m.cfg, *selectedEvent) // Open in browser err := openBrowser(eventURL) diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index 59a873ed..c49b3c26 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -330,43 +330,6 @@ func (m Model) renderStatusBar() string { 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 diff --git a/pkg/websocket/attempt_messages.go b/pkg/websocket/attempt_messages.go index c13f50ca..e02c1043 100644 --- a/pkg/websocket/attempt_messages.go +++ b/pkg/websocket/attempt_messages.go @@ -43,11 +43,14 @@ func (r AttemptRequest) Body() ([]byte, error) { } type AttemptBody struct { - Path string `json:"cli_path"` - EventID string `json:"event_id"` - AttemptId string `json:"attempt_id"` - ConnectionId string `json:"webhook_id"` - Request AttemptRequest `json:"request"` + Path string `json:"cli_path"` + EventID string `json:"event_id"` + AttemptId string `json:"attempt_id"` + ConnectionId string `json:"webhook_id"` + // RequestID is the request that created the event. It is optional: servers + // that predate it do not send it, and listen links then omit the request. + RequestID string `json:"request_id,omitempty"` + Request AttemptRequest `json:"request"` } type Attempt struct { diff --git a/pkg/websocket/attempt_messages_test.go b/pkg/websocket/attempt_messages_test.go new file mode 100644 index 00000000..926d9876 --- /dev/null +++ b/pkg/websocket/attempt_messages_test.go @@ -0,0 +1,26 @@ +package websocket + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// request_id is optional on the wire: servers that do not send it must still +// decode, and listen links fall back to the Source page without it. +func TestAttemptBodyRequestID(t *testing.T) { + t.Run("decoded when the server sends it", func(t *testing.T) { + var attempt Attempt + require.NoError(t, json.Unmarshal([]byte(`{"type":"attempt","body":{"event_id":"evt_1","attempt_id":"atm_1","webhook_id":"web_1","request_id":"req_1","request":{"method":"POST"}}}`), &attempt)) + assert.Equal(t, "req_1", attempt.Body.RequestID) + assert.Equal(t, "web_1", attempt.Body.ConnectionId) + }) + + t.Run("empty when the server does not send it", func(t *testing.T) { + var attempt Attempt + require.NoError(t, json.Unmarshal([]byte(`{"type":"attempt","body":{"event_id":"evt_1","attempt_id":"atm_1","webhook_id":"web_1","request":{"method":"POST"}}}`), &attempt)) + assert.Equal(t, "", attempt.Body.RequestID) + }) +} From 79e80121b374eb25f106aa3ad8d8eedcb8b15253 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 4 Nov 2026 09:47:12 +0000 Subject: [PATCH 2/4] feat(listen): label guest links "Hookdeck Inspect" and deprecate --console-base The TUI's [o] action and the events hint said "dashboard" for guest accounts, whose links now open Hookdeck Inspect. They now say "Hookdeck Inspect"; dashboard wording is unchanged. --console-base has no readers since guest links moved to Inspect. It is now deprecated in favour of --inspect-base, and still accepted so existing scripts don't fail on an unknown flag. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- pkg/cmd/root.go | 4 +++- pkg/listen/links/links.go | 18 ++++++++++++++++++ pkg/listen/links/links_test.go | 10 ++++++++++ pkg/listen/printer.go | 3 ++- pkg/listen/printer_test.go | 5 +++-- pkg/listen/tui/view.go | 10 +++++----- 7 files changed, 42 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 42e4653b..a0008fdf 100644 --- a/README.md +++ b/README.md @@ -1480,7 +1480,7 @@ 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. diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 2a28a0b3..966c9bbc 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -402,7 +402,9 @@ 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") diff --git a/pkg/listen/links/links.go b/pkg/listen/links/links.go index 873c6359..8d8fc26c 100644 --- a/pkg/listen/links/links.go +++ b/pkg/listen/links/links.go @@ -41,6 +41,24 @@ 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 diff --git a/pkg/listen/links/links_test.go b/pkg/listen/links/links_test.go index 9a51f002..4fb0cd40 100644 --- a/pkg/listen/links/links_test.go +++ b/pkg/listen/links/links_test.go @@ -46,6 +46,16 @@ func TestHome(t *testing.T) { }) } +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")) diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index a32ddfc3..0759d3fc 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -109,6 +109,7 @@ func printSourcesWithConnections(config *config.Config, projectID string, source sourceID := links.SessionSourceID(sources) url := target.Home(sourceID) displayURL := target.HomeDisplay(sourceID) - fmt.Printf("💡 Open dashboard to inspect, retry & bookmark events: %s\n", hyperlink(url, displayURL, os.Stdout)) + 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 426f27b7..b893ea60 100644 --- a/pkg/listen/printer_test.go +++ b/pkg/listen/printer_test.go @@ -198,7 +198,8 @@ func TestConsoleProjectLinksToInspect(t *testing.T) { printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") }) - assert.Contains(t, out, "https://inspect.hookdeck.com/abc123def456\n") + 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") }) @@ -213,6 +214,6 @@ func TestConsoleProjectLinksToInspect(t *testing.T) { printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") }) - assert.Contains(t, out, "events: https://inspect.hookdeck.com\n") + assert.Contains(t, out, "Hookdeck Inspect: https://inspect.hookdeck.com\n") }) } diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index c49b3c26..ce8fba0b 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,8 +322,8 @@ 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()) } } @@ -482,7 +482,7 @@ func (m Model) renderConnectionInfo() string { s.WriteString(m.cfg.GuestURL) } else { displayURL := dashboardHomeURL(m.cfg) - s.WriteString("💡 View dashboard to inspect, retry & bookmark events: ") + s.WriteString("💡 " + linkTarget(m.cfg).HomeHint("View dashboard to inspect, retry & bookmark events") + ": ") s.WriteString(displayURL) } s.WriteString("\n") From 9f46aa9a79a6cd2e83e64d042fb567fc253916cf Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Nov 2026 00:01:33 +0000 Subject: [PATCH 3/4] feat(listen): link guest events to Inspect by event_id Guest event links now keep Console's parameters on Inspect's host: https://inspect.hookdeck.com/?event_id=evt_...&team_id=tm_... . Inspect looks the event up and opens the request that created it on its Source page, for a visitor already in that project (hookdeck/core#6158). This replaces linking to the Source page and resolving the Source from the attempt's connection, so the optional request_id on AttemptBody is no longer needed and is removed. Log lines show team_id for guests, because Inspect needs both ids to open the event. Co-Authored-By: Claude Opus 5.5 --- pkg/listen/links/links.go | 89 ++++++++---------------- pkg/listen/links/links_test.go | 50 ++++--------- pkg/listen/proxy/renderer.go | 4 +- pkg/listen/proxy/renderer_interactive.go | 2 +- pkg/listen/proxy/renderer_links_test.go | 19 ++--- pkg/listen/proxy/renderer_simple.go | 2 +- pkg/listen/tui/links.go | 6 +- pkg/listen/tui/links_test.go | 16 +---- pkg/listen/tui/update.go | 2 +- pkg/websocket/attempt_messages.go | 13 ++-- pkg/websocket/attempt_messages_test.go | 26 ------- 11 files changed, 66 insertions(+), 163 deletions(-) delete mode 100644 pkg/websocket/attempt_messages_test.go diff --git a/pkg/listen/links/links.go b/pkg/listen/links/links.go index 8d8fc26c..3d4a8c50 100644 --- a/pkg/listen/links/links.go +++ b/pkg/listen/links/links.go @@ -11,7 +11,6 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) // Target describes where a listen session's links point. @@ -23,20 +22,12 @@ type Target struct { InspectBaseURL string // ProjectType is the profile's project type (config.ProjectType*). ProjectType string - // ProjectID scopes dashboard links with team_id. Inspect links ignore it: - // an Inspect page is addressed by its Source alone. + // 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 } -// Event identifies one event a link points at. SourceID and RequestID only -// matter for Inspect, which has no event page: an event is shown as the -// request that created it, on its Source's page. -type Event struct { - ID string - SourceID string - RequestID string -} - func (t Target) inspect() bool { return t.ProjectType == config.ProjectTypeConsole } @@ -65,7 +56,7 @@ func (t Target) HomeHint(dashboardHint string) string { // 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, "") + return inspectSource(t.InspectBaseURL, sourceID) } if t.ProjectID == "" { return t.DashboardBaseURL + "/events/cli" @@ -77,20 +68,20 @@ func (t Target) Home(sourceID string) string { // destination without the team_id query parameter. func (t Target) HomeDisplay(sourceID string) string { if t.inspect() { - return inspectSource(t.InspectBaseURL, sourceID, "") + return inspectSource(t.InspectBaseURL, sourceID) } return t.DashboardBaseURL + "/events/cli" } // 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 event's Source page, selecting its request when the request id is -// known, and the Inspect home page when the Source is unknown. -func (t Target) EventURL(event Event) string { +// 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 inspectSource(t.InspectBaseURL, event.SourceID, event.RequestID) + return inspectEvent(t.InspectBaseURL, eventID, t.ProjectID) } - link := t.DashboardBaseURL + "/events/" + event.ID + link := t.DashboardBaseURL + "/events/" + eventID if t.ProjectID != "" { link += "?team_id=" + t.ProjectID } @@ -98,28 +89,36 @@ func (t Target) EventURL(event Event) string { } // EventDisplay returns the display text for the EventURL link, as printed at -// the end of each event log line: the same destination without the team_id -// query parameter. -func (t Target) EventDisplay(event Event) string { +// 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(event) + return t.EventURL(eventID) } - return t.DashboardBaseURL + "/events/" + event.ID + return t.DashboardBaseURL + "/events/" + eventID } // inspectSource builds an Inspect Source page link. Inspect addresses a Source -// by its id without the "src_" prefix, and selects a request with ?request=. -// Without a Source it falls back to the Inspect home page. -func inspectSource(inspectBaseURL, sourceID, requestID string) string { +// 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 } - link := strings.TrimSuffix(inspectBaseURL, "/") + "/" + url.PathEscape(bare) - if requestID != "" { - link += "?request=" + url.QueryEscape(requestID) + 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 link + return strings.TrimSuffix(inspectBaseURL, "/") + "/?" + query.Encode() } // SessionSourceID returns the id of the one Source a session listens to, or @@ -130,31 +129,3 @@ func SessionSourceID(sources []*hookdeck.Source) string { } return sources[0].ID } - -// AttemptEvent returns the Event an attempt delivers: its event id, the -// Source of its connection (resolved from the connections the session -// loaded), and its request id when the server sent one. -func AttemptEvent(eventID string, attempt *websocket.Attempt, connections []*hookdeck.Connection) Event { - event := Event{ID: eventID} - if attempt == nil { - return event - } - event.SourceID = ConnectionSourceID(connections, attempt.Body.ConnectionId) - event.RequestID = attempt.Body.RequestID - return event -} - -// ConnectionSourceID returns the id of the Source that connectionID belongs -// to, from the connections the session loaded, or "" when it is not among -// them. An attempt names its connection (webhook_id) but not its Source. -func ConnectionSourceID(connections []*hookdeck.Connection, connectionID string) string { - if connectionID == "" { - return "" - } - for _, connection := range connections { - if connection != nil && connection.ID == connectionID && connection.Source != nil { - return connection.Source.ID - } - } - return "" -} diff --git a/pkg/listen/links/links_test.go b/pkg/listen/links/links_test.go index 4fb0cd40..ece0012a 100644 --- a/pkg/listen/links/links_test.go +++ b/pkg/listen/links/links_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) const ( @@ -71,38 +70,36 @@ func TestHomeDisplay(t *testing.T) { } func TestEventURL(t *testing.T) { - event := Event{ID: "evt_1", SourceID: "src_abc123def456", RequestID: "req_1"} - t.Run("dashboard with project id", func(t *testing.T) { - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", dashboardTarget("tm_123").EventURL(event)) + 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", dashboardTarget("").EventURL(event)) + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1", dashboardTarget("").EventURL("evt_1")) }) - t.Run("inspect with source and request selects the request", func(t *testing.T) { - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456?request=req_1", inspectTarget("tm_123").EventURL(event)) + 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("inspect without request id links to the source page", func(t *testing.T) { - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", inspectTarget("tm_123").EventURL(Event{ID: "evt_1", SourceID: "src_abc123def456"})) + 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("inspect with unknown source falls back to inspect home", func(t *testing.T) { - assert.Equal(t, "https://inspect.hookdeck.com", inspectTarget("tm_123").EventURL(Event{ID: "evt_1", RequestID: "req_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) { - event := Event{ID: "evt_1", SourceID: "src_abc123def456", RequestID: "req_1"} - 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(event)) + 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", func(t *testing.T) { - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456?request=req_1", inspectTarget("tm_123").EventDisplay(event)) + 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")) }) } @@ -114,24 +111,3 @@ func TestSessionSourceID(t *testing.T) { assert.Equal(t, "", SessionSourceID([]*hookdeck.Source{one, two}), "several sources have no single page") assert.Equal(t, "", SessionSourceID(nil)) } - -func TestAttemptEvent(t *testing.T) { - connections := []*hookdeck.Connection{ - {ID: "web_a", Source: &hookdeck.Source{ID: "src_a"}}, - {ID: "web_b", Source: &hookdeck.Source{ID: "src_b"}}, - } - - t.Run("resolves the source from webhook_id and carries request_id", func(t *testing.T) { - attempt := &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_b", RequestID: "req_1"}} - assert.Equal(t, Event{ID: "evt_1", SourceID: "src_b", RequestID: "req_1"}, AttemptEvent("evt_1", attempt, connections)) - }) - - t.Run("unknown connection leaves the source empty", func(t *testing.T) { - attempt := &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_zzz"}} - assert.Equal(t, Event{ID: "evt_1"}, AttemptEvent("evt_1", attempt, connections)) - }) - - t.Run("nil attempt", func(t *testing.T) { - assert.Equal(t, Event{ID: "evt_1"}, AttemptEvent("evt_1", nil, connections)) - }) -} diff --git a/pkg/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go index d106fd62..59f9b551 100644 --- a/pkg/listen/proxy/renderer.go +++ b/pkg/listen/proxy/renderer.go @@ -87,8 +87,8 @@ func (c *RendererConfig) linkTarget() links.Target { } // eventLinkDisplay returns the link printed at the end of an event's log line. -func (c *RendererConfig) eventLinkDisplay(eventID string, attempt *websocket.Attempt) string { - return c.linkTarget().EventDisplay(links.AttemptEvent(eventID, attempt, c.Connections)) +func (c *RendererConfig) eventLinkDisplay(eventID string) string { + return c.linkTarget().EventDisplay(eventID) } // NewRenderer creates the appropriate renderer based on output mode diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index f09dca9d..aeb68fcd 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -144,7 +144,7 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket localTime := eventTime.Format(interactiveTimeLayout) color := ansi.Color(os.Stdout) - displayURL := r.cfg.eventLinkDisplay(eventID, attempt) + 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 index a33c4405..cf761d92 100644 --- a/pkg/listen/proxy/renderer_links_test.go +++ b/pkg/listen/proxy/renderer_links_test.go @@ -22,25 +22,22 @@ func linksRendererConfig(t *testing.T, projectType string) *RendererConfig { target, err := url.Parse("http://localhost:3000") require.NoError(t, err) - source := &hookdeck.Source{ID: "src_abc123def456"} return &RendererConfig{ DashboardBaseURL: "https://dashboard.hookdeck.com", InspectBaseURL: "https://inspect.hookdeck.com", ProjectType: projectType, ProjectID: "tm_123", TargetURL: target, - Sources: []*hookdeck.Source{source}, - Connections: []*hookdeck.Connection{{ID: "web_1", Source: source}}, + Sources: []*hookdeck.Source{{ID: "src_abc123def456"}}, } } -func linksTestAttempt(requestID string) *websocket.Attempt { +func linksTestAttempt() *websocket.Attempt { return &websocket.Attempt{Body: websocket.AttemptBody{ Path: "/webhooks", EventID: "evt_1", AttemptId: "atm_1", ConnectionId: "web_1", - RequestID: requestID, Request: websocket.AttemptRequest{Method: "POST"}, }} } @@ -51,17 +48,15 @@ func TestSimpleRendererEventLink(t *testing.T) { for _, tc := range []struct { name string projectType string - requestID string want string }{ - {"dashboard is unchanged", "gateway", "req_1", "→ https://dashboard.hookdeck.com/events/evt_1\n"}, - {"guest links to the Inspect source page and request", "console", "req_1", "→ https://inspect.hookdeck.com/abc123def456?request=req_1\n"}, - {"guest without request_id links to the source page", "console", "", "→ https://inspect.hookdeck.com/abc123def456\n"}, + {"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(tc.requestID), response, time.Now()) + r.OnEventComplete("evt_1", linksTestAttempt(), response, time.Now()) }) assert.Contains(t, out, tc.want) assert.NotContains(t, out, "console.hookdeck.com") @@ -78,13 +73,13 @@ func TestInteractiveRendererEventLink(t *testing.T) { want string }{ {"dashboard is unchanged", "gateway", "https://dashboard.hookdeck.com/events/evt_1"}, - {"guest links to the Inspect source page and request", "console", "https://inspect.hookdeck.com/abc123def456?request=req_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("req_1"), response, time.Now()) + r.OnEventComplete("evt_1", linksTestAttempt(), response, time.Now()) require.Len(t, *sent, 1) update, ok := (*sent)[0].(tui.UpdateEventMsg) diff --git a/pkg/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go index ac32918f..db6bb52a 100644 --- a/pkg/listen/proxy/renderer_simple.go +++ b/pkg/listen/proxy/renderer_simple.go @@ -141,7 +141,7 @@ func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Atte localTime := time.Now().Format(simpleTimeLayout) color := ansi.Color(os.Stdout) - displayURL := r.cfg.eventLinkDisplay(eventID, attempt) + displayURL := r.cfg.eventLinkDisplay(eventID) durationMs := response.Duration.Milliseconds() diff --git a/pkg/listen/tui/links.go b/pkg/listen/tui/links.go index bc7f8692..087586ed 100644 --- a/pkg/listen/tui/links.go +++ b/pkg/listen/tui/links.go @@ -21,7 +21,7 @@ func dashboardHomeURL(cfg *Config) string { } // eventDashboardURL returns the deep-link for a single event: the dashboard -// event page, or for guest accounts the event's Source page in Inspect. -func eventDashboardURL(cfg *Config, event EventInfo) string { - return linkTarget(cfg).EventURL(links.AttemptEvent(event.ID, event.Data, cfg.Connections)) +// event page, or for guest accounts the Inspect link that opens the event. +func eventDashboardURL(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 6c6cb3b2..0a237e13 100644 --- a/pkg/listen/tui/links_test.go +++ b/pkg/listen/tui/links_test.go @@ -6,20 +6,17 @@ import ( "github.com/stretchr/testify/assert" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) // Construction rules are covered in pkg/listen/links; these tests only guard // the wiring from Config fields to the shared helpers. func linksTestConfig() *Config { - source := &hookdeck.Source{ID: "src_abc123def456"} return &Config{ DashboardBaseURL: "https://dashboard.hookdeck.com", InspectBaseURL: "https://inspect.hookdeck.com", ProjectID: "tm_123", - Sources: []*hookdeck.Source{source}, - Connections: []*hookdeck.Connection{{ID: "web_1", Source: source}}, + Sources: []*hookdeck.Source{{ID: "src_abc123def456"}}, } } @@ -36,15 +33,8 @@ func TestDashboardHomeURLUsesConfigFields(t *testing.T) { func TestEventDashboardURLUsesConfigFields(t *testing.T) { cfg := linksTestConfig() - event := EventInfo{ - ID: "evt_1", - Data: &websocket.Attempt{Body: websocket.AttemptBody{EventID: "evt_1", ConnectionId: "web_1", RequestID: "req_1"}}, - } - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, event)) + assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, "evt_1")) cfg.ProjectType = "console" - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456?request=req_1", eventDashboardURL(cfg, event)) - - event.Data.Body.RequestID = "" - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", eventDashboardURL(cfg, event)) + assert.Equal(t, "https://inspect.hookdeck.com/?event_id=evt_1&team_id=tm_123", eventDashboardURL(cfg, "evt_1")) } diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index 7223df87..99506d77 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -296,7 +296,7 @@ func (m Model) openSelectedEventInBrowser() tea.Cmd { } return func() tea.Msg { - eventURL := eventDashboardURL(m.cfg, *selectedEvent) + eventURL := eventDashboardURL(m.cfg, selectedEvent.ID) // Open in browser err := openBrowser(eventURL) diff --git a/pkg/websocket/attempt_messages.go b/pkg/websocket/attempt_messages.go index e02c1043..c13f50ca 100644 --- a/pkg/websocket/attempt_messages.go +++ b/pkg/websocket/attempt_messages.go @@ -43,14 +43,11 @@ func (r AttemptRequest) Body() ([]byte, error) { } type AttemptBody struct { - Path string `json:"cli_path"` - EventID string `json:"event_id"` - AttemptId string `json:"attempt_id"` - ConnectionId string `json:"webhook_id"` - // RequestID is the request that created the event. It is optional: servers - // that predate it do not send it, and listen links then omit the request. - RequestID string `json:"request_id,omitempty"` - Request AttemptRequest `json:"request"` + Path string `json:"cli_path"` + EventID string `json:"event_id"` + AttemptId string `json:"attempt_id"` + ConnectionId string `json:"webhook_id"` + Request AttemptRequest `json:"request"` } type Attempt struct { diff --git a/pkg/websocket/attempt_messages_test.go b/pkg/websocket/attempt_messages_test.go deleted file mode 100644 index 926d9876..00000000 --- a/pkg/websocket/attempt_messages_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package websocket - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// request_id is optional on the wire: servers that do not send it must still -// decode, and listen links fall back to the Source page without it. -func TestAttemptBodyRequestID(t *testing.T) { - t.Run("decoded when the server sends it", func(t *testing.T) { - var attempt Attempt - require.NoError(t, json.Unmarshal([]byte(`{"type":"attempt","body":{"event_id":"evt_1","attempt_id":"atm_1","webhook_id":"web_1","request_id":"req_1","request":{"method":"POST"}}}`), &attempt)) - assert.Equal(t, "req_1", attempt.Body.RequestID) - assert.Equal(t, "web_1", attempt.Body.ConnectionId) - }) - - t.Run("empty when the server does not send it", func(t *testing.T) { - var attempt Attempt - require.NoError(t, json.Unmarshal([]byte(`{"type":"attempt","body":{"event_id":"evt_1","attempt_id":"atm_1","webhook_id":"web_1","request":{"method":"POST"}}}`), &attempt)) - assert.Equal(t, "", attempt.Body.RequestID) - }) -} From 48c27abb5b0051be52bf647d0715acef31c4a1c4 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Nov 2026 00:32:15 +0000 Subject: [PATCH 4/4] chore(listen): drop dashboard-only names from the shared link helpers Renames the TUI's dashboardHomeURL/eventDashboardURL to homeURL/eventURL, since they return Inspect links for guest accounts, updates the comments that still said "dashboard", and removes an unused Sources field from the renderer link tests. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- pkg/listen/printer.go | 2 +- pkg/listen/proxy/renderer_links_test.go | 8 +++----- pkg/listen/tui/links.go | 8 ++++---- pkg/listen/tui/links_test.go | 10 +++++----- pkg/listen/tui/update.go | 7 ++++--- pkg/listen/tui/view.go | 2 +- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 0759d3fc..8bd9fbfa 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -95,7 +95,7 @@ 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) diff --git a/pkg/listen/proxy/renderer_links_test.go b/pkg/listen/proxy/renderer_links_test.go index cf761d92..ac0e9539 100644 --- a/pkg/listen/proxy/renderer_links_test.go +++ b/pkg/listen/proxy/renderer_links_test.go @@ -8,14 +8,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "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, so guest links stayed -// on Console after pkg/listen/links moved. These tests pin the log line each -// renderer prints to the shared builder's output. +// 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() @@ -28,7 +27,6 @@ func linksRendererConfig(t *testing.T, projectType string) *RendererConfig { ProjectType: projectType, ProjectID: "tm_123", TargetURL: target, - Sources: []*hookdeck.Source{{ID: "src_abc123def456"}}, } } diff --git a/pkg/listen/tui/links.go b/pkg/listen/tui/links.go index 087586ed..1aacf4ac 100644 --- a/pkg/listen/tui/links.go +++ b/pkg/listen/tui/links.go @@ -13,15 +13,15 @@ func linkTarget(cfg *Config) links.Target { } } -// dashboardHomeURL returns the link to the session's events: the dashboard +// 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 dashboardHomeURL(cfg *Config) string { +func homeURL(cfg *Config) string { return linkTarget(cfg).Home(links.SessionSourceID(cfg.Sources)) } -// eventDashboardURL returns the deep-link for a single event: the dashboard +// 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 eventDashboardURL(cfg *Config, eventID string) string { +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 0a237e13..f6ae6143 100644 --- a/pkg/listen/tui/links_test.go +++ b/pkg/listen/tui/links_test.go @@ -22,19 +22,19 @@ func linksTestConfig() *Config { func TestDashboardHomeURLUsesConfigFields(t *testing.T) { cfg := linksTestConfig() - assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", dashboardHomeURL(cfg)) + assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", homeURL(cfg)) cfg.ProjectType = "console" - assert.Equal(t, "https://inspect.hookdeck.com/abc123def456", 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", dashboardHomeURL(cfg)) + assert.Equal(t, "https://inspect.hookdeck.com", homeURL(cfg)) } func TestEventDashboardURLUsesConfigFields(t *testing.T) { cfg := linksTestConfig() - assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, "evt_1")) + 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://inspect.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/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 ce8fba0b..61a705b5 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -481,7 +481,7 @@ 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) + displayURL := homeURL(m.cfg) s.WriteString("💡 " + linkTarget(m.cfg).HomeHint("View dashboard to inspect, retry & bookmark events") + ": ") s.WriteString(displayURL) }