fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -0,0 +1,534 @@
/**
* Generic ActionHandler Module
*
* Provides reusable event delegation-based action handling with:
* - Automatic CSRF token injection
* - Loading state management
* - Confirmation dialogs
* - Toast integration
* - URL template processing
* - Error handling
*/
export class ActionHandler {
constructor(containerSelector, options = {}) {
this.containerSelector = containerSelector;
this.container = null;
this.options = {
csrfTokenSelector: options.csrfTokenSelector || '[data-live-component]',
toastHandler: options.toastHandler || null,
confirmationHandler: options.confirmationHandler || null,
loadingTexts: options.loadingTexts || {},
autoRefresh: options.autoRefresh !== false,
refreshHandler: options.refreshHandler || null,
...options
};
this.handlers = new Map();
this.init();
}
/**
* Initialize event delegation
*/
init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setupEventDelegation());
} else {
this.setupEventDelegation();
}
}
/**
* Setup event delegation on container
*/
setupEventDelegation() {
this.container = document.querySelector(this.containerSelector);
if (!this.container) {
console.warn(`[ActionHandler] Container not found: ${this.containerSelector}`);
return;
}
this.container.addEventListener('click', (e) => {
const button = e.target.closest('[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action) {
e.preventDefault();
this.handleAction(action, button);
}
});
}
/**
* Register a named action handler
*/
registerHandler(name, config) {
this.handlers.set(name, config);
}
/**
* Handle action from button click
*/
async handleAction(action, element) {
// Check if handler is registered
const handlerName = element.dataset.actionHandler || 'default';
const handler = this.handlers.get(handlerName);
// Get action configuration
const config = this.getActionConfig(action, element, handler);
// If handler provides URL template but config doesn't have URL, use template
if (!config.url && handler?.urlTemplate) {
config.url = handler.urlTemplate;
}
if (!config.url) {
console.warn(`[ActionHandler] No URL found for action: ${action}`, {
element,
handlerName,
config
});
return;
}
// Show confirmation if required
if (config.confirm) {
const confirmed = await this.showConfirmation(config.confirm);
if (!confirmed) {
return;
}
}
// Set loading state
const loadingText = config.loadingText || this.options.loadingTexts[action] || 'Processing...';
this.showLoading(element, loadingText);
try {
// Process URL template first (needed for both window and fetch)
let url = this.processUrlTemplate(config.url, element);
// Handle special actions (like logs that open in new window)
if (config.type === 'window') {
// Ensure URL is absolute for window.open (relative URLs don't work reliably)
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `${window.location.origin}${url}`;
}
window.open(url, config.windowTarget || '_blank');
this.resetLoading(element);
return;
}
// Handle inline expandable content (like logs in table rows)
if (config.type === 'inline') {
const containerId = element.dataset.actionParamId;
const logsRow = document.querySelector(`[data-logs-container="${containerId}"]`);
const logsContent = logsRow?.querySelector(`[data-logs-content="${containerId}"]`);
if (!logsRow || !logsContent) {
console.warn(`[ActionHandler] Logs row not found for container: ${containerId}`);
this.resetLoading(element);
return;
}
// Toggle visibility
const isVisible = logsRow.style.display !== 'none';
if (isVisible) {
// Collapse
logsRow.style.display = 'none';
const icon = element.querySelector('.logs-icon');
if (icon) {
icon.textContent = '📋';
}
element.classList.remove('active');
this.resetLoading(element);
return;
}
// Expand and load logs
logsRow.style.display = '';
const icon = element.querySelector('.logs-icon');
if (icon) {
icon.textContent = '📖';
}
element.classList.add('active');
// Check if already loaded
if (logsContent.dataset.loaded === 'true') {
this.resetLoading(element);
return;
}
// Ensure URL is absolute
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `${window.location.origin}${url}`;
}
// Fetch logs
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
...config.headers
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.resetLoading(element);
if (data.success && data.data?.logs) {
// Render logs
logsContent.innerHTML = `<pre class="docker-logs"><code>${this.escapeHtml(data.data.logs)}</code></pre>`;
logsContent.dataset.loaded = 'true';
} else {
logsContent.innerHTML = '<div class="alert alert-error">Failed to load logs</div>';
}
} catch (error) {
this.resetLoading(element);
logsContent.innerHTML = `<div class="alert alert-error">Error loading logs: ${this.escapeHtml(error.message)}</div>`;
}
return;
}
// Get CSRF token
const { token, formId } = this.getCsrfToken(element);
// Make request
const response = await fetch(url, {
method: config.method || 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
...config.headers
},
body: JSON.stringify({
_form_id: formId,
_token: token,
...config.body
})
});
const data = await response.json();
if (data.success !== false) {
// Success
const successMessage = config.successToast || config.successMessage ||
(handler?.successMessages?.[action]) ||
`Action ${action} completed successfully`;
this.showToast(successMessage, 'success');
// Auto refresh if enabled
if (this.options.autoRefresh && config.autoRefresh !== false) {
this.refresh();
}
} else {
// Error from server
const errorMessage = config.errorToast || config.errorMessage ||
(handler?.errorMessages?.[action]) ||
data.message ||
`Failed to ${action}`;
this.showToast(errorMessage, 'error');
this.resetLoading(element);
}
} catch (error) {
this.handleError(error, element, config);
}
}
/**
* Get action configuration from element attributes and handler
*/
getActionConfig(action, element, handler) {
const config = {
action: action,
url: element.dataset.actionUrl,
handler: element.dataset.actionHandler,
method: element.dataset.actionMethod || 'POST',
confirm: element.dataset.actionConfirm,
loadingText: element.dataset.actionLoadingText,
successToast: element.dataset.actionSuccessToast,
errorToast: element.dataset.actionErrorToast,
type: element.dataset.actionType,
windowTarget: element.dataset.actionWindowTarget,
autoRefresh: element.dataset.actionAutoRefresh !== 'false',
headers: {},
body: {}
};
// Merge with handler config if available
if (handler) {
// Don't override explicit URL, but use template if no URL set
if (!config.url && handler.urlTemplate) {
config.url = handler.urlTemplate;
}
if (handler.confirmations?.[action]) {
config.confirm = config.confirm || handler.confirmations[action];
}
if (handler.loadingTexts?.[action]) {
config.loadingText = config.loadingText || handler.loadingTexts[action];
}
if (handler.successMessages?.[action]) {
config.successToast = config.successToast || handler.successMessages[action];
}
if (handler.errorMessages?.[action]) {
config.errorToast = config.errorToast || handler.errorMessages[action];
}
}
// Extract parameters from data-action-param-* attributes for request body
Object.keys(element.dataset).forEach(key => {
if (key.startsWith('actionParam')) {
// Convert camelCase to lowercase (e.g., actionParamId -> id)
const paramName = key.replace(/^actionParam/, '').replace(/([A-Z])/g, '-$1').toLowerCase();
config.body[paramName] = element.dataset[key];
}
});
return config;
}
/**
* Process URL template with placeholders
*/
processUrlTemplate(url, element) {
if (!url) return url;
// Replace {action} placeholder
url = url.replace('{action}', element.dataset.action || '');
// Replace {id} placeholder - check multiple sources
if (url.includes('{id}')) {
// Try data-action-param-id (camelCase from dataset)
const id = element.dataset.actionParamId ||
// Try data-container-id (for backward compatibility)
element.dataset.containerId ||
// Try data-id
element.dataset.id ||
// Try data-action-id
element.dataset.actionId ||
// Try data-action-param-id as kebab-case attribute
element.getAttribute('data-action-param-id');
if (id) {
url = url.replace(/\{id\}/g, id);
} else {
console.warn(`[ActionHandler] Missing ID for URL template: ${url}`, element);
}
}
// Replace {param:name} placeholders
url = url.replace(/\{param:(\w+)\}/g, (match, paramName) => {
// Try camelCase (dataset converts kebab-case to camelCase)
const camelCase = `actionParam${paramName.charAt(0).toUpperCase() + paramName.slice(1)}`;
const value = element.dataset[camelCase] ||
element.dataset[`actionParam${paramName}`] ||
element.dataset[paramName] ||
// Try kebab-case attribute
element.getAttribute(`data-action-param-${paramName}`);
return value || match;
});
// Replace {data:name} placeholders
url = url.replace(/\{data:(\w+)\}/g, (match, attrName) => {
// Try camelCase from dataset
const camelCase = attrName.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
return element.dataset[camelCase] ||
element.dataset[attrName] ||
element.getAttribute(`data-${attrName}`) ||
match;
});
return url;
}
/**
* Get CSRF token from various sources
*/
getCsrfToken(element) {
// Try element itself
if (element.dataset.csrfToken) {
return {
token: element.dataset.csrfToken,
formId: this.getFormId(element)
};
}
// Try closest LiveComponent
const componentElement = element.closest(this.options.csrfTokenSelector);
if (componentElement?.dataset.csrfToken) {
return {
token: componentElement.dataset.csrfToken,
formId: componentElement.dataset.liveComponent
? `livecomponent:${componentElement.dataset.liveComponent}`
: this.getFormId(element)
};
}
// Try container
if (this.container?.dataset.csrfToken) {
return {
token: this.container.dataset.csrfToken,
formId: this.getFormId(element)
};
}
// Try meta tag
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken) {
return {
token: metaToken.content,
formId: this.getFormId(element)
};
}
// Fallback
return {
token: '',
formId: this.getFormId(element)
};
}
/**
* Generate form ID for CSRF validation
*/
getFormId(element) {
// If LiveComponent available, use component ID
const componentElement = element.closest('[data-live-component]');
if (componentElement?.dataset.liveComponent) {
return `livecomponent:${componentElement.dataset.liveComponent}`;
}
// Generate from URL
const url = element.dataset.actionUrl;
if (url) {
// Extract path from URL
const path = url.split('?')[0];
return `action:${path}`;
}
// Fallback
return `action:${element.dataset.action}`;
}
/**
* Show loading state on button
*/
showLoading(element, text) {
element.dataset.originalText = element.textContent;
element.disabled = true;
element.textContent = text;
element.style.opacity = '0.6';
element.style.cursor = 'not-allowed';
element.classList.add('action-loading');
}
/**
* Reset loading state
*/
resetLoading(element) {
element.disabled = false;
element.textContent = element.dataset.originalText || element.textContent;
element.style.opacity = '1';
element.style.cursor = 'pointer';
element.classList.remove('action-loading');
}
/**
* Show confirmation dialog
*/
async showConfirmation(message) {
if (this.options.confirmationHandler) {
return await this.options.confirmationHandler(message);
}
return confirm(message);
}
/**
* Show toast notification
*/
showToast(message, type = 'info') {
if (this.options.toastHandler) {
this.options.toastHandler(message, type);
return;
}
// Try LiveComponentUIHelper
if (window.LiveComponentUIHelper) {
const componentElement = document.querySelector(this.options.csrfTokenSelector);
const componentId = componentElement?.dataset.liveComponent || 'global';
window.LiveComponentUIHelper.showNotification(componentId, {
message: message,
type: type,
duration: 5000
});
return;
}
// Try global showToast
if (typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
// Fallback: Simple alert
if (type === 'error') {
alert(`Error: ${message}`);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
/**
* Refresh page or component
*/
refresh() {
if (this.options.refreshHandler) {
this.options.refreshHandler();
return;
}
// Try LiveComponent refresh
const componentElement = document.querySelector(this.options.csrfTokenSelector);
if (componentElement?.dataset.liveComponent) {
const componentId = componentElement.dataset.liveComponent;
if (window.LiveComponent && typeof window.LiveComponent.refresh === 'function') {
window.LiveComponent.refresh(componentId);
return;
}
}
// Fallback: Reload page
setTimeout(() => window.location.reload(), 1000);
}
/**
* Handle errors
*/
handleError(error, element, config) {
console.error('[ActionHandler] Error:', error);
const errorMessage = config.errorToast ||
config.errorMessage ||
`Error: ${error.message}`;
this.showToast(errorMessage, 'error');
this.resetLoading(element);
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}