feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,638 @@
/**
* ComponentFileUploader - File Upload Module for LiveComponents
*
* Features:
* - Drag & Drop support
* - Multi-file uploads with queue management
* - Progress tracking (per-file and overall)
* - Image/document previews
* - Client-side validation
* - CSRF protection
* - Integration with LiveComponent state management
*
* @package Framework\LiveComponents
*/
/**
* File Upload Progress Tracker
*/
export class UploadProgress {
constructor(file, fileId) {
this.file = file;
this.fileId = fileId;
this.uploadedBytes = 0;
this.totalBytes = file.size;
this.status = 'pending'; // pending, uploading, processing, complete, error
this.error = null;
this.xhr = null;
this.startTime = null;
this.endTime = null;
}
get percentage() {
if (this.totalBytes === 0) return 100;
return Math.round((this.uploadedBytes / this.totalBytes) * 100);
}
get isComplete() {
return this.status === 'complete';
}
get hasError() {
return this.status === 'error';
}
get isUploading() {
return this.status === 'uploading';
}
get uploadSpeed() {
if (!this.startTime || !this.isUploading) return 0;
const elapsed = (Date.now() - this.startTime) / 1000; // seconds
return elapsed > 0 ? this.uploadedBytes / elapsed : 0; // bytes per second
}
get remainingTime() {
const speed = this.uploadSpeed;
if (speed === 0) return 0;
const remaining = this.totalBytes - this.uploadedBytes;
return remaining / speed; // seconds
}
updateProgress(loaded, total) {
this.uploadedBytes = loaded;
this.totalBytes = total;
}
setStatus(status, error = null) {
this.status = status;
this.error = error;
if (status === 'uploading' && !this.startTime) {
this.startTime = Date.now();
}
if (status === 'complete' || status === 'error') {
this.endTime = Date.now();
}
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.setStatus('error', 'Upload cancelled');
}
}
toObject() {
return {
fileId: this.fileId,
fileName: this.file.name,
fileSize: this.totalBytes,
uploadedBytes: this.uploadedBytes,
percentage: this.percentage,
status: this.status,
error: this.error,
uploadSpeed: this.uploadSpeed,
remainingTime: this.remainingTime
};
}
}
/**
* File Validator - Client-side validation
*/
export class FileValidator {
constructor(options = {}) {
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB default
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.minFileSize = options.minFileSize || 1; // 1 byte minimum
}
validate(file) {
const errors = [];
// File size validation
if (file.size > this.maxFileSize) {
errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(this.maxFileSize)})`);
}
if (file.size < this.minFileSize) {
errors.push(`File size is too small (minimum: ${this.formatBytes(this.minFileSize)})`);
}
// MIME type validation
if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type)) {
errors.push(`File type "${file.type}" is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
}
// Extension validation
if (this.allowedExtensions.length > 0) {
const extension = file.name.split('.').pop().toLowerCase();
if (!this.allowedExtensions.includes(extension)) {
errors.push(`File extension ".${extension}" is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`);
}
}
// File name validation
if (file.name.length > 255) {
errors.push('File name is too long (maximum 255 characters)');
}
return errors;
}
isValid(file) {
return this.validate(file).length === 0;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
}
/**
* Drag & Drop Zone Manager
*/
export class DragDropZone {
constructor(element, callbacks = {}) {
this.element = element;
this.onFilesDropped = callbacks.onFilesDropped || (() => {});
this.onDragEnter = callbacks.onDragEnter || (() => {});
this.onDragLeave = callbacks.onDragLeave || (() => {});
this.dragCounter = 0; // Track nested drag events
this.isActive = false;
this.bindEvents();
}
bindEvents() {
// Prevent default browser behavior for drag events
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
this.element.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Handle drag enter
this.element.addEventListener('dragenter', (e) => {
this.dragCounter++;
if (this.dragCounter === 1) {
this.isActive = true;
this.element.classList.add('drag-over');
this.onDragEnter(e);
}
});
// Handle drag leave
this.element.addEventListener('dragleave', (e) => {
this.dragCounter--;
if (this.dragCounter === 0) {
this.isActive = false;
this.element.classList.remove('drag-over');
this.onDragLeave(e);
}
});
// Handle drag over
this.element.addEventListener('dragover', (e) => {
// Required to allow drop
});
// Handle drop
this.element.addEventListener('drop', (e) => {
this.dragCounter = 0;
this.isActive = false;
this.element.classList.remove('drag-over');
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
this.onFilesDropped(files);
}
});
}
destroy() {
this.element.classList.remove('drag-over');
// Event listeners are automatically removed when element is removed
}
}
/**
* Main ComponentFileUploader Class
*/
export class ComponentFileUploader {
constructor(componentElement, options = {}) {
this.componentElement = componentElement;
this.componentId = componentElement.dataset.liveId;
// Options
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.maxFiles = options.maxFiles || 10;
this.autoUpload = options.autoUpload !== false; // default: true
this.multiple = options.multiple !== false; // default: true
this.endpoint = options.endpoint || `/live-component/${this.componentId}/upload`;
// Callbacks
this.onFileAdded = options.onFileAdded || (() => {});
this.onFileRemoved = options.onFileRemoved || (() => {});
this.onUploadStart = options.onUploadStart || (() => {});
this.onUploadProgress = options.onUploadProgress || (() => {});
this.onUploadComplete = options.onUploadComplete || (() => {});
this.onUploadError = options.onUploadError || (() => {});
this.onAllUploadsComplete = options.onAllUploadsComplete || (() => {});
// State
this.files = new Map(); // Map<fileId, UploadProgress>
this.uploadQueue = [];
this.activeUploads = 0;
this.maxConcurrentUploads = options.maxConcurrentUploads || 2;
// Validator
this.validator = new FileValidator({
maxFileSize: this.maxFileSize,
allowedMimeTypes: this.allowedMimeTypes,
allowedExtensions: this.allowedExtensions
});
// UI Elements (optional)
this.dropZoneElement = options.dropZone;
this.fileInputElement = options.fileInput;
this.initialize();
}
initialize() {
// Setup drag & drop if dropZone provided
if (this.dropZoneElement) {
this.dragDropZone = new DragDropZone(this.dropZoneElement, {
onFilesDropped: (files) => this.addFiles(files),
onDragEnter: () => this.dropZoneElement.classList.add('drag-active'),
onDragLeave: () => this.dropZoneElement.classList.remove('drag-active')
});
}
// Setup file input if provided
if (this.fileInputElement) {
this.fileInputElement.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
this.addFiles(files);
e.target.value = ''; // Reset input
});
}
}
/**
* Add files to upload queue
*/
addFiles(files) {
const filesToAdd = Array.isArray(files) ? files : [files];
// Check max files limit
if (this.files.size + filesToAdd.length > this.maxFiles) {
const error = `Cannot add files. Maximum ${this.maxFiles} files allowed.`;
this.onUploadError({ error, fileCount: filesToAdd.length });
return;
}
for (const file of filesToAdd) {
// Generate unique file ID
const fileId = this.generateFileId(file);
// Validate file
const validationErrors = this.validator.validate(file);
const progress = new UploadProgress(file, fileId);
if (validationErrors.length > 0) {
progress.setStatus('error', validationErrors.join(', '));
this.files.set(fileId, progress);
this.onUploadError({
fileId,
file,
errors: validationErrors
});
continue;
}
// Add to files map
this.files.set(fileId, progress);
this.uploadQueue.push(fileId);
// Callback
this.onFileAdded({
fileId,
file,
progress: progress.toObject()
});
}
// Auto-upload if enabled
if (this.autoUpload) {
this.processQueue();
}
}
/**
* Remove file from queue
*/
removeFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
// Abort if uploading
if (progress.isUploading) {
progress.abort();
}
// Remove from queue
const queueIndex = this.uploadQueue.indexOf(fileId);
if (queueIndex !== -1) {
this.uploadQueue.splice(queueIndex, 1);
}
// Remove from files map
this.files.delete(fileId);
// Callback
this.onFileRemoved({ fileId, file: progress.file });
}
/**
* Start uploading all queued files
*/
uploadAll() {
this.processQueue();
}
/**
* Process upload queue
*/
async processQueue() {
while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) {
const fileId = this.uploadQueue.shift();
const progress = this.files.get(fileId);
if (!progress || progress.status !== 'pending') continue;
this.activeUploads++;
this.uploadFile(fileId).finally(() => {
this.activeUploads--;
this.processQueue(); // Process next file
// Check if all uploads are complete
if (this.activeUploads === 0 && this.uploadQueue.length === 0) {
this.onAllUploadsComplete({
totalFiles: this.files.size,
successCount: Array.from(this.files.values()).filter(p => p.isComplete).length,
errorCount: Array.from(this.files.values()).filter(p => p.hasError).length
});
}
});
}
}
/**
* Upload a single file
*/
async uploadFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
try {
// Get current component state
const componentState = this.getComponentState();
// Get CSRF tokens
const csrfTokens = await this.getCsrfTokens();
// Create FormData
const formData = new FormData();
formData.append('file', progress.file);
formData.append('state', JSON.stringify(componentState));
formData.append('params', JSON.stringify({ fileId }));
// Create XHR request
const xhr = new XMLHttpRequest();
progress.xhr = xhr;
// Setup progress tracking
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.updateProgress(e.loaded, e.total);
this.onUploadProgress({
fileId,
...progress.toObject()
});
}
});
// Setup completion handler
const uploadPromise = new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
progress.setStatus('complete');
// Update component state if provided
if (response.state) {
this.updateComponentState(response.state);
}
// Update component HTML if provided
if (response.html) {
this.updateComponentHtml(response.html);
}
this.onUploadComplete({
fileId,
file: progress.file,
response
});
resolve(response);
} else {
throw new Error(response.error || 'Upload failed');
}
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelled'));
});
});
// Set status to uploading
progress.setStatus('uploading');
this.onUploadStart({ fileId, file: progress.file });
// Open and send request
xhr.open('POST', this.endpoint);
// Set CSRF headers
xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id);
xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
await uploadPromise;
} catch (error) {
progress.setStatus('error', error.message);
this.onUploadError({
fileId,
file: progress.file,
error: error.message
});
}
}
/**
* Cancel all uploads
*/
cancelAll() {
this.uploadQueue = [];
this.files.forEach(progress => {
if (progress.isUploading) {
progress.abort();
}
});
}
/**
* Clear all files (completed and pending)
*/
clearAll() {
this.cancelAll();
this.files.clear();
}
/**
* Get overall upload progress
*/
getOverallProgress() {
if (this.files.size === 0) return 100;
const totalBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.totalBytes, 0);
const uploadedBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.uploadedBytes, 0);
return totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0;
}
/**
* Get upload statistics
*/
getStats() {
const files = Array.from(this.files.values());
return {
total: files.length,
pending: files.filter(p => p.status === 'pending').length,
uploading: files.filter(p => p.status === 'uploading').length,
complete: files.filter(p => p.status === 'complete').length,
error: files.filter(p => p.status === 'error').length,
overallProgress: this.getOverallProgress()
};
}
/**
* Helper: Generate unique file ID
*/
generateFileId(file) {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.name}`;
}
/**
* Helper: Get CSRF tokens
*/
async getCsrfTokens() {
try {
const response = await fetch(`/api/csrf/token?action=${encodeURIComponent(this.endpoint)}&method=post`, {
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`CSRF token request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to get CSRF tokens:', error);
throw error;
}
}
/**
* Helper: Get component state from DOM
*/
getComponentState() {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
try {
return JSON.parse(stateElement.textContent || '{}');
} catch (e) {
console.warn('Failed to parse component state:', e);
}
}
return {};
}
/**
* Helper: Update component state in DOM
*/
updateComponentState(newState) {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
stateElement.textContent = JSON.stringify(newState);
}
}
/**
* Helper: Update component HTML
*/
updateComponentHtml(html) {
// Find the component content container (usually the component itself)
const contentElement = this.componentElement.querySelector('[data-live-content]') || this.componentElement;
if (contentElement) {
contentElement.innerHTML = html;
}
}
/**
* Cleanup
*/
destroy() {
this.cancelAll();
this.clearAll();
if (this.dragDropZone) {
this.dragDropZone.destroy();
}
}
}
export default ComponentFileUploader;