mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
feat(stack): per-stack activity timeline with actor attribution (#852)
* feat(stack): per-stack activity timeline with actor attribution Adds an Activity tab to the Stack Anatomy panel showing a timestamped event log for each stack: deploys, restarts, starts, stops, and image updates, attributed to the user who triggered them or 'system' for automated actions. Backend: - Extends notification_history with actor_username column (idempotent migration) and a partial composite index on (node_id, stack_name, timestamp DESC) for efficient per-stack lookups. - NotificationService.dispatchAlert() accepts an optional actor that is written to the new column. - Success-side dispatchAlert calls added after deploy, bulkContainerOp (start/stop/restart), and update handlers in routes/stacks.ts so user-initiated operations are recorded, not just failures. - New GET /api/stacks/:stackName/activity?limit&before endpoint with stack:read permission gate and cursor-based pagination. Frontend: - StackAnatomyPanel grows an Anatomy / Activity tab pair using the existing Tabs primitive. - StackActivityTimeline fetches the initial 50 events, paginates on demand, and prepends live events arriving over the existing WS notifications stream without duplicates. - NotificationPanel bell dropdown suppresses user-initiated success events (start/stop/restart/deploy/update triggered by a real user), keeping the tray focused on alerts and system events. * docs(stack): add stack activity timeline feature page and internal arch docs * fix(test): add actor_username to notification-routing history assertions dispatchAlert now passes actor_username to addNotificationHistory after the activity timeline PR added the column. Update the two exact-match assertions that were failing because the expected object shape was missing this field.
This commit is contained in:
@@ -203,6 +203,7 @@ export interface NotificationHistory {
|
||||
dispatch_error?: string;
|
||||
stack_name?: string;
|
||||
container_name?: string;
|
||||
actor_username?: string | null;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
@@ -517,6 +518,7 @@ export class DatabaseService {
|
||||
this.migrateAgentsAndNotificationsNodeId();
|
||||
this.migratePolicyEvaluationColumn();
|
||||
this.migrateNotificationCategory();
|
||||
this.migrateNotificationActor();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1238,6 +1240,17 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateNotificationActor(): void {
|
||||
this.tryAddColumn('notification_history', 'actor_username', 'TEXT');
|
||||
try {
|
||||
this.db.prepare(
|
||||
'CREATE INDEX IF NOT EXISTS idx_notif_history_node_stack_ts ON notification_history(node_id, stack_name, timestamp DESC) WHERE stack_name IS NOT NULL'
|
||||
).run();
|
||||
} catch {
|
||||
// index already present or partial-index syntax unsupported
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(nodeId: number): Agent[] {
|
||||
@@ -1511,23 +1524,28 @@ export class DatabaseService {
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] {
|
||||
const sql = category
|
||||
? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?'
|
||||
: 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?';
|
||||
const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit];
|
||||
return this.db.prepare(sql).all(...args).map((row: any) => ({
|
||||
private mapNotificationRow(row: any): NotificationHistory {
|
||||
return {
|
||||
...row,
|
||||
is_read: row.is_read === 1,
|
||||
stack_name: row.stack_name ?? undefined,
|
||||
container_name: row.container_name ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
}));
|
||||
actor_username: row.actor_username ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] {
|
||||
const sql = category
|
||||
? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?'
|
||||
: 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?';
|
||||
const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit];
|
||||
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
|
||||
}
|
||||
|
||||
public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category) VALUES (?, ?, ?, ?, 0, ?, ?, ?)'
|
||||
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(
|
||||
nodeId,
|
||||
@@ -1537,6 +1555,7 @@ export class DatabaseService {
|
||||
notification.stack_name ?? null,
|
||||
notification.container_name ?? null,
|
||||
notification.category ?? null,
|
||||
notification.actor_username ?? null,
|
||||
);
|
||||
|
||||
this.db.prepare(`
|
||||
@@ -1555,9 +1574,20 @@ export class DatabaseService {
|
||||
is_read: false,
|
||||
stack_name: notification.stack_name,
|
||||
container_name: notification.container_name,
|
||||
actor_username: notification.actor_username,
|
||||
};
|
||||
}
|
||||
|
||||
public getStackActivity(nodeId: number, stackName: string, opts: { limit: number; before?: number }): NotificationHistory[] {
|
||||
const sql = opts.before
|
||||
? 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?'
|
||||
: 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? ORDER BY timestamp DESC LIMIT ?';
|
||||
const args: (number | string)[] = opts.before
|
||||
? [nodeId, stackName, opts.before, opts.limit]
|
||||
: [nodeId, stackName, opts.limit];
|
||||
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
|
||||
}
|
||||
|
||||
public markAllNotificationsRead(nodeId: number): void {
|
||||
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?');
|
||||
stmt.run(nodeId);
|
||||
|
||||
@@ -105,9 +105,9 @@ export class NotificationService {
|
||||
level: 'info' | 'warning' | 'error',
|
||||
category: NotificationCategory,
|
||||
message: string,
|
||||
options?: { stackName?: string; containerName?: string },
|
||||
options?: { stackName?: string; containerName?: string; actor?: string },
|
||||
) {
|
||||
const { stackName, containerName } = options ?? {};
|
||||
const { stackName, containerName, actor } = options ?? {};
|
||||
// Internal writes use the middleware default so they share a row key
|
||||
// with user-initiated requests; otherwise the UI and monitors split
|
||||
// between different node_id buckets.
|
||||
@@ -119,6 +119,7 @@ export class NotificationService {
|
||||
timestamp: Date.now(),
|
||||
stack_name: stackName,
|
||||
container_name: containerName,
|
||||
actor_username: actor ?? null,
|
||||
});
|
||||
|
||||
// 2. Push to connected browser clients via WebSocket
|
||||
|
||||
Reference in New Issue
Block a user