mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Whole-tree audit. Every symbol was checked against core, the plugin monorepo and all eight third-party plugins in plugins.json first. - Deprecate (removal 3.10.0) plugin-facing methods nothing calls: LogoDownloader bulk download, ConfigManager backup/secret wrappers, APIHelper extras, BackgroundDataService poll API, PluginManager / PluginStateManager info readers, and a few CacheManager, FontManager, BaseOddsManager, DynamicTeamResolver methods and PluginTestCase. plugin_api_usage.py learns their receiver names; DEPRECATIONS doc regenerated. - Remove core-internal dead code: CacheMetrics, Vegas status/stats plumbing, sync "new cycle" message (followers ignore unknown types), unused operation types, test-only PluginCatalog readers, IPC to_dict and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator callbacks, duplicate web response helpers. - Web UI: drop never-mounted json-file-manager.js, the example widget, utils/error_handler.js, four uncalled PluginAPI methods, and 29 escapeHtml shims (call window.LEDEscape directly). Public globals, BaseWidget and widget names unchanged. - Remove six one-off scripts (owner decision) and the unused markupsafe and pytest-mock pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
9.0 KiB
JavaScript
270 lines
9.0 KiB
JavaScript
/* global debugLog */
|
|
/**
|
|
* API client for plugin operations.
|
|
*
|
|
* Handles all communication with the /api/v3/plugins endpoints.
|
|
* Includes request throttling and caching for performance optimization.
|
|
*/
|
|
|
|
// Request throttling utility
|
|
const RequestThrottler = {
|
|
pending: new Map(),
|
|
cache: new Map(),
|
|
cacheTTL: 5000, // 5 seconds cache for GET requests
|
|
|
|
/**
|
|
* Throttle a request to prevent rapid-fire calls
|
|
*/
|
|
async throttle(key, fn, delay = 300) {
|
|
// Check cache first
|
|
const cached = this.cache.get(key);
|
|
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
|
|
debugLog('[RequestThrottler] Cache hit for:', key);
|
|
return cached.data;
|
|
}
|
|
|
|
// Check if request is already pending
|
|
if (this.pending.has(key)) {
|
|
debugLog('[RequestThrottler] Reusing pending request for:', key);
|
|
return this.pending.get(key);
|
|
}
|
|
|
|
debugLog('[RequestThrottler] Creating new request for:', key);
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
setTimeout(async () => {
|
|
try {
|
|
const result = await fn();
|
|
// Cache successful GET requests
|
|
if (key.includes('GET')) {
|
|
this.cache.set(key, {
|
|
data: result,
|
|
timestamp: Date.now()
|
|
});
|
|
debugLog('[RequestThrottler] Cached response for:', key);
|
|
}
|
|
resolve(result);
|
|
} catch (error) {
|
|
// Don't cache errors
|
|
debugLog('[RequestThrottler] Request failed for:', key, error);
|
|
reject(error);
|
|
} finally {
|
|
this.pending.delete(key);
|
|
}
|
|
}, delay);
|
|
});
|
|
|
|
this.pending.set(key, promise);
|
|
return promise;
|
|
},
|
|
|
|
/**
|
|
* Clear cache for a specific key or all cache
|
|
*/
|
|
clearCache(key = null) {
|
|
if (key) {
|
|
this.cache.delete(key);
|
|
} else {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
};
|
|
|
|
const PluginAPI = {
|
|
/**
|
|
* Base URL for API endpoints.
|
|
*/
|
|
baseURL: '/api/v3',
|
|
|
|
/**
|
|
* The endpoint, if it is a path under baseURL; throws INVALID_ENDPOINT
|
|
* otherwise. Every endpoint is one of this client's own API paths, so
|
|
* anything that could leave that path -- "//host", a backslash, a ".."
|
|
* segment, whitespace or control characters -- is a bug, not a request.
|
|
*
|
|
* @param {string} endpoint - API endpoint, starting with "/"
|
|
* @returns {string} The same endpoint
|
|
*/
|
|
checkEndpoint(endpoint) {
|
|
const path = typeof endpoint === 'string' ? endpoint.split(/[?#]/)[0] : '';
|
|
if (!path.startsWith('/') || path.startsWith('//') ||
|
|
/[\\\s]/.test(endpoint) ||
|
|
Array.from(endpoint).some(ch => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f) ||
|
|
path.split('/').some(segment => segment === '..' || segment === '.')) {
|
|
throw {
|
|
error_code: 'INVALID_ENDPOINT',
|
|
message: `Not an API endpoint: ${String(endpoint)}`
|
|
};
|
|
}
|
|
return endpoint;
|
|
},
|
|
|
|
/**
|
|
* Make an API request with throttling and caching.
|
|
*
|
|
* @param {string} endpoint - API endpoint
|
|
* @param {string} method - HTTP method
|
|
* @param {Object} data - Request body data
|
|
* @param {boolean} useThrottle - Whether to throttle this request (default: true for GET)
|
|
* @returns {Promise<Object>} Response data
|
|
*/
|
|
async request(endpoint, method = 'GET', data = null, useThrottle = null) {
|
|
// Default throttling: only for GET requests
|
|
if (useThrottle === null) {
|
|
useThrottle = method === 'GET';
|
|
}
|
|
|
|
const requestKey = `${method}:${endpoint}:${data ? JSON.stringify(data) : ''}`;
|
|
|
|
const makeRequest = async () => {
|
|
const url = `${this.baseURL}${this.checkEndpoint(endpoint)}`;
|
|
const options = {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
if (data && method !== 'GET') {
|
|
options.body = JSON.stringify(data);
|
|
}
|
|
|
|
// NETWORK_ERROR means only that fetch() itself rejected: no HTTP
|
|
// answer arrived (connection refused/reset, e.g. the web service
|
|
// restarting). Callers retry that (install_manager.js updateAll).
|
|
// Any HTTP response is the server's -- or a proxy's -- answer, so
|
|
// a 502 HTML page or a JSON error without error_code is API_ERROR
|
|
// and is not retried.
|
|
let response;
|
|
try {
|
|
// url is baseURL plus an endpoint checkEndpoint() accepted: a
|
|
// path on this origin's API, never a caller-chosen host.
|
|
response = await fetch(url, options); // nosemgrep
|
|
} catch (error) {
|
|
throw {
|
|
error_code: 'NETWORK_ERROR',
|
|
message: (error && error.message) || 'Network error',
|
|
original_error: error
|
|
};
|
|
}
|
|
|
|
let responseData = null;
|
|
let parseError = null;
|
|
try {
|
|
responseData = await response.json();
|
|
} catch (error) {
|
|
parseError = error;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// Handle structured errors
|
|
if (responseData && responseData.error_code) {
|
|
throw responseData;
|
|
}
|
|
throw {
|
|
error_code: 'API_ERROR',
|
|
status: response.status,
|
|
message: (responseData && responseData.message) || `HTTP ${response.status}`,
|
|
original_error: parseError || undefined
|
|
};
|
|
}
|
|
|
|
if (parseError) {
|
|
throw {
|
|
error_code: 'API_ERROR',
|
|
status: response.status,
|
|
message: `Unreadable response from the server (HTTP ${response.status})`,
|
|
original_error: parseError
|
|
};
|
|
}
|
|
|
|
return responseData;
|
|
};
|
|
|
|
// Use throttling for GET requests, immediate execution for POST/PUT/DELETE
|
|
if (useThrottle && method === 'GET') {
|
|
return await RequestThrottler.throttle(requestKey, makeRequest, 100);
|
|
} else if (method === 'GET') {
|
|
return await makeRequest();
|
|
} else {
|
|
// A write (install, uninstall, toggle, config save) can change any
|
|
// cached GET, e.g. the installed list; drop the cache once it lands.
|
|
try {
|
|
return await makeRequest();
|
|
} finally {
|
|
RequestThrottler.clearCache();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear API cache
|
|
*/
|
|
clearCache() {
|
|
RequestThrottler.clearCache();
|
|
},
|
|
|
|
/**
|
|
* Get installed plugins.
|
|
*
|
|
* @returns {Promise<Array>} List of installed plugins
|
|
*/
|
|
async getInstalledPlugins() {
|
|
const response = await this.request('/plugins/installed');
|
|
// API returns {status: 'success', data: {plugins: [...]}}
|
|
// Extract the plugins array from response.data.plugins
|
|
if (response.data && Array.isArray(response.data.plugins)) {
|
|
return response.data.plugins;
|
|
}
|
|
return [];
|
|
},
|
|
|
|
/**
|
|
* Update plugin.
|
|
*
|
|
* @param {string} pluginId - Plugin identifier
|
|
* @returns {Promise<Object>} Response data
|
|
*/
|
|
async updatePlugin(pluginId) {
|
|
return await this.request('/plugins/update', 'POST', {
|
|
plugin_id: pluginId
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get plugin health.
|
|
*
|
|
* @param {string} pluginId - Optional plugin identifier (null for all)
|
|
* @returns {Promise<Object>} Health data
|
|
*/
|
|
async getPluginHealth(pluginId = null) {
|
|
const endpoint = pluginId
|
|
? `/plugins/health/${encodeURIComponent(pluginId)}`
|
|
: '/plugins/health';
|
|
const response = await this.request(endpoint);
|
|
return response.data || {};
|
|
},
|
|
|
|
/**
|
|
* Get plugin resource metrics (execution time, memory, cpu).
|
|
*
|
|
* @param {string} pluginId - Optional plugin identifier (null for all)
|
|
* @returns {Promise<Object>} Metrics data keyed by plugin id
|
|
*/
|
|
async getPluginMetrics(pluginId = null) {
|
|
const endpoint = pluginId
|
|
? `/plugins/metrics/${encodeURIComponent(pluginId)}`
|
|
: '/plugins/metrics';
|
|
const response = await this.request(endpoint);
|
|
return response.data || {};
|
|
}
|
|
};
|
|
|
|
// Export
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = PluginAPI;
|
|
} else {
|
|
window.PluginAPI = PluginAPI;
|
|
}
|
|
|