mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"features/global-search",
|
||||
"features/sidebar",
|
||||
"features/stack-management",
|
||||
"features/stack-activity",
|
||||
"features/editor",
|
||||
"features/stack-file-explorer",
|
||||
"features/deploy-progress",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Stack Activity
|
||||
description: Per-stack event log showing deploys, restarts, config changes, and alerts, each attributed to the user or system that triggered them.
|
||||
---
|
||||
|
||||
The **Activity** tab inside each stack's panel gives you a timestamped record of everything that happened to that stack: deploys, restarts, starts, stops, and image updates. Each entry shows who triggered the event so you always have operational context.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-activity/activity-tab-populated.png" alt="Stack Activity tab showing a restart event attributed to a user" />
|
||||
</Frame>
|
||||
|
||||
## What you'll see
|
||||
|
||||
Each entry in the activity list contains:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Icon** | Category of the event (deploy, restart, stop, start, update) |
|
||||
| **Message** | What happened (e.g. "bazarr restarted") |
|
||||
| **Attributed to** | `by <username>` for user-initiated actions, omitted for automated system events |
|
||||
| **Time** | Relative time since the event (e.g. "just now", "5m ago", "2h ago") |
|
||||
|
||||
Events are grouped by day: **Today**, **Yesterday**, and **Earlier**.
|
||||
|
||||
## Who triggered an event
|
||||
|
||||
Sencho attributes every recorded event to the user who was authenticated when the action was taken. If the action was triggered by an automated process (auto-heal, image update polling, or any other system process) the event is recorded as a system action and the attribution label is omitted from the display.
|
||||
|
||||
## Accessing the Activity tab
|
||||
|
||||
1. Click any stack in the left sidebar to open its panel.
|
||||
2. Switch to the **Activity** tab in the panel header.
|
||||
|
||||
The tab loads the most recent 50 events immediately and streams new events live as they happen without a page refresh.
|
||||
|
||||
## Pagination
|
||||
|
||||
If a stack has more than 50 recorded events, a **Load more** button appears at the bottom of the list. Each click fetches the next 50 older events.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**The Activity tab shows "No activity recorded yet"**
|
||||
|
||||
The activity log is populated by operations performed through Sencho — deploys, restarts, starts, stops, and image updates — since the Sencho instance started tracking them. If you have not performed any of these operations on this stack via Sencho since the feature was enabled, the tab will be empty. Trigger a restart or deploy and the entry appears within a few seconds.
|
||||
|
||||
**Events appear in the bell notification dropdown**
|
||||
|
||||
The notification dropdown shows system alerts and error-level events only. User-initiated operational events (start, stop, restart, deploy, update) appear exclusively in the per-stack Activity tab rather than the global notification tray, keeping the tray focused on conditions that need your attention.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -2975,6 +2975,7 @@ export default function EditorLayout() {
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
notifications={notifications}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,13 +89,25 @@ function formatRelative(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const USER_OP_CATEGORIES = new Set([
|
||||
'deploy_success', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied',
|
||||
]);
|
||||
|
||||
function isUserInitiatedSuccess(n: NotificationItem): boolean {
|
||||
return n.level === 'info'
|
||||
&& n.category !== undefined
|
||||
&& USER_OP_CATEGORIES.has(n.category)
|
||||
&& n.actor_username != null
|
||||
&& n.actor_username !== 'system';
|
||||
}
|
||||
|
||||
function applyFilter(
|
||||
items: NotificationItem[],
|
||||
filter: NotifFilter,
|
||||
nodeFilter: NodeFilter,
|
||||
categoryFilter: CategoryFilter,
|
||||
): NotificationItem[] {
|
||||
let result = items;
|
||||
let result = items.filter(n => !isUserInitiatedSuccess(n));
|
||||
if (filter === 'unread') result = result.filter((n) => !n.is_read);
|
||||
else if (filter === 'alerts') result = result.filter((n) => n.level === 'warning' || n.level === 'error');
|
||||
if (nodeFilter !== NODE_FILTER_ALL) result = result.filter((n) => n.nodeId === nodeFilter);
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
stackName: string;
|
||||
@@ -16,6 +19,7 @@ interface StackAnatomyPanelProps {
|
||||
onApplyUpdate: () => void;
|
||||
onOpenFiles?: () => void;
|
||||
canEdit: boolean;
|
||||
notifications?: NotificationItem[];
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -232,6 +236,7 @@ export default function StackAnatomyPanel({
|
||||
onApplyUpdate,
|
||||
onOpenFiles,
|
||||
canEdit,
|
||||
notifications,
|
||||
}: StackAnatomyPanelProps) {
|
||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
||||
@@ -327,8 +332,12 @@ export default function StackAnatomyPanel({
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-2">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">anatomy</span>
|
||||
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
|
||||
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
{onOpenFiles && (
|
||||
<button
|
||||
@@ -348,11 +357,15 @@ export default function StackAnatomyPanel({
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" strokeWidth={1.5} />
|
||||
edit compose.yaml
|
||||
edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
||||
<StackActivityTimeline stackName={stackName} liveEvents={notifications?.filter(n => n.stack_name === stackName)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="anatomy" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3">
|
||||
{!anatomy ? (
|
||||
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
|
||||
@@ -511,6 +524,8 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface NotificationItem {
|
||||
nodeName?: string;
|
||||
stack_name?: string;
|
||||
container_name?: string;
|
||||
actor_username?: string | null;
|
||||
}
|
||||
|
||||
export interface StackStatusEntry {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface ActivityEvent {
|
||||
id: number;
|
||||
level: string;
|
||||
category?: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
stack_name?: string;
|
||||
actor_username?: string | null;
|
||||
}
|
||||
|
||||
interface StackActivityTimelineProps {
|
||||
stackName: string;
|
||||
liveEvents?: NotificationItem[];
|
||||
}
|
||||
|
||||
const CATEGORY_ICON: Record<string, LucideIcon> = {
|
||||
deploy_success: Rocket,
|
||||
stack_restarted: RefreshCcw,
|
||||
stack_stopped: CircleStop,
|
||||
stack_started: Play,
|
||||
image_update_applied: ArrowUp,
|
||||
};
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function dayLabel(ts: number): 'Today' | 'Yesterday' | 'Earlier' {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayMs = todayStart.getTime();
|
||||
if (ts >= todayMs) return 'Today';
|
||||
if (ts >= todayMs - DAY_MS) return 'Yesterday';
|
||||
return 'Earlier';
|
||||
}
|
||||
|
||||
function groupEvents(events: ActivityEvent[]): { label: string; events: ActivityEvent[] }[] {
|
||||
const groups: Record<string, ActivityEvent[]> = {};
|
||||
const order: string[] = [];
|
||||
for (const e of events) {
|
||||
const label = dayLabel(e.timestamp);
|
||||
if (!groups[label]) { groups[label] = []; order.push(label); }
|
||||
groups[label].push(e);
|
||||
}
|
||||
return order.map(label => ({ label, events: groups[label] }));
|
||||
}
|
||||
|
||||
export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTimelineProps) {
|
||||
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const seenIdsRef = useRef(new Set<number>());
|
||||
|
||||
const mergeEvents = useCallback((incoming: ActivityEvent[]) => {
|
||||
setEvents(prev => {
|
||||
const next = [...prev];
|
||||
let added = false;
|
||||
for (const e of incoming) {
|
||||
if (seenIdsRef.current.has(e.id)) continue;
|
||||
seenIdsRef.current.add(e.id);
|
||||
next.push(e);
|
||||
added = true;
|
||||
}
|
||||
if (!added) return prev;
|
||||
next.sort((a, b) => b.timestamp - a.timestamp);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
seenIdsRef.current = new Set();
|
||||
setEvents([]);
|
||||
setHasMore(true);
|
||||
|
||||
apiFetch(`/stacks/${stackName}/activity?limit=50`)
|
||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((data: { events: ActivityEvent[] }) => {
|
||||
if (cancelled) return;
|
||||
setHasMore(data.events.length === 50);
|
||||
data.events.forEach(e => seenIdsRef.current.add(e.id));
|
||||
setEvents(data.events);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setEvents([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName]);
|
||||
|
||||
// liveEvents is pre-filtered by stack_name in the parent
|
||||
useEffect(() => {
|
||||
if (!liveEvents || liveEvents.length === 0) return;
|
||||
mergeEvents(liveEvents as ActivityEvent[]);
|
||||
}, [liveEvents, mergeEvents]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const oldest = events[events.length - 1]?.timestamp;
|
||||
if (!oldest) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const r = await apiFetch(`/stacks/${stackName}/activity?limit=50&before=${oldest}`);
|
||||
if (!r.ok) return;
|
||||
const data: { events: ActivityEvent[] } = await r.json();
|
||||
setHasMore(data.events.length === 50);
|
||||
mergeEvents(data.events);
|
||||
} catch {
|
||||
toast.error('Failed to load more activity');
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [events, stackName, mergeEvents]);
|
||||
|
||||
const groups = useMemo(() => groupEvents(events), [events]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-2">
|
||||
<Activity className="w-5 h-5 text-muted-foreground/40" />
|
||||
<span className="font-mono text-[11px] text-muted-foreground">No activity recorded yet</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
{groups.map(g => (
|
||||
<div key={g.label}>
|
||||
<div className="font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle mb-1.5 px-1">{g.label}</div>
|
||||
{g.events.map(e => {
|
||||
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
|
||||
return (
|
||||
<div key={e.id} className="flex items-start gap-2 py-1.5 px-1 rounded-md hover:bg-glass-highlight/30 transition-colors">
|
||||
<Icon className="w-3 h-3 mt-0.5 shrink-0 text-brand/70" strokeWidth={1.5} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-mono text-[11px] text-foreground/90">{e.message}</span>
|
||||
{e.actor_username && e.actor_username !== 'system' && (
|
||||
<span className="ml-1.5 font-mono text-[10px] text-stat-subtitle">by {e.actor_username}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 font-mono text-[10px] text-muted-foreground"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Load more'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user