refactor: StatusBadge + SeverityBadge primitives — one way to render state
Extract the repeated cn('badge', getStatusColor/getSeverityColor) span
into primitives; port Updates, Agents, Docker, LiveOperations,
PackageDetail, AgentUpdatesEnhanced. Docker's local colour maps deleted —
its image-lifecycle statuses join the central getStatusColor map, and its
severity palette aligns with the app-wide one. AgentUpdatesEnhanced's
divergent local severity palette replaced by the shared map.
This commit is contained in:
parent
7269d25823
commit
aee87c476d
9 changed files with 84 additions and 85 deletions
|
|
@ -16,7 +16,7 @@ import {
|
|||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { formatRelativeTime, formatBytes } from '@/lib/utils';
|
||||
import { formatRelativeTime, formatBytes, getSeverityColor } from '@/lib/utils';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -225,19 +225,6 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
const totalCount = updateData?.total || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical': return 'text-red-600 bg-red-50';
|
||||
case 'important':
|
||||
case 'high': return 'text-orange-600 bg-orange-50';
|
||||
case 'moderate':
|
||||
case 'medium': return 'text-yellow-600 bg-yellow-50';
|
||||
case 'low':
|
||||
case 'none': return 'text-blue-600 bg-blue-50';
|
||||
default: return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectUpdate = (updateId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUpdates([...selectedUpdates, updateId]);
|
||||
|
|
|
|||
22
web/src/components/primitives/StateBadge.tsx
Normal file
22
web/src/components/primitives/StateBadge.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { cn, getStatusColor, getSeverityColor } from '@/lib/utils';
|
||||
|
||||
// State badge primitives — the one way to render a lifecycle status or a
|
||||
// severity as a badge. Colour mapping lives in lib/utils (getStatusColor /
|
||||
// getSeverityColor); these wrap the repeated `cn('badge', ...)` span so pages
|
||||
// stop hand-rolling it (and drifting palettes while they're at it).
|
||||
|
||||
interface BadgeProps {
|
||||
/** Extra classes merged onto the badge (e.g. 'text-[10px]', 'ml-auto'). */
|
||||
className?: string;
|
||||
/** Override the rendered text; defaults to the status/severity value. */
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<BadgeProps & { status: string }> = ({ status, className, label }) => (
|
||||
<span className={cn('badge', getStatusColor(status), className)}>{label ?? status}</span>
|
||||
);
|
||||
|
||||
export const SeverityBadge: React.FC<BadgeProps & { severity: string }> = ({ severity, className, label }) => (
|
||||
<span className={cn('badge', getSeverityColor(severity), className)}>{label ?? severity}</span>
|
||||
);
|
||||
|
|
@ -13,3 +13,4 @@ export { default as CommandCard } from './CommandCard';
|
|||
export { default as CommandStatusBadge, getCommandStatus } from './CommandStatusBadge';
|
||||
export { default as SortableTable } from './SortableTable';
|
||||
export type { Column } from './SortableTable';
|
||||
export { StatusBadge, SeverityBadge } from './StateBadge';
|
||||
|
|
|
|||
|
|
@ -139,6 +139,19 @@ export const getStatusColor = (status: string): string => {
|
|||
return 'text-danger-600 bg-danger-100';
|
||||
case 'ignored':
|
||||
return 'text-gray-500 bg-gray-100';
|
||||
// Docker image lifecycle (docker_images.status)
|
||||
case 'up-to-date':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'update-available':
|
||||
return 'text-info-600 bg-info-100';
|
||||
case 'update-approved':
|
||||
return 'text-orange-600 bg-orange-100';
|
||||
case 'update-scheduled':
|
||||
return 'text-purple-600 bg-purple-100';
|
||||
case 'update-installing':
|
||||
return 'text-indigo-600 bg-indigo-100';
|
||||
case 'update-failed':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
ProcessTable,
|
||||
CommandCard,
|
||||
SortableTable,
|
||||
StatusBadge,
|
||||
} from '@/components/primitives';
|
||||
import type { Column } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
|
@ -39,7 +40,7 @@ import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand }
|
|||
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AgentStorage } from '@/components/AgentStorage';
|
||||
|
|
@ -450,9 +451,10 @@ const Agents: React.FC = () => {
|
|||
key: 'status', label: 'Status', sortKey: 'status',
|
||||
render: (agent) => (
|
||||
<div className="flex flex-col space-y-1 items-start">
|
||||
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={isOnline(agent.last_seen) ? 'online' : 'offline'}
|
||||
label={isOnline(agent.last_seen) ? 'Online' : 'Offline'}
|
||||
/>
|
||||
{agent.reboot_required && (
|
||||
<span className="inline-flex items-center text-xs text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded-full w-fit">
|
||||
<Power className="h-3 w-3 mr-1" />
|
||||
|
|
@ -726,9 +728,10 @@ const Agents: React.FC = () => {
|
|||
'w-3 h-3 rounded-full',
|
||||
isOnline(selectedAgent.last_seen) ? 'bg-green-500' : 'bg-gray-400'
|
||||
)}></div>
|
||||
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
|
||||
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={isOnline(selectedAgent.last_seen) ? 'online' : 'offline'}
|
||||
label={isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Status Indicator */}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { DockerContainer, DockerImage, RuntimeDockerContainer, RuntimeDocke
|
|||
import { formatRelativeTime, cn } from '@/lib/utils';
|
||||
import { useMultimodalFilter } from '@/hooks/useMultimodalFilter';
|
||||
import { useColumnSort } from '@/hooks/useColumnSort';
|
||||
import { SearchInput, FilterPill, FilterCountButton, PageState, SortableTable } from '@/components/primitives';
|
||||
import { SearchInput, FilterPill, FilterCountButton, PageState, SortableTable, StatusBadge, SeverityBadge } from '@/components/primitives';
|
||||
import type { Column } from '@/components/primitives';
|
||||
|
||||
// ─── Filter config ────────────────────────────────────────────────────────────
|
||||
|
|
@ -19,28 +19,6 @@ const FILTER_CONFIG = {
|
|||
|
||||
// ─── Colour helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up-to-date': return 'bg-green-100 text-green-800';
|
||||
case 'update-available': return 'bg-blue-100 text-blue-800';
|
||||
case 'update-approved': return 'bg-orange-100 text-orange-800';
|
||||
case 'update-scheduled': return 'bg-purple-100 text-purple-800';
|
||||
case 'update-installing': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'update-failed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'low': return 'bg-gray-100 text-gray-800';
|
||||
case 'medium': return 'bg-blue-100 text-blue-800';
|
||||
case 'high': return 'bg-orange-100 text-orange-800';
|
||||
case 'critical': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getContainerStateColor = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running': return 'bg-green-50 text-green-700 border-green-200';
|
||||
|
|
@ -266,13 +244,13 @@ const Docker: React.FC = () => {
|
|||
<div className="text-gray-900 font-mono">{r.currentVersion}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-green-600 font-medium font-mono">→ {r.availableVersion}</span>
|
||||
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
|
||||
{r.status && <StatusBadge status={r.status} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-700 font-mono">{r.currentVersion}</span>
|
||||
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
|
||||
{r.status && <StatusBadge status={r.status} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -283,7 +261,7 @@ const Docker: React.FC = () => {
|
|||
key: 'severity', label: 'Risk', sortKey: 'severity',
|
||||
render: (r) => (
|
||||
r.availableVersion && r.severity
|
||||
? <span className={cn('badge', getSeverityColor(r.severity))}>{r.severity}</span>
|
||||
? <SeverityBadge severity={r.severity} />
|
||||
: r.availableVersion
|
||||
? <span className="badge bg-gray-100 text-gray-600">unknown</span>
|
||||
: <span className="text-gray-300 text-sm">—</span>
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ import {
|
|||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
|
||||
import { SearchInput, FilterDropdown, PageState, StatusBadge } from '@/components/primitives';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAgents } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
|
||||
import { useUpdates } from '@/hooks/useUpdates';
|
||||
import { logApi } from '@/lib/api';
|
||||
import { getStatusColor, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
|
|
@ -556,10 +556,15 @@ const LiveOperations: React.FC = () => {
|
|||
<span className="text-lg font-medium text-gray-900">
|
||||
{operation.packageName}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(operation.status))}>
|
||||
{getStatusIcon(operation.status)}
|
||||
<span className="ml-1">{operation.status}</span>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={operation.status}
|
||||
label={
|
||||
<>
|
||||
{getStatusIcon(operation.status)}
|
||||
<span className="ml-1">{operation.status}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{operation.activeTokenId && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium border rounded-full px-2 py-0.5',
|
||||
|
|
|
|||
|
|
@ -29,14 +29,13 @@ import {
|
|||
} from '@/hooks/useUpdates';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
import {
|
||||
getSeverityColor,
|
||||
getStatusColor,
|
||||
formatBytes,
|
||||
formatRelativeTime,
|
||||
advisoryUrl,
|
||||
cveSeverityBadge,
|
||||
} from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge, SeverityBadge } from '@/components/primitives';
|
||||
import toast from 'react-hot-toast';
|
||||
import DependencyClosureTree from '@/components/DependencyClosureTree';
|
||||
import type { PackageFleetAgent } from '@/types';
|
||||
|
|
@ -169,9 +168,7 @@ const PackageDetail: React.FC = () => {
|
|||
<h1 className="text-base font-semibold text-gray-900">{pkgName}</h1>
|
||||
<span className="text-xs text-gray-500 font-mono bg-gray-100 rounded px-1.5 py-0.5">{pkgType}</span>
|
||||
{summary.severity && summary.severity !== 'unknown' && (
|
||||
<span className={cn('badge', getSeverityColor(summary.severity))}>
|
||||
{summary.severity}
|
||||
</span>
|
||||
<SeverityBadge severity={summary.severity} />
|
||||
)}
|
||||
{vulns.length > 0 && (
|
||||
<span className="badge bg-amber-50 text-amber-700 border border-amber-200 text-xs inline-flex items-center gap-1">
|
||||
|
|
@ -328,9 +325,7 @@ const PackageDetail: React.FC = () => {
|
|||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className={cn('badge text-[10px]', getStatusColor(a.status))}>
|
||||
{a.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<StatusBadge status={a.status} className="text-[10px]" label={a.status.replace(/_/g, ' ')} />
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{a.can_approve && (
|
||||
|
|
@ -568,9 +563,7 @@ const AgentDetailPane: React.FC<AgentDetailPaneProps> = ({
|
|||
<div className="card flex items-center gap-3 text-sm text-gray-700">
|
||||
<Computer className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium">{agentRow.hostname}</span>
|
||||
<span className={cn('badge', getStatusColor(agentRow.status))}>
|
||||
{agentRow.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<StatusBadge status={agentRow.status} label={agentRow.status.replace(/_/g, ' ')} />
|
||||
<span className="text-xs text-gray-500 font-mono ml-auto">
|
||||
{agentRow.current_version}
|
||||
<ChevronRight className="inline h-3 w-3 mx-0.5 text-gray-400" />
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ import {
|
|||
HardDrive,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal } from '@/components/primitives';
|
||||
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } 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';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
|
||||
import { getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateApi } from '@/lib/api';
|
||||
|
|
@ -495,19 +495,20 @@ const Updates: React.FC = () => {
|
|||
<h1 className="text-2xl font-bold text-gray-900 truncate">
|
||||
{selectedUpdate.package_name}
|
||||
</h1>
|
||||
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
|
||||
{selectedUpdate.severity}
|
||||
</span>
|
||||
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
|
||||
{selectedUpdate.status === 'checking_dependencies' ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
checking dependencies
|
||||
</span>
|
||||
) : (
|
||||
selectedUpdate.status.replace(/_/g, ' ')
|
||||
)}
|
||||
</span>
|
||||
<SeverityBadge severity={selectedUpdate.severity} />
|
||||
<StatusBadge
|
||||
status={selectedUpdate.status}
|
||||
label={
|
||||
selectedUpdate.status === 'checking_dependencies' ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
checking dependencies
|
||||
</span>
|
||||
) : (
|
||||
selectedUpdate.status.replace(/_/g, ' ')
|
||||
)
|
||||
}
|
||||
/>
|
||||
{vulns.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
|
|
@ -885,9 +886,7 @@ const Updates: React.FC = () => {
|
|||
<span className="text-xs text-gray-500 font-mono flex-shrink-0">
|
||||
{fa.current_version} <ChevronRight className="inline h-3 w-3 text-gray-400" /> {fa.available_version}
|
||||
</span>
|
||||
<span className={cn('badge ml-auto flex-shrink-0', getStatusColor(fa.status))}>
|
||||
{fa.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<StatusBadge status={fa.status} className="ml-auto flex-shrink-0" label={fa.status.replace(/_/g, ' ')} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -1790,9 +1789,7 @@ const Updates: React.FC = () => {
|
|||
<span className="text-sm font-mono text-gray-900">{versionLabel}</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={cn('badge', getSeverityColor(pkg.max_severity))}>
|
||||
{pkg.max_severity}
|
||||
</span>
|
||||
<SeverityBadge severity={pkg.max_severity} />
|
||||
{pkg.has_vulns && (
|
||||
<span
|
||||
title="Known vulnerabilities detected"
|
||||
|
|
|
|||
Loading…
Reference in a new issue