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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
11 changes: 8 additions & 3 deletions docs/_static/install-builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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 = '<legend>Installation options</legend>';
function select(key, label, choices) {
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand Down
46 changes: 25 additions & 21 deletions helm/mosaic-stack/files/openclaw-seed/extensions/grafana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -157,12 +157,12 @@ async function fetchJson(url: string, options?: RequestInit) {
return payload as Record<string, unknown>;
}

function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, datasourceUid: string) {
function buildGrafanaQueryPayload(panel: PanelSpec, rangeMinutes: number, defaultDatasource: NonNullable<PanelSpec["datasource"]>) {
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,
Expand Down Expand Up @@ -210,7 +210,7 @@ async function validatePanels(
grafanaUrl: string,
uiUrl: string,
headers: Record<string, string>,
datasourceUid: string,
defaultDatasource: NonNullable<PanelSpec["datasource"]>,
panels: PanelSpec[],
requireData: boolean,
rangeMinutes: number,
Expand All @@ -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;
}
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -339,15 +339,15 @@ 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<PanelSpec["datasource"]>) {
const type = panel.visualization || "timeseries";
const width = type === "stat" || type === "gauge" ? 6 : 12;
return {
id: index + 1,
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,
Expand Down Expand Up @@ -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<PanelSpec["datasource"]>, rangeMinutes: number) {
return {
id: null,
uid: `mosaic-${Date.now().toString(36)}`,
Expand All @@ -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)),
};
}

Expand All @@ -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<PanelSpec["datasource"]>["type"],
};
const headers = authHeaders(api.pluginConfig);
const registerTool = (tool: Parameters<typeof api.registerTool>[0]) => {
if (isToolEnabled(api.pluginConfig, tool.name)) {
Expand Down Expand Up @@ -445,15 +449,15 @@ 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,
properties: {
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." },
Expand All @@ -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))),
Expand All @@ -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,
Expand All @@ -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." },
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"]
Expand Down
Loading
Loading