Watch
1
0
Fork
You've already forked RedFlag
0

feat: unified event timeline, desktop bundling, UI primitives

Unified timeline:
- client_errors bridge to system_events (component='client')
- Admin action audit middleware on /admin/* routes
- History page filters: type, severity dropdowns
- ChatTimeline accepts externalType/externalSeverity props

Desktop app bundling:
- Docker: Tauri builder stage (Rust + Node + webkit2gtk)
- Server signs desktop binary at startup, serves via /api/v1/desktop/:arch
- Install script downloads + verifies desktop binary (Step 7c)
- Agent spawns desktop as child process, monitors + restarts on crash
- Desktop config: enabled, max_restarts, restart_delay_sec
- Session detection: /proc environ scan (Linux), query session (Windows)
- Desktop health: POST /v1/desktop every 30s from tray app
- /v1/status includes desktop running/pid/enabled state

UI primitives:
- Modal, PageState, Pagination, StatCard components
- Dashboard, Updates, Agents pages refactored to use primitives
- Novell aesthetic preserved throughout
This commit is contained in:
Fimeg 2026-06-08 19:09:58 -04:00
commit 7cbf174652
30 changed files with 1781 additions and 481 deletions

View file

@ -52,6 +52,8 @@ interface ChatTimelineProps {
className?: string;
isScopedView?: boolean; // true for agent-specific view, false for global view
externalSearch?: string; // external search query from parent
externalType?: string; // event type filter from parent
externalSeverity?: string; // severity filter from parent
}
const entryPackageName = (entry: HistoryEntry): string | undefined => {
@ -146,7 +148,7 @@ const createPackageOperationSummary = (entry: HistoryEntry): string => {
}
};
const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScopedView = false, externalSearch }) => {
const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScopedView = false, externalSearch, externalType, externalSeverity }) => {
const [statusFilter, _setStatusFilter] = useState('all'); // 'all', 'success', 'failed', 'pending', 'completed', 'running', 'timed_out'
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const [selectedAgents, _setSelectedAgents] = useState<string[]>([]);
@ -161,15 +163,19 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
agent_id: agentId || '',
result: statusFilter !== 'all' ? statusFilter : '',
search: externalSearch || '',
type: externalType || '',
severity: externalSeverity || '',
});
// Update query params when external search changes
// Update query params when external filters change
React.useEffect(() => {
setQueryParams(prev => ({
...prev,
search: externalSearch || '',
type: externalType || '',
severity: externalSeverity || '',
}));
}, [externalSearch]);
}, [externalSearch, externalType, externalSeverity]);
// Fetch history data
const { data: historyData, isLoading, refetch: _refetch, isFetching } = useQuery({
@ -193,6 +199,14 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
params.search = queryParams.search;
}
if (queryParams.type) {
params.type = queryParams.type;
}
if (queryParams.severity) {
params.severity = queryParams.severity;
}
const response = await logApi.getAllLogs(params);
return response;
} catch (error) {

View file

@ -360,7 +360,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<div className="py-6">
{children}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{children}
</div>
</div>
</main>
</div>

View file

@ -0,0 +1,145 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Modal compound dialog with header/body/footer sections.
*
* Usage:
* ```tsx
* <Modal open onClose={() => setOpen(false)} title="Installation Logs" maxWidth="4xl">
* <Modal.Body>
* ... content ...
* </Modal.Body>
* <Modal.Footer>
* <button onClick={...}>Close</button>
* </Modal.Footer>
* </Modal>
* ```
*
* Handles: Escape key, overlay click, focus trap, scroll lock.
* Only renders when `open` is true.
*/
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl';
children: React.ReactNode;
}
const WIDTH_MAP: Record<string, string> = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'4xl': 'sm:max-w-4xl',
};
const Modal: React.FC<ModalProps> & { Body: typeof ModalBody; Footer: typeof ModalFooter } = ({
open,
onClose,
title,
maxWidth = '2xl',
children,
}) => {
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Close on Escape
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
// Focus trap: focus the content on open, restore focus on close
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
// Focus the content after a tick so the transition doesn't fight it
requestAnimationFrame(() => contentRef.current?.focus());
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [open, handleKeyDown]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 overflow-y-auto"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
ref={contentRef}
tabIndex={-1}
className={cn(
'relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full border border-gray-200',
WIDTH_MAP[maxWidth]
)}
>
{/* Header */}
{title && (
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
)}
{children}
</div>
</div>
</div>
);
};
// ─── Sub-components ────────────────────────────────────────────────────────────
interface ModalBodyProps {
children: React.ReactNode;
className?: string;
}
const ModalBody: React.FC<ModalBodyProps> = ({ children, className }) => (
<div className={cn('bg-white px-6 py-4', className)}>{children}</div>
);
interface ModalFooterProps {
children: React.ReactNode;
className?: string;
}
const ModalFooter: React.FC<ModalFooterProps> = ({ children, className }) => (
<div
className={cn(
'bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200 gap-2',
className
)}
>
{children}
</div>
);
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export default Modal;

View file

@ -0,0 +1,122 @@
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* PageState loading/error/empty/content state wrapper.
*
* Renders exactly one of its states. Most pages follow the same ternary:
* `{loading ? <Loader/> : error ? <Error/> : empty ? <Empty/> : <Content/>}`
*
* Props:
* loading true while data is fetching
* error error message string, or null/undefined when no error
* empty true when there's nothing to show
* emptyTitle heading for the empty state (default "No items found")
* emptyMessage subtext for the empty state
* loadingTitle subtext shown while loading (default "")
* errorTitle heading for the error state (default "Something went wrong")
* errorAction called when the "Retry" button is clicked
* skeleton optional custom skeleton; defaults to `<PageSkeleton rows={3} />`
* icon icon component for empty state (default Package from lucide)
* children rendered when all other states are false
*/
interface PageStateProps {
loading: boolean;
error?: string | null;
empty: boolean;
emptyTitle?: string;
emptyMessage?: string;
loadingTitle?: string;
errorTitle?: string;
errorAction?: () => void;
skeleton?: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
children?: React.ReactNode;
}
/** Generic pulsing-box skeleton. */
export const PageSkeleton: React.FC<{ rows?: number; className?: string }> = ({
rows = 3,
className,
}) => (
<div className={cn('animate-pulse', className)}>
<div className="bg-white rounded-lg border border-gray-200">
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="p-4 border-b border-gray-200 last:border-b-0">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
</div>
);
/** Centered block with icon + heading + subtext. */
const CenterBlock: React.FC<{
Icon: React.ComponentType<{ className?: string }>;
title: string;
message?: string;
children?: React.ReactNode;
}> = ({ Icon, title, message, children }) => (
<div className="text-center py-12">
<Icon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{title}</h3>
{message && <p className="mt-1 text-sm text-gray-500">{message}</p>}
{children}
</div>
);
const PageState: React.FC<PageStateProps> = ({
loading,
error,
empty,
emptyTitle = 'No items found',
emptyMessage,
loadingTitle,
errorTitle = 'Something went wrong',
errorAction,
skeleton,
icon,
children,
}) => {
const IconComponent = icon || Loader2;
if (loading) {
return (
<div>
{loadingTitle && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-sm text-gray-500">{loadingTitle}</span>
</div>
)}
{skeleton ?? <PageSkeleton />}
</div>
);
}
if (error) {
return (
<CenterBlock Icon={AlertTriangle} title={errorTitle} message={error}>
{errorAction && (
<button
onClick={errorAction}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 transition-colors"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</button>
)}
</CenterBlock>
);
}
if (empty) {
return <CenterBlock Icon={IconComponent as React.ComponentType<{ className?: string }>} title={emptyTitle} message={emptyMessage} />;
}
return <>{children}</>;
};
export default PageState;

View file

@ -0,0 +1,170 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Pagination page navigation with prev/next, number buttons, and result summary.
*
* Usage:
* ```tsx
* <Pagination
* page={currentPage}
* total={totalCount}
* pageSize={pageSize}
* onChange={setCurrentPage}
* />
* ```
*
* Props:
* page current page (1-indexed)
* total total number of items across all pages
* pageSize items per page
* onChange called with the new page number
* windowSize number of page buttons to show (default 5)
* className additional wrapper classes
*/
interface PaginationProps {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
windowSize?: number;
className?: string;
}
const Pagination: React.FC<PaginationProps> = ({
page,
total,
pageSize,
onChange,
windowSize = 5,
className,
}) => {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
const hasPrev = page > 1;
const hasNext = page < totalPages;
// Generate the page-number window
const pageNumbers = (() => {
if (totalPages <= windowSize) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (page <= 3) {
return Array.from({ length: windowSize }, (_, i) => i + 1);
}
if (page >= totalPages - 2) {
return Array.from({ length: windowSize }, (_, i) => totalPages - windowSize + i + 1);
}
return Array.from({ length: windowSize }, (_, i) => page - 2 + i);
})();
// Button classes
const btnBase =
'relative inline-flex items-center px-4 py-2 border text-sm font-medium';
const btnActive = 'z-10 bg-primary-50 border-primary-500 text-primary-600';
const btnInactive =
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50';
const btnDisabled = 'opacity-50 cursor-not-allowed';
const btnNav =
'relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50';
const from = (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
return (
<div
className={cn(
'bg-white px-4 py-3 border-t border-gray-200 sm:px-6',
className
)}
>
<div className="flex items-center justify-between">
{/* Mobile prev/next */}
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => onChange(page - 1)}
disabled={!hasPrev}
className={cn(
btnNav,
!hasPrev && btnDisabled
)}
>
Previous
</button>
<button
onClick={() => onChange(page + 1)}
disabled={!hasNext}
className={cn(
btnNav,
!hasNext && btnDisabled
)}
>
Next
</button>
</div>
{/* Desktop */}
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
{/* Result summary */}
<div>
<p className="text-sm text-gray-700">
Showing{' '}
<span className="font-medium">{total > 0 ? from : 0}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{total}</span> results
</p>
</div>
{/* Page buttons */}
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
{/* Previous */}
<button
onClick={() => onChange(page - 1)}
disabled={!hasPrev}
className={cn('rounded-l-md', btnNav, !hasPrev && btnDisabled)}
>
<span className="sr-only">Previous</span>
<ChevronLeft className="h-5 w-5" />
</button>
{pageNumbers.map((num) => {
return (
<button
key={num}
onClick={() => onChange(num)}
className={cn(
btnBase,
page === num ? btnActive : btnInactive
)}
>
{num}
</button>
);
})}
{/* Next */}
<button
onClick={() => onChange(page + 1)}
disabled={!hasNext}
className={cn('rounded-r-md', btnNav, !hasNext && btnDisabled)}
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
);
};
export default Pagination;

View file

@ -0,0 +1,136 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
/**
* StatCard a compact stat display card with icon.
*
* Usage:
* ```tsx
* <StatCard title="Total Agents" value={42} icon={Computer} to="/agents" />
* ```
*
* Props:
* title label beneath the value
* value the number (or string) to display
* icon lucide icon component
* to optional link target (wraps in react-router Link)
* color icon+background color classes (default text-gray-600 bg-gray-100)
* children optional; if provided, replaces the value/icon row entirely
*/
interface StatCardProps {
title: string;
value?: string | number;
icon?: React.ComponentType<{ className?: string }>;
to?: string;
color?: string;
className?: string;
children?: React.ReactNode;
}
const StatCard: React.FC<StatCardProps> = ({
title,
value,
icon: Icon,
to,
color = 'text-gray-600 bg-gray-100',
className,
children,
}) => {
const inner = (
<div
className={cn(
'bg-white p-4 rounded-lg border border-gray-200 shadow-sm',
to && 'hover:shadow-md transition-shadow cursor-pointer',
className
)}
>
{children ? (
children
) : (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
{Icon && (
<div className={cn('p-2 rounded-lg', color)}>
<Icon className="h-6 w-6" />
</div>
)}
</div>
)}
</div>
);
if (to) {
return <Link to={to}>{inner}</Link>;
}
return inner;
};
export default StatCard;
/**
* StatCardGroup two stat cards side-by-side with a visual divider.
*
* Used in Updates.tsx for Approved/Pending and Critical/High combined cards.
*
* Usage:
* ```tsx
* <StatCardGroup
* left={{ title: 'Approved', value: 5, icon: CheckCircle, color: '...' }}
* right={{ title: 'Pending', value: 12, icon: Clock, color: '...' }}
* />
* ```
*/
interface StatCardSideProps {
title: string;
value: string | number;
icon?: React.ComponentType<{ className?: string }>;
color?: string;
}
interface StatCardGroupProps {
left: StatCardSideProps;
right: StatCardSideProps;
className?: string;
}
export const StatCardGroup: React.FC<StatCardGroupProps> = ({
left,
right,
className,
}) => (
<div className={cn('bg-white p-4 rounded-lg border border-gray-200 shadow-sm', className)}>
<div className="flex items-center justify-between divide-x divide-gray-200">
<div className="flex-1 pr-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">{left.title}</p>
<p className={cn('text-xl font-bold', left.color ? left.color.split(' ')[0] : 'text-gray-900')}>
{left.value}
</p>
</div>
{left.icon && (
<left.icon className={cn('h-6 w-6', left.color ? left.color.split(' ')[0] : 'text-gray-400')} />
)}
</div>
</div>
<div className="flex-1 pl-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">{right.title}</p>
<p className={cn('text-xl font-bold', right.color ? right.color.split(' ')[0] : 'text-gray-900')}>
{right.value}
</p>
</div>
{right.icon && (
<right.icon className={cn('h-6 w-6', right.color ? right.color.split(' ')[0] : 'text-gray-400')} />
)}
</div>
</div>
</div>
</div>
);

View file

@ -2,3 +2,7 @@ export { default as SearchInput } from './SearchInput';
export { default as FilterPill } from './FilterPill';
export { default as FilterDropdown } from './FilterDropdown';
export { default as FilterCountButton } from './FilterCountButton';
export { default as PageState, PageSkeleton } from './PageState';
export { default as Modal } from './Modal';
export { default as Pagination } from './Pagination';
export { default as StatCard, StatCardGroup } from './StatCard';

View file

@ -67,14 +67,19 @@ const Agents: React.FC = () => {
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
const [showRestartDropdown, setShowRestartDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const restartDropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDurationDropdown(false);
}
if (restartDropdownRef.current && !restartDropdownRef.current.contains(event.target as Node)) {
setShowRestartDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
@ -1095,10 +1100,10 @@ const Agents: React.FC = () => {
</button>
{/* Duration dropdown */}
<div className="relative" ref={dropdownRef}>
<div className="relative shrink-0" ref={dropdownRef}>
<button
onClick={() => setShowDurationDropdown(!showDurationDropdown)}
className="btn btn-secondary px-3 min-w-[100px]"
className="btn btn-secondary px-3 whitespace-nowrap"
>
{getDurationLabel(heartbeatDuration)}
<ChevronDown className="h-4 w-4 ml-1" />
@ -1128,26 +1133,44 @@ const Agents: React.FC = () => {
</div>
</div>
<button
onClick={() => handleRebootAgent(selectedAgent.id, selectedAgent.hostname)}
className="w-full btn btn-warning"
>
<Power className="h-4 w-4 mr-2" />
Restart Host
</button>
<button
onClick={() => handleRemoveAgent(selectedAgent.id, selectedAgent.hostname)}
disabled={unregisterAgentMutation.isPending}
className="w-full btn btn-danger"
>
{unregisterAgentMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Remove Agent
</button>
{/* Split button: Restart Host (main) / Remove Agent (dropdown) */}
<div className="flex" ref={restartDropdownRef}>
<button
onClick={() => handleRebootAgent(selectedAgent.id, selectedAgent.hostname)}
className="flex-1 btn btn-warning rounded-r-none border-r border-warning-700"
>
<Power className="h-4 w-4 mr-2" />
Restart Host
</button>
<div className="relative">
<button
onClick={() => setShowRestartDropdown(!showRestartDropdown)}
className="btn btn-warning rounded-l-none px-2"
title="More actions"
>
<ChevronDown className="h-4 w-4" />
</button>
{showRestartDropdown && (
<div className="absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<button
onClick={() => {
setShowRestartDropdown(false);
handleRemoveAgent(selectedAgent.id, selectedAgent.hostname);
}}
disabled={unregisterAgentMutation.isPending}
className="w-full px-4 py-2 text-left text-sm text-danger-700 hover:bg-danger-50 flex items-center rounded-lg transition-colors disabled:opacity-50"
>
{unregisterAgentMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Remove Agent
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>

View file

@ -6,7 +6,6 @@ import {
Package,
CheckCircle,
AlertTriangle,
XCircle,
RefreshCw,
Clock,
} from 'lucide-react';
@ -15,85 +14,64 @@ import { useDashboardStats } from '@/hooks/useStats';
import { useServerKeySecurity } from '@/hooks/useSecurity';
import StackDriftPanel from '@/components/StackDriftPanel';
import AttentionPanel from '@/components/AttentionPanel';
import { StatCard, PageState } from '@/components/primitives';
const Dashboard: React.FC = () => {
const { data: stats, isPending, error } = useDashboardStats();
const { data: serverKeySecurity } = useServerKeySecurity();
const queryClient = useQueryClient();
if (isPending) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded-lg"></div>
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="text-center py-12">
<XCircle className="mx-auto h-12 w-12 text-danger-500" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Failed to load dashboard</h3>
<p className="mt-1 text-sm text-gray-500">Unable to fetch statistics from the server.</p>
</div>
</div>
);
}
const statCards = [
// Stat card definitions for StatCard component
const statCardDefs = [
{
title: 'Total Agents',
value: stats.total_agents,
value: stats?.total_agents ?? 0,
icon: Computer,
color: 'text-blue-600 bg-blue-100',
link: '/agents',
to: '/agents',
},
{
title: 'Online Agents',
value: stats.online_agents,
value: stats?.online_agents ?? 0,
icon: CheckCircle,
color: 'text-success-600 bg-success-100',
link: '/agents?status=online',
to: '/agents?status=online',
},
{
title: 'Pending Updates',
value: stats.pending_updates,
value: stats?.pending_updates ?? 0,
icon: Clock,
color: 'text-warning-600 bg-warning-100',
link: '/updates?status=pending',
to: '/updates?status=pending',
},
{
title: 'Failed Updates',
value: stats.failed_updates,
icon: XCircle,
value: stats?.failed_updates ?? 0,
icon: Package,
color: 'text-danger-600 bg-danger-100',
link: '/updates?status=failed',
to: '/updates?status=failed',
},
];
const severityBreakdown = [
{ label: 'Critical', value: stats.critical_updates, color: 'bg-danger-600' },
{ label: 'High', value: stats.high_updates, color: 'bg-warning-600' },
{ label: 'Medium', value: stats.medium_updates, color: 'bg-blue-600' },
{ label: 'Low', value: stats.low_updates, color: 'bg-gray-600' },
{ label: 'Critical', value: stats?.critical_updates ?? 0, color: 'bg-danger-600' },
{ label: 'High', value: stats?.high_updates ?? 0, color: 'bg-warning-600' },
{ label: 'Medium', value: stats?.medium_updates ?? 0, color: 'bg-blue-600' },
{ label: 'Low', value: stats?.low_updates ?? 0, color: 'bg-gray-600' },
];
const updateTypeBreakdown = Object.entries(stats.updates_by_type).map(([type, count]) => ({
const updateTypeBreakdown = Object.entries(stats?.updates_by_type ?? {}).map(([type, count]) => ({
type: type.charAt(0).toUpperCase() + type.slice(1),
value: count,
icon: type === 'apt' ? '📦' : type === 'docker' ? '🐳' : '📋',
}));
return (
<div className="px-4 sm:px-6 lg:px-8">
<PageState
loading={isPending}
error={error ? 'Unable to fetch statistics from the server.' : null}
empty={false}
>
{/* Page header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
@ -120,30 +98,16 @@ const Dashboard: React.FC = () => {
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => {
const Icon = stat.icon;
return (
<Link
key={stat.title}
to={stat.link}
className="group card block hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 group-hover:text-gray-900">
{stat.title}
</p>
<p className="mt-2 text-3xl font-bold text-gray-900">
{stat.value.toLocaleString()}
</p>
</div>
<div className={`p-3 rounded-lg ${stat.color}`}>
<Icon className="h-6 w-6" />
</div>
</div>
</Link>
);
})}
{statCardDefs.map((stat) => (
<StatCard
key={stat.title}
title={stat.title}
value={stat.value}
icon={stat.icon}
color={stat.color}
to={stat.to}
/>
))}
</div>
<div className="mb-8">
@ -263,7 +227,7 @@ const Dashboard: React.FC = () => {
</button>
</div>
</div>
</div>
</PageState>
);
};

View file

@ -123,10 +123,7 @@ const Docker: React.FC = () => {
const criticalUpdates = images.filter((i: DockerImage) => i.severity === 'critical').length;
return (
<div className="px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">

View file

@ -1,36 +1,120 @@
import React, { useState } from 'react';
import { History } from 'lucide-react';
import { History, Filter } from 'lucide-react';
import ChatTimeline from '@/components/ChatTimeline';
import { SearchInput } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
const EVENT_TYPES = [
{ value: '', label: 'All types' },
{ value: 'command', label: 'Commands' },
{ value: 'system_event', label: 'System events' },
{ value: 'package_event', label: 'Package events' },
{ value: 'install_transition', label: 'Install transitions' },
{ value: 'log', label: 'Logs' },
];
const SEVERITIES = [
{ value: '', label: 'All severity' },
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'critical', label: 'Critical' },
];
const HistoryPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [eventType, setEventType] = useState('');
const [severity, setSeverity] = useState('');
const [showFilters, setShowFilters] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 300);
const activeFilterCount = [eventType, severity].filter(Boolean).length;
return (
<div className="px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-3">
<History className="h-8 w-8 text-indigo-600" />
<h1 className="text-2xl font-bold text-gray-900">History & Audit Log</h1>
</div>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search events..."
className="w-64"
/>
<div className="flex items-center space-x-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md transition-colors ${
showFilters || activeFilterCount > 0
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="h-3.5 w-3.5" />
Filters
{activeFilterCount > 0 && (
<span className="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium bg-indigo-600 text-white rounded-full">
{activeFilterCount}
</span>
)}
</button>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search events..."
className="w-64"
/>
</div>
</div>
<p className="text-gray-600">
Command history, package events, and system activity across all agents
Command history, package events, system activity, and client errors across all agents
</p>
</div>
{/* Filter bar — Novell-style dense row */}
{showFilters && (
<div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-md">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Type</label>
<select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
>
{EVENT_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Severity</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
>
{SEVERITIES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{activeFilterCount > 0 && (
<button
onClick={() => { setEventType(''); setSeverity(''); }}
className="text-xs text-indigo-600 hover:text-indigo-800 underline"
>
Clear filters
</button>
)}
</div>
</div>
)}
{/* Timeline */}
<ChatTimeline externalSearch={debouncedSearch} />
<ChatTimeline
externalSearch={debouncedSearch}
externalType={eventType}
externalSeverity={severity}
/>
</div>
);
};

View file

@ -289,9 +289,7 @@ const LiveOperations: React.FC = () => {
});
return (
<div className="px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-2">

View file

@ -23,7 +23,7 @@ import {
HardDrive,
FileText,
} from 'lucide-react';
import { SearchInput, FilterDropdown, FilterPill } from '@/components/primitives';
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useQueryClient } from '@tanstack/react-query';
import { useUpdates, useUpdate, usePackages, usePackageFleet, usePackageVersions, useUpdateLifecycle, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useReopenUpdate, useResolveUpdate, useCancelCommand } from '@/hooks/useUpdates';
@ -1164,212 +1164,152 @@ const Updates: React.FC = () => {
</div>
</div>
{/* Dependency Confirmation Modal */}
{showDependencyModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-gray-200">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
<h3 className="text-lg font-semibold text-gray-900">
Dependencies Required
</h3>
<button
type="button"
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
onClick={handleCancelDependencies}
>
<span className="sr-only">Close</span>
<X className="h-5 w-5" />
</button>
{/* Dependency Confirmation Modal — Modal primitive */}
<Modal open={showDependencyModal} onClose={handleCancelDependencies} title="Dependencies Required" maxWidth="2xl">
<Modal.Body>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-amber-500" />
</div>
<div className="flex-1">
<h4 className="text-base font-medium text-gray-900">
Additional packages are required
</h4>
<p className="mt-1 text-sm text-gray-600">
To install <span className="font-medium text-gray-900">{selectedUpdate?.package_name}</span>, the following additional packages will also be installed:
</p>
</div>
</div>
{/* Content */}
<div className="bg-white px-6 py-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-amber-500" />
</div>
<div className="flex-1">
<h4 className="text-base font-medium text-gray-900">
Additional packages are required
</h4>
<p className="mt-1 text-sm text-gray-600">
To install <span className="font-medium text-gray-900">{selectedUpdate?.package_name}</span>, the following additional packages will also be installed:
</p>
</div>
</div>
{pendingDependencies.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<h5 className="text-sm font-medium text-gray-700 mb-3">Required Dependencies:</h5>
<ul className="space-y-2">
{pendingDependencies.map((dep, index) => (
<li key={index} className="flex items-center space-x-2 text-sm">
<Package className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">{dep}</span>
</li>
))}
</ul>
</div>
)}
{/* Dependencies List */}
{pendingDependencies.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<h5 className="text-sm font-medium text-gray-700 mb-3">Required Dependencies:</h5>
<ul className="space-y-2">
{pendingDependencies.map((dep, index) => (
<li key={index} className="flex items-center space-x-2 text-sm">
<Package className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">{dep}</span>
</li>
))}
</ul>
</div>
)}
{/* Warning Message */}
<div className="alert alert-warning rounded-md p-3">
<div className="flex">
<AlertTriangle className="h-4 w-4 text-amber-500 mr-2 flex-shrink-0" />
<div className="text-sm text-amber-800">
<p className="font-medium">Please review the dependencies before proceeding.</p>
<p className="mt-1">These additional packages will be installed alongside your requested package.</p>
</div>
</div>
</div>
<div className="alert alert-warning rounded-md p-3">
<div className="flex">
<AlertTriangle className="h-4 w-4 text-amber-500 mr-2 flex-shrink-0" />
<div className="text-sm text-amber-800">
<p className="font-medium">Please review the dependencies before proceeding.</p>
<p className="mt-1">These additional packages will be installed alongside your requested package.</p>
</div>
</div>
{/* Footer */}
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => handleConfirmDependencies(dependencyUpdateId!)}
disabled={dependencyLoading}
>
{dependencyLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Approving & Installing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Approve & Install All
</>
)}
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={handleCancelDependencies}
disabled={dependencyLoading}
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</Modal.Body>
{/* Log Modal */}
{showLogModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl border border-gray-200">
{/* Modern Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-lg">
<h3 className="text-lg font-semibold text-gray-900">
Installation Logs - {selectedUpdate?.package_name}
</h3>
<button
type="button"
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-md p-1"
onClick={() => setShowLogModal(false)}
>
<span className="sr-only">Close</span>
<X className="h-5 w-5" />
</button>
<Modal.Footer>
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => handleConfirmDependencies(dependencyUpdateId!)}
disabled={dependencyLoading}
>
{dependencyLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Approving & Installing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Approve & Install All
</>
)}
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={handleCancelDependencies}
disabled={dependencyLoading}
>
Cancel
</button>
</Modal.Footer>
</Modal>
{/* Log Modal — Modal primitive */}
<Modal open={showLogModal} onClose={() => setShowLogModal(false)} title={`Installation Logs — ${selectedUpdate?.package_name}`} maxWidth="4xl">
<Modal.Body className="p-0">
<div className="bg-gray-900 text-green-400 p-4 max-h-96 overflow-y-auto terminal">
{logsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-green-400 mr-2" />
<span className="text-green-400">Loading logs...</span>
</div>
{/* Terminal Content Area */}
<div className="bg-gray-900 text-green-400 p-4 max-h-96 overflow-y-auto" style={{ fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' }}>
{logsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-green-400 mr-2" />
<span className="text-green-400">Loading logs...</span>
</div>
) : logs.length === 0 ? (
<div className="text-gray-500 text-center py-8">
No installation logs available for this update.
</div>
) : (
<div className="space-y-3">
{logs.map((log, index) => (
<div key={index} className="border-b border-gray-700 pb-3 last:border-b-0">
<div className="flex items-center space-x-3 mb-2 text-xs">
<span className="text-gray-500">
{new Date(log.executedAt).toLocaleString()}
</span>
<span className={cn(
"px-2 py-1 rounded font-medium",
log.action === 'install' ? "bg-blue-900/50 text-blue-300" :
log.action === 'configure' ? "bg-yellow-900/50 text-yellow-300" :
log.action === 'cleanup' ? "bg-gray-700 text-gray-300" :
"bg-gray-700 text-gray-300"
)}>
{log.action?.toUpperCase() || 'UNKNOWN'}
</span>
{log.exit_code !== undefined && (
<span className={cn(
"px-2 py-1 rounded font-medium",
log.exit_code === 0 ? "bg-green-900/50 text-green-300" : "bg-red-900/50 text-red-300"
)}>
Exit: {log.exit_code}
</span>
)}
{log.duration_seconds && (
<span className="text-gray-500">
{log.duration_seconds}s
</span>
)}
</div>
{log.stdout && (
<div className="text-sm text-gray-300 whitespace-pre-wrap mb-2 font-mono">
{log.stdout}
</div>
)}
{log.stderr && (
<div className="text-sm text-red-400 whitespace-pre-wrap font-mono">
{log.stderr}
</div>
)}
</div>
))}
</div>
)}
) : logs.length === 0 ? (
<div className="text-gray-500 text-center py-8">
No installation logs available for this update.
</div>
{/* Modern Footer */}
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg border-t border-gray-200">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => setShowLogModal(false)}
>
Close
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => {
// Copy logs to clipboard functionality could be added here
navigator.clipboard.writeText(logs.map(log =>
`${log.action?.toUpperCase() || 'UNKNOWN'} - ${new Date(log.executedAt).toLocaleString()}\n${log.stdout || ''}\n${log.stderr || ''}`
).join('\n\n'));
// Could add toast notification here
}}
>
Copy Logs
</button>
) : (
<div className="space-y-3">
{logs.map((log, index) => (
<div key={index} className="border-b border-gray-700 pb-3 last:border-b-0">
<div className="flex items-center space-x-3 mb-2 text-xs">
<span className="text-gray-500">
{new Date(log.executedAt).toLocaleString()}
</span>
<span className={cn(
"px-2 py-1 rounded font-medium",
log.action === 'install' ? "bg-blue-900/50 text-blue-300" :
log.action === 'configure' ? "bg-yellow-900/50 text-yellow-300" :
log.action === 'cleanup' ? "bg-gray-700 text-gray-300" :
"bg-gray-700 text-gray-300"
)}>
{log.action?.toUpperCase() || 'UNKNOWN'}
</span>
{log.exit_code !== undefined && (
<span className={cn(
"px-2 py-1 rounded font-medium",
log.exit_code === 0 ? "bg-green-900/50 text-green-300" : "bg-red-900/50 text-red-300"
)}>
Exit: {log.exit_code}
</span>
)}
{log.duration_seconds && (
<span className="text-gray-500">{log.duration_seconds}s</span>
)}
</div>
{log.stdout && <div className="text-sm text-gray-300 whitespace-pre-wrap mb-2 font-mono">{log.stdout}</div>}
{log.stderr && <div className="text-sm text-red-400 whitespace-pre-wrap font-mono">{log.stderr}</div>}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</Modal.Body>
<Modal.Footer>
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => setShowLogModal(false)}
>
Close
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => {
navigator.clipboard.writeText(logs.map(log =>
`${log.action?.toUpperCase() || 'UNKNOWN'} - ${new Date(log.executedAt).toLocaleString()}\n${log.stdout || ''}\n${log.stderr || ''}`
).join('\n\n'));
}}
>
Copy Logs
</button>
</Modal.Footer>
</Modal>
</div>
);
}
@ -1533,66 +1473,17 @@ const Updates: React.FC = () => {
</div>
</div>
{/* Statistics Cards - Compact design with combined visual boxes */}
{/* Statistics Cards — StatCard primitives */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{/* Total Updates - Standalone */}
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Updates</p>
<p className="text-2xl font-bold text-gray-900">{totalStats.total}</p>
</div>
<Package className="h-8 w-8 text-gray-400" />
</div>
</div>
{/* Approved / Pending - Combined with divider */}
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between divide-x divide-gray-200">
<div className="flex-1 pr-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">Approved</p>
<p className="text-xl font-bold text-green-600">{totalStats.approved}</p>
</div>
<CheckCircle className="h-6 w-6 text-green-400" />
</div>
</div>
<div className="flex-1 pl-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">Pending</p>
<p className="text-xl font-bold text-orange-600">{totalStats.pending}</p>
</div>
<Clock className="h-6 w-6 text-orange-400" />
</div>
</div>
</div>
</div>
{/* Critical / High Priority - Combined with divider */}
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between divide-x divide-gray-200">
<div className="flex-1 pr-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">Critical</p>
<p className="text-xl font-bold text-red-600">{totalStats.critical}</p>
</div>
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
</div>
<div className="flex-1 pl-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600">High Priority</p>
<p className="text-xl font-bold text-yellow-600">{totalStats.high}</p>
</div>
<AlertTriangle className="h-6 w-6 text-yellow-400" />
</div>
</div>
</div>
</div>
<StatCard title="Total Updates" value={totalStats.total} icon={Package} />
<StatCardGroup
left={{ title: 'Approved', value: totalStats.approved, icon: CheckCircle, color: 'text-green-600' }}
right={{ title: 'Pending', value: totalStats.pending, icon: Clock, color: 'text-orange-600' }}
/>
<StatCardGroup
left={{ title: 'Critical', value: totalStats.critical, icon: AlertTriangle, color: 'text-red-600' }}
right={{ title: 'High Priority', value: totalStats.high, icon: AlertTriangle, color: 'text-yellow-600' }}
/>
</div>
{/* Quick Filters */}
@ -1784,34 +1675,19 @@ const Updates: React.FC = () => {
)}
</div>
{/* Package list */}
{packagesPending ? (
<div className="animate-pulse">
<div className="bg-white rounded-lg border border-gray-200">
{[...Array(5)].map((_, i) => (
<div key={i} className="p-4 border-b border-gray-200">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
</div>
) : packagesError ? (
<div className="text-center py-12">
<div className="text-red-500 mb-2">Failed to load updates</div>
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
</div>
) : displayedPackages.length === 0 ? (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No updates found</h3>
<p className="mt-1 text-sm text-gray-500">
{debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter || vulnFilter
? 'Try adjusting your search or filters.'
: 'All agents are up to date!'}
</p>
</div>
) : (
{/* Package list — PageState primitive for loading/error/empty */}
<PageState
loading={packagesPending}
error={packagesError ? 'Failed to load updates' : null}
empty={displayedPackages.length === 0}
emptyTitle="No updates found"
emptyMessage={
debouncedSearchQuery || statusFilter || severityFilter || typeFilter || agentFilter || vulnFilter
? 'Try adjusting your search or filters.'
: 'All agents are up to date!'
}
errorAction={() => queryClient.invalidateQueries({ queryKey: ['packages'] })}
>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
@ -1952,80 +1828,17 @@ const Updates: React.FC = () => {
{/* Pagination */}
{packageTotalPages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!packageHasPrev}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!packageHasNext}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{' '}
<span className="font-medium">{Math.min(currentPage * pageSize, packageTotal)}</span> of{' '}
<span className="font-medium">{packageTotal}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!packageHasPrev}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Previous</span>
<ChevronLeft className="h-5 w-5" />
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, packageTotalPages) }, (_, i) => {
const pageNum = packageTotalPages <= 5 ? i + 1 : currentPage <= 3 ? i + 1 : currentPage >= packageTotalPages - 2 ? packageTotalPages - 4 + i : currentPage - 2 + i;
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!packageHasNext}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
</div>
<Pagination
page={currentPage}
total={packageTotal}
pageSize={pageSize}
onChange={handlePageChange}
/>
)}
</div>
)}
</PageState>
</div>
);
};
export default Updates;
export default Updates;