mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 04:11:01 +00:00
2d56ea958a
* 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.
293 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|