v0.2.6.7 — GATE-006 D, version row OSV check
- evaluateSupplyChainHold checks version row osv_status for target version, closing hole where we OSV-check one version but install another. - confirm_dependencies uses TransitionByPackageFrom with source-state guard (StatusInstalling → Installed/Failed). - Clears pinned version on successful install. - Version bump to v0.2.6.7.
This commit is contained in:
parent
14cd0884b4
commit
7d089ab5d2
9 changed files with 339 additions and 51 deletions
127
CHANGELOG.md
127
CHANGELOG.md
|
|
@ -6,6 +6,133 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
|
|||
|
||||
---
|
||||
|
||||
## v0.2.6.6 (June 2026)
|
||||
|
||||
### Fixed
|
||||
- Windows process logging now writes `agent.log` before the console stream, so
|
||||
service-mode runs still produce file diagnostics when `stderr` is unavailable
|
||||
or invalid under Windows Service Manager.
|
||||
- Agent startup logs a `process_logger_initialized` marker after the durable log
|
||||
sink is configured.
|
||||
- Windows CPU telemetry fallback now parses PowerShell CIM JSON structurally,
|
||||
restoring core/thread counts on hosts where `wmic` is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.6.5 (June 2026)
|
||||
|
||||
### Fixed
|
||||
- Windows agent startup now wires the standard logger to
|
||||
`C:\ProgramData\RedFlag\logs\agent.log`, so service-mode diagnostics are
|
||||
available without relying on Event Viewer rendering.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.6.4 (June 2026)
|
||||
|
||||
### Fixed
|
||||
- Windows one-liner self-authenticates again (`irm | iex` path repaired).
|
||||
- Install script served as CRLF for Windows compatibility.
|
||||
- Install script bakes reachable host into URL, not server bind address.
|
||||
- Agent install URL preserves port for non-localhost hosts.
|
||||
- Windows installer tolerates missing Ed25519 verifier on cold start.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.6.2 (June 2026)
|
||||
|
||||
### Changed
|
||||
- OSV scanning moved from approval to detection — `enqueueOSVChecks` runs on the
|
||||
scan-report path, writes results to metadata. Approval reads persisted verdict
|
||||
instead of re-scanning inline.
|
||||
- Soak gate (`GATE-005`) promoted to a real policy: `supply_chain.soak_window_days`
|
||||
and `soak_enforcement` resolve env → config → DB → default. Was env-only.
|
||||
- Age gate (`package_age.go`) wired to DB config via `GetSupplyChainGateConfig`.
|
||||
Added opt-in `block_unknown_age` (default false = sovereignty).
|
||||
|
||||
### Removed
|
||||
- Dead soak override scaffolding: dropped `soak_window_hours_override` column,
|
||||
`version_soak_overrides` table, and `SoakWindowHoursOverride` model field.
|
||||
Migration 053.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.6.1 (June 2026)
|
||||
|
||||
### Added
|
||||
- RECONCILE-001: scan-set closure (close-by-absence) reconciler. Packages that
|
||||
vanish from a successful scan are closed as `not_applicable`, fixing out-of-band
|
||||
false positives.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.6.0 (June 2026)
|
||||
|
||||
### Added
|
||||
- FEAT-002: metadata pipeline — agent → server → metadata transport for upstream
|
||||
intelligence, CVE details, and package provenance.
|
||||
- GATE-005: version soak-gating configuration.
|
||||
- BRIDGE-001: auto-discovery bridge (Repology, container registry, exact match).
|
||||
- Docker enrichment pipeline: container image detail, update history pagination.
|
||||
- Filter/search primitives — composable UI components and hooks.
|
||||
|
||||
### Fixed
|
||||
- SPA nav hygiene + history crosslinks.
|
||||
- Code review batch 1 + dead store setting cleanup.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.5.2 (June 2026)
|
||||
|
||||
### Added
|
||||
- SETTINGS-001: reversible token encryption + one-liner restore.
|
||||
|
||||
### Fixed
|
||||
- Heartbeat auto-queue treats duplicate-pending as benign (ETHOS #4 — idempotency).
|
||||
|
||||
---
|
||||
|
||||
## v0.2.5.1 (June 2026)
|
||||
|
||||
### Added
|
||||
- Lifecycle history section on update detail page: shows `update_version_history`
|
||||
entries with status badges, version transitions, failure reasons, timestamps.
|
||||
- Failed state recovery: `failed` is no longer terminal — transitions to `pending`
|
||||
(reopen), `installed` (resolve), or `ignored`. `IsTerminal()` means scan-stable,
|
||||
not transition-locked.
|
||||
- Reopen/resolve endpoints: `POST /updates/:id/reopen` and `POST /updates/:id/resolve`.
|
||||
Replaced broken `RetryUpdate` which was structurally blind to capability-token installs.
|
||||
- Failure metadata on live row: `failure_reason` and `failed_by` stamped on transition
|
||||
into failed state.
|
||||
|
||||
### Changed
|
||||
- History page renamed to Fleet Activity, uses `GetFleetActivity` endpoint.
|
||||
|
||||
### Fixed
|
||||
- History 500 error on load.
|
||||
- Dead unified history substrate removed.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.3.5 (June 2026)
|
||||
|
||||
### Added
|
||||
- Unified agent + helper upgrade: closure carries both binaries, helper installs
|
||||
agent on verify.
|
||||
- `update_logs.result` extended with `started`/`partial`/`running` states — fixes
|
||||
agent-report badge semantics.
|
||||
|
||||
### Security
|
||||
- Path traversal fixes across file-serving endpoints.
|
||||
- File permissions tightened (result files 0644 so agent can read back from root-owned helper).
|
||||
- Sudoers and polkit scope narrowed.
|
||||
- Staging cleanup.
|
||||
|
||||
### Fixed
|
||||
- Self-update and gated installs work on fresh hosts (first-time registration path).
|
||||
|
||||
---
|
||||
|
||||
## v0.2.3.1 (June 2026)
|
||||
|
||||
### Security
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
**Self-hosted update management for operators who own their stack.**
|
||||
|
||||
`v0.2.3.1` — June 2026 · MIT License
|
||||
`v0.2.6.6` — June 2026 · MIT License
|
||||
|
||||
> **You're early — nearly 600 of you cloned this before it was announced.**
|
||||
> **You're early — over 1,000 of you cloned this before it was announced.**
|
||||
> A stable release is coming soon, bringing Windows support back fully gated.
|
||||
> I'll be pinning a release version this week to mark the start of stabilization —
|
||||
> focused on hardening what's here rather than shipping new features.
|
||||
> If you want it to keep existing, [sponsor the work](#sponsorship--consulting).
|
||||
|
||||
---
|
||||
|
|
@ -152,20 +154,27 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
|
|||
- Linux and Windows agent registration and update management
|
||||
- APT, DNF, Winget, Windows Update, Docker image scanning
|
||||
- Package state machine with enforced transitions and lifecycle orchestrator
|
||||
- Failed state recovery: reopen, resolve, and transition out of failed
|
||||
- Lifecycle history with status badges, version transitions, and failure reasons
|
||||
- Scan-set closure reconciler (close-by-absence) — fixes out-of-band false positives
|
||||
- Dry-run dependency checking with full closure resolution
|
||||
- Supply chain gate: OSV batch checks across transitive closures, vuln-is-a-full-stop enforcement, audited override path
|
||||
- Version soak-gating and package age gate as configurable policies
|
||||
- Capability-token minting for dnf/apt with Ed25519-signed token verification
|
||||
- Ed25519 key rotation and replay protection
|
||||
- Maintenance windows
|
||||
- Upstream version tracking (GitHub, Gitea, GitLab, Bitbucket, Repology, endoflife.date)
|
||||
- Metadata pipeline: CVE details, upstream intelligence, package provenance
|
||||
- Auto-discovery bridge (Repology, container registry, exact match)
|
||||
- Agent self-update via privileged helper (zero agent sudo)
|
||||
- Reversible token encryption with one-liner restore
|
||||
- Real-time heartbeat and rapid polling
|
||||
|
||||
**Not yet done:**
|
||||
- Live end-to-end gate test (GATE-002)
|
||||
- Windows installer stubs report fake success (CRITICAL-004)
|
||||
- No AUR, Snap, Flatpak, or Homebrew support
|
||||
- macOS agent binaries not signed
|
||||
- Mobile dashboard usable, not optimized
|
||||
- Cert pinning and enforced TLS verification
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -247,17 +256,25 @@ I am a Systems Architect with 25 years on the frontier. I build sovereign agent
|
|||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full history. Recent highlights:
|
||||
|
||||
**v0.2.3.1** — Supply chain gate hardened: vuln in the dependency closure is a full stop at approval. Audited operator override path. Ack tracking fixed (no more 34-deep recycling).
|
||||
**v0.2.6.6** — Windows agent service logging treats `agent.log` as the primary sink, even when service console handles are unavailable.
|
||||
|
||||
**v0.2.3.0** — OSV batch checks across full dependency closures. DNF dry-run detection fix. Auto-confirm frisks the whole closure.
|
||||
**v0.2.6.5** — Windows agent service logs now write to `C:\ProgramData\RedFlag\logs\agent.log`.
|
||||
|
||||
**v0.2.2.0** — Package state machine enforced. Lifecycle orchestrator foundation. Vulnerability dashboard.
|
||||
**v0.2.6.4** — Windows installer fixes: CRLF, reachable host, port preservation, Ed25519 cold-start tolerance.
|
||||
|
||||
**v0.2.1.1** — Zero-sudo agent. Helper self-upgrade. OSV expansion to apt/dnf. Staging page.
|
||||
**v0.2.6.2** — OSV scans moved to detection. Soak gate promoted to real policy. Dead scaffolding retired.
|
||||
|
||||
**v0.2.1.0** — Token-is-the-command. Helper privilege split. DiscoveryRunner. Installer interface shrunk to 4 methods.
|
||||
**v0.2.6.0** — Metadata pipeline, auto-discovery bridge, Docker enrichment, filter/search primitives.
|
||||
|
||||
**v0.2.0.7** — Refresh-token rotation. Machine-bound renewal. No unsigned binaries.
|
||||
**v0.2.5.1** — Failed state recovery, lifecycle history, reopen/resolve endpoints.
|
||||
|
||||
**v0.2.5.2** — Reversible token encryption. Idempotent heartbeat auto-queue.
|
||||
|
||||
**v0.2.3.5** — Unified agent+helper upgrade. Path traversal fixes. Self-update on fresh hosts.
|
||||
|
||||
**v0.2.3.1** — Supply chain gate hardened: vuln is a full stop. Ack tracking fixed.
|
||||
|
||||
**v0.2.2.0** — Package state machine enforced. Lifecycle orchestrator foundation.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ services:
|
|||
context: .
|
||||
dockerfile: ./server/Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.6.4}
|
||||
BUILD_VERSION: ${BUILD_VERSION:-0.2.6.7}
|
||||
container_name: redflag-server
|
||||
volumes:
|
||||
- server-config:/app/config
|
||||
|
|
|
|||
|
|
@ -929,6 +929,26 @@ func recordPackageAgeMetadata(update *models.UpdateState, dec services.PackageAg
|
|||
// OSV-checked AND clean. Mirrors the auto-confirm gate (orchestrator.closureCleared)
|
||||
// via the shared models predicates.
|
||||
func (h *UpdateHandler) evaluateSupplyChainHold(update *models.UpdateState, freshVulns []services.VulnerabilityInfo) supplyChainHold {
|
||||
// GATE-006 D: check the version row's osv_status for the target version
|
||||
// (the version that will actually install), not just the metadata side-channel.
|
||||
// This closes the hole where we OSV-check one version but install another.
|
||||
targetVersion := update.AvailableVersion
|
||||
if update.SelectedVersion != nil && *update.SelectedVersion != "" {
|
||||
targetVersion = *update.SelectedVersion
|
||||
}
|
||||
if targetVersion != "" {
|
||||
if osvStatus, osvVulns, err := h.updateQueries.GetVersionOSVStatus(update.PackageType, update.PackageName, targetVersion); err == nil && osvStatus == "vulnerable" {
|
||||
var vulns []services.VulnerabilityInfo
|
||||
if len(osvVulns) > 0 {
|
||||
_ = json.Unmarshal(osvVulns, &vulns)
|
||||
}
|
||||
return supplyChainHold{blocked: true, reason: fmt.Sprintf("target version %s has known vulnerabilities (version row)", targetVersion), vulns: vulns}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: metadata side-channel (detection-time verdict). Still needed for
|
||||
// packages not yet in the version catalog or where the version row has no
|
||||
// osv_status yet.
|
||||
if len(freshVulns) > 0 || models.MetadataHasVulns(*update, "supply_chain_vulns") {
|
||||
return supplyChainHold{blocked: true, reason: "known vulnerabilities in the package", vulns: freshVulns}
|
||||
}
|
||||
|
|
@ -1372,7 +1392,9 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
agentID, commandID, markErr)
|
||||
}
|
||||
|
||||
// NEW: If this was a successful confirm_dependencies command, mark the package as updated
|
||||
// If this was a successful confirm_dependencies command, close the
|
||||
// installing package. The source-state guard keeps stale or replayed
|
||||
// command results from resolving a pending/approved update.
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
|
||||
// Extract package info from command params
|
||||
|
|
@ -1386,11 +1408,16 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Update package status to 'updated' with actual completion timestamp
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusInstalled, nil, completionTime); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
if err := h.updateQueries.TransitionByPackageFrom(agentID, packageType, packageName, models.StatusInstalling, models.StatusInstalled, nil, completionTime); err != nil {
|
||||
log.Printf("[ERROR] [server] [updates] confirm_dependencies_status_update_failed package=%s type=%s to=%s error=%v",
|
||||
packageName, packageType, models.StatusInstalled, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_updated package=%s type=%s", packageName, packageType)
|
||||
log.Printf("[INFO] [server] [updates] confirm_dependencies_status_updated package=%s type=%s status=%s",
|
||||
packageName, packageType, models.StatusInstalled)
|
||||
// Clear any pinned version so subsequent scans pick up the latest available
|
||||
if upd, lookupErr := h.updateQueries.GetUpdateByPackage(agentID, packageType, packageName); lookupErr == nil {
|
||||
_ = h.updateQueries.ClearTargetVersion(upd.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1401,15 +1428,18 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
agentID, commandID, markErr)
|
||||
}
|
||||
|
||||
// If this was a failed confirm_dependencies command, mark the package as failed
|
||||
// If this was a failed confirm_dependencies command, close the
|
||||
// installing package as failed with the same source-state guard.
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
|
||||
if packageName, ok := command.Params["package_name"].(string); ok {
|
||||
if packageType, ok := command.Params["package_type"].(string); ok {
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusFailed, nil, nil); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
if err := h.updateQueries.TransitionByPackageFrom(agentID, packageType, packageName, models.StatusInstalling, models.StatusFailed, nil, nil); err != nil {
|
||||
log.Printf("[ERROR] [server] [updates] confirm_dependencies_status_update_failed package=%s type=%s to=%s error=%v",
|
||||
packageName, packageType, models.StatusFailed, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_failed package=%s type=%s", packageName, packageType)
|
||||
log.Printf("[INFO] [server] [updates] confirm_dependencies_status_updated package=%s type=%s status=%s",
|
||||
packageName, packageType, models.StatusFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2979,6 +3009,10 @@ func (h *UpdateHandler) ReportCapabilityResult(c *gin.Context) {
|
|||
} else {
|
||||
log.Printf("[INFO] [server] [capability] receipt_status_updated token_id=%s update_id=%s status=%s",
|
||||
tokenID, updateID, status)
|
||||
// Clear any pinned version on successful install
|
||||
if status == models.StatusInstalled {
|
||||
_ = h.updateQueries.ClearTargetVersion(updateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,25 @@ func (q *UpdateQueries) GetPackageVersions(packageType, packageName string) ([]m
|
|||
return rows, nil
|
||||
}
|
||||
|
||||
// GetVersionOSVStatus returns the osv_status and osv_vulns for a specific version
|
||||
// from the package_versions catalog. Returns ("", nil) when no row exists for that
|
||||
// version — the caller treats this as "not yet checked" (distinct from "clean").
|
||||
func (q *UpdateQueries) GetVersionOSVStatus(packageType, packageName, version string) (string, []byte, error) {
|
||||
query := `SELECT osv_status, osv_vulns FROM package_versions
|
||||
WHERE package_type = $1 AND package_name = $2 AND version = $3`
|
||||
var row struct {
|
||||
OSVStatus string `db:"osv_status"`
|
||||
OSVVulns []byte `db:"osv_vulns"`
|
||||
}
|
||||
if err := q.db.Get(&row, query, packageType, packageName, version); err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
return row.OSVStatus, row.OSVVulns, nil
|
||||
}
|
||||
|
||||
// AggregatedPackage is one package rolled up across the whole fleet — the row the
|
||||
// package-centric Updates view renders. Versions are scalar samples (exact when the
|
||||
// fleet agrees, otherwise a count tells the UI to say "N versions"). RepresentativeID
|
||||
|
|
@ -645,6 +664,22 @@ func (q *UpdateQueries) TransitionByPackage(agentID uuid.UUID, packageType, pack
|
|||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransitionByPackageFrom applies a package transition only when the row is
|
||||
// currently in the expected source state. Use this for command-result paths
|
||||
// where the state machine has broader edges for reconciler or operator flows.
|
||||
func (q *UpdateQueries) TransitionByPackageFrom(agentID uuid.UUID, packageType, packageName string, from, to models.PackageStatus, historyMeta models.JSONB, completedAt *time.Time) error {
|
||||
return q.runTransitionOpts(
|
||||
selByPackage(agentID, packageType, packageName),
|
||||
to,
|
||||
transitionOpts{
|
||||
completedAt: completedAt,
|
||||
historyMeta: historyMeta,
|
||||
requireFrom: from,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// GetPackagesInStatus returns every current_package_state row currently in the
|
||||
// given status, ordered oldest-first by last_updated_at so the orchestrator
|
||||
// sweeps the longest-waiting packages first.
|
||||
|
|
@ -954,7 +989,11 @@ func (q *UpdateQueries) UpdateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
|
|||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
|
||||
ON CONFLICT (agent_id, package_type, package_name)
|
||||
DO UPDATE SET
|
||||
current_version = EXCLUDED.current_version,
|
||||
current_version = CASE
|
||||
WHEN current_package_state.status = 'installing'
|
||||
THEN current_package_state.current_version
|
||||
ELSE EXCLUDED.current_version
|
||||
END,
|
||||
available_version = EXCLUDED.available_version,
|
||||
severity = EXCLUDED.severity,
|
||||
repository_source = EXCLUDED.repository_source,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
// Build-time injected version information (SERVER AUTHORITY)
|
||||
var (
|
||||
AgentVersion = "0.2.6.4"
|
||||
ConfigVersion = "0.2.6.4"
|
||||
AgentVersion = "0.2.6.7"
|
||||
ConfigVersion = "0.2.6.7"
|
||||
MinAgentVersion = "0.1.22"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
|
|
@ -47,6 +47,10 @@ const TAB_GROUPS: { key: string; label: string; statuses: string }[] = [
|
|||
{ key: 'failed-ignored', label: 'Failed / Ignored', statuses: 'failed,ignored' },
|
||||
];
|
||||
|
||||
const parseAgentUpdatesTab = (tab: string | null) => {
|
||||
return TAB_GROUPS.some(group => group.key === tab) ? tab || TAB_GROUPS[0].key : TAB_GROUPS[0].key;
|
||||
};
|
||||
|
||||
const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class: string }> = {
|
||||
pending: { label: 'Pending', icon: <Clock className="h-3 w-3" />, class: 'text-gray-600 bg-gray-100' },
|
||||
approved: { label: 'Approved', icon: <Check className="h-3 w-3" />, class: 'text-blue-600 bg-blue-100' },
|
||||
|
|
@ -59,7 +63,8 @@ const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class:
|
|||
};
|
||||
|
||||
export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpdatesEnhancedProps) {
|
||||
const [activeTab, setActiveTab] = useState(TAB_GROUPS[0].key);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = parseAgentUpdatesTab(searchParams.get('updates_tab'));
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -75,6 +80,21 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
const queryClient = useQueryClient();
|
||||
const activeGroup = TAB_GROUPS.find(g => g.key === activeTab) || TAB_GROUPS[0];
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedUpdates([]);
|
||||
}, [activeTab]);
|
||||
|
||||
const selectActiveTab = (tab: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (tab === TAB_GROUPS[0].key) {
|
||||
params.delete('updates_tab');
|
||||
} else {
|
||||
params.set('updates_tab', tab);
|
||||
}
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
// Fetch updates with status filter
|
||||
const { data: updateData, isLoading, error, refetch } = useQuery<AgentUpdateResponse>({
|
||||
queryKey: ['agent-updates', agentId, activeGroup.statuses, currentPage, pageSize, searchTerm, selectedSeverity],
|
||||
|
|
@ -314,7 +334,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
|
|||
{TAB_GROUPS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => { setActiveTab(tab.key); setCurrentPage(1); setSelectedUpdates([]); }}
|
||||
onClick={() => selectActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'px-4 py-2 border-b-2 transition-colors',
|
||||
activeTab === tab.key
|
||||
|
|
|
|||
|
|
@ -54,6 +54,32 @@ interface ChatTimelineProps {
|
|||
externalSearch?: string; // external search query from parent
|
||||
}
|
||||
|
||||
const entryPackageName = (entry: HistoryEntry): string | undefined => {
|
||||
if (entry.package_name) return entry.package_name;
|
||||
return typeof entry.params?.package_name === 'string' ? entry.params.package_name : undefined;
|
||||
};
|
||||
|
||||
const entryPackageType = (entry: HistoryEntry): string | undefined => {
|
||||
if (entry.package_type) return entry.package_type;
|
||||
return typeof entry.params?.package_type === 'string' ? entry.params.package_type : undefined;
|
||||
};
|
||||
|
||||
const entryUpdateLink = (entry: HistoryEntry): string | undefined => {
|
||||
if (typeof entry.params?.update_id === 'string' && entry.params.update_id) {
|
||||
return `/updates/${entry.params.update_id}`;
|
||||
}
|
||||
|
||||
const packageName = entryPackageName(entry);
|
||||
if (!packageName) return undefined;
|
||||
|
||||
const packageType = entryPackageType(entry);
|
||||
if (packageType) {
|
||||
return `/updates/package/${packageType}/${packageName}`;
|
||||
}
|
||||
|
||||
return `/updates?search=${encodeURIComponent(packageName)}`;
|
||||
};
|
||||
|
||||
// Helper function to create smart summaries for package operations
|
||||
const createPackageOperationSummary = (entry: HistoryEntry): string => {
|
||||
const action = entry.action.replace(/_/g, ' ');
|
||||
|
|
@ -635,6 +661,8 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
onToggle: () => void;
|
||||
}> = ({ entry, isExpanded, isScopedView: _isScopedView, onToggle }) => {
|
||||
const narrative = getNarrativeSummary(entry);
|
||||
const packageName = entryPackageName(entry);
|
||||
const updateLink = entryUpdateLink(entry);
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg transition-all duration-200">
|
||||
|
|
@ -736,6 +764,17 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
{/* Critical vitals - always visible in collapsed view */}
|
||||
<div className="mt-2 ml-8 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{!_isScopedView && entry.agent_id && (
|
||||
<span>
|
||||
<span className="font-medium">Agent:</span>{' '}
|
||||
<Link
|
||||
to={`/agents/${entry.agent_id}`}
|
||||
className="text-blue-700 hover:text-blue-900 hover:underline"
|
||||
>
|
||||
{entry.hostname || `${entry.agent_id.slice(0, 8)}...`}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<span className="font-medium">Action:</span> {entry.action.replace(/_/g, ' ')}
|
||||
</span>
|
||||
|
|
@ -745,9 +784,15 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
<span className="text-gray-500"> (Exit Code: {entry.exit_code})</span>
|
||||
)}
|
||||
</span>
|
||||
{entry.package_name && (
|
||||
{packageName && updateLink && (
|
||||
<span>
|
||||
<span className="font-medium">Package:</span> {entry.package_name}
|
||||
<span className="font-medium">Package:</span>{' '}
|
||||
<Link
|
||||
to={updateLink}
|
||||
className="text-green-700 hover:text-green-900 hover:underline"
|
||||
>
|
||||
{packageName}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{narrative.subject && narrative.subject !== 'system operation' && narrative.subject !== entry.package_name && (
|
||||
|
|
@ -793,11 +838,11 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
<span className="text-gray-500 font-medium">Command ID</span>
|
||||
<span className="font-mono text-gray-800 break-all">{entry.id}</span>
|
||||
</div>
|
||||
{entry.package_name && (
|
||||
{packageName && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-500 font-medium">Package</span>
|
||||
<span className="text-gray-800 truncate" title={entry.package_name}>
|
||||
{entry.package_name}
|
||||
<span className="text-gray-800 truncate" title={packageName}>
|
||||
{packageName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1051,23 +1096,13 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
{entry.hostname || 'View Agent'}
|
||||
</Link>
|
||||
|
||||
{entry.package_name && entry.package_type && (
|
||||
{packageName && updateLink && (
|
||||
<Link
|
||||
to={`/updates/package/${entry.package_type}/${entry.package_name}`}
|
||||
to={updateLink}
|
||||
className="inline-flex items-center px-2.5 py-1.5 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors font-medium"
|
||||
>
|
||||
<Package className="h-3 w-3 mr-1" />
|
||||
{entry.package_name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{entry.package_name && !entry.package_type && (
|
||||
<Link
|
||||
to={`/updates?search=${encodeURIComponent(entry.package_name)}`}
|
||||
className="inline-flex items-center px-2.5 py-1.5 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors font-medium"
|
||||
>
|
||||
<Package className="h-3 w-3 mr-1" />
|
||||
{entry.package_name}
|
||||
{packageName}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
|
|
@ -1233,4 +1268,4 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
);
|
||||
};
|
||||
|
||||
export default ChatTimeline;
|
||||
export default ChatTimeline;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import toast from 'react-hot-toast';
|
|||
import { updateApi } from '@/lib/api';
|
||||
import DependencyClosureTree from '@/components/DependencyClosureTree';
|
||||
|
||||
type UpdatesTab = 'updates' | 'commands';
|
||||
|
||||
const parseUpdatesTab = (tab: string | null): UpdatesTab => {
|
||||
return tab === 'commands' ? 'commands' : 'updates';
|
||||
};
|
||||
|
||||
const Updates: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
|
|
@ -61,11 +66,12 @@ const Updates: React.FC = () => {
|
|||
const [pendingDependencies, setPendingDependencies] = useState<string[]>([]);
|
||||
const [dependencyUpdateId, setDependencyUpdateId] = useState<string | null>(null);
|
||||
const [dependencyLoading, setDependencyLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'updates' | 'commands'>('updates');
|
||||
const activeTab = parseUpdatesTab(searchParams.get('tab'));
|
||||
|
||||
// Store filters in URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (activeTab !== 'updates') params.set('tab', activeTab);
|
||||
if (debouncedSearchQuery) params.set('search', debouncedSearchQuery);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
|
|
@ -78,7 +84,17 @@ const Updates: React.FC = () => {
|
|||
if (pageSize !== 100) params.set('page_size', pageSize.toString());
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, sortBy, sortOrder, currentPage, pageSize]);
|
||||
}, [activeTab, debouncedSearchQuery, statusFilter, severityFilter, typeFilter, agentFilter, vulnFilter, sortBy, sortOrder, currentPage, pageSize, setSearchParams]);
|
||||
|
||||
const selectActiveTab = (tab: UpdatesTab) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (tab === 'updates') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', tab);
|
||||
}
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
// Fetch updates list (still used for the summary stat cards + detail fallback)
|
||||
const { data: updatesData } = useUpdates({
|
||||
|
|
@ -1384,7 +1400,7 @@ const Updates: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('updates')}
|
||||
onClick={() => selectActiveTab('updates')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
← Back to Updates
|
||||
|
|
@ -1736,7 +1752,7 @@ const Updates: React.FC = () => {
|
|||
|
||||
{/* Command History button */}
|
||||
<button
|
||||
onClick={() => setActiveTab('commands')}
|
||||
onClick={() => selectActiveTab('commands')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
|
|
@ -2012,4 +2028,4 @@ const Updates: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Updates;
|
||||
export default Updates;
|
||||
|
|
|
|||
Loading…
Reference in a new issue