feat: UI primitives + agent version bump
- PageState, Modal, Pagination, StatCard primitives - Migrated Dashboard, Updates, LiveOperations, Agents, Docker, TokenManagement - AgentUpdate, AgentUpdatesModal now use Modal primitive - Version bump to 0.2.6.9
This commit is contained in:
parent
7cbf174652
commit
c3d84ec7f9
8 changed files with 138 additions and 248 deletions
|
|
@ -3,6 +3,7 @@ import { Upload, CheckCircle, XCircle, RotateCw, Download } from 'lucide-react';
|
|||
import { useAgentUpdate } from '@/hooks/useAgentUpdate';
|
||||
import { Agent } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Modal } from '@/components/primitives';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AgentUpdateProps {
|
||||
|
|
@ -164,65 +165,64 @@ export function AgentUpdate({ agent, onUpdateComplete, className }: AgentUpdateP
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900">
|
||||
Update Agent: {agent.hostname}
|
||||
</h3>
|
||||
|
||||
{/* Warning for same-version updates */}
|
||||
{currentVersion === availableVersion ? (
|
||||
<>
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
||||
<p className="text-amber-800 font-medium mb-2">
|
||||
⚠️ Version appears identical
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 mb-2">
|
||||
Current: <strong>{currentVersion}</strong> → Target: <strong>{availableVersion}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
This will reinstall the current version. Useful if the binary was rebuilt or corrupted.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
The agent will be temporarily offline during reinstallation.
|
||||
{/* Confirmation Dialog — Modal primitive */}
|
||||
<Modal
|
||||
open={showConfirmDialog}
|
||||
onClose={() => setShowConfirmDialog(false)}
|
||||
title={`Update Agent: ${agent.hostname}`}
|
||||
maxWidth="md"
|
||||
>
|
||||
<Modal.Body>
|
||||
{/* Warning for same-version updates */}
|
||||
{currentVersion === availableVersion ? (
|
||||
<>
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
||||
<p className="text-amber-800 font-medium mb-2">
|
||||
⚠️ Version appears identical
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Update agent from <strong>{currentVersion}</strong> to <strong>{availableVersion}</strong>?
|
||||
<p className="text-sm text-amber-700 mb-2">
|
||||
Current: <strong>{currentVersion}</strong> → Target: <strong>{availableVersion}</strong>
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
This will temporarily take the agent offline during the update process.
|
||||
<p className="text-xs text-amber-600">
|
||||
This will reinstall the current version. Useful if the binary was rebuilt or corrupted.
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
The agent will be temporarily offline during reinstallation.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Update agent from <strong>{currentVersion}</strong> to <strong>{availableVersion}</strong>?
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
This will temporarily take the agent offline during the update process.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmUpdate}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded hover:bg-primary-700",
|
||||
currentVersion === availableVersion
|
||||
? "bg-amber-600 text-white hover:bg-amber-700"
|
||||
: "bg-primary-600 text-white"
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmUpdate}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded hover:bg-primary-700",
|
||||
currentVersion === availableVersion
|
||||
? "bg-amber-600 text-white hover:bg-amber-700"
|
||||
: "bg-primary-600 text-white"
|
||||
)}
|
||||
>
|
||||
{currentVersion === availableVersion ? 'Reinstall Agent' : 'Update Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{currentVersion === availableVersion ? 'Reinstall Agent' : 'Update Agent'}
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||
import { agentApi, updateApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn, versionCompare } from '@/lib/utils';
|
||||
import { Modal } from '@/components/primitives';
|
||||
import { Agent } from '@/types';
|
||||
import { clientLogger } from '@/lib/client-logger';
|
||||
|
||||
|
|
@ -180,33 +181,21 @@ export function AgentUpdatesModal({
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalTitle = (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Download className="h-6 w-6 text-primary-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Agent Updates</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Update {selectedAgentIds.length} agent{selectedAgentIds.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl transform overflow-hidden rounded-lg bg-white p-6 text-left shadow-xl transition-all">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Download className="h-6 w-6 text-primary-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Agent Updates</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Update {selectedAgentIds.length} agent{selectedAgentIds.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="py-4">
|
||||
<Modal open={isOpen} onClose={onClose} title={modalTitle} maxWidth="4xl">
|
||||
<Modal.Body>
|
||||
{/* Selected Agents */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
||||
|
|
@ -340,20 +329,17 @@ export function AgentUpdatesModal({
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal.Footer>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import { cn } from '@/lib/utils';
|
|||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
title?: React.ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
MonitorPlay,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown } from '@/components/primitives';
|
||||
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
|
||||
|
|
@ -1243,34 +1243,18 @@ const Agents: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agents table */}
|
||||
{isPending ? (
|
||||
<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>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load agents</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Computer className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No agents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{debouncedSearchQuery || statusFilter || osFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{/* Agents table — PageState primitive */}
|
||||
<PageState
|
||||
loading={isPending}
|
||||
error={error ? 'Failed to load agents' : null}
|
||||
empty={filteredAgents.length === 0}
|
||||
emptyTitle="No agents found"
|
||||
emptyMessage={
|
||||
debouncedSearchQuery || statusFilter || osFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No agents have registered with the server yet.'
|
||||
}
|
||||
>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
|
|
@ -1443,7 +1427,7 @@ const Agents: React.FC = () => {
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageState>
|
||||
|
||||
{/* Agent Updates Modal */}
|
||||
<AgentUpdatesModal
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useDockerContainers, useDockerStats, useFleetRuntimeContainers, useFlee
|
|||
import type { DockerContainer, DockerImage, RuntimeDockerContainer, RuntimeDockerStack } from '@/types';
|
||||
import { formatRelativeTime, cn } from '@/lib/utils';
|
||||
import { useMultimodalFilter } from '@/hooks/useMultimodalFilter';
|
||||
import { SearchInput, FilterPill, FilterCountButton } from '@/components/primitives';
|
||||
import { SearchInput, FilterPill, FilterCountButton, PageState } from '@/components/primitives';
|
||||
|
||||
// ─── Filter config ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -203,34 +203,18 @@ const Docker: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Container updates table */}
|
||||
{isPending ? (
|
||||
<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 className="h-3 bg-gray-200 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-2">Failed to load container updates</div>
|
||||
<p className="text-sm text-gray-600">Please check your connection and try again.</p>
|
||||
</div>
|
||||
) : filteredImages.length === 0 && agentGroups.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Container className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No container images found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{activeCount > 0 || rawQuery
|
||||
? 'Try adjusting your search or clearing filters.'
|
||||
: 'No Docker containers or images found on any agents.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{/* Container updates table — PageState primitive */}
|
||||
<PageState
|
||||
loading={isPending}
|
||||
error={error ? 'Failed to load container updates' : null}
|
||||
empty={filteredImages.length === 0 && agentGroups.length === 0}
|
||||
emptyTitle="No container images found"
|
||||
emptyMessage={
|
||||
activeCount > 0 || rawQuery
|
||||
? 'Try adjusting your search or clearing filters.'
|
||||
: 'No Docker containers or images found on any agents.'
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{agentGroups.map((agentGroup) => (
|
||||
<div key={agentGroup.agentId} className="card overflow-hidden">
|
||||
|
|
@ -321,7 +305,7 @@ const Docker: React.FC = () => {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageState>
|
||||
|
||||
{/* Runtime Container State */}
|
||||
{runtimeContainers.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown } from '@/components/primitives';
|
||||
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAgents } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
|
||||
|
|
@ -528,37 +528,19 @@ const LiveOperations: React.FC = () => {
|
|||
</h3>
|
||||
)}
|
||||
|
||||
{/* Operations list */}
|
||||
{commandsLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="mx-auto h-12 w-12 text-blue-500 animate-spin" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Loading operations</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Fetching active commands...</p>
|
||||
</div>
|
||||
) : commandsError ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-amber-500" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Failed to load operations</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">The server may be unavailable.</p>
|
||||
<button
|
||||
onClick={() => refetchCommands()}
|
||||
className="mt-3 inline-flex items-center 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"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : filteredOperations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No active operations</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchQuery || statusFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All operations are completed. Check the Updates page to start new operations.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{/* Operations list — PageState primitive */}
|
||||
<PageState
|
||||
loading={commandsLoading}
|
||||
error={commandsError ? 'Failed to load operations' : null}
|
||||
empty={filteredOperations.length === 0}
|
||||
emptyTitle="No active operations"
|
||||
emptyMessage={
|
||||
searchQuery || statusFilter
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'All operations are completed. Check the Updates page to start new operations.'
|
||||
}
|
||||
errorAction={() => refetchCommands()}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{filteredOperations.map((operation) => (
|
||||
<div
|
||||
|
|
@ -749,11 +731,10 @@ const LiveOperations: React.FC = () => {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageState>
|
||||
|
||||
{/* Cleanup Confirmation Dialog */}
|
||||
{showCleanupDialog && (
|
||||
{/* Cleanup Confirmation Dialog */}
|
||||
{showCleanupDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Archive Failed Operations</h3>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Clock,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { SearchInput } from '@/components/primitives';
|
||||
import { SearchInput, Pagination } from '@/components/primitives';
|
||||
import {
|
||||
useRegistrationTokens,
|
||||
useCreateRegistrationToken,
|
||||
|
|
@ -507,60 +507,15 @@ const TokenManagement: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{/* Pagination — Pagination primitive */}
|
||||
{tokensData && tokensData.total > pageSize && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((currentPage - 1) * pageSize) + 1}-{Math.min(currentPage * pageSize, tokensData.total)} of {tokensData.total} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, Math.ceil(tokensData.total / pageSize)) }, (_, i) => {
|
||||
const totalPages = Math.ceil(tokensData.total / pageSize);
|
||||
let pageNum;
|
||||
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`px-3 py-1 border rounded text-sm ${
|
||||
currentPage === pageNum
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(Math.ceil(tokensData.total / pageSize), currentPage + 1))}
|
||||
disabled={currentPage >= Math.ceil(tokensData.total / pageSize)}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
total={tokensData.total}
|
||||
pageSize={pageSize}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue