Files
sencho/frontend/src/components/StackAnatomyPanel.tsx
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

532 lines
20 KiB
TypeScript

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;
content: string;
envContent: string;
selectedEnvFile: string;
gitSourcePending: boolean;
onEditCompose: () => void;
onOpenGitSource: () => void;
onApplyUpdate: () => void;
onOpenFiles?: () => void;
canEdit: boolean;
notifications?: NotificationItem[];
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
interface UpdatePreviewSummary {
has_update: boolean;
primary_image: string | null;
current_tag: string | null;
next_tag: string | null;
semver_bump: SemverBump;
blocked: boolean;
blocked_reason: string | null;
}
interface UpdatePreview {
summary: UpdatePreviewSummary;
changelog: string | null;
}
interface GitSourceInfo {
repo_url: string;
branch: string;
compose_path?: string;
}
interface PortRow {
host: string;
container: string;
proto: string;
}
interface VolumeRow {
host: string;
container: string;
}
interface Anatomy {
services: string[];
ports: Record<string, PortRow[]>;
volumes: Record<string, VolumeRow[]>;
restart: string | null;
envFiles: string[];
networks: string[];
referencedVars: string[];
}
// Matches ${VAR}, ${VAR:-default}, ${VAR-default}, ${VAR:?err}, ${VAR?err}.
// Capture group 1 is the variable name, group 2 (optional) is the modifier form.
const INTERPOLATION_REGEX = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])[^}]*)?\}/g;
function parsePortMapping(raw: unknown): PortRow | null {
if (typeof raw === 'string') {
const s = raw.replace(/^"|"$/g, '');
const protoMatch = s.match(/\/(tcp|udp)$/i);
const proto = protoMatch ? protoMatch[1].toLowerCase() : 'tcp';
const body = proto ? s.replace(/\/(tcp|udp)$/i, '') : s;
const parts = body.split(':');
if (parts.length === 2) return { host: parts[0], container: parts[1], proto };
if (parts.length === 3) return { host: parts[1], container: parts[2], proto };
return { host: body, container: body, proto };
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const host = obj.published !== undefined ? String(obj.published) : '';
const container = obj.target !== undefined ? String(obj.target) : '';
const proto = obj.protocol ? String(obj.protocol) : 'tcp';
if (host && container) return { host, container, proto };
}
return null;
}
function parseVolumeMapping(raw: unknown): VolumeRow | null {
if (typeof raw === 'string') {
const parts = raw.split(':');
if (parts.length >= 2) return { host: parts[0], container: parts[1] };
return null;
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
if (obj.source && obj.target) return { host: String(obj.source), container: String(obj.target) };
}
return null;
}
interface ServiceAnatomy {
ports: PortRow[];
volumes: VolumeRow[];
restart: string | null;
envFiles: string[];
networks: string[];
}
function parseServiceBlock(svc: Record<string, unknown>): ServiceAnatomy {
const ports: PortRow[] = Array.isArray(svc.ports)
? svc.ports.map(parsePortMapping).filter((r): r is PortRow => r !== null)
: [];
const volumes: VolumeRow[] = Array.isArray(svc.volumes)
? svc.volumes.map(parseVolumeMapping).filter((r): r is VolumeRow => r !== null)
: [];
const restart = typeof svc.restart === 'string' ? svc.restart : null;
const envFiles: string[] = typeof svc.env_file === 'string'
? [svc.env_file]
: Array.isArray(svc.env_file)
? svc.env_file.filter((e): e is string => typeof e === 'string')
: [];
let networks: string[] = [];
if (Array.isArray(svc.networks)) {
networks = svc.networks.filter((n): n is string => typeof n === 'string');
} else if (svc.networks && typeof svc.networks === 'object') {
networks = Object.keys(svc.networks as Record<string, unknown>);
}
return { ports, volumes, restart, envFiles, networks };
}
// `:-` and `-` forms supply a default value (no env entry required);
// `:?` and `?` forms signal a required variable (the user still needs to define it).
function extractInterpolations(yamlText: string): string[] {
const referenced = new Set<string>();
const defaulted = new Set<string>();
for (const m of yamlText.matchAll(INTERPOLATION_REGEX)) {
referenced.add(m[1]);
if (m[2] === ':-' || m[2] === '-') defaulted.add(m[1]);
}
return Array.from(referenced).filter(v => !defaulted.has(v));
}
function parseAnatomy(yamlText: string): Anatomy | null {
if (!yamlText.trim()) return null;
let parsed: unknown;
try {
parsed = parseYaml(yamlText);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const root = parsed as Record<string, unknown>;
const servicesObj = (root.services && typeof root.services === 'object')
? root.services as Record<string, unknown>
: {};
const serviceNames = Object.keys(servicesObj);
const ports: Record<string, PortRow[]> = {};
const volumes: Record<string, VolumeRow[]> = {};
let restart: string | null = null;
const envFilesSet = new Set<string>();
const networksSet = new Set<string>();
for (const name of serviceNames) {
const svc = servicesObj[name];
if (!svc || typeof svc !== 'object') continue;
const a = parseServiceBlock(svc as Record<string, unknown>);
if (a.ports.length > 0) ports[name] = a.ports;
if (a.volumes.length > 0) volumes[name] = a.volumes;
if (restart === null && a.restart !== null) restart = a.restart;
for (const f of a.envFiles) envFilesSet.add(f);
for (const n of a.networks) networksSet.add(n);
}
if (root.networks && typeof root.networks === 'object' && !Array.isArray(root.networks)) {
for (const n of Object.keys(root.networks)) networksSet.add(n);
}
return {
services: serviceNames,
ports,
volumes,
restart,
envFiles: Array.from(envFilesSet),
networks: Array.from(networksSet),
referencedVars: extractInterpolations(yamlText),
};
}
function parseEnvKeys(envText: string): Set<string> {
const keys = new Set<string>();
for (const raw of envText.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq <= 0) continue;
keys.add(line.slice(0, eq).trim());
}
return keys;
}
function formatGitSource(src: GitSourceInfo): string {
try {
const url = new URL(src.repo_url);
const host = url.host;
const repo = url.pathname.replace(/^\//, '').replace(/\.git$/, '');
return `${host}/${repo}#${src.branch}`;
} catch {
return `${src.repo_url}#${src.branch}`;
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
</div>
);
}
export default function StackAnatomyPanel({
stackName,
content,
envContent,
selectedEnvFile,
gitSourcePending,
onEditCompose,
onOpenGitSource,
onApplyUpdate,
onOpenFiles,
canEdit,
notifications,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
const missingVars = useMemo(() => {
if (!anatomy) return [];
return anatomy.referencedVars.filter(v => !envKeys.has(v));
}, [anatomy, envKeys]);
const envVarCount = envKeys.size;
const [gitSource, setGitSource] = useState<GitSourceInfo | null>(null);
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/git-source`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setGitSource({ repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path });
} else {
setGitSource(null);
}
} catch {
if (!cancelled) setGitSource(null);
}
};
void run();
return () => { cancelled = true; };
}, [stackName]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setUpdatePreview(data);
} else {
setUpdatePreview(null);
}
} catch {
if (!cancelled) setUpdatePreview(null);
}
};
void run();
return () => { cancelled = true; };
}, [stackName]);
const networkName = anatomy && anatomy.networks.length > 0
? anatomy.networks[0]
: `${stackName}_default`;
const firstEnvFile = anatomy?.envFiles[0] ?? selectedEnvFile ?? null;
const primaryHostPort = useMemo(() => {
if (!anatomy) return null;
for (const svc of anatomy.services) {
const rows = anatomy.ports[svc];
if (rows && rows.length > 0) return rows[0].host;
}
return null;
}, [anatomy]);
const bump = updatePreview?.summary.semver_bump ?? 'none';
const hasUpdate = Boolean(updatePreview?.summary.has_update);
const blocked = Boolean(updatePreview?.summary.blocked);
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
? 'danger'
: bump === 'minor' ? 'warn' : 'ok';
const bannerTone = bannerSeverity === 'danger'
? 'border-destructive/40 bg-destructive/[0.06] text-destructive'
: bannerSeverity === 'warn'
? 'border-warning/40 bg-warning/[0.06] text-warning'
: 'border-success/40 bg-success/[0.06] text-success';
const applyBtnTone = bannerSeverity === 'danger'
? 'border-destructive/40 text-destructive hover:bg-destructive/10'
: bannerSeverity === 'warn'
? 'border-warning/40 text-warning hover:bg-warning/10'
: 'border-success/40 text-success hover:bg-success/10';
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
const bannerLeadIn = blocked
? 'review required'
: bump === 'patch'
? 'safe to apply'
: bump === 'minor'
? 'review recommended'
: bump === 'major'
? 'breaking changes possible'
: '';
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<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
type="button"
data-testid="anatomy-files-btn"
onClick={onOpenFiles}
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<FolderOpen className="h-3 w-3" strokeWidth={1.5} />
files
</button>
)}
{canEdit && (
<button
type="button"
onClick={onEditCompose}
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
</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>
) : (
<>
<Row label="services">
{anatomy.services.length === 0 ? (
<span className="text-stat-subtitle">none defined</span>
) : (
<div className="flex flex-wrap gap-1">
{anatomy.services.map(s => (
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">
{s}
</span>
))}
</div>
)}
</Row>
<Row label="ports">
{Object.keys(anatomy.ports).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5">
{anatomy.services.length > 1 && (
<span className="text-stat-subtitle">{svc}</span>
)}
<span className="font-semibold text-foreground">{r.host}</span>
<span className="text-stat-subtitle"></span>
<span>{r.container}/{r.proto}</span>
</div>
)),
)}
</div>
)}
</Row>
<Row label="volumes">
{Object.keys(anatomy.volumes).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5 min-w-0">
{anatomy.services.length > 1 && (
<span className="text-stat-subtitle shrink-0">{svc}</span>
)}
<span className="truncate text-foreground/90">{r.host}</span>
<span className="text-stat-subtitle shrink-0"></span>
<span className="truncate">{r.container}</span>
</div>
)),
)}
</div>
)}
</Row>
<Row label="restart">
<span className="font-mono text-[11px]">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
</Row>
<Row label="env_file">
{!firstEnvFile ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5">
<div className="font-mono text-[11px]">
<span className="text-foreground/90">{firstEnvFile}</span>
<span className="text-stat-subtitle"> · {envVarCount} var{envVarCount === 1 ? '' : 's'}</span>
</div>
{missingVars.length > 0 && (
<div className="flex flex-wrap gap-1 font-mono text-[11px] text-destructive">
{missingVars.map(v => (
<span key={v}>{'${'}{v}{'}'} missing</span>
))}
</div>
)}
</div>
)}
</Row>
<Row label="network">
<span className="font-mono text-[11px]">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
</Row>
<Row label="source">
<button
type="button"
onClick={onOpenGitSource}
aria-label="Git Source"
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
>
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
{gitSource ? (
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(gitSource)}</span>
) : (
<span>local</span>
)}
{gitSourcePending && (
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-brand animate-pulse" />
)}
</button>
</Row>
</>
)}
{hasUpdate && updatePreview && (
<div className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[11px] uppercase tracking-wide">
Update available
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
<span className="text-foreground">
{' · '}
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
{' -> '}
<span className="text-foreground font-semibold">{updatePreview.summary.next_tag}</span>
</span>
)}
</div>
<div className="mt-1 font-mono text-[11px] text-foreground/80 leading-relaxed">
{[
bumpLabel,
bannerLeadIn,
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
].filter(Boolean).join(' · ')}
</div>
{blocked && updatePreview.summary.blocked_reason && (
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
)}
</div>
{canEdit && !blocked && (
<Button
type="button"
size="sm"
variant="outline"
className={cn('shrink-0 h-7 gap-1', applyBtnTone)}
onClick={onApplyUpdate}
>
<Rocket className="h-3 w-3" strokeWidth={1.5} />
apply
</Button>
)}
</div>
</div>
)}
</div>
{anatomy && anatomy.services.length > 0 && (
<div className="border-t border-muted px-3 py-2 flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">
{Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'}
</span>
{primaryHostPort && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
:{primaryHostPort}
</span>
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}