Files
sencho/backend/src/index.ts
T
Anso 3e01daf76f 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.
2026-04-30 19:53:23 -04:00

157 lines
6.7 KiB
TypeScript

import express, { Request, Response } from 'express';
import './types/express';
import { authGate, auditLog } from './middleware/authGate';
import { enforceApiTokenScope } from './middleware/apiTokenScope';
import { errorHandler } from './middleware/errorHandler';
import { createApp } from './app';
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
import { createServer } from './server';
import { attachUpgrade } from './websocket/upgradeHandler';
import { startServer } from './bootstrap/startup';
import { installShutdownHandlers } from './bootstrap/shutdown';
import { metaRouter } from './routes/meta';
import { authRouter } from './routes/auth';
import { mfaRouter } from './routes/mfa';
import { ssoRouter } from './routes/sso';
import { licenseRouter, systemUpdateRouter } from './routes/license';
import { webhooksRouter } from './routes/webhooks';
import { usersRouter } from './routes/users';
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
import { fleetRouter } from './routes/fleet';
import { cloudBackupRouter } from './routes/cloudBackup';
import { permissionsRouter } from './routes/permissions';
import { convertRouter } from './routes/convert';
import { alertsRouter } from './routes/alerts';
import { labelsRouter, stackLabelsRouter } from './routes/labels';
import { apiTokensRouter } from './routes/apiTokens';
import { auditLogRouter } from './routes/auditLog';
import { settingsRouter } from './routes/settings';
import { scheduledTasksRouter } from './routes/scheduledTasks';
import { agentsRouter } from './routes/agents';
import { metricsRouter } from './routes/metrics';
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
import { autoHealRouter } from './routes/autoHeal';
import { notificationsRouter, notificationRoutesRouter } from './routes/notifications';
import { consoleRouter } from './routes/console';
import { ssoConfigRouter } from './routes/ssoConfig';
import { registriesRouter } from './routes/registries';
import { systemMaintenanceRouter } from './routes/systemMaintenance';
import { templatesRouter } from './routes/templates';
import { securityRouter } from './routes/security';
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
// first invoked (NOT at import time), so intercepting process.emitWarning here -
// before the proxy instances are created below - fully prevents it.
// http-proxy has no compatible update; this suppression is intentional and safe.
const _origEmitWarning = process.emitWarning.bind(process);
(process as any).emitWarning = (warning: any, ...args: any[]) => {
const code = typeof args[0] === 'object' ? args[0]?.code : args[1];
if (code === 'DEP0060') return;
_origEmitWarning(warning, ...args);
};
const app = createApp();
// Public /api/health and /api/meta (no auth). Mounted before authGate.
app.use('/api', metaRouter);
// Auth / MFA / SSO routers. Mounted before authGate because some paths are
// public (login, setup, SSO callbacks); handlers that need auth use
// authMiddleware directly.
app.use('/api/auth', authRouter);
app.use('/api/auth', mfaRouter);
app.use('/api/auth/sso', ssoRouter);
// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers).
app.use('/api', authGate);
// Audit-log every mutating /api/* action (POST/PUT/DELETE/PATCH).
app.use('/api', auditLog);
app.use('/api', enforceApiTokenScope);
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted BEFORE the
// per-group routers so a request targeting a remote node short-circuits into
// the proxy instead of hitting a local handler that would read local state.
// Gateway-level paths (auth, nodes, license, fleet, webhooks, meta) are listed
// in helpers/proxyExemptPaths.ts and bypass the proxy back to the local
// handlers below.
app.use('/api/', createRemoteProxyMiddleware());
app.use('/api/license', licenseRouter);
app.use('/api/system', systemUpdateRouter);
app.use('/api/permissions', permissionsRouter);
app.use('/api/convert', convertRouter);
app.use('/api/alerts', alertsRouter);
app.use('/api/labels', labelsRouter);
app.use('/api/stacks', stackLabelsRouter);
app.use('/api/api-tokens', apiTokensRouter);
app.use('/api/audit-log', auditLogRouter);
app.use('/api/fleet', fleetRouter);
app.use('/api/cloud-backup', cloudBackupRouter);
app.use('/api/webhooks', webhooksRouter);
app.use('/api/users', usersRouter);
app.use('/api/git-sources', gitSourcesRouter);
app.use('/api/stacks', stackGitSourceRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/scheduled-tasks', scheduledTasksRouter);
app.use('/api/agents', agentsRouter);
app.use('/api', metricsRouter);
app.use('/api/image-updates', imageUpdatesRouter);
app.use('/api/auto-update', autoUpdateRouter);
app.use('/api/auto-heal', autoHealRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/notification-routes', notificationRoutesRouter);
app.use('/api/system', consoleRouter);
app.use('/api/sso/config', ssoConfigRouter);
app.use('/api/registries', registriesRouter);
app.use('/api/system', systemMaintenanceRouter);
app.use('/api/templates', templatesRouter);
app.use('/api/security', securityRouter);
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);
attachUpgrade(server, { wss, pilotTunnelWss });
// Static / SPA fallback. Production serves the built frontend; dev returns a
// JSON 404 for unmatched /api paths to prevent fetch hangs.
if (process.env.NODE_ENV === 'production') {
app.use(express.static('public'));
app.use((req: Request, res: Response) => {
if (!req.path.startsWith('/api')) {
res.sendFile('index.html', { root: 'public' });
} else {
res.status(404).json({ error: 'API endpoint not found' });
}
});
} else {
app.use((req: Request, res: Response) => {
if (req.path.startsWith('/api')) {
res.status(404).json({ error: 'API endpoint not found' });
}
});
}
// Central error handler: must be registered after all routes and static.
app.use(errorHandler);
installShutdownHandlers(server);
if (require.main === module) {
void startServer(server);
}
// Exports used by tests (supertest requires the http.Server instance).
export { app, server };