feat: v0.2.6.8 — dark/light tray app theme, version bump
LocalAgentApp: dark default with ☀/☾ toggle; palette-driven inline styles (DARK/LIGHT objects) so both modes work without Tailwind dark mode configuration. Dense Novell-style layout: status strip, section headers with red accent bar, monospace identifiers, dot indicators per scanner status. No Tailwind class changes — web/package-lock.json untouched. Version bump 0.2.6.7 → 0.2.6.8.
This commit is contained in:
parent
ffffe9b956
commit
ab6fc48925
3 changed files with 188 additions and 145 deletions
|
|
@ -1,18 +1,66 @@
|
|||
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'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
// ---------- theme palette ----------
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface Palette {
|
||||
bg: string
|
||||
bgAlt: string
|
||||
bgHeader: string
|
||||
border: string
|
||||
borderInner: string
|
||||
text: string
|
||||
textMuted: string
|
||||
textDim: string
|
||||
accent: string
|
||||
good: string
|
||||
warn: string
|
||||
bad: string
|
||||
errorBg: string
|
||||
errorBorder: string
|
||||
errorText: string
|
||||
}
|
||||
|
||||
const DARK: Palette = {
|
||||
bg: '#0f1117',
|
||||
bgAlt: '#161b27',
|
||||
bgHeader: '#161b27',
|
||||
border: '#1e2535',
|
||||
borderInner: '#1a1f2e',
|
||||
text: '#d1d5db',
|
||||
textMuted: '#9ca3af',
|
||||
textDim: '#6b7280',
|
||||
accent: '#dc2626',
|
||||
good: '#22c55e',
|
||||
warn: '#f59e0b',
|
||||
bad: '#ef4444',
|
||||
errorBg: '#2d1b1b',
|
||||
errorBorder: '#7f1d1d',
|
||||
errorText: '#fca5a5',
|
||||
}
|
||||
|
||||
const LIGHT: Palette = {
|
||||
bg: '#f9fafb',
|
||||
bgAlt: '#ffffff',
|
||||
bgHeader: '#f3f4f6',
|
||||
border: '#e5e7eb',
|
||||
borderInner: '#f3f4f6',
|
||||
text: '#374151',
|
||||
textMuted: '#6b7280',
|
||||
textDim: '#9ca3af',
|
||||
accent: '#dc2626',
|
||||
good: '#16a34a',
|
||||
warn: '#d97706',
|
||||
bad: '#dc2626',
|
||||
errorBg: '#fef2f2',
|
||||
errorBorder: '#fecaca',
|
||||
errorText: '#991b1b',
|
||||
}
|
||||
|
||||
// ---------- API types ----------
|
||||
|
||||
interface LocalIdentity {
|
||||
agent_id: string
|
||||
|
|
@ -61,11 +109,15 @@ interface LocalSnapshot {
|
|||
|
||||
type HealthState = 'healthy' | 'warning' | 'error'
|
||||
|
||||
// ---------- main component ----------
|
||||
|
||||
const LocalAgentApp: React.FC = () => {
|
||||
const [theme, setTheme] = useState<Theme>('dark')
|
||||
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 p = theme === 'dark' ? DARK : LIGHT
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -90,8 +142,7 @@ const LocalAgentApp: React.FC = () => {
|
|||
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'
|
||||
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
||||
}, [error, snapshot])
|
||||
|
||||
const scanners = useMemo(() => {
|
||||
|
|
@ -99,154 +150,146 @@ const LocalAgentApp: React.FC = () => {
|
|||
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [snapshot])
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
||||
|
||||
{/* Title bar */}
|
||||
<div style={{ background: p.bgHeader, borderBottom: `1px solid ${p.border}`, padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: healthDot, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: '13px', color: theme === 'dark' ? '#f3f4f6' : '#111827' }}>
|
||||
{snapshot?.identity.display_name || snapshot?.identity.hostname || 'RedFlag Agent'}
|
||||
</span>
|
||||
<span style={{ color: p.textDim, fontSize: '11px' }}>{healthLabel}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<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"
|
||||
onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||
title={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
|
||||
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px 4px', fontSize: '11px', lineHeight: 1, borderRadius: '2px' }}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
<button
|
||||
onClick={load}
|
||||
title="Refresh"
|
||||
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px', lineHeight: 1 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', opacity: loading ? 0.4 : 1 }}>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderLeft: `3px solid ${p.bad}`, margin: '10px 12px', padding: '7px 10px', borderRadius: '3px', fontSize: '12px', color: p.errorText, fontFamily: 'monospace' }}>
|
||||
{error}
|
||||
</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>
|
||||
{/* 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="Updates" value={String(snapshot?.status.update_count ?? 0)} accent={(snapshot?.status.update_count ?? 0) > 0 ? p.warn : p.good} />
|
||||
</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>
|
||||
{/* Identity */}
|
||||
<Section p={p} label="Identity">
|
||||
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
|
||||
<Row p={p} label="Agent" value={snapshot ? snapshot.identity.agent_id.slice(0, 16) + '…' : '—'} mono />
|
||||
<Row p={p} label="Server" value={snapshot?.identity.server_url || '—'} />
|
||||
<Row p={p} label="Version" value={snapshot?.identity.agent_version || '—'} mono />
|
||||
<Row p={p} label="Platform" value={snapshot?.identity.os_type || '—'} />
|
||||
</Section>
|
||||
|
||||
{/* Activity */}
|
||||
<Section p={p} label="Activity">
|
||||
<Row p={p} label="Last check-in" value={maybeRelative(snapshot?.status.last_check_in)} />
|
||||
<Row p={p} label="Last scan" value={maybeRelative(snapshot?.status.last_scan_time)} />
|
||||
<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})`}>
|
||||
{scanners.map(scanner => (
|
||||
<div key={scanner.name} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', padding: '5px 14px', borderBottom: `1px solid ${p.borderInner}` }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', marginTop: '4px', flexShrink: 0, background: scanner.status === 'success' ? p.good : scanner.status === 'failed' ? p.bad : p.textDim }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text }}>{scanner.name}</span>
|
||||
<span style={{ fontSize: '11px', color: p.textDim }}>{scanner.update_count} pkgs</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: scanner.last_error ? p.bad : p.textDim, marginTop: '1px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{scanner.last_error || maybeRelative(scanner.last_scan_time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '8px 14px', borderTop: `1px solid ${p.border}`, display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: p.textDim }}>
|
||||
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting data'}</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>v{snapshot?.identity.agent_version ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
tone?: 'success' | 'warning' | 'neutral'
|
||||
}
|
||||
// ---------- sub-components ----------
|
||||
|
||||
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',
|
||||
)} />
|
||||
interface WithPalette { p: Palette }
|
||||
|
||||
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' }}>
|
||||
<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 }}>{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-gray-900">{value}</p>
|
||||
{children}
|
||||
</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" />
|
||||
}
|
||||
const StatCell: React.FC<WithPalette & { label: string; value: string; accent: string }> = ({ p, label, value, accent }) => (
|
||||
<div style={{ background: p.bg, padding: '8px 14px' }}>
|
||||
<div style={{ fontSize: '10px', color: p.textDim, textTransform: 'uppercase' as const, letterSpacing: '0.06em', marginBottom: '2px' }}>{label}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 600, color: accent }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
function formatMaybeRelative(value?: string): string {
|
||||
if (!value || value.startsWith('0001-')) return '-'
|
||||
const Row: React.FC<WithPalette & { label: string; value: string; mono?: boolean; accent?: string }> = ({ p, label, value, mono, accent }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: '11px', color: p.textDim, flexShrink: 0 }}>{label}</span>
|
||||
<span style={{ fontSize: '12px', color: accent || p.text, fontFamily: mono ? 'monospace' : 'inherit', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
function maybeRelative(value?: string): string {
|
||||
if (!value || value.startsWith('0001-')) return '—'
|
||||
return formatRelativeTime(value)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue