diff --git a/docker-compose.yaml b/docker-compose.yaml index b6c4239..698c53a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,11 @@ services: volumes: - ./dist:/var/lib/grafana/plugins/parseable-parseable-datasource - ./provisioning:/etc/grafana/provisioning + cap_add: + - SYS_PTRACE + security_opt: + - seccomp:unconfined + - apparmor:unconfined depends_on: - alertmanager diff --git a/src/components/PromBuilder.tsx b/src/components/PromBuilder.tsx new file mode 100644 index 0000000..e20ead1 --- /dev/null +++ b/src/components/PromBuilder.tsx @@ -0,0 +1,405 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2, SelectableValue, TimeRange } from '@grafana/data'; +import { Button, Icon, RadioButtonGroup, Select, useStyles2 } from '@grafana/ui'; +import { DataSource } from '../datasource'; +import { PromLabelMatcher } from '../types'; + +interface PromBuilderProps { + datasource: DataSource; + streamName: string; + metricNames: string[]; + labels: string[]; + metric?: string; + matchers: PromLabelMatcher[]; + range?: boolean; + instant?: boolean; + timeRange?: TimeRange; + onChange: (next: { + metric?: string; + matchers: PromLabelMatcher[]; + range?: boolean; + instant?: boolean; + queryText: string; + }) => void; +} + +const OPERATORS: Array> = [ + { label: '=', value: '=' }, + { label: '!=', value: '!=' }, + { label: '=~', value: '=~' }, + { label: '!~', value: '!~' }, +]; + +const TYPE_OPTIONS = [ + { label: 'Range', value: 'range' as const }, + { label: 'Instant', value: 'instant' as const }, + { label: 'Both', value: 'both' as const }, +]; + +const LEGAL_METRIC_IDENT = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; +const LEGAL_LABEL_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +// PromQL 3.0 UTF-8 selector syntax: +// - Classic metric/label idents → `metric{label="value"}` +// - Names with chars outside the ident grammar (dots, slashes, etc.) → +// `{"metric.name", "label.name"="value"}` (quoted form, metric becomes +// an implicit __name__ matcher inside the braces). +function buildPromQL(metric: string | undefined, matchers: PromLabelMatcher[]): string { + const active = matchers.filter((m) => m.label && m.value !== undefined); + if (!metric && active.length === 0) { + return ''; + } + // Quoting rules from the PromQL grammar: + // \\ → backslash, \" → quote, \n → LF, \r → CR, \t → tab. + // Escape backslash first so subsequent sequences aren't double-escaped. + const quote = (s: string) => + `"${s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t')}"`; + const metricLegal = !metric || LEGAL_METRIC_IDENT.test(metric); + const labelsLegal = active.every((m) => LEGAL_LABEL_IDENT.test(m.label)); + + if (metricLegal && labelsLegal) { + if (active.length === 0) { + return metric || ''; + } + const inner = active.map((m) => `${m.label}${m.operator}${quote(m.value)}`).join(', '); + return `${metric || ''}{${inner}}`; + } + + // UTF-8 form — quote everything that's not a legal identifier. + const parts: string[] = []; + if (metric) { + parts.push(quote(metric)); + } + active.forEach((m) => { + const lhs = LEGAL_LABEL_IDENT.test(m.label) ? m.label : quote(m.label); + parts.push(`${lhs}${m.operator}${quote(m.value)}`); + }); + return `{${parts.join(', ')}}`; +} + +export const PromBuilder: React.FC = ({ + datasource, + streamName, + metricNames, + labels, + metric, + matchers, + range, + instant, + timeRange, + onChange, +}) => { + const styles = useStyles2(getStyles); + const [optionsOpen, setOptionsOpen] = useState(false); + // Per-label cached value lists. Keyed by label name; value is the array + // returned by /label//values scoped to the current metric. + const [valueCache, setValueCache] = useState>({}); + + const metricOptions = useMemo>>( + () => metricNames.map((n) => ({ label: n, value: n })), + [metricNames] + ); + const labelOptions = useMemo>>( + () => labels.filter((l) => l !== '__name__').map((l) => ({ label: l, value: l })), + [labels] + ); + + const typeValue = instant && range ? 'both' : instant ? 'instant' : 'range'; + + const emit = useCallback( + (nextMetric?: string, nextMatchers?: PromLabelMatcher[], nextRange?: boolean, nextInstant?: boolean) => { + const m = nextMetric !== undefined ? nextMetric : metric; + const list = nextMatchers ?? matchers; + const r = nextRange !== undefined ? nextRange : range; + const i = nextInstant !== undefined ? nextInstant : instant; + onChange({ + metric: m, + matchers: list, + range: r, + instant: i, + queryText: buildPromQL(m, list), + }); + }, + [metric, matchers, range, instant, onChange] + ); + + // Load values for a label whenever a row picks one (and scope to the + // currently selected metric so the dropdown shows only relevant values). + const loadLabelValues = useCallback( + async (label: string) => { + if (!streamName || !label || valueCache[label]) { + return; + } + const match = metric ? [metric] : undefined; + const opts: { match?: string[]; start?: number; end?: number; limit?: number } = { + match, + limit: 1000, + }; + if (timeRange) { + opts.start = Math.floor(timeRange.from.valueOf() / 1000); + opts.end = Math.floor(timeRange.to.valueOf() / 1000); + } + const values = await datasource.getPromLabelValues(streamName, label, opts); + setValueCache((prev) => ({ ...prev, [label]: values })); + }, + [datasource, streamName, metric, timeRange, valueCache] + ); + + // Reset cached values when metric or time range changes — both scope the + // /label/{name}/values response, so stale entries would mislead dropdowns. + useEffect(() => { + setValueCache({}); + }, [metric, timeRange?.from?.valueOf(), timeRange?.to?.valueOf()]); + + const onMetricChange = (v: SelectableValue) => { + emit(v?.value ?? undefined, matchers, range, instant); + }; + + const onMatcherChange = (idx: number, patch: Partial) => { + // Touching the always-rendered placeholder row materializes it into the + // real matchers list. + if (idx >= matchers.length) { + const seeded: PromLabelMatcher = { label: '', operator: '=', value: '', ...patch }; + emit(metric, [...matchers, seeded], range, instant); + return; + } + const next = matchers.map((m, i) => (i === idx ? { ...m, ...patch } : m)); + emit(metric, next, range, instant); + }; + + const onAddMatcher = () => { + const next = [...matchers, { label: '', operator: '=' as const, value: '' }]; + emit(metric, next, range, instant); + }; + + const onRemoveMatcher = (idx: number) => { + // Placeholder row (idx beyond real list) — clearing it just resets it + // visually; nothing to remove from state. + if (idx >= matchers.length) { + return; + } + emit(metric, matchers.filter((_, i) => i !== idx), range, instant); + }; + + // Always render at least one matcher row so the user can pick a label/value + // without first clicking "+". Mirrors the Prometheus plugin's behavior. + const displayMatchers: PromLabelMatcher[] = + matchers.length > 0 ? matchers : [{ label: '', operator: '=', value: '' }]; + + const onTypeChange = (mode: 'range' | 'instant' | 'both') => { + const r = mode === 'range' || mode === 'both'; + const i = mode === 'instant' || mode === 'both'; + emit(metric, matchers, r, i); + }; + + const livePromQL = useMemo(() => buildPromQL(metric, matchers), [metric, matchers]); + + return ( +
+
+
+
Metric
+ { + const label = v?.value ?? ''; + onMatcherChange(idx, { label, value: '' }); + if (label) { + loadLabelValues(label); + } + }} + placeholder="Select label" + width={18} + menuPlacement="bottom" + /> + ({ label: v, value: v }))} + value={m.value ? { label: m.value, value: m.value } : null} + onChange={(v) => onMatcherChange(idx, { value: v?.value ?? '' })} + onOpenMenu={() => loadLabelValues(m.label)} + placeholder="Select value" + allowCustomValue + onCreateOption={(v) => onMatcherChange(idx, { value: v })} + width={20} + menuPlacement="bottom" + /> +
+ ))} +
+
+ + +
+
Raw query
+
+ {livePromQL || Select a metric or label filter to build a query} +
+
+ +
+
setOptionsOpen(!optionsOpen)}> + + Options + {!optionsOpen && Type: {typeValue === 'both' ? 'Both' : typeValue === 'instant' ? 'Instant' : 'Range'}} +
+ {optionsOpen && ( +
+
+ Type + +
+
+ )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + root: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + width: '100%', + }), + row: css({ + display: 'flex', + gap: theme.spacing(2), + alignItems: 'flex-start', + flexWrap: 'wrap', + }), + metricCell: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }), + filtersCell: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + flex: 1, + minWidth: 0, + }), + cellLabel: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + matchersRow: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + alignItems: 'center', + }), + matcherGroup: css({ + display: 'flex', + gap: theme.spacing(0.25), + alignItems: 'center', + }), + emptyHint: css({ + color: theme.colors.text.disabled, + fontStyle: 'italic', + fontSize: theme.typography.bodySmall.fontSize, + }), + rawQueryRow: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + borderTop: `1px solid ${theme.colors.border.weak}`, + paddingTop: theme.spacing(1), + }), + rawQueryLabel: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + rawQueryBox: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + background: theme.colors.background.secondary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: theme.spacing(0.75, 1), + color: theme.colors.text.primary, + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + minHeight: theme.spacing(4), + }), + rawQueryPlaceholder: css({ + color: theme.colors.text.disabled, + fontStyle: 'italic', + fontFamily: theme.typography.fontFamily, + }), + optionsRow: css({ + display: 'flex', + flexDirection: 'column', + borderTop: `1px solid ${theme.colors.border.weak}`, + paddingTop: theme.spacing(1), + }), + optionsHeader: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + userSelect: 'none', + }), + optionsSummary: css({ + marginLeft: theme.spacing(1), + color: theme.colors.text.primary, + }), + optionsBody: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(2), + marginTop: theme.spacing(1), + flexWrap: 'wrap', + }), + optionItem: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + optionLabel: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), +}); diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 740480c..657a467 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -21,6 +21,7 @@ import { QueryEditorMode, StreamStatsResponse, MetricInfo, + PromLabelMatcher, } from '../types'; import { buildFieldTypeMap, FieldTypeMap, typeDisplayName, getAggregateOptions } from '../utils/fieldTypes'; import { buildSqlFromFilters, buildMonitorSql } from '../utils/queryBuilder'; @@ -31,10 +32,12 @@ import { clearPromqlCompletionCaches, } from '../utils/promqlCompletions'; import { ensurePromqlHoverProvider } from '../utils/promqlHover'; +import { setupSqlEditor, updateSqlSchema } from '../utils/sqlCompletions'; import { attachPromqlErrorMarkers } from '../utils/promqlParser'; import { getPromqlHistory } from '../utils/promqlHistory'; import { FilterBuilder } from './FilterBuilder'; import { StreamInfoPanel } from './StreamInfoPanel'; +import { PromBuilder } from './PromBuilder'; const ALL_ROWS_VALUE = ''; @@ -203,12 +206,16 @@ interface Props extends QueryEditorProps = ({ datasource, onChange, onRunQuery, query, app }) => { +export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query, app, range: timeRange }) => { const styles = useStyles2(getStyles); const isAlerting = app === CoreApp.UnifiedAlerting || app === CoreApp.CloudAlerting; @@ -217,7 +224,14 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ // and Alerting uses its own preview / evaluate controls. const isDashboard = app === CoreApp.Dashboard || app === CoreApp.PanelEditor; const rawEditorMode = query.editorMode || datasource.defaultEditorMode || 'builder'; - const editorMode: QueryEditorMode = isAlerting ? 'monitor' : rawEditorMode === 'promql' ? 'promql' : 'builder'; + // Auto-run disabled across all surfaces (Explore, Dashboard/Panel-edit, + // Alerting). User explicitly triggers execution via Grafana's Run button + // (Explore), the local Run queries button (Dashboard), or the preview / + // evaluate flow (Alerting). Builder/PromBuilder/SQL/PromQL/Monitor edits + // mutate query state only — they never fire onRunQuery. + const autoRunQuery = useCallback(() => { + // no-op + }, []); const filters = query.filters || []; const selectedColumns = query.selectedColumns || []; @@ -227,6 +241,18 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const [schemaFields, setSchemaFields] = useState([]); const [stats, setStats] = useState({}); const [telemetryType, setTelemetryType] = useState(); + const isMetricsStream = telemetryType === 'metrics'; + // Toggle value clamped by telemetry: metrics streams allow Builder/PromQL, + // logs/traces allow Builder/SQL. A stored mode that doesn't fit the current + // stream falls back to Builder so the UI never shows an editor for a mode + // the dataset cannot serve. + const editorMode: QueryEditorMode = isAlerting + ? 'monitor' + : rawEditorMode === 'promql' && isMetricsStream + ? 'promql' + : rawEditorMode === 'code' && !isMetricsStream + ? 'code' + : 'builder'; const [metricsList, setMetricsList] = useState([]); const [promLabels, setPromLabels] = useState([]); const [promMetricNames, setPromMetricNames] = useState([]); @@ -281,18 +307,26 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const tType = result.info?.telemetryType; setTelemetryType(tType); - // Fetch metric names and PromQL metadata for metrics streams + // Fetch metric names and PromQL metadata for metrics streams. + // Scope to the current time range + limit so /labels and + // /label/__name__/values return only what's queryable in the + // currently selected window (matches Prometheus plugin behavior). + const promOpts: { start?: number; end?: number; limit?: number } = { limit: 1000 }; + if (timeRange) { + promOpts.start = Math.floor(timeRange.from.valueOf() / 1000); + promOpts.end = Math.floor(timeRange.to.valueOf() / 1000); + } if (tType === 'metrics') { datasource .getMetricNames(streamName) .then(setMetricsList) .catch(() => setMetricsList([])); datasource - .getPromLabels(streamName) + .getPromLabels(streamName, promOpts) .then(setPromLabels) .catch(() => setPromLabels([])); datasource - .getPromMetricNames(streamName) + .getPromMetricNames(streamName, promOpts) .then(setPromMetricNames) .catch(() => setPromMetricNames([])); datasource @@ -362,10 +396,10 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ } onChange(newQuery); if (editorMode !== 'monitor') { - onRunQuery(); + autoRunQuery(); } }, - [query, onChange, onRunQuery, editorMode, fieldTypeMap] + [query, onChange, autoRunQuery, editorMode, fieldTypeMap] ); // Handle mode change @@ -383,6 +417,18 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ if (query.queryLanguage !== 'promql') { newQuery.queryText = ''; } + } else if (mode === 'code' && selectedStream?.value) { + // Logs/traces SQL editor — seed with the Builder's generated SQL so + // switching modes doesn't drop the user's existing filter/column work. + newQuery.queryLanguage = 'sql'; + if (query.queryLanguage === 'promql' || !query.queryText) { + newQuery.queryText = buildSqlFromFilters( + selectedStream.value, + query.filters || [], + query.selectedColumns || [], + fieldTypeMap + ); + } } else if (mode === 'monitor' && selectedStream?.value) { const field = query.monitorField ?? ALL_ROWS_VALUE; const agg = query.monitorAggregate ?? 'COUNT'; @@ -392,9 +438,36 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ newQuery.queryText = buildMonitorSql(selectedStream.value, field, agg, newQuery.filters, fieldTypeMap); } onChange(newQuery); - onRunQuery(); + autoRunQuery(); + }, + [query, onChange, autoRunQuery, selectedStream, fieldTypeMap] + ); + + // PromBuilder (metrics, Explore) → persist metric+matchers and the + // composed PromQL selector into the query so toggling to PromQL Code mode + // shows the same selector. + const onPromBuilderChange = useCallback( + (next: { + metric?: string; + matchers: PromLabelMatcher[]; + range?: boolean; + instant?: boolean; + queryText: string; + }) => { + onChange({ + ...query, + promBuilderMetric: next.metric, + promBuilderMatchers: next.matchers, + range: next.range, + instant: next.instant, + queryText: next.queryText, + queryLanguage: 'promql', + }); + if (next.queryText.trim()) { + autoRunQuery(); + } }, - [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + [query, onChange, autoRunQuery] ); // Handle filter changes (builder mode) @@ -405,9 +478,9 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ } const sql = buildSqlFromFilters(selectedStream.value, newFilters, selectedColumns, fieldTypeMap); onChange({ ...query, filters: newFilters, queryText: sql }); - onRunQuery(); + autoRunQuery(); }, - [query, onChange, onRunQuery, selectedStream, selectedColumns, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, selectedColumns, fieldTypeMap] ); // Handle column selection changes (builder mode) @@ -419,9 +492,9 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const colNames = cols.map((c) => c.value!).filter(Boolean); const sql = buildSqlFromFilters(selectedStream.value, filters, colNames, fieldTypeMap); onChange({ ...query, selectedColumns: colNames, queryText: sql }); - onRunQuery(); + autoRunQuery(); }, - [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, filters, fieldTypeMap] ); // Handle monitor field change @@ -438,9 +511,9 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const sql = buildMonitorSql(selectedStream.value, field, agg, filters, fieldTypeMap); onChange({ ...query, monitorField: field, monitorAggregate: agg, queryText: sql }); - onRunQuery(); + autoRunQuery(); }, - [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, filters, fieldTypeMap] ); // Handle monitor aggregate change @@ -453,9 +526,9 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const field = query.monitorField ?? ALL_ROWS_VALUE; const sql = buildMonitorSql(selectedStream.value, field, agg, filters, fieldTypeMap); onChange({ ...query, monitorAggregate: agg, queryText: sql }); - onRunQuery(); + autoRunQuery(); }, - [query, onChange, onRunQuery, selectedStream, filters, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, filters, fieldTypeMap] ); // Handle filter changes in monitor mode @@ -468,12 +541,11 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const agg = query.monitorAggregate ?? 'COUNT'; const sql = buildMonitorSql(selectedStream.value, field, agg, newFilters, fieldTypeMap); onChange({ ...query, filters: newFilters, queryText: sql }); - onRunQuery(); + autoRunQuery(); }, - [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, fieldTypeMap] ); - const isMetricsStream = telemetryType === 'metrics'; // Sub-mode for metrics datasets in Alerting: 'builder' mirrors the logs / // traces monitor builder (Field + Aggregate + Filters → SQL); 'code' runs // the full PromQL editor. New metrics alerts default to Builder; alerts @@ -513,10 +585,10 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ // with no prior PromQL body leaves the editor empty and would cause // Alerting's /eval to reject the request with "query is empty". if (nextText.trim()) { - onRunQuery(); + autoRunQuery(); } }, - [query, onChange, onRunQuery, selectedStream, fieldTypeMap] + [query, onChange, autoRunQuery, selectedStream, fieldTypeMap] ); // -- Metrics alert handlers -- @@ -565,6 +637,13 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ // invocation of this effect is fine — it's synchronous localStorage. }, [selectedStream?.value, promMetricNames, promLabels, metricMetadata, datasource, query.queryText]); + // SQL Monaco completion provider reads from a module-scope holder. Push + // the current stream + schema in so column/table suggestions track the + // dataset the user has selected. + useEffect(() => { + updateSqlSchema(selectedStream?.value, schemaFields); + }, [selectedStream?.value, schemaFields]); + // Clear context on unmount so stale suggestions don't leak across editors. useEffect(() => { return () => { @@ -572,6 +651,23 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ }; }, []); + // Logs/traces SQL code editor — keystroke updates only; run on blur. + const onSqlCodeChange = useCallback( + (value: string) => { + onChange({ ...query, queryText: value, queryLanguage: 'sql' }); + }, + [query, onChange] + ); + const onSqlCodeBlur = useCallback( + (value: string) => { + onChange({ ...query, queryText: value, queryLanguage: 'sql' }); + if (value.trim()) { + autoRunQuery(); + } + }, + [query, onChange, autoRunQuery] + ); + // Per-keystroke in the Monaco PromQL editor — updates the query spec only. // Explicit run happens on blur (below), Shift+Enter, or the Run query button. const onMetricsCodeChange = useCallback( @@ -694,7 +790,7 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ return (
{/* Dataset Info Sidebar */} - {!isAlerting && selectedStream?.value && editorMode !== 'promql' && ( + {!isAlerting && selectedStream?.value && editorMode !== 'promql' && !isMetricsStream && ( )} @@ -729,13 +825,18 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ )} {!isAlerting && ( - + )}
- {/* Builder Mode */} - {editorMode === 'builder' && selectedStream?.value && ( + {/* Builder Mode — Logs/Traces SQL filter+column builder */} + {editorMode === 'builder' && selectedStream?.value && !isMetricsStream && (
{/* Filters */}
@@ -772,6 +873,24 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ
)} + {/* Builder Mode — Metrics Prometheus-style UI */} + {editorMode === 'builder' && selectedStream?.value && isMetricsStream && ( +
+ +
+ )} + {/* Monitor Mode — Metrics datasets in Alerting: Builder / Code toggle. */} {editorMode === 'monitor' && selectedStream?.value && isMetricsStream && (
@@ -949,7 +1068,7 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ const range = mode === 'range' || mode === 'both'; const instant = mode === 'instant' || mode === 'both'; onChange({ ...query, range, instant }); - onRunQuery(); + autoRunQuery(); }} size="sm" /> @@ -993,6 +1112,38 @@ export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQ )} + {/* SQL Mode (Explore, logs/traces) */} + {editorMode === 'code' && selectedStream?.value && ( +
+ +
+ )} + {/* Prompt to select stream */} {!selectedStream?.value && (
Select a dataset to start building your query
diff --git a/src/components/StreamInfoPanel.tsx b/src/components/StreamInfoPanel.tsx index 7c71a71..e3f5fb4 100644 --- a/src/components/StreamInfoPanel.tsx +++ b/src/components/StreamInfoPanel.tsx @@ -4,6 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { StreamStatsResponse } from '../types'; import { FieldTypeMap, ParsedType, typeLabel, typeDisplayName } from '../utils/fieldTypes'; +import { sanitizeBytes, sanitizeEventsCount } from '../utils/format'; interface StreamInfoPanelProps { fieldNames: string[]; @@ -50,15 +51,17 @@ export const StreamInfoPanel: React.FC = ({ fieldNames, fi
Events - {stats.ingestion?.count?.toLocaleString() ?? '-'} + + {typeof stats.ingestion?.count === 'number' ? sanitizeEventsCount(stats.ingestion.count) : '-'} +
Ingested - {stats.ingestion?.size ?? '-'} + {stats.ingestion?.size ? sanitizeBytes(stats.ingestion.size) : '-'}
Stored - {stats.storage?.size ?? '-'} + {stats.storage?.size ? sanitizeBytes(stats.storage.size) : '-'}
)} diff --git a/src/datasource.ts b/src/datasource.ts index 1a1b67a..fcbb795 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -44,7 +44,7 @@ export class DataSource extends DataSourceWithBackend { if (!streamName) { return []; @@ -522,6 +522,9 @@ export class DataSource extends DataSourceWithBackend { if (!streamName || !labelName) { return []; @@ -747,6 +750,9 @@ export class DataSource extends DataSourceWithBackend { + if (!+a) { + return '0 Bytes'; + } + const c = b < 0 ? 0 : b; + const d = Math.floor(Math.log(a) / Math.log(1024)); + return `${parseFloat((a / Math.pow(1024, d)).toFixed(c))} ${ + ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][d] + }`; +}; + +export const HumanizeNumber = (val: number, precision?: number): string => { + if (!isFinite(val)) { + return '0'; + } + const abs = Math.abs(val); + let scaled = val; + let suffix = ''; + if (abs >= 1e12) { + scaled = val / 1e12; + suffix = 't'; + } else if (abs >= 1e9) { + scaled = val / 1e9; + suffix = 'b'; + } else if (abs >= 1e6) { + scaled = val / 1e6; + suffix = 'm'; + } else if (abs >= 1e3) { + scaled = val / 1e3; + suffix = 'k'; + } + if (!suffix) { + // No abbreviation — return integer form to match numeral's `0.[0]a` on + // sub-thousand values (e.g. 999 → "999"). + return String(val); + } + const formatted = + precision != null + ? scaled.toFixed(precision) + : scaled % 1 === 0 + ? scaled.toFixed(0) + : scaled.toFixed(1).replace(/\.0$/, ''); + return (formatted + suffix).toUpperCase(); +}; + +export const sanitizeEventsCount = (val: any) => { + return typeof val === 'number' ? HumanizeNumber(val) : '0'; +}; + +export const bytesStringToInteger = (str: string) => { + if (!str || typeof str !== 'string') { + return null; + } + const chunks = str.split(' '); + return Array.isArray(chunks) && !isNaN(Number(chunks[0])) ? parseInt(chunks[0], 10) : null; +}; + +export const sanitizeBytes = (val: any) => { + // API may send raw number (Prism shape) or a string like "12345 Bytes". + // Accept either; fall back to 0 only when both paths fail. + if (typeof val === 'number' && isFinite(val)) { + return val > 0 ? formatBytes(val) : '0 Bytes'; + } + const size = bytesStringToInteger(val); + return size ? formatBytes(size) : '0 Bytes'; +}; diff --git a/src/utils/sqlCompletions.ts b/src/utils/sqlCompletions.ts new file mode 100644 index 0000000..eaadda9 --- /dev/null +++ b/src/utils/sqlCompletions.ts @@ -0,0 +1,176 @@ +import type { Monaco } from '@monaco-editor/react'; +import type { SchemaFields } from '../types'; + +// Shared context that the registered Monaco SQL completion provider reads +// from. The Monaco provider is registered once per page lifetime, so it must +// pull the live stream/schema from a module-scope holder updated by whichever +// QueryEditor instance is currently mounted. +let currentSchema: { + stream?: string; + fields: SchemaFields[]; +} = { fields: [] }; + +export function updateSqlSchema(stream: string | undefined, fields: SchemaFields[]) { + currentSchema = { stream, fields }; +} + +const SQL_KEYWORDS = [ + 'SELECT', + 'FROM', + 'WHERE', + 'GROUP BY', + 'ORDER BY', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'AS', + 'AND', + 'OR', + 'NOT', + 'IN', + 'LIKE', + 'ILIKE', + 'BETWEEN', + 'IS NULL', + 'IS NOT NULL', + 'ASC', + 'DESC', + 'DISTINCT', + 'JOIN', + 'INNER JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'FULL JOIN', + 'ON', + 'UNION', + 'UNION ALL', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'WITH', +]; + +const SQL_AGGREGATES = [ + { name: 'COUNT', snippet: 'COUNT($1)' }, + { name: 'COUNT(DISTINCT)', snippet: 'COUNT(DISTINCT $1)' }, + { name: 'SUM', snippet: 'SUM($1)' }, + { name: 'AVG', snippet: 'AVG($1)' }, + { name: 'MIN', snippet: 'MIN($1)' }, + { name: 'MAX', snippet: 'MAX($1)' }, + { name: 'APPROX_DISTINCT', snippet: 'APPROX_DISTINCT($1)' }, +]; + +const SQL_FUNCTIONS = [ + 'NOW', + 'CURRENT_TIMESTAMP', + 'DATE_TRUNC', + 'DATE_BIN', + 'EXTRACT', + 'TO_TIMESTAMP', + 'CAST', + 'COALESCE', + 'NULLIF', + 'LOWER', + 'UPPER', + 'LENGTH', + 'SUBSTRING', + 'TRIM', + 'REPLACE', + 'CONCAT', + 'ROUND', + 'FLOOR', + 'CEIL', + 'ABS', +]; + +let sqlProviderRegistered = false; + +export function setupSqlEditor(monaco: Monaco) { + if (sqlProviderRegistered) { + return; + } + sqlProviderRegistered = true; + + monaco.languages.registerCompletionItemProvider('sql', { + triggerCharacters: [' ', '.', ',', '('], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions: any[] = []; + const { stream, fields } = currentSchema; + + // Stream / dataset name (table) + if (stream) { + const quoted = `"${stream}"`; + suggestions.push({ + label: stream, + kind: monaco.languages.CompletionItemKind.Struct, + insertText: quoted, + detail: 'dataset', + sortText: '0_' + stream, + range, + }); + } + + // Field / column names — always double-quoted (matches Prism behavior). + fields.forEach((f) => { + suggestions.push({ + label: f.name, + kind: monaco.languages.CompletionItemKind.Field, + insertText: `"${f.name}"`, + detail: typeof f.data_type === 'string' ? `column · ${f.data_type}` : 'column', + sortText: '1_' + f.name, + range, + }); + }); + + // Aggregates + SQL_AGGREGATES.forEach((a) => { + suggestions.push({ + label: a.name, + kind: monaco.languages.CompletionItemKind.Function, + insertText: a.snippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: 'aggregate', + sortText: '2_' + a.name, + range, + }); + }); + + // Scalar functions + SQL_FUNCTIONS.forEach((fn) => { + suggestions.push({ + label: fn, + kind: monaco.languages.CompletionItemKind.Function, + insertText: fn + '($1)', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: 'function', + sortText: '3_' + fn, + range, + }); + }); + + // Keywords + SQL_KEYWORDS.forEach((kw) => { + suggestions.push({ + label: kw, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: kw, + detail: 'keyword', + sortText: '4_' + kw, + range, + }); + }); + + return { suggestions }; + }, + }); +}