-
-
Notifications
You must be signed in to change notification settings - Fork 580
feat: extended http-api for providing all module variables as json … #3743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
14758cd
b34935c
d06a317
4533558
2e5e8f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import type { ControlLocation } from '@companion-app/shared/Model/Common.js' | |
| import LogController from '../Log/Controller.js' | ||
| import type { DataUserConfig } from '../Data/UserConfig.js' | ||
| import type { ServiceApi } from './ServiceApi.js' | ||
| import { Registry, Gauge } from 'prom-client' | ||
|
|
||
| const HTTP_API_SURFACE_ID = 'http' | ||
|
|
||
|
|
@@ -320,6 +321,12 @@ export class ServiceHttpApi { | |
| // surfaces | ||
| this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan) | ||
|
|
||
| // JSON endpoints | ||
| this.#apiRouter.get('/variables/:label/json', this.#variablesGetJson) | ||
|
|
||
| // Prometheus metrics | ||
| this.#apiRouter.get('/variables/:label/prometheus', this.#variablesGetPrometheus) | ||
|
|
||
| // Finally, default all unhandled to 404 | ||
| this.#apiRouter.use((_req, res) => { | ||
| res.status(404).send('') | ||
|
|
@@ -641,4 +648,165 @@ export class ServiceHttpApi { | |
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Provides JSON for module or custom variables | ||
| */ | ||
| #variablesGetJson = (req: Express.Request, res: Express.Response): void => { | ||
| const connectionLabel = req.params.label | ||
| this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/json`) | ||
|
|
||
| // Determine variable type and fetch definitions | ||
| const isCustomVariable = connectionLabel === 'custom' | ||
| const isExpressionVariable = connectionLabel === 'expression' | ||
|
|
||
| let variableDefinitions: Record<string, any> | ||
|
|
||
| if (isCustomVariable) { | ||
| variableDefinitions = this.#serviceApi.getCustomVariableDefinitions() | ||
| } else if (isExpressionVariable) { | ||
| variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions() | ||
| } else { | ||
| variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel) | ||
| } | ||
|
|
||
| // Check if connection/module exists by checking if we got any definitions | ||
| if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) { | ||
| if (!isCustomVariable && !isExpressionVariable) { | ||
| res.status(404).json({ | ||
| error: 'Connection not found', | ||
| connection: connectionLabel, | ||
| }) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| const result: Record<string, any> = {} | ||
|
|
||
| for (const [variableName, obj] of Object.entries(variableDefinitions)) { | ||
| let value: any | ||
| if (isCustomVariable) { | ||
| value = this.#serviceApi.getCustomVariableValue(variableName) | ||
| } else if (isExpressionVariable) { | ||
| value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName) | ||
| } else { | ||
| value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName) | ||
| } | ||
|
|
||
| result[variableName] = { | ||
| value, | ||
| name: connectionLabel, | ||
| ...obj, | ||
| } | ||
| } | ||
|
|
||
| res.json({ | ||
| connection: connectionLabel, | ||
| variables: result, | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Provides Prometheus metrics for module or custom variables | ||
| */ | ||
| #variablesGetPrometheus = (req: Express.Request, res: Express.Response): void => { | ||
| const connectionLabel = req.params.label | ||
| this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/prometheus`) | ||
|
|
||
| // Determine variable type and fetch definitions | ||
| const isCustomVariable = connectionLabel === 'custom' | ||
| const isExpressionVariable = connectionLabel === 'expression' | ||
|
|
||
| let variableDefinitions: Record<string, any> | ||
|
|
||
| if (isCustomVariable) { | ||
| variableDefinitions = this.#serviceApi.getCustomVariableDefinitions() | ||
| } else if (isExpressionVariable) { | ||
| variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions() | ||
| } else { | ||
| variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel) | ||
| } | ||
|
|
||
| // Check if connection exists | ||
| if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) { | ||
| if (!isCustomVariable && !isExpressionVariable) { | ||
| res.status(404).send('# Connection not found\n') | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // Create a new registry for this request | ||
| const register = new Registry() | ||
|
|
||
| for (const [variableName, obj] of Object.entries(variableDefinitions)) { | ||
| let value: any | ||
| if (isCustomVariable) { | ||
| value = this.#serviceApi.getCustomVariableValue(variableName) | ||
| } else if (isExpressionVariable) { | ||
| value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName) | ||
| } else { | ||
| value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName) | ||
| } | ||
|
|
||
| // Create sanitized metric name | ||
| const sanitizedVariableName = variableName.replace(/[^a-zA-Z0-9_]/g, '_') | ||
| const metricName = isCustomVariable | ||
| ? `companion_custom_variable_${sanitizedVariableName}` | ||
| : isExpressionVariable | ||
| ? `companion_expression_variable_${sanitizedVariableName}` | ||
| : `companion_connection_variable_${sanitizedVariableName}` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor heads-up: metric name collisions are theoretically possible. The sanitization In practice this is probably rare, but if you want to be extra safe, you could catch and log metric registration errors within the loop rather than failing the whole request. |
||
|
|
||
| const help = isCustomVariable | ||
| ? obj.description || variableName | ||
| : isExpressionVariable | ||
| ? obj.description || variableName | ||
| : obj.label || variableName | ||
|
|
||
| // Create labels object | ||
| const labels: Record<string, string> = { | ||
| variable_name: variableName, | ||
| } | ||
|
|
||
| if (!isCustomVariable && !isExpressionVariable) { | ||
| labels.connection_label = connectionLabel | ||
| } | ||
|
|
||
| // Handle different value types | ||
| if (typeof value === 'number') { | ||
| // Numeric values as gauges | ||
| const gauge = new Gauge({ | ||
| name: metricName, | ||
| help: help, | ||
| labelNames: Object.keys(labels), | ||
| registers: [register], | ||
| }) | ||
| gauge.set(labels, value) | ||
| } else { | ||
| // String and other values as info metrics (gauge with value 1) | ||
| const stringLabels = { | ||
| ...labels, | ||
| value: value !== null && value !== undefined ? String(value) : '', | ||
| } | ||
| const gauge = new Gauge({ | ||
| name: `${metricName}_info`, | ||
| help: help, | ||
| labelNames: Object.keys(stringLabels), | ||
| registers: [register], | ||
| }) | ||
| gauge.set(stringLabels, 1) | ||
| } | ||
| } | ||
|
|
||
| // Return metrics in Prometheus format | ||
| res.header('Content-Type', register.contentType) | ||
| register.metrics().then( | ||
| (metrics) => { | ||
| res.send(metrics) | ||
| }, | ||
| (err) => { | ||
| this.logger.error(`Error generating Prometheus metrics: ${err}`) | ||
| res.status(500).send('# Error generating metrics\n') | ||
| } | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding reporting of surface state (e.g. offline) would also be really nice, but maybe that's a separate issue/PR?