Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
185 changes: 125 additions & 60 deletions edge-apps/grafana/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { getRenderUrl } from './render'
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import { getRenderUrl, fetchAndRenderDashboard } from './render'
import type { ScreenlyObject } from '@screenly/edge-apps'

// Mock screenly object
Expand All @@ -22,79 +22,144 @@ Object.assign(globalThis.window, {
innerHeight: 567,
})

describe('Grafana App', () => {
describe('getRenderUrl', () => {
let originalScreenWidth: number
let originalScreenHeight: number
describe('getRenderUrl', () => {
let originalScreenWidth: number
let originalScreenHeight: number

beforeEach(() => {
originalScreenWidth = globalThis.window.innerWidth
originalScreenHeight = globalThis.window.innerHeight
})
beforeEach(() => {
originalScreenWidth = globalThis.window.innerWidth
originalScreenHeight = globalThis.window.innerHeight
})

afterEach(() => {
globalThis.window.innerWidth = originalScreenWidth
globalThis.window.innerHeight = originalScreenHeight
})

test('should construct URL with correct parameters', () => {
globalThis.window.innerWidth = 1234
globalThis.window.innerHeight = 567

const url = getRenderUrl('https://grafana.example.com', 'abc123')

expect(url).toContain(
'https://cors-proxy.example.com/https://grafana.example.com/render/d/abc123',
)
expect(url).toContain('width=1234')
expect(url).toContain('height=567')
expect(url).toContain('kiosk=true')
})

test('should use dynamic window dimensions', () => {
globalThis.window.innerWidth = 3840
globalThis.window.innerHeight = 2160

const url = getRenderUrl('grafana.example.com', 'xyz789')

expect(url).toContain('width=3840')
expect(url).toContain('height=2160')
})

afterEach(() => {
globalThis.window.innerWidth = originalScreenWidth
globalThis.window.innerHeight = originalScreenHeight
})
test('should include all required query parameters', () => {
const url = getRenderUrl('my-grafana.net', 'dash1')

test('should construct URL with correct parameters', () => {
globalThis.window.innerWidth = 1234
globalThis.window.innerHeight = 567
const params = new URLSearchParams(url.split('?')[1])
expect(params.has('width')).toBe(true)
expect(params.has('height')).toBe(true)
expect(params.get('kiosk')).toBe('true')
})

const url = getRenderUrl('https://grafana.example.com', 'abc123')
test('should include CORS proxy URL', () => {
const url = getRenderUrl('my-grafana.net', 'dash1')

expect(url).toContain(
'https://cors-proxy.example.com/https://grafana.example.com/render/d/abc123',
)
expect(url).toContain('width=1234')
expect(url).toContain('height=567')
expect(url).toContain('kiosk=true')
})
expect(url).toContain('https://cors-proxy.example.com')
})

test('should use dynamic window dimensions', () => {
globalThis.window.innerWidth = 3840
globalThis.window.innerHeight = 2160
test('should include domain in render path', () => {
const url = getRenderUrl('custom.grafana.net', 'dash-id')

const url = getRenderUrl('grafana.example.com', 'xyz789')
expect(url).toContain('custom.grafana.net')
expect(url).toContain('dash-id')
})
})

expect(url).toContain('width=3840')
expect(url).toContain('height=2160')
})
describe('fetchAndRenderDashboard', () => {
const imgElement = {
setAttribute: mock(() => {}),
} as unknown as HTMLImageElement

test('should include all required query parameters', () => {
const url = getRenderUrl('my-grafana.net', 'dash1')
beforeEach(() => {
;(imgElement.setAttribute as ReturnType<typeof mock>).mockClear()
})

const params = new URLSearchParams(url.split('?')[1])
expect(params.has('width')).toBe(true)
expect(params.has('height')).toBe(true)
expect(params.get('kiosk')).toBe('true')
})
test('should return success when fetch succeeds', async () => {
globalThis.fetch = mock(async () => ({
ok: true,
blob: async () => new Blob(['data'], { type: 'image/png' }),
})) as unknown as typeof fetch

test('should include CORS proxy URL', () => {
const url = getRenderUrl('my-grafana.net', 'dash1')
globalThis.URL.createObjectURL = mock(() => 'blob:fake-url')

Comment thread
nicomiguelino marked this conversation as resolved.
expect(url).toContain('https://cors-proxy.example.com')
})
const result = await fetchAndRenderDashboard(
'https://example.com/render',
'token123',
imgElement,
)

test('should include domain in render path', () => {
const url = getRenderUrl('custom.grafana.net', 'dash-id')
expect(result.success).toBe(true)
expect(imgElement.setAttribute).toHaveBeenCalledWith('src', 'blob:fake-url')
})

test('should return failure with HTTP status when response is not ok', async () => {
globalThis.fetch = mock(async () => ({
ok: false,
status: 401,
statusText: 'Unauthorized',
})) as unknown as typeof fetch

const result = await fetchAndRenderDashboard(
'https://example.com/render',
'bad-token',
imgElement,
)

expect(result.success).toBe(false)
if (!result.success) {
expect(result.status).toBe(401)
expect(result.statusText).toBe('Unauthorized')
expect(result.message).toContain('401')
}
})

test('should return failure with error message on network error', async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error('Network request failed')),
) as unknown as typeof fetch

const result = await fetchAndRenderDashboard(
'https://example.com/render',
'token123',
imgElement,
)

expect(result.success).toBe(false)
if (!result.success) {
expect(result.message).toBe('Network request failed')
expect(result.status).toBeUndefined()
}
})
})

expect(url).toContain('custom.grafana.net')
expect(url).toContain('dash-id')
})
describe('Configuration validation', () => {
test('refresh interval should be numeric and positive', () => {
const refreshInterval = 60
expect(typeof refreshInterval).toBe('number')
expect(refreshInterval).toBeGreaterThan(0)
})

describe('Configuration validation', () => {
test('refresh interval should be numeric and positive', () => {
const refreshInterval = 60
expect(typeof refreshInterval).toBe('number')
expect(refreshInterval).toBeGreaterThan(0)
})

test('service access token should be a string', () => {
const serviceAccessToken = 'glsa_xxxxxxxxxxxx'
expect(typeof serviceAccessToken).toBe('string')
expect(serviceAccessToken.length).toBeGreaterThan(0)
})
test('service access token should be a string', () => {
const serviceAccessToken = 'glsa_xxxxxxxxxxxx'
expect(typeof serviceAccessToken).toBe('string')
expect(serviceAccessToken.length).toBeGreaterThan(0)
})
})
9 changes: 6 additions & 3 deletions edge-apps/grafana/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@ window.onload = async function () {
const imgElement = document.querySelector('#content img') as HTMLImageElement

// Fetch dashboard immediately
const success = await fetchAndRenderDashboard(
const result = await fetchAndRenderDashboard(
imageUrl,
serviceAccessToken,
imgElement,
)

if (!success) {
throw new Error('Failed to load the Grafana dashboard image.')
if (!result.success) {
throw new Error(
`Failed to load the Grafana dashboard image (${result.message}). ` +
'This app requires the Grafana Image Renderer plugin and is not supported on Screenly Anywhere.',
Comment thread
nicomiguelino marked this conversation as resolved.
Outdated
)
}

// Set up interval to refresh the dashboard
Expand Down
22 changes: 16 additions & 6 deletions edge-apps/grafana/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export type FetchResult =
| { success: true }
| { success: false; status?: number; statusText?: string; message: string }

export function getRenderUrl(domain: string, dashboardId: string): string {
const renderUrl = `${screenly.cors_proxy_url}/${domain}/render/d/${dashboardId}`
const width = window.innerWidth || 1920
Expand All @@ -15,7 +19,7 @@ export async function fetchAndRenderDashboard(
imageUrl: string,
serviceAccessToken: string,
imgElement: HTMLImageElement,
): Promise<boolean> {
): Promise<FetchResult> {
try {
const response = await fetch(imageUrl, {
method: 'GET',
Expand All @@ -26,20 +30,26 @@ export async function fetchAndRenderDashboard(
})

if (!response.ok) {
const message = `HTTP ${response.status} ${response.statusText}`
console.error(
`Failed to fetch dashboard image from ${imageUrl}: ${response.status} ${response.statusText}`,
`Failed to fetch dashboard image from ${imageUrl}: ${message}`,
)
return false
return {
success: false,
status: response.status,
statusText: response.statusText,
message,
}
}

const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)

// Render Grafana dashboard as an image
imgElement.setAttribute('src', objectUrl)
return true
return { success: true }
Comment thread
nicomiguelino marked this conversation as resolved.
Outdated
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('Error fetching dashboard image:', error)
return false
return { success: false, message }
}
}