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:
parent
80e719acc9
commit
c0a717ab26
30 changed files with 5319 additions and 258 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1
desktop/gen/schemas/acl-manifests.json
Normal file
1
desktop/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
desktop/gen/schemas/capabilities.json
Normal file
1
desktop/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
2292
desktop/gen/schemas/desktop-schema.json
Normal file
2292
desktop/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
2292
desktop/gen/schemas/linux-schema.json
Normal file
2292
desktop/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
229
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
61
web/src/components/ErrorBoundary.tsx
Normal file
61
web/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
56
web/src/lib/client-logger.ts
Normal file
56
web/src/lib/client-logger.ts
Normal 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(() => {});
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue