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.**
|
**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.**
|
> **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.
|
> 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
|
## 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:**
|
**Implemented:**
|
||||||
- Linux and Windows agent registration and update management
|
- 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:
|
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`.
|
**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
|
# Copy this file to: config/.env
|
||||||
# Then run: docker-compose up -d
|
# Then run: docker-compose up -d
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
@ -19,6 +19,8 @@ REDFLAG_DB_USER=redflag
|
||||||
REDFLAG_DB_PASSWORD=CHANGE_ME_pick_a_strong_db_password
|
REDFLAG_DB_PASSWORD=CHANGE_ME_pick_a_strong_db_password
|
||||||
|
|
||||||
# --- Admin Account ---
|
# --- 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_USER=admin
|
||||||
REDFLAG_ADMIN_EMAIL=admin@example.com
|
REDFLAG_ADMIN_EMAIL=admin@example.com
|
||||||
REDFLAG_ADMIN_PASSWORD=CHANGE_ME_pick_a_strong_admin_password
|
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
|
# and click "Generate Keys". Copy the private key here, then
|
||||||
# restart the server. Without this, agents cannot receive
|
# restart the server. Without this, agents cannot receive
|
||||||
# signed commands or upgrade themselves.
|
# signed commands or upgrade themselves.
|
||||||
|
# BACKUP THE PRIVATE KEY. Losing it means re-enrolling every agent.
|
||||||
REDFLAG_SIGNING_PRIVATE_KEY=
|
REDFLAG_SIGNING_PRIVATE_KEY=
|
||||||
|
|
||||||
# --- Public URL (optional) ---
|
# --- Public URL (optional) ---
|
||||||
|
|
@ -52,5 +55,16 @@ REDFLAG_BINARY_STORAGE_PATH=./binaries
|
||||||
# REDFLAG_TLS_CERT_FILE=/path/to/cert.pem
|
# REDFLAG_TLS_CERT_FILE=/path/to/cert.pem
|
||||||
# REDFLAG_TLS_KEY_FILE=/path/to/key.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) ---
|
# --- Debug (disable in production) ---
|
||||||
REDFLAG_DEBUG=false
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
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:
|
ports:
|
||||||
- "31337:8080"
|
- "31337:8080"
|
||||||
command: ["./redflag-server"]
|
command: ["./redflag-server"]
|
||||||
|
|
@ -45,7 +51,14 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "31336:80"
|
- "31336:80"
|
||||||
depends_on:
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ RUN cargo build --release && \
|
||||||
cp target/release/redflag-helper /out/helper-linux-amd64/redflag-helper
|
cp target/release/redflag-helper /out/helper-linux-amd64/redflag-helper
|
||||||
|
|
||||||
# Stage 3: Final image with server and all agent binaries
|
# 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
|
RUN apk --no-cache add ca-certificates tzdata bash
|
||||||
WORKDIR /app
|
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 --from=helper-builder /out/helper-linux-amd64 ./binaries/helper-linux-amd64
|
||||||
|
|
||||||
# Copy and setup entrypoint script
|
# Copy and setup entrypoint script
|
||||||
# File is in server/ directory relative to build context
|
|
||||||
COPY server/docker-entrypoint.sh /usr/local/bin/
|
COPY server/docker-entrypoint.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
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
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
|
||||||
|
|
@ -486,10 +486,16 @@ func main() {
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
router.GET("/health", func(c *gin.Context) {
|
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) {
|
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
|
// API routes
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
|
||||||
// LogErrorRequest represents a client error log entry
|
// LogErrorRequest represents a client error log entry
|
||||||
type LogErrorRequest struct {
|
type LogErrorRequest struct {
|
||||||
Subsystem string `json:"subsystem" binding:"required"`
|
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"`
|
Message string `json:"message" binding:"required,max=10000"`
|
||||||
StackTrace string `json:"stack_trace,omitempty"`
|
StackTrace string `json:"stack_trace,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,18 @@ func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.
|
||||||
|
|
||||||
// DashboardStats represents dashboard statistics
|
// DashboardStats represents dashboard statistics
|
||||||
type DashboardStats struct {
|
type DashboardStats struct {
|
||||||
TotalAgents int `json:"total_agents"`
|
TotalAgents int `json:"total_agents"`
|
||||||
OnlineAgents int `json:"online_agents"`
|
OnlineAgents int `json:"online_agents"`
|
||||||
OfflineAgents int `json:"offline_agents"`
|
OfflineAgents int `json:"offline_agents"`
|
||||||
PendingUpdates int `json:"pending_updates"`
|
PendingUpdates int `json:"pending_updates"`
|
||||||
FailedUpdates int `json:"failed_updates"`
|
FailedUpdates int `json:"failed_updates"`
|
||||||
VulnerablePackages int `json:"vulnerable_packages"`
|
SecurityUpdateCount int `json:"security_update_count"` // distinct advisories in available-version OSV check
|
||||||
CriticalUpdates int `json:"critical_updates"`
|
InstalledCVECount int `json:"installed_cve_count"` // distinct advisories in installed-version OSV check
|
||||||
ImportantUpdates int `json:"high_updates"`
|
CriticalUpdates int `json:"critical_updates"`
|
||||||
ModerateUpdates int `json:"medium_updates"`
|
ImportantUpdates int `json:"high_updates"`
|
||||||
LowUpdates int `json:"low_updates"`
|
ModerateUpdates int `json:"medium_updates"`
|
||||||
UpdatesByType map[string]int `json:"updates_by_type"`
|
LowUpdates int `json:"low_updates"`
|
||||||
|
UpdatesByType map[string]int `json:"updates_by_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDashboardStats returns dashboard statistics using aggregate queries (F-B1-6 fix)
|
// 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
|
stats.LowUpdates = updateStats.LowUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vulnerable packages (OSV.dev findings on non-terminal updates)
|
// Remediation count — distinct advisories in available-version OSV check.
|
||||||
vulnCount, err := h.updateQueries.GetVulnerablePackageCount()
|
if n, err := h.updateQueries.GetSecurityUpdateCount(); err == nil {
|
||||||
if err == nil {
|
stats.SecurityUpdateCount = n
|
||||||
stats.VulnerablePackages = vulnCount
|
}
|
||||||
|
// 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)
|
c.JSON(http.StatusOK, stats)
|
||||||
|
|
|
||||||
|
|
@ -460,39 +460,69 @@ func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
|
||||||
pkgType, pkgName, version string
|
pkgType, pkgName, version string
|
||||||
}
|
}
|
||||||
seen := make(map[dedupKey]bool)
|
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
|
var reqs []services.OSVCheckRequest
|
||||||
|
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if !services.NeedsSupplyChainCheck(e.PackageType) {
|
if !services.NeedsSupplyChainCheck(e.PackageType) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remediation check — available version.
|
||||||
k := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionTo}
|
k := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionTo}
|
||||||
if seen[k] {
|
if !seen[k] {
|
||||||
continue
|
seen[k] = true
|
||||||
}
|
if !getFresh(e.AgentID, "")[e.PackageType+"\x00"+e.PackageName] {
|
||||||
seen[k] = true
|
reqs = append(reqs, services.OSVCheckRequest{
|
||||||
|
AgentID: e.AgentID,
|
||||||
fresh, ok := freshByAgent[e.AgentID]
|
PkgType: e.PackageType,
|
||||||
if !ok {
|
PkgName: e.PackageName,
|
||||||
f, err := q.FreshSupplyChainPackages(e.AgentID, services.OSVRecheckInterval)
|
Version: e.VersionTo,
|
||||||
if err != nil {
|
})
|
||||||
log.Printf("[WARNING] [supply_chain] fresh_query_failed agent=%s error=%v", e.AgentID, err)
|
|
||||||
f = map[string]bool{}
|
|
||||||
}
|
}
|
||||||
fresh = f
|
|
||||||
freshByAgent[e.AgentID] = fresh
|
|
||||||
}
|
|
||||||
if fresh[e.PackageType+"\x00"+e.PackageName] {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reqs = append(reqs, services.OSVCheckRequest{
|
// Threat check — installed version. Skip if blank or same as available
|
||||||
AgentID: e.AgentID,
|
// (no new information) or already fresh.
|
||||||
PkgType: e.PackageType,
|
if e.VersionFrom == "" || e.VersionFrom == e.VersionTo {
|
||||||
PkgName: e.PackageName,
|
continue
|
||||||
Version: e.VersionTo,
|
}
|
||||||
})
|
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 {
|
if len(reqs) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -1215,24 +1215,52 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVulnerablePackageCount returns the number of distinct packages that have
|
// GetSecurityUpdateCount returns the number of distinct OSV advisory IDs present
|
||||||
// known CVEs from OSV.dev. A package is counted if its metadata contains a
|
// in supply_chain_vulns across all non-terminal package rows. Deduplicates by
|
||||||
// non-empty, non-null supply_chain_vulns field.
|
// advisory ID so sub-packages sharing one advisory count as one, not many.
|
||||||
func (q *UpdateQueries) GetVulnerablePackageCount() (int, error) {
|
func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT COUNT(*) FROM (
|
SELECT COUNT(DISTINCT vuln->>'id')
|
||||||
SELECT DISTINCT package_type, package_name
|
FROM current_package_state,
|
||||||
FROM current_package_state
|
jsonb_array_elements(
|
||||||
WHERE metadata IS NOT NULL
|
CASE WHEN jsonb_typeof((metadata->>'supply_chain_vulns')::jsonb) = 'array'
|
||||||
AND metadata ? 'supply_chain_vulns'
|
THEN (metadata->>'supply_chain_vulns')::jsonb
|
||||||
AND metadata->>'supply_chain_vulns' != '[]'
|
ELSE '[]'::jsonb
|
||||||
AND metadata->>'supply_chain_vulns' != ''
|
END
|
||||||
AND status NOT IN ('installed', 'ignored', 'failed')
|
) AS vuln
|
||||||
) vuln_packages
|
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
|
var count int
|
||||||
if err := q.db.Get(&count, query); err != nil {
|
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
|
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
|
// FreshSupplyChainPackages returns the set of (package_type, package_name) for
|
||||||
// an agent whose stored supply-chain result is newer than within. Used by the
|
// an agent whose stored supply-chain result is newer than within. namespace
|
||||||
// scan path to skip packages already checked recently. Keyed "type\x00name".
|
// selects which timestamp to consult: "" or "remediation" → supply_chain_checked_at,
|
||||||
func (q *UpdateQueries) FreshSupplyChainPackages(agentID uuid.UUID, within time.Duration) (map[string]bool, error) {
|
// "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 := `
|
query := `
|
||||||
SELECT package_type, package_name
|
SELECT package_type, package_name
|
||||||
FROM current_package_state
|
FROM current_package_state
|
||||||
WHERE agent_id = $1
|
WHERE agent_id = $1
|
||||||
AND metadata ? 'supply_chain_checked_at'
|
AND metadata ? $3
|
||||||
AND (metadata->>'supply_chain_checked_at')::timestamptz > NOW() - make_interval(secs => $2)`
|
AND (metadata->>$3)::timestamptz > NOW() - make_interval(secs => $2)`
|
||||||
rows, err := q.db.Query(query, agentID, within.Seconds())
|
rows, err := q.db.Query(query, agentID, within.Seconds(), checkedKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query fresh supply-chain packages: %w", err)
|
return nil, fmt.Errorf("query fresh supply-chain packages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,11 +178,15 @@ const osvBatchesInFlight = 4
|
||||||
var osvBatchSem = make(chan struct{}, osvBatchesInFlight)
|
var osvBatchSem = make(chan struct{}, osvBatchesInFlight)
|
||||||
|
|
||||||
// OSVCheckRequest is one package to check against OSV.dev.
|
// 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 {
|
type OSVCheckRequest struct {
|
||||||
AgentID uuid.UUID
|
AgentID uuid.UUID
|
||||||
PkgType string
|
PkgType string
|
||||||
PkgName string
|
PkgName string
|
||||||
Version string
|
Version string
|
||||||
|
Namespace string // "" = remediation (default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OSVStoreFunc persists a supply-chain result (or a recorded failure) for one
|
// 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()
|
now := time.Now().UTC()
|
||||||
for i, r := range reqs {
|
for i, r := range reqs {
|
||||||
result := batchResp.Results[i]
|
result := batchResp.Results[i]
|
||||||
|
checkedKey, vulnsKey, errorKey := osvMetaKeys(r.Namespace)
|
||||||
meta := map[string]interface{}{
|
meta := map[string]interface{}{
|
||||||
"supply_chain_checked_at": now.Format(time.RFC3339),
|
checkedKey: now.Format(time.RFC3339),
|
||||||
"supply_chain_check_error": nil,
|
errorKey: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Vulns) > 0 {
|
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)
|
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
meta["supply_chain_vulns"] = string(vulnJSON)
|
meta[vulnsKey] = string(vulnJSON)
|
||||||
log.Printf("[SECURITY] [supply_chain] vulns_found pkg=%s type=%s ecosystem=%s count=%d",
|
log.Printf("[SECURITY] [supply_chain] vulns_found namespace=%s pkg=%s type=%s ecosystem=%s count=%d",
|
||||||
r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(result.Vulns))
|
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(result.Vulns))
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[INFO] [supply_chain] clean pkg=%s type=%s ecosystem=%s version=%s",
|
log.Printf("[INFO] [supply_chain] clean namespace=%s pkg=%s type=%s ecosystem=%s version=%s",
|
||||||
r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
|
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
|
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
|
// recordBatchFailure persists a failure record for every request in a batch
|
||||||
// that could not be checked (HTTP error, decode failure, count mismatch).
|
// that could not be checked (HTTP error, decode failure, count mismatch).
|
||||||
// No checked_at is set so these packages are retried next cycle.
|
// No checked_at is set so these packages are retried next cycle.
|
||||||
func recordBatchFailure(reqs []OSVCheckRequest, store OSVStoreFunc) {
|
func recordBatchFailure(reqs []OSVCheckRequest, store OSVStoreFunc) {
|
||||||
for _, r := range reqs {
|
for _, r := range reqs {
|
||||||
|
_, _, errorKey := osvMetaKeys(r.Namespace)
|
||||||
meta := map[string]interface{}{
|
meta := map[string]interface{}{
|
||||||
"supply_chain_check_error": "osv_batch_failed",
|
errorKey: "osv_batch_failed",
|
||||||
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
|
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
|
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)
|
log.Printf("[WARNING] [supply_chain] failure_record_failed pkg=%s error=%v", r.PkgName, err)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ COPY . .
|
||||||
RUN npx vite build
|
RUN npx vite build
|
||||||
|
|
||||||
# Production stage
|
# 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 built assets from build stage
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
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": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.8.4",
|
"@tanstack/react-query": "^5.8.4",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
|
@ -674,6 +676,233 @@
|
||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"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 { Toaster } from 'react-hot-toast';
|
||||||
import { useAuthStore, useUIStore } from '@/lib/store';
|
import { useAuthStore, useUIStore } from '@/lib/store';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/Dashboard';
|
||||||
import Agents from '@/pages/Agents';
|
import Agents from '@/pages/Agents';
|
||||||
|
|
@ -77,6 +78,7 @@ const App: React.FC = () => {
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
|
<div className={`min-h-screen bg-gray-50 ${theme === 'dark' ? 'dark' : ''}`}>
|
||||||
{/* Toast notifications */}
|
{/* Toast notifications */}
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|
@ -161,6 +163,7 @@ const App: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||||
import { agentApi } from '@/lib/api';
|
import { agentApi } from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
interface AgentStorageProps {
|
interface AgentStorageProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|
@ -61,16 +62,14 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
||||||
const { data: storageData, refetch: refetchStorage, error: storageError, isLoading } = useQuery({
|
const { data: storageData, refetch: refetchStorage, error: storageError, isLoading } = useQuery({
|
||||||
queryKey: ['storage-metrics', agentId],
|
queryKey: ['storage-metrics', agentId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('[DEBUG] Fetching storage metrics for agent:', agentId);
|
clientLogger.debug('Fetching storage metrics for agent:', { agentId });
|
||||||
try {
|
try {
|
||||||
const result = await agentApi.getStorageMetrics(agentId);
|
const result = await agentApi.getStorageMetrics(agentId);
|
||||||
console.log('[DEBUG] Storage metrics result:', result);
|
clientLogger.debug('Storage metrics result received', { agentId, hasMetrics: 'metrics' in result, count: result.metrics?.length || 0 });
|
||||||
console.log('[DEBUG] Result has metrics prop:', 'metrics' in result);
|
|
||||||
console.log('[DEBUG] Result.metrics length:', result.metrics?.length || 0);
|
|
||||||
setLastRefreshed(new Date());
|
setLastRefreshed(new Date());
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} 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;
|
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
|
// Show API error if request failed
|
||||||
if (storageError) {
|
if (storageError) {
|
||||||
console.error('[AgentStorage] API Error:', storageError);
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="alert alert-danger rounded-md">
|
<div className="alert alert-danger rounded-md">
|
||||||
|
|
@ -197,13 +189,8 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
||||||
|
|
||||||
const disks = parseDiskInfo();
|
const disks = parseDiskInfo();
|
||||||
|
|
||||||
// Debug disk parsing
|
|
||||||
console.log('[AgentStorage] Parsed disks:', disks);
|
|
||||||
console.log('[AgentStorage] storageMetrics:', storageMetrics);
|
|
||||||
|
|
||||||
// Show error if no data
|
// Show error if no data
|
||||||
if (!storageData || !storageData.metrics || storageData.metrics.length === 0) {
|
if (!storageData || !storageData.metrics || storageData.metrics.length === 0) {
|
||||||
console.log('[AgentStorage] No storage data available');
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="alert alert-warning rounded-md">
|
<div className="alert alert-warning rounded-md">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { agentApi, updateApi } from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { cn, versionCompare } from '@/lib/utils';
|
import { cn, versionCompare } from '@/lib/utils';
|
||||||
import { Agent } from '@/types';
|
import { Agent } from '@/types';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
interface AgentUpdatesModalProps {
|
interface AgentUpdatesModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -123,7 +124,7 @@ export function AgentUpdatesModal({
|
||||||
|
|
||||||
// Generate nonce for security
|
// Generate nonce for security
|
||||||
const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version);
|
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
|
// Use individual update endpoint with nonce
|
||||||
return agentApi.updateAgent(agentId, {
|
return agentApi.updateAgent(agentId, {
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,27 @@ const AttentionPanel: React.FC = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b. Packages with known CVEs (OSV.dev findings)
|
// 1b. Installed version has known CVEs — this is the threat number.
|
||||||
if (stats && stats.vulnerable_packages > 0) {
|
if (stats && stats.installed_cve_count > 0) {
|
||||||
|
const n = stats.installed_cve_count;
|
||||||
alerts.push({
|
alerts.push({
|
||||||
key: 'vulnerable-packages',
|
key: 'installed-cves',
|
||||||
severity: 'vuln',
|
severity: 'vuln',
|
||||||
title: `${stats.vulnerable_packages} package${stats.vulnerable_packages === 1 ? '' : 's'} with known CVEs`,
|
title: `${n} known CVE${n === 1 ? '' : 's'} in installed packages`,
|
||||||
detail: 'Review vulnerabilities before approving updates',
|
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',
|
href: '/updates?vuln=true',
|
||||||
icon: ShieldAlert,
|
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 { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Container,
|
Container,
|
||||||
Bell,
|
Bell,
|
||||||
|
BookOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||||
|
|
@ -33,9 +34,25 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||||||
|
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.read).length;
|
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 = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
|
|
@ -176,14 +193,31 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User section */}
|
{/* User section */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
<div className="absolute bottom-0 left-0 right-0 border-t border-gray-200">
|
||||||
<button
|
{/* Version display */}
|
||||||
onClick={handleLogout}
|
{serverVersion && (
|
||||||
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"
|
<div className="px-4 py-2 text-xs text-gray-400 text-center border-b border-gray-100">
|
||||||
>
|
v{serverVersion}
|
||||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
</div>
|
||||||
Logout
|
)}
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { setupApi } from '@/lib/api';
|
import { setupApi } from '@/lib/api';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
interface SetupCompletionCheckerProps {
|
interface SetupCompletionCheckerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -34,13 +35,13 @@ export const SetupCompletionChecker: React.FC<SetupCompletionCheckerProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasInSetup && !currentSetupMode) {
|
if (wasInSetup && !currentSetupMode) {
|
||||||
console.log('Setup completed - redirecting to login');
|
clientLogger.debug('Setup completed — redirecting to login');
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (wasInSetup) {
|
if (wasInSetup) {
|
||||||
console.log('Setup completed (endpoint unreachable) - redirecting to login');
|
clientLogger.debug('Setup completed (endpoint unreachable) — redirecting to login');
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSecurityEvents, useSecurityWebSocket } from '@/hooks/useSecuritySettings';
|
import { useSecurityEvents, useSecurityWebSocket } from '@/hooks/useSecuritySettings';
|
||||||
import { SecurityEvent, EventFilters } from '@/types/security';
|
import { SecurityEvent, EventFilters } from '@/types/security';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
const SecurityEvents: React.FC = () => {
|
const SecurityEvents: React.FC = () => {
|
||||||
const [filters, setFilters] = useState<EventFilters>({});
|
const [filters, setFilters] = useState<EventFilters>({});
|
||||||
|
|
@ -96,7 +97,7 @@ const SecurityEvents: React.FC = () => {
|
||||||
// Export events
|
// Export events
|
||||||
const exportEvents = async (format: 'json' | 'csv') => {
|
const exportEvents = async (format: 'json' | 'csv') => {
|
||||||
// Implementation would call API to export events
|
// Implementation would call API to export events
|
||||||
console.log(`Exporting events as ${format}`);
|
clientLogger.debug('Exporting events', { format });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear filters
|
// Clear filters
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { agentApi } from '@/lib/api';
|
import { agentApi } from '@/lib/api';
|
||||||
import { Agent } from '@/types';
|
import { Agent } from '@/types';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
interface UseAgentUpdateReturn {
|
interface UseAgentUpdateReturn {
|
||||||
checkForUpdate: (agentId: string) => Promise<void>;
|
checkForUpdate: (agentId: string) => Promise<void>;
|
||||||
|
|
@ -69,8 +70,7 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
|
||||||
|
|
||||||
// Step 2: Generate nonce for authorized update
|
// Step 2: Generate nonce for authorized update
|
||||||
const nonceData = await agentApi.generateUpdateNonce(agent.id, targetVersion);
|
const nonceData = await agentApi.generateUpdateNonce(agent.id, targetVersion);
|
||||||
|
clientLogger.debug('Update nonce generated', { agentId: agent.id, targetVersion });
|
||||||
console.log('[UI] Update nonce generated:', nonceData);
|
|
||||||
|
|
||||||
// Step 3: Trigger the actual update
|
// Step 3: Trigger the actual update
|
||||||
const updateResponse = await agentApi.updateAgent(agent.id, {
|
const updateResponse = await agentApi.updateAgent(agent.id, {
|
||||||
|
|
@ -87,8 +87,8 @@ export function useAgentUpdate(): UseAgentUpdateReturn {
|
||||||
|
|
||||||
// Step 5: Refresh agent data in cache
|
// Step 5: Refresh agent data in cache
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
clientLogger.debug('Update initiated successfully', { agentId: agent.id, targetVersion });
|
||||||
|
|
||||||
console.log('[UI] Update initiated successfully:', updateResponse);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UI] Update failed:', error);
|
console.error('[UI] Update failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
KeyRotationResponse,
|
KeyRotationResponse,
|
||||||
MachineFingerprint
|
MachineFingerprint
|
||||||
} from '@/types/security';
|
} from '@/types/security';
|
||||||
|
import { clientLogger } from '@/lib/client-logger';
|
||||||
|
|
||||||
// Default security settings
|
// Default security settings
|
||||||
const defaultSecuritySettings: SecuritySettings = {
|
const defaultSecuritySettings: SecuritySettings = {
|
||||||
|
|
@ -349,7 +350,7 @@ export const useSecurityWebSocket = () => {
|
||||||
|
|
||||||
ws.current.onopen = () => {
|
ws.current.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
console.log('Security WebSocket connected');
|
clientLogger.debug('Security WebSocket connected');
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.current.onmessage = (event) => {
|
ws.current.onmessage = (event) => {
|
||||||
|
|
@ -370,7 +371,7 @@ export const useSecurityWebSocket = () => {
|
||||||
|
|
||||||
ws.current.onclose = () => {
|
ws.current.onclose = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
console.log('Security WebSocket disconnected');
|
clientLogger.debug('Security WebSocket disconnected');
|
||||||
|
|
||||||
// Attempt to reconnect after 5 seconds
|
// Attempt to reconnect after 5 seconds
|
||||||
setTimeout(() => {
|
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>
|
</div>
|
||||||
|
|
||||||
{/* System info — screenshot square lives in the card header */}
|
{/* System info — screenshot left, stats right, top processes below */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{(() => {
|
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||||
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="flex gap-6">
|
||||||
<div className="flex items-start justify-between mb-4 gap-4">
|
{/* Screenshot — left, fixed width */}
|
||||||
<h2 className="text-lg font-medium text-gray-900">System Information</h2>
|
{(() => {
|
||||||
<div className="shrink-0 w-48">
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative aspect-video w-full overflow-hidden rounded border',
|
'relative aspect-video w-full overflow-hidden rounded border',
|
||||||
|
|
@ -891,125 +893,79 @@ const Agents: React.FC = () => {
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})()}
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{/* All stats — right column, stacked flush top */}
|
||||||
{/* Basic System Info */}
|
<div className="flex-1 space-y-3 min-w-0">
|
||||||
<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">
|
|
||||||
{(() => {
|
{(() => {
|
||||||
|
const osInfo = parseOSInfo(selectedAgent);
|
||||||
const meta = getSystemMetadata(selectedAgent);
|
const meta = getSystemMetadata(selectedAgent);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 flex items-center">
|
<p className="text-xs text-gray-500">Platform</p>
|
||||||
<Cpu className="h-4 w-4 mr-1" />
|
<p className="text-sm font-medium text-gray-900">{osInfo.platform}</p>
|
||||||
CPU
|
</div>
|
||||||
</p>
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Distribution</p>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
{meta.cpuModel}
|
{osInfo.distribution}
|
||||||
</p>
|
{osInfo.version && <span className="text-xs text-gray-500 ml-1">({osInfo.version})</span>}
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{meta.cpuCores} cores
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{meta.memoryTotal > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 flex items-center">
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<MemoryStick className="h-4 w-4 mr-1" />
|
<MemoryStick className="h-3 w-3" /> Memory
|
||||||
Memory
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{formatBytes(meta.memoryTotal)}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{formatBytes(meta.memoryTotal)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meta.diskTotal > 0 && (
|
{meta.diskTotal > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 flex items-center">
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<HardDrive className="h-4 w-4 mr-1" />
|
<HardDrive className="h-3 w-3" /> Disk ({meta.diskMount})
|
||||||
Disk ({meta.diskMount})
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
|
{formatBytes(meta.diskUsed)} / {formatBytes(meta.diskTotal)}
|
||||||
</p>
|
</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
|
<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)}%` }}
|
style={{ width: `${Math.round((meta.diskUsed / meta.diskTotal) * 100)}%` }}
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used</p>
|
||||||
{Math.round((meta.diskUsed / meta.diskTotal) * 100)}% used
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meta.processes !== 'Unknown' && (
|
{meta.processes !== 'Unknown' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 flex items-center">
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<GitBranch className="h-4 w-4 mr-1" />
|
<GitBranch className="h-3 w-3" /> Running Processes
|
||||||
Running Processes
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{meta.processes}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{meta.processes}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meta.uptime !== 'Unknown' && (
|
{meta.uptime !== 'Unknown' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 flex items-center">
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="h-3 w-3" /> Uptime
|
||||||
Uptime
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{meta.uptime}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{meta.uptime}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -1017,6 +973,54 @@ const Agents: React.FC = () => {
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,8 @@ export interface DashboardStats {
|
||||||
approved_updates: number;
|
approved_updates: number;
|
||||||
installed_updates: number;
|
installed_updates: number;
|
||||||
failed_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;
|
critical_updates: number;
|
||||||
high_updates: number;
|
high_updates: number;
|
||||||
medium_updates: number;
|
medium_updates: number;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue