mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-21 21:26:16 +01:00
Compare commits
4 Commits
380d2b1091
...
ed50367f70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed50367f70 | ||
|
|
4ebe869591 | ||
|
|
c9bbbe91f2 | ||
|
|
5bfe4f6970 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user