Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.9.3: device classification + ARM support — Pixel 3 lands

DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id
combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak
hostname fallback. Hardware-bound IDs on DMI-less devices.

DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from
/sys signals — system battery (scope=Device peripherals excluded, UPS
excluded), DRM connector state, framebuffer min-dimension for phone/tablet
split. Reports device_type/device_model/os_distro in registration and
system-info paths.

SERVER-001: migration 061 — device_type, device_type_manual (operator
override, never agent-written), device_model, os_distro on agents.
effective_device_type computed into every serialized agent.

SERVER-002: PUT /admin/agents/:id/device-type — set/clear override,
enum-validated, journaled.

WEB-001: device-type icons + fleet filter, device model in list, detail
header badge with reclassify dropdown, os_distro surfaced.

INSTALL-003: arm64 install path unblocked — helper (required manifest
component) now cross-built aarch64-unknown-linux-musl via rust-lld in the
server image, signed at boot (helperArches += arm64), listed in the release
manifest. Install template already handled uname -m and pacman.

Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow
updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
This commit is contained in:
Fimeg 2026-07-06 18:21:23 -04:00
commit ff2f30f47a
58 changed files with 2989 additions and 429 deletions

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Upload, CheckCircle, XCircle, RotateCw, Download } from 'lucide-react';
import { Upload, CheckCircle, XCircle, RotateCw, Download, AlertTriangle } from 'lucide-react';
import { useAgentUpdate } from '@/hooks/useAgentUpdate';
import { Agent } from '@/types';
import { cn } from '@/lib/utils';
@ -179,8 +179,9 @@ export function AgentUpdate({ agent, onUpdateComplete, className }: AgentUpdateP
{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 className="text-amber-800 font-medium mb-2 inline-flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4" />
Version appears identical
</p>
<p className="text-sm text-amber-700 mb-2">
Current: <strong>{currentVersion}</strong> Target: <strong>{availableVersion}</strong>

View file

@ -0,0 +1,53 @@
import React from 'react';
import { Server, Monitor, Smartphone, Tablet, Computer } from 'lucide-react';
import { cn } from '@/lib/utils';
// DeviceTypeIcon — consistent form-factor rendering (WEB-001).
// effective_device_type drives everything; unknown/absent falls back to the
// generic Computer icon so pre-migration agents render unchanged.
const DEVICE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
server: Server,
desktop: Monitor,
phone: Smartphone,
tablet: Tablet,
};
const DEVICE_BADGE_CLASSES: Record<string, string> = {
server: 'bg-gray-100 text-gray-700',
desktop: 'bg-slate-100 text-slate-700',
phone: 'bg-emerald-100 text-emerald-700',
tablet: 'bg-violet-100 text-violet-700',
};
export const deviceTypeLabel = (type?: string): string => {
if (!type) return 'Unknown';
return type.charAt(0).toUpperCase() + type.slice(1);
};
export const DeviceTypeIcon: React.FC<{ type?: string; className?: string }> = ({ type, className }) => {
const Icon = (type && DEVICE_ICONS[type]) || Computer;
return <Icon className={className || 'h-4 w-4'} />;
};
export const DeviceTypeBadge: React.FC<{ type?: string; overridden?: boolean; className?: string }> = ({
type,
overridden,
className,
}) => {
if (!type || !DEVICE_ICONS[type]) return null;
return (
<span
className={cn(
'inline-flex items-center text-xs px-1.5 py-0.5 rounded-full w-fit',
DEVICE_BADGE_CLASSES[type],
className
)}
title={overridden ? 'Device type set by operator' : 'Device type auto-detected'}
>
<DeviceTypeIcon type={type} className="h-3 w-3 mr-1" />
{deviceTypeLabel(type)}
{overridden && <span className="ml-1 opacity-60">*</span>}
</span>
);
};

View file

@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { clientErrorLogger } from '@/lib/client-error-logger';
interface Props {
@ -43,7 +44,7 @@ class ErrorBoundary extends Component<Props, State> {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
<div className="text-4xl mb-4"></div>
<AlertTriangle className="h-10 w-10 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
</h2>

View file

@ -197,7 +197,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
)}
<div className="p-4 space-y-1">
<a
href="https://github.com/Fimeg/RedFlag"
href="https://codeberg.org/Fimeg/RedFlag"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
@ -289,7 +289,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
onClick={() => setIsNotificationDropdownOpen(false)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>

View file

@ -107,6 +107,49 @@ interface LocalSnapshot {
status: LocalStatus
}
interface UpdateItem {
package_type: string
package_name: string
current_version: string
available_version: string
severity: string
cve_list: string[]
size_bytes: number
repository_source: string
}
interface ScanSnapshot {
last_scan_time?: string
update_count: number
updates: UpdateItem[]
}
interface TriggerScanResponse {
accepted: boolean
error?: string
}
interface ApprovePolicy {
decision: string
reason: string
executed: boolean
verified_artifacts: number
exit_code: number
error?: string
}
interface ApproveResult {
request_id: string
osv_status: string
osv_vuln_count: number
closure_size: number
policy: ApprovePolicy | null
}
// Local approval is gated to the ecosystems whose closures the agent can
// resolve and pin — mirrors gatedLocalApproval in the agent.
const APPROVABLE = new Set(['dnf', 'apt'])
type HealthState = 'healthy' | 'warning' | 'error'
// ---------- main component ----------
@ -114,9 +157,12 @@ type HealthState = 'healthy' | 'warning' | 'error'
const LocalAgentApp: React.FC = () => {
const [theme, setTheme] = useState<Theme>('dark')
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
const [updates, setUpdates] = useState<UpdateItem[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
const [scanState, setScanState] = useState<'idle' | 'requesting' | 'running'>('idle')
const [scanNote, setScanNote] = useState<string | null>(null)
const p = theme === 'dark' ? DARK : LIGHT
const load = useCallback(async () => {
@ -125,6 +171,8 @@ const LocalAgentApp: React.FC = () => {
const next = await invoke<LocalSnapshot>('local_status')
setSnapshot(next)
setLastRefresh(new Date())
const scan = await invoke<ScanSnapshot>('list_updates')
setUpdates(scan.updates ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -138,10 +186,33 @@ const LocalAgentApp: React.FC = () => {
return () => window.clearInterval(id)
}, [load])
const triggerScan = useCallback(async () => {
setScanState('requesting')
setScanNote(null)
try {
const resp = await invoke<TriggerScanResponse>('trigger_scan')
if (resp.accepted) {
setScanState('running')
// The scan runs agent-side; the 5s poll picks up results. Clear the
// running indicator after a grace window rather than tracking scan
// completion state the local API doesn't expose per-request.
window.setTimeout(() => setScanState('idle'), 20000)
} else {
setScanState('idle')
setScanNote(resp.error || 'scan not accepted')
}
} catch (err) {
setScanState('idle')
setScanNote(err instanceof Error ? err.message : String(err))
}
}, [])
// Standalone (unregistered) is a first-class posture, not a degraded state —
// only fleet-joined agents that lose their status report warn on registration.
const health = useMemo<HealthState>(() => {
if (error || !snapshot) return 'error'
if (!snapshot.identity.registered || !snapshot.status.registered) return 'warning'
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online') return 'warning'
if (snapshot.identity.registered && !snapshot.status.registered) return 'warning'
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online' && snapshot.identity.registered) return 'warning'
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
}, [error, snapshot])
@ -152,8 +223,6 @@ const LocalAgentApp: React.FC = () => {
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
const criticalCount = snapshot?.status.summary.by_severity?.critical ?? 0
const highCount = snapshot?.status.summary.by_severity?.high ?? 0
return (
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
@ -198,10 +267,21 @@ const LocalAgentApp: React.FC = () => {
{/* Status strip */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
<StatCell p={p} label="Fleet" value={snapshot?.identity.registered ? 'Bound' : 'Unbound'} accent={snapshot?.identity.registered ? p.good : p.warn} />
<StatCell p={p} label="Mode" value={snapshot?.identity.registered ? 'Fleet-joined' : 'Standalone'} accent={snapshot?.identity.registered ? p.good : p.text} />
<StatCell p={p} label="Updates" value={String(snapshot?.status.update_count ?? 0)} accent={(snapshot?.status.update_count ?? 0) > 0 ? p.warn : p.good} />
</div>
{/* Pending updates — the standalone admin surface */}
<UpdatesSection
p={p}
updates={updates}
standalone={snapshot ? !snapshot.identity.registered : false}
scanState={scanState}
scanNote={scanNote}
onScan={triggerScan}
onChanged={load}
/>
{/* Identity */}
<Section p={p} label="Identity">
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
@ -218,17 +298,6 @@ const LocalAgentApp: React.FC = () => {
<Row p={p} label="Status" value={snapshot?.status.agent_status || 'unknown'} accent={snapshot?.status.agent_status === 'online' ? p.good : p.warn} />
</Section>
{/* Severity — only shown when there are updates */}
{(snapshot?.status.update_count ?? 0) > 0 && (
<Section p={p} label="Severity">
{criticalCount > 0 && <Row p={p} label="Critical" value={String(criticalCount)} accent={p.bad} />}
{highCount > 0 && <Row p={p} label="High" value={String(highCount)} accent={p.warn} />}
{snapshot?.status.summary.by_ecosystem && Object.entries(snapshot.status.summary.by_ecosystem).map(([eco, count]) => (
<Row key={eco} p={p} label={eco} value={String(count)} />
))}
</Section>
)}
{/* Scanners */}
{scanners.length > 0 && (
<Section p={p} label={`Scanners (${scanners.length})`}>
@ -262,6 +331,204 @@ const LocalAgentApp: React.FC = () => {
interface WithPalette { p: Palette }
function severityColor(p: Palette, severity: string): string {
const s = severity.toLowerCase()
if (s === 'critical') return p.bad
if (s === 'high' || s === 'important') return p.warn
if (s === 'medium' || s === 'moderate') return p.accent
return p.textDim
}
const SEVERITY_ORDER: Record<string, number> = {
critical: 0, high: 1, important: 1, medium: 2, moderate: 2, low: 3,
}
interface UpdatesSectionProps extends WithPalette {
updates: UpdateItem[]
standalone: boolean
scanState: 'idle' | 'requesting' | 'running'
scanNote: string | null
onScan: () => void
onChanged: () => void
}
const UpdatesSection: React.FC<UpdatesSectionProps> = ({ p, updates, standalone, scanState, scanNote, onScan, onChanged }) => {
const sorted = useMemo(
() =>
[...updates].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4
const sb = SEVERITY_ORDER[b.severity.toLowerCase()] ?? 4
return sa !== sb ? sa - sb : a.package_name.localeCompare(b.package_name)
}),
[updates],
)
return (
<div style={{ borderBottom: `1px solid ${p.border}` }}>
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '2px', height: '10px', background: p.accent, borderRadius: '1px', flexShrink: 0 }} />
<span style={{ fontSize: '10px', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: p.textMuted }}>
Updates ({updates.length})
</span>
<div style={{ flex: 1 }} />
<button
onClick={onScan}
disabled={scanState !== 'idle'}
style={{
background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px',
color: scanState === 'idle' ? p.text : p.textDim, cursor: scanState === 'idle' ? 'pointer' : 'default',
padding: '1px 8px', fontSize: '10px', textTransform: 'uppercase' as const, letterSpacing: '0.06em',
}}
>
{scanState === 'idle' ? 'Scan now' : scanState === 'requesting' ? 'Requesting…' : 'Scanning…'}
</button>
</div>
{scanNote && (
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.warn, borderBottom: `1px solid ${p.borderInner}` }}>{scanNote}</div>
)}
{!standalone && updates.length > 0 && (
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.textDim, borderBottom: `1px solid ${p.borderInner}` }}>
Fleet-managed approvals happen on the server.
</div>
)}
{sorted.length === 0 ? (
<div style={{ padding: '8px 14px', fontSize: '12px', color: p.textDim }}>No pending updates.</div>
) : (
sorted.map(update => (
<UpdateRow key={`${update.package_type}:${update.package_name}`} p={p} update={update} standalone={standalone} onChanged={onChanged} />
))
)}
</div>
)
}
type ApprovePhase = 'idle' | 'confirm' | 'busy' | 'blocked' | 'done' | 'failed'
const UpdateRow: React.FC<WithPalette & { update: UpdateItem; standalone: boolean; onChanged: () => void }> = ({ p, update, standalone, onChanged }) => {
const [phase, setPhase] = useState<ApprovePhase>('idle')
const [message, setMessage] = useState<string | null>(null)
const [overrideReason, setOverrideReason] = useState('')
const [result, setResult] = useState<ApproveResult | null>(null)
const approvable = standalone && APPROVABLE.has(update.package_type)
const approve = useCallback(async (reason: string) => {
setPhase('busy')
setMessage(null)
try {
const res = await invoke<ApproveResult>('approve_update', {
request: {
package_type: update.package_type,
package_name: update.package_name,
available_version: update.available_version,
operator: '',
override_reason: reason,
},
})
setResult(res)
setPhase('done')
onChanged()
} catch (err) {
const text = err instanceof Error ? err.message : String(err)
setMessage(text)
// The gate refuses vulnerable/unreachable OSV verdicts without an
// explicit operator reason — offer the journaled override path.
setPhase(text.includes('override requires an explicit reason') ? 'blocked' : 'failed')
}
}, [update, onChanged])
const sevColor = severityColor(p, update.severity)
return (
<div style={{ borderBottom: `1px solid ${p.borderInner}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 14px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: sevColor }} title={update.severity || 'unknown'} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{update.package_name}
</span>
<span style={{ fontSize: '10px', color: p.textDim, flexShrink: 0 }}>{update.package_type}</span>
</div>
<div style={{ fontSize: '11px', color: p.textDim, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{update.current_version || '?'} {update.available_version || '?'}
{update.cve_list.length > 0 && <span style={{ color: sevColor }}> · {update.cve_list.length} CVE</span>}
</div>
</div>
{approvable && phase === 'idle' && (
<button
onClick={() => setPhase('confirm')}
style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, cursor: 'pointer', padding: '2px 8px', fontSize: '11px', flexShrink: 0 }}
>
Install
</button>
)}
{phase === 'busy' && <span style={{ fontSize: '11px', color: p.warn, flexShrink: 0 }}>Verifying & installing</span>}
{phase === 'done' && <span style={{ fontSize: '11px', color: p.good, flexShrink: 0 }}>Installed</span>}
</div>
{phase === 'confirm' && (
<div style={{ padding: '6px 14px 8px 28px', fontSize: '11px', color: p.textMuted }}>
<div style={{ marginBottom: '6px' }}>
Local gates run first (closure resolve, OSV vulnerability check); the install executes through the signed helper and is journaled.
</div>
<button onClick={() => approve(overrideReason)} style={{ background: p.accent, border: 'none', borderRadius: '2px', color: '#fff', cursor: 'pointer', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}>
Approve & install
</button>
<button onClick={() => setPhase('idle')} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Cancel
</button>
</div>
)}
{(phase === 'blocked' || phase === 'failed') && message && (
<div style={{ margin: '2px 14px 8px 28px', padding: '6px 8px', background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderRadius: '3px', fontSize: '11px', color: p.errorText, fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{message}
</div>
)}
{phase === 'blocked' && (
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px' }}>
<input
value={overrideReason}
onChange={e => setOverrideReason(e.target.value)}
placeholder="Override reason (journaled)"
style={{ width: '100%', boxSizing: 'border-box', background: p.bgAlt, border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, padding: '4px 6px', fontSize: '11px', marginBottom: '6px' }}
/>
<button
onClick={() => approve(overrideReason)}
disabled={overrideReason.trim() === ''}
style={{ background: overrideReason.trim() ? p.accent : p.border, border: 'none', borderRadius: '2px', color: '#fff', cursor: overrideReason.trim() ? 'pointer' : 'default', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}
>
Override & install
</button>
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Cancel
</button>
</div>
)}
{phase === 'failed' && (
<div style={{ padding: '0 14px 8px 28px' }}>
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Dismiss
</button>
</div>
)}
{phase === 'done' && result && (
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px', color: p.textDim }}>
OSV {result.osv_status} · closure {result.closure_size} pkg{result.closure_size === 1 ? '' : 's'} · {result.policy?.decision ?? 'no verdict'}
{' — clears from this list on the next scan'}
</div>
)}
</div>
)
}
const Section: React.FC<WithPalette & { label: string; children: React.ReactNode }> = ({ p, label, children }) => (
<div style={{ borderBottom: `1px solid ${p.border}` }}>
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>

View file

@ -179,6 +179,16 @@ export const agentApi = {
return response.data;
},
// Set or clear the operator device-type override (SERVER-002).
// deviceType null clears the override, reverting to auto-detected.
reclassifyDeviceType: async (
agentId: string,
deviceType: string | null
): Promise<{ id: string; device_type: string; device_type_manual: string | null; effective_device_type: string }> => {
const response = await api.put(`/admin/agents/${agentId}/device-type`, { device_type: deviceType });
return response.data;
},
// Get a single command by ID (used to poll screenshot result)
getCommand: async (commandId: string): Promise<any> => {
const response = await api.get(`/commands/${commandId}`);

View file

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { Package, Container, Monitor, AppWindow, ClipboardList, type LucideIcon } from 'lucide-react';
// Utility function for combining class names
export function cn(...inputs: ClassValue[]) {
@ -142,21 +143,24 @@ import { packageStatusColor, packageSeverityColor } from '@/components/primitive
export const getStatusColor = packageStatusColor;
export const getSeverityColor = packageSeverityColor;
export const getPackageTypeIcon = (type: string): string => {
// Package-type icons — lucide components, consistent with the iconography
// used everywhere else in the UI (UI-DASHBOARD-AUDIT #4: the old emoji
// glyphs clashed with the aesthetic).
export const getPackageTypeIcon = (type: string): LucideIcon => {
switch (type) {
case 'apt':
return '📦';
case 'docker':
return '🐳';
case 'yum':
case 'dnf':
return '🐧';
return Package;
case 'docker':
return Container;
case 'windows':
return '🪟';
case 'windows_update':
return Monitor;
case 'winget':
return '📱';
return AppWindow;
default:
return '📋';
return ClipboardList;
}
};

View file

@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import {
Computer,
RefreshCw,
ChevronRight as ChevronRightIcon,
ChevronDown,
@ -48,6 +47,8 @@ import { formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { AgentStorage } from '@/components/AgentStorage';
import { DeviceTypeIcon, DeviceTypeBadge, deviceTypeLabel } from '@/components/DeviceTypeIcon';
import { DEVICE_TYPES } from '@/types';
import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced';
import { AgentHealth } from '@/components/AgentHealth';
import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
@ -76,6 +77,7 @@ const Agents: React.FC = () => {
const filterConfig = {
status: { urlParam: 'status', label: 'Status' },
os: { urlParam: 'os', label: 'OS' },
device: { urlParam: 'device', label: 'Device' },
};
const filter = useFilterUrl(filterConfig);
const { sortBy, sortOrder, handleSort, applySort } = useColumnSort({
@ -91,6 +93,9 @@ const Agents: React.FC = () => {
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
const [showRestartDropdown, setShowRestartDropdown] = useState(false);
const [showReclassifyDropdown, setShowReclassifyDropdown] = useState(false);
const [reclassifyPending, setReclassifyPending] = useState(false);
const reclassifyDropdownRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const restartDropdownRef = useRef<HTMLDivElement>(null);
@ -103,6 +108,9 @@ const Agents: React.FC = () => {
if (restartDropdownRef.current && !restartDropdownRef.current.contains(event.target as Node)) {
setShowRestartDropdown(false);
}
if (reclassifyDropdownRef.current && !reclassifyDropdownRef.current.contains(event.target as Node)) {
setShowReclassifyDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
@ -270,10 +278,15 @@ const Agents: React.FC = () => {
// to agent metadata on every poll response and toggle.
const heartbeatStatus = useHeartbeatStatus(selectedAgent?.metadata);
// Filter agents based on OS
// Filter agents based on OS and device type
const filteredAgents = agents.filter(agent => {
if (!filter.values.os) return true;
return agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase());
if (filter.values.os && !agent.os_type.toLowerCase().includes(filter.values.os.toLowerCase())) {
return false;
}
if (filter.values.device && (agent.effective_device_type || 'server') !== filter.values.device) {
return false;
}
return true;
});
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
@ -322,6 +335,26 @@ const Agents: React.FC = () => {
};
// Handle agent reboot
// Operator device-type override (WEB-001/SERVER-002). null clears back to auto.
const handleReclassify = async (agentId: string, deviceType: string | null) => {
setReclassifyPending(true);
try {
const result = await agentApi.reclassifyDeviceType(agentId, deviceType);
queryClient.invalidateQueries({ queryKey: ['agents'] });
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
toast.success(
deviceType
? `Reclassified as ${deviceTypeLabel(result.effective_device_type)}`
: `Override cleared — auto-detected as ${deviceTypeLabel(result.effective_device_type)}`
);
} catch (error: any) {
toast.error(error.message || 'Failed to reclassify device');
} finally {
setReclassifyPending(false);
setShowReclassifyDropdown(false);
}
};
const handleRebootAgent = async (agentId: string, hostname: string) => {
if (!(await confirm({
title: 'Schedule System Restart',
@ -451,8 +484,11 @@ const Agents: React.FC = () => {
key: 'hostname', label: 'Agent', sortKey: 'hostname',
render: (agent) => (
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<Computer className="h-4 w-4 text-gray-600" />
<div
className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"
title={deviceTypeLabel(agent.effective_device_type)}
>
<DeviceTypeIcon type={agent.effective_device_type} className="h-4 w-4 text-gray-600" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">
@ -461,14 +497,14 @@ const Agents: React.FC = () => {
</button>
</div>
<div className="text-xs text-gray-500">
{agent.metadata && (() => {
{agent.device_model || (agent.metadata && (() => {
const meta = getSystemMetadata(agent);
const parts = [];
if (meta.cpuCores !== 'Unknown') parts.push(`${meta.cpuCores} cores`);
if (meta.memoryTotal > 0) parts.push(formatBytes(meta.memoryTotal));
if (parts.length > 0) return parts.join(' • ');
return 'System info available';
})()}
})())}
</div>
</div>
</div>
@ -519,7 +555,8 @@ const Agents: React.FC = () => {
<div>
<div className="text-sm text-gray-900">{osInfo.distribution || agent.os_type}</div>
<div className="text-xs text-gray-500">
{osInfo.version ? `${osInfo.version}${agent.os_architecture || agent.architecture}` : (agent.os_architecture || agent.architecture)}
{[agent.os_distro, osInfo.version, agent.os_architecture || agent.architecture]
.filter(Boolean).join(' • ')}
</div>
</div>
);
@ -630,8 +667,61 @@ const Agents: React.FC = () => {
</div>
</div>
{/* Sub-line with registration info only */}
<div className="text-sm text-gray-600">
{/* Sub-line: device classification + registration info */}
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
<div className="relative" ref={reclassifyDropdownRef}>
<button
onClick={() => setShowReclassifyDropdown(!showReclassifyDropdown)}
disabled={reclassifyPending}
className="hover:opacity-75 transition-opacity"
title="Reclassify device type"
>
<DeviceTypeBadge
type={selectedAgent.effective_device_type || 'server'}
overridden={!!selectedAgent.device_type_manual}
/>
</button>
{showReclassifyDropdown && (
<div className="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
{DEVICE_TYPES.map(t => (
<button
key={t}
onClick={() => handleReclassify(selectedAgent.id, t)}
disabled={reclassifyPending}
className={cn(
'w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center space-x-2',
selectedAgent.effective_device_type === t && 'bg-gray-50 font-medium'
)}
>
<DeviceTypeIcon type={t} className="h-4 w-4 text-gray-500" />
<span>{deviceTypeLabel(t)}</span>
</button>
))}
{selectedAgent.device_type_manual && (
<button
onClick={() => handleReclassify(selectedAgent.id, null)}
disabled={reclassifyPending}
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 border-t border-gray-100"
>
Clear override (use auto-detect)
</button>
)}
</div>
)}
</div>
{selectedAgent.device_model && (
<>
<span className="text-gray-400"></span>
<span>{selectedAgent.device_model}</span>
</>
)}
{selectedAgent.os_distro && (
<>
<span className="text-gray-400"></span>
<span>{selectedAgent.os_distro}</span>
</>
)}
<span className="text-gray-400"></span>
<span>Registered {formatRelativeTime(selectedAgent.created_at)}</span>
</div>
</div>
@ -1159,6 +1249,7 @@ const Agents: React.FC = () => {
{ value: 'offline', label: 'Offline' },
], placeholder: 'All Status' },
{ label: 'OS', value: filter.values.os, onChange: (v) => filter.setFilter('os', v), options: osTypes.map(os => ({ value: os, label: os })), placeholder: 'All OS' },
{ label: 'Device', value: filter.values.device, onChange: (v) => filter.setFilter('device', v), options: DEVICE_TYPES.map(t => ({ value: t, label: deviceTypeLabel(t) })), placeholder: 'All Devices' },
]}
pills={buildFilterPills(filter, filterConfig)}
onClearAll={() => filter.clearAll()}

View file

@ -97,7 +97,7 @@ const Dashboard: React.FC = () => {
<AlertTriangle className="h-5 w-5 mr-3" />
<div>
<strong className="font-bold">Security Upgrade Required:</strong>
<span className="block sm:inline"> Your server is missing a private key for secure agent updates. Please go to <Link to="/settings/agents" className="font-bold underline hover:text-yellow-900">Agent Management</Link> to generate one.</span>
<span className="block sm:inline"> Your server is missing a private key for secure agent updates. Please go to <Link to="/settings/agents" className="font-bold underline hover:text-yellow-900">Agents &amp; Enrollment</Link> to generate one.</span>
</div>
</div>
</div>
@ -185,7 +185,7 @@ const Dashboard: React.FC = () => {
{updateTypeBreakdown.map((type) => (
<div key={type.type} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<span className="text-2xl">{type.icon}</span>
<type.icon className="h-5 w-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{type.type}
</span>

View file

@ -211,7 +211,7 @@ const LiveOperations: React.FC = () => {
// Optional: Show a secondary toast with the cheeky warning
setTimeout(() => {
toast(result.cheeky_warning ?? '', {
icon: '⚠️',
icon: <AlertTriangle className="h-4 w-4 text-amber-600" />,
style: {
background: '#fef3c7',
color: '#92400e',

View file

@ -137,7 +137,7 @@ const PackageDetail: React.FC = () => {
const failedAgents = agents.filter((a) => a.can_retry);
return (
<div className="space-y-4 p-4 max-w-4xl">
<div className="space-y-4 px-4 sm:px-6 lg:px-8 max-w-5xl">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<button onClick={() => navigate('/updates')} className="hover:text-gray-700 inline-flex items-center gap-1">
@ -408,18 +408,18 @@ const PackageDetail: React.FC = () => {
return (
<li key={v.id} className="py-2.5 flex items-center gap-3 flex-wrap">
<span className="text-sm font-mono text-gray-900 flex-shrink-0">{v.version}</span>
{isLatest && (
<span className="text-[10px] font-medium text-blue-700 bg-blue-50 rounded px-1.5 py-0.5">
available
</span>
)}
{isInstalled && isLatest && (
{isInstalled && (
<span className="text-[10px] font-medium text-gray-600 bg-gray-100 rounded px-1.5 py-0.5">
installed
</span>
)}
{isLatest && !isInstalled && (
<span className="text-[10px] font-medium text-blue-700 bg-blue-50 rounded px-1.5 py-0.5">
available
</span>
)}
{osv && (
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', osvCls)}>{osv}</span>
<span className={cn('text-[10px] font-medium border rounded px-1.5 py-0.5', osvCls)}>{osv}</span>
)}
{v.sha256 && (
<span
@ -430,10 +430,10 @@ const PackageDetail: React.FC = () => {
{v.sha256.slice(0, 8)}
</span>
)}
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">
<span className="text-xs text-gray-500 ml-auto flex-shrink-0">
{v.published_at
? formatRelativeTime(v.published_at)
: formatRelativeTime(v.first_scanned_at)}
? `published ${formatRelativeTime(v.published_at)}`
: `seen ${formatRelativeTime(v.first_scanned_at)}`}
</span>
</li>
);
@ -442,7 +442,7 @@ const PackageDetail: React.FC = () => {
</div>
)}
<VulnerabilityList vulnerabilities={vulns} title="Supply Chain" />
<VulnerabilityList vulnerabilities={vulns} />
</>
)}
</div>
@ -583,7 +583,7 @@ const AgentDetailPane: React.FC<AgentDetailPaneProps> = ({
) : (
<Clock className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
)}
<span className="font-mono text-gray-700 flex-shrink-0">{cmd.action}</span>
<span className="font-mono text-gray-700 flex-shrink-0">{cmd.command_type}</span>
<span
className={cn('text-[10px] border rounded px-1 py-0.5 flex-shrink-0', commandStatusInlineColor(cmd.status))}
>

View file

@ -131,7 +131,7 @@ const Settings: React.FC = () => {
{/* Token Overview */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Token Overview</h2>
<h2 className="text-lg font-semibold text-gray-900">Registration Keys</h2>
<Link
to="/settings/agents"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
@ -143,7 +143,7 @@ const Settings: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold text-gray-900">{tokenStats.total_tokens}</p>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-sm text-gray-600">Total Keys</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">{tokenStats.active_tokens}</p>
@ -161,7 +161,7 @@ const Settings: React.FC = () => {
) : (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading token statistics...</p>
<p className="text-sm text-gray-500 mt-2">Loading key statistics...</p>
</div>
)}
</div>

View file

@ -23,7 +23,7 @@ import {
HardDrive,
FileText,
} from 'lucide-react';
import { FilterBar, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } from '@/components/primitives';
import { FilterBar, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge, CommandStatusBadge } from '@/components/primitives';
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
import { useDebounce } from '@/hooks/useDebounce';
import { useQueryClient } from '@tanstack/react-query';
@ -296,9 +296,11 @@ const Updates: React.FC = () => {
}
};
// Get unique values for filters
const statuses = [...new Set(updates.map((u: UpdatePackage) => u.status))];
const severities = [...new Set(updates.map((u: UpdatePackage) => u.severity))];
// Filter dropdown options — the canonical status/severity sets from the
// package state machine, not values scraped off the current page (which
// collapse to a single option as soon as a filter is applied).
const statuses = ['pending', 'approved', 'checking_dependencies', 'pending_dependencies', 'installing', 'installed', 'failed', 'ignored'];
const severities = ['critical', 'high', 'medium', 'low'];
// Quick filter functions
const handleQuickFilter = (quick: string) => {
@ -417,6 +419,12 @@ const Updates: React.FC = () => {
const fleetAgents = packageFleetData?.agents || [];
const versionTimeline = packageVersionsData?.versions || [];
// Hostname for this update's agent — the fleet data already fetched on
// this page carries it, so don't show a bare truncated UUID when we can
// name the machine.
const agentHostname = fleetAgents.find((fa) => fa.agent_id === selectedUpdate.agent_id)?.hostname;
const agentLabel = agentHostname || `${selectedUpdate.agent_id.slice(0, 8)}`;
// Recent commands narrowed to this update — agent + package_name + package_type
// is unique per current_package_state row, so this matches without server changes.
const allRecent = recentCommandsData?.commands || [];
@ -450,6 +458,7 @@ const Updates: React.FC = () => {
return 0;
})();
const isFailed = selectedUpdate.status === 'failed';
const TypeIcon = getPackageTypeIcon(selectedUpdate.package_type);
return (
<div className="px-4 sm:px-6 lg:px-8">
@ -465,7 +474,7 @@ const Updates: React.FC = () => {
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center flex-wrap gap-2 mb-1">
<span className="text-2xl">{getPackageTypeIcon(selectedUpdate.package_type)}</span>
<TypeIcon className="h-6 w-6 text-gray-500 flex-shrink-0" />
<h1 className="text-2xl font-bold text-gray-900 truncate">
{selectedUpdate.package_name}
</h1>
@ -840,7 +849,7 @@ const Updates: React.FC = () => {
<GitBranch className="h-4 w-4 text-gray-500" />
Lifecycle History
<span className="text-xs text-gray-500 font-normal">
({lifecycleData.count} on {selectedUpdate.agent_id.slice(0, 8)})
({lifecycleData.count} on {agentLabel})
</span>
</h2>
<ul className="divide-y divide-gray-100">
@ -1000,7 +1009,10 @@ const Updates: React.FC = () => {
</button>
)}
{['installing', 'completed', 'failed'].includes(selectedUpdate.status) && (
{/* 'installed', not 'completed' the package state machine has no
'completed' status, so logs were unreachable after a successful
install (UI-SCOPE sweep 2026-07-02). */}
{['installing', 'installed', 'failed'].includes(selectedUpdate.status) && (
<button
onClick={() => handleViewLogs(selectedUpdate.id)}
disabled={logsLoading}
@ -1049,7 +1061,7 @@ const Updates: React.FC = () => {
<div className="flex items-center gap-2 mb-1">
<Computer className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-900 truncate">
{selectedUpdate.agent_id.slice(0, 8)}
{agentLabel}
</span>
<ChevronRight className="h-4 w-4 text-gray-400 ml-auto group-hover:text-gray-700 transition-colors" />
</div>
@ -1277,26 +1289,35 @@ const Updates: React.FC = () => {
</div>
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
{command.package_name}
</div>
{command.package_name && command.package_type ? (
<Link
to={`/updates/package/${command.package_type}/${command.package_name}`}
className="text-sm text-gray-900 hover:text-primary-600 hover:underline"
>
{command.package_name}
</Link>
) : (
<div className="text-sm text-gray-900">
{command.package_name || '—'}
</div>
)}
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
{command.agent_hostname}
</div>
{command.agent_id ? (
<Link
to={`/agents/${command.agent_id}`}
className="text-sm text-gray-900 hover:text-primary-600 hover:underline"
>
{command.agent_hostname}
</Link>
) : (
<div className="text-sm text-gray-900">
{command.agent_hostname}
</div>
)}
</td>
<td className="table-cell">
<span className={cn(
'badge',
command.status === 'completed' ? 'bg-green-100 text-green-800' :
command.status === 'failed' ? 'bg-red-100 text-red-800' :
command.status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
command.status === 'pending' || command.status === 'sent' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
)}>
{command.status}
</span>
<CommandStatusBadge status={command.status} />
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
@ -1498,7 +1519,7 @@ const Updates: React.FC = () => {
<FilterBar
search={{ value: searchQuery, onChange: setSearchQuery, placeholder: 'Search updates by package name...' }}
filters={[
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: statuses.map((s: string) => ({ value: s, label: s })), placeholder: 'All Status' },
{ label: 'Status', value: filter.values.status, onChange: (v) => filter.setFilter('status', v), options: statuses.map((s: string) => ({ value: s, label: s.replace(/_/g, ' ') })), placeholder: 'All Status' },
{ label: 'Severity', value: filter.values.severity, onChange: (v) => filter.setFilter('severity', v), options: severities.map((s: string) => ({ value: s, label: s })), placeholder: 'All Severities' },
{ label: 'Type', value: filter.values.type, onChange: (v) => filter.setFilter('type', v), options: packageTypes.map((t: string) => ({ value: t, label: t.toUpperCase() })), placeholder: 'All Types' },
]}
@ -1597,11 +1618,12 @@ const Updates: React.FC = () => {
pkg.approved_count > 0 ? { label: `${pkg.approved_count} approved`, cls: 'bg-green-50 text-green-700 border-green-200' } :
pkg.installed_count > 0 ? { label: 'up to date', cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' } :
{ label: '—', cls: 'bg-gray-50 text-gray-500 border-gray-200' };
const RowTypeIcon = getPackageTypeIcon(pkg.package_type);
return (
<tr key={`${pkg.package_type}/${pkg.package_name}`} className="hover:bg-gray-50">
<td className="table-cell">
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg flex-shrink-0">{getPackageTypeIcon(pkg.package_type)}</span>
<RowTypeIcon className="h-4 w-4 text-gray-400 flex-shrink-0" />
<button
onClick={() => navigate(`/updates/package/${pkg.package_type}/${pkg.package_name}`)}
className="text-sm font-medium text-gray-900 hover:text-primary-600 truncate block max-w-[16rem]"

View file

@ -12,6 +12,7 @@ import {
Users,
Key,
Terminal,
KeyRound,
Server,
Monitor,
Laptop,
@ -63,6 +64,22 @@ const PLATFORMS = [
},
] as const;
// Expiry choices offered in the create-key form. Ceiling raised from 168h
// (7d) to 2160h (90d) 2026-06-30 — matches server/internal/api/handlers/
// registration_tokens.go's maxRegistrationTokenDuration. See
// docs/tasks/UI-REGISTRATION-ENROLLMENT-UNIFY.md for the reasoning: 90 days
// mirrors the refresh-token TTL already trusted elsewhere in this system,
// and there's still no "never expires" option — expires_at is a required
// column, and an unbounded bearer credential is a bigger step than a longer
// bound one.
const EXPIRY_OPTIONS = [
{ value: '24h', label: '24 hours' },
{ value: '72h', label: '3 days' },
{ value: '168h', label: '7 days (1 week)' },
{ value: '720h', label: '30 days' },
{ value: '2160h', label: '90 days (maximum)' },
] as const;
function getServerUrl(): string {
// The host:port the browser is on is always reachable by the agent machine.
const { protocol, hostname, port } = window.location;
@ -96,6 +113,64 @@ const getStatusText = (token: RegistrationToken): string => {
const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.slice(0, 8)}`;
// PlatformPicker + InstallCommandBox are shared between the "use an existing
// key" and "key just created" branches of the enrollment flow below, so the
// platform choice and the resulting one-liner look and behave identically
// regardless of how the operator got there.
const PlatformPicker: React.FC<{ value: string; onChange: (id: string) => void }> = ({ value, onChange }) => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{PLATFORMS.map((p) => {
const Icon = p.icon;
const selected = value === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => onChange(p.id)}
disabled={!p.available}
className={cn(
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
selected ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center justify-between mb-2">
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-primary-600' : 'text-gray-600')} />
{selected && <CheckCircle className="w-4 h-4 text-primary-600" />}
</div>
<div className="font-medium text-gray-900">{p.name}</div>
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
</button>
);
})}
</div>
);
const InstallCommandBox: React.FC<{
command: string;
platform: string;
copied: boolean;
onCopy: () => void;
}> = ({ command, platform, copied, onCopy }) => (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation command
{platform === 'windows' && <span className="text-primary-600"> (Run in PowerShell as Administrator)</span>}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{command}</code>
</pre>
<button
onClick={onCopy}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
title="Copy command"
>
{copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
);
const AgentsEnrollment: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -117,18 +192,19 @@ const AgentsEnrollment: React.FC = () => {
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Selection + panels
// Key-list selection (right-hand detail pane, independent of enrollment)
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
const [showInstall, setShowInstall] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [revealToken, setRevealToken] = useState(false);
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Install panel state (separate from detail selection — install picks a live
// token to enroll a NEW agent with; detail inspects any token)
// Enrollment flow state — one card, two ways in ("use existing key" vs.
// "create new key"), converging on the same platform picker + one-liner.
// Replaces the old showInstall/showCreate toggle-panel pair.
const [enrollTab, setEnrollTab] = useState<'use' | 'create'>('use');
const enrollTabTouched = React.useRef(false);
const [installPlatform, setInstallPlatform] = useState<string>('linux');
const [installTokenId, setInstallTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const [createdToken, setCreatedToken] = useState<{ token: string; label: string } | null>(null);
// Create-key form
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
@ -137,7 +213,9 @@ const AgentsEnrollment: React.FC = () => {
max_seats: 1,
});
// Signing keys (preserved from the old Agent Management page)
// Signing keys (preserved from the old Agent Management page — now its own
// section instead of being repeated under whichever key happens to be
// selected, since it isn't actually per-key).
const { data: serverKeySecurity, isLoading: isLoadingServerKeySecurity, refetch: refetchServerKeySecurity } =
useServerKeySecurity();
const [generatingKeys, setGeneratingKeys] = useState(false);
@ -167,7 +245,7 @@ const AgentsEnrollment: React.FC = () => {
const selectedToken = allTokens.find((t) => t.id === selectedTokenId) || null;
// Active tokens with available seats — what the install panel offers.
// Active tokens with available seats — what "use existing key" offers.
const availableTokens = React.useMemo(
() =>
allTokens.filter(
@ -176,6 +254,15 @@ const AgentsEnrollment: React.FC = () => {
[allTokens],
);
// If the operator hasn't touched the tab and there's nothing to enroll
// with yet, default straight to "create new key" instead of showing an
// empty dropdown.
React.useEffect(() => {
if (!enrollTabTouched.current && !isLoading && availableTokens.length === 0) {
setEnrollTab('create');
}
}, [availableTokens, isLoading]);
const installToken = availableTokens.find((t) => t.id === installTokenId) ?? null;
React.useEffect(() => {
if (installTokenId && !availableTokens.some((t) => t.id === installTokenId)) {
@ -183,8 +270,8 @@ const AgentsEnrollment: React.FC = () => {
}
}, [availableTokens, installTokenId]);
// Deep-link from elsewhere (e.g. the Agents page): `?install=1` opens the
// install panel; `?install=<tokenId>` opens it pre-seeded with that key so
// Deep-link from elsewhere (e.g. the Agents page): `?install=1` lands on the
// "use existing key" tab; `?install=<tokenId>` pre-seeds it with that key so
// the operator lands on the exact key they came to enroll with. The guard
// effect above drops the seed if the key turns out unavailable. Strip the
// param afterward so a refresh or back-nav doesn't re-trigger it.
@ -194,7 +281,8 @@ const AgentsEnrollment: React.FC = () => {
const inst = searchParams.get('install');
if (!inst) return;
deepLinkHandled.current = true;
setShowInstall(true);
enrollTabTouched.current = true;
setEnrollTab('use');
if (inst !== '1') setInstallTokenId(inst);
searchParams.delete('install');
setSearchParams(searchParams, { replace: true });
@ -207,9 +295,9 @@ const AgentsEnrollment: React.FC = () => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: (data: any) => {
const label = formData.label || data.label;
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreate(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
setCreatedToken({ token: data.token, label });
refetch();
},
});
@ -269,11 +357,13 @@ const AgentsEnrollment: React.FC = () => {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
// Jump straight from a selected key into the install flow, pre-seeded with
// that key — no re-picking it in the install dropdown.
// Jump straight from a selected key into the enrollment flow, pre-seeded
// with that key — no re-picking it in the dropdown.
const installWithKey = (tokenId: string) => {
enrollTabTouched.current = true;
setCreatedToken(null);
setEnrollTab('use');
setInstallTokenId(tokenId);
setShowInstall(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@ -309,7 +399,10 @@ const AgentsEnrollment: React.FC = () => {
}
};
const installCommand = generateInstallCommand(installPlatform, installToken?.token);
// The token driving the platform picker + one-liner: whichever key was
// just created, or (in "use existing key" mode) whichever key is selected.
const enrollTokenValue = createdToken?.token ?? installToken?.token;
const installCommand = generateInstallCommand(installPlatform, enrollTokenValue);
const boundAgents = boundAgentsData?.agents || [];
return (
@ -319,233 +412,193 @@ const AgentsEnrollment: React.FC = () => {
</button>
{/* Header */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agents &amp; Enrollment</h1>
<p className="mt-2 text-gray-600">
Enroll agents and manage registration keys. Select a key to see who enrolled with it.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowInstall((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Terminal className="w-4 h-4" />
{showInstall ? 'Hide install' : 'Install agent'}
</button>
<button
onClick={() => setShowCreate((v) => !v)}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
New key
</button>
</div>
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">Agents &amp; Enrollment</h1>
<p className="mt-2 text-gray-600">
Enroll new agents and manage the registration keys that let them join.
</p>
</div>
{/* Install panel */}
{showInstall && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Install a new agent</h2>
<button onClick={() => setShowInstall(false)} className="text-sm text-gray-500 hover:text-gray-700">
close
</button>
</div>
{availableTokens.length === 0 ? (
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
No registration keys with available seats.{' '}
<button onClick={() => setShowCreate(true)} className="text-primary-600 hover:text-primary-800 underline">
Create a key
</button>{' '}
first existing agents are unaffected.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Enroll with key:</span>
<select
value={installTokenId}
onChange={(e) => setInstallTokenId(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[320px]"
>
<option value=""> Select a key ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{(t.token ?? t.id).slice(0, 12)}{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{PLATFORMS.map((p) => {
const Icon = p.icon;
const selected = installPlatform === p.id;
return (
<button
key={p.id}
onClick={() => setInstallPlatform(p.id)}
disabled={!p.available}
className={cn(
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
selected ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center justify-between mb-2">
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-primary-600' : 'text-gray-600')} />
{selected && <CheckCircle className="w-4 h-4 text-primary-600" />}
</div>
<div className="font-medium text-gray-900">{p.name}</div>
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
</button>
);
})}
</div>
{installToken ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation command
{installPlatform === 'windows' && <span className="text-primary-600"> (Run in PowerShell as Administrator)</span>}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{installCommand}</code>
</pre>
<button
onClick={() => copyToClipboard(installCommand, 'install')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600"
title="Copy command"
>
{copiedCommand === 'install' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
) : (
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
Select a key above to generate the one-liner.
</div>
)}
</div>
)}
{/* Enroll a new agent single flow: pick or create a key, pick a
platform, copy the one-liner. Replaces the old install/create
toggle panels. */}
<div className="card mb-6">
<div className="flex items-center gap-2 mb-1">
<Terminal className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Enroll a new agent</h2>
</div>
)}
<p className="text-sm text-gray-500 mb-4">
Pick a registration key (or make one), choose a platform, and copy the one-liner.
</p>
{/* Create-key form */}
{showCreate && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create new registration key</h2>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
</div>
</div>
<div className="flex gap-3">
{createdToken ? (
<div className="space-y-4">
<div className="alert alert-success flex items-start justify-between gap-3">
<p className="text-sm text-success-800">
<span className="font-medium">Key "{createdToken.label}" created.</span> Copy the
token now it cannot be retrieved again, only a hash is stored.
</p>
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
onClick={() => setCreatedToken(null)}
className="text-xs text-success-700 hover:text-success-900 shrink-0 whitespace-nowrap"
>
{createToken.isPending ? 'Creating...' : 'Create key'}
</button>
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
Enroll another
</button>
</div>
</form>
</div>
)}
{/* Created-key reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-6">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Key created</h3>
</div>
<button onClick={() => setCreatedToken(null)} className="text-green-600 hover:text-green-800 text-sm">
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this key now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
<code className="flex-1 font-mono text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token, 'created-token')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
className="p-2 text-gray-500 hover:text-gray-700 border border-gray-200 rounded"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command, 'created-cmd')}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
{copiedCommand === 'created-token' ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
<InstallCommandBox
command={installCommand}
platform={installPlatform}
copied={copiedCommand === 'install'}
onCopy={() => copyToClipboard(installCommand, 'install')}
/>
</div>
</div>
)}
) : (
<div className="space-y-4">
<div className="inline-flex rounded-lg border border-gray-200 p-0.5 bg-gray-50">
<button
type="button"
onClick={() => {
enrollTabTouched.current = true;
setEnrollTab('use');
}}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
enrollTab === 'use' ? 'bg-white shadow-sm text-primary-700 font-medium' : 'text-gray-500 hover:text-gray-700',
)}
>
Use existing key
</button>
<button
type="button"
onClick={() => {
enrollTabTouched.current = true;
setEnrollTab('create');
}}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors inline-flex items-center gap-1',
enrollTab === 'create' ? 'bg-white shadow-sm text-primary-700 font-medium' : 'text-gray-500 hover:text-gray-700',
)}
>
<Plus className="w-3.5 h-3.5" />
Create new key
</button>
</div>
{enrollTab === 'use' ? (
availableTokens.length === 0 ? (
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
No registration keys with available seats.{' '}
<button
onClick={() => {
enrollTabTouched.current = true;
setEnrollTab('create');
}}
className="text-primary-600 hover:text-primary-800 underline"
>
Create one
</button>{' '}
existing agents are unaffected.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Key:</span>
<select
value={installTokenId}
onChange={(e) => setInstallTokenId(e.target.value)}
className="form-input bg-white min-w-[320px] w-auto"
>
<option value=""> Select a key ({availableTokens.length} available) </option>
{availableTokens.map((t) => (
<option key={t.id} value={t.id}>
{(t.token ?? t.id).slice(0, 12)}{t.label ? ` · ${t.label}` : ''} · {t.seats_used}/{t.max_seats} seats
</option>
))}
</select>
</div>
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
{installToken ? (
<InstallCommandBox
command={installCommand}
platform={installPlatform}
copied={copiedCommand === 'install'}
onCopy={() => copyToClipboard(installCommand, 'install')}
/>
) : (
<div className="text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-lg p-4">
Select a key above to generate the one-liner.
</div>
)}
</div>
)
) : (
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Servers"
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="form-input bg-white"
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 90 days per key revoke anytime before then.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="form-input"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
</div>
</div>
<button type="submit" disabled={createToken.isPending} className="btn-primary">
{createToken.isPending ? 'Creating...' : 'Create key'}
</button>
</form>
)}
</div>
)}
</div>
{/* Stats */}
{stats && (
@ -563,8 +616,12 @@ const AgentsEnrollment: React.FC = () => {
</div>
)}
{/* Master-detail */}
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6">
{/* Registration keys — master-detail roster + audit view */}
<div className="mb-3">
<h2 className="text-lg font-semibold text-gray-900">Registration keys</h2>
<p className="text-sm text-gray-500">All keys, active and historical. Select one to see who enrolled with it.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 mb-6">
{/* LEFT: key list */}
<div className="bg-white rounded-lg border border-gray-200 flex flex-col max-h-[70vh]">
<div className="p-4 border-b border-gray-200 space-y-3">
@ -678,7 +735,7 @@ const AgentsEnrollment: React.FC = () => {
<button
onClick={() => installWithKey(selectedToken.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"
title="Open the install panel pre-seeded with this key"
title="Jump to the enrollment flow pre-seeded with this key"
>
<Terminal className="w-4 h-4" />
Install with this key
@ -845,60 +902,64 @@ const AgentsEnrollment: React.FC = () => {
</div>
)}
</div>
{/* Signing keys — preserved capability from the old Agent Management page */}
<div className="border-t border-gray-200 pt-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3 inline-flex items-center gap-1.5">
<Key className="w-4 h-4 text-gray-500" />
Server signing key
</h3>
{isLoadingServerKeySecurity ? (
<div className="py-3 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-success rounded-md p-2.5 flex-1">
<p className="text-sm text-green-800 inline-flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Server has a private key for signing agent updates.
</p>
</div>
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
{serverKeySecurity.public_key_fingerprint}
</code>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-warning rounded-md p-2.5 flex-1">
<p className="text-sm text-amber-800">
Server is missing a private key generate one to enable secure agent updates.
</p>
</div>
<button
onClick={generateKeys}
disabled={generatingKeys}
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Generating...
</>
) : (
<>
<Key className="w-4 h-4" />
Generate signing keys
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Server signing key not per-key, so it lives here rather than
repeated inside every token's detail pane. */}
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-1 inline-flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-500" />
Server signing key
</h2>
<p className="text-sm text-gray-500 mb-4">
Signs agent update packages so agents can verify what they're installing.
</p>
{isLoadingServerKeySecurity ? (
<div className="py-3 text-center">
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
</div>
) : serverKeySecurity?.has_private_key ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-success rounded-md p-2.5 flex-1">
<p className="text-sm text-green-800 inline-flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Server has a private key for signing agent updates.
</p>
</div>
<code className="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded font-mono">
{serverKeySecurity.public_key_fingerprint}
</code>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="alert alert-warning rounded-md p-2.5 flex-1">
<p className="text-sm text-amber-800">
Server is missing a private key generate one to enable secure agent updates.
</p>
</div>
<button
onClick={generateKeys}
disabled={generatingKeys}
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md disabled:opacity-50"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Generating...
</>
) : (
<>
<Key className="w-4 h-4" />
Generate signing keys
</>
)}
</button>
</div>
)}
</div>
</div>
);
};

View file

@ -442,8 +442,9 @@ const UpstreamTracking: React.FC = () => {
<span className="text-gray-400">never</span>
)}
{s.last_error && (
<div className="text-red-600 truncate max-w-xs" title={s.last_error}>
{s.last_error}
<div className="text-red-600 truncate max-w-xs inline-flex items-center gap-1" title={s.last_error}>
<AlertOctagon className="h-3 w-3 flex-shrink-0" />
{s.last_error}
</div>
)}
</td>

View file

@ -30,9 +30,19 @@ export interface Agent {
is_updating?: boolean;
updating_to_version?: string;
update_available?: boolean;
// Device classification (DEVICE-001/SERVER-001)
device_type?: string; // agent-detected: server | desktop | phone | tablet
device_type_manual?: string; // operator override, absent = use auto
device_model?: string; // "Google Pixel 3", "Dell PowerEdge R740"
os_distro?: string; // /etc/os-release ID: "arch", "fedora"
effective_device_type?: string; // server-computed COALESCE(manual, auto)
// Note: ip_address not available from API yet
}
export type DeviceType = 'server' | 'desktop' | 'phone' | 'tablet';
export const DEVICE_TYPES: DeviceType[] = ['server', 'desktop', 'phone', 'tablet'];
export interface AgentSpec {
id: string;
agent_id: string;