Compare commits

...

4 Commits

Author SHA1 Message Date
henrygd
ed50367f70 fix(agent): add fallback for podman container health (#1475) 2026-03-15 17:59:59 -04:00
henrygd
4ebe869591 ui: virtualize smart table 2026-03-15 15:20:07 -04:00
henrygd
c9bbbe91f2 ui: improve table col widths and hide text showing above header 2026-03-15 14:59:25 -04:00
henrygd
5bfe4f6970 agent: include ip in container port if not 0.0.0.0 or :: 2026-03-15 14:58:21 -04:00
8 changed files with 469 additions and 150 deletions

View File

@@ -368,6 +368,12 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteString(", ") builder.WriteString(", ")
} }
switch p.IP {
case "0.0.0.0", "::":
default:
builder.WriteString(p.IP)
builder.WriteByte(':')
}
builder.WriteString(strconv.Itoa(int(p.PublicPort))) builder.WriteString(strconv.Itoa(int(p.PublicPort)))
} }
// clear ports slice so it doesn't get reused and blend into next response // clear ports slice so it doesn't get reused and blend into next response
@@ -394,22 +400,60 @@ func parseDockerStatus(status string) (string, container.DockerHealth) {
statusText = trimmed statusText = trimmed
} }
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))) healthText := strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))
// Some Docker statuses include a "health:" prefix inside the parentheses. // Some Docker statuses include a "health:" prefix inside the parentheses.
// Strip it so it maps correctly to the known health states. // Strip it so it maps correctly to the known health states.
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 { if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
prefix := strings.TrimSpace(healthText[:colonIdx]) prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx]))
if prefix == "health" || prefix == "health status" { if prefix == "health" || prefix == "health status" {
healthText = strings.TrimSpace(healthText[colonIdx+1:]) healthText = strings.TrimSpace(healthText[colonIdx+1:])
} }
} }
if health, ok := container.DockerHealthStrings[healthText]; ok { if health, ok := parseDockerHealthStatus(healthText); ok {
return statusText, health return statusText, health
} }
return trimmed, container.DockerHealthNone return trimmed, container.DockerHealthNone
} }
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
return health, ok
}
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
if err != nil {
return container.DockerHealthNone, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
}
var inspectInfo struct {
State struct {
Health struct {
Status string
}
}
}
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
return container.DockerHealthNone, err
}
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
return health, nil
}
return container.DockerHealthNone, nil
}
// Updates stats for individual container with cache-time-aware delta tracking // Updates stats for individual container with cache-time-aware delta tracking
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error { func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
name := ctr.Names[0][1:] name := ctr.Names[0][1:]
@@ -419,6 +463,21 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
return err return err
} }
statusText, health := parseDockerStatus(ctr.Status)
// Docker exposes Health.Status on /containers/json in API 1.52+.
// Podman currently requires falling back to the inspect endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
if ctr.Health.Status != "" {
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
health = h
}
} else if dm.usingPodman {
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
health = podmanHealth
}
}
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
@@ -430,16 +489,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
} }
stats.Id = ctr.IdShort stats.Id = ctr.IdShort
statusText, health := parseDockerStatus(ctr.Status)
// Use Health.Status if it's available (Docker API 1.52+; Podman TBD - https://github.com/containers/podman/issues/27786)
if ctr.Health.Status != "" {
if h, ok := container.DockerHealthStrings[ctr.Health.Status]; ok {
health = h
}
}
stats.Status = statusText stats.Status = statusText
stats.Health = health stats.Health = health

View File

@@ -35,6 +35,12 @@ type recordingRoundTripper struct {
lastQuery map[string]string lastQuery map[string]string
} }
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.called = true rt.called = true
rt.lastPath = req.URL.EscapedPath() rt.lastPath = req.URL.EscapedPath()
@@ -214,6 +220,28 @@ func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
}) })
} }
func TestGetPodmanContainerHealth(t *testing.T) {
called := false
dm := &dockerManager{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
assert.Equal(t, "/containers/0123456789ab/json", req.URL.EscapedPath())
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
Request: req,
}, nil
})},
}
health, err := dm.getPodmanContainerHealth("0123456789ab")
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, container.DockerHealthHealthy, health)
}
func TestValidateCpuPercentage(t *testing.T) { func TestValidateCpuPercentage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1129,6 +1157,18 @@ func TestParseDockerStatus(t *testing.T) {
expectedStatus: "", expectedStatus: "",
expectedHealth: container.DockerHealthNone, expectedHealth: container.DockerHealthNone,
}, },
{
name: "status health with health: prefix",
input: "Up 5 minutes (health: starting)",
expectedStatus: "Up 5 minutes",
expectedHealth: container.DockerHealthStarting,
},
{
name: "status health with health status: prefix",
input: "Up 10 minutes (health status: unhealthy)",
expectedStatus: "Up 10 minutes",
expectedHealth: container.DockerHealthUnhealthy,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1140,6 +1180,84 @@ func TestParseDockerStatus(t *testing.T) {
} }
} }
func TestParseDockerHealthStatus(t *testing.T) {
tests := []struct {
input string
expectedHealth container.DockerHealth
expectedOk bool
}{
{"healthy", container.DockerHealthHealthy, true},
{"unhealthy", container.DockerHealthUnhealthy, true},
{"starting", container.DockerHealthStarting, true},
{"none", container.DockerHealthNone, true},
{" Healthy ", container.DockerHealthHealthy, true},
{"unknown", container.DockerHealthNone, false},
{"", container.DockerHealthNone, false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
health, ok := parseDockerHealthStatus(tt.input)
assert.Equal(t, tt.expectedHealth, health)
assert.Equal(t, tt.expectedOk, ok)
})
}
}
func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
var requestedPaths []string
dm := &dockerManager{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedPaths = append(requestedPaths, req.URL.EscapedPath())
switch req.URL.EscapedPath() {
case "/containers/0123456789ab/stats":
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"read":"2026-03-15T21:26:59Z",
"cpu_stats":{"cpu_usage":{"total_usage":1000},"system_cpu_usage":2000},
"memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}},
"networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}}
}`)),
Request: req,
}, nil
case "/containers/0123456789ab/json":
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
Request: req,
}, nil
default:
return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath())
}
})},
containerStatsMap: make(map[string]*container.Stats),
apiStats: &container.ApiStats{},
usingPodman: true,
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
ctr := &container.ApiInfo{
IdShort: "0123456789ab",
Names: []string{"/beszel"},
Status: "Up 2 minutes",
Image: "beszel:latest",
}
err := dm.updateContainerStats(ctr, defaultCacheTimeMs)
require.NoError(t, err)
assert.Equal(t, []string{"/containers/0123456789ab/stats", "/containers/0123456789ab/json"}, requestedPaths)
assert.Equal(t, container.DockerHealthHealthy, dm.containerStatsMap[ctr.IdShort].Health)
assert.Equal(t, "Up 2 minutes", dm.containerStatsMap[ctr.IdShort].Status)
}
func TestConstantsAndUtilityFunctions(t *testing.T) { func TestConstantsAndUtilityFunctions(t *testing.T) {
// Test constants are properly defined // Test constants are properly defined
assert.Equal(t, uint16(60000), defaultCacheTimeMs) assert.Equal(t, uint16(60000), defaultCacheTimeMs)
@@ -1458,9 +1576,8 @@ func TestAnsiEscapePattern(t *testing.T) {
func TestConvertContainerPortsToString(t *testing.T) { func TestConvertContainerPortsToString(t *testing.T) {
type port = struct { type port = struct {
PrivatePort uint16
PublicPort uint16 PublicPort uint16
Type string IP string
} }
tests := []struct { tests := []struct {
name string name string
@@ -1473,72 +1590,64 @@ func TestConvertContainerPortsToString(t *testing.T) {
expected: "", expected: "",
}, },
{ {
name: "single port public==private", name: "single port",
ports: []port{ ports: []port{
{PublicPort: 80, PrivatePort: 80}, {PublicPort: 80, IP: "0.0.0.0"},
}, },
expected: "80", expected: "80",
}, },
{ {
name: "single port public!=private", name: "single port with non-default IP",
ports: []port{ ports: []port{
{PublicPort: 443, PrivatePort: 2019}, {PublicPort: 80, IP: "1.2.3.4"},
}, },
// expected: "443:2019", expected: "1.2.3.4:80",
expected: "443", },
{
name: "ipv6 default ip",
ports: []port{
{PublicPort: 80, IP: "::"},
},
expected: "80",
}, },
{ {
name: "zero PublicPort is skipped", name: "zero PublicPort is skipped",
ports: []port{ ports: []port{
{PublicPort: 0, PrivatePort: 8080}, {PublicPort: 0, IP: "0.0.0.0"},
{PublicPort: 80, PrivatePort: 80}, {PublicPort: 80, IP: "0.0.0.0"},
}, },
expected: "80", expected: "80",
}, },
{ {
name: "ports sorted ascending by PublicPort", name: "ports sorted ascending by PublicPort",
ports: []port{ ports: []port{
{PublicPort: 443, PrivatePort: 443}, {PublicPort: 443, IP: "0.0.0.0"},
{PublicPort: 80, PrivatePort: 80}, {PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 8080, PrivatePort: 8080}, {PublicPort: 8080, IP: "0.0.0.0"},
}, },
expected: "80, 443, 8080", expected: "80, 443, 8080",
}, },
{
name: "same PublicPort sorted by PrivatePort",
ports: []port{
{PublicPort: 443, PrivatePort: 9000},
{PublicPort: 443, PrivatePort: 2019},
},
// expected: "443:2019,443:9000",
expected: "443",
},
{ {
name: "duplicates are deduplicated", name: "duplicates are deduplicated",
ports: []port{ ports: []port{
{PublicPort: 80, PrivatePort: 80}, {PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 80, PrivatePort: 80}, {PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 443, PrivatePort: 2019}, {PublicPort: 443, IP: "0.0.0.0"},
{PublicPort: 443, PrivatePort: 2019},
}, },
// expected: "80,443:2019",
expected: "80, 443", expected: "80, 443",
}, },
{ {
name: "mixed zero and non-zero ports", name: "multiple ports with different IPs",
ports: []port{ ports: []port{
{PublicPort: 0, PrivatePort: 5432}, {PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 443, PrivatePort: 2019}, {PublicPort: 443, IP: "1.2.3.4"},
{PublicPort: 80, PrivatePort: 80},
{PublicPort: 0, PrivatePort: 9000},
}, },
// expected: "80,443:2019", expected: "80, 1.2.3.4:443",
expected: "80, 443",
}, },
{ {
name: "ports slice is nilled after call", name: "ports slice is nilled after call",
ports: []port{ ports: []port{
{PublicPort: 8080, PrivatePort: 8080}, {PublicPort: 8080, IP: "0.0.0.0"},
}, },
expected: "8080", expected: "8080",
}, },
@@ -1549,10 +1658,9 @@ func TestConvertContainerPortsToString(t *testing.T) {
ctr := &container.ApiInfo{} ctr := &container.ApiInfo{}
for _, p := range tt.ports { for _, p := range tt.ports {
ctr.Ports = append(ctr.Ports, struct { ctr.Ports = append(ctr.Ports, struct {
// PrivatePort uint16
PublicPort uint16 PublicPort uint16
// Type string IP string
}{PublicPort: p.PublicPort}) }{PublicPort: p.PublicPort, IP: p.IP})
} }
result := convertContainerPortsToString(ctr) result := convertContainerPortsToString(ctr)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, result)

View File

@@ -17,7 +17,7 @@ type ApiInfo struct {
Ports []struct { Ports []struct {
// PrivatePort uint16 // PrivatePort uint16
PublicPort uint16 PublicPort uint16
// IP string IP string
// Type string // Type string
} }
// ImageID string // ImageID string

View File

@@ -15,7 +15,7 @@ import {
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons" import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores" import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
@@ -63,7 +63,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById) const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span> const longestName = useStore($longestSystemNameLen)
return (
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
</div>
)
}, },
}, },
// { // {
@@ -82,7 +87,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const val = getValue() as number const val = getValue() as number
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span> return <span className="ms-1 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
}, },
}, },
{ {
@@ -94,7 +99,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
const val = getValue() as number const val = getValue() as number
const formatted = formatBytes(val, false, undefined, true) const formatted = formatBytes(val, false, undefined, true)
return ( return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span> <span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
) )
}, },
}, },
@@ -103,11 +108,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
accessorFn: (record) => record.net, accessorFn: (record) => record.net,
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
minSize: 112,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const val = getValue() as number const val = getValue() as number
const formatted = formatBytes(val, true, undefined, false) const formatted = formatBytes(val, true, undefined, false)
return ( return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span> <div className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</div>
) )
}, },
}, },
@@ -116,6 +122,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
invertSorting: true, invertSorting: true,
accessorFn: (record) => record.health, accessorFn: (record) => record.health,
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
minSize: 121,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const healthValue = getValue() as number const healthValue = getValue() as number
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown" const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
@@ -138,15 +145,21 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
id: "ports", id: "ports",
accessorFn: (record) => record.ports || undefined, accessorFn: (record) => record.ports || undefined,
header: ({ column }) => ( header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Ports", context: "Container ports" })} Icon={SquareArrowRightEnterIcon} /> <HeaderButton
column={column}
name={t({ message: "Ports", context: "Container ports" })}
Icon={SquareArrowRightEnterIcon}
/>
), ),
sortingFn: (a, b) => getPortValue(a.original.ports) - getPortValue(b.original.ports),
minSize: 147,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const val = getValue() as string const val = getValue() as string | undefined
if (!val) { if (!val) {
return <span className="ms-2">-</span> return <div className="ms-1.5 text-muted-foreground">-</div>
} }
const className = "ms-1.5 w-20 block truncate tabular-nums" const className = "ms-1 w-27 block truncate tabular-nums"
if (val.length > 9) { if (val.length > 14) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger className={className}>{val}</TooltipTrigger> <TooltipTrigger className={className}>{val}</TooltipTrigger>
@@ -165,7 +178,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} /> <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
), ),
cell: ({ getValue }) => { cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span> const val = getValue() as string
return (
<div className="ms-1 xl:w-40 truncate" title={val}>
{val}
</div>
)
}, },
}, },
{ {
@@ -175,7 +193,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status), sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
return <span className="ms-1.5 w-25 block truncate">{getValue() as string}</span> return <span className="ms-1 w-25 block truncate">{getValue() as string}</span>
}, },
}, },
{ {
@@ -185,7 +203,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const timestamp = getValue() as number const timestamp = getValue() as number
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span> return <span className="ms-1 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
}, },
}, },
] ]
@@ -215,3 +233,17 @@ function HeaderButton({
</Button> </Button>
) )
} }
/**
* Convert port string to a number for sorting.
* Handles formats like "80", "127.0.0.1:80", and "80, 443" (takes the first mapping).
*/
function getPortValue(ports: string | undefined): number {
if (!ports) {
return 0
}
const first = ports.includes(",") ? ports.substring(0, ports.indexOf(",")) : ports
const colonIndex = first.lastIndexOf(":")
const portStr = colonIndex === -1 ? first : first.substring(colonIndex + 1)
return Number(portStr) || 0
}

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: html comes directly from docker via agent */
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { import {
@@ -13,7 +14,7 @@ import {
type VisibilityState, type VisibilityState,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { memo, RefObject, useEffect, useRef, useState } from "react" import { memo, type RefObject, useEffect, useRef, useState } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
@@ -44,6 +45,20 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
) )
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
// Hide ports column if no ports are present
useEffect(() => {
if (data) {
const hasPorts = data.some((container) => container.ports)
setColumnVisibility((prev) => {
if (prev.ports === hasPorts) {
return prev
}
return { ...prev, ports: hasPorts }
})
}
}, [data])
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("") const [globalFilter, setGlobalFilter] = useState("")
@@ -67,7 +82,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
setData((curItems) => { setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set() const containerIds = new Set()
const newItems = [] const newItems: ContainerRecord[] = []
for (const item of items) { for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) { if (Math.abs(lastUpdated - item.updated) < 70_000) {
containerIds.add(item.id) containerIds.add(item.id)
@@ -301,9 +316,6 @@ function ContainerSheet({
setSheetOpen: (open: boolean) => void setSheetOpen: (open: boolean) => void
activeContainer: RefObject<ContainerRecord | null> activeContainer: RefObject<ContainerRecord | null>
}) { }) {
const container = activeContainer.current
if (!container) return null
const [logsDisplay, setLogsDisplay] = useState<string>("") const [logsDisplay, setLogsDisplay] = useState<string>("")
const [infoDisplay, setInfoDisplay] = useState<string>("") const [infoDisplay, setInfoDisplay] = useState<string>("")
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false) const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
@@ -311,6 +323,8 @@ function ContainerSheet({
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false) const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
const logsContainerRef = useRef<HTMLDivElement>(null) const logsContainerRef = useRef<HTMLDivElement>(null)
const container = activeContainer.current
function scrollLogsToBottom() { function scrollLogsToBottom() {
if (logsContainerRef.current) { if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight }) logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
@@ -318,6 +332,7 @@ function ContainerSheet({
} }
const refreshLogs = async () => { const refreshLogs = async () => {
if (!container) return
setIsRefreshingLogs(true) setIsRefreshingLogs(true)
const startTime = Date.now() const startTime = Date.now()
@@ -349,6 +364,8 @@ function ContainerSheet({
})() })()
}, [container]) }, [container])
if (!container) return null
return ( return (
<> <>
<LogsFullscreenDialog <LogsFullscreenDialog
@@ -445,11 +462,12 @@ function ContainerSheet({
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) { function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return ( return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2"> <TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead className="px-2" key={header.id}> <TableHead className="px-2" key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead> </TableHead>
) )
@@ -481,6 +499,7 @@ const ContainerTableRow = memo(function ContainerTableRow({
className="py-0 ps-4.5" className="py-0 ps-4.5"
style={{ style={{
height: virtualRow.size, height: virtualRow.size,
width: cell.column.getSize(),
}} }}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@@ -3,13 +3,16 @@ import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState, type ColumnFiltersState,
type Column, type Column,
type Row,
type SortingState, type SortingState,
type Table as TableType,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { import {
Activity, Activity,
Box, Box,
@@ -40,6 +43,7 @@ import {
toFixedFloat, toFixedFloat,
formatTemperature, formatTemperature,
cn, cn,
getVisualStringWidth,
secondsToString, secondsToString,
hourWithSeconds, hourWithSeconds,
formatShortDate, formatShortDate,
@@ -57,7 +61,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { useCallback, useMemo, useEffect, useState } from "react" import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// Column definition for S.M.A.R.T. attributes table // Column definition for S.M.A.R.T. attributes table
@@ -101,7 +105,11 @@ function formatCapacity(bytes: number): string {
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const columns: ColumnDef<SmartDeviceRecord>[] = [ export const createColumns = (
longestName: number,
longestModel: number,
longestDevice: number
): ColumnDef<SmartDeviceRecord>[] => [
{ {
id: "system", id: "system",
accessorFn: (record) => record.system, accessorFn: (record) => record.system,
@@ -114,7 +122,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById) const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span> return (
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
</div>
)
}, },
}, },
{ {
@@ -122,7 +134,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />, header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}> <div
className="font-medium max-w-40 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestDevice / 1.05}ch` }}
>
{getValue() as string} {getValue() as string}
</div> </div>
), ),
@@ -132,7 +148,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />, header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}> <div
className="max-w-48 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestModel / 1.05}ch` }}
>
{getValue() as string} {getValue() as string}
</div> </div>
), ),
@@ -141,7 +161,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
accessorKey: "capacity", accessorKey: "capacity",
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>, cell: ({ getValue }) => <span className="ms-1">{formatCapacity(getValue() as number)}</span>,
}, },
{ {
accessorKey: "state", accessorKey: "state",
@@ -149,9 +169,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => { cell: ({ getValue }) => {
const status = getValue() as string const status = getValue() as string
return ( return (
<div className="ms-1.5"> <Badge className="ms-1" variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>
<Badge variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge> {status}
</div> </Badge>
) )
}, },
}, },
@@ -160,11 +180,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type), sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<div className="ms-1.5"> <Badge variant="outline" className="ms-1 uppercase">
<Badge variant="outline" className="uppercase">
{getValue() as string} {getValue() as string}
</Badge> </Badge>
</div>
), ),
}, },
{ {
@@ -176,11 +194,11 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => { cell: ({ getValue }) => {
const hours = getValue() as number | undefined const hours = getValue() as number | undefined
if (hours == null) { if (hours == null) {
return <div className="text-sm text-muted-foreground ms-1.5">N/A</div> return <div className="text-sm text-muted-foreground ms-1">N/A</div>
} }
const seconds = hours * 3600 const seconds = hours * 3600
return ( return (
<div className="text-sm ms-1.5"> <div className="text-sm ms-1">
<div>{secondsToString(seconds, "hour")}</div> <div>{secondsToString(seconds, "hour")}</div>
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div> <div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
</div> </div>
@@ -196,9 +214,9 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cycles = getValue() as number | undefined const cycles = getValue() as number | undefined
if (cycles == null) { if (cycles == null) {
return <div className="text-muted-foreground ms-1.5">N/A</div> return <div className="text-muted-foreground ms-1">N/A</div>
} }
return <span className="ms-1.5">{cycles.toLocaleString()}</span> return <span className="ms-1">{cycles.toLocaleString()}</span>
}, },
}, },
{ {
@@ -208,10 +226,10 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
cell: ({ getValue }) => { cell: ({ getValue }) => {
const temp = getValue() as number | null | undefined const temp = getValue() as number | null | undefined
if (!temp) { if (!temp) {
return <div className="text-muted-foreground ms-1.5">N/A</div> return <div className="text-muted-foreground ms-1">N/A</div>
} }
const { value, unit } = formatTemperature(temp) const { value, unit } = formatTemperature(temp)
return <span className="ms-1.5">{`${value} ${unit}`}</span> return <span className="ms-1">{`${value} ${unit}`}</span>
}, },
}, },
// { // {
@@ -236,7 +254,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
// if today, use hourWithSeconds, otherwise use formatShortDate // if today, use hourWithSeconds, otherwise use formatShortDate
const formatter = const formatter =
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
return <span className="ms-1.5 tabular-nums">{formatter(timestamp)}</span> return <span className="ms-1 tabular-nums">{formatter(timestamp)}</span>
}, },
}, },
] ]
@@ -275,6 +293,36 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null) const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
const [globalFilter, setGlobalFilter] = useState("") const [globalFilter, setGlobalFilter] = useState("")
const allSystems = useStore($allSystemsById)
// duplicate the devices to test with more rows
// if (
// smartDevices?.length &&
// smartDevices.length < 50 &&
// typeof window !== "undefined" &&
// window.location.hostname === "localhost"
// ) {
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
// }
// Calculate the right width for the columns based on the longest strings among the displayed devices
const { longestName, longestModel, longestDevice } = useMemo(() => {
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
if (!smartDevices || Object.keys(allSystems).length === 0) {
return result
}
const seenSystems = new Set<string>()
for (const device of smartDevices) {
if (!systemId && !seenSystems.has(device.system)) {
seenSystems.add(device.system)
const name = allSystems[device.system]?.name ?? ""
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
}
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
}
return result
}, [smartDevices, systemId, allSystems])
const openSheet = (disk: SmartDeviceRecord) => { const openSheet = (disk: SmartDeviceRecord) => {
setActiveDiskId(disk.id) setActiveDiskId(disk.id)
@@ -440,9 +488,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
// Filter columns based on whether systemId is provided // Filter columns based on whether systemId is provided
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
const columns = createColumns(longestName, longestModel, longestDevice)
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return [...baseColumns, actionColumn] return [...baseColumns, actionColumn]
}, [systemId, actionColumn]) }, [systemId, actionColumn, longestName, longestModel, longestDevice])
const table = useReactTable({ const table = useReactTable({
data: smartDevices || ([] as SmartDeviceRecord[]), data: smartDevices || ([] as SmartDeviceRecord[]),
@@ -474,6 +523,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
.every((term) => searchString.includes(term)) .every((term) => searchString.includes(term))
}, },
}) })
const rows = table.getRowModel().rows
// Hide the table on system pages if there's no data, but always show on global page // Hide the table on system pages if there's no data, but always show on global page
if (systemId && !smartDevices?.length && !columnFilters.length) { if (systemId && !smartDevices?.length && !columnFilters.length) {
@@ -513,57 +563,123 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<div className="rounded-md border text-nowrap"> <SmartDevicesTable
<Table> table={table}
<TableHeader> rows={rows}
{table.getHeaderGroups().map((headerGroup) => ( colLength={tableColumns.length}
<TableRow key={headerGroup.id}> data={smartDevices}
{headerGroup.headers.map((header) => { openSheet={openSheet}
return ( />
<TableHead key={header.id} className="px-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="md:ps-5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={tableColumns.length} className="h-24 text-center">
{smartDevices ? (
t`No results.`
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card> </Card>
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} /> <DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
</div> </div>
) )
} }
const SmartDevicesTable = memo(function SmartDevicesTable({
table,
rows,
colLength,
data,
openSheet,
}: {
table: TableType<SmartDeviceRecord>
rows: Row<SmartDeviceRecord>[]
colLength: number
data: SmartDeviceRecord[] | undefined
openSheet: (disk: SmartDeviceRecord) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 65,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto rounded-md border",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="w-full text-sm text-nowrap">
<SmartTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center pointer-events-none">
{data ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
})
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
const SmartDeviceTableRow = memo(function SmartDeviceTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<SmartDeviceRecord>
virtualRow: VirtualItem
openSheet: (disk: SmartDeviceRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="md:ps-5 py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
function DiskSheet({ function DiskSheet({
diskId, diskId,
open, open,

View File

@@ -46,7 +46,6 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
return setData([]) return setData([])
}, [systemId]) }, [systemId])
useEffect(() => { useEffect(() => {
const lastUpdated = data[0]?.updated ?? 0 const lastUpdated = data[0]?.updated ?? 0
@@ -360,15 +359,9 @@ function SystemdSheet({
return ( return (
<> <>
{hasCurrent ? current : notAvailable} {hasCurrent ? current : notAvailable}
{hasMax && ( {hasMax && <span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${max})`}</span>}
<span className="text-muted-foreground ms-1.5">
{`(${t`limit`}: ${max})`}
</span>
)}
{max === null && ( {max === null && (
<span className="text-muted-foreground ms-1.5"> <span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}</span>
{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}
</span>
)} )}
</> </>
) )
@@ -621,6 +614,7 @@ function SystemdSheet({
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) { function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
return ( return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2"> <TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {

View File

@@ -391,6 +391,7 @@ function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
const { t } = useLingui() const { t } = useLingui()
return ( return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2"> <TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {