Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-08 16:01:05 -04:00
commit 7d089ab5d2
9 changed files with 339 additions and 51 deletions

View file

@ -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

View file

@ -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.
---

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -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,

View file

@ -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"
)

View file

@ -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

View file

@ -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;

View file

@ -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;