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:
parent
c0a717ab26
commit
7cbf174652
30 changed files with 1781 additions and 481 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
145
web/src/components/primitives/Modal.tsx
Normal file
145
web/src/components/primitives/Modal.tsx
Normal 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;
|
||||
122
web/src/components/primitives/PageState.tsx
Normal file
122
web/src/components/primitives/PageState.tsx
Normal 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;
|
||||
170
web/src/components/primitives/Pagination.tsx
Normal file
170
web/src/components/primitives/Pagination.tsx
Normal 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;
|
||||
136
web/src/components/primitives/StatCard.tsx
Normal file
136
web/src/components/primitives/StatCard.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue