diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 4aebadc..37fb4f5 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "fmt" "io" @@ -11,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/flashcatcloud/flashduty-cli/internal/output" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) @@ -320,16 +322,177 @@ func bindURLTagged(body map[string]any, rv reflect.Value) { // printGenericResult renders a generated command's typed response. In // machine-readable mode (TOON/JSON) it marshals the whole value — which is what -// the agent reads. In human (table) mode it derives an aligned table by -// reflection (renderGenericTable), since generated commands carry no hand-written -// column set; anything that isn't a list or object falls back to indented JSON. +// the agent reads. A list-shaped response (a top-level array of objects, or an +// items/docs/list page envelope whose other fields are scalar pagination +// metadata) that overflows compactListOutputLimit is first bounded to the +// leading rows that fit via boundProjectedList, with the reduction announced on +// stderr; a payload that fits, and any detail-shaped single object, prints +// untouched. In human (table) mode it derives an aligned table by reflection +// (renderGenericTable), since generated commands carry no hand-written column +// set; anything that isn't a list or object falls back to indented JSON. func printGenericResult(ctx *RunContext, data any) error { if ctx.Structured() { - return ctx.Printer.Print(data, nil) + return printBoundedGenericResult(ctx, data) } return renderGenericTable(ctx, data) } +// printBoundedGenericResult is printGenericResult's structured-mode half. The +// under-cap fast path is the pre-bound behavior verbatim — same printer call, +// byte-identical output. Only an over-cap list payload detours through the +// bounding machinery, and only there does the output change (fewer rows; a +// rebuilt envelope, so key order is no longer the struct's field order). +func printBoundedGenericResult(ctx *RunContext, data any) error { + encoded, err := marshalStructured(data) + if err != nil || len(encoded)+1 < compactListOutputLimit { + // Fits the budget (or cannot be measured, in which case the printer + // surfaces the same marshal error): emit untouched. + return ctx.Printer.Print(data, nil) + } + + generic, err := genericStructured(data) + if err != nil { + return ctx.Printer.Print(data, nil) + } + + switch value := generic.(type) { + case []any: + rows, ok := objectRows(value) + if !ok { + return ctx.Printer.Print(data, nil) + } + bounded, note, err := boundProjectedList(rows, compactListOutputLimit) + if err != nil { + return err + } + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(bounded, nil) + case map[string]any: + key, ok := listEnvelopeKey(value) + if !ok { + // Detail-shaped single object: never bounded, never errored — a + // shortened id or status would pass for a real value. + return ctx.Printer.Print(data, nil) + } + rows, ok := objectRows(value[key].([]any)) + if !ok { + return ctx.Printer.Print(data, nil) + } + // boundProjectedList sizes the rows standalone, but printed inside the + // envelope they share the budget with the pagination siblings (and, in + // indented JSON, sit one indent level deeper). Fit against the full + // limit, then re-fit with the observed envelope overhead subtracted + // until the whole payload is under it. + budget := compactListOutputLimit + for { + bounded, note, err := boundProjectedList(rows, budget) + if err != nil { + return err + } + value[key] = bounded + out, err := marshalStructured(value) + if err != nil { + return err + } + if len(out)+1 < compactListOutputLimit { + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(value, nil) + } + budget -= len(out) + 2 - compactListOutputLimit + } + default: + return ctx.Printer.Print(data, nil) + } +} + +// genericStructured decodes data through its JSON encoding into plain +// maps/slices/scalars, so the list-bounding machinery can walk rows of any SDK +// response type. Numbers decode as json.Number first (UseNumber) and are then +// narrowed by narrowNumbers: decoding straight to float64 would round integer +// IDs above 2^53 (channel_id, team_id, …) in the bounded output. Unset SDK +// timestamps go in as null (NullUnsetInstants), matching what the printer +// would have emitted for the unbounded payload. +func genericStructured(data any) (any, error) { + raw, err := json.Marshal(output.NullUnsetInstants(data)) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var generic any + if err := dec.Decode(&generic); err != nil { + return nil, err + } + return narrowNumbers(generic), nil +} + +// narrowNumbers rewrites every json.Number in a decoded generic value to its +// int64 form when the literal is an integer (exact for IDs beyond 2^53), else +// float64 — both encoders (JSON and TOON) render those natively. +func narrowNumbers(value any) any { + switch v := value.(type) { + case json.Number: + if i, err := v.Int64(); err == nil { + return i + } + if f, err := v.Float64(); err == nil { + return f + } + return v.String() + case map[string]any: + for key, item := range v { + v[key] = narrowNumbers(item) + } + return v + case []any: + for i, item := range v { + v[i] = narrowNumbers(item) + } + return v + default: + return value + } +} + +// listEnvelopeKey reports whether value is a paginated list envelope — exactly +// one array field named items/docs/list with only scalar siblings (total, +// has_next_page, search_after_ctx, …) — and returns the row array's key. It +// mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the +// same shape when generating these commands. +func listEnvelopeKey(value map[string]any) (string, bool) { + key := "" + for name, field := range value { + _, isArray := field.([]any) + if isArray && (name == "items" || name == "docs" || name == "list") { + if key != "" { + return "", false // two candidate row arrays: not a flat list envelope + } + key = name + continue + } + switch field.(type) { + case map[string]any, []any: + return "", false // non-scalar sibling: a richer response, not a flat list + } + } + return key, key != "" +} + +// objectRows converts a decoded JSON array to rows for boundProjectedList. ok +// is false when any element is not an object: an array of scalars has no row +// fields to bound and prints unbounded instead. +func objectRows(items []any) ([]map[string]any, bool) { + rows := make([]map[string]any, len(items)) + for i, item := range items { + row, ok := item.(map[string]any) + if !ok { + return nil, false + } + rows[i] = row + } + return rows, true +} + // genParseTimeFlag parses a relative-or-absolute time flag into unix seconds, // mirroring the curated incident-list --since/--until handling: a Go duration // ("7d", "24h") is "now minus duration", "+7d" is the future, "now" is now, and diff --git a/internal/cli/gen_support_test.go b/internal/cli/gen_support_test.go index 965eb1c..6ba00bf 100644 --- a/internal/cli/gen_support_test.go +++ b/internal/cli/gen_support_test.go @@ -1,6 +1,15 @@ package cli -import "testing" +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { req := new(struct { @@ -14,3 +23,208 @@ func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { t.Fatalf("Value = %v, want nil", req.Value) } } + +// oversizedInsightRows returns n insight-incident rows whose fat description +// fields push any page of them well past compactListOutputLimit, plus the +// incident IDs in row order so a test can tell emitted rows from dropped ones. +func oversizedInsightRows(n int) ([]any, []string) { + rows := make([]any, n) + ids := make([]string, n) + for i := range rows { + ids[i] = fmt.Sprintf("inc-%024d", i) + rows[i] = map[string]any{ + "incident_id": ids[i], + "title": fmt.Sprintf("Database failover on db-%d", i), + "severity": "Critical", + "channel_id": 12345, + "channel_name": "db-alerts", + "description": strings.Repeat(fmt.Sprintf("row %d root-cause detail ", i), 40), + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + } + } + return rows, ids +} + +// TestPrintGenericResultBoundsListEnvelope drives a generated list verb whose +// response is an items[] page envelope (insight incident-list): an oversized +// page must come back under the structured-output limit with the reduction +// announced on stderr and the envelope keys intact, in both structured +// formats. +func TestPrintGenericResultBoundsListEnvelope(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, ids := oversizedInsightRows(40) + stub.data = map[string]any{ + "items": rows, + "total": 40, + "has_next_page": true, + "search_after_ctx": "cursor-1", + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded %s envelope is %d bytes, want <%d", format, len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced %s page should announce itself on stderr, got:\n%s", format, stderrText) + } + // The first row survives intact; the last was dropped by the + // prefix reduction. + if !strings.Contains(out, ids[0]) { + t.Errorf("bounded %s output lost leading row %q:\n%s", format, ids[0], out) + } + if strings.Contains(out, ids[len(ids)-1]) { + t.Errorf("bounded %s output still contains trailing row %q", format, ids[len(ids)-1]) + } + // The pagination envelope rides along with the bounded rows. + for _, key := range []string{"total", "has_next_page", "search_after_ctx"} { + if !strings.Contains(out, key) { + t.Errorf("bounded %s output lost envelope key %q:\n%s", format, key, out) + } + } + if format == "json" { + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &envelope); err != nil { + t.Fatalf("bounded json is not an object: %v", err) + } + if _, ok := envelope["items"].([]any); !ok { + t.Fatalf("bounded json lost the items array: %v", envelope) + } + } + }) + } +} + +// TestPrintGenericResultBoundsTopLevelArray drives a generated verb whose +// response is a bare top-level array (monit rule-list-basic): an oversized +// page must be bounded the same way as an items[] envelope. +func TestPrintGenericResultBoundsTopLevelArray(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 40) + for i := range rows { + rows[i] = map[string]any{ + "id": i + 1, + "name": strings.Repeat(fmt.Sprintf("rule %d ", i), 40), + "folder_id": 100, + "ds_type": "prometheus", + "cron_pattern": "0 * * * * *", + "enabled": true, + } + } + stub.data = rows + + out, stderrText, err := execCommandSplit("monit", "rule-list-basic", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded top-level array is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + var decoded []map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decoded); err != nil { + t.Fatalf("bounded top-level array is not a JSON array: %v\n%s", err, out) + } + if len(decoded) == 0 || len(decoded) >= len(rows) { + t.Errorf("bounded array has %d rows, want a reduced page in [1, %d)", len(decoded), len(rows)) + } +} + +// TestPrintGenericResultKeepsLargeIDsExact is the precision guard for the +// typed-slice round trip: an integer ID above 2^53 (channel_id) must reach +// the bounded output with every digit intact, where a float64 decode would +// have rounded it. +func TestPrintGenericResultKeepsLargeIDsExact(t *testing.T) { + const bigChannelID = "9007199254740993" // 2^53 + 1 + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, _ := oversizedInsightRows(40) + for _, row := range rows { + row.(map[string]any)["channel_id"] = json.Number(bigChannelID) + } + stub.data = map[string]any{"items": rows, "total": 40} + + out, _, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if !strings.Contains(out, bigChannelID) { + t.Errorf("bounded %s output lost digits of channel_id %s", format, bigChannelID) + } + if strings.Contains(out, "9007199254740992") { + t.Errorf("bounded %s output rounded channel_id to the nearest float64", format) + } + }) + } +} + +// TestPrintGenericResultDetailNeverBounded: a detail-shaped single object is +// excluded from list bounding no matter its size — never reduced, never +// errored — and prints byte-identical to the direct printer. +func TestPrintGenericResultDetailNeverBounded(t *testing.T) { + detail := &heuristicRow{Name: strings.Repeat("x", 40*1024), Count: 7} + + for _, f := range []output.Format{output.FormatJSON, output.FormatTOON} { + var got, want bytes.Buffer + if err := printGenericResult(structuredCtx(&got, f), detail); err != nil { + t.Fatalf("%v oversized detail errored: %v", f, err) + } + if err := output.NewPrinter(f, false, &want).Print(detail, nil); err != nil { + t.Fatalf("%v reference: %v", f, err) + } + if got.String() != want.String() { + t.Errorf("oversized detail output changed for %v\n got:\n%s\nwant:\n%s", f, got.String(), want.String()) + } + if len(got.Bytes()) < compactListOutputLimit { + t.Errorf("detail payload should pass through unbounded, got %d bytes", len(got.Bytes())) + } + } +} + +// TestPrintGenericResultShortenedRowStaysUTF8 covers the single-row-overflow +// path through a generated verb: one row too big on its own is shortened with +// a "..." marker, valid UTF-8, and a stderr note — never an unmarked clip. +func TestPrintGenericResultShortenedRowStaysUTF8(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []any{map[string]any{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 5000), + "severity": "Critical", + }}, + "total": 1, + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened single row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(out, "inc-1") { + t.Errorf("identifier field must survive shortening intact, got:\n%s", out) + } + if !strings.Contains(stderrText, "were shortened to fit") { + t.Errorf("shortened row should announce the clipped fields on stderr, got:\n%s", stderrText) + } +} diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 485df4c..d404df8 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -84,13 +84,14 @@ func newInsightTopAlertsCmd() *cobra.Command { } func newInsightIncidentsCmd() *cobra.Command { - var since, until string + var since, until, fields string var limit, page int + defaultStructuredFields := []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} cmd := &cobra.Command{ Use: "incidents", Short: "Query incidents with performance metrics", - Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window.", "Analytics", "IncidentList"), + Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window. In json/toon mode, rows default to the compact fields incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications; pass --fields to choose a different projection.", "Analytics", "IncidentList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -114,6 +115,33 @@ func newInsightIncidentsCmd() *cobra.Command { return err } + if ctx.Structured() { + selectedFields := defaultStructuredFields + if cmd.Flags().Changed("fields") { + selectedFields = parseStringSlice(fields) + if len(selectedFields) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) + } + proj, err := projectFields(result.Items, selectedFields) + if err != nil { + return err + } + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { + return err + } + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) + effectiveLimit := limit + if len(proj) < len(result.Items) { + effectiveLimit = len(proj) + } + return ctx.PrintList(proj, nil, len(proj), page, effectiveLimit, int(result.Total)) + } + cols := []output.Column{ {Header: "ID", Field: func(v any) string { return v.(flashduty.IncidentRawItem).IncidentID @@ -147,6 +175,7 @@ func newInsightIncidentsCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,severity); ignored in table mode. Use to avoid dumping the full nested record.") return cmd } diff --git a/internal/cli/insight_export_test.go b/internal/cli/insight_export_test.go index b565470..839479a 100644 --- a/internal/cli/insight_export_test.go +++ b/internal/cli/insight_export_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "unicode/utf8" "github.com/flashcatcloud/go-flashduty" ) @@ -55,6 +56,149 @@ type insightExportStub struct { listBody map[string]any } +// insightIncidentRow builds one /insight/incident/list row for the stub, +// carrying both the compact-projection fields and the full-record fields a +// default projection must drop. +func insightIncidentRow() map[string]any { + return map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "severity": "Critical", + "progress": "Triggered", + "channel_id": 12345, + "channel_name": "db-alerts", + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + "description": "root volume at 98%", + "labels": map[string]any{"service": "db", "env": "prod"}, + "responders": []map[string]any{{"person_id": 101, "person_name": "Alice"}}, + } +} + +// TestInsightIncidentsStructuredDefaultUsesCompactProjection mirrors incident +// list: structured mode must not dump the full nested SDK row when --fields +// is omitted, and the default projection announces itself on stderr. +func TestInsightIncidentsStructuredDefaultUsesCompactProjection(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, key := range []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} { + if !strings.Contains(out, key) { + t.Errorf("default %s output missing compact key %q, got:\n%s", format, key, out) + } + } + // Full-record keys must not leak. (stdout only: the stderr note + // embeds the compact field names, never these.) + for _, key := range []string{"description", "labels", "responders"} { + if strings.Contains(out, key) { + t.Errorf("default %s output should not contain full-record key %q, got:\n%s", format, key, out) + } + } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } + }) + } +} + +// TestInsightIncidentsStructuredFieldsFlag: an explicit --fields wins over the +// default projection and stays exactly the named fields. +func TestInsightIncidentsStructuredFieldsFlag(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", + "--fields", "incident_id,severity", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + assertProjectedJSONFields(t, out, []string{"incident_id", "severity"}) + if strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("explicit --fields must not print the default-projection note, got:\n%s", stderrText) + } +} + +// TestInsightIncidentsStructuredBounded: an oversized projected page is +// bounded below the structured-output limit — reduced to the leading intact +// rows, or a single oversized row shortened with a marked, announced clip. +func TestInsightIncidentsStructuredBounded(t *testing.T) { + t.Run("reduced page keeps every value intact", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 10) + for i := range rows { + row := insightIncidentRow() + row["incident_id"] = fmt.Sprintf("inc-%d", i) + row["title"] = strings.Repeat(fmt.Sprintf("db-%d failover ", i), 200) + rows[i] = row + } + stub.data = map[string]any{"items": rows, "total": 10} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("bounded page is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + if strings.Contains(out, "...") { + t.Errorf("page reduction must never shorten a value, got:\n%s", out) + } + }) + + t.Run("single oversized row is shortened and announced", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := insightIncidentRow() + row["title"] = strings.Repeat("数据库故障", 5000) + stub.data = map[string]any{"items": []any{row}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(stderrText, "were shortened to fit") || !strings.Contains(stderrText, "title") { + t.Errorf("shortened row should announce the clipped field on stderr, got:\n%s", stderrText) + } + }) +} + +// TestInsightIncidentsTableUnchanged: the human table keeps its full column +// set — the structured projection must not leak into table mode. +func TestInsightIncidentsTableUnchanged(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, _, err := execCommandSplit("insight", "incidents") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, want := range []string{"ID", "TITLE", "SEVERITY", "CHANNEL", "MTTA", "MTTR", "NOTIFICATIONS", "inc-1", "db-alerts"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } +} + // TestInsightIncidentExportComplete verifies the happy path: when the CSV // data-row count matches the incident-list total, the command exits 0 and // reports the actual written row count on stderr. diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index a34b513..766b704 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -65,7 +65,7 @@ Some asks span several commands. For those the skill ships a script that fetches | intent / 意图 (terms route in either language) | card | |---|---| -| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / **summarize or analyze an incident 故障汇总分析** | **`reference/incident.md`** | +| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / **summarize or analyze an incident 故障汇总分析** / raw notification feed 通知明细 (per-event, via `timeline` `i_notify` entries — not aggregates) | **`reference/incident.md`** | | post-mortem / postmortem 复盘 / 复盘报告 / 复盘模板 / post-incident review / RCA report | **`reference/postmortem.md`** | | alert / 告警 / dedup 去重 / alert fields 告警字段 / alert pipeline 告警管道 | **`reference/alert.md`** | | change / 变更 / deployment 部署 / release 发布 / correlated change 变更关联 / what changed | **`reference/change.md`** | @@ -81,7 +81,8 @@ Some asks span several commands. For those the skill ships a script that fetches | dispatch rule 分派策略 / 分派规则 / escalation rule 升级规则 / notify layers 通知层级 / who gets paged | **`reference/escalation.md`** | | silence 静默 / 屏蔽 / inhibit 抑制 / drop rule 丢弃 / noise reduction 降噪 / maintenance silence 维护窗口静默 | **`reference/noise.md`** | | enrichment / 数据加工 / 富化 / label mapping 字段映射 / extraction 提取 / mapping schema 集成 schema | **`reference/enrichment.md`** | -| insight / 洞察 / stats 统计 / trend 趋势 / MTTA / MTTR / top alerts Top 告警 / incident export 故障导出 | **`reference/insight.md`** | +| insight / 洞察 / stats 统计 / trend 趋势 / MTTA / MTTR / top alerts Top 告警 / incident export 故障导出 / notification volume 通知量 (aggregated metrics — not the raw per-event feed) | **`reference/insight.md`** | +| per-person notification counts 通知次数 (how many notifications a person received) | **`reference/insight.md`** (`insight responder` → `total_notifications`); per-person delivery outcome detail → `incident timeline` `i_notify` entries (`person_id` + `failed_reason` only — there is no answer/接通 field) | | schedule / on-call / 值班 / 排班 / rotation 轮值 / who is on call 谁在值班 / shift 班次 / next responder 下一班 | **`reference/schedule.md`** | | calendar / 日历 / on-call calendar 值班日历 / calendar event 日历事件 / holiday 休假 | **`reference/calendar.md`** | | template / 通知模板 / message template 消息模板 / card template 卡片模板 | **`reference/template.md`** | @@ -94,5 +95,6 @@ Some asks span several commands. For those the skill ships a script that fetches | sourcemap / source map / source mapping / symbolication / deobfuscate / stack enrich / dSYM / miniprogram source map | **`reference/sourcemap.md`** | | status page / 状态页 / public incident 公开事件 / public timeline 公开时间线 / maintenance window 维护窗口 / subscriber 订阅者 | **`reference/status-page.md`** | | AI-SRE platform / customize / 安装配置 MCP server (connector) 连接器 / install mcp / skill upload 上传技能 / A2A agent / session export 会话导出 | **`reference/safari.md`** | +| usage / quota / billing 额度 / 用量 / 计费 — route per product | RUM session usage/quota → **`reference/rum.md`** §resource-info (`free_cnt`/`used_cnt`, `session_limit_reached`, billing window); AI-SRE token usage → **`reference/safari.md`** `session-list` (items carry `token_usage`, the account-billing source of truth); anything else (e.g. 语音通知额度) → **no CLI verb** — point to the console 费用中心, do NOT sweep `--help` | Shared reference: `reference/filters.md` — read it before composing any `filters` / `source_filters` / `target_filters` value (silence / inhibit / drop / escalation rules); it carries the condition shape, operators, and the valid key set per rule family. diff --git a/skills/flashduty/reference/insight.md b/skills/flashduty/reference/insight.md index 71c7b9c..d653d83 100644 --- a/skills/flashduty/reference/insight.md +++ b/skills/flashduty/reference/insight.md @@ -6,6 +6,8 @@ Prereq: `SKILL.md` read. All `insight` verbs are **read-only** — no mutations, "噪声治理 / 高频告警 / MTTA / MTTR / 绩效复盘 / 月报 / SRE review / noise reduction / alert fatigue / who responds fastest / channel performance / team metrics / incident export / CSV export" → **insight**. +"per-person notification counts / 通知次数 / 通知量 (how many notifications did a person receive)" → **insight responder** (`total_notifications`); per-person delivery outcome detail lives in `incident timeline` `i_notify` entries (`person_id` + `failed_reason` only — no answer/接通 field), not here. + Do **not** hand-aggregate from `alert list` / `incident list` — `insight` does server-side aggregation and gives authoritative numbers. Key IDs you may need: `--team-ids` and `--channel-ids` from `fduty channel list` or `fduty team list`; `--responder-ids` from `fduty member list`. ## Intent → verb @@ -227,6 +229,7 @@ List insight incidents ### incidents Query incidents with performance metrics +- `--fields` string - `--limit` int - `--page` int - `--since` string