v0.1.27 release: Complete implementation
Features: - Error logging system with ETHOS #1 compliance - Command factory pattern with UUID generation - Hardware binding with machine fingerprint validation - Ed25519 cryptographic signing for updates - Deduplication and idempotency for commands - Circuit breakers and retry logic - Frontend error logging integration Bug Fixes: - Version display using compile-time injection - Migration 017 CONCURRENTLY issue resolved - Docker build context fixes - Rate limiting implementation verified Documentation: - README updated to reflect actual implementation - v0.1.27 inventory analysis added
This commit is contained in:
108
aggregator-web/src/hooks/useScanState.ts
Normal file
108
aggregator-web/src/hooks/useScanState.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { toastWithLogging } from '@/lib/toast-with-logging';
|
||||
|
||||
interface ScanState {
|
||||
isScanning: boolean;
|
||||
commandId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing scan button state and preventing duplicate scans
|
||||
* Integrates with backend deduplication (409 Conflict responses)
|
||||
*/
|
||||
export function useScanState(agentId: string, subsystem: string) {
|
||||
const [state, setState] = useState<ScanState>({
|
||||
isScanning: false,
|
||||
});
|
||||
|
||||
const triggerScan = useCallback(async () => {
|
||||
if (state.isScanning) {
|
||||
toastWithLogging.info('Scan already in progress', { subsystem });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ isScanning: true, commandId: undefined, error: undefined });
|
||||
|
||||
try {
|
||||
const result = await api.post(`/agents/${agentId}/subsystems/${subsystem}/trigger`);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
commandId: result.data.command_id,
|
||||
}));
|
||||
|
||||
// Poll for completion or wait for subscription update
|
||||
await waitForScanComplete(agentId, result.data.command_id);
|
||||
|
||||
setState({ isScanning: false, commandId: result.data.command_id });
|
||||
|
||||
toastWithLogging.success(`${subsystem} scan completed`, { subsystem });
|
||||
} catch (error: any) {
|
||||
const isAlreadyRunning = error.response?.status === 409;
|
||||
|
||||
if (isAlreadyRunning) {
|
||||
const existingCommandId = error.response?.data?.command_id;
|
||||
setState({
|
||||
isScanning: false,
|
||||
commandId: existingCommandId,
|
||||
error: 'Scan already in progress',
|
||||
});
|
||||
|
||||
toastWithLogging.info(`Scan already running (command: ${existingCommandId})`, { subsystem });
|
||||
} else {
|
||||
const errorMessage = error.response?.data?.error || error.message;
|
||||
setState({
|
||||
isScanning: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
toastWithLogging.error(`Failed to trigger scan: ${errorMessage}`, { subsystem });
|
||||
}
|
||||
}
|
||||
}, [agentId, subsystem, state.isScanning]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ isScanning: false, commandId: undefined, error: undefined });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isScanning: state.isScanning,
|
||||
commandId: state.commandId,
|
||||
error: state.error,
|
||||
triggerScan,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for scan to complete by polling command status
|
||||
* Max wait: 5 minutes
|
||||
*/
|
||||
async function waitForScanComplete(agentId: string, commandId: string): Promise<void> {
|
||||
const maxWaitMs = 300000; // 5 minutes max
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 2000; // Poll every 2 seconds
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const result = await api.get(`/agents/${agentId}/commands/${commandId}`);
|
||||
|
||||
if (result.data.status === 'completed' || result.data.status === 'failed') {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
reject(error);
|
||||
}
|
||||
|
||||
if (Date.now() - startTime > maxWaitMs) {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Scan timeout'));
|
||||
}
|
||||
}, pollInterval);
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,43 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Error logging interceptor
|
||||
import { clientErrorLogger } from './client-error-logger';
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Don't log errors from the error logger itself
|
||||
if (error.config?.headers?.['X-Error-Logger-Request']) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Extract subsystem from URL
|
||||
const subsystem = extractSubsystem(error.config?.url);
|
||||
|
||||
// Log API errors
|
||||
clientErrorLogger.logError({
|
||||
subsystem,
|
||||
error_type: 'api_error',
|
||||
message: error.message,
|
||||
metadata: {
|
||||
status_code: error.response?.status,
|
||||
endpoint: error.config?.url,
|
||||
method: error.config?.method,
|
||||
response_data: error.response?.data,
|
||||
},
|
||||
}).catch(() => {
|
||||
// Don't let logging errors hide the original error
|
||||
});
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
function extractSubsystem(url: string = ''): string {
|
||||
const matches = url.match(/\/(storage|system|docker|updates|agent)/);
|
||||
return matches ? matches[1] : 'unknown';
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
export const agentApi = {
|
||||
// Get all agents
|
||||
@@ -876,4 +913,7 @@ export const storageMetricsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Named export for api instance
|
||||
export { api };
|
||||
|
||||
export default api;
|
||||
166
aggregator-web/src/lib/client-error-logger.ts
Normal file
166
aggregator-web/src/lib/client-error-logger.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { api, ApiError } from './api';
|
||||
|
||||
export interface ClientErrorLog {
|
||||
subsystem: string;
|
||||
error_type: 'javascript_error' | 'api_error' | 'ui_error' | 'validation_error';
|
||||
message: string;
|
||||
stack_trace?: string;
|
||||
metadata?: Record<string, any>;
|
||||
url: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClientErrorLogger provides reliable frontend error logging with retry logic
|
||||
* Implements ETHOS #3: Assume Failure; Build for Resilience
|
||||
*/
|
||||
export class ClientErrorLogger {
|
||||
private maxRetries = 3;
|
||||
private baseDelayMs = 1000;
|
||||
private localStorageKey = 'redflag-error-queue';
|
||||
private offlineBuffer: ClientErrorLog[] = [];
|
||||
private isOnline = navigator.onLine;
|
||||
|
||||
constructor() {
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', () => this.flushOfflineBuffer());
|
||||
window.addEventListener('offline', () => { this.isOnline = false; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error with automatic retry and offline queuing
|
||||
*/
|
||||
async logError(errorData: Omit<ClientErrorLog, 'url' | 'timestamp'>): Promise<void> {
|
||||
const fullError: ClientErrorLog = {
|
||||
...errorData,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Try to send immediately
|
||||
try {
|
||||
await this.sendWithRetry(fullError);
|
||||
return;
|
||||
} catch (error) {
|
||||
// If failed after retries, queue for later
|
||||
this.queueForRetry(fullError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error to backend with exponential backoff retry
|
||||
*/
|
||||
private async sendWithRetry(error: ClientErrorLog): Promise<void> {
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
await api.post('/logs/client-error', error, {
|
||||
headers: { 'X-Error-Logger-Request': 'true' },
|
||||
});
|
||||
|
||||
// Success, remove from queue if it was there
|
||||
this.removeFromQueue(error);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === this.maxRetries) {
|
||||
throw error; // Rethrow after final attempt
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
await this.sleep(this.baseDelayMs * Math.pow(2, attempt - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue error for retry when network is available
|
||||
*/
|
||||
private queueForRetry(error: ClientErrorLog): void {
|
||||
try {
|
||||
const queue = this.getQueue();
|
||||
queue.push({
|
||||
...error,
|
||||
retryCount: (error as any).retryCount || 0,
|
||||
queuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(this.localStorageKey, JSON.stringify(queue));
|
||||
|
||||
// Also keep in memory buffer
|
||||
this.offlineBuffer.push(error);
|
||||
} catch (storageError) {
|
||||
// localStorage might be full or unavailable
|
||||
console.warn('Failed to queue error for retry:', storageError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush offline buffer when coming back online
|
||||
*/
|
||||
private async flushOfflineBuffer(): Promise<void> {
|
||||
if (!this.isOnline) return;
|
||||
|
||||
const queue = this.getQueue();
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const failed: typeof queue = [];
|
||||
|
||||
for (const queuedError of queue) {
|
||||
try {
|
||||
await this.sendWithRetry(queuedError);
|
||||
} catch (error) {
|
||||
failed.push(queuedError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update queue with remaining failed items
|
||||
if (failed.length < queue.length) {
|
||||
localStorage.setItem(this.localStorageKey, JSON.stringify(failed));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current error queue from localStorage
|
||||
*/
|
||||
private getQueue(): any[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.localStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove successfully sent error from queue
|
||||
*/
|
||||
private removeFromQueue(sentError: ClientErrorLog): void {
|
||||
try {
|
||||
const queue = this.getQueue();
|
||||
const filtered = queue.filter(queued =>
|
||||
queued.timestamp !== sentError.timestamp ||
|
||||
queued.message !== sentError.message
|
||||
);
|
||||
|
||||
if (filtered.length < queue.length) {
|
||||
localStorage.setItem(this.localStorageKey, JSON.stringify(filtered));
|
||||
}
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const clientErrorLogger = new ClientErrorLogger();
|
||||
|
||||
// Auto-retry failed logs on app load
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
clientErrorLogger.flushOfflineBuffer().catch(() => {});
|
||||
});
|
||||
}
|
||||
76
aggregator-web/src/lib/toast-with-logging.ts
Normal file
76
aggregator-web/src/lib/toast-with-logging.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import toast, { ToastOptions } from 'react-hot-toast';
|
||||
import { clientErrorLogger } from './client-error-logger';
|
||||
|
||||
/**
|
||||
* Extract subsystem from current route
|
||||
*/
|
||||
function getCurrentSubsystem(): string {
|
||||
if (typeof window === 'undefined') return 'unknown';
|
||||
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Map routes to subsystems
|
||||
if (path.includes('/storage')) return 'storage';
|
||||
if (path.includes('/system')) return 'system';
|
||||
if (path.includes('/docker')) return 'docker';
|
||||
if (path.includes('/updates')) return 'updates';
|
||||
if (path.includes('/agent/')) return 'agent';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap toast.error to automatically log errors to backend
|
||||
* Implements ETHOS #1: Errors are History
|
||||
*/
|
||||
export const toastWithLogging = {
|
||||
error: (message: string, options?: ToastOptions & { subsystem?: string }) => {
|
||||
const subsystem = options?.subsystem || getCurrentSubsystem();
|
||||
|
||||
// Log to backend asynchronously - don't block UI
|
||||
clientErrorLogger.logError({
|
||||
subsystem,
|
||||
error_type: 'ui_error',
|
||||
message: message.substring(0, 5000), // Prevent excessively long messages
|
||||
metadata: {
|
||||
component: options?.id,
|
||||
duration: options?.duration,
|
||||
position: options?.position,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}).catch(() => {
|
||||
// Silently ignore logging failures - don't crash the UI
|
||||
});
|
||||
|
||||
// Show toast to user
|
||||
return toast.error(message, options);
|
||||
},
|
||||
|
||||
// Passthrough methods
|
||||
success: toast.success,
|
||||
info: toast.info,
|
||||
warning: toast.warning,
|
||||
loading: toast.loading,
|
||||
dismiss: toast.dismiss,
|
||||
remove: toast.remove,
|
||||
promise: toast.promise,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for toast with automatic subsystem detection
|
||||
*/
|
||||
export function useToastWithLogging() {
|
||||
return {
|
||||
error: (message: string, options?: ToastOptions & { subsystem?: string }) => {
|
||||
return toastWithLogging.error(message, {
|
||||
...options,
|
||||
subsystem: options?.subsystem || getCurrentSubsystem(),
|
||||
});
|
||||
},
|
||||
success: toast.success,
|
||||
info: toast.info,
|
||||
warning: toast.warning,
|
||||
loading: toast.loading,
|
||||
dismiss: toast.dismiss,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user