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
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ Materialize the result of a KQL query into a table with .set, .append, .set-or-a
| `mode` | string | No | set \(create, fail if it exists\), append \(add to an existing table\), set-or-append \(default\), or set-or-replace \(replace all data\) |
| `sourceQuery` | string | Yes | KQL query whose result becomes the ingested data \(e.g., LogsTable \| where Level == "Error" \| where Timestamp > ago\(1h\)\). Project the columns in the target table\'s order — matching is positional, not by name |
| `async` | boolean | No | Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations |
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags=\"\[''daily''\]\" |
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags='\["daily"\]' |

#### Output

Expand Down
186 changes: 169 additions & 17 deletions apps/docs/content/docs/en/integrations/grafana.mdx

Large diffs are not rendered by default.

131 changes: 131 additions & 0 deletions apps/sim/app/api/tools/grafana/check_data_source_health/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @vitest-environment node
*/
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockSecureFetch, mockValidateUrl, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
mockValidateUrl: vi.fn(),
MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024,
}))

vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithPinnedIP: mockSecureFetch,
validateUrlWithDNS: mockValidateUrl,
MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES,
}))

import { POST } from '@/app/api/tools/grafana/check_data_source_health/route'

const baseBody = {
apiKey: 'glsa_token',
baseUrl: 'https://grafana.example.com',
dataSourceUid: 'P1234AB5678',
}

function grafanaResponse(body: unknown, status: number) {
return {
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: new Headers(),
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
}
}

function post(body: Record<string, unknown> = baseBody) {
return POST(createMockRequest('POST', body) as never, undefined as never)
}

describe('POST /api/tools/grafana/check_data_source_health', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' })
mockValidateUrl.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
})

it('reports a healthy data source', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse({ status: 'OK', message: 'Data source is working' }, 200)
)

const response = await post()
const data = await response.json()

expect(data.success).toBe(true)
expect(data.output).toEqual({ status: 'OK', message: 'Data source is working' })
})

it('reports an UNHEALTHY data source, which Grafana answers with HTTP 400', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse({ status: 'ERROR', message: 'dial tcp: connection refused' }, 400)
)

const response = await post()
const data = await response.json()

expect(data.success).toBe(true)
expect(data.output.status).toBe('ERROR')
expect(data.output.message).toBe('dial tcp: connection refused')
})

it('surfaces the plugin details when Grafana supplies them', async () => {
mockSecureFetch.mockResolvedValue(
grafanaResponse(
{ status: 'ERROR', message: 'bad query', details: { verboseMessage: 'x' } },
400
)
)

const response = await post()
const data = await response.json()

expect(data.output.details).toEqual({ verboseMessage: 'x' })
})

it('treats a failure with no health verdict as a real request failure', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ message: 'Data source not found' }, 404))

const response = await post()
const data = await response.json()

expect(data.success).toBe(false)
expect(data.error).toContain('404')
})

it('bounds and protects the outbound call', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200))

await post()

const [url, resolvedIP, options] = mockSecureFetch.mock.calls[0]
expect(resolvedIP).toBe('203.0.113.10')
expect(url).toBe('https://grafana.example.com/api/datasources/uid/P1234AB5678/health')
expect(options.maxResponseBytes).toBe(MOCK_MAX_JSON_BYTES)
expect(options.timeout).toBeGreaterThan(0)
expect(options.stripAuthOnRedirect).toBe(true)
expect(options.headers.Authorization).toBe('Bearer glsa_token')
})

it('encodes the UID so it cannot re-target the request path', async () => {
mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200))

await post({ ...baseBody, dataSourceUid: 'a/../../admin' })

const [url] = mockSecureFetch.mock.calls[0]
expect(url).toBe('https://grafana.example.com/api/datasources/uid/a%2F..%2F..%2Fadmin/health')
})

it('rejects an unauthenticated request before reaching Grafana', async () => {
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: false,
error: 'Authentication required',
})

const response = await post()

expect(response.status).toBe(401)
expect(mockSecureFetch).not.toHaveBeenCalled()
})
})
139 changes: 139 additions & 0 deletions apps/sim/app/api/tools/grafana/check_data_source_health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaCheckDataSourceHealthContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
MAX_JSON_API_RESPONSE_BYTES,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const dynamic = 'force-dynamic'

const logger = createLogger('GrafanaCheckDataSourceHealthAPI')

const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
const MAX_ERROR_MESSAGE_LENGTH = 2000

/**
* Runs a data source health check.
*
* Grafana answers an *unhealthy* data source with HTTP 400 carrying the same
* `{status, message}` payload it uses for a healthy one, so the diagnostic the
* caller actually wants only exists on the failure status. A plain tool would
* have that converted into an opaque tool error, making the check able to report
* health and never ill-health — hence this route, which reads the payload off
* either status and reports it as a successful check.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()

try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Grafana health check: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}

const parsed = await parseRequest(
grafanaCheckDataSourceHealthContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body

const baseUrl = params.baseUrl.replace(/\/$/, '')
const healthUrl = `${baseUrl}/api/datasources/uid/${encodeURIComponent(
params.dataSourceUid.trim()
)}/health`

const urlValidation = await validateUrlWithDNS(healthUrl, 'baseUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return NextResponse.json({
success: false,
error: `Invalid Grafana baseUrl: ${urlValidation.error}`,
})
}

const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${params.apiKey}`,
}
if (params.organizationId) {
headers['X-Grafana-Org-Id'] = params.organizationId
}

const response = await secureFetchWithPinnedIP(healthUrl, urlValidation.resolvedIP, {
method: 'GET',
headers,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})

const raw = await response.text()
let body: unknown = null
if (raw.length > 0) {
try {
body = JSON.parse(raw)
} catch {
body = null
}
}

const payload =
body && typeof body === 'object'
? (body as { status?: unknown; message?: unknown; details?: unknown })
: null

/**
* A `status` in the body means Grafana ran the check and reported a verdict,
* whatever the HTTP status. Anything else — an auth failure, a missing data
* source, a plugin with no health endpoint — is a genuine request failure.
*/
if (payload && typeof payload.status === 'string') {
return NextResponse.json({
success: true,
output: {
status: payload.status,
message: typeof payload.message === 'string' ? payload.message : null,
...(payload.details === undefined ? {} : { details: payload.details }),
},
})
}

logger.warn(`[${requestId}] Grafana health check did not report a status (${response.status})`)
return NextResponse.json({
success: false,
error: `Failed to check data source health: HTTP ${response.status} ${truncate(
raw,
MAX_ERROR_MESSAGE_LENGTH
)}`,
})
} catch (error) {
logger.error(`[${requestId}] Error checking Grafana data source health:`, error)
return NextResponse.json({ success: false, error: getErrorMessage(error) })
}
})
20 changes: 15 additions & 5 deletions apps/sim/app/api/tools/grafana/update_alert_rule/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import { grafanaUpdateAlertRuleContract } from '@/lib/api/contracts/tools/grafana'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
Expand All @@ -17,6 +18,11 @@ export const dynamic = 'force-dynamic'

const logger = createLogger('GrafanaUpdateAlertRuleAPI')

/** Grafana is reached over two sequential hops, so each one needs its own bound. */
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
const MAX_ERROR_MESSAGE_LENGTH = 2000

export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()

Expand Down Expand Up @@ -64,7 +70,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getHeaders['X-Grafana-Org-Id'] = params.organizationId
}

const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl')
if (!getValidation.isValid || !getValidation.resolvedIP) {
return NextResponse.json({
Expand All @@ -78,18 +84,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
headers: getHeaders,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})

if (!getResponse.ok) {
const errorText = await getResponse.text()
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
error: `Failed to fetch existing alert rule: ${errorText}`,
})
}

const existingRule = (await getResponse.json()) as any
const existingRule = (await getResponse.json()) as Record<string, unknown>

if (!existingRule || !existingRule.uid) {
return NextResponse.json({
Expand Down Expand Up @@ -193,7 +201,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers['X-Disable-Provenance'] = 'true'
}

const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl')
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return NextResponse.json({
Expand All @@ -208,10 +216,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
body: JSON.stringify(updatedRule),
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
stripAuthOnRedirect: true,
})

if (!updateResponse.ok) {
const errorText = await updateResponse.text()
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
return NextResponse.json({
success: false,
output: {},
Expand Down
Loading
Loading