mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +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:
@@ -234,6 +234,7 @@ describe('NotificationService - routing logic', () => {
|
||||
timestamp: expect.any(Number),
|
||||
stack_name: undefined,
|
||||
container_name: undefined,
|
||||
actor_username: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +251,7 @@ describe('NotificationService - routing logic', () => {
|
||||
timestamp: expect.any(Number),
|
||||
stack_name: 'my-app',
|
||||
container_name: 'my-app-web-1',
|
||||
actor_username: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { dashboardRouter } from './routes/dashboard';
|
||||
import { containersRouter, portsRouter } from './routes/containers';
|
||||
import { nodesRouter } from './routes/nodes';
|
||||
import { stacksRouter } from './routes/stacks';
|
||||
import { stackActivityRouter } from './routes/stackActivity';
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
@@ -117,6 +118,7 @@ app.use('/api/containers', containersRouter);
|
||||
app.use('/api/ports', portsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/stacks', stackActivityRouter);
|
||||
app.use('/api/stacks', stacksRouter);
|
||||
|
||||
const { server, wss, pilotTunnelWss } = createServer(app);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
|
||||
export const stackActivityRouter = Router();
|
||||
|
||||
stackActivityRouter.get('/:stackName/activity', (req: Request, res: Response): void => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
|
||||
const before = req.query.before ? parseInt(String(req.query.before), 10) : undefined;
|
||||
if (before !== undefined && isNaN(before)) {
|
||||
res.status(400).json({ error: 'Invalid before parameter' });
|
||||
return;
|
||||
}
|
||||
const events = DatabaseService.getInstance().getStackActivity(req.nodeId, stackName, { limit, before });
|
||||
res.json({ events });
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../se
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
|
||||
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -31,6 +31,12 @@ function notifyActionFailure(action: string, stackName: string, error: unknown):
|
||||
.catch(err => console.error('[Stacks] Failed to dispatch failure notification for %s:', sanitizeForLog(stackName), err));
|
||||
}
|
||||
|
||||
function notifyActionSuccess(category: NotificationCategory, message: string, stackName: string, actor: string): void {
|
||||
NotificationService.getInstance()
|
||||
.dispatchAlert('info', category, message, { stackName, actor })
|
||||
.catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err));
|
||||
}
|
||||
|
||||
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const stackDir = path.join(fsService.getBaseDir(), stackName);
|
||||
@@ -589,6 +595,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
console.log(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system');
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
|
||||
);
|
||||
@@ -619,6 +626,12 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
|
||||
|
||||
type StackContainerAction = 'restart' | 'stop' | 'start';
|
||||
|
||||
const CONTAINER_ACTION_META: Record<StackContainerAction, { category: NotificationCategory; pastTense: string }> = {
|
||||
restart: { category: 'stack_restarted', pastTense: 'restarted' },
|
||||
stop: { category: 'stack_stopped', pastTense: 'stopped' },
|
||||
start: { category: 'stack_started', pastTense: 'started' },
|
||||
};
|
||||
|
||||
async function bulkContainerOp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -645,6 +658,8 @@ async function bulkContainerOp(
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: `${titleCase} completed via Engine API.` });
|
||||
const { category, pastTense } = CONTAINER_ACTION_META[action];
|
||||
notifyActionSuccess(category, `${stackName} ${pastTense}`, stackName, req.user?.username ?? 'system');
|
||||
} catch (error: unknown) {
|
||||
console.error('[Stacks] %s failed: %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), error);
|
||||
const message = getErrorMessage(error, `Failed to ${action} containers`);
|
||||
@@ -740,6 +755,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
console.log(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system');
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
|
||||
);
|
||||
|
||||
@@ -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