Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion apps/ai/src/mcp/lib/dashboard-schema-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ const queriesSection = (): string => {
"### `whereClause` is a custom grammar, not SQL",
"",
"Operators (the only ones): `=`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `!contains`,",
"`exists`, `!exists`. Clauses join with ` AND `. On `traces` and `logs`, attribute",
"`exists`, `!exists`. Clauses join with ` AND `. On `traces`, `logs` and `metrics`, attribute",
'clauses can be OR-ed inside one level of parentheses: `(attr.a = "1" OR attr.b !exists)`.',
"Every member must be an attribute on the same map (all span or all resource); named keys",
"like `service.name` cannot be OR-ed, and there is no AND or nesting inside a group. A",
Expand All @@ -424,6 +424,11 @@ const queriesSection = (): string => {
'a span attribute, so `db.system = "clickhouse"` works directly. Cap: 5 `attr.*` plus 5',
"`resource.*` filters per query.",
"",
"On `metrics`, `attr.<key>` filters the datapoint labels with any operator. A metric reported",
"per `state` (`system.cpu.utilization`) averages to roughly 1/states unless scoped: `avg` with",
'`attr.state = "idle"` is the idle fraction (busy is 1 minus it), `attr.state != "idle"` drops',
'idle, and an OR group is the `IN` form: `(attr.state = "user" OR attr.state = "system")`.',
"",
"### Formulas and hidden series",
"",
"`formulas: [{ id, name, expression, legend }]` references queries by `name` (`A / B`), and",
Expand Down
44 changes: 44 additions & 0 deletions apps/ai/src/mcp/lib/inspect-widget-summary.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, expect, it } from "vitest"
import {
makeQueryBuilderBreakdownDataSource,
makeQueryBuilderTimeseriesDataSource,
makeQueryDraft,
} from "@maple/backend/dashboard-templates/helpers"
import { summarizeOutcome, SLOW_RAW_SQL_MS, type InspectionOutcome } from "./inspect-widget"

const widget = {
Expand Down Expand Up @@ -37,6 +42,45 @@ describe("summarizeOutcome", () => {
expect(slow.note).toContain("may time out")
})

describe("seriesCount", () => {
const draft = makeQueryDraft({ id: "q-a", name: "A", dataSource: "metrics", aggregation: "avg" })
const query = (status: "ok" | "error", rowCount: number, seriesCount: number) => ({
queryId: "q",
queryName: "q",
status,
stats: { rowCount, seriesCount, seriesStats: [] },
flags: [],
})
const supported = (queries: ReadonlyArray<ReturnType<typeof query>>): InspectionOutcome => ({
kind: "supported",
data: {
widget: {
id: "w-1",
visualization: "chart",
endpoint: "custom_query_builder_timeseries",
hasFormulaWarning: false,
hasUnsupportedTransform: false,
},
timeRange,
queries: [...queries],
verdict: "looks_healthy",
flags: [],
notes: [],
},
})

it("takes the largest chart query, so a formula over its operands is not counted twice", () => {
const chart = { ...widget, dataSource: makeQueryBuilderTimeseriesDataSource([draft]) }
const outcome = supported([query("ok", 60, 4), query("ok", 60, 4), query("error", 0, 9)])
expect(summarizeOutcome(chart, outcome).seriesCount).toBe(4)
})

it("counts a breakdown's groups, which are its rows", () => {
const table = { ...widget, dataSource: makeQueryBuilderBreakdownDataSource([draft]) }
expect(summarizeOutcome(table, supported([query("ok", 12, 1)])).seriesCount).toBe(12)
})
})

it("scores a funnel widget from its query instead of skipping it", () => {
const funnel = (first: number): InspectionOutcome => ({
kind: "unsupported",
Expand Down
17 changes: 15 additions & 2 deletions apps/ai/src/mcp/lib/inspect-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,13 +842,24 @@ export function summarizeOutcome(widget: DashboardWidget, outcome: InspectionOut
// change, so it rides along on the mutation-tool summary rather than only
// appearing in a full `inspect_chart_data` response.
const actionableNote = outcome.data.notes.find((note) => note.startsWith('unit "percent'))
// A breakdown's groups are its rows. The largest query, not the sum, so hidden
// operands and the formula built from them are not counted twice.
const isBreakdown = dataSourceQuerySet(widget.dataSource)?.resultShape === "breakdown"
const seriesCount = outcome.data.queries.reduce(
(largest, query) =>
query.status === "ok"
? Math.max(largest, isBreakdown ? query.stats.rowCount : query.stats.seriesCount)
: largest,
0,
)
return {
widgetId: widget.id,
...(widget.display.title !== undefined ? { title: widget.display.title } : undefined),
visualization: widget.visualization,
verdict: outcome.data.verdict satisfies WidgetInspectionVerdict,
flags: [...outcome.data.flags],
...(actionableNote !== undefined ? { note: actionableNote } : undefined),
seriesCount,
}
}
if (outcome.kind === "unsupported" && outcome.funnel !== undefined) {
Expand Down Expand Up @@ -1062,8 +1073,9 @@ export function validationDoc(
const single = options.single && summary.inspected.length === 1 ? summary.inspected[0] : undefined
if (single !== undefined) {
const flagPart = single.flags.length > 0 ? ` (${single.flags.join(", ")})` : ""
const seriesPart = single.seriesCount !== undefined ? `, ${single.seriesCount} series` : ""
const lines = [
`${verdictIcon(single.verdict)} "${single.title ?? single.widgetId}": ${single.verdict}${flagPart}`,
`${verdictIcon(single.verdict)} "${single.title ?? single.widgetId}": ${single.verdict}${seriesPart}${flagPart}`,
]
if (single.note) lines.push(single.note)
if (single.verdict === "suspicious" || single.verdict === "broken") {
Expand Down Expand Up @@ -1093,8 +1105,9 @@ export function validationDoc(
doc.list(
summary.inspected.map((entry) => {
const flagPart = entry.flags.length > 0 ? ` (${entry.flags.join(", ")})` : ""
const seriesPart = entry.seriesCount !== undefined ? `, ${entry.seriesCount} series` : ""
const notePart = entry.note ? `: ${entry.note}` : ""
return `${verdictIcon(entry.verdict)} ${entry.widgetId} "${entry.title ?? ""}": ${entry.verdict}${flagPart}${notePart}`
return `${verdictIcon(entry.verdict)} ${entry.widgetId} "${entry.title ?? ""}": ${entry.verdict}${seriesPart}${flagPart}${notePart}`
}),
),
)
Expand Down
41 changes: 40 additions & 1 deletion apps/ai/src/mcp/tools/create-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"
import { normalizeGroupBy } from "./create-dashboard"
import { dataSourceQuerySet } from "@maple/widgets/dashboard"
import { buildSimpleWidgets, normalizeGroupBy } from "./create-dashboard"

describe("normalizeGroupBy", () => {
it("accepts query_data spellings", () => {
Expand All @@ -15,3 +16,41 @@ describe("normalizeGroupBy", () => {
expect(normalizeGroupBy("resource.host.name", "metrics")).toBe("resource.host.name")
})
})

describe("buildSimpleWidgets", () => {
const cpu = {
title: "Idle CPU by host",
source: "metrics" as const,
metric_name: "system.cpu.utilization",
metric_type: "gauge" as const,
group_by: "resource.host.name",
where: 'attr.state = "idle"',
unit: "percent",
}

it("accepts a resource.<key> group_by for metrics", () => {
expect(typeof buildSimpleWidgets([cpu])).not.toBe("string")
expect(buildSimpleWidgets([{ ...cpu, source: "logs" }])).toContain(
'invalid group_by "resource.host.name"',
)
})

it("AND-s a where clause with service_name into the query draft", () => {
const widgets = buildSimpleWidgets([{ ...cpu, service_name: "node" }])
const draft = typeof widgets === "string" ? undefined : dataSourceQuerySet(widgets[0]?.dataSource)
expect(draft?.queries[0]?.whereClause).toBe('service.name = "node" AND attr.state = "idle"')
})

it("quotes service_name so a double quote cannot open a second clause", () => {
const widgets = buildSimpleWidgets([{ ...cpu, where: undefined, service_name: 'x" AND attr.a = "1' }])
const draft = typeof widgets === "string" ? undefined : dataSourceQuerySet(widgets[0]?.dataSource)
expect(draft?.queries[0]?.whereClause).toBe(`service.name = 'x" AND attr.a = "1'`)
})

it("rejects a where clause on a list widget instead of dropping it", () => {
const result = buildSimpleWidgets([
{ title: "Recent logs", source: "logs", visualization: "list", where: 'attr.a = "1"' },
])
expect(result).toContain("where is not supported on list widgets")
})
})
25 changes: 20 additions & 5 deletions apps/ai/src/mcp/tools/create-dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./types"
import { Effect, Schema } from "effect"
import { CreateDashboardOutput } from "@maple/domain/mcp-outputs"
import { quoteWhereValue } from "@maple/domain/where-clause"
import { CurrentMcpTenant } from "../lib/query-warehouse"
import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService"
import {
DashboardTemplateParameterKey,
PortableDashboardDocument,
WIDGET_UNIT_TOKENS,
defaultWidgetLayout,
findNextPosition,
} from "@maple/domain/http"
Expand Down Expand Up @@ -55,7 +57,9 @@ const SimpleWidgetSpec = Schema.Struct({
metric_type: Schema.optionalKey(Schema.Literals(QUERY_BUILDER_METRIC_TYPES)),
service_name: Schema.optionalKey(Schema.String),
group_by: Schema.optionalKey(Schema.String),
unit: Schema.optionalKey(Schema.String),
/** A whereClause in the query-builder grammar, AND-ed with `service_name`. */
where: Schema.optionalKey(Schema.String),
unit: Schema.optionalKey(Schema.Literals(WIDGET_UNIT_TOKENS)),
})
type SimpleWidgetSpec = typeof SimpleWidgetSpec.Type

Expand Down Expand Up @@ -140,7 +144,13 @@ function simpleSpecToWidget(
const metricType = spec.metric_type

const metric = spec.metric ?? (source === "metrics" ? "avg" : "count")
const where = spec.service_name ? `service.name = "${spec.service_name}"` : ""
const extraWhere = spec.where?.trim() ?? ""
const where = [
spec.service_name ? `service.name = ${quoteWhereValue(spec.service_name)}` : "",
extraWhere,
]
.filter((clause) => clause !== "")
.join(" AND ")

let groupBy: string[]
if (spec.group_by) {
Expand Down Expand Up @@ -192,6 +202,9 @@ function simpleSpecToWidget(
}

if (viz === "list") {
if (extraWhere !== "") {
return `Widget "${spec.title}": where is not supported on list widgets; use service_name, or a chart/table.`
}
if (source === "logs") {
return {
id,
Expand Down Expand Up @@ -277,7 +290,7 @@ function computeAutoLayout(
return placed.map((widget) => widget.layout)
}

function buildSimpleWidgets(specs: ReadonlyArray<SimpleWidgetSpec>): WidgetDef[] | string {
export function buildSimpleWidgets(specs: ReadonlyArray<SimpleWidgetSpec>): WidgetDef[] | string {
const layouts = computeAutoLayout(specs)
const widgets: WidgetDef[] = []
const errors: string[] = []
Expand Down Expand Up @@ -354,9 +367,11 @@ export function registerCreateDashboardTool(server: McpToolRegistrar) {
),
widgets: P.optionalJson(
Schema.Array(SimpleWidgetSpec),
`Simplified widget specs, one per widget: { title, visualization?: ${SIMPLE_SPEC_VISUALIZATIONS.join("|")}, source: ${QUERY_BUILDER_DATA_SOURCES.join("|")}, metric?, metric_name?, metric_type?, service_name?, group_by?, unit? }. ` +
`Simplified widget specs, one per widget: { title, visualization?: ${SIMPLE_SPEC_VISUALIZATIONS.join("|")}, source: ${QUERY_BUILDER_DATA_SOURCES.join("|")}, metric?, metric_name?, metric_type?, service_name?, group_by?, where?, unit?: ${WIDGET_UNIT_TOKENS.join("|")} }. ` +
`Not the query_data vocabulary: group_by is ${groupByDoc("traces")} for traces, ${groupByDoc("logs")} for logs, ${groupByDoc("metrics")} for metrics, ${groupByDoc("product_events")} for product_events. ` +
"table needs a group_by; list shows recent traces or logs. Charts default to group_by service.name.",
"table needs a group_by; list shows recent traces or logs. Charts default to group_by service.name. " +
'where is a whereClause (describe_dashboard_schema "queries"), e.g. attr.state = "idle" to read one state of a per-state metric like system.cpu.utilization (unscoped it averages to about 1/states). ' +
"unit percent expects a 0-1 fraction, percent_100 a 0-100 value.",
),
dashboard_json: optionalJsonText(
PortableDashboardDocument,
Expand Down
30 changes: 1 addition & 29 deletions apps/web/src/components/infra/constants.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,5 @@
import { toEpochMs } from "@maple/ui/lib/time-format"

// Shared controls for the infra detail routes. Per-resource metric tabs/strips
// stay in their own routes (genuinely page-specific); only the time-range
// vocabulary is common.

export const TIME_PRESETS = [
{ value: "15m", label: "Last 15 minutes" },
{ value: "1h", label: "Last hour" },
{ value: "6h", label: "Last 6 hours" },
{ value: "12h", label: "Last 12 hours" },
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
] as const

const BUCKET_SECONDS: Record<string, number> = {
"15m": 15,
"1h": 60,
"6h": 300,
"12h": 600,
"24h": 900,
"7d": 3600,
} satisfies Record<string, number>

/** Chart bucket width for a preset; falls back to 60s for anything unrecognized. */
export function bucketSecondsFor(preset: string): number {
return BUCKET_SECONDS[preset] ?? 60
}

/**
* Bucket widths a kubelet-sampled series can honestly be drawn at. The floor is
* the scrape interval — anything finer would draw gaps, not resolution.
Expand All @@ -48,8 +21,7 @@ const BUCKET_LADDER: ReadonlyArray<number> = [

/**
* Chart bucket for an arbitrary window: about a hundred points, snapped up to
* the ladder. The preset table above can't serve a page whose window lives in
* the URL, where a custom range has no preset to look up.
* the ladder, so a custom range in the URL needs no preset to look up.
*/
export function bucketSecondsForRange(startTime: string, endTime: string): number {
const windowSeconds = Math.max((toEpochMs(endTime) - toEpochMs(startTime)) / 1000, 60)
Expand Down
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@
},
// Single-module Workers: the root alchemy.run.ts imports src/worker.ts, and
// alchemy bundles it, so it is the app's one entry.
// The tool playground config is run by path from scripts/tool-playground.sh.
"apps/ai": {
"entry": ["src/worker.ts"]
"entry": ["src/worker.ts", "vitest.playground.config.ts", "src/evals/playground/*.playground.ts"]
},
"apps/alerting": {
"entry": ["src/worker.ts"]
Expand Down
2 changes: 2 additions & 0 deletions packages/domain/src/mcp-outputs/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const WidgetInspectionEntry = Schema.Struct({
verdict: WidgetInspectionVerdict,
flags: Schema.Array(InspectChartFlag),
note: Schema.optionalKey(Schema.String),
/** Series (groups, for a breakdown) in the widget's largest query, so a `group_by` split is visible. */
seriesCount: Schema.optionalKey(Schema.Number),
})

/** The automatic check the mutation tools run on the widgets they wrote. */
Expand Down
Loading
Loading