mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2,
|
||||
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,9 +9,12 @@ 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: string;
|
||||
level: ActivityLevel;
|
||||
category?: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
@@ -33,9 +36,37 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
|
||||
};
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const PAGE_SIZE = 50;
|
||||
const DAY_BUCKET_REFRESH_MS = 60_000;
|
||||
|
||||
function dayLabel(ts: number): 'Today' | 'Yesterday' | 'Earlier' {
|
||||
const todayStart = new Date();
|
||||
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';
|
||||
@@ -43,11 +74,11 @@ function dayLabel(ts: number): 'Today' | 'Yesterday' | 'Earlier' {
|
||||
return 'Earlier';
|
||||
}
|
||||
|
||||
function groupEvents(events: ActivityEvent[]): { label: string; events: ActivityEvent[] }[] {
|
||||
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);
|
||||
const label = dayLabel(e.timestamp, now);
|
||||
if (!groups[label]) { groups[label] = []; order.push(label); }
|
||||
groups[label].push(e);
|
||||
}
|
||||
@@ -59,6 +90,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
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[]) => {
|
||||
@@ -72,7 +107,7 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
added = true;
|
||||
}
|
||||
if (!added) return prev;
|
||||
next.sort((a, b) => b.timestamp - a.timestamp);
|
||||
next.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -80,48 +115,95 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
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);
|
||||
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);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setEvents([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName]);
|
||||
}, [stackName, reloadKey]);
|
||||
|
||||
// liveEvents is pre-filtered by stack_name in the parent
|
||||
useEffect(() => {
|
||||
if (!liveEvents || liveEvents.length === 0) return;
|
||||
mergeEvents(liveEvents as ActivityEvent[]);
|
||||
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 oldest = events[events.length - 1]?.timestamp;
|
||||
if (!oldest) return;
|
||||
const oldestEvent = events[events.length - 1];
|
||||
if (!oldestEvent) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const r = await apiFetch(`/stacks/${stackName}/activity?limit=50&before=${oldest}`);
|
||||
if (!r.ok) return;
|
||||
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();
|
||||
setHasMore(data.events.length === 50);
|
||||
mergeEvents(data.events);
|
||||
} catch {
|
||||
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), [events]);
|
||||
const groups = useMemo(() => groupEvents(events, now), [events, now]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -131,6 +213,23 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -142,18 +241,33 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
|
||||
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}>
|
||||
<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} className="flex items-start gap-2 py-1.5 px-1 rounded-md hover:bg-glass-highlight/30 transition-colors">
|
||||
<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>
|
||||
{e.actor_username && e.actor_username !== 'system' && (
|
||||
<span className="ml-1.5 font-mono text-[10px] text-stat-subtitle">by {e.actor_username}</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>
|
||||
|
||||
Reference in New Issue
Block a user