Skip to content

Commit 9fc9230

Browse files
committed
feat: add 24 new CLI commands for alert, oncall, insight, audit, and postmortem
New command groups: - alert list/get/events/timeline/merge (5 commands) - alert-event list (global alert event search) - oncall who, oncall schedule list/get (3 commands) - insight team/channel/responder/top-alerts/incidents/notifications (6 commands) - audit search (audit log search) - postmortem list (post-mortem reports) Extended existing commands: - incident merge/snooze/reopen/reassign/feed/detail (6 commands) - change trend (deployment frequency) Supporting changes: - Bump flashduty-sdk to v0.6.0 - Add FormatDuration/FormatDurationFloat helpers for MTTA/MTTR display - Extend time parser with future duration (+24h) and day notation (7d) - Add 24 mock methods to command_test.go
1 parent fd4d52b commit 9fc9230

16 files changed

Lines changed: 2034 additions & 5 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/flashduty-sdk v0.4.1
6+
github.com/flashcatcloud/flashduty-sdk v0.6.0
77
github.com/spf13/cobra v1.10.2
88
golang.org/x/term v0.42.0
99
gopkg.in/yaml.v3 v3.0.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2-
github.com/flashcatcloud/flashduty-sdk v0.4.1 h1:8KRd4ex0ffN/XxmNiweLDIuxWEWWthtsg9lEXd4e53M=
3-
github.com/flashcatcloud/flashduty-sdk v0.4.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
2+
github.com/flashcatcloud/flashduty-sdk v0.6.0 h1:2dzJJ5s7Wgq7KWbsnbGMSo0+yN8yuNMVc50M+dtF8rU=
3+
github.com/flashcatcloud/flashduty-sdk v0.6.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
44
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
55
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
66
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

internal/cli/alert.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
flashduty "github.com/flashcatcloud/flashduty-sdk"
9+
"github.com/spf13/cobra"
10+
11+
"github.com/flashcatcloud/flashduty-cli/internal/output"
12+
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
13+
)
14+
15+
func newAlertCmd() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "alert",
18+
Short: "Manage alerts",
19+
}
20+
cmd.AddCommand(newAlertListCmd())
21+
cmd.AddCommand(newAlertGetCmd())
22+
cmd.AddCommand(newAlertEventsCmd())
23+
cmd.AddCommand(newAlertTimelineCmd())
24+
cmd.AddCommand(newAlertMergeCmd())
25+
return cmd
26+
}
27+
28+
func newAlertListCmd() *cobra.Command {
29+
var severity, channel, title, since, until string
30+
var active, recovered, muted bool
31+
var limit, page int
32+
33+
cmd := &cobra.Command{
34+
Use: "list",
35+
Short: "List alerts",
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
return runCommand(cmd, args, func(ctx *RunContext) error {
38+
if active && recovered {
39+
return fmt.Errorf("--active and --recovered are mutually exclusive")
40+
}
41+
42+
startTime, err := timeutil.Parse(since)
43+
if err != nil {
44+
return fmt.Errorf("invalid --since: %w", err)
45+
}
46+
endTime, err := timeutil.Parse(until)
47+
if err != nil {
48+
return fmt.Errorf("invalid --until: %w", err)
49+
}
50+
51+
input := &flashduty.ListAlertsInput{
52+
StartTime: startTime,
53+
EndTime: endTime,
54+
AlertSeverity: severity,
55+
Title: title,
56+
Limit: limit,
57+
Page: page,
58+
}
59+
60+
if active {
61+
input.IsActive = boolPtr(true)
62+
} else if recovered {
63+
input.IsActive = boolPtr(false)
64+
}
65+
66+
if muted {
67+
input.EverMuted = boolPtr(true)
68+
}
69+
70+
if channel != "" {
71+
channelIDs, err := parseIntSlice(channel)
72+
if err != nil {
73+
return fmt.Errorf("invalid --channel: %w", err)
74+
}
75+
input.ChannelIDs = channelIDs
76+
}
77+
78+
result, err := ctx.Client.ListAlerts(cmdContext(ctx.Cmd), input)
79+
if err != nil {
80+
return err
81+
}
82+
83+
cols := []output.Column{
84+
{Header: "ID", Field: func(v any) string { return v.(flashduty.Alert).AlertID }},
85+
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.Alert).Title }},
86+
{Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.Alert).AlertSeverity }},
87+
{Header: "STATUS", Field: func(v any) string { return v.(flashduty.Alert).AlertStatus }},
88+
{Header: "EVENTS", Field: func(v any) string { return fmt.Sprintf("%d", v.(flashduty.Alert).EventCnt) }},
89+
{Header: "CHANNEL", Field: func(v any) string { return v.(flashduty.Alert).ChannelName }},
90+
{Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.Alert).StartTime) }},
91+
}
92+
93+
return ctx.PrintList(result.Alerts, cols, len(result.Alerts), page, result.Total)
94+
})
95+
},
96+
}
97+
98+
cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info")
99+
cmd.Flags().BoolVar(&active, "active", false, "Show active only")
100+
cmd.Flags().BoolVar(&recovered, "recovered", false, "Show recovered only")
101+
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
102+
cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only")
103+
cmd.Flags().StringVar(&title, "title", "", "Search by title keyword")
104+
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
105+
cmd.Flags().StringVar(&until, "until", "now", "End time")
106+
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
107+
cmd.Flags().IntVar(&page, "page", 1, "Page number")
108+
109+
return cmd
110+
}
111+
112+
func newAlertGetCmd() *cobra.Command {
113+
return &cobra.Command{
114+
Use: "get <alert_id>",
115+
Short: "Get alert detail",
116+
Args: requireArgs("alert_id"),
117+
RunE: func(cmd *cobra.Command, args []string) error {
118+
return runCommand(cmd, args, func(ctx *RunContext) error {
119+
result, err := ctx.Client.GetAlertDetail(cmdContext(ctx.Cmd), &flashduty.GetAlertDetailInput{
120+
AlertID: ctx.Args[0],
121+
})
122+
if err != nil {
123+
return err
124+
}
125+
126+
if ctx.JSON {
127+
return ctx.Printer.Print(result.Alert, nil)
128+
}
129+
130+
printAlertDetail(ctx.Writer, result.Alert)
131+
return nil
132+
})
133+
},
134+
}
135+
}
136+
137+
func printAlertDetail(w io.Writer, a flashduty.Alert) {
138+
labels := make([]string, 0, len(a.Labels))
139+
for k, v := range a.Labels {
140+
labels = append(labels, k+"="+v)
141+
}
142+
143+
incidentInfo := "-"
144+
if a.Incident != nil {
145+
incidentInfo = fmt.Sprintf("%s (%s)", a.Incident.IncidentID, a.Incident.Progress)
146+
}
147+
148+
mutedStr := "No"
149+
if a.EverMuted {
150+
mutedStr = "Yes"
151+
}
152+
153+
_, _ = fmt.Fprintf(w, "ID: %s\n", a.AlertID)
154+
_, _ = fmt.Fprintf(w, "Title: %s\n", a.Title)
155+
_, _ = fmt.Fprintf(w, "Severity: %s\n", a.AlertSeverity)
156+
_, _ = fmt.Fprintf(w, "Status: %s\n", a.AlertStatus)
157+
_, _ = fmt.Fprintf(w, "Alert Key: %s\n", orDash(a.AlertKey))
158+
_, _ = fmt.Fprintf(w, "Channel: %s\n", a.ChannelName)
159+
_, _ = fmt.Fprintf(w, "Integration: %s (%s)\n", a.IntegrationName, a.IntegrationType)
160+
_, _ = fmt.Fprintf(w, "Events: %d\n", a.EventCnt)
161+
_, _ = fmt.Fprintf(w, "Started: %s\n", output.FormatTime(a.StartTime))
162+
_, _ = fmt.Fprintf(w, "Last Event: %s\n", output.FormatTime(a.LastTime))
163+
_, _ = fmt.Fprintf(w, "Recovered: %s\n", output.FormatTime(a.EndTime))
164+
_, _ = fmt.Fprintf(w, "Muted: %s\n", mutedStr)
165+
_, _ = fmt.Fprintf(w, "Incident: %s\n", incidentInfo)
166+
_, _ = fmt.Fprintf(w, "Labels: %s\n", orDash(strings.Join(labels, ", ")))
167+
_, _ = fmt.Fprintf(w, "Description: %s\n", orDash(a.Description))
168+
}
169+
170+
func newAlertEventsCmd() *cobra.Command {
171+
return &cobra.Command{
172+
Use: "events <alert_id>",
173+
Short: "List alert events",
174+
Args: requireArgs("alert_id"),
175+
RunE: func(cmd *cobra.Command, args []string) error {
176+
return runCommand(cmd, args, func(ctx *RunContext) error {
177+
result, err := ctx.Client.ListAlertEvents(cmdContext(ctx.Cmd), &flashduty.ListAlertEventsInput{
178+
AlertID: ctx.Args[0],
179+
})
180+
if err != nil {
181+
return err
182+
}
183+
184+
if len(result.AlertEvents) == 0 {
185+
ctx.WriteResult("No alert events found.")
186+
return nil
187+
}
188+
189+
cols := []output.Column{
190+
{Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).EventID }},
191+
{Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEvent).EventSeverity }},
192+
{Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEvent).EventStatus }},
193+
{Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEvent).EventTime) }},
194+
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEvent).Title }},
195+
}
196+
197+
return ctx.PrintTotal(result.AlertEvents, cols, len(result.AlertEvents))
198+
})
199+
},
200+
}
201+
}
202+
203+
func newAlertTimelineCmd() *cobra.Command {
204+
var limit, page int
205+
206+
cmd := &cobra.Command{
207+
Use: "timeline <alert_id>",
208+
Short: "View alert timeline",
209+
Args: requireArgs("alert_id"),
210+
RunE: func(cmd *cobra.Command, args []string) error {
211+
return runCommand(cmd, args, func(ctx *RunContext) error {
212+
result, err := ctx.Client.GetAlertFeed(cmdContext(ctx.Cmd), &flashduty.GetAlertFeedInput{
213+
AlertID: ctx.Args[0],
214+
Limit: limit,
215+
Page: page,
216+
})
217+
if err != nil {
218+
return err
219+
}
220+
221+
if len(result.Items) == 0 {
222+
ctx.WriteResult("No timeline events.")
223+
return nil
224+
}
225+
226+
cols := []output.Column{
227+
{Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.TimelineEvent).Timestamp) }},
228+
{Header: "TYPE", Field: func(v any) string { return v.(flashduty.TimelineEvent).Type }},
229+
{Header: "OPERATOR", Field: func(v any) string { return v.(flashduty.TimelineEvent).OperatorName }},
230+
{Header: "DETAIL", MaxWidth: 80, Field: func(v any) string {
231+
d := v.(flashduty.TimelineEvent).Detail
232+
if d == nil {
233+
return "-"
234+
}
235+
return fmt.Sprintf("%v", d)
236+
}},
237+
}
238+
239+
return ctx.Printer.Print(result.Items, cols)
240+
})
241+
},
242+
}
243+
244+
cmd.Flags().IntVar(&limit, "limit", 20, "Max events")
245+
cmd.Flags().IntVar(&page, "page", 1, "Page number")
246+
247+
return cmd
248+
}
249+
250+
func newAlertMergeCmd() *cobra.Command {
251+
var incidentID, comment string
252+
253+
cmd := &cobra.Command{
254+
Use: "merge <alert_id> [<alert_id2> ...]",
255+
Short: "Merge alerts into an incident",
256+
Args: requireArgs("alert_id"),
257+
RunE: func(cmd *cobra.Command, args []string) error {
258+
return runCommand(cmd, args, func(ctx *RunContext) error {
259+
if err := ctx.Client.MergeAlertsToIncident(cmdContext(ctx.Cmd), &flashduty.MergeAlertsInput{
260+
AlertIDs: ctx.Args,
261+
IncidentID: incidentID,
262+
Comment: comment,
263+
}); err != nil {
264+
return err
265+
}
266+
267+
ctx.WriteResult(fmt.Sprintf("Merged %d alert(s) into incident %s.", len(ctx.Args), incidentID))
268+
return nil
269+
})
270+
},
271+
}
272+
273+
cmd.Flags().StringVar(&incidentID, "incident", "", "Target incident ID")
274+
cmd.Flags().StringVar(&comment, "comment", "", "Merge comment")
275+
_ = cmd.MarkFlagRequired("incident")
276+
277+
return cmd
278+
}

internal/cli/alert_event.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
flashduty "github.com/flashcatcloud/flashduty-sdk"
7+
"github.com/spf13/cobra"
8+
9+
"github.com/flashcatcloud/flashduty-cli/internal/output"
10+
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
11+
)
12+
13+
func newAlertEventCmd() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "alert-event",
16+
Short: "Manage alert events",
17+
}
18+
cmd.AddCommand(newAlertEventListCmd())
19+
return cmd
20+
}
21+
22+
func newAlertEventListCmd() *cobra.Command {
23+
var severity, channel, integrationType, since, until string
24+
var limit, page int
25+
26+
cmd := &cobra.Command{
27+
Use: "list",
28+
Short: "List alert events globally",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
return runCommand(cmd, args, func(ctx *RunContext) error {
31+
startTime, err := timeutil.Parse(since)
32+
if err != nil {
33+
return fmt.Errorf("invalid --since: %w", err)
34+
}
35+
endTime, err := timeutil.Parse(until)
36+
if err != nil {
37+
return fmt.Errorf("invalid --until: %w", err)
38+
}
39+
40+
input := &flashduty.ListAlertEventsGlobalInput{
41+
StartTime: startTime,
42+
EndTime: endTime,
43+
Limit: limit,
44+
Page: page,
45+
}
46+
47+
if severity != "" {
48+
input.Severities = parseStringSlice(severity)
49+
}
50+
51+
if channel != "" {
52+
channelIDs, err := parseIntSlice(channel)
53+
if err != nil {
54+
return fmt.Errorf("invalid --channel: %w", err)
55+
}
56+
input.ChannelIDs = channelIDs
57+
}
58+
59+
if integrationType != "" {
60+
input.IntegrationTypes = parseStringSlice(integrationType)
61+
}
62+
63+
result, err := ctx.Client.ListAlertEventsGlobal(cmdContext(ctx.Cmd), input)
64+
if err != nil {
65+
return err
66+
}
67+
68+
cols := []output.Column{
69+
{Header: "EVENT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).EventID }},
70+
{Header: "ALERT_ID", Field: func(v any) string { return v.(flashduty.AlertEvent).AlertID }},
71+
{Header: "SEVERITY", Field: func(v any) string { return v.(flashduty.AlertEvent).EventSeverity }},
72+
{Header: "STATUS", Field: func(v any) string { return v.(flashduty.AlertEvent).EventStatus }},
73+
{Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertEvent).EventTime) }},
74+
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEvent).Title }},
75+
}
76+
77+
return ctx.PrintList(result.AlertEvents, cols, len(result.AlertEvents), page, result.Total)
78+
})
79+
},
80+
}
81+
82+
cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info (comma-separated)")
83+
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
84+
cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types")
85+
cmd.Flags().StringVar(&since, "since", "1h", "Start time")
86+
cmd.Flags().StringVar(&until, "until", "now", "End time")
87+
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
88+
cmd.Flags().IntVar(&page, "page", 1, "Page number")
89+
90+
return cmd
91+
}

0 commit comments

Comments
 (0)