Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-08 17:22:01 -04:00
commit ab6fc48925
3 changed files with 188 additions and 145 deletions

View file

@ -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)
}