diff --git a/backend/src/__tests__/docker-event-service.test.ts b/backend/src/__tests__/docker-event-service.test.ts index 328cf818..657cbe68 100644 --- a/backend/src/__tests__/docker-event-service.test.ts +++ b/backend/src/__tests__/docker-event-service.test.ts @@ -121,6 +121,7 @@ describe('DockerEventService - die classification', () => { 'error', expect.stringContaining('Container Crash Detected'), undefined, + 'web', ); }); @@ -188,6 +189,7 @@ describe('DockerEventService - die classification', () => { 'error', expect.stringContaining('OOM Kill'), undefined, + 'hog', ); }); @@ -220,6 +222,7 @@ describe('DockerEventService - die classification', () => { 'error', expect.stringContaining('Healthcheck failed'), undefined, + 'api', ); }); @@ -314,6 +317,7 @@ describe('DockerEventService - malformed payloads', () => { 'error', expect.stringContaining('Container Crash Detected'), undefined, + 'ok', ); }); }); @@ -497,6 +501,7 @@ describe('DockerEventService - hardening', () => { 'error', expect.stringContaining('Container Crash Detected'), undefined, + 'app', ); }); @@ -550,6 +555,7 @@ describe('DockerEventService - hardening', () => { 'error', expect.stringContaining('Container Crash Detected'), undefined, + 'ephemeral', ); }); diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 2df01a98..22790f37 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -214,6 +214,23 @@ describe('NotificationService - routing logic', () => { level: 'info', message: 'Should be logged', timestamp: expect.any(Number), + stack_name: undefined, + container_name: undefined, + }); + }); + + it('persists stackName and containerName context on the history row', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1'); + + expect(mockAddNotificationHistory).toHaveBeenCalledWith({ + level: 'warning', + message: 'Restarted', + timestamp: expect.any(Number), + stack_name: 'my-app', + container_name: 'my-app-web-1', }); }); diff --git a/backend/src/services/AutoHealService.ts b/backend/src/services/AutoHealService.ts index 0e8e78a2..2736583f 100644 --- a/backend/src/services/AutoHealService.ts +++ b/backend/src/services/AutoHealService.ts @@ -244,6 +244,7 @@ export class AutoHealService { 'info', `Auto-Heal: Restarted ${containerName} on stack ${policy.stack_name} after being unhealthy for ${policy.unhealthy_duration_mins} minute(s).`, policy.stack_name, + containerName, ) .catch(err => console.error('[AutoHeal] notification dispatch failed:', err)); } catch (err) { @@ -267,6 +268,7 @@ export class AutoHealService { 'warning', `Auto-Heal: Failed to restart ${containerName} on stack ${policy.stack_name}. Error: ${errorMsg}`, policy.stack_name, + containerName, ) .catch(e => console.error('[AutoHeal] notification dispatch failed:', e)); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 21a7e018..4c71bec9 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -199,6 +199,8 @@ export interface NotificationHistory { timestamp: number; is_read: boolean; dispatch_error?: string; + stack_name?: string; + container_name?: string; } export interface FleetSnapshot { @@ -444,6 +446,7 @@ export class DatabaseService { this.migrateRegistries(); this.migrateRoleAssignments(); this.migrateNotificationRoutes(); + this.migrateNotificationHistoryContext(); this.migrateScanPolicyFleetColumns(); this.migrateSecretMisconfigColumns(); } @@ -1076,6 +1079,18 @@ export class DatabaseService { try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ } } + private migrateNotificationHistoryContext(): void { + const tryAddColumn = (col: string, def: string) => { + try { + this.db.prepare(`ALTER TABLE notification_history ADD COLUMN ${col} ${def}`).run(); + } catch { + /* column already present */ + } + }; + tryAddColumn('stack_name', 'TEXT'); + tryAddColumn('container_name', 'TEXT'); + } + private migrateScanPolicyFleetColumns(): void { const tryAddColumn = (table: string, col: string, def: string) => { try { @@ -1360,13 +1375,23 @@ export class DatabaseService { const stmt = this.db.prepare('SELECT * FROM notification_history ORDER BY timestamp DESC LIMIT ?'); return stmt.all(limit).map((row: any) => ({ ...row, - is_read: row.is_read === 1 + is_read: row.is_read === 1, + stack_name: row.stack_name ?? undefined, + container_name: row.container_name ?? undefined, })); } public addNotificationHistory(notification: Omit): NotificationHistory { - const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)'); - const result = stmt.run(notification.level, notification.message, notification.timestamp); + const stmt = this.db.prepare( + 'INSERT INTO notification_history (level, message, timestamp, is_read, stack_name, container_name) VALUES (?, ?, ?, 0, ?, ?)' + ); + const result = stmt.run( + notification.level, + notification.message, + notification.timestamp, + notification.stack_name ?? null, + notification.container_name ?? null + ); this.db.exec(` DELETE FROM notification_history @@ -1381,6 +1406,8 @@ export class DatabaseService { message: notification.message, timestamp: notification.timestamp, is_read: false, + stack_name: notification.stack_name, + container_name: notification.container_name, }; } diff --git a/backend/src/services/DockerEventService.ts b/backend/src/services/DockerEventService.ts index bdedfc25..ef694412 100644 --- a/backend/src/services/DockerEventService.ts +++ b/backend/src/services/DockerEventService.ts @@ -426,6 +426,7 @@ export class DockerEventService { void this.emitError( `Healthcheck failed: ${name} is unhealthy.`, stackName, + state.name, ); } else { state.unhealthySince = undefined; @@ -535,7 +536,7 @@ export class DockerEventService { // rate-suppressed alerts don't silently lock out the next real crash. if (state) state.lastCrashAlertAt = Date.now(); - await this.emitError(message, info.stackName); + await this.emitError(message, info.stackName, info.name); } private isCrashAlertsEnabled(): boolean { @@ -641,16 +642,16 @@ export class DockerEventService { // Notification wrappers (prefix with node name for multi-node clarity) // ======================================================================== - private async emitError(message: string, stackName?: string): Promise { - return this.notifier.dispatchAlert('error', this.prefix(message), stackName); + private async emitError(message: string, stackName?: string, containerName?: string): Promise { + return this.notifier.dispatchAlert('error', this.prefix(message), stackName, containerName); } - private async emitWarning(message: string, stackName?: string): Promise { - return this.notifier.dispatchAlert('warning', this.prefix(message), stackName); + private async emitWarning(message: string, stackName?: string, containerName?: string): Promise { + return this.notifier.dispatchAlert('warning', this.prefix(message), stackName, containerName); } - private async emitInfo(message: string, stackName?: string): Promise { - return this.notifier.dispatchAlert('info', this.prefix(message), stackName); + private async emitInfo(message: string, stackName?: string, containerName?: string): Promise { + return this.notifier.dispatchAlert('info', this.prefix(message), stackName, containerName); } private prefix(message: string): string { diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 7d2d479a..7ccd1b5d 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -39,12 +39,19 @@ export class NotificationService { * - agents table (all tiers): global fallback channels used when no * notification_routes match or when no stackName is provided. */ - public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string, stackName?: string) { + public async dispatchAlert( + level: 'info' | 'warning' | 'error', + message: string, + stackName?: string, + containerName?: string, + ) { // 1. Log to history and get the full inserted record (with id) const notification = this.dbService.addNotificationHistory({ level, message, - timestamp: Date.now() + timestamp: Date.now(), + stack_name: stackName, + container_name: containerName, }); // 2. Push to connected browser clients via WebSocket diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7fd24b6c..03b4bf34 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -239,6 +239,7 @@ export default function EditorLayout() { const [gitSourcePendingMap, setGitSourcePendingMap] = useState>({}); const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(null); + const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -1044,6 +1045,27 @@ export default function EditorLayout() { }; }, [containers]); // eslint-disable-line react-hooks/exhaustive-deps + // Resolve a pending container name (from notification click) to a live + // container id once the target stack's container list loads, then dispatch + // the logs event. Only consume when the current stack matches the pending + // target — prevents a canceled unsaved-load from leaking the pending name + // into an unrelated container refresh. Container ids churn across + // recreations, so we store the name and resolve here instead of storing an + // id at dispatch time. + useEffect(() => { + const pending = pendingLogsRef.current; + if (!pending || selectedFile !== pending.stackName || containers.length === 0) return; + pendingLogsRef.current = null; + const match = containers.find(c => + (c.Names ?? []).some(n => n.replace(/^\//, '') === pending.containerName), + ); + if (match) { + window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { + detail: { containerId: match.Id, containerName: pending.containerName }, + })); + } + }, [containers, selectedFile]); + const hasUnsavedChanges = () => content !== originalContent || envContent !== originalEnvContent; @@ -1157,6 +1179,21 @@ export default function EditorLayout() { } }; + const navigateToNotification = (notif: NotificationItem) => { + if (!notif.stack_name) return; + pendingLogsRef.current = notif.container_name + ? { stackName: notif.stack_name, containerName: notif.container_name } + : null; + const targetNode = notif.nodeId !== undefined + ? nodes.find(n => n.id === notif.nodeId) + : activeNode; + if (targetNode && targetNode.id !== activeNode?.id) { + loadFileOnNode(targetNode, notif.stack_name); + } else { + loadFile(notif.stack_name); + } + }; + const changeEnvFile = async (file: string) => { setSelectedEnvFile(file); setIsFileLoading(true); @@ -2536,6 +2573,7 @@ export default function EditorLayout() { onMarkAllRead={markAllRead} onClearAll={clearAllNotifications} onDelete={deleteNotification} + onNavigate={navigateToNotification} /> diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index ddc10378..045cf612 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -89,6 +89,7 @@ interface NotificationPanelProps { onMarkAllRead: () => void; onClearAll: () => void; onDelete: (notif: NotificationItem) => void; + onNavigate?: (notif: NotificationItem) => void; } export function NotificationPanel({ @@ -97,8 +98,10 @@ export function NotificationPanel({ onMarkAllRead, onClearAll, onDelete, + onNavigate, }: NotificationPanelProps) { const [filter, setFilter] = useState('all'); + const [open, setOpen] = useState(false); const unreadCount = useMemo( () => notifications.filter((n) => !n.is_read).length, @@ -137,8 +140,14 @@ export function NotificationPanel({ ) : null; + const handleNavigate = (notif: NotificationItem) => { + if (!onNavigate || !notif.stack_name) return; + onNavigate(notif); + setOpen(false); + }; + return ( - + + ) : ( +
{content}
+ )}