mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// Auto-name generators — turn what the user has configured so far into a name a
|
|
// human would have written. Paired with `useAutoName`, which decides WHEN a
|
|
// generated name is allowed to win; these functions only decide WHAT it says.
|
|
//
|
|
// Two rules keep the output honest:
|
|
// • Only describe what is actually configured. A half-built panel yields a
|
|
// shorter name, never a speculative one, and an empty string when there is
|
|
// nothing to say (the field then shows its placeholder).
|
|
// • Sentence templates come from i18n; the identifiers interpolated into them
|
|
// (column names, SQL aggregation functions, operators, stream names) are
|
|
// user data and stay verbatim.
|
|
|
|
/** Minimal shape of vue-i18n's `t` — avoids dragging the composer type in. */
|
|
type TranslateFn = (_key: string, _params?: Record<string, unknown>) => string;
|
|
|
|
const MAX_MEASURES = 2;
|
|
|
|
const capitalize = (value: string): string =>
|
|
value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value;
|
|
|
|
/**
|
|
* Collapse runs of whitespace and trim. The FULL name is kept — the header
|
|
* truncates it visually with CSS (an ellipsis in the saved value is wrong; the
|
|
* stored name must be the complete one).
|
|
*/
|
|
const tidy = (value: string): string => value.replace(/\s+/g, " ").trim();
|
|
|
|
// ── Dashboard panels ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A panel field as stored in `queries[].fields.{x,y,breakdown}`. TWO shapes
|
|
* exist and both are live:
|
|
*
|
|
* • BUILDER (what usePanelFields writes today) — the aggregation is
|
|
* `functionName` and the column lives inside `args`, as
|
|
* `args[{type:"field"}].value.field`. There is no `column` key at all.
|
|
* • LEGACY (older saved panels and the bundled dashboards, e.g.
|
|
* utils/rum/web_vitals.json) — flat `column` + `aggregationFunction`.
|
|
*
|
|
* Reading only one of them is how the first cut produced "_timestamp by Cost"
|
|
* for a sum(_timestamp): `aggregationFunction` was undefined on a builder
|
|
* field, so every measure silently degraded to its bare field name.
|
|
*/
|
|
interface PanelFieldArg {
|
|
type?: string;
|
|
/** A `function` arg nests another field expression — e.g. a cast. */
|
|
value?: { field?: string; args?: PanelFieldArg[] } | null;
|
|
}
|
|
|
|
interface PanelField {
|
|
label?: string;
|
|
alias?: string;
|
|
/** LEGACY shape. */
|
|
column?: string;
|
|
/** LEGACY shape. */
|
|
aggregationFunction?: string | null;
|
|
/** BUILDER shape. */
|
|
functionName?: string | null;
|
|
/** BUILDER shape. `false` marks the field AS the time column (see isTimeColumn). */
|
|
treatAsNonTimestamp?: boolean;
|
|
/** BUILDER shape. */
|
|
args?: PanelFieldArg[];
|
|
}
|
|
|
|
/** The slice of a panel's state that naming reads (`dashboardPanelData`). */
|
|
interface PanelAutoNameInput {
|
|
data?: {
|
|
type?: string;
|
|
queries?: Array<{
|
|
customQuery?: boolean;
|
|
fields?: {
|
|
stream?: string;
|
|
x?: PanelField[];
|
|
y?: PanelField[];
|
|
breakdown?: PanelField[];
|
|
};
|
|
}>;
|
|
};
|
|
layout?: { currentQueryIndex?: number };
|
|
}
|
|
|
|
/** Chart types that carry no field config at all — nothing to derive a name from. */
|
|
const FIELDLESS_CHART_TYPES = ["markdown", "html", "custom_chart"];
|
|
|
|
/** Time bucketing is HOW a chart is drawn, not WHAT it measures. */
|
|
const TIME_AGGREGATIONS = ["histogram", "date_bin"];
|
|
|
|
/**
|
|
* The time column is deployment-configurable (`zoConfig.timestamp_column`) —
|
|
* `_timestamp` is only its default. usePanelFields already compares against the
|
|
* configured value, so this must too; hardcoding `_timestamp` would silently
|
|
* mis-name every panel on a deployment that renames it.
|
|
*/
|
|
export const DEFAULT_TIMESTAMP_COLUMN = "_timestamp";
|
|
|
|
export interface PanelAutoNameOptions {
|
|
/** From `store.state.zoConfig.timestamp_column`. */
|
|
timestampColumn?: string;
|
|
}
|
|
|
|
const aggregationOf = (field: PanelField | null | undefined): string | null =>
|
|
field?.functionName ?? field?.aggregationFunction ?? null;
|
|
|
|
/**
|
|
* First field named anywhere in an argument list, descending through nested
|
|
* function args so a wrapped column (sum of a cast) still names the panel.
|
|
*/
|
|
const columnInArgs = (args: PanelFieldArg[] | undefined): string => {
|
|
for (const arg of args ?? []) {
|
|
if (arg?.type === "field" && arg?.value?.field) return arg.value.field;
|
|
if (arg?.type === "function") {
|
|
const nested = columnInArgs(arg?.value?.args);
|
|
if (nested) return nested;
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
/** The underlying column, from whichever of the two shapes this field uses. */
|
|
const columnOf = (field: PanelField | null | undefined): string => {
|
|
return (columnInArgs(field?.args) || field?.column || field?.alias || "").trim();
|
|
};
|
|
|
|
/**
|
|
* How to refer to a field in prose. The builder's `label` is the humanised name
|
|
* the dashboard already shows on axes and legends ("Cost"), so it reads best;
|
|
* the raw column is the fallback. Generated aliases (x_axis_1) are never a name
|
|
* a human would recognise, so they lose to the column.
|
|
*/
|
|
const fieldName = (field: PanelField | null | undefined): string => {
|
|
const label = (field?.label || "").trim();
|
|
if (label) return label;
|
|
return columnOf(field);
|
|
};
|
|
|
|
/**
|
|
* True when this field IS the time column. Builder fields record that decision
|
|
* at add-time as `treatAsNonTimestamp: false`, which is authoritative even if
|
|
* the deployment's configured column changes later; otherwise fall back to
|
|
* comparing the column against the configured name.
|
|
*/
|
|
const isTimeColumn = (field: PanelField | null | undefined, timestampColumn: string): boolean => {
|
|
if (field?.treatAsNonTimestamp === false) return true;
|
|
return columnOf(field) === timestampColumn;
|
|
};
|
|
|
|
/**
|
|
* "Record count" for count(*)/count(<time column>); otherwise "Avg of Duration".
|
|
* Capitalised per measure, not once over the whole name — otherwise a two-
|
|
* measure panel reads "Sum of Cost, max of Calls", capitalised on the first
|
|
* word only.
|
|
*/
|
|
const measureLabel = (field: PanelField, t: TranslateFn, timestampColumn: string): string => {
|
|
const name = fieldName(field);
|
|
if (!name) return "";
|
|
const fn = aggregationOf(field);
|
|
if (!fn || TIME_AGGREGATIONS.includes(fn)) return capitalize(name);
|
|
if (fn === "count" && (columnOf(field) === "*" || isTimeColumn(field, timestampColumn)))
|
|
return capitalize(t("dashboard.autoName.recordCount"));
|
|
return capitalize(t("dashboard.autoName.measure", { fn, field: name }));
|
|
};
|
|
|
|
/**
|
|
* Build a panel title from its configured measures and breakdown, e.g.
|
|
* "Avg of duration by service" / "Record count" / "k8s_logs overview".
|
|
* Returns "" when the panel has nothing worth describing yet.
|
|
*/
|
|
export function buildPanelAutoName(
|
|
panelData: PanelAutoNameInput | null | undefined,
|
|
t: TranslateFn,
|
|
options: PanelAutoNameOptions = {},
|
|
): string {
|
|
const timestampColumn = options.timestampColumn || DEFAULT_TIMESTAMP_COLUMN;
|
|
const data = panelData?.data;
|
|
if (!data) return "";
|
|
|
|
const queryIndex = panelData?.layout?.currentQueryIndex ?? 0;
|
|
const query = data.queries?.[queryIndex];
|
|
const stream = (query?.fields?.stream || "").trim();
|
|
|
|
const streamFallback = () =>
|
|
stream ? tidy(capitalize(t("dashboard.autoName.streamOverview", { stream }))) : "";
|
|
|
|
if (data.type && FIELDLESS_CHART_TYPES.includes(data.type)) return streamFallback();
|
|
|
|
// A hand-written query exposes only generated aliases (x_axis_1, y_axis_1);
|
|
// naming a panel after those is worse than saying nothing about them.
|
|
if (query?.customQuery) return streamFallback();
|
|
|
|
const measures = (query?.fields?.y ?? [])
|
|
.map((field: PanelField) => measureLabel(field, t, timestampColumn))
|
|
.filter(Boolean);
|
|
|
|
if (measures.length === 0) return streamFallback();
|
|
|
|
// Name after the first couple of measures only — a short, readable name beats
|
|
// a complete one. A trailing "+N more" count just reads as clutter, so extra
|
|
// measures are dropped silently rather than tallied.
|
|
const subject = measures.slice(0, MAX_MEASURES).join(", ");
|
|
|
|
// Breakdown is the explicit "split by" axis; on charts without one, a
|
|
// non-time X field plays the same role. The time bucket is excluded by both
|
|
// its aggregation and its column, since an X field can carry the timestamp
|
|
// with no histogram on it at all.
|
|
const breakdown = fieldName(query?.fields?.breakdown?.[0]);
|
|
const xDimension = (query?.fields?.x ?? []).find(
|
|
(field: PanelField) =>
|
|
!TIME_AGGREGATIONS.includes(aggregationOf(field) ?? "") &&
|
|
!isTimeColumn(field, timestampColumn),
|
|
);
|
|
const dimension = breakdown || fieldName(xDimension);
|
|
|
|
// "Count of Cost by Cost" is noise, not a name — a dimension that repeats the
|
|
// measure adds nothing.
|
|
const name =
|
|
dimension && dimension !== subject
|
|
? t("dashboard.autoName.measureByDimension", { subject, dimension })
|
|
: subject;
|
|
|
|
return tidy(name);
|
|
}
|
|
|
|
// ── Alerts ─────────────────────────────────────────────────────────────────
|
|
|
|
// An alert name is an IDENTIFIER, not a sentence: the backend rejects
|
|
// `[:#?\s'"%&]` (RE_OFGA_UNSUPPORTED_NAME in src/common/utils/auth.rs, mirrored
|
|
// by AddAlert.schema's nameNoSpecialChars rule). So the alert templates join
|
|
// with underscores, comparison symbols are spelled out, and everything is run
|
|
// through `slugify` as a backstop — a generated name that the form would then
|
|
// reject as invalid is worse than no name at all.
|
|
const ALERT_NAME_UNSUPPORTED_CHARS = /[:#?\s'"%&]+/g;
|
|
|
|
/** Comparison symbols spelled out — `>=` is legal in a name but unreadable in one. */
|
|
const OPERATOR_WORDS: Record<string, string> = {
|
|
"=": "eq",
|
|
"==": "eq",
|
|
"!=": "neq",
|
|
"<>": "neq",
|
|
">": "gt",
|
|
">=": "gte",
|
|
"<": "lt",
|
|
"<=": "lte",
|
|
};
|
|
|
|
// Only the TRAILING underscore is trimmed: a leading one is meaningful here —
|
|
// OpenObserve's internal streams are named `_rundata`, `_rumdata`, and so on,
|
|
// so stripping it would rename the very streams most likely to be alerted on.
|
|
const slugify = (value: string): string =>
|
|
value.replace(ALERT_NAME_UNSUPPORTED_CHARS, "_").replace(/_{2,}/g, "_").replace(/_+$/g, "");
|
|
|
|
const operatorWord = (operator: string): string => OPERATOR_WORDS[operator] ?? operator;
|
|
|
|
interface AlertCondition {
|
|
column?: string;
|
|
operator?: string;
|
|
value?: unknown;
|
|
conditions?: AlertCondition[];
|
|
}
|
|
|
|
/** The slice of an alert form that naming reads. */
|
|
interface AlertAutoNameInput {
|
|
stream_name?: string;
|
|
is_real_time?: string | boolean;
|
|
query_condition?: {
|
|
type?: string;
|
|
aggregation?: { function?: string | null; having?: { column?: string } | null } | null;
|
|
conditions?: AlertCondition;
|
|
} | null;
|
|
}
|
|
|
|
/** First leaf condition of a (possibly nested) condition group. */
|
|
const firstLeafCondition = (group: AlertCondition | undefined): AlertCondition | null => {
|
|
for (const node of group?.conditions ?? []) {
|
|
if (Array.isArray(node?.conditions)) {
|
|
const nested = firstLeafCondition(node);
|
|
if (nested) return nested;
|
|
} else if (node?.column) {
|
|
return node;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// English on purpose, and NOT in the catalogue: the result is slugified into the
|
|
// alert's `name`, which is persisted and shared across the org. Translating these
|
|
// made a German user's alert save with German text where an English user's saved
|
|
// `anomaly_<stream>` — same alert, different stored identity per locale.
|
|
const ALERT_NAME_PATTERN = {
|
|
anomaly: (p: Record<string, string>) => `anomaly_${p.stream}`,
|
|
realTime: (p: Record<string, string>) => `realtime_${p.stream}`,
|
|
queryAlert: (p: Record<string, string>) => `${p.stream}_query_alert`,
|
|
streamAlert: (p: Record<string, string>) => `${p.stream}_alert`,
|
|
aggregation: (p: Record<string, string>) => `${p.stream}_${p.fn}_${p.field}`,
|
|
condition: (p: Record<string, string>) => `${p.stream}_${p.column}_${p.operator}_${p.value}`,
|
|
} as const;
|
|
|
|
type AlertNamePattern = keyof typeof ALERT_NAME_PATTERN;
|
|
|
|
/**
|
|
* Build an alert name from its stream and what it actually watches, e.g.
|
|
* "anomaly_k8s_logs" / "k8s_logs_status_gte_500" / "k8s_logs_avg_latency".
|
|
* Returns "" until a stream is chosen — before that there is nothing to name
|
|
* the alert after.
|
|
*/
|
|
export function buildAlertAutoName(
|
|
formData: AlertAutoNameInput | null | undefined,
|
|
_t?: TranslateFn,
|
|
): string {
|
|
const stream = (formData?.stream_name || "").trim();
|
|
if (!stream) return "";
|
|
|
|
const name = (key: AlertNamePattern, params: Record<string, unknown>) =>
|
|
slugify(tidy(ALERT_NAME_PATTERN[key](params as Record<string, string>)));
|
|
|
|
const mode = String(formData?.is_real_time ?? "false");
|
|
if (mode === "anomaly") return name("anomaly", { stream });
|
|
if (mode === "true") return name("realTime", { stream });
|
|
|
|
const queryCondition = formData?.query_condition ?? {};
|
|
|
|
// SQL/PromQL alerts are defined by a query the name can't summarise; the
|
|
// stream plus the alert's nature is the most it can honestly say.
|
|
if (queryCondition.type === "sql" || queryCondition.type === "promql") {
|
|
return name("queryAlert", { stream });
|
|
}
|
|
|
|
const aggregation = queryCondition.aggregation;
|
|
const aggregationColumn = (aggregation?.having?.column || "").trim();
|
|
if (aggregationColumn && aggregation?.function) {
|
|
return name("aggregation", { stream, fn: aggregation.function, field: aggregationColumn });
|
|
}
|
|
|
|
const condition = firstLeafCondition(queryCondition.conditions);
|
|
if (
|
|
condition?.column &&
|
|
condition.operator &&
|
|
condition.value !== "" &&
|
|
condition.value != null
|
|
) {
|
|
return name("condition", {
|
|
stream,
|
|
column: condition.column,
|
|
operator: operatorWord(condition.operator),
|
|
value: String(condition.value),
|
|
});
|
|
}
|
|
|
|
return name("streamAlert", { stream });
|
|
}
|