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:
parent
b3d946b84f
commit
ff2f30f47a
58 changed files with 2989 additions and 429 deletions
|
|
@ -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>
|
||||
|
|
|
|||
53
web/src/components/DeviceTypeIcon.tsx
Normal file
53
web/src/components/DeviceTypeIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue