diff --git a/README.md b/README.md index 8f8f67a..d629b9a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ AI Factory Operations Agent is an NVIDIA blueprint for deploying an extensible a Supported workflows include: - Read-only Kubernetes workload and cluster inspection. -- Prometheus queries and Grafana dashboard creation. +- Prometheus and VictoriaMetrics queries and Grafana dashboard creation. - Slurm job root-cause analysis from scheduler and log evidence. - Optional cluster-management integrations. - Sandboxed command execution with an auditable agent workflow. @@ -29,7 +29,7 @@ OpenShell isolates general agent command execution. Kubernetes, observability, S - Runtime: Kubernetes, `kubectl`, and Helm 3. - LLM: an OpenAI-compatible endpoint or a cluster capable of running chart-managed vLLM. - GPU/driver: required only when deploying chart-managed vLLM; requirements depend on the selected model profile. -- Optional services: Prometheus, Grafana, Slurm, or cluster-management endpoints for the corresponding modules. +- Optional services: Prometheus or VictoriaMetrics, Grafana, Slurm, or cluster-management endpoints for the corresponding modules. ## Getting Started diff --git a/docs/_static/install-builder.mjs b/docs/_static/install-builder.mjs index ddbb41c..dd3daf7 100644 --- a/docs/_static/install-builder.mjs +++ b/docs/_static/install-builder.mjs @@ -20,7 +20,7 @@ const fields = { lokiSecret: ['Loki Authorization Secret (optional)', ''], lokiKey: ['Loki Secret key', 'authorization'], grafanaUrl: ['Grafana URL, including serving path', '', 'https://grafana.example.com/grafana'], - grafanaUid: ['Prometheus datasource UID', 'prometheus'], grafanaSecret: ['Grafana Secret (username, password)', 'mosaic-grafana-auth'], + grafanaUid: ['Metrics datasource UID', 'prometheus'], grafanaType: ['Metrics datasource type', 'prometheus'], grafanaSecret: ['Grafana Secret (username, password)', 'mosaic-grafana-auth'], bcmHead: ['BCM SSH head host', '', 'user@bcm-head.example.com'], bcmSecret: ['BCM SSH Secret (id_ecdsa)', 'bcm-host-ssh-key'], evidenceRoot: ['Slurm evidence directory on the collector node', '', '/slurm'], evidenceNode: ['Slurm collector node hostname', '', 'slurm-login-node'], @@ -57,6 +57,7 @@ export function buildCommand(state) { } if (enabled.has('grafana')) { set('observability.grafanaUrl', value('grafanaUrl'), true); set('observability.grafanaDatasourceUid', value('grafanaUid'), true); + set('observability.grafanaDatasourceType', value('grafanaType'), true); set('observability.grafanaAuth.existingSecret', value('grafanaSecret'), true); } if (enabled.has('bcm')) { @@ -92,7 +93,7 @@ export function buildCommand(state) { const root = typeof document === 'undefined' ? null : document.querySelector('#install-command-builder'); if (root) { - const state = {inference: 'external', channel: 'devel', slurmBackend: 'bcm', modules: ['kubernetes']}; + const state = {inference: 'external', channel: 'devel', slurmBackend: 'bcm', grafanaType: 'prometheus', modules: ['kubernetes']}; const form = document.createElement('form'); form.addEventListener('submit', event => event.preventDefault()); const options = document.createElement('fieldset'); options.innerHTML = 'Installation options'; function select(key, label, choices) { @@ -140,12 +141,16 @@ if (root) { if (enabled.has('prometheus')) keys.push('prometheusUrl'); if (enabled.has('alertmanager')) keys.push('alertmanagerUrl'); if (enabled.has('loki')) keys.push('lokiUrl', 'lokiTenant', 'lokiSecret', 'lokiKey'); - if (enabled.has('grafana')) keys.push('grafanaUrl', 'grafanaUid', 'grafanaSecret'); + if (enabled.has('grafana')) keys.push('grafanaUrl', 'grafanaUid', 'grafanaType', 'grafanaSecret'); if (enabled.has('slurm')) settingsBody.append(select('slurmBackend', 'Slurm integration', [['bcm', 'Through BCM'], ['vanilla', 'Standalone Slurm']])); if (enabled.has('bcm')) keys.push('bcmHead', 'bcmSecret'); if (enabled.has('slurm') && state.slurmBackend === 'vanilla') keys.push('evidenceRoot', 'evidenceNode'); if (enabled.has('research')) keys.push('corpus'); for (const key of keys) { + if (key === 'grafanaType') { + settingsBody.append(select(key, fields[key][0], [['prometheus', 'Prometheus'], ['victoriametrics-metrics-datasource', 'VictoriaMetrics (native plugin)']])); + continue; + } const [label, initial, placeholder] = fields[key]; const wrapper = document.createElement('label'); wrapper.textContent = label; const input = document.createElement('input'); input.name = key; input.value = state[key] ?? initial; input.placeholder = placeholder || ''; input.addEventListener('input', () => {state[key] = input.value; update(false);}); wrapper.append(input); settingsBody.append(wrapper); diff --git a/docs/installation.md b/docs/installation.md index cc07ca3..0ff85bb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -202,7 +202,17 @@ All module changes below update an existing AI Factory Operations Agent release ### Observability -To connect AI Factory Operations Agent to an existing Prometheus service, run: +Connect to an existing Prometheus or VictoriaMetrics service with +`observability.prometheusUrl`. Choose the query base URL for your backend: + +| Backend | Example base URL | +| --- | --- | +| Prometheus | `http://kube-prometheus-stack-prometheus.prometheus.svc.cluster.local:9090` | +| Single-node VictoriaMetrics | `http://victoriametrics.monitoring.svc.cluster.local:8428` | +| VictoriaMetrics cluster | `http://vmselect.monitoring.svc.cluster.local:8481/select/0/prometheus` | + +Replace `0` with your tenant ID for a VictoriaMetrics cluster. Include any proxy +prefix and omit `/api/v1`. Set `PROMETHEUS_URL` to your endpoint: ```bash export PROMETHEUS_URL='http://kube-prometheus-stack-prometheus.prometheus.svc.cluster.local:9090' @@ -260,6 +270,7 @@ To connect AI Factory Operations Agent to an existing Grafana service, run: ```bash export GRAFANA_URL='http://kube-prometheus-stack-grafana.prometheus.svc.cluster.local/grafana' export GRAFANA_DATASOURCE_UID='prometheus' +export GRAFANA_DATASOURCE_TYPE='prometheus' export GRAFANA_USERNAME='admin' read -rsp 'Grafana password: ' GRAFANA_PASSWORD; echo @@ -275,6 +286,7 @@ helm upgrade mosaic "$MOSAIC_CHART" \ --set modules.grafana.enabled=true \ --set-string observability.grafanaUrl="$GRAFANA_URL" \ --set-string observability.grafanaDatasourceUid="$GRAFANA_DATASOURCE_UID" \ + --set-string observability.grafanaDatasourceType="$GRAFANA_DATASOURCE_TYPE" \ --set observability.grafanaAuth.existingSecret=mosaic-grafana-auth \ --wait \ --timeout 12m @@ -284,6 +296,11 @@ unset GRAFANA_PASSWORD `grafanaUrl` includes Grafana's configured serving path. Use the service root for a root-served Grafana or `/grafana` for the NMC deployment. +For an installed [native VictoriaMetrics datasource](https://docs.victoriametrics.com/victoriametrics/integrations/grafana/datasource/), +set `GRAFANA_DATASOURCE_UID` to its UID and +`GRAFANA_DATASOURCE_TYPE=victoriametrics-metrics-datasource`. +For a Prometheus datasource, keep `prometheus`. + ### Terminal To enable the optional browser terminal service, run: diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/index.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/index.ts index 8efb63c..186b4c8 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/index.ts +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/index.ts @@ -8,7 +8,7 @@ declare const Buffer: { from(value: string): { toString(encoding: string): strin type PanelSpec = { title: string; query: string; - datasource?: { type: "prometheus" | "loki"; uid: string }; + datasource?: { type: "prometheus" | "victoriametrics-metrics-datasource" | "loki"; uid: string }; unit?: string; visualization?: "timeseries" | "stat" | "gauge" | "table" | "logs"; legend?: string; @@ -157,12 +157,12 @@ async function fetchJson(url: string, options?: RequestInit) { return payload as Record; } -function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, datasourceUid: string) { +function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, defaultDatasource: NonNullable) { return { queries: [ { refId: "A", - datasource: panel.datasource || { type: "prometheus", uid: datasourceUid }, + datasource: panel.datasource || defaultDatasource, expr: panel.query, ...(panel.datasource?.type === "loki" ? {queryType: "range", maxLines: 1000} : {}), range: true, @@ -210,7 +210,7 @@ async function validatePanels( grafanaUrl: string, uiUrl: string, headers: Record, - datasourceUid: string, + defaultDatasource: NonNullable, panels: PanelSpec[], requireData: boolean, rangeMinutes: number, @@ -220,8 +220,8 @@ async function validatePanels( const datasources = await fetchJson(`${grafanaUrl}/api/datasources`, {headers}) as unknown as Array<{uid: string; type: string; name: string}>; for (const panel of panels) { - const selected = panel.datasource || {type: "prometheus", uid: datasourceUid}; - if (!["prometheus", "loki"].includes(selected.type) || !datasources.some(source => source.uid === selected.uid && source.type === selected.type)) { + const selected = panel.datasource || defaultDatasource; + if (!["prometheus", "victoriametrics-metrics-datasource", "loki"].includes(selected.type) || !datasources.some(source => source.uid === selected.uid && source.type === selected.type)) { issues.push({panelTitle: panel.title, query: panel.query, severity: "error", message: "Panel datasource UID or type is missing or unavailable. Select the intended datasource from availableDatasources and retry."}); continue; } @@ -232,10 +232,10 @@ async function validatePanels( const url = new URL(`${prometheusUrl}/api/v1/query`); url.searchParams.set("query", panel.query); try { - const payload = selected.type === "prometheus" ? await fetchJson(url.toString()) : {}; + const payload = selected.type !== "loki" ? await fetchJson(url.toString()) : {}; const data = payload.data as { result?: unknown[] } | undefined; const result = Array.isArray(data?.result) ? data.result : []; - if (requireData && selected.type === "prometheus" && result.length === 0) { + if (requireData && selected.type !== "loki" && result.length === 0) { issues.push({ panelTitle: panel.title, query: panel.query, @@ -248,7 +248,7 @@ async function validatePanels( const grafanaPayload = await fetchJson(`${grafanaUrl}/api/ds/query`, { method: "POST", headers, - body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, datasourceUid)), + body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, defaultDatasource)), }); frameCount = grafanaFrameCount(grafanaPayload); if (requireData && frameCount === 0) { @@ -271,7 +271,7 @@ async function validatePanels( const uiProxyPayload = await fetchJson(`${uiUrl}/api/grafana/proxy/api/ds/query`, { method: "POST", headers: { ...uiAuthHeaders(), Origin: uiUrl }, - body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, datasourceUid)), + body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, defaultDatasource)), }); const uiFrameCount = grafanaFrameCount(uiProxyPayload); if (requireData && uiFrameCount === 0) { @@ -339,7 +339,7 @@ function appendTimeRange(path: string, rangeMinutes: number) { return `${path}${separator}orgId=1&from=now-${Math.trunc(rangeMinutes)}m&to=now&kiosk`; } -function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { +function buildPanel(panel: PanelSpec, index: number, defaultDatasource: NonNullable) { const type = panel.visualization || "timeseries"; const width = type === "stat" || type === "gauge" ? 6 : 12; return { @@ -347,7 +347,7 @@ function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { type, title: panel.title, description: panel.description, - datasource: panel.datasource || { type: "prometheus", uid: datasourceUid }, + datasource: panel.datasource || defaultDatasource, gridPos: { h: type === "stat" || type === "gauge" ? 6 : 8, w: width, @@ -376,7 +376,7 @@ function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { }; } -function buildDashboard(title: string, panels: PanelSpec[], datasourceUid: string, rangeMinutes: number) { +function buildDashboard(title: string, panels: PanelSpec[], defaultDatasource: NonNullable, rangeMinutes: number) { return { id: null, uid: `mosaic-${Date.now().toString(36)}`, @@ -390,7 +390,7 @@ function buildDashboard(title: string, panels: PanelSpec[], datasourceUid: strin from: `now-${Math.max(1, Math.trunc(rangeMinutes))}m`, to: "now", }, - panels: panels.map((panel, index) => buildPanel(panel, index, datasourceUid)), + panels: panels.map((panel, index) => buildPanel(panel, index, defaultDatasource)), }; } @@ -403,6 +403,10 @@ export default definePluginEntry({ const prometheusUrl = resolveString(api.pluginConfig, "prometheusUrl"); const uiUrl = resolveString(api.pluginConfig, "uiUrl", DEFAULT_UI_URL); const datasourceUid = resolveString(api.pluginConfig, "datasourceUid"); + const defaultDatasource = { + uid: datasourceUid, + type: resolveString(api.pluginConfig, "datasourceType", "prometheus") as NonNullable["type"], + }; const headers = authHeaders(api.pluginConfig); const registerTool = (tool: Parameters[0]) => { if (isToolEnabled(api.pluginConfig, tool.name)) { @@ -445,7 +449,7 @@ export default definePluginEntry({ registerTool({ name: "grafana_dashboard_validate", label: "Validate Grafana Dashboard Queries", - description: "Validate dashboard panel PromQL or LogQL against the selected Grafana datasource before creating a dashboard.", + description: "Validate dashboard panel PromQL, MetricsQL or LogQL against the selected Grafana datasource before creating a dashboard.", parameters: { type: "object", additionalProperties: false, @@ -453,7 +457,7 @@ export default definePluginEntry({ preset: { type: "string", description: `Optional preset: ${Object.keys(PRESETS).join(", ")}.` }, panels: { type: "array", - description: "Panel specs: title, query (PromQL or LogQL), datasource {type: prometheus or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured Prometheus default.", + description: "Panel specs: title, query (PromQL, MetricsQL or LogQL), datasource {type: prometheus, victoriametrics-metrics-datasource or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured metrics default.", items: { type: "object" }, }, requireData: { type: "boolean", description: "If true, empty query results are validation errors. Default true." }, @@ -469,7 +473,7 @@ export default definePluginEntry({ grafanaUrl, uiUrl, headers, - datasourceUid, + defaultDatasource, panels, boolParam(rawParams.requireData, true), Math.max(1, Math.min(10080, numberParam(rawParams.rangeMinutes, 60))), @@ -481,7 +485,7 @@ export default definePluginEntry({ registerTool({ name: "grafana_dashboard_create", label: "Create Grafana Dashboard", - description: "Create a Grafana dashboard after validating every Prometheus or Loki panel query, then open it in the UI Grafana tab.", + description: "Create a Grafana dashboard after validating every Prometheus, VictoriaMetrics or Loki panel query, then open it in the UI Grafana tab.", parameters: { type: "object", additionalProperties: false, @@ -490,7 +494,7 @@ export default definePluginEntry({ preset: { type: "string", description: `Optional preset: ${Object.keys(PRESETS).join(", ")}.` }, panels: { type: "array", - description: "Panel specs: title, query (PromQL or LogQL), datasource {type: prometheus or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured Prometheus default.", + description: "Panel specs: title, query (PromQL, MetricsQL or LogQL), datasource {type: prometheus, victoriametrics-metrics-datasource or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured metrics default.", items: { type: "object" }, }, rangeMinutes: { type: "number", description: "Dashboard time range in minutes. Default 60." }, @@ -507,7 +511,7 @@ export default definePluginEntry({ const requireData = boolParam(rawParams.requireData, true); const rangeMinutes = Math.max(1, Math.min(10080, numberParam(rawParams.rangeMinutes, 60))); - const validation = await validatePanels(prometheusUrl, grafanaUrl, uiUrl, headers, datasourceUid, panels, requireData, rangeMinutes); + const validation = await validatePanels(prometheusUrl, grafanaUrl, uiUrl, headers, defaultDatasource, panels, requireData, rangeMinutes); if (!validation.ok) { return jsonToolResult({ created: false, @@ -516,7 +520,7 @@ export default definePluginEntry({ }); } - const dashboard = buildDashboard(title, panels, datasourceUid, rangeMinutes); + const dashboard = buildDashboard(title, panels, defaultDatasource, rangeMinutes); const createPayload = await fetchJson(`${grafanaUrl}/api/dashboards/db`, { method: "POST", headers, diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/openclaw.plugin.json b/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/openclaw.plugin.json index d5577cb..202892f 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/openclaw.plugin.json +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/grafana/openclaw.plugin.json @@ -19,7 +19,7 @@ }, "prometheusUrl": { "type": "string", - "description": "Prometheus service base URL used for validation." + "description": "Prometheus-compatible query base URL, including any VictoriaMetrics cluster tenant prefix." }, "uiUrl": { "type": "string", @@ -50,6 +50,14 @@ "type": "array", "items": { "type": "string" }, "description": "Optional list of Grafana tool names to suppress." + }, + "datasourceType": { + "type": "string", + "enum": [ + "prometheus", + "victoriametrics-metrics-datasource" + ], + "description": "Grafana metrics datasource type. Defaults to prometheus." } }, "required": ["grafanaUrl", "prometheusUrl", "datasourceUid"] diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/observability/grafana.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/observability/grafana.ts index b9c6214..20ab659 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/observability/grafana.ts +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/observability/grafana.ts @@ -6,7 +6,7 @@ declare const Buffer: { from(value: string): { toString(encoding: string): strin type PanelSpec = { title: string; query: string; - datasource?: { type: "prometheus" | "loki"; uid: string }; + datasource?: { type: "prometheus" | "victoriametrics-metrics-datasource" | "loki"; uid: string }; unit?: string; visualization?: "timeseries" | "stat" | "gauge" | "table" | "logs"; legend?: string; @@ -118,12 +118,12 @@ async function fetchJson(url: string, options?: RequestInit) { return payload as Record; } -function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, datasourceUid: string) { +function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, defaultDatasource: NonNullable) { return { queries: [ { refId: "A", - datasource: panel.datasource || { type: "prometheus", uid: datasourceUid }, + datasource: panel.datasource || defaultDatasource, expr: panel.query, ...(panel.datasource?.type === "loki" ? {queryType: "range", maxLines: 1000} : {}), range: true, @@ -171,7 +171,7 @@ async function validatePanels( grafanaUrl: string, uiUrl: string, headers: Record, - datasourceUid: string, + defaultDatasource: NonNullable, panels: PanelSpec[], requireData: boolean, rangeMinutes: number, @@ -181,8 +181,8 @@ async function validatePanels( const datasources = await fetchJson(`${grafanaUrl}/api/datasources`, {headers}) as unknown as Array<{uid: string; type: string; name: string}>; for (const panel of panels) { - const selected = panel.datasource || {type: "prometheus", uid: datasourceUid}; - if (!["prometheus", "loki"].includes(selected.type) || !datasources.some(source => source.uid === selected.uid && source.type === selected.type)) { + const selected = panel.datasource || defaultDatasource; + if (!["prometheus", "victoriametrics-metrics-datasource", "loki"].includes(selected.type) || !datasources.some(source => source.uid === selected.uid && source.type === selected.type)) { issues.push({panelTitle: panel.title, query: panel.query, severity: "error", message: "Panel datasource UID or type is missing or unavailable. Select the intended datasource from availableDatasources and retry."}); continue; } @@ -193,10 +193,10 @@ async function validatePanels( const url = new URL(`${prometheusUrl}/api/v1/query`); url.searchParams.set("query", panel.query); try { - const payload = selected.type === "prometheus" ? await fetchJson(url.toString()) : {}; + const payload = selected.type !== "loki" ? await fetchJson(url.toString()) : {}; const data = payload.data as { result?: unknown[] } | undefined; const result = Array.isArray(data?.result) ? data.result : []; - if (requireData && selected.type === "prometheus" && result.length === 0) { + if (requireData && selected.type !== "loki" && result.length === 0) { issues.push({ panelTitle: panel.title, query: panel.query, @@ -209,7 +209,7 @@ async function validatePanels( const grafanaPayload = await fetchJson(`${grafanaUrl}/api/ds/query`, { method: "POST", headers, - body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, datasourceUid)), + body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, defaultDatasource)), }); frameCount = grafanaFrameCount(grafanaPayload); if (requireData && frameCount === 0) { @@ -232,7 +232,7 @@ async function validatePanels( const uiProxyPayload = await fetchJson(`${uiUrl}/api/grafana/proxy/api/ds/query`, { method: "POST", headers: { ...uiAuthHeaders(), Origin: uiUrl }, - body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, datasourceUid)), + body: JSON.stringify(buildGrafanaQueryPayload(panel, rangeMinutes, defaultDatasource)), }); const uiFrameCount = grafanaFrameCount(uiProxyPayload); if (requireData && uiFrameCount === 0) { @@ -300,7 +300,7 @@ function appendTimeRange(path: string, rangeMinutes: number) { return `${path}${separator}orgId=1&from=now-${Math.trunc(rangeMinutes)}m&to=now&kiosk`; } -function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { +function buildPanel(panel: PanelSpec, index: number, defaultDatasource: NonNullable) { const type = panel.visualization || "timeseries"; const width = type === "stat" || type === "gauge" ? 6 : 12; return { @@ -308,7 +308,7 @@ function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { type, title: panel.title, description: panel.description, - datasource: panel.datasource || { type: "prometheus", uid: datasourceUid }, + datasource: panel.datasource || defaultDatasource, gridPos: { h: type === "stat" || type === "gauge" ? 6 : 8, w: width, @@ -337,7 +337,7 @@ function buildPanel(panel: PanelSpec, index: number, datasourceUid: string) { }; } -function buildDashboard(title: string, panels: PanelSpec[], datasourceUid: string, rangeMinutes: number) { +function buildDashboard(title: string, panels: PanelSpec[], defaultDatasource: NonNullable, rangeMinutes: number) { return { id: null, uid: `mosaic-${Date.now().toString(36)}`, @@ -351,7 +351,7 @@ function buildDashboard(title: string, panels: PanelSpec[], datasourceUid: strin from: `now-${Math.max(1, Math.trunc(rangeMinutes))}m`, to: "now", }, - panels: panels.map((panel, index) => buildPanel(panel, index, datasourceUid)), + panels: panels.map((panel, index) => buildPanel(panel, index, defaultDatasource)), }; } @@ -363,6 +363,10 @@ export function registerGrafanaTools(api: PluginApi) { const prometheusUrl = resolveString(api.pluginConfig, "prometheusUrl"); const uiUrl = resolveString(api.pluginConfig, "uiUrl", DEFAULT_UI_URL); const datasourceUid = resolveString(api.pluginConfig, "datasourceUid"); + const defaultDatasource = { + uid: datasourceUid, + type: resolveString(api.pluginConfig, "datasourceType", "prometheus") as NonNullable["type"], + }; const headers = authHeaders(api.pluginConfig); const registerTool = (tool: Parameters[0]) => { if (isToolEnabled(api.pluginConfig, tool.name)) { @@ -395,7 +399,7 @@ export function registerGrafanaTools(api: PluginApi) { registerTool({ name: "dashboard_create", label: "Create Dashboard", - description: "Create a Grafana dashboard after validating every Prometheus or Loki panel query, then open it in the UI Grafana tab.", + description: "Create a Grafana dashboard after validating every Prometheus, VictoriaMetrics or Loki panel query, then open it in the UI Grafana tab.", parameters: { type: "object", additionalProperties: false, @@ -403,7 +407,7 @@ export function registerGrafanaTools(api: PluginApi) { title: { type: "string", description: "Dashboard title." }, panels: { type: "array", - description: "Panel specs: title, query (PromQL or LogQL), datasource {type: prometheus or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured Prometheus default.", + description: "Panel specs: title, query (PromQL, MetricsQL or LogQL), datasource {type: prometheus, victoriametrics-metrics-datasource or loki, uid: discovered Grafana UID}, visualization (timeseries, stat, gauge, table, logs), optional unit/legend/description. Omit datasource only to use the configured metrics default.", items: { type: "object" }, }, rangeMinutes: { type: "number", description: "Dashboard time range in minutes. Default 60." }, @@ -415,12 +419,12 @@ export function registerGrafanaTools(api: PluginApi) { const title = stringParam(rawParams.title) || "Generated Dashboard"; const panels = panelsFromParams(rawParams); if (panels.length === 0) { - throw new Error("Provide panels with explicit PromQL or LogQL queries."); + throw new Error("Provide panels with explicit PromQL, MetricsQL or LogQL queries."); } const requireData = boolParam(rawParams.requireData, true); const rangeMinutes = Math.max(1, Math.min(10080, numberParam(rawParams.rangeMinutes, 60))); - const validation = await validatePanels(prometheusUrl, grafanaUrl, uiUrl, headers, datasourceUid, panels, requireData, rangeMinutes); + const validation = await validatePanels(prometheusUrl, grafanaUrl, uiUrl, headers, defaultDatasource, panels, requireData, rangeMinutes); if (!validation.ok) { return jsonToolResult({ created: false, @@ -429,7 +433,7 @@ export function registerGrafanaTools(api: PluginApi) { }); } - const dashboard = buildDashboard(title, panels, datasourceUid, rangeMinutes); + const dashboard = buildDashboard(title, panels, defaultDatasource, rangeMinutes); const createPayload = await fetchJson(`${grafanaUrl}/api/dashboards/db`, { method: "POST", headers, diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/observability/index.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/observability/index.ts index e82768e..60cbbb0 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/observability/index.ts +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/observability/index.ts @@ -167,7 +167,7 @@ async function queryRange(baseUrl: string, promql: string, rawParams: Record { + const endpoint = "http://vmselect.metrics.svc:8481/select/17/prometheus"; + const args = ["--set-string", `observability.prometheusUrl=${endpoint}`, + "--set-string", "observability.grafanaDatasourceUid=vm-metrics", + "--set-string", "observability.grafanaDatasourceType=victoriametrics-metrics-datasource"]; + const seed = render(...args, "--show-only", "templates/openclaw-seed-configmap.yaml"); + const config = JSON.parse(configMapBlock(seed, "openclaw.json")); + for (const name of ["observability", "grafana"]) { + assert.equal(config.plugins.entries[name].config.prometheusUrl, endpoint); + assert.equal(config.plugins.entries[name].config.datasourceUid, "vm-metrics"); + assert.equal(config.plugins.entries[name].config.datasourceType, "victoriametrics-metrics-datasource"); + } + for (const template of ["templates/openclaw.yaml", "templates/mosaic-ui.yaml"]) { + assert.ok(render(...args, "--show-only", template).includes(endpoint)); + } +}); diff --git a/helm/mosaic-stack/tests/mixed-dashboards.test.mjs b/helm/mosaic-stack/tests/mixed-dashboards.test.mjs index 5e6abc9..0c59fcc 100644 --- a/helm/mosaic-stack/tests/mixed-dashboards.test.mjs +++ b/helm/mosaic-stack/tests/mixed-dashboards.test.mjs @@ -11,9 +11,9 @@ const hooks=registerHooks({resolve(s,c,next){return s==='openclaw/plugin-sdk/plu const {default:plugin}=await import('../files/openclaw-seed/extensions/grafana/index.ts'); hooks.deregister(); for(const [register,name] of [[registerGrafanaTools,'dashboard_create'],[plugin.register,'grafana_dashboard_create']]) { - test(`${name} routes mixed panels to their datasources and rejects failed validation`,async t=>{ + for (const metricsType of ['prometheus', 'victoriametrics-metrics-datasource']) test(`${name} (${metricsType}) routes mixed panels to their datasources and rejects failed validation`,async t=>{ const saved=[];const queries=[];let fail=false;let empty=false; - const sources=[{uid:'metrics-test',type:'prometheus',name:'Metrics'},{uid:'logs-test',type:'loki',name:'Logs'}]; + const sources=[{uid:'metrics-test',type:metricsType,name:'Metrics'},{uid:'logs-test',type:'loki',name:'Logs'}]; const server=createServer(async(req,res)=>{ let body='';for await(const part of req)body+=part; const data=body?JSON.parse(body):{}; @@ -30,13 +30,13 @@ for(const [register,name] of [[registerGrafanaTools,'dashboard_create'],[plugin. }).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); t.after(()=>{server.closeAllConnections();server.close()}); const url=`http://127.0.0.1:${server.address().port}`; - const tools={};register({pluginConfig:{grafanaEnabled:true,grafanaUrl:url+'/monitoring',prometheusUrl:url,uiUrl:url,datasourceUid:'metrics-test'},registerTool:x=>tools[x.name]=x}); + const tools={};register({pluginConfig:{grafanaEnabled:true,grafanaUrl:url+'/monitoring',prometheusUrl:url,uiUrl:url,datasourceUid:'metrics-test',...(metricsType === 'prometheus' ? {} : {datasourceType:metricsType})},registerTool:x=>tools[x.name]=x}); const panels=[{title:'Health',query:'up',visualization:'stat'},{title:'Events',query:'{service="example"}',visualization:'logs',datasource:{uid:'logs-test',type:'loki'}}]; const result=await tools[name].execute('test',{title:'Correlation',panels,openInUi:false}); assert.equal(result.details.created,true); assert.ok(result.details.iframeUrl.startsWith('/api/grafana/proxy/d/test-board?')); assert.equal(result.details.dashboardUrl,url+'/monitoring/d/test-board'); - assert.deepEqual(saved[0].panels.map(p=>p.datasource),[{type:'prometheus',uid:'metrics-test'},{type:'loki',uid:'logs-test'}]); + assert.deepEqual(saved[0].panels.map(p=>p.datasource),[{type:metricsType,uid:'metrics-test'},{type:'loki',uid:'logs-test'}]); assert.equal(saved[0].panels[1].type,'logs'); assert.ok(queries.some(q=>q.datasource.uid==='logs-test'&&q.expr===panels[1].query)); fail=true; @@ -65,5 +65,11 @@ for(const [register,name] of [[registerGrafanaTools,'dashboard_create'],[plugin. assert.equal(recovered.created,true); assert.equal(saved.length,2); assert.equal(saved[1].panels[0].datasource.uid,datasource.uid); + const alternate={uid:'alternate-metrics',type:metricsType === 'prometheus' ? 'victoriametrics-metrics-datasource' : 'prometheus',name:'Alternate metrics'}; + sources.push(alternate); + const explicit=(await tools[name].execute('test',{panels:[{...panels[0],datasource:alternate}],openInUi:false})).details; + assert.equal(explicit.created,true); + assert.equal(saved[2].panels[0].datasource.type,alternate.type); + assert.equal(queries.at(-1).datasource.uid,alternate.uid); }); } diff --git a/helm/mosaic-stack/tests/victoriametrics.test.mjs b/helm/mosaic-stack/tests/victoriametrics.test.mjs new file mode 100644 index 0000000..f2765f8 --- /dev/null +++ b/helm/mosaic-stack/tests/victoriametrics.test.mjs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {registerHooks} from 'node:module'; +import test from 'node:test'; + +const hooks = registerHooks({resolve(specifier, context, next) { + return specifier === 'openclaw/plugin-sdk/plugin-entry' + ? {url:'data:text/javascript,export const definePluginEntry = x => x;', shortCircuit:true} + : next(specifier, context); +}}); +const {default:plugin} = await import('../files/openclaw-seed/extensions/observability/index.ts'); +hooks.deregister(); + +function tools(base) { + const registered = {}; + plugin.register({pluginConfig:{grafanaEnabled:false, prometheusUrl:base}, + registerTool:tool => { registered[tool.name] = params => tool.execute('test', params); }}); + return registered; +} + +for (const prefix of ['', '/select/23/prometheus']) { + test(`metrics discovery and queries preserve the configured API prefix (${prefix || 'root'})`, async t => { + const requests = []; + const expression = 'WITH (load = vector(3)) load * 2'; + const server = createServer((req,res) => { + const url = new URL(req.url, 'http://localhost'); + requests.push({url,method:req.method}); + const data = url.pathname.endsWith('/values') ? ['node_load1','node_memory_MemAvailable_bytes'] + : url.pathname.endsWith('/query_range') + ? {resultType:'matrix',result:[{metric:{node:'worker-b'},values:[[1790812800,'6'],[1790812860,'8']]}]} + : {resultType:'vector',result:[{metric:{node:'worker-b'},value:[1790812800,'6']}]}; + res.setHeader('Content-Type','application/json'); + res.end(JSON.stringify({status:'success',data})); + }).listen(0,'127.0.0.1'); + await new Promise(resolve => server.once('listening',resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const api = tools(`http://127.0.0.1:${server.address().port}${prefix}/`); + assert.equal((await api.observability_metric_names({})).details.names.length,2); + const instant = (await api.observability_query({query:expression})).details; + assert.deepEqual(instant.result[0],{metric:{node:'worker-b'},value:{timestamp:1790812800,value:6}}); + const range = (await api.observability_range_query({query:expression,start:'2026-10-01T00:00:00Z',end:'2026-10-01T00:05:00Z',stepSeconds:60})).details; + assert.equal(range.result[0].values[1].value,8); + assert.deepEqual(requests.map(({url}) => url.pathname),[ + `${prefix}/api/v1/label/__name__/values`, `${prefix}/api/v1/query`, `${prefix}/api/v1/query_range`, + ]); + for (const request of requests) assert.equal(request.method,'GET'); + for (const {url} of requests.slice(1)) assert.equal(url.searchParams.get('query'),expression); + assert.equal(requests[2].url.searchParams.get('step'),'60'); + }); +} + +test('VictoriaMetrics serves discovery, MetricsQL instant queries and historical queries', { + skip:!process.env.VICTORIAMETRICS_TEST_URL, +}, async () => { + const api = tools(process.env.VICTORIAMETRICS_TEST_URL); + const names = (await api.observability_metric_names({})).details; + assert.ok(names.names.length > 0); + const query = 'WITH (x = vector(2)) x + 3'; + const instant = (await api.observability_query({query})).details; + assert.equal(instant.resultType,'vector'); + assert.equal(instant.result[0].value.value,5); + const range = (await api.observability_range_query({query,rangeMinutes:2,stepSeconds:30})).details; + assert.equal(range.resultType,'matrix'); + assert.ok(range.result[0].values.length > 1); + assert.ok(range.result[0].values.every(sample => sample.value === 5)); +}); diff --git a/helm/mosaic-stack/values.yaml b/helm/mosaic-stack/values.yaml index 8aeb523..1b21968 100644 --- a/helm/mosaic-stack/values.yaml +++ b/helm/mosaic-stack/values.yaml @@ -17,10 +17,12 @@ namespace: labels: {} observability: + # Prometheus-compatible query URL; VictoriaMetrics cluster URLs include /select//prometheus. prometheusUrl: http://kube-prometheus-stack-prometheus.prometheus.svc.cluster.local:9090 grafanaUrl: http://kube-prometheus-stack-grafana.prometheus.svc.cluster.local/grafana alertmanagerUrl: http://kube-prometheus-stack-alertmanager.prometheus.svc.cluster.local:9093 grafanaDatasourceUid: prometheus + grafanaDatasourceType: prometheus loki: url: "" tenantId: "" diff --git a/tests/docs/install-builder.test.mjs b/tests/docs/install-builder.test.mjs index 6ab7ff4..35730bb 100644 --- a/tests/docs/install-builder.test.mjs +++ b/tests/docs/install-builder.test.mjs @@ -155,3 +155,9 @@ print(json.dumps({'catalog':catalog,'cases':cases}))`; }])); for (const {state, components} of cases) assert.deepEqual(normalize(selectedComponents(catalog, state, enabledModules(state))), normalize(components)); }); + +for (const grafanaType of [undefined, 'victoriametrics-metrics-datasource']) test(`Grafana datasource type preserves the default or configured selection: ${grafanaType}`, () => { + const args = argumentsFor({...example, modules:['grafana'], grafanaType, grafanaUid:'metrics'}); + assert.ok(args.includes(`observability.grafanaDatasourceType=${grafanaType || 'prometheus'}`)); + assert.ok(args.includes('observability.grafanaDatasourceUid=metrics')); +});