feat(agent): Add docker image update available flag (#2211)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Bruno Bousquet
2026-09-11 01:39:40 +02:00
committed by GitHub
parent 5fe1583655
commit f204dc17e6
15 changed files with 883 additions and 21 deletions

View File

@@ -186,11 +186,12 @@ type Stats struct {
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
Ports string `json:"-" cbor:"10,keyasint"`
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
Ports string `json:"-" cbor:"10,keyasint"`
UpdateAvailable bool `json:"u,omitzero" cbor:"11,keyasint,omitzero"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`

View File

@@ -0,0 +1,46 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateContainerRecordsPersistsImageUpdateAvailability(t *testing.T) {
_, app := newTestSystemWithHub(t)
const (
systemID = "system123"
containerID = "abcdef123456"
image = "nginx:latest"
)
data := &container.Stats{
Id: containerID,
Name: "web",
Image: image,
UpdateAvailable: true,
}
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
var record struct {
Image string `db:"image"`
UpdateAvailable bool `db:"updatable"`
}
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
Where(dbx.HashExp{"id": containerID}).One(&record))
assert.Equal(t, image, record.Image)
assert.True(t, record.UpdateAvailable)
data.UpdateAvailable = false
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
Where(dbx.HashExp{"id": containerID}).One(&record))
assert.Equal(t, image, record.Image)
assert.False(t, record.UpdateAvailable)
}

View File

@@ -376,7 +376,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
valueStrings := make([]string, 0, len(data))
for i, container := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updateAvailable%[1]s}, {:updated})", suffix))
params["id"+suffix] = container.Id
params["name"+suffix] = container.Name
params["image"+suffix] = container.Image
@@ -390,9 +390,10 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
}
params["net"+suffix] = netBytes
params["updateAvailable"+suffix] = container.UpdateAvailable
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updatable, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updatable = excluded.updatable, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()

View File

@@ -0,0 +1,24 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
collection.Fields.Add(&core.BoolField{Name: "updatable"})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
collection.Fields.RemoveByName("updatable")
return app.Save(collection)
})
}

View File

@@ -4,6 +4,7 @@ import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import {
CircleArrowUpIcon,
ClockIcon,
ContainerIcon,
CpuIcon,
@@ -177,11 +178,25 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
),
cell: ({ getValue }) => {
cell: ({ getValue, row }) => {
const val = getValue() as string
return (
<div className="ms-1 xl:w-40 truncate" title={val}>
{val}
<div className="ms-1 xl:w-40 flex items-center gap-2">
<span className="truncate" title={val}>
{val}
</span>
{row.original.updatable && (
<Tooltip>
<TooltipTrigger
className="shrink-0 rounded-sm text-emerald-600 dark:text-emerald-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t`Image update available`}
onClick={(event) => event.stopPropagation()}
>
<CircleArrowUpIcon className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t`Image update available`}</TooltipContent>
</Tooltip>
)}
</div>
)
},

View File

@@ -66,7 +66,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
function fetchData(systemId?: string) {
pb.collection<ContainerRecord>("containers")
.getList(0, 2000, {
fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated",
fields: "id,name,image,updatable,ports,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(({ items }) => {

View File

@@ -335,6 +335,7 @@ export interface ContainerRecord extends RecordModel {
system: string
name: string
image: string
updatable?: boolean
ports: string
cpu: number
memory: number