Watch
1
0
Fork
You've already forked RedFlag
0

fix: README, .env.example, ErrorBoundary, client-logger, HEALTHCHECK, Docker hygiene

- README: version v0.2.6.8, corrected stale gate claim, updated changelog
- .env.example: merged two competing files into one, deleted bootstrap duplicate
- ErrorBoundary: new component wrapping app, prevents white-screen crashes
- Layout sidebar: version display from /api/health, Docs link to GitHub
- client-logger: debug/trace logger gated behind localStorage.redflag_debug=1,
  routes through existing /logs/client-error server endpoint (ETHOS #1)
- All web console.log calls rerouted through client-logger instead of deleted
- Server health endpoint returns version field
- Server accepts client_debug/client_trace in error_type validation
- Dockerfiles: pinned alpine:latest->3.21, nginx:alpine->1.27-alpine,
  added HEALTHCHECK directives
- docker-compose: healthcheck blocks for server and web services
- .dockerignore: created to slim Docker build context
This commit is contained in:
Fimeg 2026-06-08 18:23:39 -04:00
commit c0a717ab26
30 changed files with 5319 additions and 258 deletions

View file

@ -2,7 +2,7 @@
**Self-hosted update management for operators who own their stack.**
`v0.2.6.6` — June 2026 · MIT License
`v0.2.6.8` — June 2026 · MIT License
> **You're early — over 1,000 of you cloned this before it was announced.**
> A stable release is coming soon, bringing Windows support back fully gated.
@ -148,7 +148,7 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
## Status
**Compiles, runs on the maintainer's stack, not yet battle-tested.** No live deployment outside the dev environment. The gate (supply-chain verification) has not completed a full end-to-end run against real infrastructure. Treat everything below as "implemented and locally exercised, not production-proven."
**Compiles, runs on the maintainer's stack, not yet battle-tested.** No live deployment outside the dev environment. The supply-chain gate has completed an end-to-end run (2026-06-05: `hyprutils` through capability-token minting, helper verification, and install on a live Fedora agent). Treat everything below as "implemented and locally exercised, not production-proven."
**Implemented:**
- Linux and Windows agent registration and update management
@ -256,7 +256,7 @@ I am a Systems Architect with 25 years on the frontier. I build sovereign agent
See [CHANGELOG.md](CHANGELOG.md) for the full history. Recent highlights:
**v0.2.6.6** — Windows agent service logging treats `agent.log` as the primary sink, even when service console handles are unavailable.
**v0.2.6.8** — Dark/light tray app theme, desktop tray spine, prototype Tauri desktop app.
**v0.2.6.5** — Windows agent service logs now write to `C:\ProgramData\RedFlag\logs\agent.log`.

View file

@ -1,37 +0,0 @@
# RedFlag Bootstrap Configuration
# Copy this to ./config/.env and edit the values below
# PostgreSQL Configuration
POSTGRES_DB=redflag
POSTGRES_USER=redflag
POSTGRES_PASSWORD=redflag_bootstrap
# RedFlag Server Configuration
REDFLAG_SERVER_HOST=0.0.0.0
REDFLAG_SERVER_PORT=8080
REDFLAG_DB_HOST=postgres
REDFLAG_DB_PORT=5432
REDFLAG_DB_NAME=redflag
REDFLAG_DB_USER=redflag
REDFLAG_DB_PASSWORD=redflag_bootstrap
# Admin Configuration
REDFLAG_ADMIN_USER=admin
REDFLAG_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD
REDFLAG_JWT_SECRET=CHANGE_ME_JWT_SECRET_AT_LEAST_32_CHARS_LONG
# Token Configuration
REDFLAG_TOKEN_EXPIRY=24h
REDFLAG_MAX_TOKENS=100
REDFLAG_MAX_SEATS=10
# CORS Configuration (F-A3-14)
# Set to your dashboard URL in production (default: http://localhost:3000)
# REDFLAG_CORS_ORIGIN=https://your-dashboard-domain.com
# Observability Configuration (OBS-001A)
# Enable the authenticated Prometheus /metrics endpoint and set a dedicated
# scrape token. Future UI rotation stores only the SHA-256 hash in
# observability.metrics_token_hash; this plaintext env value is bootstrap-only.
# REDFLAG_OBSERVABILITY_METRICS_ENABLED=true
# REDFLAG_METRICS_TOKEN=CHANGE_ME_METRICS_TOKEN_AT_LEAST_32_CHARS_LONG

View file

@ -1,5 +1,5 @@
# ============================================================
# RedFlag .env Configuration
# RedFlag Configuration
# Copy this file to: config/.env
# Then run: docker-compose up -d
# ============================================================
@ -19,6 +19,8 @@ REDFLAG_DB_USER=redflag
REDFLAG_DB_PASSWORD=CHANGE_ME_pick_a_strong_db_password
# --- Admin Account ---
# After first-run setup these are persisted in the database;
# the .env values re-apply on restart.
REDFLAG_ADMIN_USER=admin
REDFLAG_ADMIN_EMAIL=admin@example.com
REDFLAG_ADMIN_PASSWORD=CHANGE_ME_pick_a_strong_admin_password
@ -34,6 +36,7 @@ REDFLAG_MAX_SEATS=50
# and click "Generate Keys". Copy the private key here, then
# restart the server. Without this, agents cannot receive
# signed commands or upgrade themselves.
# BACKUP THE PRIVATE KEY. Losing it means re-enrolling every agent.
REDFLAG_SIGNING_PRIVATE_KEY=
# --- Public URL (optional) ---
@ -52,5 +55,16 @@ REDFLAG_BINARY_STORAGE_PATH=./binaries
# REDFLAG_TLS_CERT_FILE=/path/to/cert.pem
# REDFLAG_TLS_KEY_FILE=/path/to/key.pem
# --- CORS (F-A3-14) ---
# Set to your dashboard URL in production (default: http://localhost:3000)
# REDFLAG_CORS_ORIGIN=https://your-dashboard-domain.com
# --- Observability (OBS-001A) ---
# Enable the authenticated Prometheus /metrics endpoint and set a dedicated
# scrape token. Future UI rotation stores only the SHA-256 hash in
# observability.metrics_token_hash; this plaintext env value is bootstrap-only.
# REDFLAG_OBSERVABILITY_METRICS_ENABLED=true
# REDFLAG_METRICS_TOKEN=CHANGE_ME_METRICS_TOKEN_AT_LEAST_32_CHARS_LONG
# --- Debug (disable in production) ---
REDFLAG_DEBUG=false

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,12 @@ services:
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
ports:
- "31337:8080"
command: ["./redflag-server"]
@ -45,7 +51,14 @@ services:
ports:
- "31336:80"
depends_on:
- server
server:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped
volumes:

View file

@ -74,7 +74,7 @@ RUN cargo build --release && \
cp target/release/redflag-helper /out/helper-linux-amd64/redflag-helper
# Stage 3: Final image with server and all agent binaries
FROM alpine:latest
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata bash
WORKDIR /app
@ -92,10 +92,12 @@ COPY --from=agent-builder /build/binaries ./binaries
COPY --from=helper-builder /out/helper-linux-amd64 ./binaries/helper-linux-amd64
# Copy and setup entrypoint script
# File is in server/ directory relative to build context
COPY server/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]

View file

@ -486,10 +486,16 @@ func main() {
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
c.JSON(200, gin.H{
"status": "healthy",
"version": version.AgentVersion,
})
})
router.GET("/api/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
c.JSON(200, gin.H{
"status": "healthy",
"version": version.AgentVersion,
})
})
// API routes

View file

@ -125,7 +125,7 @@ func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
// LogErrorRequest represents a client error log entry
type LogErrorRequest struct {
Subsystem string `json:"subsystem" binding:"required"`
ErrorType string `json:"error_type" binding:"required,oneof=javascript_error api_error ui_error validation_error"`
ErrorType string `json:"error_type" binding:"required,oneof=javascript_error api_error ui_error validation_error client_debug client_trace"`
Message string `json:"message" binding:"required,max=10000"`
StackTrace string `json:"stack_trace,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`

View file

@ -24,17 +24,18 @@ func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.
// DashboardStats represents dashboard statistics
type DashboardStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
VulnerablePackages int `json:"vulnerable_packages"`
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
SecurityUpdateCount int `json:"security_update_count"` // distinct advisories in available-version OSV check
InstalledCVECount int `json:"installed_cve_count"` // distinct advisories in installed-version OSV check
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
}
// GetDashboardStats returns dashboard statistics using aggregate queries (F-B1-6 fix)
@ -71,10 +72,13 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
stats.LowUpdates = updateStats.LowUpdates
}
// Vulnerable packages (OSV.dev findings on non-terminal updates)
vulnCount, err := h.updateQueries.GetVulnerablePackageCount()
if err == nil {
stats.VulnerablePackages = vulnCount
// Remediation count — distinct advisories in available-version OSV check.
if n, err := h.updateQueries.GetSecurityUpdateCount(); err == nil {
stats.SecurityUpdateCount = n
}
// Threat count — distinct advisories in installed-version OSV check.
if n, err := h.updateQueries.GetInstalledCVECount(); err == nil {
stats.InstalledCVECount = n
}
c.JSON(http.StatusOK, stats)

View file

@ -460,39 +460,69 @@ func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
pkgType, pkgName, version string
}
seen := make(map[dedupKey]bool)
freshByAgent := make(map[uuid.UUID]map[string]bool)
// freshByAgent caches freshness results per agent per namespace to avoid
// repeated DB queries within one batch.
type agentNS struct {
id uuid.UUID
namespace string
}
freshByAgent := make(map[agentNS]map[string]bool)
getFresh := func(agentID uuid.UUID, ns string) map[string]bool {
key := agentNS{agentID, ns}
if m, ok := freshByAgent[key]; ok {
return m
}
f, err := q.FreshSupplyChainPackages(agentID, services.OSVRecheckInterval, ns)
if err != nil {
log.Printf("[WARNING] [supply_chain] fresh_query_failed agent=%s namespace=%s error=%v", agentID, ns, err)
f = map[string]bool{}
}
freshByAgent[key] = f
return f
}
var reqs []services.OSVCheckRequest
for _, e := range events {
if !services.NeedsSupplyChainCheck(e.PackageType) {
continue
}
// Remediation check — available version.
k := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionTo}
if seen[k] {
continue
}
seen[k] = true
fresh, ok := freshByAgent[e.AgentID]
if !ok {
f, err := q.FreshSupplyChainPackages(e.AgentID, services.OSVRecheckInterval)
if err != nil {
log.Printf("[WARNING] [supply_chain] fresh_query_failed agent=%s error=%v", e.AgentID, err)
f = map[string]bool{}
if !seen[k] {
seen[k] = true
if !getFresh(e.AgentID, "")[e.PackageType+"\x00"+e.PackageName] {
reqs = append(reqs, services.OSVCheckRequest{
AgentID: e.AgentID,
PkgType: e.PackageType,
PkgName: e.PackageName,
Version: e.VersionTo,
})
}
fresh = f
freshByAgent[e.AgentID] = fresh
}
if fresh[e.PackageType+"\x00"+e.PackageName] {
continue
}
reqs = append(reqs, services.OSVCheckRequest{
AgentID: e.AgentID,
PkgType: e.PackageType,
PkgName: e.PackageName,
Version: e.VersionTo,
})
// Threat check — installed version. Skip if blank or same as available
// (no new information) or already fresh.
if e.VersionFrom == "" || e.VersionFrom == e.VersionTo {
continue
}
ki := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionFrom}
if seen[ki] {
continue
}
seen[ki] = true
if !getFresh(e.AgentID, "installed")[e.PackageType+"\x00"+e.PackageName] {
reqs = append(reqs, services.OSVCheckRequest{
AgentID: e.AgentID,
PkgType: e.PackageType,
PkgName: e.PackageName,
Version: e.VersionFrom,
Namespace: "installed",
})
}
}
if len(reqs) == 0 {

View file

@ -1215,24 +1215,52 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
return stats, nil
}
// GetVulnerablePackageCount returns the number of distinct packages that have
// known CVEs from OSV.dev. A package is counted if its metadata contains a
// non-empty, non-null supply_chain_vulns field.
func (q *UpdateQueries) GetVulnerablePackageCount() (int, error) {
// GetSecurityUpdateCount returns the number of distinct OSV advisory IDs present
// in supply_chain_vulns across all non-terminal package rows. Deduplicates by
// advisory ID so sub-packages sharing one advisory count as one, not many.
func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
query := `
SELECT COUNT(*) FROM (
SELECT DISTINCT package_type, package_name
FROM current_package_state
WHERE metadata IS NOT NULL
AND metadata ? 'supply_chain_vulns'
AND metadata->>'supply_chain_vulns' != '[]'
AND metadata->>'supply_chain_vulns' != ''
AND status NOT IN ('installed', 'ignored', 'failed')
) vuln_packages
SELECT COUNT(DISTINCT vuln->>'id')
FROM current_package_state,
jsonb_array_elements(
CASE WHEN jsonb_typeof((metadata->>'supply_chain_vulns')::jsonb) = 'array'
THEN (metadata->>'supply_chain_vulns')::jsonb
ELSE '[]'::jsonb
END
) AS vuln
WHERE metadata IS NOT NULL
AND metadata ? 'supply_chain_vulns'
AND metadata->>'supply_chain_vulns' NOT IN ('[]', '')
AND status NOT IN ('installed', 'ignored', 'failed')
`
var count int
if err := q.db.Get(&count, query); err != nil {
return 0, fmt.Errorf("failed to get vulnerable package count: %w", err)
return 0, fmt.Errorf("get security update count: %w", err)
}
return count, nil
}
// GetInstalledCVECount returns the number of distinct OSV advisory IDs found
// in installed_vulns — the threat check against the currently-installed version.
// Only counts rows in active states (not installed/ignored/failed).
func (q *UpdateQueries) GetInstalledCVECount() (int, error) {
query := `
SELECT COUNT(DISTINCT vuln->>'id')
FROM current_package_state,
jsonb_array_elements(
CASE WHEN jsonb_typeof((metadata->>'installed_vulns')::jsonb) = 'array'
THEN (metadata->>'installed_vulns')::jsonb
ELSE '[]'::jsonb
END
) AS vuln
WHERE metadata IS NOT NULL
AND metadata ? 'installed_vulns'
AND metadata->>'installed_vulns' NOT IN ('[]', '')
AND status NOT IN ('installed', 'ignored', 'failed')
`
var count int
if err := q.db.Get(&count, query); err != nil {
return 0, fmt.Errorf("get installed CVE count: %w", err)
}
return count, nil
}
@ -1724,16 +1752,21 @@ func (q *UpdateQueries) RecordClosureCheckError(id uuid.UUID, reason string) err
}
// FreshSupplyChainPackages returns the set of (package_type, package_name) for
// an agent whose stored supply-chain result is newer than within. Used by the
// scan path to skip packages already checked recently. Keyed "type\x00name".
func (q *UpdateQueries) FreshSupplyChainPackages(agentID uuid.UUID, within time.Duration) (map[string]bool, error) {
// an agent whose stored supply-chain result is newer than within. namespace
// selects which timestamp to consult: "" or "remediation" → supply_chain_checked_at,
// "installed" → installed_checked_at. Keyed "type\x00name".
func (q *UpdateQueries) FreshSupplyChainPackages(agentID uuid.UUID, within time.Duration, namespace string) (map[string]bool, error) {
checkedKey := "supply_chain_checked_at"
if namespace == "installed" {
checkedKey = "installed_checked_at"
}
query := `
SELECT package_type, package_name
FROM current_package_state
WHERE agent_id = $1
AND metadata ? 'supply_chain_checked_at'
AND (metadata->>'supply_chain_checked_at')::timestamptz > NOW() - make_interval(secs => $2)`
rows, err := q.db.Query(query, agentID, within.Seconds())
AND metadata ? $3
AND (metadata->>$3)::timestamptz > NOW() - make_interval(secs => $2)`
rows, err := q.db.Query(query, agentID, within.Seconds(), checkedKey)
if err != nil {
return nil, fmt.Errorf("query fresh supply-chain packages: %w", err)
}

View file

@ -178,11 +178,15 @@ const osvBatchesInFlight = 4
var osvBatchSem = make(chan struct{}, osvBatchesInFlight)
// OSVCheckRequest is one package to check against OSV.dev.
// Namespace routes results to different metadata keys:
// - "" or "remediation" → supply_chain_checked_at / supply_chain_vulns (available-version check)
// - "installed" → installed_checked_at / installed_vulns (installed-version threat check)
type OSVCheckRequest struct {
AgentID uuid.UUID
PkgType string
PkgName string
Version string
AgentID uuid.UUID
PkgType string
PkgName string
Version string
Namespace string // "" = remediation (default)
}
// OSVStoreFunc persists a supply-chain result (or a recorded failure) for one
@ -296,9 +300,10 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
now := time.Now().UTC()
for i, r := range reqs {
result := batchResp.Results[i]
checkedKey, vulnsKey, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
"supply_chain_checked_at": now.Format(time.RFC3339),
"supply_chain_check_error": nil,
checkedKey: now.Format(time.RFC3339),
errorKey: nil,
}
if len(result.Vulns) > 0 {
@ -307,12 +312,12 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
continue
}
meta["supply_chain_vulns"] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found pkg=%s type=%s ecosystem=%s count=%d",
r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(result.Vulns))
meta[vulnsKey] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found namespace=%s pkg=%s type=%s ecosystem=%s count=%d",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(result.Vulns))
} else {
log.Printf("[INFO] [supply_chain] clean pkg=%s type=%s ecosystem=%s version=%s",
r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
log.Printf("[INFO] [supply_chain] clean namespace=%s pkg=%s type=%s ecosystem=%s version=%s",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
@ -321,14 +326,23 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
}
}
// osvMetaKeys returns the metadata key names for a given check namespace.
func osvMetaKeys(namespace string) (checkedAt, vulns, checkError string) {
if namespace == "installed" {
return "installed_checked_at", "installed_vulns", "installed_check_error"
}
return "supply_chain_checked_at", "supply_chain_vulns", "supply_chain_check_error"
}
// recordBatchFailure persists a failure record for every request in a batch
// that could not be checked (HTTP error, decode failure, count mismatch).
// No checked_at is set so these packages are retried next cycle.
func recordBatchFailure(reqs []OSVCheckRequest, store OSVStoreFunc) {
for _, r := range reqs {
_, _, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
"supply_chain_check_error": "osv_batch_failed",
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
errorKey: "osv_batch_failed",
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] failure_record_failed pkg=%s error=%v", r.PkgName, err)

View file

@ -16,7 +16,10 @@ COPY . .
RUN npx vite build
# Production stage
FROM nginx:alpine
FROM nginx:1.27-alpine
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html

229
web/package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.15.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
@ -23,6 +24,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
@ -674,6 +676,233 @@
"react": "^18 || ^19"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",

View file

@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { useAuthStore, useUIStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import ErrorBoundary from '@/components/ErrorBoundary';
import Layout from '@/components/Layout';
import Dashboard from '@/pages/Dashboard';
import Agents from '@/pages/Agents';
@ -77,6 +78,7 @@ const App: React.FC = () => {
}, [token]);
return (
<ErrorBoundary>
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
{/* Toast notifications */}
<Toaster
@ -161,6 +163,7 @@ const App: React.FC = () => {
/>
</Routes>
</div>
</ErrorBoundary>
);
};

View file

@ -9,6 +9,7 @@ import { formatBytes, formatRelativeTime } from '@/lib/utils';
import { agentApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { clientLogger } from '@/lib/client-logger';
interface AgentStorageProps {
agentId: string;
@ -61,16 +62,14 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
const { data: storageData, refetch: refetchStorage, error: storageError, isLoading } = useQuery({
queryKey: ['storage-metrics', agentId],
queryFn: async () => {
console.log('[DEBUG] Fetching storage metrics for agent:', agentId);
clientLogger.debug('Fetching storage metrics for agent:', { agentId });
try {
const result = await agentApi.getStorageMetrics(agentId);
console.log('[DEBUG] Storage metrics result:', result);
console.log('[DEBUG] Result has metrics prop:', 'metrics' in result);
console.log('[DEBUG] Result.metrics length:', result.metrics?.length || 0);
clientLogger.debug('Storage metrics result received', { agentId, hasMetrics: 'metrics' in result, count: result.metrics?.length || 0 });
setLastRefreshed(new Date());
return result;
} catch (err) {
console.error('[DEBUG] Error fetching storage metrics:', err);
clientLogger.debug('Error fetching storage metrics', { agentId, error: err instanceof Error ? err.message : String(err) });
throw err;
}
},
@ -146,15 +145,8 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
};
// Debug what we're rendering
console.log('[AgentStorage] Rendering with storageData:', storageData);
console.log('[AgentStorage] agentData:', agentData);
console.log('[AgentStorage] error:', storageError);
console.log('[AgentStorage] isLoading:', isLoading);
// Show API error if request failed
if (storageError) {
console.error('[AgentStorage] API Error:', storageError);
return (
<div className="space-y-4">
<div className="alert alert-danger rounded-md">
@ -197,13 +189,8 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
const disks = parseDiskInfo();
// Debug disk parsing
console.log('[AgentStorage] Parsed disks:', disks);
console.log('[AgentStorage] storageMetrics:', storageMetrics);
// Show error if no data
if (!storageData || !storageData.metrics || storageData.metrics.length === 0) {
console.log('[AgentStorage] No storage data available');
return (
<div className="space-y-4">
<div className="alert alert-warning rounded-md">

View file

@ -15,6 +15,7 @@ import { agentApi, updateApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn, versionCompare } from '@/lib/utils';
import { Agent } from '@/types';
import { clientLogger } from '@/lib/client-logger';
interface AgentUpdatesModalProps {
isOpen: boolean;
@ -123,7 +124,7 @@ export function AgentUpdatesModal({
// Generate nonce for security
const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version);
console.log('[UI] Update nonce generated for single agent:', nonceData);
clientLogger.debug('Update nonce generated for single agent', { agentId, version: pkg.version });
// Use individual update endpoint with nonce
return agentApi.updateAgent(agentId, {

View file

@ -50,13 +50,27 @@ const AttentionPanel: React.FC = () => {
});
}
// 1b. Packages with known CVEs (OSV.dev findings)
if (stats && stats.vulnerable_packages > 0) {
// 1b. Installed version has known CVEs — this is the threat number.
if (stats && stats.installed_cve_count > 0) {
const n = stats.installed_cve_count;
alerts.push({
key: 'vulnerable-packages',
key: 'installed-cves',
severity: 'vuln',
title: `${stats.vulnerable_packages} package${stats.vulnerable_packages === 1 ? '' : 's'} with known CVEs`,
detail: 'Review vulnerabilities before approving updates',
title: `${n} known CVE${n === 1 ? '' : 's'} in installed packages`,
detail: 'Installed versions have active advisories — patch to remediate',
href: '/updates',
icon: ShieldAlert,
});
}
// 1c. Security updates available — remediation framing, not threat.
if (stats && stats.security_update_count > 0) {
const n = stats.security_update_count;
alerts.push({
key: 'security-updates',
severity: 'patch',
title: `${n} security update${n === 1 ? '' : 's'} available`,
detail: 'Updates carry security advisories — review and approve to apply',
href: '/updates?vuln=true',
icon: ShieldAlert,
});

View file

@ -0,0 +1,61 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('[ErrorBoundary] Uncaught error:', error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
<div className="text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
</h2>
<p className="text-gray-600 mb-6 text-sm">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="btn btn-primary"
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
@ -15,6 +15,7 @@ import {
RefreshCw,
Container,
Bell,
BookOpen,
} from 'lucide-react';
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
import { cn, formatRelativeTime } from '@/lib/utils';
@ -33,9 +34,25 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
const [searchQuery, setSearchQuery] = useState('');
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
const [serverVersion, setServerVersion] = useState<string | null>(null);
const unreadCount = notifications.filter(n => !n.read).length;
// Fetch server version from health endpoint
useEffect(() => {
let cancelled = false;
// Use a direct fetch to avoid requiring auth for health
fetch('/api/health')
.then(res => res.json())
.then(data => {
if (!cancelled && data?.version) {
setServerVersion(data.version);
}
})
.catch(() => { /* health endpoint may not be reachable yet */ });
return () => { cancelled = true; };
}, []);
const navigation = [
{
name: 'Dashboard',
@ -176,14 +193,31 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
</nav>
{/* User section */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
>
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
Logout
</button>
<div className="absolute bottom-0 left-0 right-0 border-t border-gray-200">
{/* Version display */}
{serverVersion && (
<div className="px-4 py-2 text-xs text-gray-400 text-center border-b border-gray-100">
v{serverVersion}
</div>
)}
<div className="p-4 space-y-1">
<a
href="https://github.com/Fimeg/RedFlag"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
>
<BookOpen className="mr-3 h-5 w-5 text-gray-400" />
Docs
</a>
<button
onClick={handleLogout}
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
>
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
Logout
</button>
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { setupApi } from '@/lib/api';
import { clientLogger } from '@/lib/client-logger';
interface SetupCompletionCheckerProps {
children: React.ReactNode;
@ -34,13 +35,13 @@ export const SetupCompletionChecker: React.FC<SetupCompletionCheckerProps> = ({
}
if (wasInSetup && !currentSetupMode) {
console.log('Setup completed - redirecting to login');
clientLogger.debug('Setup completed — redirecting to login');
navigate('/login', { replace: true });
return;
}
} catch (error) {
if (wasInSetup) {
console.log('Setup completed (endpoint unreachable) - redirecting to login');
clientLogger.debug('Setup completed (endpoint unreachable) — redirecting to login');
navigate('/login', { replace: true });
return;
}

View file

@ -19,6 +19,7 @@ import {
} from 'lucide-react';
import { useSecurityEvents, useSecurityWebSocket } from '@/hooks/useSecuritySettings';
import { SecurityEvent, EventFilters } from '@/types/security';
import { clientLogger } from '@/lib/client-logger';
const SecurityEvents: React.FC = () => {
const [filters, setFilters] = useState<EventFilters>({});
@ -96,7 +97,7 @@ const SecurityEvents: React.FC = () => {
// Export events
const exportEvents = async (format: 'json' | 'csv') => {
// Implementation would call API to export events
console.log(`Exporting events as ${format}`);
clientLogger.debug('Exporting events', { format });
};
// Clear filters

View file

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { agentApi } from '@/lib/api';
import { Agent } from '@/types';
import { clientLogger } from '@/lib/client-logger';
interface UseAgentUpdateReturn {
checkForUpdate: (agentId: string) => Promise<void>;
@ -69,8 +70,7 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
// Step 2: Generate nonce for authorized update
const nonceData = await agentApi.generateUpdateNonce(agent.id, targetVersion);
console.log('[UI] Update nonce generated:', nonceData);
clientLogger.debug('Update nonce generated', { agentId: agent.id, targetVersion });
// Step 3: Trigger the actual update
const updateResponse = await agentApi.updateAgent(agent.id, {
@ -87,8 +87,8 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
// Step 5: Refresh agent data in cache
queryClient.invalidateQueries({ queryKey: ['agents'] });
clientLogger.debug('Update initiated successfully', { agentId: agent.id, targetVersion });
console.log('[UI] Update initiated successfully:', updateResponse);
} catch (error) {
console.error('[UI] Update failed:', error);

View file

@ -11,6 +11,7 @@ import {
KeyRotationResponse,
MachineFingerprint
} from '@/types/security';
import { clientLogger } from '@/lib/client-logger';
// Default security settings
const defaultSecuritySettings: SecuritySettings = {
@ -349,7 +350,7 @@ export const useSecurityWebSocket = () => {
ws.current.onopen = () => {
setConnected(true);
console.log('Security WebSocket connected');
clientLogger.debug('Security WebSocket connected');
};
ws.current.onmessage = (event) => {
@ -370,7 +371,7 @@ export const useSecurityWebSocket = () => {
ws.current.onclose = () => {
setConnected(false);
console.log('Security WebSocket disconnected');
clientLogger.debug('Security WebSocket disconnected');
// Attempt to reconnect after 5 seconds
setTimeout(() => {

View file

@ -0,0 +1,56 @@
/**
* ClientLogger sends debug/trace signals to the server-side client_errors table.
*
* ETHOS #1: Errors are History. Debug/trace signals are not errors, but they
* belong in the system's logging infrastructure, not on console.log where they
* spam operators. This module routes them through the same /logs/client-error
* endpoint as real errors, just tagged with client_debug / client_trace so they
* can be filtered separately.
*
* Toggle: set localStorage['redflag_debug'] = '1' to enable, or unset to
* suppress. The UI could grow a toggle in Settings General later.
*/
import { api } from './api';
const isDebug = (): boolean => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('redflag_debug') === '1';
};
function getSubsystem(): string {
if (typeof window === 'undefined') return 'unknown';
const path = window.location.pathname;
if (path.startsWith('/agents')) return 'agents';
if (path.startsWith('/updates') || path.startsWith('/staging')) return 'updates';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/history')) return 'history';
if (path.startsWith('/settings')) return 'settings';
return 'ui';
}
export const clientLogger = {
debug: (message: string, metadata?: Record<string, unknown>) => {
if (!isDebug()) return;
api.post('/logs/client-error', {
subsystem: getSubsystem(),
error_type: 'client_debug',
message: message.substring(0, 5000),
metadata: metadata ?? {},
url: window.location.href,
}).catch(() => {
// Best effort — don't let logging failures cascade
});
},
trace: (message: string, metadata?: Record<string, unknown>) => {
if (!isDebug()) return;
api.post('/logs/client-error', {
subsystem: getSubsystem(),
error_type: 'client_trace',
message: message.substring(0, 5000),
metadata: metadata ?? {},
url: window.location.href,
}).catch(() => {});
},
};

View file

@ -816,23 +816,25 @@ const Agents: React.FC = () => {
)}
</div>
{/* System info — screenshot square lives in the card header */}
{/* System info — screenshot left, stats right, top processes below */}
<div className="card">
{(() => {
const integrations = readIntegrations(selectedAgent.metadata);
const sunshine = integrations.sunshine;
const state = resolveState(sunshine);
const live = state === 'active';
const sunshineReady = live || state === 'running';
const isCapturing = captureScreenshotMutation.isPending;
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
const hasImage = !!screenshotImage;
const canClick = !isCapturing && !isPolling;
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
return (
<div className="flex items-start justify-between mb-4 gap-4">
<h2 className="text-lg font-medium text-gray-900">System Information</h2>
<div className="shrink-0 w-48">
<div className="flex gap-6">
{/* Screenshot — left, fixed width */}
{(() => {
const integrations = readIntegrations(selectedAgent.metadata);
const sunshine = integrations.sunshine;
const state = resolveState(sunshine);
const live = state === 'active';
const sunshineReady = live || state === 'running';
const isCapturing = captureScreenshotMutation.isPending;
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
const hasImage = !!screenshotImage;
const canClick = !isCapturing && !isPolling;
return (
<div className="shrink-0 w-[260px]">
<div
className={cn(
'relative aspect-video w-full overflow-hidden rounded border',
@ -891,125 +893,79 @@ const Agents: React.FC = () => {
: null}
</div>
</div>
</div>
);
})()}
);
})()}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic System Info */}
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">Platform</p>
<p className="text-sm font-medium text-gray-900">
{(() => {
const osInfo = parseOSInfo(selectedAgent);
return osInfo.platform;
})()}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Distribution</p>
<p className="text-sm font-medium text-gray-900">
{(() => {
const osInfo = parseOSInfo(selectedAgent);
return osInfo.distribution;
})()}
</p>
{(() => {
const osInfo = parseOSInfo(selectedAgent);
if (osInfo.version) {
return (
<p className="text-xs text-gray-500 mt-1">
Version: {osInfo.version}
</p>
);
}
return null;
})()}
</div>
<div>
<p className="text-sm text-gray-600">Architecture</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.os_architecture || selectedAgent.architecture}
</p>
</div>
</div>
{/* Hardware Specs */}
<div className="space-y-4">
{/* All stats — right column, stacked flush top */}
<div className="flex-1 space-y-3 min-w-0">
{(() => {
const osInfo = parseOSInfo(selectedAgent);
const meta = getSystemMetadata(selectedAgent);
return (
<>
<div>
<p className="text-sm text-gray-600 flex items-center">
<Cpu className="h-4 w-4 mr-1" />
CPU
</p>
<p className="text-xs text-gray-500">Platform</p>
<p className="text-sm font-medium text-gray-900">{osInfo.platform}</p>
</div>
<div>
<p className="text-xs text-gray-500">Distribution</p>
<p className="text-sm font-medium text-gray-900">
{meta.cpuModel}
</p>
<p className="text-xs text-gray-500">
{meta.cpuCores} cores
{osInfo.distribution}
{osInfo.version && <span className="text-xs text-gray-500 ml-1">({osInfo.version})</span>}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Architecture</p>
<p className="text-sm font-medium text-gray-900">
{selectedAgent.os_architecture || selectedAgent.architecture}
</p>
</div>
<div>
<p className="text-xs text-gray-500 flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</p>
<p className="text-sm font-medium text-gray-900">{meta.cpuModel}</p>
<p className="text-xs text-gray-500">{meta.cpuCores} cores</p>
</div>
{meta.memoryTotal > 0 && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<MemoryStick className="h-4 w-4 mr-1" />
Memory
</p>
<p className="text-sm font-medium text-gray-900">
{formatBytes(meta.memoryTotal)}
<p className="text-xs text-gray-500 flex items-center gap-1">
<MemoryStick className="h-3 w-3" /> Memory
</p>
<p className="text-sm font-medium text-gray-900">{formatBytes(meta.memoryTotal)}</p>
</div>
)}
{meta.diskTotal > 0 && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<HardDrive className="h-4 w-4 mr-1" />
Disk ({meta.diskMount})
<p className="text-xs text-gray-500 flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Disk ({meta.diskMount})
</p>
<p className="text-sm font-medium text-gray-900">
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
></div>
/>
</div>
<p className="text-xs text-gray-500">
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
</p>
<p className="text-xs text-gray-500">{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used</p>
</div>
)}
{meta.processes !== 'Unknown' && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<GitBranch className="h-4 w-4 mr-1" />
Running Processes
</p>
<p className="text-sm font-medium text-gray-900">
{meta.processes}
<p className="text-xs text-gray-500 flex items-center gap-1">
<GitBranch className="h-3 w-3" /> Running Processes
</p>
<p className="text-sm font-medium text-gray-900">{meta.processes}</p>
</div>
)}
{meta.uptime !== 'Unknown' && (
<div>
<p className="text-sm text-gray-600 flex items-center">
<Clock className="h-4 w-4 mr-1" />
Uptime
</p>
<p className="text-sm font-medium text-gray-900">
{meta.uptime}
<p className="text-xs text-gray-500 flex items-center gap-1">
<Clock className="h-3 w-3" /> Uptime
</p>
<p className="text-sm font-medium text-gray-900">{meta.uptime}</p>
</div>
)}
</>
@ -1017,6 +973,54 @@ const Agents: React.FC = () => {
})()}
</div>
</div>
{/* Top Processes — below the split */}
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-900 flex items-center gap-1">
<Activity className="h-4 w-4" /> Top Processes
</p>
<button
onClick={() => navigate(`/updates?agent=${selectedAgent.id}`)}
className="text-xs text-blue-600 hover:text-blue-800"
>
See More
</button>
</div>
{(() => {
const meta = getSystemMetadata(selectedAgent);
const topProcesses = selectedAgent.metadata?.top_processes;
if (topProcesses && Array.isArray(topProcesses) && topProcesses.length > 0) {
return (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b border-gray-100">
<th className="text-left py-1 font-medium">Name</th>
<th className="text-right py-1 font-medium">PID</th>
<th className="text-right py-1 font-medium">CPU%</th>
<th className="text-right py-1 font-medium">Mem%</th>
</tr>
</thead>
<tbody>
{topProcesses.slice(0, 5).map((proc: any, i: number) => (
<tr key={proc.pid || i} className="border-b border-gray-50 last:border-0">
<td className="py-1 text-gray-900 font-medium truncate max-w-[160px]">{proc.name}</td>
<td className="py-1 text-right text-gray-600">{proc.pid}</td>
<td className="py-1 text-right text-gray-600">{proc.cpu != null ? `${proc.cpu.toFixed(1)}%` : '—'}</td>
<td className="py-1 text-right text-gray-600">{proc.mem != null ? `${proc.mem.toFixed(1)}%` : '—'}</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<p className="text-xs text-gray-400 italic">
Process details not reported by this agent. Showing count: {meta.processes}
</p>
);
})()}
</div>
</div>
</div>
)}

View file

@ -258,7 +258,8 @@ export interface DashboardStats {
approved_updates: number;
installed_updates: number;
failed_updates: number;
vulnerable_packages: number;
security_update_count: number; // distinct advisories on available version
installed_cve_count: number; // distinct advisories on installed version (threat)
critical_updates: number;
high_updates: number;
medium_updates: number;