feat: post-upgrade attestation — new binary proves the swap took
Old binary drops a marker (command_id, from/to) once the swap is committed, on both the helper path and the legacy path. New binary checks it at startup: running >= target just clears the marker (check-in confirm still owns success); short of target means the swap failed or rolled back, so it files a failed update_agent report under the original command_id and the server clears is_updating right away instead of sitting out the stuck-update timeout. Marker survives failed reports for retry, drops on 409 or after 24h.
This commit is contained in:
parent
1fa76bf665
commit
2f3363cbce
11 changed files with 304 additions and 102 deletions
|
|
@ -128,6 +128,11 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
return fmt.Errorf("failed to initialize command handler: %w", err)
|
||||
}
|
||||
|
||||
// Post-upgrade healthcheck: if the previous process restarted us as part of a
|
||||
// self-upgrade, verify this binary is the version the swap installed and
|
||||
// report a failed update_agent under the original command_id if not.
|
||||
handlers.RunUpgradeAttestation(apiClient, cfg, ackTracker)
|
||||
|
||||
// Initialize desktop manager (spawns Tauri system tray + local UI)
|
||||
desktopMgr := desktop.NewManager(
|
||||
"", // auto-detect binary alongside agent
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
}
|
||||
}
|
||||
}
|
||||
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, updateStartTime)
|
||||
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, commandID, version, updateStartTime)
|
||||
}
|
||||
|
||||
currentBinaryPath, err := getCurrentBinaryPath()
|
||||
|
|
@ -200,6 +200,12 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
updateSuccess = true
|
||||
log.Printf("[INFO] [agent] [upgrade] install_complete duration_seconds=%d", int(time.Since(updateStartTime).Seconds()))
|
||||
|
||||
// Binary is committed on disk — even if the restart dispatch below fails, the
|
||||
// next boot runs the new binary, so the marker stays valid either way.
|
||||
if err := WriteUpgradeAttestation(commandID, version); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [upgrade] restart_dispatch")
|
||||
if err := restartAgentService(); err != nil {
|
||||
// Binary is swapped on disk, but restart failed. The current process
|
||||
|
|
@ -217,7 +223,7 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
// and hands the helper the server-signed agent-self token. The helper verifies the
|
||||
// token signature and the staged binary's hash, backs up, installs, chmods, and
|
||||
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
|
||||
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, startTime time.Time) error {
|
||||
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, commandID, targetVersion string, startTime time.Time) error {
|
||||
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
|
||||
const upgradeStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
|
||||
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
|
||||
|
|
@ -238,7 +244,9 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
|
|||
|
||||
// Clean up staged binaries on failure. On success the helper restarts
|
||||
// us (SIGTERM) before we reach this defer, so the files are already
|
||||
// replaced and this is a harmless no-op.
|
||||
// replaced and this is a harmless no-op. The attestation marker is also
|
||||
// dropped on failure — no restart happened, so the next boot must not
|
||||
// attest against this command.
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
|
|
@ -247,6 +255,7 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
|
|||
log.Printf("[WARNING] [agent] [upgrade] staging_cleanup_failed path=%s error=%v", p, err)
|
||||
}
|
||||
}
|
||||
ClearUpgradeAttestation()
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -259,6 +268,13 @@ func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[s
|
|||
return fmt.Errorf("failed to parse capability_token: %w", err)
|
||||
}
|
||||
|
||||
// The marker must be on disk before the helper runs: on success the helper
|
||||
// restarts this process mid-Execute, and the post-upgrade healthcheck in the
|
||||
// new binary (RunUpgradeAttestation) is what verifies the swap actually took.
|
||||
if err := WriteUpgradeAttestation(commandID, targetVersion); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [upgrade] invoking_helper token_id=%s has_helper=%v", token.TokenID, hasHelper)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
|
|
|||
171
agent/internal/handlers/upgrade_attestation.go
Normal file
171
agent/internal/handlers/upgrade_attestation.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
)
|
||||
|
||||
// UpgradeAttestation is the marker the old binary writes immediately before the
|
||||
// self-upgrade restart. The new binary reads it at startup and attests whether
|
||||
// the process now running is actually the version the swap installed. Without
|
||||
// this, a swap that silently failed (or was rolled back from .bak) leaves the
|
||||
// old binary checking in normally and the server waiting out the full
|
||||
// stuck-update timeout before anyone notices.
|
||||
type UpgradeAttestation struct {
|
||||
CommandID string `json:"command_id"`
|
||||
FromVersion string `json:"from_version"`
|
||||
ToVersion string `json:"to_version"`
|
||||
InitiatedAt time.Time `json:"initiated_at"`
|
||||
}
|
||||
|
||||
// attestationMaxAge bounds marker retries. Past this the server's
|
||||
// reconcileAgentUpdates sweep has long since timed the command out, so a
|
||||
// report would only 409; drop the marker instead of retrying forever.
|
||||
const attestationMaxAge = 24 * time.Hour
|
||||
|
||||
func upgradeAttestationPath() string {
|
||||
return filepath.Join(constants.GetAgentStateDir(), "upgrade-attestation.json")
|
||||
}
|
||||
|
||||
// WriteUpgradeAttestation persists the pre-restart marker. Called by the
|
||||
// upgrade handler after the binary swap is committed (or handed to the helper)
|
||||
// and before the service restart.
|
||||
func WriteUpgradeAttestation(commandID, toVersion string) error {
|
||||
att := UpgradeAttestation{
|
||||
CommandID: commandID,
|
||||
FromVersion: version.Version,
|
||||
ToVersion: toVersion,
|
||||
InitiatedAt: time.Now().UTC(),
|
||||
}
|
||||
data, err := json.Marshal(att)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal upgrade attestation: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(upgradeAttestationPath(), data, 0o600); err != nil {
|
||||
return fmt.Errorf("write upgrade attestation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUpgradeAttestation removes the marker. Used on upgrade paths that fail
|
||||
// before the restart is dispatched — the next boot is not a post-upgrade boot.
|
||||
func ClearUpgradeAttestation() {
|
||||
if err := os.Remove(upgradeAttestationPath()); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_remove_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunUpgradeAttestation is the post-upgrade healthcheck, called once at agent
|
||||
// startup before the polling loop. If a marker exists, the running version is
|
||||
// checked against the upgrade target:
|
||||
//
|
||||
// - running >= target: the swap worked. Local log only — success closure is
|
||||
// deliberately owned by the server (version check-in confirm in
|
||||
// ReportMetrics + the reconcileAgentUpdates sweep), not this report.
|
||||
// - running < target: the swap failed or was rolled back. Report a failed
|
||||
// update_agent log under the original command_id — the server marks the
|
||||
// command failed, clears is_updating immediately, and journals a system
|
||||
// event, instead of the operator waiting out the stuck-update timeout.
|
||||
func RunUpgradeAttestation(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker) {
|
||||
data, err := os.ReadFile(upgradeAttestationPath())
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_read_failed error=%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var att UpgradeAttestation
|
||||
if err := json.Unmarshal(data, &att); err != nil {
|
||||
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_corrupt error=%v", err)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
|
||||
if versionAtLeast(version.Version, att.ToVersion) {
|
||||
log.Printf("[INFO] [agent] [upgrade] post_upgrade_attestation_ok running=%s target=%s command_id=%s",
|
||||
version.Version, att.ToVersion, att.CommandID)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[CRITICAL] [agent] [upgrade] post_upgrade_attestation_failed running=%s target=%s from=%s command_id=%s",
|
||||
version.Version, att.ToVersion, att.FromVersion, att.CommandID)
|
||||
|
||||
expired := time.Since(att.InitiatedAt) > attestationMaxAge
|
||||
|
||||
report := client.LogReport{
|
||||
CommandID: att.CommandID,
|
||||
Action: "update_agent",
|
||||
Result: "failed",
|
||||
Stderr: fmt.Sprintf(
|
||||
"post-upgrade attestation failed: running version %s, expected %s (was %s before the swap) — binary swap failed or was rolled back",
|
||||
version.Version, att.ToVersion, att.FromVersion),
|
||||
ExitCode: 1,
|
||||
Metadata: map[string]string{
|
||||
"subsystem_label": "Agent Update",
|
||||
"subsystem": "agent",
|
||||
"target_version": att.ToVersion,
|
||||
"running_version": version.Version,
|
||||
"attested_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
if err := ReportLogWithAck(apiClient, cfg, ackTracker, report); err != nil {
|
||||
// 409 = the command is already finalized server-side (timeout sweep or an
|
||||
// earlier report won the race) — the marker has nothing left to say.
|
||||
if strings.Contains(err.Error(), "409") {
|
||||
log.Printf("[INFO] [agent] [upgrade] attestation_already_finalized command_id=%s", att.CommandID)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
if expired {
|
||||
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker expired (>%v), dropping", err, attestationMaxAge)
|
||||
ClearUpgradeAttestation()
|
||||
return
|
||||
}
|
||||
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker retained for retry on next start", err)
|
||||
return
|
||||
}
|
||||
ClearUpgradeAttestation()
|
||||
}
|
||||
|
||||
// versionAtLeast reports whether running >= target, comparing dotted numeric
|
||||
// versions (leading "v" tolerated). Mirrors the server's IsNewerOrEqualVersion
|
||||
// check-in confirm: a running version past the target still proves the swap
|
||||
// took. Non-numeric segments fall back to string comparison.
|
||||
func versionAtLeast(running, target string) bool {
|
||||
r := strings.Split(strings.TrimPrefix(running, "v"), ".")
|
||||
t := strings.Split(strings.TrimPrefix(target, "v"), ".")
|
||||
for i := 0; i < len(r) || i < len(t); i++ {
|
||||
var rs, ts string
|
||||
if i < len(r) {
|
||||
rs = r[i]
|
||||
}
|
||||
if i < len(t) {
|
||||
ts = t[i]
|
||||
}
|
||||
rn, rErr := strconv.Atoi(rs)
|
||||
tn, tErr := strconv.Atoi(ts)
|
||||
if rErr != nil || tErr != nil {
|
||||
if rs == ts {
|
||||
continue
|
||||
}
|
||||
return rs > ts
|
||||
}
|
||||
if rn != tn {
|
||||
return rn > tn
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
26
agent/internal/handlers/upgrade_attestation_test.go
Normal file
26
agent/internal/handlers/upgrade_attestation_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionAtLeast(t *testing.T) {
|
||||
cases := []struct {
|
||||
running, target string
|
||||
want bool
|
||||
}{
|
||||
{"0.2.7.0", "0.2.7.0", true},
|
||||
{"v0.2.7.0", "0.2.7.0", true},
|
||||
{"0.2.7.0", "v0.2.7.0", true},
|
||||
{"0.2.7.1", "0.2.7.0", true}, // past the target still proves the swap
|
||||
{"0.2.8.0", "0.2.7.9", true},
|
||||
{"0.2.7.0", "0.2.7.1", false}, // rolled back / swap failed
|
||||
{"0.2.6.9", "0.2.7.0", false},
|
||||
{"0.2.10.0", "0.2.9.0", true}, // numeric, not lexicographic
|
||||
{"0.2.7", "0.2.7.0", false}, // shorter = missing segment treated as lower
|
||||
{"0.2.7.0", "0.2.7", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := versionAtLeast(c.running, c.target); got != c.want {
|
||||
t.Errorf("versionAtLeast(%q, %q) = %v, want %v", c.running, c.target, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,21 +153,14 @@ if [ ! -d "$AGENT_HOME" ]; then
|
|||
sudo mkdir -p "$AGENT_HOME"
|
||||
sudo mkdir -p "$AGENT_HOME/cache"
|
||||
sudo mkdir -p "$AGENT_HOME/state"
|
||||
sudo mkdir -p "$AGENT_HOME/localapi"
|
||||
sudo mkdir -p "$AGENT_CONFIG_DIR"
|
||||
sudo mkdir -p "$SERVER_KEY_DIR"
|
||||
sudo mkdir -p "$AGENT_LOG_DIR"
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -R "$AGENT_USER:$AGENT_USER" "$BASE_DIR"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME"
|
||||
sudo chmod 710 "$BASE_DIR"
|
||||
sudo chmod 710 "$AGENT_HOME"
|
||||
sudo chmod 750 "$AGENT_HOME/cache"
|
||||
sudo chmod 750 "$AGENT_HOME/state"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME/localapi"
|
||||
sudo chmod 750 "$AGENT_HOME/localapi"
|
||||
sudo chmod 755 "$AGENT_CONFIG_DIR"
|
||||
sudo chown "$AGENT_USER:$AGENT_USER" "$SERVER_KEY_DIR"
|
||||
sudo chmod 755 "$SERVER_KEY_DIR"
|
||||
|
|
@ -180,6 +173,19 @@ if [ ! -d "$AGENT_HOME" ]; then
|
|||
echo " - Logs: $AGENT_LOG_DIR"
|
||||
fi
|
||||
|
||||
# Local-API / tray access chain — enforced on EVERY run, not just first
|
||||
# install. Upgrades from pre-tray versions carry agent:agent 700 dirs that
|
||||
# block the ${LOCAL_API_GROUP} group from traversing to the socket, which
|
||||
# leaves the tray installed but unable to connect. Idempotent by design.
|
||||
# 710/750: group gets traverse only on the path, read on the socket dir;
|
||||
# the agent re-asserts socket dir/file ownership itself on startup.
|
||||
sudo mkdir -p "$AGENT_HOME/localapi"
|
||||
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR" "$AGENT_HOME" "$AGENT_HOME/localapi"
|
||||
sudo chmod 710 "$BASE_DIR"
|
||||
sudo chmod 710 "$AGENT_HOME"
|
||||
sudo chmod 750 "$AGENT_HOME/localapi"
|
||||
echo "✓ Local API access chain enforced ($BASE_DIR -> $AGENT_HOME/localapi, group $LOCAL_API_GROUP)"
|
||||
|
||||
# Step 4: Install sudoers configuration with OS-specific commands
|
||||
PM=$(detect_package_manager)
|
||||
echo "Detected package manager: $PM"
|
||||
|
|
@ -717,6 +723,13 @@ EOF
|
|||
echo "✓ Tray autostart entry installed (${XDG_AUTOSTART_DIR}/redflag-desktop.desktop)"
|
||||
fi
|
||||
|
||||
# Stock GNOME ships no tray host — without the AppIndicator extension
|
||||
# the icon never appears and the tray looks broken. Other desktops
|
||||
# (KDE, Hyprland/waybar, XFCE) support StatusNotifier out of the box.
|
||||
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
|
||||
echo "[INFO] [installer] [desktop] GNOME detected without the AppIndicator extension — install gnome-shell-extension-appindicator or the tray icon will not be visible"
|
||||
fi
|
||||
|
||||
# The local API socket lives under /var/lib/redflag (0710, group
|
||||
# ${LOCAL_API_GROUP}) — the tray cannot reach it unless its desktop
|
||||
# user is in the group. Add the invoking user; takes effect at next
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
|
|||
* MetricItem — a single system-info metric cell.
|
||||
*
|
||||
* Renders a label, value, optional icon, optional sub-text, and optional
|
||||
* progress bar. Designed to be an independent grid item inside SystemInfoGrid.
|
||||
* progress bar. Designed to be an independent grid or flex item.
|
||||
*
|
||||
* Usage:
|
||||
* <MetricItem label="CPU" value="Intel i7" icon={Cpu} sub="4 cores" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
|
|||
* Renders a compact table (Name, PID, CPU%, Mem%) when process data is
|
||||
* available, or a fallback message with the process count.
|
||||
*
|
||||
* Designed as an independent grid item inside SystemInfoGrid.
|
||||
* Designed as an independent grid or flex item.
|
||||
*/
|
||||
interface ProcessInfo {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { cn } from '@/lib/utils';
|
|||
* ScreenshotCard — screenshot thumbnail with sunshine overlay and capture state.
|
||||
*
|
||||
* Renders the agent's screenshot (or capture prompt), live/stream badges,
|
||||
* and timestamp. Designed as a grid item inside SystemInfoGrid with
|
||||
* col-start-1 row-span-2 to anchor top-left.
|
||||
* and timestamp. The image box keeps a 16:9 ratio at its natural size but
|
||||
* grows past it to absorb extra height — pass `className="grow"` from a
|
||||
* flex-col parent to make the card fill leftover vertical space.
|
||||
*
|
||||
* Handlers are passed in as props — this component doesn't own mutations.
|
||||
*/
|
||||
|
|
@ -49,10 +50,10 @@ const ScreenshotCard: React.FC<ScreenshotCardProps> = ({
|
|||
const canClick = !isCapturing && !isPolling;
|
||||
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className={cn('flex min-w-0 flex-col', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full overflow-hidden rounded border',
|
||||
'relative aspect-video w-full grow 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-1 text-slate-400',
|
||||
canClick && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* SystemInfoGrid — CSS Grid container for system information primitives.
|
||||
*
|
||||
* Items auto-place into a 2-column grid that collapses to 1 on narrow
|
||||
* viewports. The screenshot (or any anchor item) gets col-start-1
|
||||
* row-span-2 via its own className prop.
|
||||
*
|
||||
* Usage:
|
||||
* <SystemInfoGrid>
|
||||
* <ScreenshotCard className="col-start-1 row-span-2" ... />
|
||||
* <MetricItem label="CPU" ... />
|
||||
* <MetricItem label="Memory" ... />
|
||||
* <ProcessTable ... />
|
||||
* </SystemInfoGrid>
|
||||
*/
|
||||
interface SystemInfoGridProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SystemInfoGrid: React.FC<SystemInfoGridProps> = ({ children, className }) => (
|
||||
<div
|
||||
className={cn('grid gap-6', className)}
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gridAutoRows: 'min-content',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SystemInfoGrid;
|
||||
|
|
@ -6,3 +6,6 @@ export { default as PageState, PageSkeleton } from './PageState';
|
|||
export { default as Modal } from './Modal';
|
||||
export { default as Pagination } from './Pagination';
|
||||
export { default as StatCard, StatCardGroup } from './StatCard';
|
||||
export { default as ScreenshotCard } from './ScreenshotCard';
|
||||
export { default as MetricItem } from './MetricItem';
|
||||
export { default as ProcessTable } from './ProcessTable';
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ import {
|
|||
MonitorPlay,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
|
||||
import SystemInfoGrid from '@/components/primitives/SystemInfoGrid';
|
||||
import ScreenshotCard from '@/components/primitives/ScreenshotCard';
|
||||
import MetricItem from '@/components/primitives/MetricItem';
|
||||
import ProcessTable from '@/components/primitives/ProcessTable';
|
||||
import {
|
||||
SearchInput,
|
||||
FilterDropdown,
|
||||
PageState,
|
||||
ScreenshotCard,
|
||||
MetricItem,
|
||||
ProcessTable,
|
||||
} from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
|
||||
|
|
@ -825,7 +828,7 @@ const Agents: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* System info — independent primitives in a CSS Grid */}
|
||||
{/* System info — screenshot pane + metrics grid */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||
|
||||
|
|
@ -837,51 +840,51 @@ const Agents: React.FC = () => {
|
|||
const topProcesses = selectedAgent.metadata?.top_processes;
|
||||
|
||||
return (
|
||||
<SystemInfoGrid>
|
||||
<ScreenshotCard
|
||||
className="col-start-1 row-span-2"
|
||||
image={screenshotImage}
|
||||
isCapturing={captureScreenshotMutation.isPending}
|
||||
isPolling={screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status)}
|
||||
sunshine={sunshine ? { ...sunshine, state: resolveState(sunshine) } : undefined}
|
||||
screenshotStatus={screenshotCommand}
|
||||
onCapture={() => handleCaptureScreenshot(selectedAgent.id)}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
/>
|
||||
<MetricItem label="Platform" value={osInfo.platform} />
|
||||
<MetricItem
|
||||
label="Distribution"
|
||||
value={osInfo.distribution}
|
||||
sub={osInfo.version ? `(${osInfo.version})` : undefined}
|
||||
/>
|
||||
<MetricItem
|
||||
label="Architecture"
|
||||
value={selectedAgent.os_architecture || selectedAgent.architecture}
|
||||
/>
|
||||
<MetricItem label="CPU" value={meta.cpuModel} icon={Cpu} sub={`${meta.cpuCores} cores`} />
|
||||
{meta.memoryTotal > 0 && (
|
||||
<MetricItem label="Memory" value={formatBytes(meta.memoryTotal)} icon={MemoryStick} />
|
||||
)}
|
||||
{meta.diskTotal > 0 && (
|
||||
<MetricItem
|
||||
label={`Disk (${meta.diskMount})`}
|
||||
value={`${formatBytes(meta.diskUsed)} / ${formatBytes(meta.diskTotal)}`}
|
||||
icon={HardDrive}
|
||||
progress={{ used: meta.diskUsed, total: meta.diskTotal }}
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
{/* Left: screenshot grows to keep the panes level, platform identity below */}
|
||||
<div className="flex flex-col gap-3 md:w-[45%] md:shrink-0">
|
||||
<ScreenshotCard
|
||||
className="grow"
|
||||
image={screenshotImage}
|
||||
isCapturing={captureScreenshotMutation.isPending}
|
||||
isPolling={screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status)}
|
||||
sunshine={sunshine ? { ...sunshine, state: resolveState(sunshine) } : undefined}
|
||||
screenshotStatus={screenshotCommand}
|
||||
onCapture={() => handleCaptureScreenshot(selectedAgent.id)}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
/>
|
||||
)}
|
||||
{meta.processes !== 'Unknown' && (
|
||||
<MetricItem label="Running Processes" value={meta.processes} icon={GitBranch} />
|
||||
)}
|
||||
{meta.uptime !== 'Unknown' && (
|
||||
<MetricItem label="Uptime" value={meta.uptime} icon={Clock} />
|
||||
)}
|
||||
<ProcessTable
|
||||
processes={topProcesses}
|
||||
processCount={meta.processes}
|
||||
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
|
||||
/>
|
||||
</SystemInfoGrid>
|
||||
<MetricItem label="Platform" value={osInfo.platform} />
|
||||
<MetricItem label="Distribution" value={osInfo.distribution} sub={osInfo.version ? `(${osInfo.version})` : undefined} />
|
||||
<MetricItem label="Architecture" value={selectedAgent.os_architecture || selectedAgent.architecture} />
|
||||
</div>
|
||||
{/* Right: hardware metrics, ProcessTable spanning full width below */}
|
||||
<div className="grid flex-1 grid-cols-1 content-start gap-3 min-w-0 sm:grid-cols-2">
|
||||
<MetricItem label="CPU" value={meta.cpuModel} icon={Cpu} sub={`${meta.cpuCores} cores`} />
|
||||
{meta.memoryTotal > 0 && (
|
||||
<MetricItem label="Memory" value={formatBytes(meta.memoryTotal)} icon={MemoryStick} />
|
||||
)}
|
||||
{meta.diskTotal > 0 && (
|
||||
<MetricItem
|
||||
label={`Disk (${meta.diskMount})`}
|
||||
value={`${formatBytes(meta.diskUsed)} / ${formatBytes(meta.diskTotal)}`}
|
||||
icon={HardDrive}
|
||||
progress={{ used: meta.diskUsed, total: meta.diskTotal }}
|
||||
/>
|
||||
)}
|
||||
{meta.processes !== 'Unknown' && (
|
||||
<MetricItem label="Running Processes" value={meta.processes} icon={GitBranch} />
|
||||
)}
|
||||
{meta.uptime !== 'Unknown' && (
|
||||
<MetricItem label="Uptime" value={meta.uptime} icon={Clock} />
|
||||
)}
|
||||
<ProcessTable
|
||||
className="col-span-full"
|
||||
processes={topProcesses}
|
||||
processCount={meta.processes}
|
||||
onSeeMore={() => navigate(`/updates?agent=${selectedAgent.id}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue