feat: FEAT-002 local agent API, desktop tray spine, screenshot handler
Slices 1-3 of the local agent IPC surface: - Read model (local_status.go, loop wired): update counts, scanner status, check-in state, token receipt counts — no token material exposed - Local IPC (localapi/): Unix socket (group=redflag-local, 0660) + Windows named pipe (SDDL: LocalSystem/Admins/RedFlagLocal); five read-only endpoints - `redflag-agent -local-status` CLI probe of the local API surface - Screenshot capture handler (screenshot.go, dispatch wired) - Tauri desktop spine (desktop/): tray icon, left-click window, local IPC reader - Desktop React entry (web/src/desktop/LocalAgentApp.tsx, index.desktop.html, vite.desktop.config.ts) - Installer group provisioning: linux.sh creates redflag-local, sets SupplementaryGroups; windows.ps1 creates RedFlagLocal security group - Server-side: screenshot receipt handler on agents, updates handler additions - web/package.json: @tauri-apps/api + tauri CLI dev dep added
This commit is contained in:
parent
b0e2a77528
commit
ffffe9b956
35 changed files with 1996 additions and 31 deletions
253
web/src/desktop/LocalAgentApp.tsx
Normal file
253
web/src/desktop/LocalAgentApp.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Monitor,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
interface LocalIdentity {
|
||||
agent_id: string
|
||||
server_url: string
|
||||
hostname?: string
|
||||
os_type?: string
|
||||
display_name?: string
|
||||
organization?: string
|
||||
tags?: string[]
|
||||
agent_version: string
|
||||
config_version?: string
|
||||
check_in_interval: number
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
interface UpdateSummary {
|
||||
total: number
|
||||
by_ecosystem?: Record<string, number>
|
||||
by_severity?: Record<string, number>
|
||||
}
|
||||
|
||||
interface ScannerState {
|
||||
name: string
|
||||
status: string
|
||||
last_scan_time?: string
|
||||
last_duration_ms?: number
|
||||
last_error?: string
|
||||
update_count: number
|
||||
}
|
||||
|
||||
interface LocalStatus {
|
||||
agent_status: string
|
||||
last_check_in?: string
|
||||
last_updated?: string
|
||||
last_scan_time?: string
|
||||
update_count: number
|
||||
summary: UpdateSummary
|
||||
scanners?: Record<string, ScannerState>
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
interface LocalSnapshot {
|
||||
identity: LocalIdentity
|
||||
status: LocalStatus
|
||||
}
|
||||
|
||||
type HealthState = 'healthy' | 'warning' | 'error'
|
||||
|
||||
const LocalAgentApp: React.FC = () => {
|
||||
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const next = await invoke<LocalSnapshot>('local_status')
|
||||
setSnapshot(next)
|
||||
setLastRefresh(new Date())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = window.setInterval(load, 5000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
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'
|
||||
const critical = snapshot.status.summary.by_severity?.critical ?? 0
|
||||
return critical > 0 ? 'warning' : 'healthy'
|
||||
}, [error, snapshot])
|
||||
|
||||
const scanners = useMemo(() => {
|
||||
if (!snapshot?.status.scanners) return []
|
||||
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-md',
|
||||
health === 'healthy' && 'bg-success-50 text-success-700',
|
||||
health === 'warning' && 'bg-warning-50 text-warning-700',
|
||||
health === 'error' && 'bg-danger-50 text-danger-700',
|
||||
)}>
|
||||
{health === 'healthy' ? <ShieldCheck className="h-5 w-5" /> : health === 'warning' ? <AlertTriangle className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-tight">RedFlag Local Agent</h1>
|
||||
<p className="text-xs text-gray-500">{snapshot?.identity.hostname || 'Local machine'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="px-5 py-4">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-danger-200 bg-danger-50 px-3 py-2 text-sm text-danger-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Metric
|
||||
icon={Server}
|
||||
label="Fleet"
|
||||
value={snapshot?.identity.registered ? 'Bound' : 'Unbound'}
|
||||
tone={snapshot?.identity.registered ? 'success' : 'warning'}
|
||||
/>
|
||||
<Metric
|
||||
icon={Package}
|
||||
label="Updates"
|
||||
value={String(snapshot?.status.update_count ?? 0)}
|
||||
tone={(snapshot?.status.update_count ?? 0) > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<Metric
|
||||
icon={Clock}
|
||||
label="Check-in"
|
||||
value={formatMaybeRelative(snapshot?.status.last_check_in)}
|
||||
/>
|
||||
<Metric
|
||||
icon={Activity}
|
||||
label="Scan"
|
||||
value={formatMaybeRelative(snapshot?.status.last_scan_time)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Identity</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[112px_1fr] gap-x-3 gap-y-2 px-4 py-3 text-sm">
|
||||
<dt className="text-gray-500">Agent</dt>
|
||||
<dd className="truncate font-mono text-xs text-gray-900">{snapshot?.identity.agent_id || '-'}</dd>
|
||||
<dt className="text-gray-500">Server</dt>
|
||||
<dd className="truncate text-gray-900">{snapshot?.identity.server_url || '-'}</dd>
|
||||
<dt className="text-gray-500">Version</dt>
|
||||
<dd className="text-gray-900">{snapshot?.identity.agent_version || '-'}</dd>
|
||||
<dt className="text-gray-500">Platform</dt>
|
||||
<dd className="text-gray-900">{snapshot?.identity.os_type || '-'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Scanners</h2>
|
||||
<span className="text-xs text-gray-500">{scanners.length}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{scanners.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">No scanner state reported</div>
|
||||
) : (
|
||||
scanners.map((scanner) => (
|
||||
<div key={scanner.name} className="flex items-center gap-3 px-4 py-3">
|
||||
<ScannerIcon status={scanner.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{scanner.name}</p>
|
||||
<span className="text-xs text-gray-500">{scanner.update_count} updates</span>
|
||||
</div>
|
||||
<p className={cn('truncate text-xs', scanner.last_error ? 'text-danger-700' : 'text-gray-500')}>
|
||||
{scanner.last_error || formatMaybeRelative(scanner.last_scan_time)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting status'}</span>
|
||||
<span>{snapshot?.status.agent_status || 'unknown'}</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
tone?: 'success' | 'warning' | 'neutral'
|
||||
}
|
||||
|
||||
const Metric: React.FC<MetricProps> = ({ icon: Icon, label, value, tone = 'neutral' }) => (
|
||||
<div className="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-gray-500" />
|
||||
<span className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
tone === 'success' && 'bg-success-500',
|
||||
tone === 'warning' && 'bg-warning-500',
|
||||
tone === 'neutral' && 'bg-gray-300',
|
||||
)} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-gray-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ScannerIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||
if (status === 'success') {
|
||||
return <CheckCircle2 className="h-4 w-4 flex-shrink-0 text-success-600" />
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <AlertTriangle className="h-4 w-4 flex-shrink-0 text-danger-600" />
|
||||
}
|
||||
return <Monitor className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
}
|
||||
|
||||
function formatMaybeRelative(value?: string): string {
|
||||
if (!value || value.startsWith('0001-')) return '-'
|
||||
return formatRelativeTime(value)
|
||||
}
|
||||
|
||||
export default LocalAgentApp
|
||||
10
web/src/desktop/main.tsx
Normal file
10
web/src/desktop/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import LocalAgentApp from './LocalAgentApp'
|
||||
import '../index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<LocalAgentApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import { updateApi, agentApi } from '@/lib/api';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
interface ActiveCommand {
|
||||
|
|
@ -72,4 +72,35 @@ export const useClearFailedCommands = (): UseMutationResult<{ message: string; c
|
|||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger a screenshot capture on an agent. Returns the command ID.
|
||||
export const useCaptureScreenshot = (): UseMutationResult<{ message: string; command_id: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (agentId: string) => agentApi.captureScreenshot(agentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Poll a specific command until it completes. Used to retrieve the
|
||||
// screenshot image from the command result's stdout field.
|
||||
export const useCommand = (commandId: string | null, enabled: boolean = true): UseQueryResult<any, Error> => {
|
||||
return useQuery({
|
||||
queryKey: ['command', commandId],
|
||||
queryFn: () => agentApi.getCommand(commandId!),
|
||||
enabled: !!commandId && enabled,
|
||||
refetchInterval: (query: any) => {
|
||||
// Stop polling once the command reaches a terminal state
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'failed' || status === 'timed_out' || status === 'cancelled') {
|
||||
return false;
|
||||
}
|
||||
return 2000; // Poll every 2 seconds while in flight
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
|
|
@ -154,6 +154,18 @@ export const agentApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger screenshot capture on an agent
|
||||
captureScreenshot: async (agentId: string): Promise<{ message: string; command_id: string }> => {
|
||||
const response = await api.post(`/agents/${agentId}/screenshot`);
|
||||
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}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger agent reboot
|
||||
rebootAgent: async (id: string, delayMinutes: number = 1, message?: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/reboot`, {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
import { SearchInput, FilterDropdown } from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
|
||||
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -40,7 +40,7 @@ import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
|
|||
import { BulkAgentUpdate } from '@/components/RelayList';
|
||||
import ChatTimeline from '@/components/ChatTimeline';
|
||||
import AgentSoftwareBindings from '@/components/AgentSoftwareBindings';
|
||||
import { AgentIntegrations } from '@/components/AgentIntegrations';
|
||||
import { readIntegrations, resolveState } from '@/types/integrations';
|
||||
|
||||
type AgentDetailTab = 'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
|
||||
|
||||
|
|
@ -66,6 +66,7 @@ const Agents: React.FC = () => {
|
|||
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
|
||||
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
|
||||
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
|
|
@ -228,7 +229,10 @@ const Agents: React.FC = () => {
|
|||
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
|
||||
const cancelCommandMutation = useCancelCommand();
|
||||
|
||||
|
||||
// Screenshot capture
|
||||
const captureScreenshotMutation = useCaptureScreenshot();
|
||||
const { data: screenshotCommand } = useCommand(screenshotCommandId, !!screenshotCommandId);
|
||||
|
||||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
|
|
@ -323,6 +327,22 @@ const Agents: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle screenshot capture
|
||||
const handleCaptureScreenshot = async (agentId: string) => {
|
||||
try {
|
||||
const result = await captureScreenshotMutation.mutateAsync(agentId);
|
||||
setScreenshotCommandId(result.command_id);
|
||||
toast.success('Screenshot capture requested');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to capture screenshot: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract base64 screenshot from command result
|
||||
const screenshotImage = screenshotCommand?.status === 'completed'
|
||||
? screenshotCommand?.result?.stdout || null
|
||||
: null;
|
||||
|
||||
// Handle rapid polling toggle
|
||||
const handleRapidPollingToggle = async (agentId: string, enabled: boolean, durationMinutes?: number) => {
|
||||
// Prevent multiple clicks
|
||||
|
|
@ -796,8 +816,93 @@ const Agents: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrations — observed by the agent, rendered above system info */}
|
||||
<AgentIntegrations metadata={selectedAgent.metadata} />
|
||||
{/* Screen square — screenshot capture / Sunshine connection link */}
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const running = state === 'running';
|
||||
const sunshineReady = live || running;
|
||||
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
|
||||
// Determine what the square shows
|
||||
const hasImage = !!screenshotImage;
|
||||
const squareClickable = sunshineReady || !isCapturing;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full max-w-sm overflow-hidden rounded border',
|
||||
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
|
||||
'flex flex-col items-center justify-center gap-2 text-slate-400',
|
||||
squareClickable && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (sunshineReady && sunshine?.web_ui) {
|
||||
window.open(sunshine.web_ui, '_blank', 'noreferrer');
|
||||
} else if (!isCapturing && !isPolling) {
|
||||
handleCaptureScreenshot(selectedAgent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${screenshotImage}`}
|
||||
alt="Agent screenshot"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MonitorPlay className="h-10 w-10 opacity-70" />
|
||||
<span className="text-xs">
|
||||
{isCapturing ? 'Requesting...'
|
||||
: isPolling ? 'Capturing...'
|
||||
: sunshineReady ? 'Open stream host'
|
||||
: 'Click to capture'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIVE marker when Sunshine session is active */}
|
||||
{live && (
|
||||
<span className="absolute top-2 left-2 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sunshine status badge */}
|
||||
{sunshineReady && (
|
||||
<span className="absolute top-2 right-2 rounded bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{live ? sunshine?.client_name || 'Connected' : 'Sunshine running'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Loading spinner overlay */}
|
||||
{(isCapturing || isPolling) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<RefreshCw className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption row beneath the square */}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||
{sunshine?.version && <span>Sunshine v{sunshine.version}</span>}
|
||||
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at && (
|
||||
<span className="ml-auto">Captured {formatRelativeTime(screenshotCommand.completed_at)}</span>
|
||||
)}
|
||||
{screenshotCommand?.status === 'failed' && (
|
||||
<span className="text-red-500 ml-auto">Capture failed</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* System info */}
|
||||
<div className="card">
|
||||
|
|
|
|||
Loading…
Reference in a new issue