mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -54,6 +54,7 @@ import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { HubOnlyGate } from './HubOnlyGate';
|
||||
import type { SectionId } from './settings/types';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
|
||||
// These bespoke phone screens reuse the desktop view's component (with a mobile
|
||||
// branch), code-split exactly like the desktop content path so the heavy chunks
|
||||
@@ -295,12 +296,15 @@ export default function EditorLayout() {
|
||||
// Optimistically flip to the detail surface the instant a row is tapped,
|
||||
// before loadFile's fetch resolves selectedFile; cleared once it settles.
|
||||
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
|
||||
const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null);
|
||||
}, [isFileLoading, pendingDetailStack]);
|
||||
|
||||
const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []);
|
||||
|
||||
const { surface: mobileSurface, detailReady, detailOpen } = deriveMobileSurface({
|
||||
activeView,
|
||||
selectedFile,
|
||||
@@ -404,6 +408,32 @@ export default function EditorLayout() {
|
||||
}
|
||||
}, [stackActions, setActiveView, isMobile, navigateMobileAware]);
|
||||
|
||||
// Notification navigation: node_update_available notifications route to the
|
||||
// Fleet view and open the Node Updates sheet (desktop only). The intent state
|
||||
// handles both cross-view navigation and same-view re-entry (handleNavigate
|
||||
// returns early when already on Fleet, but the state change triggers render).
|
||||
const handleNotificationNavigate = useCallback((notif: NotificationItem) => {
|
||||
if (notif.category === 'node_update_available') {
|
||||
if (isMobile) {
|
||||
navigateMobileAware('fleet');
|
||||
} else {
|
||||
setFleetUpdatesIntent({ tab: 'nodes' });
|
||||
handleNavigate('fleet');
|
||||
}
|
||||
return;
|
||||
}
|
||||
stackActions.navigateToNotification(notif);
|
||||
}, [isMobile, navigateMobileAware, handleNavigate, stackActions]);
|
||||
|
||||
const handleNotificationNavigateChangelog = useCallback(() => {
|
||||
if (isMobile) {
|
||||
navigateMobileAware('fleet');
|
||||
} else {
|
||||
setFleetUpdatesIntent({ tab: 'changelog' });
|
||||
handleNavigate('fleet');
|
||||
}
|
||||
}, [isMobile, navigateMobileAware, handleNavigate]);
|
||||
|
||||
const renderEditor = (headerActions?: ReactNode) => (
|
||||
<EditorView
|
||||
headerActions={headerActions}
|
||||
@@ -668,7 +698,8 @@ export default function EditorLayout() {
|
||||
onMarkAllRead={markAllRead}
|
||||
onClearAll={clearAllNotifications}
|
||||
onDelete={deleteNotification}
|
||||
onNavigate={stackActions.navigateToNotification}
|
||||
onNavigate={handleNotificationNavigate}
|
||||
onNavigateChangelog={handleNotificationNavigateChangelog}
|
||||
/>
|
||||
);
|
||||
const themeSwitchEl = <ThemeQuickSwitch onOpenAppearance={() => openSettings('appearance')} />;
|
||||
@@ -727,6 +758,8 @@ export default function EditorLayout() {
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
||||
securityTab={securityTab}
|
||||
onSecurityTabChange={setSecurityTab}
|
||||
renderEditor={renderEditor}
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ViewRouterProps {
|
||||
onClearNotifications: () => void;
|
||||
securityTab: SecurityTab;
|
||||
onSecurityTabChange: (tab: SecurityTab) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
// Render slot for the inline editor view. Kept as a callback so the
|
||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||
// not on every parent render that lands on a different view.
|
||||
@@ -112,6 +114,8 @@ export function ViewRouter({
|
||||
onClearNotifications,
|
||||
securityTab,
|
||||
onSecurityTabChange,
|
||||
fleetUpdatesIntent,
|
||||
onFleetUpdatesIntentConsumed,
|
||||
renderEditor,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
@@ -175,7 +179,11 @@ export function ViewRouter({
|
||||
<HubOnlyGate>
|
||||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||
<LazyView>
|
||||
<FleetView onNavigateToNode={onFleetNavigateToNode} />
|
||||
<FleetView
|
||||
onNavigateToNode={onFleetNavigateToNode}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
||||
/>
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</HubOnlyGate>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Camera, FileDown,
|
||||
Network, SlidersHorizontal,
|
||||
@@ -32,9 +33,11 @@ import { useNodeActions } from './nodes/useNodeActions';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed }: FleetViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
@@ -50,6 +53,17 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
updateStatuses: updateStatus.updateStatuses,
|
||||
});
|
||||
|
||||
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
|
||||
|
||||
useEffect(() => {
|
||||
if (fleetUpdatesIntent) {
|
||||
setInitialUpdatesTab(fleetUpdatesIntent.tab);
|
||||
updateStatus.setShowUpdateModal(true);
|
||||
updateStatus.fetchUpdateStatus();
|
||||
onFleetUpdatesIntentConsumed?.();
|
||||
}
|
||||
}, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]);
|
||||
|
||||
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
|
||||
|
||||
const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({
|
||||
@@ -248,6 +262,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
updateStatuses={updateStatus.updateStatuses}
|
||||
updatingNodeId={updateStatus.updatingNodeId}
|
||||
isAdmin={isAdmin}
|
||||
initialTab={initialUpdatesTab}
|
||||
fetchUpdateStatus={updateStatus.fetchUpdateStatus}
|
||||
triggerNodeUpdate={updateStatus.triggerNodeUpdate}
|
||||
retryNodeUpdate={updateStatus.retryNodeUpdate}
|
||||
|
||||
@@ -217,11 +217,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
||||
/>
|
||||
)}
|
||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && (
|
||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
||||
Update available
|
||||
</Badge>
|
||||
)}
|
||||
{updateStatus?.skipActive && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0">
|
||||
Skipped
|
||||
</Badge>
|
||||
)}
|
||||
{isOnline && isCritical(node) && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
|
||||
@@ -295,7 +300,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
)}
|
||||
|
||||
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && isAdmin && (
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && onUpdate && isAdmin && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
||||
Download, RefreshCw, Monitor, Globe,
|
||||
Download, RefreshCw, Monitor, Globe, ExternalLink, Ban,
|
||||
} from 'lucide-react';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { formatVersion, isValidVersion } from '@/lib/version';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
import type { NodeUpdateStatus } from './types';
|
||||
|
||||
@@ -23,6 +23,7 @@ interface NodeUpdatesSheetProps {
|
||||
* only for admins, matching the requireAdmin guard on the fleet routes they
|
||||
* call. Non-admins still see the read-only status table. */
|
||||
isAdmin: boolean;
|
||||
initialTab?: 'nodes' | 'changelog';
|
||||
fetchUpdateStatus: () => Promise<void>;
|
||||
triggerNodeUpdate: (nodeId: number) => void;
|
||||
retryNodeUpdate: (nodeId: number) => void;
|
||||
@@ -32,18 +33,61 @@ interface NodeUpdatesSheetProps {
|
||||
|
||||
export function NodeUpdatesSheet({
|
||||
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
|
||||
initialTab = 'nodes',
|
||||
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
|
||||
}: NodeUpdatesSheetProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'nodes' | 'changelog'>(initialTab);
|
||||
const [skipLoading, setSkipLoading] = useState<number | null>(null);
|
||||
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
||||
const [releaseHtmlUrl, setReleaseHtmlUrl] = useState<string | null>(null);
|
||||
const [loadingRelease, setLoadingRelease] = useState(false);
|
||||
const [hasSeenChangelog, setHasSeenChangelog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(initialTab);
|
||||
}, [open, initialTab]);
|
||||
|
||||
// Always fetch release notes when the sheet opens (changelog shows current
|
||||
// release regardless of update availability). Pass recheck when the user
|
||||
// forced a version recheck so the changelog stays in sync.
|
||||
useEffect(() => {
|
||||
if (open && releaseNotes === null && !loadingRelease) {
|
||||
setLoadingRelease(true);
|
||||
const recheck = recheckingUpdates ? '?recheck=true' : '';
|
||||
apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true })
|
||||
.then(res => res.ok ? res.json() as Promise<{ releaseNotes: string | null; htmlUrl: string | null }> : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setReleaseNotes(data.releaseNotes);
|
||||
setReleaseHtmlUrl(data.htmlUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* silent */ })
|
||||
.finally(() => setLoadingRelease(false));
|
||||
}
|
||||
}, [open, releaseNotes, loadingRelease, recheckingUpdates]);
|
||||
|
||||
// Clear the changelog dot when user opens that tab.
|
||||
useEffect(() => {
|
||||
if (open && activeTab === 'changelog') {
|
||||
setHasSeenChangelog(true);
|
||||
}
|
||||
}, [open, activeTab]);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
onOpenChange(next);
|
||||
if (!next) setSearch('');
|
||||
if (!next) {
|
||||
setSearch('');
|
||||
setActiveTab('nodes');
|
||||
setHasSeenChangelog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecheck = async () => {
|
||||
setRecheckingUpdates(true);
|
||||
setReleaseNotes(null); // force re-fetch with fresh release notes
|
||||
try {
|
||||
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
||||
if (res.ok) {
|
||||
@@ -70,6 +114,49 @@ export function NodeUpdatesSheet({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipVersion = async (nodeId: number, version: string | null) => {
|
||||
if (!version) return;
|
||||
setSkipLoading(nodeId);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
localOnly: true,
|
||||
});
|
||||
if (res.ok || res.status === 204) {
|
||||
toast.success('Version skipped.');
|
||||
await fetchUpdateStatus();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to skip version.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to skip version.');
|
||||
} finally {
|
||||
setSkipLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnskipVersion = async (nodeId: number) => {
|
||||
setSkipLoading(nodeId);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (res.ok || res.status === 204) {
|
||||
toast.success('Skip cleared.');
|
||||
await fetchUpdateStatus();
|
||||
} else {
|
||||
toast.error('Failed to clear skip.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to clear skip.');
|
||||
} finally {
|
||||
setSkipLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length;
|
||||
const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
|
||||
const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length;
|
||||
@@ -98,6 +185,16 @@ export function NodeUpdatesSheet({
|
||||
}]
|
||||
: undefined;
|
||||
|
||||
const showChangelogDot = available > 0 && !hasSeenChangelog;
|
||||
|
||||
const showSkip = (s: NodeUpdateStatus) =>
|
||||
s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion);
|
||||
|
||||
const tabs: Array<{ id: string; label: string; count?: number; dot?: boolean }> = [
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'changelog', label: 'Changelog', dot: showChangelogDot },
|
||||
];
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
@@ -113,6 +210,9 @@ export function NodeUpdatesSheet({
|
||||
} : undefined}
|
||||
secondaryActions={secondaryActions}
|
||||
footerContext={footerContext}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as 'nodes' | 'changelog')}
|
||||
size="lg"
|
||||
>
|
||||
{checkingUpdates ? (
|
||||
@@ -124,6 +224,34 @@ export function NodeUpdatesSheet({
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
No nodes found.
|
||||
</div>
|
||||
) : activeTab === 'changelog' ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||
{loadingRelease ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : releaseNotes ? (
|
||||
<div className="space-y-4">
|
||||
<pre className="whitespace-pre-wrap text-sm font-sans text-stat-value leading-relaxed">
|
||||
{releaseNotes}
|
||||
</pre>
|
||||
{releaseHtmlUrl && (
|
||||
<a
|
||||
href={releaseHtmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
|
||||
>
|
||||
View on GitHub <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Release notes could not be loaded.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetSection title="Summary">
|
||||
@@ -166,7 +294,7 @@ export function NodeUpdatesSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
||||
<span>Node</span>
|
||||
<span>Type</span>
|
||||
<span>Current</span>
|
||||
@@ -176,7 +304,7 @@ export function NodeUpdatesSheet({
|
||||
|
||||
<div className="divide-y divide-card-border/40">
|
||||
{filtered.map(s => (
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center px-3 py-2">
|
||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 items-center px-3 py-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
||||
{s.type === 'local'
|
||||
@@ -195,7 +323,7 @@ export function NodeUpdatesSheet({
|
||||
<span className="text-xs font-mono tabular-nums">
|
||||
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end items-center gap-1">
|
||||
{s.updateStatus && (
|
||||
<UpdateStatusBadge
|
||||
status={s.updateStatus}
|
||||
@@ -204,12 +332,28 @@ export function NodeUpdatesSheet({
|
||||
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
|
||||
/>
|
||||
)}
|
||||
{!s.updateStatus && !s.updateAvailable && (
|
||||
{!s.updateStatus && !s.updateAvailable && !s.skipActive && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
|
||||
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
||||
</Badge>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && isAdmin && (
|
||||
{s.skipActive && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40">
|
||||
<Ban className="w-2.5 h-2.5 mr-0.5" /> Skipped {formatVersion(s.skippedVersion)}
|
||||
</Badge>
|
||||
)}
|
||||
{s.skipActive && isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-stat-value"
|
||||
onClick={() => { void handleUnskipVersion(s.nodeId); }}
|
||||
disabled={skipLoading === s.nodeId}
|
||||
>
|
||||
Unskip
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -224,7 +368,18 @@ export function NodeUpdatesSheet({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !isAdmin && (
|
||||
{showSkip(s) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-warning"
|
||||
onClick={() => { void handleSkipVersion(s.nodeId, s.latestVersion); }}
|
||||
disabled={skipLoading === s.nodeId}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && !isAdmin && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
|
||||
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
|
||||
</Badge>
|
||||
|
||||
@@ -34,7 +34,12 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesShe
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
// Default: release-notes fetch returns empty notes (called by useEffect on mount).
|
||||
// Tests that need specific apiFetch responses override this.
|
||||
apiFetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ releaseNotes: null, htmlUrl: null }) });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NodeUpdatesSheet', () => {
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface NodeUpdateStatus {
|
||||
updateAvailable: boolean;
|
||||
updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null;
|
||||
error?: string | null;
|
||||
skipActive?: boolean;
|
||||
skippedVersion?: string | null;
|
||||
}
|
||||
|
||||
export type ViewMode = 'grid' | 'topology';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -54,6 +54,7 @@ export type NotificationCategory =
|
||||
| 'autoheal_triggered'
|
||||
| 'monitor_alert'
|
||||
| 'scan_finding'
|
||||
| 'node_update_available'
|
||||
| 'system';
|
||||
|
||||
export interface NotificationItem {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SystemSheetTab {
|
||||
id: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemSheetProps {
|
||||
@@ -283,6 +284,12 @@ function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) {
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.dot && (
|
||||
<span aria-hidden className="relative inline-flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand" />
|
||||
</span>
|
||||
)}
|
||||
{tab.count !== undefined && (
|
||||
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">{tab.count}</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user