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:
Fimeg
2025-12-20 13:47:36 -05:00
parent 54c554ac7c
commit 62697df112
19 changed files with 1405 additions and 18 deletions

View 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);
});
}

View File

@@ -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;

View 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(() => {});
});
}

View 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,
};
}