desktop: put this computer health first
The Agent now owns one local view of machine health and top processes. RedFlag opens at dashboard scale, names itself plainly, and renders that evidence before updates.\n\nCI now compiles the Tauri bridge before release day.
This commit is contained in:
parent
320ad46e00
commit
ffa7afbb58
6 changed files with 302 additions and 20 deletions
|
|
@ -129,6 +129,28 @@ jobs:
|
||||||
- name: Build web UI
|
- name: Build web UI
|
||||||
run: cd web && npm ci && npm run build
|
run: cd web && npm ci && npm run build
|
||||||
|
|
||||||
|
desktop-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||||
|
- name: Install Tauri system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
- name: Check RedFlag desktop
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm ci
|
||||||
|
npm run build:desktop
|
||||||
|
cd ../desktop
|
||||||
|
cargo check
|
||||||
|
|
||||||
installer-integrity:
|
installer-integrity:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -262,7 +284,7 @@ jobs:
|
||||||
# public SHA to Forgejo, without force, then reads it back anonymously.
|
# public SHA to Forgejo, without force, then reads it back anonymously.
|
||||||
sync-public:
|
sync-public:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [go-vet, go-test, rust-test, cross-compile, web-build, installer-integrity, dep-scan, no-ai-attribution, action-pins, public-history]
|
needs: [go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, no-ai-attribution, action-pins, public-history]
|
||||||
if: github.ref == 'refs/heads/public'
|
if: github.ref == 'refs/heads/public'
|
||||||
env:
|
env:
|
||||||
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
|
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||||
|
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,6 +50,8 @@ type Options struct {
|
||||||
RequestLog func(format string, args ...interface{})
|
RequestLog func(format string, args ...interface{})
|
||||||
ListenerOverride net.Listener
|
ListenerOverride net.Listener
|
||||||
DesktopProvider DesktopStatusProvider
|
DesktopProvider DesktopStatusProvider
|
||||||
|
SystemProvider func() (*system.SystemInfo, error)
|
||||||
|
ProcessProvider func(limit int) ([]system.TopProcess, error)
|
||||||
// TriggerScan enqueues a package-update scan through the agent's existing
|
// TriggerScan enqueues a package-update scan through the agent's existing
|
||||||
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
|
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
|
||||||
// group boundary on the socket/pipe — anyone who can connect may trigger.
|
// group boundary on the socket/pipe — anyone who can connect may trigger.
|
||||||
|
|
@ -135,6 +138,8 @@ type handler struct {
|
||||||
triggerScan func(source string) error
|
triggerScan func(source string) error
|
||||||
approveUpdate func(body []byte) (interface{}, error)
|
approveUpdate func(body []byte) (interface{}, error)
|
||||||
onDesktopHealth func(version string, windowOpen bool)
|
onDesktopHealth func(version string, windowOpen bool)
|
||||||
|
systemInfo func() (*system.SystemInfo, error)
|
||||||
|
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||||
|
|
@ -194,6 +199,12 @@ type TokenResponse struct {
|
||||||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemResponse struct {
|
||||||
|
System *system.SystemInfo `json:"system"`
|
||||||
|
TopProcesses []system.TopProcess `json:"top_processes"`
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func newHandler(opts Options) http.Handler {
|
func newHandler(opts Options) http.Handler {
|
||||||
h := &handler{
|
h := &handler{
|
||||||
cfg: opts.Config,
|
cfg: opts.Config,
|
||||||
|
|
@ -202,6 +213,16 @@ func newHandler(opts Options) http.Handler {
|
||||||
triggerScan: opts.TriggerScan,
|
triggerScan: opts.TriggerScan,
|
||||||
approveUpdate: opts.ApproveUpdate,
|
approveUpdate: opts.ApproveUpdate,
|
||||||
onDesktopHealth: opts.OnDesktopHealth,
|
onDesktopHealth: opts.OnDesktopHealth,
|
||||||
|
systemInfo: opts.SystemProvider,
|
||||||
|
topProcesses: opts.ProcessProvider,
|
||||||
|
}
|
||||||
|
if h.systemInfo == nil {
|
||||||
|
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||||
|
return system.GetSystemInfo(version.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.topProcesses == nil {
|
||||||
|
h.topProcesses = system.GetTopProcesses
|
||||||
}
|
}
|
||||||
if h.loadCache == nil {
|
if h.loadCache == nil {
|
||||||
h.loadCache = cache.Load
|
h.loadCache = cache.Load
|
||||||
|
|
@ -211,6 +232,7 @@ func newHandler(opts Options) http.Handler {
|
||||||
mux.HandleFunc("/v1/status", h.status)
|
mux.HandleFunc("/v1/status", h.status)
|
||||||
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
|
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
|
||||||
mux.HandleFunc("/v1/packages", h.scansLatest)
|
mux.HandleFunc("/v1/packages", h.scansLatest)
|
||||||
|
mux.HandleFunc("/v1/system", h.system)
|
||||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||||
|
|
@ -218,6 +240,28 @@ func newHandler(opts Options) http.Handler {
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireGet(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := h.systemInfo()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] [agent] [localapi] system_info_failed error=%v", err)
|
||||||
|
http.Error(w, "system health unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
processes, err := h.topProcesses(8)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARNING] [agent] [localapi] top_processes_failed error=%v", err)
|
||||||
|
processes = nil
|
||||||
|
}
|
||||||
|
writeJSON(w, SystemResponse{
|
||||||
|
System: info,
|
||||||
|
TopProcesses: processes,
|
||||||
|
CollectedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireGet(w, r) {
|
if !requireGet(w, r) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||||
|
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||||
"github.com/gofrs/uuid/v5"
|
"github.com/gofrs/uuid/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -115,6 +116,48 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSystemHealthUsesAgentCollectors(t *testing.T) {
|
||||||
|
handler := newHandler(Options{
|
||||||
|
Config: testConfig(t),
|
||||||
|
LoadCache: func() (*cache.LocalCache, error) {
|
||||||
|
return &cache.LocalCache{}, nil
|
||||||
|
},
|
||||||
|
SystemProvider: func() (*system.SystemInfo, error) {
|
||||||
|
return &system.SystemInfo{
|
||||||
|
Hostname: "workstation-01",
|
||||||
|
OSVersion: "Arch Linux",
|
||||||
|
OSArchitecture: "amd64",
|
||||||
|
RunningProcesses: 173,
|
||||||
|
Uptime: "4 days",
|
||||||
|
MemoryInfo: system.MemoryInfo{
|
||||||
|
Total: 32 << 30,
|
||||||
|
Used: 12 << 30,
|
||||||
|
Available: 20 << 30,
|
||||||
|
UsedPercent: 37.5,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
ProcessProvider: func(limit int) ([]system.TopProcess, error) {
|
||||||
|
if limit != 8 {
|
||||||
|
t.Fatalf("process limit = %d, want 8", limit)
|
||||||
|
}
|
||||||
|
return []system.TopProcess{{Name: "redflag-agent", PID: 42, CPU: 1.5, Mem: 0.8}}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var response SystemResponse
|
||||||
|
requestJSON(t, handler, http.MethodGet, "/v1/system", &response)
|
||||||
|
if response.System.Hostname != "workstation-01" {
|
||||||
|
t.Fatalf("hostname = %q, want workstation-01", response.System.Hostname)
|
||||||
|
}
|
||||||
|
if response.System.MemoryInfo.UsedPercent != 37.5 {
|
||||||
|
t.Fatalf("memory used = %.1f, want 37.5", response.System.MemoryInfo.UsedPercent)
|
||||||
|
}
|
||||||
|
if len(response.TopProcesses) != 1 || response.TopProcesses[0].Name != "redflag-agent" {
|
||||||
|
t.Fatalf("top processes = %#v", response.TopProcesses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
||||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||||
return nil, errors.New("cannot read cache")
|
return nil, errors.New("cannot read cache")
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,82 @@ struct ScanSnapshot {
|
||||||
updates: Vec<UpdateItem>,
|
updates: Vec<UpdateItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SystemSnapshot {
|
||||||
|
system: SystemInfo,
|
||||||
|
#[serde(default)]
|
||||||
|
top_processes: Vec<TopProcess>,
|
||||||
|
collected_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SystemInfo {
|
||||||
|
hostname: String,
|
||||||
|
os_type: String,
|
||||||
|
os_version: String,
|
||||||
|
os_architecture: String,
|
||||||
|
ip_address: String,
|
||||||
|
cpu_info: CpuInfo,
|
||||||
|
memory_info: MemoryInfo,
|
||||||
|
#[serde(default)]
|
||||||
|
disk_info: Vec<DiskInfo>,
|
||||||
|
running_processes: i64,
|
||||||
|
uptime: String,
|
||||||
|
reboot_required: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
reboot_reason: String,
|
||||||
|
#[serde(default)]
|
||||||
|
device_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
device_model: String,
|
||||||
|
#[serde(default)]
|
||||||
|
os_distro: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct CpuInfo {
|
||||||
|
model_name: String,
|
||||||
|
cores: i64,
|
||||||
|
threads: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct MemoryInfo {
|
||||||
|
total: u64,
|
||||||
|
available: u64,
|
||||||
|
used: u64,
|
||||||
|
used_percent: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct DiskInfo {
|
||||||
|
mountpoint: String,
|
||||||
|
total: u64,
|
||||||
|
available: u64,
|
||||||
|
used: u64,
|
||||||
|
used_percent: f64,
|
||||||
|
filesystem: String,
|
||||||
|
is_root: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct TopProcess {
|
||||||
|
name: String,
|
||||||
|
pid: i64,
|
||||||
|
cpu: f64,
|
||||||
|
mem: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn list_updates() -> Result<ScanSnapshot, String> {
|
fn list_updates() -> Result<ScanSnapshot, String> {
|
||||||
local_get_json("/v1/packages")
|
local_get_json("/v1/packages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn system_health() -> Result<SystemSnapshot, String> {
|
||||||
|
local_get_json("/v1/system")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct TriggerScanResponse {
|
struct TriggerScanResponse {
|
||||||
accepted: bool,
|
accepted: bool,
|
||||||
|
|
@ -368,6 +439,7 @@ fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
local_status,
|
local_status,
|
||||||
|
system_health,
|
||||||
list_updates,
|
list_updates,
|
||||||
trigger_scan,
|
trigger_scan,
|
||||||
approve_update
|
approve_update
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "RedFlag Local Agent",
|
"title": "RedFlag",
|
||||||
"width": 460,
|
"width": 980,
|
||||||
"height": 640,
|
"height": 760,
|
||||||
"minWidth": 400,
|
"minWidth": 760,
|
||||||
"minHeight": 520,
|
"minHeight": 620,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"visible": true
|
"visible": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,38 @@ interface ScanSnapshot {
|
||||||
updates: UpdateItem[]
|
updates: UpdateItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
hostname: string
|
||||||
|
os_type: string
|
||||||
|
os_version: string
|
||||||
|
os_architecture: string
|
||||||
|
ip_address: string
|
||||||
|
cpu_info: { model_name: string; cores: number; threads: number }
|
||||||
|
memory_info: { total: number; available: number; used: number; used_percent: number }
|
||||||
|
disk_info: Array<{
|
||||||
|
mountpoint: string
|
||||||
|
total: number
|
||||||
|
available: number
|
||||||
|
used: number
|
||||||
|
used_percent: number
|
||||||
|
filesystem: string
|
||||||
|
is_root: boolean
|
||||||
|
}>
|
||||||
|
running_processes: number
|
||||||
|
uptime: string
|
||||||
|
reboot_required: boolean
|
||||||
|
reboot_reason: string
|
||||||
|
device_type: string
|
||||||
|
device_model: string
|
||||||
|
os_distro: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemSnapshot {
|
||||||
|
system: SystemInfo
|
||||||
|
top_processes: Array<{ name: string; pid: number; cpu: number; mem: number }>
|
||||||
|
collected_at: string
|
||||||
|
}
|
||||||
|
|
||||||
interface TriggerScanResponse {
|
interface TriggerScanResponse {
|
||||||
accepted: boolean
|
accepted: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
|
@ -157,6 +189,7 @@ type HealthState = 'healthy' | 'warning' | 'error'
|
||||||
const LocalAgentApp: React.FC = () => {
|
const LocalAgentApp: React.FC = () => {
|
||||||
const [theme, setTheme] = useState<Theme>('dark')
|
const [theme, setTheme] = useState<Theme>('dark')
|
||||||
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
|
||||||
|
const [system, setSystem] = useState<SystemSnapshot | null>(null)
|
||||||
const [updates, setUpdates] = useState<UpdateItem[]>([])
|
const [updates, setUpdates] = useState<UpdateItem[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -168,10 +201,14 @@ const LocalAgentApp: React.FC = () => {
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
const next = await invoke<LocalSnapshot>('local_status')
|
const [next, health, scan] = await Promise.all([
|
||||||
|
invoke<LocalSnapshot>('local_status'),
|
||||||
|
invoke<SystemSnapshot>('system_health'),
|
||||||
|
invoke<ScanSnapshot>('list_updates'),
|
||||||
|
])
|
||||||
setSnapshot(next)
|
setSnapshot(next)
|
||||||
|
setSystem(health)
|
||||||
setLastRefresh(new Date())
|
setLastRefresh(new Date())
|
||||||
const scan = await invoke<ScanSnapshot>('list_updates')
|
|
||||||
setUpdates(scan.updates ?? [])
|
setUpdates(scan.updates ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
|
@ -182,7 +219,7 @@ const LocalAgentApp: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
const id = window.setInterval(load, 5000)
|
const id = window.setInterval(load, 15000)
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
|
@ -213,8 +250,11 @@ const LocalAgentApp: React.FC = () => {
|
||||||
if (error || !snapshot) return 'error'
|
if (error || !snapshot) return 'error'
|
||||||
if (snapshot.identity.registered && !snapshot.status.registered) 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'
|
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online' && snapshot.identity.registered) return 'warning'
|
||||||
|
if (system?.system.reboot_required) return 'warning'
|
||||||
|
if ((system?.system.memory_info.used_percent ?? 0) >= 90) return 'warning'
|
||||||
|
if (system?.system.disk_info.some(disk => disk.used_percent >= 90)) return 'warning'
|
||||||
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
|
||||||
}, [error, snapshot])
|
}, [error, snapshot, system])
|
||||||
|
|
||||||
const scanners = useMemo(() => {
|
const scanners = useMemo(() => {
|
||||||
if (!snapshot?.status.scanners) return []
|
if (!snapshot?.status.scanners) return []
|
||||||
|
|
@ -223,18 +263,26 @@ const LocalAgentApp: React.FC = () => {
|
||||||
|
|
||||||
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
|
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
|
||||||
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
|
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
|
||||||
|
const rootDisk = system?.system.disk_info.find(disk => disk.is_root)
|
||||||
|
?? system?.system.disk_info[0]
|
||||||
|
const memoryColor = (system?.system.memory_info.used_percent ?? 0) >= 90
|
||||||
|
? p.bad
|
||||||
|
: (system?.system.memory_info.used_percent ?? 0) >= 80 ? p.warn : p.good
|
||||||
|
const diskColor = (rootDisk?.used_percent ?? 0) >= 90
|
||||||
|
? p.bad
|
||||||
|
: (rootDisk?.used_percent ?? 0) >= 80 ? p.warn : p.good
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
|
||||||
|
|
||||||
{/* Title bar */}
|
{/* RedFlag is the product; this computer is the first surface. */}
|
||||||
<div style={{ background: p.bgHeader, borderBottom: `1px solid ${p.border}`, padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ background: p.bgHeader, borderBottom: `1px solid ${p.border}`, padding: '14px 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: healthDot, flexShrink: 0 }} />
|
<div style={{ width: '4px', height: '28px', background: p.accent, borderRadius: '2px' }} />
|
||||||
<span style={{ fontWeight: 600, fontSize: '13px', color: theme === 'dark' ? '#f3f4f6' : '#111827' }}>
|
<div>
|
||||||
{snapshot?.identity.display_name || snapshot?.identity.hostname || 'RedFlag Agent'}
|
<div style={{ fontWeight: 750, fontSize: '19px', letterSpacing: '-0.02em', color: theme === 'dark' ? '#f9fafb' : '#111827' }}>RedFlag</div>
|
||||||
</span>
|
<div style={{ color: p.textDim, fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.12em' }}>This Computer</div>
|
||||||
<span style={{ color: p.textDim, fontSize: '11px' }}>{healthLabel}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -259,18 +307,63 @@ const LocalAgentApp: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '18px', borderBottom: `1px solid ${p.border}`, background: p.bgAlt }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ width: '9px', height: '9px', borderRadius: '50%', background: healthDot }} />
|
||||||
|
<span style={{ fontSize: '20px', fontWeight: 650, color: p.text }}>
|
||||||
|
{snapshot?.identity.display_name || system?.system.hostname || snapshot?.identity.hostname || 'This computer'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px', color: p.textMuted, fontSize: '12px' }}>
|
||||||
|
{system?.system.device_model || system?.system.device_type || 'Computer'} · {system?.system.os_version || snapshot?.identity.os_type || 'Unknown OS'} · {healthLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{system?.system.reboot_required && (
|
||||||
|
<div style={{ border: `1px solid ${p.warn}`, color: p.warn, borderRadius: '4px', padding: '5px 9px', fontSize: '11px' }}>
|
||||||
|
Reboot required{system.system.reboot_reason ? ` · ${system.system.reboot_reason}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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' }}>
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status strip */}
|
{/* System health is the landing view. */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
|
||||||
<StatCell p={p} label="Mode" value={snapshot?.identity.registered ? 'Fleet-joined' : 'Standalone'} accent={snapshot?.identity.registered ? p.good : p.text} />
|
<StatCell p={p} label="Memory" value={system ? `${system.system.memory_info.used_percent.toFixed(0)}%` : '—'} accent={memoryColor} />
|
||||||
|
<StatCell p={p} label="Root disk" value={rootDisk ? `${rootDisk.used_percent.toFixed(0)}%` : '—'} accent={diskColor} />
|
||||||
|
<StatCell p={p} label="Processes" value={system ? String(system.system.running_processes) : '—'} accent={p.text} />
|
||||||
|
<StatCell p={p} label="Uptime" value={system?.system.uptime || '—'} accent={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} />
|
<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>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', borderBottom: `1px solid ${p.border}` }}>
|
||||||
|
<Section p={p} label="Computer health">
|
||||||
|
<Row p={p} label="Processor" value={system?.system.cpu_info.model_name || '—'} />
|
||||||
|
<Row p={p} label="CPU" value={system ? `${system.system.cpu_info.cores} cores · ${system.system.cpu_info.threads} threads` : '—'} />
|
||||||
|
<Row p={p} label="Memory" value={system ? `${formatBytes(system.system.memory_info.used)} of ${formatBytes(system.system.memory_info.total)}` : '—'} />
|
||||||
|
<Row p={p} label="Root storage" value={rootDisk ? `${formatBytes(rootDisk.used)} of ${formatBytes(rootDisk.total)} · ${rootDisk.filesystem}` : '—'} />
|
||||||
|
<Row p={p} label="Network" value={system?.system.ip_address || '—'} mono />
|
||||||
|
</Section>
|
||||||
|
<Section p={p} label="Top processes">
|
||||||
|
{(system?.top_processes.length ?? 0) === 0 ? (
|
||||||
|
<div style={{ padding: '10px 14px', color: p.textDim, fontSize: '12px' }}>Process data unavailable.</div>
|
||||||
|
) : system?.top_processes.slice(0, 6).map(process => (
|
||||||
|
<div key={process.pid} style={{ display: 'grid', gridTemplateColumns: '1fr 54px 54px', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, fontSize: '11px' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{process.name}</span>
|
||||||
|
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.cpu.toFixed(1)}% CPU</span>
|
||||||
|
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.mem.toFixed(1)}% RAM</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pending updates — the standalone admin surface */}
|
{/* Pending updates — the standalone admin surface */}
|
||||||
<UpdatesSection
|
<UpdatesSection
|
||||||
p={p}
|
p={p}
|
||||||
|
|
@ -560,4 +653,12 @@ function maybeRelative(value?: string): string {
|
||||||
return formatRelativeTime(value)
|
return formatRelativeTime(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
||||||
|
const amount = value / (1024 ** unit)
|
||||||
|
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
export default LocalAgentApp
|
export default LocalAgentApp
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue