feat: add node update alerts with changelog tab and skip-version handling (#1463)

* feat: add node update alerts with changelog tab and skip-version handling

- Add node_update_available notification category with blue/brand bell dot
- Route node_update_available notifications to Fleet -> Node updates sheet
- Add Changelog tab to NodeUpdatesSheet with GitHub release notes
- Add per-node skip-version persistence (node_update_skips table)
- Skip hides update CTA on node card and sheet; re-surfaces on newer version
- Skipped nodes excluded from Update all backend filter
- Add pulsating dot indicator on Changelog tab when updates available
- Always-visible View changelog action in notification row bottom
- Admin-only for all mutating controls (skip, unskip, update)
- Backend tests for skip-version semantics (15 tests)
- Update fleet-view.mdx, remote-updates.mdx, and OpenAPI spec

* fix: address audit findings - nested button, stale changelog, semver normalization, mobile intent

- Move View changelog button outside routable button (sibling element)
- Fix aria-label for node_update_available notification rows
- Support ?recheck=true on release-notes endpoint
- Invalidate release notes cache on forced recheck
- Store normalized semver (semver.valid strips v prefix)
- Skip fleetUpdatesIntent on mobile (desktop only)
- Add v-prefix normalization test

* fix: restore View changelog on same line as timestamp, opposite sides

The button is always visible at the bottom right of the notification card,
on the same row as the timestamp (just now), using justify-between layout.

* fix: update tests for node_update_available category and release-notes fetch

- Backend: monitor-service tests now expect node_update_available instead of system
- Frontend: NodeUpdatesSheet tests mock release-notes API call to prevent undefined then()

* fix: resolve ci lint failures
This commit is contained in:
Anso
2026-06-26 00:07:51 -04:00
committed by GitHub
parent 0384c47d1e
commit 315e8b6379
21 changed files with 789 additions and 49 deletions
+53 -18
View File
@@ -127,6 +127,7 @@ interface NotificationPanelProps {
onClearAll: () => void;
onDelete: (notif: NotificationItem) => void;
onNavigate?: (notif: NotificationItem) => void;
onNavigateChangelog?: (notif: NotificationItem) => void;
}
export function NotificationPanel({
@@ -136,6 +137,7 @@ export function NotificationPanel({
onClearAll,
onDelete,
onNavigate,
onNavigateChangelog,
}: NotificationPanelProps) {
const [filter, setFilter] = useState<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
@@ -151,6 +153,11 @@ export function NotificationPanel({
[notifications],
);
const hasNodeUpdateNotifs = useMemo(
() => notifications.some((n) => !n.is_read && n.category === 'node_update_available'),
[notifications],
);
const remoteNodeIds = useMemo(() => {
const ids = new Set<number>();
for (const n of nodes) if (n.type === 'remote') ids.add(n.id);
@@ -189,13 +196,25 @@ export function NotificationPanel({
const bellBadge =
unreadCount > 0 ? (
<span aria-hidden="true" className="absolute -right-1 -top-1 flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-destructive" />
<span className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
hasNodeUpdateNotifs ? 'bg-brand' : 'bg-destructive',
)} />
<span className={cn(
"relative inline-flex h-2.5 w-2.5 rounded-full",
hasNodeUpdateNotifs ? 'bg-brand' : 'bg-destructive',
)} />
</span>
) : null;
const handleNavigate = (notif: NotificationItem) => {
if (!onNavigate || !notif.stack_name) return;
if (!onNavigate) return;
if (notif.category === 'node_update_available') {
onNavigate(notif);
setOpen(false);
return;
}
if (!notif.stack_name) return;
onNavigate(notif);
setOpen(false);
};
@@ -356,6 +375,7 @@ export function NotificationPanel({
}
onDelete={onDelete}
onNavigate={onNavigate ? handleNavigate : undefined}
onNavigateChangelog={onNavigateChangelog}
/>
))}
</div>
@@ -372,13 +392,14 @@ interface NotificationRowProps {
showNodeName: boolean;
onDelete: (notif: NotificationItem) => void;
onNavigate?: (notif: NotificationItem) => void;
onNavigateChangelog?: (notif: NotificationItem) => void;
}
function NotificationRow({ notif, showNodeName, onDelete, onNavigate }: NotificationRowProps) {
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) {
const config = LEVEL_CONFIG[notif.level];
const Icon = config.icon;
const isUnread = !notif.is_read;
const isRoutable = Boolean(onNavigate && notif.stack_name);
const isRoutable = Boolean(onNavigate && (notif.stack_name || notif.category === 'node_update_available'));
const surfaceClasses = cn(
'flex w-full items-start gap-3 px-[var(--density-row-x)] py-[var(--density-row-y)] text-left transition-colors',
@@ -400,25 +421,39 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate }: Notifica
>
{notif.message}
</p>
<div className="mt-1 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{showNodeName && notif.nodeName ? (
<>
<span className="rounded-sm border border-card-border bg-muted/40 px-1.5 py-0.5 normal-case tracking-normal text-stat-subtitle">
{notif.nodeName}
</span>
<span className="text-stat-icon">·</span>
</>
) : null}
<span className="tabular-nums">{formatRelative(notif.timestamp)}</span>
<div className="mt-1 flex items-center justify-between gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
<div className="flex items-center gap-1.5">
{showNodeName && notif.nodeName ? (
<>
<span className="rounded-sm border border-card-border bg-muted/40 px-1.5 py-0.5 normal-case tracking-normal text-stat-subtitle">
{notif.nodeName}
</span>
<span className="text-stat-icon">·</span>
</>
) : null}
<span className="tabular-nums">{formatRelative(notif.timestamp)}</span>
</div>
{notif.category === 'node_update_available' && onNavigateChangelog && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] font-sans normal-case tracking-normal text-brand hover:text-brand/80"
onClick={(e) => { e.stopPropagation(); onNavigateChangelog(notif); }}
>
View changelog
</Button>
)}
</div>
</div>
</>
);
const ariaLabel = isRoutable
? (notif.container_name
? `Open ${notif.stack_name} and view logs for ${notif.container_name}`
: `Open ${notif.stack_name}`)
? (notif.category === 'node_update_available'
? 'Open Fleet node updates'
: notif.container_name
? `Open ${notif.stack_name} and view logs for ${notif.container_name}`
: `Open ${notif.stack_name}`)
: undefined;
return (