Files
LEDMatrix/web_interface/static/v3/js/plugins/api_client.js
T
ChuckandClaude Opus 5.5 0e9e2cabba fix(web): widget cache-busting, dead frontend code, and dependency pins (#656)
- Plugin-supplied widgets load as /static/plugin-widgets/...js?v=<plugin
  version>, so an update isn't hidden behind the year-long immutable cache.
- Fire-and-forget loadInstalledPlugins() calls catch the rejection it has
  already reported, so the global handler no longer adds a second toast.
- Timezone picker renders again when the General partial is re-injected.
- Remove dead code: executePluginAction's six plugin-id fallbacks and
  [DEBUG] logging, window.currentPluginConfig and every read of it, the
  file-upload JSON delete branch, unused PluginAPI / PluginInstallManager /
  PluginStateManager helpers, loadPluginWidgetsFromManifest, the stale
  install_manager.js and LEDVisibility fallbacks, error_handler.js's global
  escapeHtml, 13 unused CSS rules, and stale comments/no-op returns.
- pytz < 2027, psutil < 7 in requirements-test.txt, pytest-cov < 8.
- Pin anthropics/claude-code-action to the commit v1 resolves to.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:40:52 -04:00

319 lines
10 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 [];
},
/**
* Toggle plugin enabled/disabled.
*
* @param {string} pluginId - Plugin identifier
* @param {boolean} enabled - Whether plugin should be enabled
* @returns {Promise<Object>} Response data
*/
async togglePlugin(pluginId, enabled) {
return await this.request('/plugins/toggle', 'POST', {
plugin_id: pluginId,
enabled: enabled
});
},
/**
* Get plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin configuration
*/
async getPluginConfig(pluginId) {
const response = await this.request(`/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`);
return response.data || {};
},
/**
* Save plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Configuration data
* @returns {Promise<Object>} Response data
*/
async savePluginConfig(pluginId, config) {
return await this.request('/plugins/config', 'POST', {
plugin_id: pluginId,
config: config
});
},
/**
* Reset plugin configuration to defaults.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Response data
*/
async resetPluginConfig(pluginId) {
return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST');
},
/**
* 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;
}