Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
487c3fb
feat(grafana): lay the groundwork for a new Grafana Edge App
nicomiguelino Dec 11, 2025
56e21c8
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 11, 2025
73b933c
fix: make the iframe occupy the whole screen; make the corners not ro…
nicomiguelino Dec 11, 2025
4b13573
docs: add instructions for getting embeddable Grafana URLs
nicomiguelino Dec 11, 2025
e16b21d
chore: resolve formatting errors
nicomiguelino Dec 11, 2025
41e5468
chore: resolve formatting and linting issues
nicomiguelino Dec 11, 2025
ad5b260
update app icon
salmanfarisvp Dec 12, 2025
f23f494
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 12, 2025
07154bf
feat: convert Grafana app from iframe to image rendering
nicomiguelino Dec 16, 2025
dd68464
chore: fix HTTP URL for the render URL. Replace `http` with `https`.
nicomiguelino Dec 16, 2025
63d087a
feat(grafana): Convert iframe rendering to API-driven image display
nicomiguelino Dec 17, 2025
0fc3ac3
feat: Add CORS proxy server to edge-apps-library
nicomiguelino Dec 17, 2025
cb4637a
chore(grafana): fix issues with running tests in CI
nicomiguelino Dec 17, 2025
609dc0f
refactor(grafana): Remove unnecessary dashboard_slug setting
nicomiguelino Dec 17, 2025
62fa52b
docs(grafana): remove obsolete settings from `README.md`
nicomiguelino Dec 17, 2025
e0a9d88
refactor(grafana): Update manifest schemas
nicomiguelino Dec 17, 2025
8ee268d
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 17, 2025
ac12b60
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 17, 2025
26763cc
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 17, 2025
641a6a3
feat(grafana): Implement dynamic OAuth token fetching
nicomiguelino Dec 17, 2025
634fdaf
Apply suggestions from code review
nicomiguelino Dec 17, 2025
0f27981
refactor: Improve numeric setting parsing in getSettingWithDefault
nicomiguelino Dec 17, 2025
8db1f81
fix: add support for showing error message in browser
nicomiguelino Dec 17, 2025
c9facfe
chore(grafana): rename `service_access_token` to `access_token`
nicomiguelino Dec 17, 2025
14c6a22
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 18, 2025
94df8ea
Merge remote-tracking branch 'origin/master' into nicomiguelino/grafa…
nicomiguelino Dec 19, 2025
2ce6f90
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 23, 2025
37f546e
refactor(edge-apps-library): make token endpoint type configurable in…
nicomiguelino Dec 23, 2025
531b63f
chore: use a common config file for formatting code; remove local `.p…
nicomiguelino Dec 23, 2025
dc9f68a
chore: remove custom error-message mechanism
nicomiguelino Dec 23, 2025
ee861f2
refactor(grafana): add panic-overlay error handling
nicomiguelino Dec 23, 2025
a45aa86
chore: refactor error-handling to `edge-apps-library`
nicomiguelino Dec 23, 2025
2fd7656
chore(ci): fix workflow for running formatting check
nicomiguelino Dec 23, 2025
584acb1
chore(grafana): refactor formatting workflow
nicomiguelino Dec 23, 2025
d4abda2
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 24, 2025
e72a4fe
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 25, 2025
caf7c67
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 25, 2025
780f151
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 28, 2025
a79b2e4
chore(edge-apps-library): update `bun.lock`
nicomiguelino Dec 28, 2025
f3703ec
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Dec 29, 2025
53ef0f3
Merge branch 'master' into nicomiguelino/grafana-edge-app
nicomiguelino Jan 2, 2026
d504897
chore(edge-apps-library): update `bun.lock`
nicomiguelino Jan 2, 2026
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
**/*.min.js
edge-apps/edge-apps-library/
edge-apps/grafana/
edge-apps/powerbi-legacy/
edge-apps/powerbi-legacy/**
edge-apps/powerbi
Expand Down
329 changes: 329 additions & 0 deletions edge-apps/edge-apps-library/bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion edge-apps/edge-apps-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"dependencies": {
"@photostructure/tz-lookup": "^11.3.0",
"country-locale-map": "^1.9.11",
"offline-geocode-city": "^1.0.2"
"offline-geocode-city": "^1.0.2",
"panic-overlay": "^1.0.51"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
Expand Down
146 changes: 146 additions & 0 deletions edge-apps/edge-apps-library/scripts/cors-proxy-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import http from 'http'
import https from 'https'
import { URL } from 'url'

// Listen on a specific host via the HOST environment variable
const host = process.env.HOST || '0.0.0.0'
// Listen on a specific port via the PORT environment variable
const port = process.env.PORT || 8080

// Allow self-signed certificates (like cors-anywhere does)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'

const server = http.createServer((req, res) => {
// Set CORS headers for all requests
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH',
)
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Max-Age', '86400')

// Handle preflight OPTIONS requests
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}

// Extract target URL from request path (cors-anywhere format: /https://example.com/path)
const urlMatch = req.url.match(/^\/(https?:\/\/.+)$/)

if (!urlMatch) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
error: 'Invalid request format. Expected: /{protocol}://{host}/{path}',
}),
)
return
}

const targetUrl = urlMatch[1]

try {
const parsedUrl = new URL(targetUrl)
console.log(`Proxying ${req.method} request to: ${targetUrl}`)

// Choose http or https module based on protocol
const httpModule = parsedUrl.protocol === 'https:' ? https : http

// Prepare request options
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: req.method,
headers: {
...req.headers,
host: parsedUrl.host, // Set correct host header
},
// For HTTPS, allow self-signed certificates
rejectUnauthorized: false,
}

// Remove headers that shouldn't be forwarded
delete options.headers['x-forwarded-for']
delete options.headers['x-forwarded-proto']
delete options.headers['x-forwarded-host']

// Make the proxy request
const proxyReq = httpModule.request(options, (proxyRes) => {
// Copy response headers (excluding hop-by-hop headers)
const excludeHeaders = [
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
]

Object.keys(proxyRes.headers).forEach((key) => {
if (!excludeHeaders.includes(key.toLowerCase())) {
res.setHeader(key, proxyRes.headers[key])
}
})

// Set response status and stream the response
res.writeHead(proxyRes.statusCode)
proxyRes.pipe(res)
})

// Handle proxy request errors
proxyReq.on('error', (error) => {
console.error('Proxy error:', error.message)
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
error: 'Proxy error',
message: error.message,
}),
)
}
})

// Handle request timeout
proxyReq.setTimeout(30000, () => {
proxyReq.destroy()
if (!res.headersSent) {
res.writeHead(504, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
error: 'Gateway timeout',
}),
)
}
})

// Pipe request body for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
req.pipe(proxyReq)
} else {
proxyReq.end()
}
} catch (error) {
console.error('Invalid URL:', targetUrl, error.message)
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
error: 'Invalid URL format',
message: error.message,
}),
)
}
})

// Start the server
server.listen(port, host, () => {
console.log(
`Running CORS Proxy (Node.js built-ins) on http://${host}:${port}`,
)
})
17 changes: 17 additions & 0 deletions edge-apps/edge-apps-library/src/utils/error-handling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import panic from 'panic-overlay'
import { getSettingWithDefault, signalReady } from './settings.js'

/**
* Set up error handling with panic-overlay
* Configures panic-overlay to display errors on screen if the display_errors setting is enabled
*/
export function setupErrorHandling(): void {
const displayErrors = getSettingWithDefault<boolean>('display_errors', false)
panic.configure({
handleErrors: displayErrors,
})
if (displayErrors) {
window.addEventListener('error', signalReady)
window.addEventListener('unhandledrejection', signalReady)
}
}
2 changes: 2 additions & 0 deletions edge-apps/edge-apps-library/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './error-handling.js'
export * from './theme.js'
export * from './locale.js'
export * from './metadata.js'
export * from './oauth.js'
export * from './settings.js'
export * from './utm.js'
22 changes: 22 additions & 0 deletions edge-apps/edge-apps-library/src/utils/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Retrieves an OAuth token from the Screenly OAuth service
* @param tokenType The token endpoint type (default: 'access_token')
* @returns The token for the configured OAuth provider
*/
export const getToken = async (
tokenType: string = 'access_token',
): Promise<string> => {
const response = await fetch(
screenly.settings.screenly_oauth_tokens_url + tokenType + '/',
{
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`,
},
},
)

const { token } = await response.json()
return token
}
6 changes: 6 additions & 0 deletions edge-apps/grafana/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.DS_Store
static/js/*.js
static/js/*.js.map
1 change: 1 addition & 0 deletions edge-apps/grafana/.ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
60 changes: 60 additions & 0 deletions edge-apps/grafana/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Grafana Dashboard

Displays Grafana dashboards as images on Screenly screens with automatic refresh intervals.

## Features

- Render Grafana dashboards as images via the rendering API
- Automatic refresh at configurable intervals
- Dynamic resolution based on screen size
- Responsive design for landscape and portrait displays
- Theme color integration via @screenly/edge-apps

## Deployment

Create and deploy the Edge App:

```bash
screenly edge-app create --name my-grafana --in-place
screenly edge-app deploy
screenly edge-app instance create
```

## Configuration

The app accepts the following settings via `screenly.yml`:

- `domain` - The Grafana domain (e.g., `someone.grafana.net`)
- `dashboard_id` - The unique ID of the Grafana dashboard
- `refresh_interval` - The interval in seconds to refresh the dashboard image (default: 60)

The service access token is automatically fetched by the app when you have connected Screenly with your Grafana integration.

### Getting Dashboard Information

1. **Find Your Dashboard ID**
- Open your Grafana dashboard
- The URL in the browser will look like: `https://your-domain.grafana.net/d/<dashboard_id>/<dashboard_slug>`
- Extract the `<dashboard_id>` value (you don't need the slug)

2. **Example Configuration**

```yaml
domain: someone.grafana.net
dashboard_id: abc123
refresh_interval: 60
```

## Development

```bash
bun install # Install dependencies
bun run build # Build the app
bun run dev # Start development server
```

## Testing

```bash
bun run test # Run tests
```
Loading