mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-29 04:47:46 +02:00
feat: list pending package updates on the system page (#2427)
This commit is contained in:
@@ -26,6 +26,8 @@ const (
|
||||
GetZfsData
|
||||
// Sync network monitor configuration to agent
|
||||
SyncNetworkMonitors
|
||||
// Request the list of pending package updates from agent
|
||||
GetPackageUpdates
|
||||
// Add new actions here...
|
||||
)
|
||||
|
||||
|
||||
20
internal/entities/system/package_updates.go
Normal file
20
internal/entities/system/package_updates.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package system
|
||||
|
||||
// PackageUpdate is one pending package update on the host.
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name" cbor:"0,keyasint"`
|
||||
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
|
||||
Available string `json:"available" cbor:"2,keyasint"`
|
||||
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// PackageUpdates is the detail payload returned by the agent for the
|
||||
// GetPackageUpdates action. The counts in Info.PackageUpdates come from the same check.
|
||||
type PackageUpdates struct {
|
||||
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
|
||||
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
|
||||
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
// SecurityKnown is true if the package manager flags security updates per package.
|
||||
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
|
||||
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
|
||||
}
|
||||
@@ -202,6 +202,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
||||
// get systemd service details
|
||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||
// get pending package updates
|
||||
apiAuth.GET("/package-updates", h.getPackageUpdates)
|
||||
// /containers routes
|
||||
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||
// get container logs
|
||||
@@ -445,6 +447,23 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||
}
|
||||
|
||||
// getPackageUpdates handles GET /api/beszel/package-updates requests
|
||||
func (h *Hub) getPackageUpdates(e *core.RequestEvent) error {
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
if systemID == "" {
|
||||
return e.BadRequestError("Invalid system parameter", nil)
|
||||
}
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
updates, err := system.FetchPackageUpdatesFromAgent()
|
||||
if err != nil {
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
return e.JSON(http.StatusOK, updates)
|
||||
}
|
||||
|
||||
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||
// Fetches fresh SMART data from the agent and updates the collection
|
||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||
|
||||
@@ -548,6 +548,59 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// /package-updates route
|
||||
{
|
||||
Name: "GET /package-updates - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /package-updates - missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/package-updates",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /package-updates - invalid system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/package-updates?system=invalid-system",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /package-updates - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /package-updates - good user should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// /systemd routes
|
||||
{
|
||||
Name: "GET /systemd/info - no auth should fail",
|
||||
|
||||
@@ -791,6 +791,15 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FetchPackageUpdatesFromAgent fetches the list of pending package updates from the agent.
|
||||
func (sys *System) FetchPackageUpdatesFromAgent() (system.PackageUpdates, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result system.PackageUpdates
|
||||
err := sys.request(ctx, common.GetPackageUpdates, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
|
||||
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useState } from "react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import type { GPUData } from "@/types"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import InfoBar from "./system/info-bar"
|
||||
@@ -16,6 +17,7 @@ import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
||||
import {
|
||||
LazyContainersTable,
|
||||
LazyNetworkMonitorsTable,
|
||||
LazyPackageUpdatesTable,
|
||||
LazySmartTable,
|
||||
LazySystemdTable,
|
||||
LazyZfsTable,
|
||||
@@ -73,6 +75,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const hasGpu = hasGpuData || hasGpuPowerData
|
||||
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
|
||||
const hasNetworkMonitors = supportsNetworkMonitors(system)
|
||||
// counts key the table so it refetches the list only after a new check
|
||||
const packageUpdates = system.status === SystemStatus.Up && system.info.pu?.[0] ? system.info.pu.join(",") : ""
|
||||
|
||||
// keep tabsRef in sync for keyboard navigation
|
||||
const tabs = ["core", "network", "disk"]
|
||||
@@ -163,6 +167,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
|
||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||
|
||||
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
|
||||
|
||||
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
|
||||
</>
|
||||
)
|
||||
@@ -215,6 +221,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
|
||||
|
||||
@@ -47,6 +47,17 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const PackageUpdatesTable = lazy(() => import("./package-updates-table"))
|
||||
|
||||
export function LazyPackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <PackageUpdatesTable systemId={systemId} counts={counts} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
|
||||
|
||||
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
GitCompareArrowsIcon,
|
||||
PackageCheckIcon,
|
||||
PackageIcon,
|
||||
PackageOpenIcon,
|
||||
ShieldAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { pb } from "@/lib/api"
|
||||
import { classifyVersionChange, type VersionChange } from "@/lib/package-updates"
|
||||
import { cn, formatShortDate } from "@/lib/utils"
|
||||
import type { PackageUpdate, PackageUpdates } from "@/types"
|
||||
|
||||
interface PackageUpdateRow extends PackageUpdate {
|
||||
change: VersionChange
|
||||
}
|
||||
|
||||
/** Sort order of version changes, largest first. */
|
||||
const changeRank: Record<VersionChange, number> = { major: 4, minor: 3, patch: 2, revision: 1, other: 0 }
|
||||
|
||||
const changeVariant: Record<VersionChange, BadgeProps["variant"]> = {
|
||||
major: "danger",
|
||||
minor: "warning",
|
||||
patch: "success",
|
||||
revision: "secondary",
|
||||
other: "outline",
|
||||
}
|
||||
|
||||
function changeLabel(change: VersionChange) {
|
||||
switch (change) {
|
||||
case "major":
|
||||
return t({ message: "Major", context: "Version change" })
|
||||
case "minor":
|
||||
return t({ message: "Minor", context: "Version change" })
|
||||
case "patch":
|
||||
return t({ message: "Patch", context: "Version change" })
|
||||
case "revision":
|
||||
return t({ message: "Revision", context: "Version change" })
|
||||
default:
|
||||
return t({ message: "Other", context: "Version change" })
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderButton({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<PackageUpdateRow>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{name}
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function getColumns(securityKnown: boolean): ColumnDef<PackageUpdateRow>[] {
|
||||
const columns: ColumnDef<PackageUpdateRow>[] = [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (pkg) => pkg.name,
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Package`} Icon={PackageIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 block">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
accessorFn: (pkg) => pkg.current ?? "",
|
||||
enableSorting: false,
|
||||
header: () => (
|
||||
<span className="flex items-center gap-2 px-3">
|
||||
<PackageCheckIcon className="size-4" />
|
||||
<Trans context="Installed package version">Current</Trans>
|
||||
</span>
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="ms-1.5 block font-mono text-xs text-muted-foreground">{(getValue() as string) || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "available",
|
||||
accessorFn: (pkg) => pkg.available,
|
||||
enableSorting: false,
|
||||
header: () => (
|
||||
<span className="flex items-center gap-2 px-3">
|
||||
<PackageOpenIcon className="size-4" />
|
||||
<Trans context="Package version available to install">Available</Trans>
|
||||
</span>
|
||||
),
|
||||
cell: ({ getValue }) => <span className="ms-1.5 block font-mono text-xs">{getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: "change",
|
||||
accessorFn: (pkg) => changeRank[pkg.change],
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Change`} Icon={GitCompareArrowsIcon} />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={changeVariant[row.original.change]} className="ms-1.5">
|
||||
{changeLabel(row.original.change)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
if (securityKnown) {
|
||||
columns.push({
|
||||
id: "security",
|
||||
accessorFn: (pkg) => (pkg.security ? 1 : 0),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Security`} Icon={ShieldAlertIcon} />,
|
||||
cell: ({ row }) =>
|
||||
row.original.security ? (
|
||||
<span className="ms-1.5 flex items-center gap-1.5 text-red-600 dark:text-red-400">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
<Trans>Security</Trans>
|
||||
</span>
|
||||
) : null,
|
||||
})
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists pending package updates reported by the agent. The agent caches the result of
|
||||
* its background check, so this refetches only when the update counts change.
|
||||
*/
|
||||
export default function PackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
|
||||
const [data, setData] = useState<PackageUpdates | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
pb.send<PackageUpdates>("/api/beszel/package-updates", { query: { system: systemId } })
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setData(result)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setError(err?.message || t`Failed to load package updates`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [systemId, counts])
|
||||
|
||||
const rows = useMemo<PackageUpdateRow[]>(
|
||||
() => (data?.packages ?? []).map((pkg) => ({ ...pkg, change: classifyVersionChange(pkg.current, pkg.available) })),
|
||||
[data]
|
||||
)
|
||||
const securityKnown = !!data?.securityKnown
|
||||
const columns = useMemo(() => getColumns(securityKnown), [securityKnown])
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
state: { sorting, globalFilter },
|
||||
globalFilterFn: (row, _columnId, filterValue: string) => {
|
||||
const pkg = row.original
|
||||
const searchString = `${pkg.name} ${pkg.current ?? ""} ${pkg.available} ${changeLabel(pkg.change)}`.toLowerCase()
|
||||
return filterValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
if (!data && !error) {
|
||||
return null
|
||||
}
|
||||
|
||||
const securityCount = rows.filter((pkg) => pkg.security).length
|
||||
const tableRows = table.getRowModel().rows
|
||||
|
||||
return (
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>Package Updates</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center flex-wrap">
|
||||
{data?.manager && (
|
||||
<>
|
||||
<span className="font-mono">{data.manager}</span>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
</>
|
||||
)}
|
||||
<Trans>Total: {rows.length}</Trans>
|
||||
{securityKnown && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Security: {securityCount}</Trans>
|
||||
</>
|
||||
)}
|
||||
{!!data?.checkedAt && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Checked {formatShortDate(new Date(data.checkedAt * 1000).toISOString())}</Trans>
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="px-4 w-full max-w-full md:w-64"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{error ? (
|
||||
<p className="px-2 sm:px-1 text-sm text-muted-foreground">{error}</p>
|
||||
) : (
|
||||
<div className="h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md">
|
||||
<table className="text-sm w-full text-nowrap">
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableRows.length ? (
|
||||
tableRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-2.5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center pointer-events-none">
|
||||
{rows.length ? <Trans>No results.</Trans> : <Trans>Up to date</Trans>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
61
internal/site/src/lib/package-updates.test.ts
Normal file
61
internal/site/src/lib/package-updates.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { classifyVersionChange } from "./package-updates"
|
||||
|
||||
// version pairs are taken from real apt, dnf, zypper, pacman and apk output
|
||||
test("major, minor and patch use the first differing upstream component", () => {
|
||||
expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major")
|
||||
expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor")
|
||||
expect(classifyVersionChange("3.0.7-104.el9", "3.1.5-8.el9")).toBe("minor")
|
||||
expect(classifyVersionChange("1.3.7-1", "1.3.8-1")).toBe("patch")
|
||||
expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch")
|
||||
expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch")
|
||||
expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch")
|
||||
// missing components count as zero
|
||||
expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch")
|
||||
expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor")
|
||||
// components compare as numbers, not strings
|
||||
expect(classifyVersionChange("1.9.0", "1.10.0")).toBe("minor")
|
||||
// dotted versions without a revision, such as Ubuntu kernel metapackages
|
||||
expect(classifyVersionChange("5.15.0.91.88", "5.15.0.92.89")).toBe("patch")
|
||||
})
|
||||
|
||||
test("epochs are stripped when equal", () => {
|
||||
expect(classifyVersionChange("2:9.2.280-1.fc42", "2:9.2.390-1.fc42")).toBe("patch")
|
||||
expect(classifyVersionChange("1:2.3.4-1ubuntu1", "1:2.4.0-1ubuntu1")).toBe("minor")
|
||||
expect(classifyVersionChange("1:3.2.6-3.fc42", "1:3.2.6-4.fc42")).toBe("revision")
|
||||
})
|
||||
|
||||
test("revision-only changes", () => {
|
||||
expect(classifyVersionChange("2.35-0ubuntu3.4", "2.35-0ubuntu3.15")).toBe("revision")
|
||||
expect(classifyVersionChange("5.15.0-91.101", "5.15.0-92.102")).toBe("revision")
|
||||
expect(classifyVersionChange("12.3.0-1ubuntu1~22.04", "12.3.0-1ubuntu1~22.04.3")).toBe("revision")
|
||||
expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision")
|
||||
expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision")
|
||||
expect(classifyVersionChange("42-30", "42-31")).toBe("revision")
|
||||
expect(
|
||||
classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1")
|
||||
).toBe("revision")
|
||||
})
|
||||
|
||||
test("falls back to other when the change can't be classified safely", () => {
|
||||
// unknown or identical versions
|
||||
expect(classifyVersionChange(undefined, "1.0-1")).toBe("other")
|
||||
expect(classifyVersionChange("", "1.0-1")).toBe("other")
|
||||
expect(classifyVersionChange("1.0-1", "1.0-1")).toBe("other")
|
||||
// epoch changes reset the version scheme
|
||||
expect(classifyVersionChange("1.5-1", "1:1.0-1")).toBe("other")
|
||||
expect(classifyVersionChange("1:2.0-1", "2:2.0-1")).toBe("other")
|
||||
// calendar versions
|
||||
expect(classifyVersionChange("2025c-1.fc42", "2026b-1.fc42")).toBe("other")
|
||||
expect(classifyVersionChange("2026c-1", "2026d-1")).toBe("other")
|
||||
expect(classifyVersionChange("20240226-r0", "20260413-r0")).toBe("other")
|
||||
expect(classifyVersionChange("2023.2.60_v7.0.306-90.1.el9_2", "2025.2.80_v9.0.305-91.el9")).toBe("other")
|
||||
expect(classifyVersionChange("20230731-1.git94f0e2c.el9_3.1", "20250905-1.git377cc42.el9_7")).toBe("other")
|
||||
// pre-release and suffix-only changes
|
||||
expect(classifyVersionChange("2.0~rc1-1", "2.0-1")).toBe("other")
|
||||
expect(classifyVersionChange("1.2.3+dfsg-1", "1.2.3+dfsg2-1")).toBe("other")
|
||||
// non-numeric versions
|
||||
expect(classifyVersionChange("git20240101-1", "git20240301-1")).toBe("other")
|
||||
// downgrades
|
||||
expect(classifyVersionChange("1.3.0-1", "1.2.9-1")).toBe("other")
|
||||
})
|
||||
81
internal/site/src/lib/package-updates.ts
Normal file
81
internal/site/src/lib/package-updates.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Kind of version change between an installed and an available package version.
|
||||
* - major / minor / patch: first differing numeric component of the upstream version
|
||||
* - revision: same upstream version, only the distro packaging revision changed
|
||||
* - other: anything that can't be classified safely (unknown current version,
|
||||
* epoch change, calendar versions, pre-release suffixes, downgrades)
|
||||
*/
|
||||
export type VersionChange = "major" | "minor" | "patch" | "revision" | "other"
|
||||
|
||||
interface SplitVersion {
|
||||
epoch: number
|
||||
upstream: string
|
||||
revision: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a distro version string into epoch, upstream version and packaging revision.
|
||||
* Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1")
|
||||
* and apk ("1.2.5-r3") formats. The revision follows the last "-".
|
||||
*/
|
||||
function splitVersion(version: string): SplitVersion {
|
||||
let epoch = 0
|
||||
const epochMatch = /^(\d+):/.exec(version)
|
||||
if (epochMatch) {
|
||||
epoch = Number(epochMatch[1])
|
||||
version = version.slice(epochMatch[0].length)
|
||||
}
|
||||
const dash = version.lastIndexOf("-")
|
||||
if (dash > 0) {
|
||||
return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) }
|
||||
}
|
||||
return { epoch, upstream: version, revision: "" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading components at or above this look like dates or years (20240226, 2026b,
|
||||
* 2025.2.80), where a change in the first component is not a major upgrade.
|
||||
*/
|
||||
const CALENDAR_VERSION_MIN = 1000
|
||||
|
||||
/** Classifies the change from `current` to `available` as major, minor, patch or revision. */
|
||||
export function classifyVersionChange(current?: string, available?: string): VersionChange {
|
||||
current = current?.trim()
|
||||
available = available?.trim()
|
||||
if (!current || !available || current === available) {
|
||||
return "other"
|
||||
}
|
||||
const from = splitVersion(current)
|
||||
const to = splitVersion(available)
|
||||
// a new epoch means the version scheme was reset, so the numbers aren't comparable
|
||||
if (from.epoch !== to.epoch) {
|
||||
return "other"
|
||||
}
|
||||
if (from.upstream === to.upstream) {
|
||||
return from.revision !== to.revision ? "revision" : "other"
|
||||
}
|
||||
const fromMatch = /^\d+(?:\.\d+)*/.exec(from.upstream)
|
||||
const toMatch = /^\d+(?:\.\d+)*/.exec(to.upstream)
|
||||
if (!fromMatch || !toMatch) {
|
||||
return "other"
|
||||
}
|
||||
const fromParts = fromMatch[0].split(".").map(Number)
|
||||
const toParts = toMatch[0].split(".").map(Number)
|
||||
if (fromParts[0] >= CALENDAR_VERSION_MIN || toParts[0] >= CALENDAR_VERSION_MIN) {
|
||||
return "other"
|
||||
}
|
||||
const length = Math.max(fromParts.length, toParts.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const a = fromParts[i] ?? 0
|
||||
const b = toParts[i] ?? 0
|
||||
if (a === b) {
|
||||
continue
|
||||
}
|
||||
if (b < a) {
|
||||
return "other"
|
||||
}
|
||||
return i === 0 ? "major" : i === 1 ? "minor" : "patch"
|
||||
}
|
||||
// same numbers, so only a suffix such as "~rc1", "+dfsg" or a letter changed
|
||||
return "other"
|
||||
}
|
||||
19
internal/site/src/types.d.ts
vendored
19
internal/site/src/types.d.ts
vendored
@@ -226,6 +226,25 @@ export interface ZfsVdev {
|
||||
checksumErrs?: number
|
||||
}
|
||||
|
||||
/** pending package update from GET /api/beszel/package-updates */
|
||||
export interface PackageUpdate {
|
||||
name: string
|
||||
/** installed version, missing if unknown */
|
||||
current?: string
|
||||
available: string
|
||||
security?: boolean
|
||||
}
|
||||
|
||||
export interface PackageUpdates {
|
||||
/** package manager name, e.g. "apt" */
|
||||
manager?: string
|
||||
/** unix time in seconds of the last check */
|
||||
checkedAt?: number
|
||||
/** true if the package manager flags security updates per package */
|
||||
securityKnown?: boolean
|
||||
packages: PackageUpdate[] | null
|
||||
}
|
||||
|
||||
export interface ZfsDataset {
|
||||
name: string
|
||||
used?: number
|
||||
|
||||
Reference in New Issue
Block a user