Package: @lilith/ui-dev-content Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
|
|
/**
|
|
* OperationQueue - Manages concurrent transformer operations with availability-based concurrency
|
|
*
|
|
* Key features:
|
|
* - Availability-based concurrency: No fixed limit, starts operations if service is available
|
|
* - Health check integration: Checks transformer.checkHealth() before starting
|
|
* - Auto-retry: Retries queued operations when services become available
|
|
* - Toast integration: Each operation gets a persistent progress toast
|
|
* - Progress tracking: Supports progress updates from 0-100%
|
|
*/
|
|
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
|
import { useToast } from '@lilith/ui-feedback';
|
|
const OperationQueueContext = createContext(undefined);
|
|
export function OperationQueueProvider({ children, healthCheckInterval = 10000, }) {
|
|
const [operations, setOperations] = useState([]);
|
|
const { showToast, updateToast, removeToast } = useToast();
|
|
const healthCheckTimerRef = useRef(null);
|
|
const pendingToastActionsRef = useRef([]);
|
|
// Store toast functions in a ref to avoid them being effect dependencies
|
|
// This prevents the effect from re-running when ToastProvider re-renders
|
|
const toastFunctionsRef = useRef({ updateToast, removeToast });
|
|
toastFunctionsRef.current = { updateToast, removeToast };
|
|
// Process pending toast actions after render to avoid setState-in-render
|
|
useEffect(() => {
|
|
const actions = pendingToastActionsRef.current;
|
|
if (actions.length === 0)
|
|
return;
|
|
pendingToastActionsRef.current = [];
|
|
const { updateToast, removeToast } = toastFunctionsRef.current;
|
|
for (const action of actions) {
|
|
if (action.type === 'update') {
|
|
updateToast(action.toastId, action.updates);
|
|
}
|
|
else {
|
|
removeToast(action.toastId);
|
|
}
|
|
}
|
|
}, [operations]); // Only depend on operations - toast functions accessed via ref
|
|
// ============================================================================
|
|
// Operation Management
|
|
// ============================================================================
|
|
const getOperation = useCallback((id) => {
|
|
return operations.find((op) => op.id === id);
|
|
}, [operations]);
|
|
const getRunningOperations = useCallback(() => {
|
|
return operations.filter((op) => op.status === 'running');
|
|
}, [operations]);
|
|
const getQueuedOperations = useCallback(() => {
|
|
return operations.filter((op) => op.status === 'queued');
|
|
}, [operations]);
|
|
const updateOperation = useCallback((id, updates) => {
|
|
setOperations((prev) => prev.map((op) => {
|
|
if (op.id === id) {
|
|
const updated = { ...op, ...updates };
|
|
// Queue toast update for processing after render
|
|
pendingToastActionsRef.current.push({
|
|
type: 'update',
|
|
toastId: op.toastId,
|
|
updates: {
|
|
message: getOperationMessage(updated),
|
|
progress: updated.progress,
|
|
type: getToastType(updated.status),
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
return op;
|
|
}));
|
|
}, []);
|
|
const completeOperation = useCallback((id, result) => {
|
|
setOperations((prev) => prev.map((op) => {
|
|
if (op.id === id) {
|
|
const updated = {
|
|
...op,
|
|
status: 'completed',
|
|
result,
|
|
progress: 100,
|
|
completedAt: new Date(),
|
|
};
|
|
// Queue toast update for processing after render
|
|
const changeCount = result.changes?.length || 0;
|
|
const message = result.success && changeCount > 0
|
|
? `${op.transformer.name} completed - Click to review ${changeCount} ${changeCount === 1 ? 'change' : 'changes'}`
|
|
: result.success
|
|
? `${op.transformer.name} completed - No changes needed`
|
|
: `${op.transformer.name} failed`;
|
|
pendingToastActionsRef.current.push({
|
|
type: 'update',
|
|
toastId: op.toastId,
|
|
updates: {
|
|
message,
|
|
type: result.success ? 'success' : 'error',
|
|
progress: 100,
|
|
persistent: true,
|
|
dismissible: true,
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
return op;
|
|
}));
|
|
// Trigger retry of queued operations (services might be available now)
|
|
setTimeout(() => retryQueued(), 100);
|
|
}, []);
|
|
const failOperation = useCallback((id, error) => {
|
|
setOperations((prev) => prev.map((op) => {
|
|
if (op.id === id) {
|
|
const updated = {
|
|
...op,
|
|
status: 'failed',
|
|
error,
|
|
progress: 0,
|
|
completedAt: new Date(),
|
|
};
|
|
// Queue toast update for processing after render
|
|
pendingToastActionsRef.current.push({
|
|
type: 'update',
|
|
toastId: op.toastId,
|
|
updates: {
|
|
message: `${op.transformer.name} failed: ${error}`,
|
|
type: 'error',
|
|
persistent: true,
|
|
dismissible: true,
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
return op;
|
|
}));
|
|
// Trigger retry of queued operations
|
|
setTimeout(() => retryQueued(), 100);
|
|
}, []);
|
|
const clearCompleted = useCallback(() => {
|
|
setOperations((prev) => {
|
|
const toRemove = prev.filter((op) => op.status === 'completed' || op.status === 'failed');
|
|
// Queue toast removals for processing after render
|
|
toRemove.forEach((op) => {
|
|
pendingToastActionsRef.current.push({
|
|
type: 'remove',
|
|
toastId: op.toastId,
|
|
});
|
|
});
|
|
return prev.filter((op) => op.status === 'queued' || op.status === 'running');
|
|
});
|
|
}, []);
|
|
// ============================================================================
|
|
// Queue Operation (with availability check)
|
|
// ============================================================================
|
|
const queueOperation = useCallback(async (handle, transformer) => {
|
|
const id = `op-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
// Check service availability
|
|
let serviceAvailable = true;
|
|
try {
|
|
const health = await transformer.checkHealth();
|
|
serviceAvailable = health.available && !health.degraded;
|
|
}
|
|
catch (error) {
|
|
console.warn(`Health check failed for ${transformer.id}:`, error);
|
|
serviceAvailable = false;
|
|
}
|
|
// Determine initial status
|
|
const status = serviceAvailable ? 'running' : 'queued';
|
|
// Create toast notification
|
|
const toastId = showToast(serviceAvailable
|
|
? `${transformer.name} starting...`
|
|
: `${transformer.name} queued (service unavailable)`, serviceAvailable ? 'loading' : 'info', {
|
|
persistent: true,
|
|
dismissible: false,
|
|
progress: 0,
|
|
});
|
|
const operation = {
|
|
id,
|
|
handle,
|
|
transformer,
|
|
status,
|
|
progress: 0,
|
|
toastId,
|
|
serviceAvailable,
|
|
startedAt: serviceAvailable ? new Date() : undefined,
|
|
};
|
|
setOperations((prev) => [...prev, operation]);
|
|
return id;
|
|
}, [showToast]);
|
|
// ============================================================================
|
|
// Retry Queued Operations (availability-based)
|
|
// ============================================================================
|
|
const retryQueued = useCallback(async () => {
|
|
const queued = operations.filter((op) => op.status === 'queued');
|
|
for (const op of queued) {
|
|
try {
|
|
const health = await op.transformer.checkHealth();
|
|
const serviceAvailable = health.available && !health.degraded;
|
|
if (serviceAvailable) {
|
|
// Service is now available, start the operation
|
|
updateOperation(op.id, {
|
|
status: 'running',
|
|
serviceAvailable: true,
|
|
startedAt: new Date(),
|
|
progress: 5,
|
|
});
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.warn(`Health check failed for ${op.transformer.id}:`, error);
|
|
}
|
|
}
|
|
}, [operations, updateOperation]);
|
|
// ============================================================================
|
|
// Periodic Health Check (auto-retry queued operations)
|
|
// ============================================================================
|
|
useEffect(() => {
|
|
healthCheckTimerRef.current = setInterval(() => {
|
|
const queuedCount = operations.filter((op) => op.status === 'queued').length;
|
|
if (queuedCount > 0) {
|
|
retryQueued();
|
|
}
|
|
}, healthCheckInterval);
|
|
return () => {
|
|
if (healthCheckTimerRef.current) {
|
|
clearInterval(healthCheckTimerRef.current);
|
|
}
|
|
};
|
|
}, [healthCheckInterval, operations, retryQueued]);
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
const getOperationMessage = (op) => {
|
|
switch (op.status) {
|
|
case 'queued':
|
|
return `${op.transformer.name} queued (service unavailable)`;
|
|
case 'running':
|
|
return `${op.transformer.name} - ${op.progress}%`;
|
|
case 'completed':
|
|
return `${op.transformer.name} completed`;
|
|
case 'failed':
|
|
return `${op.transformer.name} failed`;
|
|
default:
|
|
return op.transformer.name;
|
|
}
|
|
};
|
|
const getToastType = (status) => {
|
|
switch (status) {
|
|
case 'queued':
|
|
return 'info';
|
|
case 'running':
|
|
return 'loading';
|
|
case 'completed':
|
|
return 'success';
|
|
case 'failed':
|
|
return 'error';
|
|
default:
|
|
return 'info';
|
|
}
|
|
};
|
|
// ============================================================================
|
|
// Render
|
|
// ============================================================================
|
|
return (_jsx(OperationQueueContext.Provider, { value: {
|
|
operations,
|
|
queueOperation,
|
|
updateOperation,
|
|
completeOperation,
|
|
failOperation,
|
|
getOperation,
|
|
getRunningOperations,
|
|
getQueuedOperations,
|
|
clearCompleted,
|
|
retryQueued,
|
|
}, children: children }));
|
|
}
|
|
// ============================================================================
|
|
// Hook
|
|
// ============================================================================
|
|
export function useOperationQueue() {
|
|
const context = useContext(OperationQueueContext);
|
|
if (!context) {
|
|
throw new Error('useOperationQueue must be used within an OperationQueueProvider');
|
|
}
|
|
return context;
|
|
}
|