Skip to content

Commit aae0a24

Browse files
sarg3ntclaude
andcommitted
feat(updates): surface held packages distinctly with hold/unhold actions
Before this change, the OS Updates grid lumped held packages in with the regular Updates list. Held entries — packages an operator has explicitly pinned via apt-mark/dpkg — still appear in `apt list --upgradable` when a newer version exists in the repos, but apt will never upgrade them. Showing them in the active updates view was misleading: "Update All" would silently skip them, the Security count over-counted, and there was no UI to undo a hold from this page. This change does four things across the agent, dashboard, and UI: 1. Agent — `Package` struct gains `IsHeld bool` with json tag `is_held,omitempty`. `aptPackageManager.ListUpgradable` now calls `apt-mark showhold` after parsing `apt list --upgradable` and marks each matching package. Failures from apt-mark are non-fatal; the listing falls back to IsHeld=false rather than erroring the whole request, because the hold lookup is purely an enrichment step. The pure-logic core (`applyHeldMarks`) is extracted so it can be unit-tested without execing. 2. Dashboard — mirroring agent struct; `agent.Package` gains the same `IsHeld` / `is_held` field so JSON round-trips intact. 3. UI — the pkg-view-filter dropdown gains a `Held` option between `Updates` and `All Packages`. The `Updates` filter is now `update_available=true AND is_held=false` (held rows belong in their own tab, not the active-upgrades plan); the `Held` filter shows only `is_held=true`. `Update All (N)` and the Security button counts also exclude held packages so what's shown matches what `apt upgrade` would actually do. 4. UI actions — a new `Hold` button mirrors the existing `Unhold` button; both POST to the matching `/api/os-updates/packages/{hold, unhold}` endpoints (which already existed). After a hold or unhold the row is updated in place and the active filter is reapplied so the row moves between the Updates and Held tabs without a refresh. Tests: - `TestApplyHeldMarks` covers the enrichment helper: held rows are marked, non-held rows are left alone, empty inputs are safe, and held names with no matching package are ignored. Refs #45 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent adef4a4 commit aae0a24

6 files changed

Lines changed: 163 additions & 11 deletions

File tree

gearbox-agent/internal/gears/updates/pm_apt.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,47 @@ func (a *aptPackageManager) ListUpgradable() ([]Package, error) {
8585
}
8686
packages := a.parseAptListOutput(string(output))
8787
a.fetchPackageSizes(packages)
88+
a.markHeldPackages(packages)
8889
return packages, nil
8990
}
9091

92+
// markHeldPackages sets pkg.IsHeld=true for any package whose name appears in
93+
// `apt-mark showhold`. Held packages can still show up in `apt list --upgradable`
94+
// when a newer version exists in the repos; apt simply refuses to upgrade them.
95+
// Marking them lets the dashboard render a "held" badge and route the row to a
96+
// separate "Held" view instead of the active updates list.
97+
//
98+
// Errors from apt-mark are non-fatal: we just leave IsHeld=false rather than
99+
// failing the whole upgradable listing, since the held lookup is purely an
100+
// enrichment step.
101+
func (a *aptPackageManager) markHeldPackages(packages []Package) {
102+
if len(packages) == 0 {
103+
return
104+
}
105+
held, err := a.ListHeldPackages()
106+
if err != nil {
107+
return
108+
}
109+
applyHeldMarks(packages, held)
110+
}
111+
112+
// applyHeldMarks is the pure-logic core of markHeldPackages, broken out so it
113+
// can be unit-tested without execing apt-mark.
114+
func applyHeldMarks(packages []Package, held []string) {
115+
if len(held) == 0 {
116+
return
117+
}
118+
heldSet := make(map[string]struct{}, len(held))
119+
for _, name := range held {
120+
heldSet[name] = struct{}{}
121+
}
122+
for i := range packages {
123+
if _, ok := heldSet[packages[i].Name]; ok {
124+
packages[i].IsHeld = true
125+
}
126+
}
127+
}
128+
91129
func (a *aptPackageManager) TriggerUpdateCheck() error {
92130
_, err := a.collector.runCommandWithOutput("apt-get", "update")
93131
if err != nil {

gearbox-agent/internal/gears/updates/updates.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ type Package struct {
3838
AvailableVersion string `json:"available_version"`
3939
Architecture string `json:"architecture"`
4040
IsSecurityUpdate bool `json:"is_security_update"`
41-
Priority string `json:"priority"` // low, medium, high, critical
41+
IsHeld bool `json:"is_held,omitempty"` // Package is held (pinned via apt-mark/dpkg) — apt will not upgrade it even though a newer version is available
42+
Priority string `json:"priority"` // low, medium, high, critical
4243
Repository string `json:"repository"`
4344
Size int64 `json:"size_bytes"` // Download size in bytes
4445
ChangelogURL string `json:"changelog_url"` // URL to package changelog (Launchpad)

gearbox-agent/internal/gears/updates/updates_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,49 @@ func TestAptSnapshot_Fields(t *testing.T) {
480480
t.Error("CreatedAt mismatch")
481481
}
482482
}
483+
484+
// TestApplyHeldMarks verifies that the held-marker correctly flips IsHeld on
485+
// packages whose names appear in the held list, leaves others alone, and is a
486+
// no-op when there are no held packages or no packages.
487+
func TestApplyHeldMarks(t *testing.T) {
488+
t.Run("marks held packages and leaves others alone", func(t *testing.T) {
489+
pkgs := []Package{
490+
{Name: "openssl"},
491+
{Name: "linux-image-generic"},
492+
{Name: "nginx"},
493+
}
494+
applyHeldMarks(pkgs, []string{"openssl", "nginx"})
495+
496+
if !pkgs[0].IsHeld {
497+
t.Errorf("openssl should be held")
498+
}
499+
if pkgs[1].IsHeld {
500+
t.Errorf("linux-image-generic should not be held")
501+
}
502+
if !pkgs[2].IsHeld {
503+
t.Errorf("nginx should be held")
504+
}
505+
})
506+
507+
t.Run("empty held list is a no-op", func(t *testing.T) {
508+
pkgs := []Package{{Name: "openssl"}}
509+
applyHeldMarks(pkgs, nil)
510+
if pkgs[0].IsHeld {
511+
t.Errorf("no packages should be marked when held list is empty")
512+
}
513+
})
514+
515+
t.Run("empty packages list is safe", func(t *testing.T) {
516+
// Must not panic.
517+
applyHeldMarks(nil, []string{"openssl"})
518+
applyHeldMarks([]Package{}, []string{"openssl"})
519+
})
520+
521+
t.Run("held name with no matching package is ignored", func(t *testing.T) {
522+
pkgs := []Package{{Name: "nginx"}}
523+
applyHeldMarks(pkgs, []string{"ghost-package", "nginx"})
524+
if !pkgs[0].IsHeld {
525+
t.Errorf("nginx should be held")
526+
}
527+
})
528+
}

gearbox/internal/framework/agent/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ type Package struct {
666666
AvailableVersion string `json:"available_version"`
667667
Architecture string `json:"architecture"`
668668
IsSecurityUpdate bool `json:"is_security_update"`
669+
IsHeld bool `json:"is_held,omitempty"` // Held packages (apt-mark/dpkg pinned) won't be upgraded even when a newer version is available.
669670
Priority string `json:"priority"`
670671
Repository string `json:"repository"`
671672
Size int64 `json:"size_bytes"`

gearbox/internal/framework/templates/pages/os_updates.templ

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ templ OSUpdatesPage(data OSUpdatesPageData) {
223223
class="h-[38px] px-3 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
224224
>
225225
<option value="updates">Updates</option>
226+
<option value="held">Held</option>
226227
<option value="all">All Packages</option>
227228
</select>
228229
if data.CanAction {

gearbox/static/js/os-updates/os-updates-page.js

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,6 +2055,9 @@ let allPackagesLoaded = false;
20552055

20562056
// Normalize upgradable Package objects (from /api/os-updates/packages) to the
20572057
// shape expected by the grid (which was designed around InstalledPackage).
2058+
// is_held flows from the agent; held packages can still appear in
2059+
// `apt list --upgradable` when a newer version exists but apt-mark/dpkg has
2060+
// pinned them. The UI surfaces them via a "held" badge and a dedicated tab.
20582061
function normalizeUpgradablePackages(packages) {
20592062
return packages.map(p => ({
20602063
name: p.name,
@@ -2064,7 +2067,7 @@ function normalizeUpgradablePackages(packages) {
20642067
description: '',
20652068
update_available: true,
20662069
is_security_update: p.is_security_update || false,
2067-
is_held: false,
2070+
is_held: p.is_held || false,
20682071
package_url: p.package_url || '',
20692072
}));
20702073
}
@@ -2214,13 +2217,21 @@ function initInstalledPackagesGrid(el, packages, canAction) {
22142217
title: '',
22152218
field: 'name',
22162219
headerSort: false,
2217-
width: 220,
2220+
width: 260,
22182221
hozAlign: 'right',
22192222
formatter: function(cell) {
22202223
const row = cell.getRow().getData();
2221-
const holdBtn = row.is_held
2222-
? '<button class="pkg-unhold-btn px-2.5 py-1 text-xs bg-yellow-100 dark:bg-yellow-900/30 hover:bg-yellow-200 dark:hover:bg-yellow-900/50 text-yellow-700 dark:text-yellow-400 rounded transition-colors mr-1">Unhold</button>'
2223-
: '';
2224+
// Show Unhold for held packages, Hold for any upgradable-but-not-held
2225+
// row. The action mirrors the existing Unhold pattern and posts to
2226+
// the matching /api/os-updates/packages/hold endpoint.
2227+
let holdBtn;
2228+
if (row.is_held) {
2229+
holdBtn = '<button class="pkg-unhold-btn px-2.5 py-1 text-xs bg-yellow-100 dark:bg-yellow-900/30 hover:bg-yellow-200 dark:hover:bg-yellow-900/50 text-yellow-700 dark:text-yellow-400 rounded transition-colors mr-1">Unhold</button>';
2230+
} else if (row.update_available) {
2231+
holdBtn = '<button class="pkg-hold-btn px-2.5 py-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 text-yellow-700 dark:text-yellow-400 rounded transition-colors mr-1">Hold</button>';
2232+
} else {
2233+
holdBtn = '';
2234+
}
22242235
return holdBtn + '<button class="pkg-remove-btn px-2.5 py-1 text-xs bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400 rounded transition-colors">Remove</button>';
22252236
},
22262237
cellClick: function(e, cell) {
@@ -2229,6 +2240,8 @@ function initInstalledPackagesGrid(el, packages, canAction) {
22292240
removeInstalledPackageTabulator(cell, name);
22302241
} else if (e.target.classList.contains('pkg-unhold-btn')) {
22312242
unholdPackage(name, cell);
2243+
} else if (e.target.classList.contains('pkg-hold-btn')) {
2244+
holdPackage(name, cell);
22322245
}
22332246
}
22342247
});
@@ -2271,14 +2284,23 @@ function _applyPkgViewFilter(value, allData) {
22712284
if (!installedPkgTable) return;
22722285

22732286
if (value === 'updates') {
2274-
installedPkgTable.setFilter('update_available', '=', true);
2287+
// Active updates: upgradable AND not held. "Hold" is an explicit operator
2288+
// opt-out from upgrade, so held rows do not belong in the updates view.
2289+
installedPkgTable.setFilter([
2290+
{ field: 'update_available', type: '=', value: true },
2291+
{ field: 'is_held', type: '=', value: false },
2292+
]);
2293+
} else if (value === 'held') {
2294+
installedPkgTable.setFilter('is_held', '=', true);
22752295
} else {
22762296
installedPkgTable.clearFilter();
22772297
}
22782298

2279-
// Count packages with updates
2280-
const updateCount = allData.filter(p => p.update_available).length;
2281-
const securityCount = allData.filter(p => p.is_security_update).length;
2299+
// Count packages excluding held ones — held packages are intentionally
2300+
// excluded from the upgrade plan, so "Update All (N)" should reflect only
2301+
// what would actually be upgraded.
2302+
const updateCount = allData.filter(p => p.update_available && !p.is_held).length;
2303+
const securityCount = allData.filter(p => p.is_security_update && !p.is_held).length;
22822304

22832305
// Show/hide Update All button
22842306
const updateAllBtn = document.getElementById('pkg-update-all-btn');
@@ -2362,6 +2384,44 @@ function removeInstalledPackageTabulator(cell, name) {
23622384

23632385
// ── Package Hold / Unhold ─────────────────────────────────────────────────────
23642386

2387+
async function holdPackage(name, cell) {
2388+
showConfirmModal({
2389+
title: 'Hold Package',
2390+
message: 'Hold ' + name + '? This prevents apt from upgrading it until the hold is removed.',
2391+
type: 'warning',
2392+
confirmText: 'Hold',
2393+
onConfirm: async () => {
2394+
try {
2395+
const resp = await fetch('/api/os-updates/packages/hold?server=' + currentServerID, {
2396+
method: 'POST',
2397+
headers: { 'Content-Type': 'application/json' },
2398+
body: JSON.stringify({ name: name })
2399+
});
2400+
if (!resp.ok) {
2401+
const errMsg = await extractErrorMessage(resp);
2402+
throw new Error(errMsg);
2403+
}
2404+
showToast(name + ' is now held', 'success');
2405+
// Flip is_held in place so the row re-renders into the held filter.
2406+
if (cell) {
2407+
const row = cell.getRow();
2408+
const data = row.getData();
2409+
data.is_held = true;
2410+
row.update(data);
2411+
// Re-apply the active filter so the row moves between tabs
2412+
// without requiring a manual switch.
2413+
const filterSelect = document.getElementById('pkg-view-filter');
2414+
if (filterSelect) {
2415+
_applyPkgViewFilter(filterSelect.value, installedPkgTable.getData());
2416+
}
2417+
}
2418+
} catch (err) {
2419+
showToast('Failed to hold package: ' + err.message, 'error');
2420+
}
2421+
}
2422+
});
2423+
}
2424+
23652425
async function unholdPackage(name, cell) {
23662426
showConfirmModal({
23672427
title: 'Remove Hold',
@@ -2380,12 +2440,17 @@ async function unholdPackage(name, cell) {
23802440
throw new Error(errMsg);
23812441
}
23822442
showToast(name + ' hold removed', 'success');
2383-
// Update the row data in-place
2443+
// Update the row data in-place and reapply the active filter so
2444+
// the row moves out of the "Held" tab when viewing it.
23842445
if (cell) {
23852446
const row = cell.getRow();
23862447
const data = row.getData();
23872448
data.is_held = false;
23882449
row.update(data);
2450+
const filterSelect = document.getElementById('pkg-view-filter');
2451+
if (filterSelect) {
2452+
_applyPkgViewFilter(filterSelect.value, installedPkgTable.getData());
2453+
}
23892454
}
23902455
} catch (err) {
23912456
showToast('Failed to remove hold: ' + err.message, 'error');

0 commit comments

Comments
 (0)