From 41a3d9359f5e52b16a07aa25547e5cc94b6fb3f7 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sun, 30 Nov 2025 13:32:37 -0500 Subject: [PATCH] add dedicated S.M.A.R.T. page with persistent device storage - Add /smart route to view SMART data across all systems - Store SMART devices in new smart_devices collection - Auto-fetch SMART data when system first comes online - Add refresh/delete actions per device with realtime updates - Add navbar and command palette entries --- internal/hub/hub.go | 21 +- internal/hub/systems/system.go | 59 +- internal/hub/systems/system_smart.go | 132 ++ internal/hub/systems/system_systemd_test.go | 18 +- internal/hub/ws/handlers.go | 14 +- .../0_collections_snapshot_0_16_2.go | 195 +++ .../site/src/components/command-palette.tsx | 21 +- .../containers-table/containers-table.tsx | 18 +- internal/site/src/components/navbar.tsx | 9 + internal/site/src/components/router.tsx | 1 + .../components/routes/settings/general.tsx | 2 +- internal/site/src/components/routes/smart.tsx | 20 + .../components/routes/system/smart-table.tsx | 1137 ++++++++++------- internal/site/src/index.css | 3 +- internal/site/src/main.tsx | 5 +- internal/site/src/types.d.ts | 17 + 16 files changed, 1151 insertions(+), 521 deletions(-) create mode 100644 internal/hub/systems/system_smart.go create mode 100644 internal/site/src/components/routes/smart.tsx diff --git a/internal/hub/hub.go b/internal/hub/hub.go index ec0d2839..28bd07fd 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -268,8 +268,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // update / delete user alerts apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) - // get SMART data - apiAuth.GET("/smart", h.getSmartData) + // refresh SMART devices for a system + apiAuth.POST("/smart/refresh", h.refreshSmartData) // get systemd service details apiAuth.GET("/systemd/info", h.getSystemdInfo) // /containers routes @@ -365,22 +365,25 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]any{"details": details}) } -// getSmartData handles GET /api/beszel/smart requests -func (h *Hub) getSmartData(e *core.RequestEvent) error { +// refreshSmartData handles POST /api/beszel/smart/refresh requests +// Fetches fresh SMART data from the agent and updates the collection +func (h *Hub) refreshSmartData(e *core.RequestEvent) error { systemID := e.Request.URL.Query().Get("system") if systemID == "" { return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) } + system, err := h.sm.GetSystem(systemID) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) } - data, err := system.FetchSmartDataFromAgent() - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + + // Fetch and save SMART devices + if err := system.FetchAndSaveSmartDevices(); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - e.Response.Header().Set("Cache-Control", "public, max-age=60") - return e.JSON(http.StatusOK, data) + + return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) } // generates key pair if it doesn't exist and returns signer diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 8ca5dbf9..f18704aa 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -9,6 +9,7 @@ import ( "math/rand" "net" "strings" + "sync" "time" "github.com/henrygd/beszel/internal/common" @@ -40,6 +41,7 @@ type System struct { WsConn *ws.WsConn // Handler for agent WebSocket connection agentVersion semver.Version // Agent version updateTicker *time.Ticker // Ticker for updating the system + smartOnce sync.Once // Once for fetching and saving smart devices } func (sm *SystemManager) NewSystem(systemId string) *System { @@ -191,6 +193,13 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return nil }) + // Fetch and save SMART devices when system first comes online + if err == nil { + sys.smartOnce.Do(func() { + go sys.FetchAndSaveSmartDevices() + }) + } + return systemRecord, err } @@ -208,7 +217,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s for i, service := range data { suffix := fmt.Sprintf("%d", i) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix)) - params["id"+suffix] = getSystemdServiceId(systemId, service.Name) + params["id"+suffix] = makeStableHashId(systemId, service.Name) params["name"+suffix] = service.Name params["state"+suffix] = service.State params["sub"+suffix] = service.Sub @@ -225,13 +234,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s return err } -// getSystemdServiceId generates a deterministic unique id for a systemd service -func getSystemdServiceId(systemId string, serviceName string) string { - hash := fnv.New32a() - hash.Write([]byte(systemId + serviceName)) - return fmt.Sprintf("%x", hash.Sum32()) -} - // createContainerRecords creates container records func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { if len(data) == 0 { @@ -435,43 +437,12 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic return result, err } -// FetchSmartDataFromAgent fetches SMART data from the agent -func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) { - // fetch via websocket - if sys.WsConn != nil && sys.WsConn.IsConnected() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return sys.WsConn.RequestSmartData(ctx) +func makeStableHashId(strings ...string) string { + hash := fnv.New32a() + for _, str := range strings { + hash.Write([]byte(str)) } - // fetch via SSH - var result map[string]any - err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) { - stdout, err := session.StdoutPipe() - if err != nil { - return false, err - } - stdin, stdinErr := session.StdinPipe() - if stdinErr != nil { - return false, stdinErr - } - if err := session.Shell(); err != nil { - return false, err - } - req := common.HubRequest[any]{Action: common.GetSmartData} - _ = cbor.NewEncoder(stdin).Encode(req) - _ = stdin.Close() - var resp common.AgentResponse - if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil { - return false, err - } - // Convert to generic map for JSON response - result = make(map[string]any, len(resp.SmartData)) - for k, v := range resp.SmartData { - result[k] = v - } - return false, nil - }) - return result, err + return fmt.Sprintf("%x", hash.Sum32()) } // fetchDataViaSSH handles fetching data using SSH. diff --git a/internal/hub/systems/system_smart.go b/internal/hub/systems/system_smart.go new file mode 100644 index 00000000..c3393464 --- /dev/null +++ b/internal/hub/systems/system_smart.go @@ -0,0 +1,132 @@ +package systems + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/fxamacker/cbor/v2" + "github.com/henrygd/beszel/internal/common" + "github.com/henrygd/beszel/internal/entities/smart" + "github.com/pocketbase/pocketbase/core" + "golang.org/x/crypto/ssh" +) + +// FetchSmartDataFromAgent fetches SMART data from the agent +func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) { + // fetch via websocket + if sys.WsConn != nil && sys.WsConn.IsConnected() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return sys.WsConn.RequestSmartData(ctx) + } + // fetch via SSH + var result map[string]smart.SmartData + err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) { + stdout, err := session.StdoutPipe() + if err != nil { + return false, err + } + stdin, stdinErr := session.StdinPipe() + if stdinErr != nil { + return false, stdinErr + } + if err := session.Shell(); err != nil { + return false, err + } + req := common.HubRequest[any]{Action: common.GetSmartData} + _ = cbor.NewEncoder(stdin).Encode(req) + _ = stdin.Close() + var resp common.AgentResponse + if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil { + return false, err + } + result = resp.SmartData + return false, nil + }) + return result, err +} + +// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database +func (sys *System) FetchAndSaveSmartDevices() error { + smartData, err := sys.FetchSmartDataFromAgent() + if err != nil || len(smartData) == 0 { + return err + } + return sys.saveSmartDevices(smartData) +} + +// saveSmartDevices saves SMART device data to the smart_devices collection +func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error { + if len(smartData) == 0 { + return nil + } + + hub := sys.manager.hub + collection, err := hub.FindCachedCollectionByNameOrId("smart_devices") + if err != nil { + return err + } + + for deviceKey, device := range smartData { + if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil { + return err + } + } + + return nil +} + +func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error { + hub := sys.manager.hub + recordID := makeStableHashId(sys.Id, deviceKey) + + record, err := hub.FindRecordById(collection, recordID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + record = core.NewRecord(collection) + record.Set("id", recordID) + } + + name := device.DiskName + if name == "" { + name = deviceKey + } + + powerOnHours, powerCycles := extractPowerMetrics(device.Attributes) + record.Set("system", sys.Id) + record.Set("name", name) + record.Set("model", device.ModelName) + record.Set("state", device.SmartStatus) + record.Set("capacity", device.Capacity) + record.Set("temp", device.Temperature) + record.Set("firmware", device.FirmwareVersion) + record.Set("serial", device.SerialNumber) + record.Set("type", device.DiskType) + record.Set("hours", powerOnHours) + record.Set("cycles", powerCycles) + record.Set("attributes", device.Attributes) + + return hub.SaveNoValidate(record) +} + +// extractPowerMetrics extracts power on hours and power cycles from SMART attributes +func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) { + for _, attr := range attributes { + nameLower := strings.ToLower(attr.Name) + if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) { + powerOnHours = attr.RawValue + } + if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) { + powerCycles = attr.RawValue + } + if powerOnHours > 0 && powerCycles > 0 { + break + } + } + return +} diff --git a/internal/hub/systems/system_systemd_test.go b/internal/hub/systems/system_systemd_test.go index 41ab494b..c2d890d5 100644 --- a/internal/hub/systems/system_systemd_test.go +++ b/internal/hub/systems/system_systemd_test.go @@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) { serviceName := "nginx.service" // Call multiple times and ensure same result - id1 := getSystemdServiceId(systemId, serviceName) - id2 := getSystemdServiceId(systemId, serviceName) - id3 := getSystemdServiceId(systemId, serviceName) + id1 := makeStableHashId(systemId, serviceName) + id2 := makeStableHashId(systemId, serviceName) + id3 := makeStableHashId(systemId, serviceName) assert.Equal(t, id1, id2) assert.Equal(t, id2, id3) @@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) { serviceName1 := "nginx.service" serviceName2 := "apache.service" - id1 := getSystemdServiceId(systemId1, serviceName1) - id2 := getSystemdServiceId(systemId2, serviceName1) - id3 := getSystemdServiceId(systemId1, serviceName2) - id4 := getSystemdServiceId(systemId2, serviceName2) + id1 := makeStableHashId(systemId1, serviceName1) + id2 := makeStableHashId(systemId2, serviceName1) + id3 := makeStableHashId(systemId1, serviceName2) + id4 := makeStableHashId(systemId2, serviceName2) // All IDs should be different assert.NotEqual(t, id1, id2) @@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) { } for _, tc := range testCases { - id := getSystemdServiceId(tc.systemId, tc.serviceName) + id := makeStableHashId(tc.systemId, tc.serviceName) // FNV-32 produces 8 hex characters assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName) } }) t.Run("hexadecimal output", func(t *testing.T) { - id := getSystemdServiceId("test-system", "test-service") + id := makeStableHashId("test-system", "test-service") assert.NotEmpty(t, id) // Should only contain hexadecimal characters diff --git a/internal/hub/ws/handlers.go b/internal/hub/ws/handlers.go index 5d07fe6f..d9472279 100644 --- a/internal/hub/ws/handlers.go +++ b/internal/hub/ws/handlers.go @@ -6,6 +6,7 @@ import ( "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" + "github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" "github.com/lxzan/gws" @@ -155,7 +156,7 @@ func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error { //////////////////////////////////////////////////////////////////////////// // RequestSmartData requests SMART data via WebSocket. -func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) { +func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, error) { if !ws.IsConnected() { return nil, gws.ErrConnClosed } @@ -163,7 +164,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) if err != nil { return nil, err } - var result map[string]any + var result map[string]smart.SmartData handler := ResponseHandler(&smartDataHandler{result: &result}) if err := ws.handleAgentRequest(req, handler); err != nil { return nil, err @@ -174,19 +175,14 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) // smartDataHandler parses SMART data map from AgentResponse type smartDataHandler struct { BaseHandler - result *map[string]any + result *map[string]smart.SmartData } func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error { if agentResponse.SmartData == nil { return errors.New("no SMART data in response") } - // convert to map[string]any for transport convenience in hub layer - out := make(map[string]any, len(agentResponse.SmartData)) - for k, v := range agentResponse.SmartData { - out[k] = v - } - *h.result = out + *h.result = agentResponse.SmartData return nil } diff --git a/internal/migrations/0_collections_snapshot_0_16_2.go b/internal/migrations/0_collections_snapshot_0_16_2.go index cd703df3..a40d805a 100644 --- a/internal/migrations/0_collections_snapshot_0_16_2.go +++ b/internal/migrations/0_collections_snapshot_0_16_2.go @@ -1243,6 +1243,201 @@ func init() { "type": "base", "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id" + }, + { + "createRule": null, + "deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{10}", + "hidden": false, + "id": "text3208210256", + "max": 10, + "min": 10, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "2hz5ncl8tizk5nx", + "hidden": false, + "id": "relation3377271179", + "maxSelect": 1, + "minSelect": 0, + "name": "system", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3616895705", + "max": 0, + "min": 0, + "name": "model", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2744374011", + "max": 0, + "min": 0, + "name": "state", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3051925876", + "max": null, + "min": null, + "name": "capacity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number190023114", + "max": null, + "min": null, + "name": "temp", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3589068740", + "max": 0, + "min": 0, + "name": "firmware", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3547646428", + "max": 0, + "min": 0, + "name": "serial", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2363381545", + "max": 0, + "min": 0, + "name": "type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1234567890", + "max": null, + "min": null, + "name": "hours", + "onlyInt": true, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number0987654321", + "max": null, + "min": null, + "name": "cycles", + "onlyInt": true, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json832282224", + "maxSize": 0, + "name": "attributes", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2571630677", + "indexes": [ + "CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)" + ], + "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", + "name": "smart_devices", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" } ]` diff --git a/internal/site/src/components/command-palette.tsx b/internal/site/src/components/command-palette.tsx index b753bbce..a0c73818 100644 --- a/internal/site/src/components/command-palette.tsx +++ b/internal/site/src/components/command-palette.tsx @@ -8,10 +8,11 @@ import { ContainerIcon, DatabaseBackupIcon, FingerprintIcon, - LayoutDashboard, + HardDriveIcon, LogsIcon, MailIcon, Server, + ServerIcon, SettingsIcon, UsersIcon, } from "lucide-react" @@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; )} { navigate(basePath) setOpen(false) }} > - + - Dashboard + All Systems Page @@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; Page + { + navigate(getPagePath($router, "smart")) + setOpen(false) + }} + > + + S.M.A.R.T. + + Page + + { navigate(getPagePath($router, "settings", { name: "general" })) diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx index 16300664..1c6eabc3 100644 --- a/internal/site/src/components/containers-table/containers-table.tsx +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from " import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" import { Button } from "@/components/ui/button" import { $allSystemsById } from "@/lib/stores" -import { MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react" +import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react" import { Separator } from "../ui/separator" import { $router, Link } from "../router" import { listenKeys } from "nanostores" @@ -36,7 +36,7 @@ const syntaxTheme = "github-dark-dimmed" export default function ContainersTable({ systemId }: { systemId?: string }) { const loadTime = Date.now() - const [data, setData] = useState([]) + const [data, setData] = useState(undefined) const [sorting, setSorting] = useBrowserStorage( `sort-c-${systemId ? 1 : 0}`, [{ id: systemId ? "name" : "system", desc: false }], @@ -67,7 +67,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) { newItems.push(item) } } - for (const item of curItems) { + for (const item of curItems ?? []) { if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) { newItems.push(item) } @@ -97,7 +97,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) { }, []) const table = useReactTable({ - data, + data: data ?? [], columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)), getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), @@ -174,7 +174,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
- +
) @@ -184,10 +184,12 @@ const AllContainersTable = memo(function AllContainersTable({ table, rows, colLength, + data, }: { table: TableType rows: Row[] colLength: number + data: ContainerRecord[] | undefined }) { // The virtualizer will need a reference to the scrollable container element const scrollRef = useRef(null) @@ -231,7 +233,11 @@ const AllContainersTable = memo(function AllContainersTable({ ) : ( - No results. + {data ? ( + No results. + ) : ( + + )} )} diff --git a/internal/site/src/components/navbar.tsx b/internal/site/src/components/navbar.tsx index d0035e8d..27b084fb 100644 --- a/internal/site/src/components/navbar.tsx +++ b/internal/site/src/components/navbar.tsx @@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router" import { ContainerIcon, DatabaseBackupIcon, + HardDriveIcon, LogOutIcon, LogsIcon, SearchIcon, @@ -29,6 +30,7 @@ import { LangToggle } from "./lang-toggle" import { Logo } from "./logo" import { ModeToggle } from "./mode-toggle" import { $router, basePath, Link, prependBasePath } from "./router" +import { t } from "@lingui/core/macro" const CommandPalette = lazy(() => import("./command-palette")) @@ -55,6 +57,13 @@ export default function Navbar() { > + + + ) { e.preventDefault() diff --git a/internal/site/src/components/routes/smart.tsx b/internal/site/src/components/routes/smart.tsx new file mode 100644 index 00000000..99f517c2 --- /dev/null +++ b/internal/site/src/components/routes/smart.tsx @@ -0,0 +1,20 @@ +import { useEffect } from "react" +import SmartTable from "@/components/routes/system/smart-table" +import { ActiveAlerts } from "@/components/active-alerts" +import { FooterRepoLink } from "@/components/footer-repo-link" + +export default function Smart() { + useEffect(() => { + document.title = `S.M.A.R.T. / Beszel` + }, []) + + return ( + <> +
+ + +
+ + + ) +} diff --git a/internal/site/src/components/routes/system/smart-table.tsx b/internal/site/src/components/routes/system/smart-table.tsx index a4c3792c..b6c8c9e2 100644 --- a/internal/site/src/components/routes/system/smart-table.tsx +++ b/internal/site/src/components/routes/system/smart-table.tsx @@ -1,486 +1,751 @@ -import * as React from "react" import { t } from "@lingui/core/macro" import { - ColumnDef, - ColumnFiltersState, - Column, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - SortingState, - useReactTable, + type ColumnDef, + type ColumnFiltersState, + type Column, + type SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, } from "@tanstack/react-table" -import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react" +import { + Activity, + Box, + Clock, + HardDrive, + BinaryIcon, + RotateCwIcon, + LoaderCircleIcon, + CheckCircle2Icon, + XCircleIcon, + ArrowLeftRightIcon, + MoreHorizontalIcon, + RefreshCwIcon, + ServerIcon, + Trash2Icon, + XIcon, +} from "lucide-react" import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { pb } from "@/lib/api" -import { SmartData, SmartAttribute } from "@/types" -import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils" +import type { SmartDeviceRecord, SmartAttribute } from "@/types" +import { + formatBytes, + toFixedFloat, + formatTemperature, + cn, + secondsToString, + hourWithSeconds, + formatShortDate, +} from "@/lib/utils" import { Trans } from "@lingui/react/macro" +import { useStore } from "@nanostores/react" +import { $allSystemsById } from "@/lib/stores" import { ThermometerIcon } from "@/components/ui/icons" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Separator } from "@/components/ui/separator" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useCallback, useMemo, useEffect, useState } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" // Column definition for S.M.A.R.T. attributes table export const smartColumns: ColumnDef[] = [ - { - accessorKey: "id", - header: "ID", - }, - { - accessorFn: (row) => row.n, - header: "Name", - }, - { - accessorFn: (row) => row.rs || row.rv?.toString(), - header: "Value", - }, - { - accessorKey: "v", - header: "Normalized", - }, - { - accessorKey: "w", - header: "Worst", - }, - { - accessorKey: "t", - header: "Threshold", - }, - { - // accessorFn: (row) => row.wf, - accessorKey: "wf", - header: "Failing", - }, + { + accessorKey: "id", + header: "ID", + }, + { + accessorFn: (row) => row.n, + header: "Name", + }, + { + accessorFn: (row) => row.rs || row.rv?.toString(), + header: "Value", + }, + { + accessorKey: "v", + header: "Normalized", + }, + { + accessorKey: "w", + header: "Worst", + }, + { + accessorKey: "t", + header: "Threshold", + }, + { + // accessorFn: (row) => row.wf, + accessorKey: "wf", + header: "Failing", + }, ] - - export type DiskInfo = { - device: string - model: string - serialNumber: string - firmwareVersion: string - capacity: string - status: string - temperature: number - deviceType: string - powerOnHours?: number - powerCycles?: number + id: string + system: string + device: string + model: string + capacity: string + status: string + temperature: number + deviceType: string + powerOnHours?: number + powerCycles?: number + attributes?: SmartAttribute[] + updated: string } // Function to format capacity display function formatCapacity(bytes: number): string { - const { value, unit } = formatBytes(bytes) - return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` + const { value, unit } = formatBytes(bytes) + return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` } -// Function to convert SmartData to DiskInfo -function convertSmartDataToDiskInfo(smartDataRecord: Record): DiskInfo[] { - const unknown = "Unknown" - return Object.entries(smartDataRecord).map(([key, smartData]) => ({ - device: smartData.dn || key, - model: smartData.mn || unknown, - serialNumber: smartData.sn || unknown, - firmwareVersion: smartData.fv || unknown, - capacity: smartData.c ? formatCapacity(smartData.c) : unknown, - status: smartData.s || unknown, - temperature: smartData.t || 0, - deviceType: smartData.dt || unknown, - // These fields need to be extracted from SmartAttribute if available - powerOnHours: smartData.a?.find(attr => { - const name = attr.n.toLowerCase(); - return name.includes("poweronhours") || name.includes("power_on_hours"); - })?.rv, - powerCycles: smartData.a?.find(attr => { - const name = attr.n.toLowerCase(); - return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles"); - })?.rv, - })) +// Function to convert SmartDeviceRecord to DiskInfo +function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] { + const unknown = "Unknown" + return records.map((record) => ({ + id: record.id, + system: record.system, + device: record.name || unknown, + model: record.model || unknown, + serialNumber: record.serial || unknown, + firmwareVersion: record.firmware || unknown, + capacity: record.capacity ? formatCapacity(record.capacity) : unknown, + status: record.state || unknown, + temperature: record.temp || 0, + deviceType: record.type || unknown, + attributes: record.attributes, + updated: record.updated, + powerOnHours: record.hours, + powerCycles: record.cycles, + })) } +const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" export const columns: ColumnDef[] = [ - { - accessorKey: "device", - sortingFn: (a, b) => a.original.device.localeCompare(b.original.device), - header: ({ column }) => , - cell: ({ row }) => ( -
- {row.getValue("device")} -
- ), - }, - { - accessorKey: "model", - sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), - header: ({ column }) => , - cell: ({ row }) => ( -
- {row.getValue("model")} -
- ), - }, - { - accessorKey: "capacity", - header: ({ column }) => , - cell: ({ getValue }) => ( - {getValue() as string} - ), - }, - { - accessorKey: "temperature", - invertSorting: true, - header: ({ column }) => , - cell: ({ getValue }) => { - const { value, unit } = formatTemperature(getValue() as number) - return {`${value} ${unit}`} - }, - }, - { - accessorKey: "status", - header: ({ column }) => , - cell: ({ getValue }) => { - const status = getValue() as string - return ( -
- - {status} - -
- ) - }, - }, - { - accessorKey: "deviceType", - sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType), - header: ({ column }) => , - cell: ({ getValue }) => ( -
- - {getValue() as string} - -
- ), - }, - { - accessorKey: "powerOnHours", - invertSorting: true, - header: ({ column }) => , - cell: ({ getValue }) => { - const hours = (getValue() ?? 0) as number - if (!hours && hours !== 0) { - return ( -
- N/A -
- ) - } - const seconds = hours * 3600 - return ( -
-
{secondsToString(seconds, "hour")}
-
{secondsToString(seconds, "day")}
-
- ) - }, - }, - { - accessorKey: "powerCycles", - invertSorting: true, - header: ({ column }) => , - cell: ({ getValue }) => { - const cycles = getValue() as number | undefined - if (!cycles && cycles !== 0) { - return ( -
- N/A -
- ) - } - return {cycles} - }, - }, - { - accessorKey: "serialNumber", - sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber), - header: ({ column }) => , - cell: ({ getValue }) => ( - {getValue() as string} - ), - }, - { - accessorKey: "firmwareVersion", - sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion), - header: ({ column }) => , - cell: ({ getValue }) => ( - {getValue() as string} - ), - }, + { + id: "system", + accessorFn: (record) => record.system, + sortingFn: (a, b) => { + const allSystems = $allSystemsById.get() + const systemNameA = allSystems[a.original.system]?.name ?? "" + const systemNameB = allSystems[b.original.system]?.name ?? "" + return systemNameA.localeCompare(systemNameB) + }, + header: ({ column }) => , + cell: ({ getValue }) => { + const allSystems = useStore($allSystemsById) + return {allSystems[getValue() as string]?.name ?? ""} + }, + }, + { + accessorKey: "device", + sortingFn: (a, b) => a.original.device.localeCompare(b.original.device), + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.getValue("device")} +
+ ), + }, + { + accessorKey: "model", + sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.getValue("model")} +
+ ), + }, + { + accessorKey: "capacity", + header: ({ column }) => , + cell: ({ getValue }) => {getValue() as string}, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ getValue }) => { + const status = getValue() as string + return ( +
+ {status} +
+ ) + }, + }, + { + accessorKey: "deviceType", + sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType), + header: ({ column }) => , + cell: ({ getValue }) => ( +
+ + {getValue() as string} + +
+ ), + }, + { + accessorKey: "powerOnHours", + invertSorting: true, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const hours = (getValue() ?? 0) as number + if (!hours && hours !== 0) { + return
N/A
+ } + const seconds = hours * 3600 + return ( +
+
{secondsToString(seconds, "hour")}
+
{secondsToString(seconds, "day")}
+
+ ) + }, + }, + { + accessorKey: "powerCycles", + invertSorting: true, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const cycles = getValue() as number | undefined + if (!cycles && cycles !== 0) { + return
N/A
+ } + return {cycles} + }, + }, + { + accessorKey: "temperature", + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const { value, unit } = formatTemperature(getValue() as number) + return {`${value} ${unit}`} + }, + }, + // { + // accessorKey: "serialNumber", + // sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber), + // header: ({ column }) => , + // cell: ({ getValue }) => {getValue() as string}, + // }, + // { + // accessorKey: "firmwareVersion", + // sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion), + // header: ({ column }) => , + // cell: ({ getValue }) => {getValue() as string}, + // }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as string + // if today, use hourWithSeconds, otherwise use formatShortDate + const formatter = + new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate + return {formatter(timestamp)} + }, + }, ] function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { - const isSorted = column.getIsSorted() - return ( - - ) + const isSorted = column.getIsSorted() + return ( + + ) } -export default function DisksTable({ systemId }: { systemId: string }) { - const [sorting, setSorting] = React.useState([{ id: "device", desc: false }]) - const [columnFilters, setColumnFilters] = React.useState([]) - const [rowSelection, setRowSelection] = React.useState({}) - const [smartData, setSmartData] = React.useState | undefined>(undefined) - const [activeDisk, setActiveDisk] = React.useState(null) - const [sheetOpen, setSheetOpen] = React.useState(false) +export default function DisksTable({ systemId }: { systemId?: string }) { + const [sorting, setSorting] = useState([{ id: systemId ? "device" : "system", desc: false }]) + const [columnFilters, setColumnFilters] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [smartDevices, setSmartDevices] = useState(undefined) + const [activeDiskId, setActiveDiskId] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null) + const [globalFilter, setGlobalFilter] = useState("") - const openSheet = (disk: DiskInfo) => { - setActiveDisk(disk) - setSheetOpen(true) - } + const openSheet = (disk: DiskInfo) => { + setActiveDiskId(disk.id) + setSheetOpen(true) + } - // Fetch smart data when component mounts or systemId changes - React.useEffect(() => { - if (systemId) { - pb.send>("/api/beszel/smart", { query: { system: systemId } }) - .then((data) => { - setSmartData(data) - }) - .catch(() => setSmartData({})) - } - }, [systemId]) + // Fetch smart devices from collection (without attributes to save bandwidth) + const fetchSmartDevices = useCallback(() => { + pb.collection("smart_devices") + .getFullList({ + filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined, + fields: SMART_DEVICE_FIELDS, + }) + .then((records) => { + setSmartDevices(records) + }) + .catch(() => setSmartDevices([])) + }, [systemId]) - // Convert SmartData to DiskInfo, if no data use empty array - const diskData = React.useMemo(() => { - return smartData ? convertSmartDataToDiskInfo(smartData) : [] - }, [smartData]) + // Fetch smart devices when component mounts or systemId changes + useEffect(() => { + fetchSmartDevices() + }, [fetchSmartDevices]) + // Subscribe to live updates so rows add/remove without manual refresh/filtering + useEffect(() => { + let unsubscribe: (() => void) | undefined + const pbOptions = systemId + ? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } + : { fields: SMART_DEVICE_FIELDS } - const table = useReactTable({ - data: diskData, - columns: columns, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onRowSelectionChange: setRowSelection, - state: { - sorting, - columnFilters, - rowSelection, - }, - }) + ;(async () => { + try { + unsubscribe = await pb.collection("smart_devices").subscribe( + "*", + (event) => { + const record = event.record as SmartDeviceRecord + setSmartDevices((currentDevices) => { + const devices = currentDevices ?? [] + const matchesSystemScope = !systemId || record.system === systemId - if (!diskData.length && !columnFilters.length) { - return null - } + if (event.action === "delete") { + return devices.filter((device) => device.id !== record.id) + } - return ( -
- - -
-
- - S.M.A.R.T. - - - Click on a device to view more information. - -
- - table.getColumn("device")?.setFilterValue(event.target.value) - } - className="ms-auto px-4 w-full max-w-full md:w-64" - /> -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - openSheet(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {smartData ? t`No results.` : } + if (!matchesSystemScope) { + // Record moved out of scope; ensure it disappears locally. + return devices.filter((device) => device.id !== record.id) + } - - - )} - -
-
-
- -
- ) + const existingIndex = devices.findIndex((device) => device.id === record.id) + if (existingIndex === -1) { + return [record, ...devices] + } + + const next = [...devices] + next[existingIndex] = record + return next + }) + }, + pbOptions + ) + } catch (error) { + console.error("Failed to subscribe to SMART device updates:", error) + } + })() + + return () => { + unsubscribe?.() + } + }, [systemId]) + + const handleRowRefresh = useCallback( + async (disk: DiskInfo) => { + if (!disk.system) return + setRowActionState({ type: "refresh", id: disk.id }) + try { + await pb.send("/api/beszel/smart/refresh", { + method: "POST", + query: { system: disk.system }, + }) + } catch (error) { + console.error("Failed to refresh SMART device:", error) + } finally { + setRowActionState((state) => (state?.id === disk.id ? null : state)) + } + }, + [fetchSmartDevices] + ) + + const handleDeleteDevice = useCallback(async (disk: DiskInfo) => { + setRowActionState({ type: "delete", id: disk.id }) + try { + await pb.collection("smart_devices").delete(disk.id) + // setSmartDevices((current) => current?.filter((device) => device.id !== disk.id)) + } catch (error) { + console.error("Failed to delete SMART device:", error) + } finally { + setRowActionState((state) => (state?.id === disk.id ? null : state)) + } + }, []) + + const actionColumn = useMemo>( + () => ({ + id: "actions", + enableSorting: false, + header: () => ( + + Actions + + ), + cell: ({ row }) => { + const disk = row.original + const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh" + const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete" + + return ( +
+ + + + + event.stopPropagation()}> + { + event.stopPropagation() + handleRowRefresh(disk) + }} + disabled={isRowRefreshing || isRowDeleting} + > + + Refresh + + + { + event.stopPropagation() + handleDeleteDevice(disk) + }} + disabled={isRowDeleting} + > + + Delete + + + +
+ ) + }, + }), + [handleRowRefresh, handleDeleteDevice, rowActionState] + ) + + // Filter columns based on whether systemId is provided + const tableColumns = useMemo(() => { + const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns + return [...baseColumns, actionColumn] + }, [systemId, actionColumn]) + + // Convert SmartDeviceRecord to DiskInfo + const diskData = useMemo(() => { + return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : [] + }, [smartDevices]) + + const table = useReactTable({ + data: diskData, + columns: tableColumns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + rowSelection, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const disk = row.original + const systemName = $allSystemsById.get()[disk.system]?.name ?? "" + const device = disk.device ?? "" + const model = disk.model ?? "" + const status = disk.status ?? "" + const type = disk.deviceType ?? "" + const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase() + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + // Hide the table on system pages if there's no data, but always show on global page + if (systemId && !diskData.length && !columnFilters.length) { + return null + } + + return ( +
+ + +
+
+ S.M.A.R.T. + + Click on a device to view more information. + +
+
+ setGlobalFilter(event.target.value)} + className="px-4 w-full max-w-full md:w-64" + /> + {globalFilter && ( + + )} +
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + openSheet(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {smartDevices ? ( + t`No results.` + ) : ( + + )} + + + )} + +
+
+
+ +
+ ) } -function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) { - if (!disk) return null +function DiskSheet({ + diskId, + open, + onOpenChange, +}: { + diskId: string | null + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [disk, setDisk] = useState(null) + const [isLoading, setIsLoading] = useState(false) - const smartAttributes = smartData?.a || [] + // Fetch full device record (including attributes) when sheet opens + useEffect(() => { + if (!diskId) { + setDisk(null) + return + } + // Only fetch when opening, not when closing (keeps data visible during close animation) + if (!open) return + setIsLoading(true) + pb.collection("smart_devices") + .getOne(diskId) + .then(setDisk) + .catch(() => setDisk(null)) + .finally(() => setIsLoading(false)) + }, [open, diskId]) - // Find all attributes where when failed is not empty - const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '') + const smartAttributes = disk?.attributes || [] - // Filter columns to only show those that have values in at least one row - const visibleColumns = React.useMemo(() => { - return smartColumns.filter(column => { - const accessorKey = (column as any).accessorKey as keyof SmartAttribute - if (!accessorKey) { - return true - } - // Check if any row has a non-empty value for this column - return smartAttributes.some(attr => { - return attr[accessorKey] !== undefined - }) - }) - }, [smartAttributes]) + // Find all attributes where when failed is not empty + const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "") - const table = useReactTable({ - data: smartAttributes, - columns: visibleColumns, - getCoreRowModel: getCoreRowModel(), - }) + // Filter columns to only show those that have values in at least one row + const visibleColumns = useMemo(() => { + return smartColumns.filter((column) => { + const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined + if (!accessorKey) { + return true + } + // Check if any row has a non-empty value for this column + return smartAttributes.some((attr) => { + return attr[accessorKey] !== undefined + }) + }) + }, [smartAttributes]) - return ( - - - - S.M.A.R.T. Details - {disk.device} - - {disk.model} - {disk.serialNumber} - - -
- - {smartData?.s === "PASSED" ? ( - - ) : ( - - )} - S.M.A.R.T. Self-Test: {smartData?.s} - {failedAttributes.length > 0 && ( - - Failed Attributes: {failedAttributes.map(attr => attr.n).join(", ")} - - )} - - {smartAttributes.length > 0 ? ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => { - // Check if the attribute is failed - const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ''; + const table = useReactTable({ + data: smartAttributes, + columns: visibleColumns, + getCoreRowModel: getCoreRowModel(), + }) - return ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ); - })} - -
-
- ) : ( -
- No S.M.A.R.T. attributes available for this device. -
- )} -
-
-
- ) -} \ No newline at end of file + const unknown = "Unknown" + const deviceName = disk?.name || unknown + const model = disk?.model || unknown + const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown + const serialNumber = disk?.serial || unknown + const firmwareVersion = disk?.firmware || unknown + const status = disk?.state || unknown + + return ( + + + + + S.M.A.R.T. Details - {deviceName} + + + {model} + + {capacity} + + + + {serialNumber} + + + Serial Number + + + + + + {firmwareVersion} + + + Firmware Version + + + + +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + + {status === "PASSED" ? : } + + S.M.A.R.T. Self-Test: {status} + + {failedAttributes.length > 0 && ( + + Failed Attributes: {failedAttributes.map((attr) => attr.n).join(", ")} + + )} + + {smartAttributes.length > 0 ? ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => { + // Check if the attribute is failed + const isFailedAttribute = row.original.wf && row.original.wf.trim() !== "" + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + })} + +
+
+ ) : ( +
+ No S.M.A.R.T. attributes available for this device. +
+ )} + + )} +
+
+
+ ) +} diff --git a/internal/site/src/index.css b/internal/site/src/index.css index ce8dcc36..9cba7028 100644 --- a/internal/site/src/index.css +++ b/internal/site/src/index.css @@ -34,7 +34,7 @@ --table-header: hsl(225, 6%, 97%); --chart-saturation: 65%; --chart-lightness: 50%; - --container: 1480px; + --container: 1500px; } .dark { @@ -117,7 +117,6 @@ } @layer utilities { - /* Fonts */ @supports (font-variation-settings: normal) { :root { diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index 09e23598..81cd3c63 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts" const LoginPage = lazy(() => import("@/components/login/login.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx")) const Containers = lazy(() => import("@/components/routes/containers.tsx")) +const Smart = lazy(() => import("@/components/routes/smart.tsx")) const SystemDetail = lazy(() => import("@/components/routes/system.tsx")) const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx")) @@ -62,6 +63,8 @@ const App = memo(() => { return } else if (page.route === "containers") { return + } else if (page.route === "smart") { + return } else if (page.route === "settings") { return } @@ -97,7 +100,7 @@ const Layout = () => { ) : ( -
+
diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index c191ae92..df6ac03e 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -377,6 +377,23 @@ export interface SmartAttribute { wf?: string } +export interface SmartDeviceRecord extends RecordModel { + id: string + system: string + name: string + model: string + state: string + capacity: number + temp: number + firmware: string + serial: string + type: string + hours: number + cycles: number + attributes: SmartAttribute[] + updated: string +} + export interface SystemdRecord extends RecordModel { system: string name: string