Watch
1
0
Fork
You've already forked RedFlag
0

feat: docker enrichment pipeline, package detail, update history pagination

This commit is contained in:
Fimeg 2026-06-05 21:18:06 -04:00
commit f005255e68
25 changed files with 1791 additions and 185 deletions

View file

@ -809,9 +809,33 @@ func (c *Client) ReportMetrics(agentID uuid.UUID, report MetricsReport) error {
// DockerReport represents Docker image information
type DockerReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []DockerReportItem `json:"images"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []DockerReportItem `json:"images"`
Containers []DockerReportContainer `json:"containers,omitempty"`
Stacks []DockerReportStack `json:"stacks,omitempty"`
EngineVersion string `json:"engine_version,omitempty"`
}
// DockerReportContainer represents a running/stopped container reported by the agent.
type DockerReportContainer struct {
ContainerID string `json:"container_id"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"image_id"`
State string `json:"state"` // running, stopped, paused, etc.
Health string `json:"health"` // healthy, unhealthy, starting, ""
StackName string `json:"stack_name,omitempty"` // compose stack label
Ports string `json:"ports,omitempty"` // "0.0.0.0:80->80/tcp"
CreatedAt int64 `json:"created_at"`
Labels map[string]string `json:"labels,omitempty"`
}
// DockerReportStack represents a Docker Compose stack derived from container labels.
type DockerReportStack struct {
Name string `json:"name"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
}
// DockerReportItem represents a single Docker image

View file

@ -391,11 +391,31 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
Images: imageItems,
}
// Enrich report with container, stack, and engine version data.
// These are collected directly from the Docker API (agent-pull model).
dockerScanner, err := orchestrator.NewDockerScanner()
if err != nil {
log.Printf("[WARN] [agent] [docker] could not create scanner for enrichment: %v", err)
} else {
defer dockerScanner.Close()
containers, err := dockerScanner.ScanContainers()
if err != nil {
log.Printf("[WARN] [agent] [docker] container scan failed: %v", err)
} else {
report.Containers = containers
report.Stacks = dockerScanner.ScanStacks(containers)
}
report.EngineVersion = dockerScanner.GetEngineVersion()
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Docker images: %w", err)
}
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates) to server\n", len(result.Updates), updateCount)
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates), %d containers, %d stacks to server\n",
len(result.Updates), updateCount, len(report.Containers), len(report.Stacks))
} else {
log.Println("[INFO] [agent] [docker] No Docker images found")
}

View file

@ -158,6 +158,97 @@ func (s *DockerScanner) Name() string {
return "Docker Image Scanner"
}
// ScanContainers returns container-level data for enriched Docker reports.
func (s *DockerScanner) ScanContainers() ([]client.DockerReportContainer, error) {
ctx := context.Background()
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var result []client.DockerReportContainer
for _, c := range containers {
stackName := ""
if v, ok := c.Labels["com.docker.stack.namespace"]; ok {
stackName = v
}
if v, ok := c.Labels["com.docker.compose.project"]; ok && stackName == "" {
stackName = v
}
health := ""
if c.State == "running" {
inspect, err := s.client.ContainerInspect(ctx, c.ID)
if err == nil && inspect.State != nil && inspect.State.Health != nil {
health = inspect.State.Health.Status
}
}
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
ports := ""
for _, p := range c.Ports {
if p.PublicPort > 0 {
ports += fmt.Sprintf("%s:%d->%d/%s ", p.IP, p.PublicPort, p.PrivatePort, p.Type)
}
}
result = append(result, client.DockerReportContainer{
ContainerID: c.ID[:12],
Name: name,
Image: c.Image,
ImageID: c.ImageID,
State: c.State,
Health: health,
StackName: stackName,
Ports: strings.TrimSpace(ports),
CreatedAt: c.Created,
Labels: c.Labels,
})
}
return result, nil
}
// ScanStacks aggregates containers into compose stacks.
func (s *DockerScanner) ScanStacks(containers []client.DockerReportContainer) []client.DockerReportStack {
stackMap := make(map[string]*client.DockerReportStack)
for _, c := range containers {
if c.StackName == "" {
continue
}
s, ok := stackMap[c.StackName]
if !ok {
s = &client.DockerReportStack{Name: c.StackName}
stackMap[c.StackName] = s
}
s.ContainerCount++
if c.State == "running" {
s.RunningCount++
}
}
result := make([]client.DockerReportStack, 0, len(stackMap))
for _, s := range stackMap {
result = append(result, *s)
}
return result
}
// GetEngineVersion returns the Docker engine version string.
func (s *DockerScanner) GetEngineVersion() string {
ctx := context.Background()
v, err := s.client.ServerVersion(ctx)
if err != nil {
return ""
}
return v.Version
}
// Close closes the Docker client
func (s *DockerScanner) Close() error {
if s.client != nil {

View file

@ -700,6 +700,10 @@ func main() {
dashboard.GET("/updates/:id/fleet", updateHandler.GetPackageFleet)
dashboard.GET("/updates/:id/versions", updateHandler.GetPackageVersions)
dashboard.GET("/updates/:id/lifecycle", updateHandler.GetUpdateLifecycleHistory)
dashboard.GET("/updates/package/:type/:name", updateHandler.GetPackageSummaryByCoords)
dashboard.GET("/updates/package/:type/:name/agents", updateHandler.GetPackageAgentsByCoords)
dashboard.GET("/updates/package/:type/:name/versions", updateHandler.GetPackageVersionsByCoords)
dashboard.GET("/updates/package/:type/:name/vulnerabilities", updateHandler.GetPackageVulnerabilitiesByCoords)
dashboard.POST("/updates/:id/approve", updateHandler.ApproveUpdate)
dashboard.POST("/updates/approve", updateHandler.ApproveUpdates)
dashboard.POST("/updates/:id/reject", updateHandler.RejectUpdate)
@ -752,6 +756,10 @@ func main() {
dashboard.GET("/agents/:id/metrics/system", metricsHandler.GetAgentSystemMetrics)
dashboard.GET("/agents/:id/docker-images", dockerReportsHandler.GetAgentDockerImages)
dashboard.GET("/agents/:id/docker-info", dockerReportsHandler.GetAgentDockerInfo)
dashboard.GET("/agents/:id/docker-containers", dockerReportsHandler.GetAgentDockerContainers)
dashboard.GET("/agents/:id/docker-stacks", dockerReportsHandler.GetAgentDockerStacks)
dashboard.GET("/docker/fleet-containers", dockerReportsHandler.GetDockerContainersFleet)
dashboard.GET("/docker/fleet-stacks", dockerReportsHandler.GetDockerStacksFleet)
// Admin/Registration Token routes (for agent enrollment management)
admin := dashboard.Group("/admin")

View file

@ -1,6 +1,7 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
@ -114,6 +115,23 @@ func (h *DockerReportsHandler) ReportDockerImages(c *gin.Context) {
return
}
// Store enriched data: containers, stacks, engine version
if len(req.Containers) > 0 {
if err := h.dockerQueries.UpsertContainers(agentID, req.Containers); err != nil {
log.Printf("[WARNING] [server] [docker] container_upsert_failed agent=%s error=%v", agentID, err)
}
}
if len(req.Stacks) > 0 {
if err := h.dockerQueries.UpsertStacks(agentID, req.Stacks); err != nil {
log.Printf("[WARNING] [server] [docker] stack_upsert_failed agent=%s error=%v", agentID, err)
}
}
if req.EngineVersion != "" {
if err := h.dockerQueries.UpdateDockerEngineVersion(agentID, req.EngineVersion); err != nil {
log.Printf("[WARNING] [server] [docker] engine_version_update_failed agent=%s error=%v", agentID, err)
}
}
// Command finalization is owned by ReportLog — the single point that closes
// the command AND writes the [HISTORY] row. Completing it here too would mark
// the command terminal, then 409 the agent's history-bearing ReportLog and drop
@ -394,3 +412,61 @@ func convertInterfaceMapToJSONB(data models.JSONB) models.JSONB {
return models.JSONB(result)
}
// GetAgentDockerContainers returns containers for a specific agent.
func (h *DockerReportsHandler) GetAgentDockerContainers(c *gin.Context) {
agentIDStr := c.Param("agentId")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
containers, err := h.dockerQueries.GetDockerContainers(agentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch containers"})
return
}
c.JSON(http.StatusOK, gin.H{"containers": containers, "total": len(containers)})
}
// GetAgentDockerStacks returns stacks for a specific agent.
func (h *DockerReportsHandler) GetAgentDockerStacks(c *gin.Context) {
agentIDStr := c.Param("agentId")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
stacks, err := h.dockerQueries.GetDockerStacks(agentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch stacks"})
return
}
c.JSON(http.StatusOK, gin.H{"stacks": stacks, "total": len(stacks)})
}
// GetDockerContainersFleet returns containers across all agents.
func (h *DockerReportsHandler) GetDockerContainersFleet(c *gin.Context) {
containers, err := h.dockerQueries.GetDockerContainersFleet()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch fleet containers"})
return
}
c.JSON(http.StatusOK, gin.H{"containers": containers, "total": len(containers)})
}
// GetDockerStacksFleet returns stacks across all agents.
func (h *DockerReportsHandler) GetDockerStacksFleet(c *gin.Context) {
stacks, err := h.dockerQueries.GetDockerStacksFleet()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch fleet stacks"})
return
}
c.JSON(http.StatusOK, gin.H{"stacks": stacks, "total": len(stacks)})
}

View file

@ -544,6 +544,105 @@ func (h *UpdateHandler) GetPackageVersions(c *gin.Context) {
})
}
// GetPackageSummaryByCoords returns the package-centric aggregate view for
// GET /updates/package/:type/:name — metadata, fleet status counts, and a
// deduplicated vulnerability list, without being tied to a single agent row.
func (h *UpdateHandler) GetPackageSummaryByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
summary, err := h.updateQueries.GetPackageSummary(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_summary_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, summary)
}
// GetPackageAgentsByCoords returns per-agent rows for
// GET /updates/package/:type/:name/agents — each agent's version, status, and
// actionable flags (can_approve, can_install, can_retry).
func (h *UpdateHandler) GetPackageAgentsByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
fleet, err := h.updateQueries.GetPackageFleet(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_agents_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load package agents"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"agents": fleet,
})
}
// GetPackageVersionsByCoords returns the version timeline for
// GET /updates/package/:type/:name/versions.
func (h *UpdateHandler) GetPackageVersionsByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
versions, err := h.updateQueries.GetPackageVersions(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [versions] package_versions_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load version timeline"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"versions": versions,
})
}
// GetPackageVulnerabilitiesByCoords returns the deduplicated vulnerability list for
// GET /updates/package/:type/:name/vulnerabilities — merged from package_versions
// osv_vulns and live agent supply_chain_vulns metadata.
func (h *UpdateHandler) GetPackageVulnerabilitiesByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
vulns, err := h.updateQueries.GetPackageVulnerabilitiesByTypeAndName(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_vulns_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load vulnerabilities"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"vulnerabilities": vulns,
})
}
// supplyChainHold is the result of the manual-approval supply-chain gate: whether
// approval must stop, whether the cause was an unverifiable closure (vs. a known
// vuln), a human-readable reason, and the top-level vulnerabilities to echo back.

View file

@ -0,0 +1,7 @@
-- Reverse DOCKER-ENRICHED-SCAN
DROP TABLE IF EXISTS docker_stacks;
DROP TABLE IF EXISTS docker_containers;
ALTER TABLE agents DROP COLUMN IF EXISTS docker_version;
ALTER TABLE docker_images DROP COLUMN IF EXISTS used;
ALTER TABLE docker_images DROP COLUMN IF EXISTS size_bytes;
ALTER TABLE docker_images DROP COLUMN IF EXISTS image_created_at;

View file

@ -0,0 +1,59 @@
-- DOCKER-ENRICHED-SCAN: Container, stack, and engine version tracking.
-- Extends the agent's scan_docker report with container-level data,
-- compose stack grouping, and Docker engine version.
-- 1. Docker containers reported by agents
CREATE TABLE IF NOT EXISTS docker_containers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
container_id VARCHAR(32) NOT NULL, -- short Docker container ID (12 chars)
name TEXT NOT NULL,
image TEXT NOT NULL,
image_id TEXT NOT NULL,
state VARCHAR(32) NOT NULL, -- running, stopped, paused, etc.
health VARCHAR(32) NOT NULL DEFAULT '', -- healthy, unhealthy, starting, ''
stack_name TEXT NOT NULL DEFAULT '',
ports TEXT NOT NULL DEFAULT '',
created_at_epoch BIGINT NOT NULL DEFAULT 0,
labels JSONB NOT NULL DEFAULT '{}',
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (agent_id, container_id)
);
CREATE INDEX IF NOT EXISTS idx_docker_containers_agent
ON docker_containers (agent_id);
CREATE INDEX IF NOT EXISTS idx_docker_containers_stack
ON docker_containers (agent_id, stack_name)
WHERE stack_name <> '';
CREATE INDEX IF NOT EXISTS idx_docker_containers_state
ON docker_containers (agent_id, state);
-- 2. Docker Compose stacks derived from container labels
CREATE TABLE IF NOT EXISTS docker_stacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
name TEXT NOT NULL,
container_count INT NOT NULL DEFAULT 0,
running_count INT NOT NULL DEFAULT 0,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (agent_id, name)
);
CREATE INDEX IF NOT EXISTS idx_docker_stacks_agent
ON docker_stacks (agent_id);
-- 3. Engine version on the agents table (if not already present)
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS docker_version TEXT NOT NULL DEFAULT '';
-- 4. Add used flag and size to docker_images if not present
ALTER TABLE docker_images
ADD COLUMN IF NOT EXISTS used BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE docker_images
ADD COLUMN IF NOT EXISTS size_bytes BIGINT NOT NULL DEFAULT 0;
ALTER TABLE docker_images
ADD COLUMN IF NOT EXISTS image_created_at TIMESTAMPTZ;

View file

@ -254,4 +254,143 @@ func (q *DockerQueries) GetDockerStats() (*models.DockerStats, error) {
}
return &stats, nil
}
// UpsertContainers replaces all containers for an agent with the latest report.
func (q *DockerQueries) UpsertContainers(agentID uuid.UUID, containers []models.AgentDockerContainer) error {
if len(containers) == 0 {
return nil
}
tx, err := q.db.Begin()
if err != nil {
return fmt.Errorf("docker_containers: begin tx: %w", err)
}
defer tx.Rollback()
// Delete old containers for this agent (full replace on each report)
if _, err := tx.Exec(`DELETE FROM docker_containers WHERE agent_id = $1`, agentID); err != nil {
return fmt.Errorf("docker_containers: delete old: %w", err)
}
stmt, err := tx.Prepare(`
INSERT INTO docker_containers
(agent_id, container_id, name, image, image_id, state, health, stack_name, ports, created_at_epoch, labels, last_seen_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
ON CONFLICT (agent_id, container_id) DO UPDATE
SET name = EXCLUDED.name,
image = EXCLUDED.image,
image_id = EXCLUDED.image_id,
state = EXCLUDED.state,
health = EXCLUDED.health,
stack_name = EXCLUDED.stack_name,
ports = EXCLUDED.ports,
created_at_epoch = EXCLUDED.created_at_epoch,
labels = EXCLUDED.labels,
last_seen_at = NOW()`)
if err != nil {
return fmt.Errorf("docker_containers: prepare: %w", err)
}
defer stmt.Close()
for _, c := range containers {
labelsJSON := models.JSONB{}
for k, v := range c.Labels {
labelsJSON[k] = v
}
if _, err := stmt.Exec(agentID, c.ContainerID, c.Name, c.Image, c.ImageID, c.State, c.Health, c.StackName, c.Ports, c.CreatedAt, labelsJSON); err != nil {
log.Printf("[WARNING] [server] [database] docker_container_upsert_failed agent=%s container=%s error=%v", agentID, c.ContainerID, err)
}
}
return tx.Commit()
}
// UpsertStacks replaces all stacks for an agent with the latest report.
func (q *DockerQueries) UpsertStacks(agentID uuid.UUID, stacks []models.AgentDockerStack) error {
if len(stacks) == 0 {
return nil
}
tx, err := q.db.Begin()
if err != nil {
return fmt.Errorf("docker_stacks: begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM docker_stacks WHERE agent_id = $1`, agentID); err != nil {
return fmt.Errorf("docker_stacks: delete old: %w", err)
}
stmt, err := tx.Prepare(`
INSERT INTO docker_stacks (agent_id, name, container_count, running_count, last_seen_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (agent_id, name) DO UPDATE
SET container_count = EXCLUDED.container_count,
running_count = EXCLUDED.running_count,
last_seen_at = NOW()`)
if err != nil {
return fmt.Errorf("docker_stacks: prepare: %w", err)
}
defer stmt.Close()
for _, s := range stacks {
if _, err := stmt.Exec(agentID, s.Name, s.ContainerCount, s.RunningCount); err != nil {
log.Printf("[WARNING] [server] [database] docker_stack_upsert_failed agent=%s stack=%s error=%v", agentID, s.Name, err)
}
}
return tx.Commit()
}
// UpdateDockerEngineVersion stores the Docker engine version on the agent row.
func (q *DockerQueries) UpdateDockerEngineVersion(agentID uuid.UUID, version string) error {
if version == "" {
return nil
}
_, err := q.db.Exec(`UPDATE agents SET docker_version = $1 WHERE id = $2`, version, agentID)
if err != nil {
return fmt.Errorf("docker_version: update: %w", err)
}
return nil
}
// GetDockerContainers returns all containers for an agent.
func (q *DockerQueries) GetDockerContainers(agentID uuid.UUID) ([]models.StoredDockerContainer, error) {
var rows []models.StoredDockerContainer
err := q.db.Select(&rows, `SELECT * FROM docker_containers WHERE agent_id = $1 ORDER BY name`, agentID)
if err != nil {
return nil, fmt.Errorf("docker_containers: get: %w", err)
}
return rows, nil
}
// GetDockerStacks returns all stacks for an agent.
func (q *DockerQueries) GetDockerStacks(agentID uuid.UUID) ([]models.StoredDockerStack, error) {
var rows []models.StoredDockerStack
err := q.db.Select(&rows, `SELECT * FROM docker_stacks WHERE agent_id = $1 ORDER BY name`, agentID)
if err != nil {
return nil, fmt.Errorf("docker_stacks: get: %w", err)
}
return rows, nil
}
// GetDockerContainersFleet returns containers across all agents.
func (q *DockerQueries) GetDockerContainersFleet() ([]models.StoredDockerContainer, error) {
var rows []models.StoredDockerContainer
err := q.db.Select(&rows, `SELECT * FROM docker_containers ORDER BY agent_id, name`)
if err != nil {
return nil, fmt.Errorf("docker_containers: fleet: %w", err)
}
return rows, nil
}
// GetDockerStacksFleet returns stacks across all agents.
func (q *DockerQueries) GetDockerStacksFleet() ([]models.StoredDockerStack, error) {
var rows []models.StoredDockerStack
err := q.db.Select(&rows, `SELECT * FROM docker_stacks ORDER BY agent_id, name`)
if err != nil {
return nil, fmt.Errorf("docker_stacks: fleet: %w", err)
}
return rows, nil
}

View file

@ -117,6 +117,11 @@ type PackageFleetEntry struct {
Severity string `json:"severity" db:"severity"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
SelectedVersion *string `json:"selected_version" db:"selected_version"`
// Action flags — derived from status after query, not stored.
CanApprove bool `json:"can_approve" db:"-"`
CanInstall bool `json:"can_install" db:"-"`
CanRetry bool `json:"can_retry" db:"-"`
}
// UpsertPackageVersion records or enriches one version in the timeline catalog. It is
@ -301,7 +306,8 @@ func (q *UpdateQueries) GetPackageFleet(packageType, packageName string) ([]Pack
query := `
SELECT cps.agent_id, a.hostname, cps.id AS update_id,
cps.current_version, cps.available_version, cps.status,
cps.severity, cps.expected_sha256, cps.last_updated_at
cps.severity, cps.expected_sha256, cps.last_updated_at,
cps.selected_version
FROM current_package_state cps
JOIN agents a ON a.id = cps.agent_id
WHERE cps.package_type = $1 AND cps.package_name = $2
@ -310,9 +316,142 @@ func (q *UpdateQueries) GetPackageFleet(packageType, packageName string) ([]Pack
if err := q.db.Select(&rows, query, packageType, packageName); err != nil {
return nil, err
}
for i := range rows {
s := models.PackageStatus(rows[i].Status)
rows[i].CanApprove = s == models.StatusPending
rows[i].CanInstall = s == models.StatusApproved
rows[i].CanRetry = s == models.StatusFailed
}
return rows, nil
}
// packageSummaryRow is the raw SQL scan target for GetPackageSummary.
type packageSummaryRow struct {
PackageType string `db:"package_type"`
PackageName string `db:"package_name"`
Severity string `db:"severity"`
Metadata models.JSONB `db:"metadata"`
TotalAgents int `db:"total_agents"`
PendingCount int `db:"pending_count"`
ApprovedCount int `db:"approved_count"`
ActiveCount int `db:"active_count"`
InstalledCount int `db:"installed_count"`
FailedCount int `db:"failed_count"`
IgnoredCount int `db:"ignored_count"`
LatestAvailable string `db:"latest_available"`
LatestInstalled *string `db:"latest_installed"`
}
// GetPackageSummary returns the package-centric aggregate view for a given
// (package_type, package_name): fleet status counts, version extremes, and
// enrichment metadata extracted from the most recently updated agent row.
func (q *UpdateQueries) GetPackageSummary(packageType, packageName string) (*models.PackageSummary, error) {
query := `
SELECT
package_type,
package_name,
(SELECT severity FROM current_package_state
WHERE package_type = $1 AND package_name = $2
ORDER BY CASE severity
WHEN 'critical' THEN 4 WHEN 'high' THEN 3
WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0
END DESC LIMIT 1) AS severity,
(SELECT metadata FROM current_package_state
WHERE package_type = $1 AND package_name = $2
ORDER BY last_updated_at DESC LIMIT 1) AS metadata,
COUNT(*) AS total_agents,
COUNT(*) FILTER (WHERE status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE status = 'approved') AS approved_count,
COUNT(*) FILTER (WHERE status IN (
'checking_dependencies','pending_dependencies','installing'))
AS active_count,
COUNT(*) FILTER (WHERE status = 'installed') AS installed_count,
COUNT(*) FILTER (WHERE status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE status = 'ignored') AS ignored_count,
COALESCE(MAX(available_version), '') AS latest_available,
MAX(current_version) FILTER (WHERE status = 'installed') AS latest_installed
FROM current_package_state
WHERE package_type = $1 AND package_name = $2
GROUP BY package_type, package_name`
var row packageSummaryRow
if err := q.db.Get(&row, query, packageType, packageName); err != nil {
return nil, err
}
// Enrich via the existing UpdateState machinery.
tmp := &models.UpdateState{Metadata: row.Metadata}
tmp.EnrichFromMetadata()
return &models.PackageSummary{
PackageType: row.PackageType,
PackageName: row.PackageName,
Severity: row.Severity,
PackageDescription: tmp.PackageDescription,
HomepageURL: tmp.HomepageURL,
SizeBytes: tmp.SizeBytes,
CVEList: tmp.CVEList,
Vulnerabilities: tmp.Vulnerabilities,
TotalAgents: row.TotalAgents,
PendingCount: row.PendingCount,
ApprovedCount: row.ApprovedCount,
ActiveCount: row.ActiveCount,
InstalledCount: row.InstalledCount,
FailedCount: row.FailedCount,
IgnoredCount: row.IgnoredCount,
LatestAvailable: row.LatestAvailable,
LatestInstalled: row.LatestInstalled,
}, nil
}
// GetPackageVulnerabilitiesByTypeAndName returns a deduplicated vulnerability
// list for a package by merging osv_vulns across all package_versions rows and
// supply_chain_vulns from current agent metadata. Source is "osv" for both
// (both originate from OSV batch checks); duplicates are suppressed by CVE ID.
func (q *UpdateQueries) GetPackageVulnerabilitiesByTypeAndName(packageType, packageName string) ([]models.VulnerabilityEntry, error) {
// Collect from package_versions catalog (most authoritative — written at approval time).
versionsQuery := `
SELECT osv_vulns
FROM package_versions
WHERE package_type = $1 AND package_name = $2
AND osv_vulns IS NOT NULL AND osv_vulns != '[]'`
var osvRows []string
if err := q.db.Select(&osvRows, versionsQuery, packageType, packageName); err != nil {
return nil, err
}
// Also collect supply_chain_vulns from live agent rows (catches packages not
// yet through an approval cycle).
agentQuery := `
SELECT metadata->>'supply_chain_vulns'
FROM current_package_state
WHERE package_type = $1 AND package_name = $2
AND metadata->>'supply_chain_vulns' IS NOT NULL
AND metadata->>'supply_chain_vulns' != '[]'`
var agentRows []string
if err := q.db.Select(&agentRows, agentQuery, packageType, packageName); err != nil {
return nil, err
}
seen := make(map[string]bool)
var result []models.VulnerabilityEntry
for _, raw := range append(osvRows, agentRows...) {
var entries []models.VulnerabilityEntry
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
continue
}
for _, e := range entries {
if seen[e.ID] {
continue
}
seen[e.ID] = true
e.Source = "osv"
result = append(result, e)
}
}
return result, nil
}
// ApproveUpdate marks an update as approved in the new event sourcing system
// --- State machine: the single transition path ---------------------------
//

View file

@ -30,6 +30,7 @@ type Agent struct {
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
DockerVersion string `json:"docker_version" db:"docker_version"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@ -56,6 +57,7 @@ type AgentWithLastScan struct {
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
DockerVersion string `json:"docker_version" db:"docker_version"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
LastScan *time.Time `json:"last_scan" db:"last_scan"`

View file

@ -101,9 +101,60 @@ type AgentDockerImage struct {
// DockerReportRequest is sent by agents when reporting Docker image updates
type DockerReportRequest struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []AgentDockerImage `json:"images"`
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Images []AgentDockerImage `json:"images"`
Containers []AgentDockerContainer `json:"containers,omitempty"`
Stacks []AgentDockerStack `json:"stacks,omitempty"`
EngineVersion string `json:"engine_version,omitempty"`
}
// AgentDockerContainer represents a container reported by an agent.
type AgentDockerContainer struct {
ContainerID string `json:"container_id"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"image_id"`
State string `json:"state"`
Health string `json:"health"`
StackName string `json:"stack_name"`
Ports string `json:"ports"`
CreatedAt int64 `json:"created_at"`
Labels map[string]string `json:"labels"`
}
// AgentDockerStack represents a compose stack derived from container labels.
type AgentDockerStack struct {
Name string `json:"name"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
}
// StoredDockerContainer is a container row persisted in the docker_containers table.
type StoredDockerContainer struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
ContainerID string `json:"container_id" db:"container_id"`
Name string `json:"name" db:"name"`
Image string `json:"image" db:"image"`
ImageID string `json:"image_id" db:"image_id"`
State string `json:"state" db:"state"`
Health string `json:"health" db:"health"`
StackName string `json:"stack_name" db:"stack_name"`
Ports string `json:"ports" db:"ports"`
CreatedAt int64 `json:"created_at_epoch" db:"created_at_epoch"`
Labels JSONB `json:"labels" db:"labels"`
LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
}
// StoredDockerStack is a stack row persisted in the docker_stacks table.
type StoredDockerStack struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
Name string `json:"name" db:"name"`
ContainerCount int `json:"container_count" db:"container_count"`
RunningCount int `json:"running_count" db:"running_count"`
LastSeenAt time.Time `json:"last_seen_at" db:"last_seen_at"`
}
// DockerImageInfo represents detailed Docker image information for API responses

View file

@ -141,7 +141,8 @@ type UpdateState struct {
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status PackageStatus `json:"status" db:"status"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
SelectedVersion *string `json:"selected_version,omitempty" db:"selected_version"` // GATE-005: soak-gate target
SelectedVersion *string `json:"selected_version,omitempty" db:"selected_version"` // GATE-005: soak-gate target
SoakWindowHoursOverride *float64 `json:"soak_window_hours_override,omitempty" db:"soak_window_hours_override"` // GATE-005: per-package override
// Enrichment fields — populated from Metadata by EnrichFromMetadata().
// Not persisted; db:"-" excludes them from SQL scans.
@ -173,6 +174,29 @@ type PackageVersion struct {
Source *string `json:"source" db:"source"`
}
// PackageSummary is the package-centric aggregate view returned by
// GET /updates/package/:type/:name. It carries metadata from one representative
// agent row, fleet-wide status counts, and version extremes.
type PackageSummary struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
Severity string `json:"severity"`
PackageDescription string `json:"package_description"`
HomepageURL string `json:"homepage_url"`
SizeBytes int64 `json:"size_bytes"`
CVEList []string `json:"cve_list"`
Vulnerabilities []VulnerabilityEntry `json:"vulnerabilities"`
TotalAgents int `json:"total_agents"`
PendingCount int `json:"pending_count"`
ApprovedCount int `json:"approved_count"`
ActiveCount int `json:"active_count"`
InstalledCount int `json:"installed_count"`
FailedCount int `json:"failed_count"`
IgnoredCount int `json:"ignored_count"`
LatestAvailable string `json:"latest_available"`
LatestInstalled *string `json:"latest_installed,omitempty"`
}
// UpdateHistory represents the version history of a package
type UpdateHistory struct {
ID uuid.UUID `json:"id" db:"id"`

View file

@ -410,6 +410,12 @@ func (w *worker) run() {
// Re-queue job for next execution
job.NextRunAt = time.Now().UTC().Add(time.Duration(job.IntervalMinutes) * time.Minute)
w.scheduler.queue.Push(job)
// Persist last_run_at / next_run_at so the UI shows a current "Next Run" time
if dbErr := w.scheduler.subsystemQueries.UpdateLastRun(job.AgentID, job.Subsystem); dbErr != nil {
log.Printf("[WARN] [Worker %d] [scheduler] update_last_run_failed agent=%s subsystem=%s err=%v",
w.id, job.AgentHostname, job.Subsystem, dbErr)
}
}
log.Printf("[Worker %d] Stopped\n", w.id)

View file

@ -7,6 +7,7 @@ import Layout from '@/components/Layout';
import Dashboard from '@/pages/Dashboard';
import Agents from '@/pages/Agents';
import Updates from '@/pages/Updates';
import PackageDetail from '@/pages/PackageDetail';
import Docker from '@/pages/Docker';
import LiveOperations from '@/pages/LiveOperations';
import History from '@/pages/History';
@ -136,6 +137,7 @@ const App: React.FC = () => {
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/updates/package/:type/:name" element={<PackageDetail />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />

View file

@ -506,7 +506,11 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
{/* Next Run */}
<td className="py-3 pr-4 text-right text-xs text-gray-600">
{subsystem.next_run_at && subsystem.auto_run ? formatRelativeTime(subsystem.next_run_at) : '-'}
{subsystem.next_run_at && subsystem.auto_run ? (
new Date(subsystem.next_run_at) <= new Date()
? <span className="text-orange-600 font-medium">Overdue</span>
: formatRelativeTime(subsystem.next_run_at)
) : '-'}
</td>
{/* Actions */}

View file

@ -1,128 +0,0 @@
import React, { useState } from 'react';
import { Bell, X, Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
import { useRealtimeStore } from '@/lib/store';
import { cn, formatRelativeTime } from '@/lib/utils';
const NotificationCenter: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
const unreadCount = notifications.filter(n => !n.read).length;
const getNotificationIcon = (type: string) => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-success-600" />;
case 'error':
return <XCircle className="w-5 h-5 text-danger-600" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-warning-600" />;
default:
return <Info className="w-5 h-5 text-blue-600" />;
}
};
const getNotificationColor = (type: string) => {
switch (type) {
case 'success':
return 'border-success-200 bg-success-50';
case 'error':
return 'border-danger-200 bg-danger-50';
case 'warning':
return 'border-warning-200 bg-warning-50';
default:
return 'border-blue-200 bg-blue-50';
}
};
return (
<div className="fixed top-4 right-4 z-40">
{/* Notification bell */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow"
>
<Bell className="w-5 h-5 text-gray-600" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-danger-600 text-white text-xs rounded-full flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Notifications dropdown */}
{isOpen && (
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center space-x-2">
{notifications.length > 0 && (
<button
onClick={clearNotifications}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear All
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Notifications list */}
<div className="overflow-y-auto max-h-80">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p>No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
getNotificationColor(notification.type)
)}
onClick={() => markNotificationRead(notification.id)}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{notification.title}
</p>
{!notification.read && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
New
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-2">
{formatRelativeTime(notification.timestamp)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
export default NotificationCenter;

View file

@ -122,6 +122,41 @@ export const useInstallDockerUpdate = () => {
});
};
// Runtime container/stack hooks (DOCKER-ENRICHED-SCAN)
export const useAgentRuntimeContainers = (agentId: string) => {
return useQuery({
queryKey: ['agent-runtime-containers', agentId],
queryFn: () => dockerApi.getAgentRuntimeContainers(agentId),
enabled: !!agentId,
refetchInterval: 30000,
});
};
export const useAgentRuntimeStacks = (agentId: string) => {
return useQuery({
queryKey: ['agent-runtime-stacks', agentId],
queryFn: () => dockerApi.getAgentRuntimeStacks(agentId),
enabled: !!agentId,
refetchInterval: 30000,
});
};
export const useFleetRuntimeContainers = () => {
return useQuery({
queryKey: ['fleet-runtime-containers'],
queryFn: () => dockerApi.getFleetRuntimeContainers(),
refetchInterval: 30000,
});
};
export const useFleetRuntimeStacks = () => {
return useQuery({
queryKey: ['fleet-runtime-stacks'],
queryFn: () => dockerApi.getFleetRuntimeStacks(),
refetchInterval: 30000,
});
};
// Hook for bulk Docker operations
export const useBulkDockerActions = () => {
const queryClient = useQueryClient();

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { updateApi, capabilityTokenApi } from '@/lib/api';
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse, PackageListResponse, PackageFleetResponse, PackageVersionsResponse, CapabilityTokenStatusResponse } from '@/types';
import type { UpdatePackage, ListQueryParams, UpdateApprovalRequest, UpdateListResponse, PackageListResponse, PackageFleetResponse, PackageVersionsResponse, CapabilityTokenStatusResponse, PackageSummary, PackageAgentsResponse, PackageVulnerabilitiesResponse } from '@/types';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
export const useUpdates = (params?: ListQueryParams): UseQueryResult<UpdateListResponse, Error> => {
@ -41,6 +41,39 @@ export const usePackageVersions = (id: string, enabled: boolean = true): UseQuer
});
};
export const usePackageSummary = (pkgType: string, pkgName: string, enabled: boolean = true): UseQueryResult<PackageSummary, Error> => {
return useQuery({
queryKey: ['package-summary', pkgType, pkgName],
queryFn: () => updateApi.getPackageSummaryByCoords(pkgType, pkgName),
enabled: enabled && !!pkgType && !!pkgName,
});
};
export const usePackageAgents = (pkgType: string, pkgName: string, enabled: boolean = true): UseQueryResult<PackageAgentsResponse, Error> => {
return useQuery({
queryKey: ['package-agents', pkgType, pkgName],
queryFn: () => updateApi.getPackageAgentsByCoords(pkgType, pkgName),
enabled: enabled && !!pkgType && !!pkgName,
refetchInterval: 10000,
});
};
export const usePackageVersionsByCoords = (pkgType: string, pkgName: string, enabled: boolean = true): UseQueryResult<PackageVersionsResponse, Error> => {
return useQuery({
queryKey: ['package-versions-coords', pkgType, pkgName],
queryFn: () => updateApi.getPackageVersionsByCoords(pkgType, pkgName),
enabled: enabled && !!pkgType && !!pkgName,
});
};
export const usePackageVulnerabilities = (pkgType: string, pkgName: string, enabled: boolean = true): UseQueryResult<PackageVulnerabilitiesResponse, Error> => {
return useQuery({
queryKey: ['package-vulns', pkgType, pkgName],
queryFn: () => updateApi.getPackageVulnerabilitiesByCoords(pkgType, pkgName),
enabled: enabled && !!pkgType && !!pkgName,
});
};
export const useUpdateLifecycle = (id: string, enabled: boolean = true) => {
return useQuery({
queryKey: ['update-lifecycle', id],

View file

@ -34,6 +34,11 @@ import {
MaintenanceWindow,
CreateMaintenanceWindowRequest,
CapabilityTokenStatusResponse,
PackageSummary,
PackageAgentsResponse,
PackageVulnerabilitiesResponse,
RuntimeDockerContainer,
RuntimeDockerStack,
} from '@/types';
// Base URL for API - use nginx proxy
@ -353,6 +358,27 @@ export const updateApi = {
return response.data;
},
// Package-centric endpoints (by type + name, not by update row ID)
getPackageSummaryByCoords: async (pkgType: string, pkgName: string): Promise<PackageSummary> => {
const response = await api.get(`/updates/package/${pkgType}/${pkgName}`);
return response.data;
},
getPackageAgentsByCoords: async (pkgType: string, pkgName: string): Promise<PackageAgentsResponse> => {
const response = await api.get(`/updates/package/${pkgType}/${pkgName}/agents`);
return response.data;
},
getPackageVersionsByCoords: async (pkgType: string, pkgName: string): Promise<PackageVersionsResponse> => {
const response = await api.get(`/updates/package/${pkgType}/${pkgName}/versions`);
return response.data;
},
getPackageVulnerabilitiesByCoords: async (pkgType: string, pkgName: string): Promise<PackageVulnerabilitiesResponse> => {
const response = await api.get(`/updates/package/${pkgType}/${pkgName}/vulnerabilities`);
return response.data;
},
// Get update logs
getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => {
const response = await api.get(`/updates/${id}/logs`, {
@ -684,6 +710,27 @@ export const dockerApi = {
triggerScan: async (agentIds?: string[]): Promise<void> => {
await api.post('/docker/scan', { agent_ids: agentIds });
},
// Runtime container state (DOCKER-ENRICHED-SCAN)
getAgentRuntimeContainers: async (agentId: string): Promise<{ containers: RuntimeDockerContainer[] }> => {
const response = await api.get(`/agents/${agentId}/docker-containers`);
return response.data;
},
getAgentRuntimeStacks: async (agentId: string): Promise<{ stacks: RuntimeDockerStack[] }> => {
const response = await api.get(`/agents/${agentId}/docker-stacks`);
return response.data;
},
getFleetRuntimeContainers: async (): Promise<{ containers: RuntimeDockerContainer[] }> => {
const response = await api.get('/docker/fleet-containers');
return response.data;
},
getFleetRuntimeStacks: async (): Promise<{ stacks: RuntimeDockerStack[] }> => {
const response = await api.get('/docker/fleet-stacks');
return response.data;
},
};
// Admin API endpoints

View file

@ -290,4 +290,36 @@ export const storage = {
// Silent fail for storage issues
}
},
};
// advisoryUrl resolves a vulnerability ID to its canonical advisory page.
// Unknown prefixes fall back to OSV.dev.
export const advisoryUrl = (id: string): string => {
const up = id.toUpperCase();
if (up.startsWith('CVE-')) return `https://nvd.nist.gov/vuln/detail/${id}`;
if (up.startsWith('GHSA-')) return `https://github.com/advisories/${id}`;
if (up.startsWith('ALSA-')) return `https://errata.almalinux.org/${id.split('-')[1]}/${id}.html`;
if (up.startsWith('RHSA-') || up.startsWith('RHBA-') || up.startsWith('RLSA-'))
return `https://access.redhat.com/errata/${id}`;
if (up.startsWith('DSA-')) return `https://security-tracker.debian.org/tracker/${id}`;
if (up.startsWith('USN-')) return `https://ubuntu.com/security/notices/${id}`;
return `https://osv.dev/vulnerability/${id}`;
};
// cveSeverityBadge maps an OSV qualitative severity string to display label + Tailwind classes.
export const cveSeverityBadge = (severity?: string): { label: string; cls: string } => {
const s = (severity || '').toUpperCase();
switch (s) {
case 'CRITICAL':
return { label: 'CRITICAL', cls: 'bg-red-100 text-red-800 border-red-300' };
case 'HIGH':
return { label: 'HIGH', cls: 'bg-red-50 text-red-700 border-red-200' };
case 'MODERATE':
case 'MEDIUM':
return { label: 'MEDIUM', cls: 'bg-amber-100 text-amber-800 border-amber-300' };
case 'LOW':
return { label: 'LOW', cls: 'bg-yellow-50 text-yellow-700 border-yellow-200' };
default:
return { label: 'UNSCORED', cls: 'bg-gray-100 text-gray-600 border-gray-300' };
}
};

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Search, Package, AlertTriangle, Container } from 'lucide-react';
import { useDockerContainers, useDockerStats } from '@/hooks/useDocker';
import type { DockerContainer, DockerImage } from '@/types';
import { Search, Package, AlertTriangle, Container, Layers, Activity } from 'lucide-react';
import { useDockerContainers, useDockerStats, useFleetRuntimeContainers, useFleetRuntimeStacks } from '@/hooks/useDocker';
import type { DockerContainer, DockerImage, RuntimeDockerContainer, RuntimeDockerStack } from '@/types';
import { formatRelativeTime, cn } from '@/lib/utils';
const Docker: React.FC = () => {
@ -20,6 +20,11 @@ const Docker: React.FC = () => {
});
useDockerStats();
const { data: fleetContainersData } = useFleetRuntimeContainers();
const { data: fleetStacksData } = useFleetRuntimeStacks();
const runtimeContainers: RuntimeDockerContainer[] = fleetContainersData?.containers || [];
const runtimeStacks: RuntimeDockerStack[] = fleetStacksData?.stacks || [];
const containers = dockerData?.containers || [];
const images = dockerData?.images || [];
@ -80,6 +85,25 @@ const Docker: React.FC = () => {
}).join(', ');
};
const getContainerStateColor = (state: string): string => {
switch (state.toLowerCase()) {
case 'running': return 'bg-green-50 text-green-700 border-green-200';
case 'paused': return 'bg-yellow-50 text-yellow-700 border-yellow-200';
case 'exited':
case 'stopped': return 'bg-red-50 text-red-700 border-red-200';
default: return 'bg-gray-50 text-gray-600 border-gray-200';
}
};
const getHealthColor = (health: string): string => {
switch (health.toLowerCase()) {
case 'healthy': return 'bg-green-50 text-green-700 border-green-200';
case 'unhealthy': return 'bg-red-50 text-red-700 border-red-200';
case 'starting': return 'bg-blue-50 text-blue-700 border-blue-200';
default: return 'bg-gray-50 text-gray-500 border-gray-200';
}
};
// Helper functions for status and severity colors
const getStatusColor = (status: string): string => {
switch (status) {
@ -332,7 +356,7 @@ const Docker: React.FC = () => {
Ports
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Severity
Update Risk
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
@ -376,9 +400,15 @@ const Docker: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn('badge', getSeverityColor(container.severity || 'medium'))}>
{container.severity || 'medium'}
</span>
{container.update_available && container.severity ? (
<span className={cn('badge', getSeverityColor(container.severity))}>
{container.severity}
</span>
) : container.update_available ? (
<span className="badge bg-gray-100 text-gray-600">unknown</span>
) : (
<span className="text-gray-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn('badge', getStatusColor(container.status))}>
@ -397,6 +427,87 @@ const Docker: React.FC = () => {
))}
</div>
)}
{/* Runtime Container State */}
{runtimeContainers.length > 0 && (
<div className="mt-6">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-gray-500" />
Container State
<span className="text-xs text-gray-500 font-normal">({runtimeContainers.length})</span>
</h2>
<div className="card overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-500 text-left">
<th className="pb-2 font-normal">Name</th>
<th className="pb-2 font-normal">Image</th>
<th className="pb-2 font-normal">State</th>
<th className="pb-2 font-normal">Health</th>
<th className="pb-2 font-normal">Stack</th>
<th className="pb-2 font-normal">Ports</th>
<th className="pb-2 font-normal">Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{runtimeContainers.map((c) => (
<tr key={c.id}>
<td className="py-2 font-mono text-gray-900">{c.name.replace(/^\//, '')}</td>
<td className="py-2 text-gray-600 max-w-[14rem] truncate" title={c.image}>{c.image}</td>
<td className="py-2">
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', getContainerStateColor(c.state))}>
{c.state}
</span>
</td>
<td className="py-2">
{c.health ? (
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', getHealthColor(c.health))}>
{c.health}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="py-2 text-gray-500">{c.stack_name || '—'}</td>
<td className="py-2 font-mono text-gray-500">{c.ports || '—'}</td>
<td className="py-2 text-gray-400">{formatRelativeTime(c.last_seen_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Compose Stacks */}
{runtimeStacks.length > 0 && (
<div className="mt-6">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Layers className="h-4 w-4 text-gray-500" />
Compose Stacks
<span className="text-xs text-gray-500 font-normal">({runtimeStacks.length})</span>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{runtimeStacks.map((s) => (
<div key={s.id} className="card">
<div className="flex items-center gap-2 mb-2">
<Layers className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 truncate">{s.name}</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn(
'font-medium',
s.running_count === s.container_count ? 'text-green-700' : 'text-amber-600'
)}>
{s.running_count}/{s.container_count} running
</span>
</div>
<div className="text-[10px] text-gray-400 mt-1">{formatRelativeTime(s.last_seen_at)}</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,689 @@
import React, { useState } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import {
Package,
Computer,
ChevronLeft,
ChevronRight,
AlertTriangle,
Clock,
Shield,
ExternalLink,
GitBranch,
Activity,
CheckCircle,
XCircle,
Loader2,
} from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import {
usePackageSummary,
usePackageAgents,
usePackageVersionsByCoords,
usePackageVulnerabilities,
useUpdate,
useUpdateLifecycle,
useApproveUpdate,
useInstallUpdate,
useReopenUpdate,
} from '@/hooks/useUpdates';
import { useRecentCommands } from '@/hooks/useCommands';
import {
getSeverityColor,
getStatusColor,
formatBytes,
formatRelativeTime,
advisoryUrl,
cveSeverityBadge,
} from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import DependencyClosureTree from '@/components/DependencyClosureTree';
import type { PackageFleetAgent } from '@/types';
const PackageDetail: React.FC = () => {
const { type: pkgType = '', name: pkgName = '' } = useParams<{ type: string; name: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const agentFilter = searchParams.get('agent');
const queryClient = useQueryClient();
const [approvingId, setApprovingId] = useState<string | null>(null);
const [installingId, setInstallingId] = useState<string | null>(null);
const [retryingId, setRetryingId] = useState<string | null>(null);
const { data: summary, isLoading: summaryLoading, error: summaryError } = usePackageSummary(pkgType, pkgName);
const { data: agentsData, isLoading: agentsLoading } = usePackageAgents(pkgType, pkgName);
const { data: versionsData } = usePackageVersionsByCoords(pkgType, pkgName);
const { data: vulnsData } = usePackageVulnerabilities(pkgType, pkgName);
// Agent-filtered: find the update_id for the selected agent
const agentRow: PackageFleetAgent | undefined = agentsData?.agents?.find(
(a) => a.agent_id === agentFilter
);
const agentUpdateId = agentRow?.update_id ?? '';
const { data: agentUpdate } = useUpdate(agentUpdateId, !!agentFilter && !!agentUpdateId);
const { data: lifecycleData } = useUpdateLifecycle(agentUpdateId, !!agentFilter && !!agentUpdateId);
const { data: recentCommandsData } = useRecentCommands(50);
const approveMutation = useApproveUpdate();
const installMutation = useInstallUpdate();
const reopenMutation = useReopenUpdate();
const handleApprove = async (updateId: string, hostname: string) => {
setApprovingId(updateId);
try {
await approveMutation.mutateAsync({ id: updateId });
toast.success(`Approved update for ${hostname}`);
queryClient.invalidateQueries({ queryKey: ['package-agents', pkgType, pkgName] });
} catch {
toast.error(`Failed to approve update for ${hostname}`);
} finally {
setApprovingId(null);
}
};
const handleInstall = async (updateId: string, hostname: string) => {
setInstallingId(updateId);
try {
await installMutation.mutateAsync(updateId);
toast.success(`Install queued for ${hostname}`);
queryClient.invalidateQueries({ queryKey: ['package-agents', pkgType, pkgName] });
} catch {
toast.error(`Failed to queue install for ${hostname}`);
} finally {
setInstallingId(null);
}
};
const handleRetry = async (updateId: string, hostname: string) => {
setRetryingId(updateId);
try {
await reopenMutation.mutateAsync(updateId);
toast.success(`Reopened ${hostname} for retry`);
queryClient.invalidateQueries({ queryKey: ['package-agents', pkgType, pkgName] });
} catch {
toast.error(`Failed to reopen update for ${hostname}`);
} finally {
setRetryingId(null);
}
};
if (summaryLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
);
}
if (summaryError || !summary) {
return (
<div className="p-6">
<button onClick={() => navigate('/updates')} className="btn btn-secondary mb-4 inline-flex items-center gap-1">
<ChevronLeft className="h-3.5 w-3.5" /> Updates
</button>
<p className="text-sm text-red-600">Package not found: {pkgType}/{pkgName}</p>
</div>
);
}
const agents = agentsData?.agents ?? [];
const versions = versionsData?.versions ?? [];
const vulns = vulnsData?.vulnerabilities ?? summary.vulnerabilities ?? [];
const pendingAgents = agents.filter((a) => a.can_approve);
const approvedAgents = agents.filter((a) => a.can_install);
const failedAgents = agents.filter((a) => a.can_retry);
return (
<div className="space-y-4 p-4 max-w-4xl">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<button onClick={() => navigate('/updates')} className="hover:text-gray-700 inline-flex items-center gap-1">
<ChevronLeft className="h-3 w-3" /> Updates
</button>
<span>/</span>
<span className="text-gray-700 font-medium">{pkgName}</span>
{agentFilter && agentRow && (
<>
<span>/</span>
<button
onClick={() => navigate(`/updates/package/${pkgType}/${pkgName}`)}
className="hover:text-gray-700"
>
fleet
</button>
<span>/</span>
<span className="text-gray-700">{agentRow.hostname}</span>
</>
)}
</div>
{/* Package header */}
<div className="card">
<div className="flex items-start gap-3">
<Package className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-base font-semibold text-gray-900">{pkgName}</h1>
<span className="text-xs text-gray-500 font-mono bg-gray-100 rounded px-1.5 py-0.5">{pkgType}</span>
{summary.severity && summary.severity !== 'unknown' && (
<span className={cn('badge', getSeverityColor(summary.severity))}>
{summary.severity}
</span>
)}
{vulns.length > 0 && (
<span className="badge bg-amber-50 text-amber-700 border border-amber-200 text-xs inline-flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{vulns.length} {vulns.length === 1 ? 'vuln' : 'vulns'}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 flex-wrap text-xs text-gray-500">
<span className="font-mono">
latest: <span className="text-gray-700">{summary.latest_available || '—'}</span>
</span>
{summary.latest_installed && (
<span className="font-mono">
installed: <span className="text-gray-700">{summary.latest_installed}</span>
</span>
)}
{summary.size_bytes > 0 && (
<span>{formatBytes(summary.size_bytes)}</span>
)}
{summary.homepage_url && (
<a
href={summary.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-gray-700"
>
homepage <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{summary.package_description && (
<p className="text-xs text-gray-600 mt-1.5">{summary.package_description}</p>
)}
</div>
</div>
</div>
{/* Agent-filtered view */}
{agentFilter ? (
<AgentDetailPane
agentRow={agentRow}
agentUpdate={agentUpdate}
lifecycleData={lifecycleData}
recentCommands={recentCommandsData?.commands ?? []}
pkgType={pkgType}
pkgName={pkgName}
/>
) : (
<>
{/* Fleet Status */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2">
<Computer className="h-4 w-4 text-gray-500" />
Fleet Status
<span className="text-xs text-gray-500 font-normal">({summary.total_agents})</span>
</h2>
<div className="flex items-center gap-1.5 flex-wrap">
{summary.installed_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-green-50 text-green-700 border-green-200">
{summary.installed_count} installed
</span>
)}
{summary.pending_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-blue-50 text-blue-700 border-blue-200">
{summary.pending_count} pending
</span>
)}
{summary.approved_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-indigo-50 text-indigo-700 border-indigo-200">
{summary.approved_count} approved
</span>
)}
{summary.active_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-amber-50 text-amber-700 border-amber-200">
{summary.active_count} active
</span>
)}
{summary.failed_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-red-50 text-red-700 border-red-200">
{summary.failed_count} failed
</span>
)}
</div>
</div>
{/* Bulk action bar */}
{(pendingAgents.length > 0 || approvedAgents.length > 0 || failedAgents.length > 0) && (
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-gray-100">
{pendingAgents.length > 1 && (
<button
className="btn btn-secondary text-xs"
onClick={() => {
pendingAgents.forEach((a) => handleApprove(a.update_id, a.hostname));
}}
>
Approve all pending ({pendingAgents.length})
</button>
)}
{approvedAgents.length > 1 && (
<button
className="btn btn-secondary text-xs"
onClick={() => {
approvedAgents.forEach((a) => handleInstall(a.update_id, a.hostname));
}}
>
Install all approved ({approvedAgents.length})
</button>
)}
</div>
)}
{agentsLoading ? (
<div className="flex items-center justify-center h-16">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
) : agents.length === 0 ? (
<p className="text-sm text-gray-500 italic">No agents tracking this package.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-500 text-left">
<th className="pb-2 font-normal">Agent</th>
<th className="pb-2 font-normal">Current</th>
<th className="pb-2 font-normal">Available</th>
<th className="pb-2 font-normal">Status</th>
<th className="pb-2 font-normal text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{agents.map((a) => (
<tr key={a.agent_id} className="group">
<td className="py-2">
<button
onClick={() =>
navigate(`/updates/package/${pkgType}/${pkgName}?agent=${a.agent_id}`)
}
className="inline-flex items-center gap-1 text-gray-900 hover:text-indigo-700 group-hover:underline"
>
<Computer className="h-3 w-3 text-gray-400 flex-shrink-0" />
{a.hostname}
</button>
</td>
<td className="py-2 font-mono text-gray-600">{a.current_version}</td>
<td className="py-2 font-mono text-gray-700">
{a.selected_version ? (
<span title={`pinned to ${a.selected_version}`}>
{a.available_version}{' '}
<span className="text-[10px] text-indigo-600 ml-1">[{a.selected_version}]</span>
</span>
) : (
a.available_version
)}
</td>
<td className="py-2">
<span className={cn('badge text-[10px]', getStatusColor(a.status))}>
{a.status.replace(/_/g, ' ')}
</span>
</td>
<td className="py-2 text-right">
{a.can_approve && (
<button
className="btn btn-secondary text-[10px] py-0.5 px-2"
disabled={approvingId === a.update_id}
onClick={() => handleApprove(a.update_id, a.hostname)}
>
{approvingId === a.update_id ? (
<Loader2 className="h-3 w-3 animate-spin inline" />
) : (
'Approve'
)}
</button>
)}
{a.can_install && (
<button
className="btn btn-secondary text-[10px] py-0.5 px-2"
disabled={installingId === a.update_id}
onClick={() => handleInstall(a.update_id, a.hostname)}
>
{installingId === a.update_id ? (
<Loader2 className="h-3 w-3 animate-spin inline" />
) : (
'Install'
)}
</button>
)}
{a.can_retry && (
<button
className="btn btn-secondary text-[10px] py-0.5 px-2"
disabled={retryingId === a.update_id}
onClick={() => handleRetry(a.update_id, a.hostname)}
>
{retryingId === a.update_id ? (
<Loader2 className="h-3 w-3 animate-spin inline" />
) : (
'Retry'
)}
</button>
)}
{!a.can_approve && !a.can_install && !a.can_retry && (
<button
className="btn btn-secondary text-[10px] py-0.5 px-2 opacity-60"
onClick={() =>
navigate(`/updates/package/${pkgType}/${pkgName}?agent=${a.agent_id}`)
}
>
Details
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Version Timeline */}
{versions.length > 0 && (
<div className="card">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Clock className="h-4 w-4 text-gray-500" />
Version Timeline
<span className="text-xs text-gray-500 font-normal">({versions.length})</span>
</h2>
<ul className="divide-y divide-gray-100">
{versions.map((v) => {
const osv = v.osv_status;
const osvCls =
osv === 'vulnerable'
? 'bg-amber-50 text-amber-700 border-amber-200'
: osv === 'clean'
? 'bg-green-50 text-green-700 border-green-200'
: 'bg-gray-50 text-gray-500 border-gray-200';
const isLatest = v.version === summary.latest_available;
const isInstalled = v.version === summary.latest_installed;
return (
<li key={v.id} className="py-2.5 flex items-center gap-3 flex-wrap">
<span className="text-sm font-mono text-gray-900 flex-shrink-0">{v.version}</span>
{isLatest && (
<span className="text-[10px] font-medium text-blue-700 bg-blue-50 rounded px-1.5 py-0.5">
available
</span>
)}
{isInstalled && isLatest && (
<span className="text-[10px] font-medium text-gray-600 bg-gray-100 rounded px-1.5 py-0.5">
installed
</span>
)}
{osv && (
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', osvCls)}>{osv}</span>
)}
{v.sha256 && (
<span
className="text-[10px] text-gray-400 font-mono flex-shrink-0"
title={v.sha256}
>
<Shield className="h-3 w-3 inline mr-0.5 text-green-500" />
{v.sha256.slice(0, 8)}
</span>
)}
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">
{v.published_at
? formatRelativeTime(v.published_at)
: formatRelativeTime(v.first_scanned_at)}
</span>
</li>
);
})}
</ul>
</div>
)}
{/* Supply Chain */}
{vulns.length > 0 && (
<div className="card">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Shield className="h-4 w-4 text-gray-500" />
Supply Chain
<span className="text-xs text-gray-500 font-normal">({vulns.length} vulnerabilities)</span>
</h2>
<ul className="space-y-2">
{vulns.map((v) => {
const sev = cveSeverityBadge(v.severity);
return (
<li key={v.id} className="bg-amber-50/40 border border-amber-100 rounded p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
{sev.label}
</span>
<a
href={advisoryUrl(v.id)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold text-amber-900 font-mono inline-flex items-center gap-1 hover:text-amber-700 hover:underline"
>
{v.id}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{v.summary && (
<p className="text-xs text-gray-700 mt-1">{v.summary}</p>
)}
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
{v.fixed_version && (
<span>
fixed in{' '}
<span className="font-mono text-green-700">{v.fixed_version}</span>
</span>
)}
{v.aliases && v.aliases.length > 0 && (
<span>
aliases:{' '}
{v.aliases.map((alias, i) => (
<React.Fragment key={alias}>
{i > 0 && ', '}
<a
href={advisoryUrl(alias)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-indigo-600 hover:underline"
>
{alias}
</a>
</React.Fragment>
))}
</span>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
<p className="text-xs text-gray-400 mt-3">Source: OSV.dev</p>
</div>
)}
</>
)}
</div>
);
};
// AgentDetailPane renders the agent-scoped lifecycle, history, and commands
// for a single agent row within this package's fleet.
interface AgentDetailPaneProps {
agentRow?: PackageFleetAgent;
agentUpdate: any;
lifecycleData: any;
recentCommands: any[];
pkgType: string;
pkgName: string;
}
const AgentDetailPane: React.FC<AgentDetailPaneProps> = ({
agentRow,
agentUpdate,
lifecycleData,
recentCommands,
pkgType,
pkgName,
}) => {
if (!agentRow) {
return <p className="text-sm text-gray-500 italic">Agent not found in fleet for this package.</p>;
}
const agentCommands = recentCommands.filter(
(cmd: any) =>
cmd.agent_id === agentRow.agent_id &&
cmd.package_name === pkgName &&
cmd.package_type === pkgType
);
const deps: string[] = agentUpdate?.metadata?.dependencies ?? [];
const closure = (() => {
try {
const raw = agentUpdate?.metadata?.resolved_closure;
if (raw && typeof raw === 'string') return JSON.parse(raw);
if (Array.isArray(raw)) return raw;
} catch {
// ignore
}
return undefined;
})();
return (
<div className="space-y-4">
{/* Agent context strip */}
<div className="card flex items-center gap-3 text-sm text-gray-700">
<Computer className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="font-medium">{agentRow.hostname}</span>
<span className={cn('badge', getStatusColor(agentRow.status))}>
{agentRow.status.replace(/_/g, ' ')}
</span>
<span className="text-xs text-gray-500 font-mono ml-auto">
{agentRow.current_version}
<ChevronRight className="inline h-3 w-3 mx-0.5 text-gray-400" />
{agentRow.available_version}
</span>
<Link
to={`/updates/${agentRow.update_id}`}
className="text-xs text-indigo-600 hover:underline ml-2 flex-shrink-0"
>
full detail
</Link>
</div>
{/* Dependency closure */}
{(deps.length > 0 || closure) && (
<div className="card">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<GitBranch className="h-4 w-4 text-gray-500" />
Dependencies
</h2>
<DependencyClosureTree dependencies={deps} closure={closure} />
</div>
)}
{/* Lifecycle history */}
{lifecycleData?.history?.length > 0 && (
<div className="card">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<GitBranch className="h-4 w-4 text-gray-500" />
Lifecycle History
<span className="text-xs text-gray-500 font-normal">({lifecycleData.count})</span>
</h2>
<ul className="divide-y divide-gray-100">
{lifecycleData.history.map((h: any) => {
const statusColors: Record<string, string> = {
installed: 'bg-green-50 text-green-700 border-green-200',
failed: 'bg-red-50 text-red-700 border-red-200',
rollback: 'bg-amber-50 text-amber-700 border-amber-200',
};
const cls = statusColors[h.update_status] || 'bg-gray-50 text-gray-600 border-gray-200';
const reason =
h.update_status === 'failed'
? h.failure_reason || h.metadata?.failure_reason
: null;
return (
<li key={h.id} className="py-2.5">
<div className="flex items-center gap-3 flex-wrap">
<span className={cn('text-[10px] font-medium border rounded px-1.5 py-0.5', cls)}>
{h.update_status}
</span>
<span className="text-sm font-mono text-gray-900">
{h.version_from} {h.version_to}
</span>
{reason && (
<span className="text-xs text-red-600 truncate max-w-xs" title={reason}>
{reason}
</span>
)}
<span className="text-xs text-gray-500 ml-auto flex-shrink-0">
{formatRelativeTime(h.update_completed_at)}
</span>
</div>
</li>
);
})}
</ul>
</div>
)}
{/* Recent commands */}
{agentCommands.length > 0 && (
<div className="card">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-gray-500" />
Commands
<span className="text-xs text-gray-500 font-normal">({agentCommands.length})</span>
</h2>
<ul className="space-y-1.5">
{agentCommands.slice(0, 10).map((cmd: any) => (
<li
key={cmd.id}
className="flex items-center gap-3 text-xs py-1.5 border-b border-gray-50 last:border-0"
>
{cmd.status === 'completed' ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : cmd.status === 'failed' ? (
<XCircle className="h-3.5 w-3.5 text-red-500 flex-shrink-0" />
) : (
<Clock className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
)}
<span className="font-mono text-gray-700 flex-shrink-0">{cmd.action}</span>
<span
className={cn(
'text-[10px] border rounded px-1 py-0.5 flex-shrink-0',
cmd.status === 'completed'
? 'bg-green-50 text-green-700 border-green-200'
: cmd.status === 'failed'
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-gray-50 text-gray-600 border-gray-200'
)}
>
{cmd.status}
</span>
<span className="text-gray-400 ml-auto flex-shrink-0">
{cmd.created_at ? formatRelativeTime(cmd.created_at) : '—'}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default PackageDetail;

View file

@ -29,48 +29,12 @@ import { useQueryClient } from '@tanstack/react-query';
import { useUpdates, useUpdate, usePackages, usePackageFleet, usePackageVersions, useUpdateLifecycle, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useReopenUpdate, useResolveUpdate, useCancelCommand } from '@/hooks/useUpdates';
import { useRecentCommands } from '@/hooks/useCommands';
import type { UpdatePackage } from '@/types';
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { updateApi } from '@/lib/api';
import DependencyClosureTree from '@/components/DependencyClosureTree';
// advisoryUrl resolves a vulnerability/alias ID to its canonical advisory page,
// following Dependency-Track's alias-linking pattern: each ID prefix routes to
// the authority that issued it. Unknown prefixes fall back to OSV.dev, which
// aggregates most advisories.
const advisoryUrl = (id: string): string => {
const up = id.toUpperCase();
if (up.startsWith('CVE-')) return `https://nvd.nist.gov/vuln/detail/${id}`;
if (up.startsWith('GHSA-')) return `https://github.com/advisories/${id}`;
if (up.startsWith('ALSA-')) return `https://errata.almalinux.org/${id.split('-')[1]}/${id}.html`;
if (up.startsWith('RHSA-') || up.startsWith('RHBA-') || up.startsWith('RLSA-'))
return `https://access.redhat.com/errata/${id}`;
if (up.startsWith('DSA-')) return `https://security-tracker.debian.org/tracker/${id}`;
if (up.startsWith('USN-')) return `https://ubuntu.com/security/notices/${id}`;
if (up.startsWith('PYSEC-') || up.startsWith('GO-') || up.startsWith('OSV-'))
return `https://osv.dev/vulnerability/${id}`;
return `https://osv.dev/vulnerability/${id}`;
};
// CVE severity → badge classes. OSV reports qualitative severity for GHSA
// (npm/PyPI) as LOW/MODERATE/HIGH/CRITICAL; treat MODERATE and MEDIUM alike.
const cveSeverityBadge = (severity?: string): { label: string; cls: string } => {
const s = (severity || '').toUpperCase();
switch (s) {
case 'CRITICAL':
return { label: 'CRITICAL', cls: 'bg-red-100 text-red-800 border-red-300' };
case 'HIGH':
return { label: 'HIGH', cls: 'bg-red-50 text-red-700 border-red-200' };
case 'MODERATE':
case 'MEDIUM':
return { label: 'MEDIUM', cls: 'bg-amber-100 text-amber-800 border-amber-300' };
case 'LOW':
return { label: 'LOW', cls: 'bg-yellow-50 text-yellow-700 border-yellow-200' };
default:
return { label: 'UNSCORED', cls: 'bg-gray-100 text-gray-600 border-gray-300' };
}
};
const Updates: React.FC = () => {
const { id } = useParams<{ id?: string }>();
@ -1957,7 +1921,7 @@ const Updates: React.FC = () => {
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg flex-shrink-0">{getPackageTypeIcon(pkg.package_type)}</span>
<button
onClick={() => navigate(`/updates/${pkg.representative_id}`)}
onClick={() => navigate(`/updates/package/${pkg.package_type}/${pkg.package_name}`)}
className="text-sm font-medium text-gray-900 hover:text-primary-600 truncate block max-w-[16rem]"
title={pkg.package_name}
>
@ -2020,7 +1984,7 @@ const Updates: React.FC = () => {
</td>
<td className="table-cell">
<button
onClick={() => navigate(`/updates/${pkg.representative_id}`)}
onClick={() => navigate(`/updates/package/${pkg.package_type}/${pkg.package_name}`)}
className="text-gray-400 hover:text-primary-600"
title="View package detail"
>

View file

@ -158,6 +158,32 @@ export interface DockerContainerListResponse {
total_pages: number;
}
// Runtime container state reported by agents (DOCKER-ENRICHED-SCAN)
export interface RuntimeDockerContainer {
id: string;
agent_id: string;
container_id: string;
name: string;
image: string;
image_id: string;
state: string;
health: string;
stack_name: string;
ports: string;
created_at_epoch: number;
labels: Record<string, string>;
last_seen_at: string;
}
export interface RuntimeDockerStack {
id: string;
agent_id: string;
name: string;
container_count: number;
running_count: number;
last_seen_at: string;
}
export interface DockerStats {
total_containers: number;
running_containers: number;
@ -264,6 +290,52 @@ export interface PackageFleetAgent {
severity: string;
expected_sha256: string | null;
last_updated_at: string;
selected_version: string | null;
can_approve: boolean;
can_install: boolean;
can_retry: boolean;
}
export interface VulnerabilityEntry {
id: string;
summary?: string;
severity?: string;
description?: string;
source?: string;
fixed_version?: string;
aliases?: string[];
}
export interface PackageSummary {
package_type: string;
package_name: string;
severity: string;
package_description: string;
homepage_url: string;
size_bytes: number;
cve_list: string[];
vulnerabilities: VulnerabilityEntry[];
total_agents: number;
pending_count: number;
approved_count: number;
active_count: number;
installed_count: number;
failed_count: number;
ignored_count: number;
latest_available: string;
latest_installed: string | null;
}
export interface PackageAgentsResponse {
package_type: string;
package_name: string;
agents: PackageFleetAgent[];
}
export interface PackageVulnerabilitiesResponse {
package_type: string;
package_name: string;
vulnerabilities: VulnerabilityEntry[];
}
export interface PackageFleetResponse {