diff --git a/README.md b/README.md index 0331d67..6ed1784 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,9 @@ OpsOrch uses structured expressions for querying logs and metrics, replacing fre } ``` +### Provider Deep Links +Normalized resources now carry an optional `url` deep link back to the upstream system. Adapters should populate this string whenever the provider exposes a canonical UI link so OpsOrch clients can jump directly to the source incident, alert, log view, metric chart, ticket, team, service, or message. The field is passthrough only—OpsOrch does not generate, log, or modify these URLs—so adapters remain responsible for ensuring they do not leak secrets. + ### Adapter Architecture OpsOrch Core contains **no provider logic**. Adapters implement capability interfaces in their own repos and register with the registry. diff --git a/api/server_test.go b/api/server_test.go index 60e2835..e4c736e 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -21,11 +21,22 @@ import ( "github.com/opsorch/opsorch-core/service" ) +const ( + incidentBaseURL = "https://incidents.test/" + alertBaseURL = "https://alerts.test/" + logEntryURL = "https://logs.test/stream" + metricSeriesURL = "https://metrics.test/series/cpu" + metricDescriptorURL = "https://metrics.test/describe/cpu" + ticketBaseURL = "https://tickets.test/" + messagingResultURL = "https://messages.test/m1" + serviceURL = "https://services.test/svc1" +) + // stubIncidentProvider implements incident.Provider for tests. type stubIncidentProvider struct{} func (s stubIncidentProvider) Query(ctx context.Context, query schema.IncidentQuery) ([]schema.Incident, error) { - res := []schema.Incident{{ID: "1", Title: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}} + res := []schema.Incident{{ID: "1", Title: "test", URL: incidentBaseURL + "1", CreatedAt: time.Now(), UpdatedAt: time.Now()}} if query.Limit > 0 && query.Limit < len(res) { return res[:query.Limit], nil } @@ -33,15 +44,15 @@ func (s stubIncidentProvider) Query(ctx context.Context, query schema.IncidentQu } func (s stubIncidentProvider) Get(ctx context.Context, id string) (schema.Incident, error) { - return schema.Incident{ID: id, Title: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil + return schema.Incident{ID: id, Title: "test", URL: incidentBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil } func (s stubIncidentProvider) Create(ctx context.Context, in schema.CreateIncidentInput) (schema.Incident, error) { - return schema.Incident{ID: "new", Title: in.Title, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil + return schema.Incident{ID: "new", Title: in.Title, URL: incidentBaseURL + "new", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil } func (s stubIncidentProvider) Update(ctx context.Context, id string, in schema.UpdateIncidentInput) (schema.Incident, error) { - return schema.Incident{ID: id, Title: derefString(in.Title, "test"), CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil + return schema.Incident{ID: id, Title: derefString(in.Title, "test"), URL: incidentBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil } func (s stubIncidentProvider) GetTimeline(ctx context.Context, id string) ([]schema.TimelineEntry, error) { @@ -62,7 +73,7 @@ func derefString(s *string, fallback string) string { type stubAlertProvider struct{} func (s stubAlertProvider) Query(ctx context.Context, query schema.AlertQuery) ([]schema.Alert, error) { - res := []schema.Alert{{ID: "a1", Title: "test alert", Status: "firing", Severity: "critical", CreatedAt: time.Now(), UpdatedAt: time.Now()}} + res := []schema.Alert{{ID: "a1", Title: "test alert", Status: "firing", Severity: "critical", URL: alertBaseURL + "a1", CreatedAt: time.Now(), UpdatedAt: time.Now()}} if query.Limit > 0 && query.Limit < len(res) { return res[:query.Limit], nil } @@ -70,13 +81,13 @@ func (s stubAlertProvider) Query(ctx context.Context, query schema.AlertQuery) ( } func (s stubAlertProvider) Get(ctx context.Context, id string) (schema.Alert, error) { - return schema.Alert{ID: id, Title: "test alert", Status: "firing", Severity: "critical", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil + return schema.Alert{ID: id, Title: "test alert", Status: "firing", Severity: "critical", URL: alertBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil } type stubLogProvider struct{} func (s stubLogProvider) Query(ctx context.Context, q schema.LogQuery) ([]schema.LogEntry, error) { - return []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry"}}, nil + return []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry", URL: logEntryURL}}, nil } type stubMetricProvider struct{} @@ -85,6 +96,7 @@ func (s stubMetricProvider) Query(ctx context.Context, q schema.MetricQuery) ([] return []schema.MetricSeries{{ Name: "cpu", Points: []schema.MetricPoint{{Timestamp: time.Now(), Value: 1.0}}, + URL: metricSeriesURL, }}, nil } @@ -94,6 +106,7 @@ func (s stubMetricProvider) Describe(ctx context.Context, scope schema.QueryScop Type: "gauge", Description: "CPU usage", Labels: []string{"host"}, + URL: metricDescriptorURL, }}, nil } @@ -101,36 +114,36 @@ type stubTicketProvider struct{} func (s stubTicketProvider) Query(ctx context.Context, query schema.TicketQuery) ([]schema.Ticket, error) { now := time.Now() - return []schema.Ticket{{ID: "t1", Title: "ticket", Status: "open", CreatedAt: now, UpdatedAt: now}}, nil + return []schema.Ticket{{ID: "t1", Title: "ticket", Status: "open", URL: ticketBaseURL + "t1", CreatedAt: now, UpdatedAt: now}}, nil } func (s stubTicketProvider) Get(ctx context.Context, id string) (schema.Ticket, error) { now := time.Now() - return schema.Ticket{ID: id, Title: "ticket", Status: "open", CreatedAt: now, UpdatedAt: now}, nil + return schema.Ticket{ID: id, Title: "ticket", Status: "open", URL: ticketBaseURL + id, CreatedAt: now, UpdatedAt: now}, nil } func (s stubTicketProvider) Create(ctx context.Context, in schema.CreateTicketInput) (schema.Ticket, error) { now := time.Now() - return schema.Ticket{ID: "new", Title: in.Title, Status: "open", CreatedAt: now, UpdatedAt: now}, nil + return schema.Ticket{ID: "new", Title: in.Title, Status: "open", URL: ticketBaseURL + "new", CreatedAt: now, UpdatedAt: now}, nil } func (s stubTicketProvider) Update(ctx context.Context, id string, in schema.UpdateTicketInput) (schema.Ticket, error) { now := time.Now() title := derefString(in.Title, "ticket") status := derefString(in.Status, "open") - return schema.Ticket{ID: id, Title: title, Status: status, CreatedAt: now, UpdatedAt: now}, nil + return schema.Ticket{ID: id, Title: title, Status: status, URL: ticketBaseURL + id, CreatedAt: now, UpdatedAt: now}, nil } type stubMessagingProvider struct{} func (s stubMessagingProvider) Send(ctx context.Context, msg schema.Message) (schema.MessageResult, error) { - return schema.MessageResult{ID: "m1", Channel: msg.Channel, Metadata: msg.Metadata, SentAt: time.Now()}, nil + return schema.MessageResult{ID: "m1", Channel: msg.Channel, URL: messagingResultURL, Metadata: msg.Metadata, SentAt: time.Now()}, nil } type stubServiceProvider struct{} func (s stubServiceProvider) Query(ctx context.Context, q schema.ServiceQuery) ([]schema.Service, error) { - return []schema.Service{{ID: "svc1", Name: "Service 1"}}, nil + return []schema.Service{{ID: "svc1", Name: "Service 1", URL: serviceURL}}, nil } type memorySecret struct { @@ -282,6 +295,9 @@ func TestIncidentQuery(t *testing.T) { if len(out) != 1 || out[0].ID != "1" { t.Fatalf("unexpected incident response: %+v", out) } + if out[0].URL != incidentBaseURL+"1" { + t.Fatalf("expected incident url %s1, got %s", incidentBaseURL, out[0].URL) + } } func TestIncidentQueryViaPlugin(t *testing.T) { @@ -437,6 +453,9 @@ func TestLogQuery(t *testing.T) { if len(out) != 1 || out[0].Message != "log-entry" { t.Fatalf("unexpected log response: %+v", out) } + if out[0].URL != logEntryURL { + t.Fatalf("expected log url %s, got %s", logEntryURL, out[0].URL) + } } func TestMetricQuery(t *testing.T) { @@ -457,6 +476,9 @@ func TestMetricQuery(t *testing.T) { if len(out) != 1 || out[0].Name != "cpu" { t.Fatalf("unexpected metric response: %+v", out) } + if out[0].URL != metricSeriesURL { + t.Fatalf("expected metric url %s, got %s", metricSeriesURL, out[0].URL) + } } func TestMetricDescribe(t *testing.T) { @@ -476,6 +498,9 @@ func TestMetricDescribe(t *testing.T) { if len(out["metrics"]) != 1 || out["metrics"][0].Name != "cpu" { t.Fatalf("unexpected metric describe response: %+v", out) } + if out["metrics"][0].URL != metricDescriptorURL { + t.Fatalf("expected metric descriptor url %s, got %s", metricDescriptorURL, out["metrics"][0].URL) + } } func TestServiceQuery(t *testing.T) { @@ -488,6 +513,13 @@ func TestServiceQuery(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } + var services []schema.Service + if err := json.NewDecoder(w.Body).Decode(&services); err != nil { + t.Fatalf("decode services: %v", err) + } + if len(services) != 1 || services[0].URL != serviceURL { + t.Fatalf("unexpected service response: %+v", services) + } } func TestTicketCreateAndGet(t *testing.T) { @@ -507,6 +539,9 @@ func TestTicketCreateAndGet(t *testing.T) { if len(tickets) != 1 || tickets[0].ID != "t1" { t.Fatalf("unexpected ticket list: %+v", tickets) } + if tickets[0].URL != ticketBaseURL+"t1" { + t.Fatalf("expected ticket url %st1, got %s", ticketBaseURL, tickets[0].URL) + } body, _ := json.Marshal(schema.CreateTicketInput{Title: "t"}) req := httptest.NewRequest(http.MethodPost, "/tickets", bytes.NewReader(body)) @@ -515,6 +550,13 @@ func TestTicketCreateAndGet(t *testing.T) { if w.Code != http.StatusCreated { t.Fatalf("create expected 201, got %d", w.Code) } + var created schema.Ticket + if err := json.NewDecoder(w.Body).Decode(&created); err != nil { + t.Fatalf("decode created ticket: %v", err) + } + if created.URL != ticketBaseURL+"new" { + t.Fatalf("expected created ticket url %snew, got %s", ticketBaseURL, created.URL) + } req2 := httptest.NewRequest(http.MethodGet, "/tickets/abc", nil) w2 := httptest.NewRecorder() @@ -529,6 +571,9 @@ func TestTicketCreateAndGet(t *testing.T) { if tkt.ID != "abc" { t.Fatalf("unexpected ticket id: %s", tkt.ID) } + if tkt.URL != ticketBaseURL+"abc" { + t.Fatalf("expected ticket url %sabc, got %s", ticketBaseURL, tkt.URL) + } } func TestMessagingSend(t *testing.T) { @@ -547,6 +592,9 @@ func TestMessagingSend(t *testing.T) { if res.ID != "m1" || res.Channel != "c" { t.Fatalf("unexpected messaging response: %+v", res) } + if res.URL != messagingResultURL { + t.Fatalf("expected messaging url %s, got %s", messagingResultURL, res.URL) + } } func TestMissingProvidersReturn501(t *testing.T) { @@ -656,6 +704,9 @@ func TestAlertQuery(t *testing.T) { if len(out) != 1 || out[0].ID != "a1" { t.Fatalf("unexpected alert response: %+v", out) } + if out[0].URL != alertBaseURL+"a1" { + t.Fatalf("expected alert url %sa1, got %s", alertBaseURL, out[0].URL) + } } func TestAlertGet(t *testing.T) { @@ -676,6 +727,9 @@ func TestAlertGet(t *testing.T) { if out.ID != "abc" { t.Fatalf("unexpected alert ID: %s", out.ID) } + if out.URL != alertBaseURL+"abc" { + t.Fatalf("expected alert url %sabc, got %s", alertBaseURL, out.URL) + } } func TestAlertMissingProvider(t *testing.T) { diff --git a/api/team_handler_test.go b/api/team_handler_test.go index 7c279b6..512879a 100644 --- a/api/team_handler_test.go +++ b/api/team_handler_test.go @@ -189,6 +189,7 @@ func TestTeamProperty_TeamQueryBodyProcessing(t *testing.T) { ID: "team-1", Name: query.Name, Tags: query.Tags, + URL: fmt.Sprintf("https://teams.test/%s", query.Name), }, } return teams, nil @@ -282,6 +283,10 @@ func TestTeamProperty_TeamQueryBodyProcessing(t *testing.T) { if tc.query.Name != "" && teams[0].Name != tc.query.Name { t.Errorf("expected name %s, got %s", tc.query.Name, teams[0].Name) } + expectedURL := fmt.Sprintf("https://teams.test/%s", tc.query.Name) + if teams[0].URL != expectedURL { + t.Errorf("expected team url %s, got %s", expectedURL, teams[0].URL) + } }) } } @@ -301,6 +306,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) { ID: "team-123", Name: "Backend Team", Parent: "engineering", + URL: "https://teams.test/team-123", Tags: map[string]string{"department": "engineering"}, }, expectError: false, @@ -311,6 +317,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) { mockTeam: schema.Team{ ID: "team-abc-123_def", Name: "Platform Team", + URL: "https://teams.test/team-abc-123_def", }, expectError: false, }, @@ -320,6 +327,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) { mockTeam: schema.Team{ ID: "very-long-team-id-with-many-characters-12345678901234567890", Name: "Data Science Team", + URL: "https://teams.test/very-long-team-id-with-many-characters-12345678901234567890", }, expectError: false, }, @@ -370,6 +378,9 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) { if team.Parent != tc.mockTeam.Parent { t.Errorf("expected parent %s, got %s", tc.mockTeam.Parent, team.Parent) } + if team.URL != tc.mockTeam.URL { + t.Errorf("expected team url %s, got %s", tc.mockTeam.URL, team.URL) + } } }) } diff --git a/schema/alert.go b/schema/alert.go index f86fa20..cf56d3a 100644 --- a/schema/alert.go +++ b/schema/alert.go @@ -37,6 +37,7 @@ type Alert struct { Status string `json:"status"` Severity string `json:"severity"` Service string `json:"service,omitempty"` + URL string `json:"url,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/schema/incident.go b/schema/incident.go index 7db698d..7b440fb 100644 --- a/schema/incident.go +++ b/schema/incident.go @@ -33,6 +33,7 @@ type Incident struct { Status string `json:"status"` Severity string `json:"severity"` Service string `json:"service,omitempty"` + URL string `json:"url,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Fields map[string]any `json:"fields,omitempty"` diff --git a/schema/log.go b/schema/log.go index 0a386f2..9084722 100644 --- a/schema/log.go +++ b/schema/log.go @@ -48,6 +48,7 @@ type LogEntry struct { Message string `json:"message"` Severity string `json:"severity,omitempty"` Service string `json:"service,omitempty"` + URL string `json:"url,omitempty"` Labels map[string]string `json:"labels,omitempty"` // filterable Fields map[string]any `json:"fields,omitempty"` // structured JSON Metadata map[string]any `json:"metadata,omitempty"` // provider-specific diff --git a/schema/messaging.go b/schema/messaging.go index 9eeced9..230a9e3 100644 --- a/schema/messaging.go +++ b/schema/messaging.go @@ -32,5 +32,6 @@ type MessageResult struct { ID string `json:"id"` Channel string `json:"channel"` SentAt time.Time `json:"sentAt"` + URL string `json:"url,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } diff --git a/schema/metric.go b/schema/metric.go index 144d9bf..eed5126 100644 --- a/schema/metric.go +++ b/schema/metric.go @@ -50,6 +50,7 @@ type MetricDescriptor struct { Description string `json:"description"` // Human-readable description Labels []string `json:"labels"` // Available label keys Unit string `json:"unit,omitempty"` // "bytes", "seconds", "requests" + URL string `json:"url,omitempty"` // Upstream link to the metric (e.g. Prometheus expression browser) Metadata map[string]any `json:"metadata,omitempty"` // Provider-specific metadata } @@ -59,6 +60,7 @@ type MetricSeries struct { Service string `json:"service,omitempty"` Labels map[string]any `json:"labels,omitempty"` Points []MetricPoint `json:"points"` + URL string `json:"url,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } diff --git a/schema/service.go b/schema/service.go index e43ddf3..8a40d61 100644 --- a/schema/service.go +++ b/schema/service.go @@ -39,6 +39,9 @@ type Service struct { // Providers may map this from title, name, alias, or other upstream fields. Name string `json:"name"` + // URL is the deep link to the service in the provider's UI. + URL string `json:"url,omitempty"` + // Tags contains normalized key/value tags for filtering and correlation. // Providers flatten their native tag/label formats into string pairs. Tags map[string]string `json:"tags,omitempty"` diff --git a/schema/team.go b/schema/team.go index b370e22..0f3bcbf 100644 --- a/schema/team.go +++ b/schema/team.go @@ -34,6 +34,9 @@ type Team struct { // value if you need the parent details. Parent string `json:"parent,omitempty"` + // URL is the deep link to the team in the provider's UI. + URL string `json:"url,omitempty"` + // Tags contains normalized key/value tags for filtering and correlation. Tags map[string]string `json:"tags,omitempty"` diff --git a/schema/ticket.go b/schema/ticket.go index 610673e..784aba7 100644 --- a/schema/ticket.go +++ b/schema/ticket.go @@ -23,6 +23,7 @@ type Ticket struct { Status string `json:"status"` Assignees []string `json:"assignees,omitempty"` Reporter string `json:"reporter,omitempty"` + URL string `json:"url,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Fields map[string]any `json:"fields,omitempty"`