ui: command primitives extracted; agent page split-button sizing restored
CommandCard and CommandStatusBadge pulled out of Agents.tsx; restart host split button back to flush edges with stretched trigger.
This commit is contained in:
parent
b9e083c4f7
commit
469d61c0dc
11 changed files with 520 additions and 200 deletions
92
web/src/components/primitives/CommandCard.tsx
Normal file
92
web/src/components/primitives/CommandCard.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import { RefreshCw, Clock, CheckCircle, XCircle, Activity, Package, Search } from 'lucide-react';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
import { getCommandStatus } from './CommandStatusBadge';
|
||||
|
||||
function getCommandDisplayInfo(command: any): { icon: React.ReactNode; label: string } {
|
||||
const getPackageName = (cmd: any): string => {
|
||||
if (cmd.package_name) return cmd.package_name;
|
||||
if (cmd.params?.package_name) return cmd.params.package_name;
|
||||
if (cmd.params?.update_id && cmd.update_name) return cmd.update_name;
|
||||
return 'unknown package';
|
||||
};
|
||||
|
||||
const actionMap: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
scan: { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
|
||||
install_updates: { icon: <Package className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
|
||||
dry_run_update: { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${getPackageName(command)}` },
|
||||
confirm_dependencies: { icon: <CheckCircle className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
|
||||
};
|
||||
|
||||
return actionMap[command.command_type] ?? {
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
label: command.command_type.replace('_', ' '),
|
||||
};
|
||||
}
|
||||
|
||||
function commandTimestamp(createdAt: string): string {
|
||||
const createdTime = new Date(createdAt);
|
||||
const hoursOld = (Date.now() - createdTime.getTime()) / (1000 * 60 * 60);
|
||||
return hoursOld > 1 ? createdTime.toLocaleString() : formatRelativeTime(createdAt);
|
||||
}
|
||||
|
||||
interface CommandCardProps {
|
||||
command: any;
|
||||
onCancel: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CommandCard: React.FC<CommandCardProps> = ({ command, onCancel, onDismiss, disabled }) => {
|
||||
const displayInfo = getCommandDisplayInfo(command);
|
||||
const statusInfo = getCommandStatus(command.status);
|
||||
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
|
||||
<div className="flex-shrink-0 mt-0.5">{displayInfo.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
|
||||
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
|
||||
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">{commandTimestamp(command.created_at)}</span>
|
||||
{(command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => onCancel(command.id)}
|
||||
disabled={disabled}
|
||||
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{(command.status === 'timed_out' || command.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => onDismiss(command.id)}
|
||||
disabled={disabled}
|
||||
className="text-xs text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandCard;
|
||||
37
web/src/components/primitives/CommandStatusBadge.tsx
Normal file
37
web/src/components/primitives/CommandStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CommandStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed' | 'timed_out' | string;
|
||||
|
||||
interface StatusInfo {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function getCommandStatus(status: CommandStatus): StatusInfo {
|
||||
switch (status) {
|
||||
case 'pending': return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
|
||||
case 'sent': return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
|
||||
case 'running': return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
|
||||
case 'completed': return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
case 'failed': return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
case 'timed_out': return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
default: return { text: status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
}
|
||||
}
|
||||
|
||||
interface CommandStatusBadgeProps {
|
||||
status: CommandStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommandStatusBadge: React.FC<CommandStatusBadgeProps> = ({ status, className }) => {
|
||||
const { text, color } = getCommandStatus(status);
|
||||
return (
|
||||
<span className={cn('inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border', color, className)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandStatusBadge;
|
||||
|
|
@ -9,3 +9,5 @@ export { default as StatCard, StatCardGroup } from './StatCard';
|
|||
export { default as ScreenshotCard } from './ScreenshotCard';
|
||||
export { default as MetricItem } from './MetricItem';
|
||||
export { default as ProcessTable } from './ProcessTable';
|
||||
export { default as CommandCard } from './CommandCard';
|
||||
export { default as CommandStatusBadge, getCommandStatus } from './CommandStatusBadge';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|||
import {
|
||||
Computer,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
ChevronDown,
|
||||
Activity,
|
||||
|
|
@ -18,7 +17,6 @@ import {
|
|||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Power,
|
||||
MonitorPlay,
|
||||
Upload,
|
||||
|
|
@ -30,6 +28,7 @@ import {
|
|||
ScreenshotCard,
|
||||
MetricItem,
|
||||
ProcessTable,
|
||||
CommandCard,
|
||||
} from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
|
|
@ -394,49 +393,6 @@ const Agents: React.FC = () => {
|
|||
return activeCommandsData.commands.filter(cmd => cmd.agent_id === selectedAgent.id);
|
||||
};
|
||||
|
||||
// Helper function to get command display info
|
||||
const getCommandDisplayInfo = (command: any) => {
|
||||
// Helper to get package name from command params
|
||||
const getPackageName = (cmd: any) => {
|
||||
if (cmd.package_name) return cmd.package_name;
|
||||
if (cmd.params?.package_name) return cmd.params.package_name;
|
||||
if (cmd.params?.update_id && cmd.update_name) return cmd.update_name;
|
||||
return 'unknown package';
|
||||
};
|
||||
|
||||
const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = {
|
||||
'scan': { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
|
||||
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
|
||||
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${getPackageName(command)}` },
|
||||
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
|
||||
};
|
||||
|
||||
return actionMap[command.command_type] || {
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
label: command.command_type.replace('_', ' ')
|
||||
};
|
||||
};
|
||||
|
||||
// Get command status
|
||||
const getCommandStatus = (command: any) => {
|
||||
switch (command.status) {
|
||||
case 'pending':
|
||||
return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
|
||||
case 'sent':
|
||||
return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
|
||||
case 'running':
|
||||
return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
|
||||
case 'completed':
|
||||
return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
case 'timed_out':
|
||||
return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
|
||||
default:
|
||||
return { text: command.status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique OS types for filter
|
||||
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
|
||||
|
||||
|
|
@ -718,84 +674,15 @@ const Agents: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return displayCommands.map((command, _index) => {
|
||||
const displayInfo = getCommandDisplayInfo(command);
|
||||
const statusInfo = getCommandStatus(command);
|
||||
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
|
||||
|
||||
return (
|
||||
<div key={command.id} className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{displayInfo.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{isActive ? (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
|
||||
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
|
||||
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
|
||||
statusInfo.color
|
||||
)}>
|
||||
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
<span className="ml-1">{displayInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const createdTime = new Date(command.created_at);
|
||||
const now = new Date();
|
||||
const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
// Show exact time for commands older than 1 hour, relative time for recent ones
|
||||
if (hoursOld > 1) {
|
||||
return createdTime.toLocaleString();
|
||||
} else {
|
||||
return formatRelativeTime(command.created_at);
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
{isActive && (command.status === 'pending' || command.status === 'sent') && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelCommandMutation.isPending}
|
||||
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{command.status === 'timed_out' && (
|
||||
<button
|
||||
onClick={() => handleCancelCommand(command.id)}
|
||||
disabled={cancelCommandMutation.isPending}
|
||||
className="text-xs text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return displayCommands.map((command) => (
|
||||
<CommandCard
|
||||
key={command.id}
|
||||
command={command}
|
||||
onCancel={handleCancelCommand}
|
||||
onDismiss={handleCancelCommand}
|
||||
disabled={cancelCommandMutation.isPending}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
|
|
@ -1003,16 +890,16 @@ const Agents: React.FC = () => {
|
|||
<Power className="h-4 w-4 mr-2" />
|
||||
Restart Host
|
||||
</button>
|
||||
<div className="relative">
|
||||
<div className="relative flex">
|
||||
<button
|
||||
onClick={() => setShowRestartDropdown(!showRestartDropdown)}
|
||||
className="btn btn-warning rounded-l-none px-2"
|
||||
className="btn btn-warning rounded-l-none px-2 self-stretch"
|
||||
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">
|
||||
<div className="absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRestartDropdown(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue