feat: docker enrichment pipeline, package detail, update history pagination
This commit is contained in:
parent
d1424e8377
commit
f005255e68
25 changed files with 1791 additions and 185 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ---------------------------
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
689
web/src/pages/PackageDetail.tsx
Normal file
689
web/src/pages/PackageDetail.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue