Skip to content

Commit cb4c778

Browse files
committed
Merge origin/main into fix/monit-query-diagnose-dstype
2 parents 09fd0ed + 03fb1be commit cb4c778

29 files changed

Lines changed: 657 additions & 176 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/go-flashduty v0.12.0
6+
github.com/flashcatcloud/go-flashduty v0.13.1
77
github.com/mattn/go-runewidth v0.0.27
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.12.0 h1:2jjQsTB212XvwpRcX8W6uQ+Zjzmlu857T7YtTs5nH8Q=
5-
github.com/flashcatcloud/go-flashduty v0.12.0/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
4+
github.com/flashcatcloud/go-flashduty v0.13.1 h1:6AzKyeaY+dvcVOod8DqOhzjA6ilK/tTTALGrjIW+RdY=
5+
github.com/flashcatcloud/go-flashduty v0.13.1/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=

internal/cli/alert.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func newAlertTimelineCmd() *cobra.Command {
285285
},
286286
}
287287

288-
cmd.Flags().IntVar(&limit, "limit", 20, "Max events")
288+
cmd.Flags().IntVar(&limit, "limit", 20, "Max events (max 100)")
289289
cmd.Flags().IntVar(&page, "page", 1, "Page number")
290290

291291
return cmd

internal/cli/alert_event.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,11 @@ func newAlertEventListCmd() *cobra.Command {
9797
if err != nil {
9898
return err
9999
}
100-
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
100+
note, err := boundProjectedOutput(proj, compactListOutputLimit)
101+
if err != nil {
101102
return err
102103
}
104+
noteProjectionShortening(cmd.ErrOrStderr(), note)
103105
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
104106
}
105107

internal/cli/fieldproject.go

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ func noteDefaultProjection(w io.Writer, fields []string) {
8080
strings.Join(fields, ","))
8181
}
8282

83+
// noteProjectionShortening tells the caller, on stderr, that some values came
84+
// back clipped. Without it a shortened value is only visible to a reader, not
85+
// to the jq filter or exact match a --json consumer runs over it, so a query
86+
// that silently matches nothing looks like an empty result rather than a
87+
// truncated one.
88+
func noteProjectionShortening(w io.Writer, note string) {
89+
if note == "" {
90+
return
91+
}
92+
_, _ = fmt.Fprintln(w, note)
93+
}
94+
8395
// boundProjectedOutput keeps the new agent-oriented projections below their
8496
// command budget without changing the selected keys. List rows (many small
8597
// records) are shortened fairly when they overflow the budget, with
@@ -88,44 +100,49 @@ func noteDefaultProjection(w io.Writer, fields []string) {
88100
// a genuinely short value, so silently shortening it would hand the caller
89101
// wrong data instead of a compact one. If a detail projection doesn't fit,
90102
// the command fails with an error instead.
91-
func boundProjectedOutput(data any, maxBytes int) error {
103+
//
104+
// It returns a caller-printable note (empty when nothing was shortened) that
105+
// names the clipped fields, so the caller can announce the loss on stderr —
106+
// the "..." marker is only visible to something that reads the value, never
107+
// to the filter a --json consumer runs over it.
108+
func boundProjectedOutput(data any, maxBytes int) (string, error) {
92109
switch value := data.(type) {
93110
case map[string]any:
94-
return boundProjectedDetail(value, maxBytes)
111+
return "", boundProjectedDetail(value, maxBytes)
95112
case []map[string]any:
96113
return boundProjectedList(value, maxBytes)
97114
default:
98-
return fmt.Errorf("internal error: unsupported projected output %T", data)
115+
return "", fmt.Errorf("internal error: unsupported projected output %T", data)
99116
}
100117
}
101118

102-
// boundProjectedDetail rejects an oversized single-object projection instead
103-
// of truncating it, naming the largest fields so the caller can fix the
104-
// request in one pass: drop some of them from --fields, or drop --fields
105-
// entirely for the full, unbounded detail.
106-
func boundProjectedDetail(row map[string]any, maxBytes int) error {
107-
encoded, err := marshalStructured(row)
108-
if err != nil {
109-
return err
110-
}
111-
if len(encoded)+1 < maxBytes {
112-
return nil
119+
// largestProjectedFields names the up to three fields carrying the most bytes
120+
// in a projection, so an over-budget request can be narrowed in one pass
121+
// instead of one re-run per field. Sizes are summed per field across every
122+
// row, which is what makes it meaningful for a list: the field responsible
123+
// for the overflow is the one that is big in aggregate, not in any one row.
124+
// Ties break on name so the same oversized request always names the same
125+
// fields, despite Go's randomized map iteration order.
126+
func largestProjectedFields(rows []map[string]any) (string, error) {
127+
totals := map[string]int{}
128+
for _, row := range rows {
129+
for key, value := range row {
130+
encoded, err := marshalStructured(map[string]any{key: value})
131+
if err != nil {
132+
return "", err
133+
}
134+
totals[key] += len(encoded)
135+
}
113136
}
114137

115138
type fieldSize struct {
116139
name string
117140
size int
118141
}
119-
sizes := make([]fieldSize, 0, len(row))
120-
for key, value := range row {
121-
fieldEncoded, err := marshalStructured(map[string]any{key: value})
122-
if err != nil {
123-
return err
124-
}
125-
sizes = append(sizes, fieldSize{key, len(fieldEncoded)})
142+
sizes := make([]fieldSize, 0, len(totals))
143+
for name, size := range totals {
144+
sizes = append(sizes, fieldSize{name, size})
126145
}
127-
// Ties break on name so the same oversized request always names the same
128-
// fields, despite Go's randomized map iteration order.
129146
sort.Slice(sizes, func(i, j int) bool {
130147
if sizes[i].size != sizes[j].size {
131148
return sizes[i].size > sizes[j].size
@@ -139,8 +156,28 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
139156
for i, f := range sizes {
140157
largest[i] = fmt.Sprintf("%s (%d bytes)", f.name, f.size)
141158
}
159+
return strings.Join(largest, ", "), nil
160+
}
161+
162+
// boundProjectedDetail rejects an oversized single-object projection instead
163+
// of truncating it, naming the largest fields so the caller can fix the
164+
// request in one pass: drop some of them from --fields, or drop --fields
165+
// entirely for the full, unbounded detail.
166+
func boundProjectedDetail(row map[string]any, maxBytes int) error {
167+
encoded, err := marshalStructured(row)
168+
if err != nil {
169+
return err
170+
}
171+
if len(encoded)+1 < maxBytes {
172+
return nil
173+
}
174+
175+
largest, err := largestProjectedFields([]map[string]any{row})
176+
if err != nil {
177+
return err
178+
}
142179
return fmt.Errorf("projected detail is %d bytes, exceeds the %d-byte limit; largest fields: %s; request fewer --fields, or omit --fields for the full, unbounded detail",
143-
len(encoded), maxBytes, strings.Join(largest, ", "))
180+
len(encoded), maxBytes, largest)
144181
}
145182

146183
// boundProjectedList shortens a list projection's string values fairly when
@@ -153,14 +190,25 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error {
153190
// marker itself disappear, so a shortened value is always distinguishable
154191
// from a genuinely short one; if no cap at or above that floor fits, the
155192
// command fails with a small error instead of emitting values that look
156-
// real but aren't.
157-
func boundProjectedList(rows []map[string]any, maxBytes int) error {
193+
// real but aren't. Whatever it clips, it reports back in the returned note.
194+
func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) {
158195
encoded, err := marshalStructured(rows)
159196
if err != nil {
160-
return err
197+
return "", err
161198
}
162199
if len(encoded)+1 < maxBytes {
163-
return nil
200+
return "", nil
201+
}
202+
203+
// The overflow error names the fields responsible, exactly as the detail
204+
// path does, so the request can be narrowed in one pass.
205+
tooBig := func() (string, error) {
206+
largest, err := largestProjectedFields(rows)
207+
if err != nil {
208+
return "", err
209+
}
210+
return "", fmt.Errorf("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields",
211+
len(encoded), len(rows), maxBytes, largest)
164212
}
165213

166214
maxLen := 0
@@ -172,7 +220,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
172220
}
173221
}
174222
if maxLen == 0 {
175-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
223+
return tooBig()
176224
}
177225

178226
fits := func(limit int) (bool, error) {
@@ -202,12 +250,12 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
202250
// reintroduce.
203251
const minMarkedTruncationCap = 4
204252
if maxLen <= minMarkedTruncationCap {
205-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
253+
return tooBig()
206254
}
207255
if ok, err := fits(minMarkedTruncationCap); err != nil {
208-
return err
256+
return "", err
209257
} else if !ok {
210-
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
258+
return tooBig()
211259
}
212260

213261
// Binary search for the largest cap that still fits: fits(limit) is true
@@ -219,7 +267,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
219267
mid := lo + (hi-lo+1)/2
220268
ok, err := fits(mid)
221269
if err != nil {
222-
return err
270+
return "", err
223271
}
224272
if ok {
225273
lo = mid
@@ -228,14 +276,33 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error {
228276
}
229277
}
230278

279+
shortened, total := 0, 0
280+
fields := map[string]bool{}
231281
for _, row := range rows {
232282
for key, value := range row {
233-
if text, ok := value.(string); ok {
234-
row[key] = truncateUTF8Bytes(text, lo)
283+
text, ok := value.(string)
284+
if !ok {
285+
continue
286+
}
287+
total++
288+
clipped := truncateUTF8Bytes(text, lo)
289+
if clipped != text {
290+
shortened++
291+
fields[key] = true
235292
}
293+
row[key] = clipped
236294
}
237295
}
238-
return nil
296+
if shortened == 0 {
297+
return "", nil
298+
}
299+
names := make([]string, 0, len(fields))
300+
for name := range fields {
301+
names = append(names, name)
302+
}
303+
sort.Strings(names)
304+
return fmt.Sprintf("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \"...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values",
305+
shortened, total, maxBytes, strings.Join(names, ", ")), nil
239306
}
240307

241308
func truncateUTF8Bytes(value string, maxBytes int) string {

0 commit comments

Comments
 (0)