Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions internal/cli/alert_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command {
}

func newAlertEventListCmd() *cobra.Command {
var severity, channel, integrationType, since, until string
var severity, channel, integrationType, since, until, fields string
var limit, page int

cmd := &cobra.Command{
Use: "list",
Short: "List alert events globally",
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"),
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type. In json/toon mode, output defaults to compact event fields; use --fields to choose a different projection.", "Alerts", "EventReadList"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 {
return fmt.Errorf("--fields must name at least one field")
}

startTime, err := timeutil.Parse(since)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
Expand Down Expand Up @@ -77,6 +81,21 @@ func newAlertEventListCmd() *cobra.Command {
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }},
}

if ctx.Structured() {
fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
if fields != "" {
fieldNames = parseStringSlice(fields)
}
proj, err := projectFields(result.Items, fieldNames)
if err != nil {
return err
}
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
return err
}
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
}

return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
})
},
Expand All @@ -90,6 +109,7 @@ func newAlertEventListCmd() *cobra.Command {
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
cmd.Flags().IntVar(&page, "page", 1, "Page number")
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. Long strings are truncated as needed to keep structured output below 16 KiB.")

return cmd
}
89 changes: 89 additions & 0 deletions internal/cli/fieldproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import (
"reflect"
"sort"
"strings"
"unicode/utf8"
)

const (
compactListOutputLimit = 16 * 1024
compactDetailOutputLimit = 8 * 1024
)

// projectFields reduces each struct element of items to a map containing only
Expand Down Expand Up @@ -63,6 +69,89 @@ func projectFields(items any, fields []string) ([]map[string]any, error) {
return out, nil
}

// boundProjectedOutput keeps the new agent-oriented projections below their
// command budget without changing the selected keys. Short values remain byte
// identical; when retained strings alone would overflow the actual JSON/TOON
// encoding, they are shortened fairly and marked with "...". If keys and
// non-string values alone exceed the budget, the command fails with a small
// error instead of emitting an oversized payload.
func boundProjectedOutput(data any, maxBytes int) error {
var rows []map[string]any
switch value := data.(type) {
case map[string]any:
rows = []map[string]any{value}
case []map[string]any:
rows = value
default:
return fmt.Errorf("internal error: unsupported projected output %T", data)
}

encoded, err := marshalStructured(data)
if err != nil {
return err
}
if len(encoded)+1 < maxBytes {
return nil
}

stringCount := 0
for _, row := range rows {
for _, value := range row {
if _, ok := value.(string); ok {
stringCount++
}
}
}
if stringCount == 0 {
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
}

fieldLimit := maxBytes / stringCount
for {
for _, row := range rows {
for key, value := range row {
if text, ok := value.(string); ok {
row[key] = truncateUTF8Bytes(text, fieldLimit)
}
}
}

encoded, err = marshalStructured(data)
if err != nil {
return err
}
if len(encoded)+1 < maxBytes {
return nil
}
if fieldLimit == 0 {
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
}
fieldLimit /= 2
}
}

func truncateUTF8Bytes(value string, maxBytes int) string {
if len(value) <= maxBytes {
return value
}
if maxBytes <= 0 {
return ""
}
if maxBytes <= 3 {
end := maxBytes
for end > 0 && !utf8.ValidString(value[:end]) {
end--
}
return value[:end]
}

end := maxBytes - 3
for end > 0 && !utf8.ValidString(value[:end]) {
end--
}
return value[:end] + "..."
}

// jsonTagIndex maps each exported field's json tag name (leading component, sans
// `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged
// fields, and embedded/anonymous fields are skipped — only declared, named,
Expand Down
Loading