Files
sencho/frontend/src/components/stack/StackActivityTimeline.tsx
T
Anso 2d56ea958a fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)
* fix(stack-activity): per-stack history integrity, attribution, sanitization

Address the Stack Activity audit findings (PR 1 of 2):

- Per-stack history integrity: drop the per-insert 100-row prune in
  addNotificationHistory that evicted quieter stacks' history whenever
  another stack got chatty. Periodic cleanupOldNotifications now caps
  per (node, stack) at 500 rows and per-node unattached system events
  at 1000 rows, on top of the existing 30-day retention. Signature
  takes an options bag and returns a per-stage summary so MonitorService
  can log what actually ran each cycle.

- Actor attribution: thread req.user?.username through every
  notifyActionFailure call site and add synthetic actors at service
  emit sites (system:autoheal, system:scheduler, system:image-update,
  system:docker-events, system:blueprint, system:monitor, system:policy).
  The timeline renders system actors as "via <Label>" so an autoheal
  redeploy is no longer indistinguishable from a user redeploy.

- Message sanitization: new sanitizeNotificationMessage at
  NotificationService.dispatchAlert strips KEY=VALUE pairs whose key
  ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic
  auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and
  truncates to 1000 chars. Applied to the stored history and to every
  downstream Discord/Slack/webhook channel. The ImageUpdateService
  recovery-path direct DB write also runs through the sanitizer.

- Composite pagination cursor: getStackActivity now accepts a
  (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only
  form silently dropped events when a single compose up emitted many
  events sharing one millisecond. Route rejects beforeId without before.

- Frontend hardening: distinct error state with retry button (initial
  fetch failure no longer renders as the genuine empty state), strict
  positive-integer parsing on cursor params, overrequest-by-1 pagination
  so the last page does not leave a dead "Load more" click, runtime
  guard on liveEvents merge that validates the level union, per-minute
  day-bucket recompute so an open panel does not stay on "Today" past
  midnight.

No tier, role, or capability gate touched. Route permission gate
remains stack:read on the named stack.

* fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir

External review surfaced two leak paths in the message sanitizer:

- The sensitive-key regex was uppercase-only. Compose env names are
  conventionally uppercase but lowercase forms (db_password, jwt_secret,
  github_token) are valid and do leak through the same Docker and
  compose-parse error paths. Make the regex case-insensitive and tighten
  it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word,
  while still leaving BYPASS, COMPASS, and similar non-secret keys alone.

- The compose-dir path collapse only read process.env.COMPOSE_DIR, but
  the real resolution chain is node.compose_dir (per-node DB override)
  -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom
  compose_dir could still leak absolute paths into stored history and
  downstream channels. Route both the dispatchAlert call and the
  ImageUpdateService recovery-path direct write through
  NodeRegistry.getInstance().getComposeDir(localNodeId) so the
  collapse covers every resolution outcome.

Tests now assert lowercase keys are redacted and that BYPASS-style
non-secrets stay intact in both cases. notification-routing mock
extended to stub the new getComposeDir call.

* chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal

Close three small follow-ups on the per-stack activity timeline:

- A11y: each day-group gets role="list" and each event row gets
  role="listitem" so screen readers traverse the timeline as a list
  instead of a wall of text. The day-group container also carries an
  aria-label naming the bucket.

- Visibility-aware day-bucket tick: the 60s setInterval that re-derives
  Today/Yesterday/Earlier now short-circuits when document.hidden, so a
  backgrounded panel does not re-render every minute for no visible
  effect.

- Live-disconnect signal: useNotifications dispatches a
  sencho:notifications-connection custom event on WebSocket open and
  close. The timeline listens and, when explicitly disconnected, shows
  a one-line "Live updates offline; reconnecting…" hint above the list.
  The sidebar ticker already surfaces fleet-wide connection state; this
  adds an in-context cue for users who are focused on a single stack.

Stack-name case normalization was considered and rejected: stack names
are case-permissive per the isValidStackName validator, and lowercasing
on read or write would silently rename or hide a user's "MyApp" stack.

* ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex

ESLint no-useless-escape errored on \- inside the character class
[a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the
end of the class so it's an unambiguous literal and the escape is no
longer required. Behavior is identical; sanitizer tests still pass.

* revert(stack-activity): drop unvalidated E2E spec from this PR

The spec was committed without ever running against a real Docker
daemon, then failed in CI when it ran for the first time: deploy
returned 200 but no notification appeared on the activity endpoint
within the polling window, suggesting either a deploy-notification
race or a node-id resolution mismatch in the CI environment.

Backend unit tests (route + composite cursor + sanitizer) and
frontend component tests cover the same logic. The E2E spec will
land in a dedicated follow-up once it has been authored against a
working CI environment.
2026-05-25 21:09:00 -04:00

293 lines
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
} 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';
type ActivityLevel = 'info' | 'warning' | 'error';
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
interface ActivityEvent {
id: number;
level: ActivityLevel;
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;
const PAGE_SIZE = 50;
const DAY_BUCKET_REFRESH_MS = 60_000;
const SYSTEM_ACTOR_LABEL: Record<string, string> = {
'system': 'System',
'system:autoheal': 'Auto-Heal',
'system:scheduler': 'Scheduler',
'system:image-update': 'Image Update',
'system:docker-events': 'Docker',
'system:blueprint': 'Blueprint',
'system:monitor': 'Monitor',
'system:policy': 'Policy',
};
function formatActor(actor: string): { label: string; isSystem: boolean } {
const isSystem = actor === 'system' || actor.startsWith('system:');
return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem };
}
function isActivityEvent(value: unknown): value is ActivityEvent {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v.id === 'number'
&& typeof v.timestamp === 'number'
&& typeof v.message === 'string'
&& typeof v.level === 'string'
&& ACTIVITY_LEVELS.includes(v.level);
}
function dayLabel(ts: number, now: number): 'Today' | 'Yesterday' | 'Earlier' {
const todayStart = new Date(now);
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[], now: number): { label: string; events: ActivityEvent[] }[] {
const groups: Record<string, ActivityEvent[]> = {};
const order: string[] = [];
for (const e of events) {
const label = dayLabel(e.timestamp, now);
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 [error, setError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [now, setNow] = useState(() => Date.now());
const [liveDisconnected, setLiveDisconnected] = useState(false);
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 || b.id - a.id);
return next;
});
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(false);
seenIdsRef.current = new Set();
setEvents([]);
setHasMore(true);
apiFetch(`/stacks/${stackName}/activity?limit=${PAGE_SIZE + 1}`)
.then(async r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<{ events: ActivityEvent[] }>;
})
.then(data => {
if (cancelled) return;
const more = data.events.length > PAGE_SIZE;
const trimmed = more ? data.events.slice(0, PAGE_SIZE) : data.events;
setHasMore(more);
trimmed.forEach(e => seenIdsRef.current.add(e.id));
setEvents(trimmed);
})
.catch(() => {
if (cancelled) return;
setError(true);
setHasMore(false);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [stackName, reloadKey]);
useEffect(() => {
if (!liveEvents || liveEvents.length === 0) return;
const safe = liveEvents.filter(isActivityEvent);
if (safe.length === 0) return;
mergeEvents(safe);
}, [liveEvents, mergeEvents]);
useEffect(() => {
// Skip the day-bucket refresh while the tab is hidden so a backgrounded
// panel does not re-render every minute for no visible effect.
const id = window.setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
setNow(Date.now());
}, DAY_BUCKET_REFRESH_MS);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
// Upstream useNotifications dispatches this event when its WebSocket
// flips. A single layout-level listener keeps the timeline honest about
// whether new events would actually arrive.
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ connected: boolean }>).detail;
setLiveDisconnected(detail?.connected === false);
};
window.addEventListener('sencho:notifications-connection', handler);
return () => window.removeEventListener('sencho:notifications-connection', handler);
}, []);
const loadMore = useCallback(async () => {
const oldestEvent = events[events.length - 1];
if (!oldestEvent) return;
setLoadingMore(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE + 1),
before: String(oldestEvent.timestamp),
beforeId: String(oldestEvent.id),
});
const r = await apiFetch(`/stacks/${stackName}/activity?${params.toString()}`);
if (!r.ok) {
toast.error('Failed to load more activity');
// 5xx is likely server-side; stop offering a button that keeps failing.
if (r.status >= 500) setHasMore(false);
return;
}
const data: { events: ActivityEvent[] } = await r.json();
const more = data.events.length > PAGE_SIZE;
const trimmed = more ? data.events.slice(0, PAGE_SIZE) : data.events;
setHasMore(more);
mergeEvents(trimmed);
} catch (err) {
console.error('[StackActivity] loadMore failed:', err);
toast.error('Failed to load more activity');
setHasMore(false);
} finally {
setLoadingMore(false);
}
}, [events, stackName, mergeEvents]);
const groups = useMemo(() => groupEvents(events, now), [events, now]);
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 (error && events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 gap-2">
<AlertCircle className="w-5 h-5 text-destructive/60" />
<span className="font-mono text-[11px] text-muted-foreground">Activity unavailable</span>
<Button
variant="ghost"
size="sm"
className="h-7 font-mono text-[10px]"
onClick={() => setReloadKey(k => k + 1)}
>
Retry
</Button>
</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">
{liveDisconnected && (
<div
role="status"
className="font-mono text-[10px] text-stat-subtitle italic px-1"
>
Live updates offline; reconnecting
</div>
)}
{groups.map(g => (
<div key={g.label} role="list" aria-label={`Stack activity, ${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;
const actor = e.actor_username ? formatActor(e.actor_username) : null;
return (
<div
key={e.id}
role="listitem"
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>
{actor && (
<span className={`ml-1.5 font-mono text-[10px] text-stat-subtitle${actor.isSystem ? ' italic' : ''}`}>
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
</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>
);
}