mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 02:36:17 +01:00
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
This commit is contained in:
@@ -268,8 +268,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// get SMART data
|
// refresh SMART devices for a system
|
||||||
apiAuth.GET("/smart", h.getSmartData)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
@@ -365,22 +365,25 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSmartData handles GET /api/beszel/smart requests
|
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
// 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")
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
if systemID == "" {
|
if systemID == "" {
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||||
}
|
}
|
||||||
data, err := system.FetchSmartDataFromAgent()
|
|
||||||
if err != nil {
|
// Fetch and save SMART devices
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
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
|
// generates key pair if it doesn't exist and returns signer
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
@@ -40,6 +41,7 @@ type System struct {
|
|||||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
agentVersion semver.Version // Agent version
|
agentVersion semver.Version // Agent version
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
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 {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -191,6 +193,13 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil
|
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
|
return systemRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +217,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
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))
|
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["name"+suffix] = service.Name
|
||||||
params["state"+suffix] = service.State
|
params["state"+suffix] = service.State
|
||||||
params["sub"+suffix] = service.Sub
|
params["sub"+suffix] = service.Sub
|
||||||
@@ -225,13 +234,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
return err
|
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
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
@@ -435,43 +437,12 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
func makeStableHashId(strings ...string) string {
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
hash := fnv.New32a()
|
||||||
// fetch via websocket
|
for _, str := range strings {
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
hash.Write([]byte(str))
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestSmartData(ctx)
|
|
||||||
}
|
}
|
||||||
// fetch via SSH
|
return fmt.Sprintf("%x", hash.Sum32())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDataViaSSH handles fetching data using SSH.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
|
|||||||
132
internal/hub/systems/system_smart.go
Normal file
132
internal/hub/systems/system_smart.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName := "nginx.service"
|
serviceName := "nginx.service"
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
// Call multiple times and ensure same result
|
||||||
id1 := getSystemdServiceId(systemId, serviceName)
|
id1 := makeStableHashId(systemId, serviceName)
|
||||||
id2 := getSystemdServiceId(systemId, serviceName)
|
id2 := makeStableHashId(systemId, serviceName)
|
||||||
id3 := getSystemdServiceId(systemId, serviceName)
|
id3 := makeStableHashId(systemId, serviceName)
|
||||||
|
|
||||||
assert.Equal(t, id1, id2)
|
assert.Equal(t, id1, id2)
|
||||||
assert.Equal(t, id2, id3)
|
assert.Equal(t, id2, id3)
|
||||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName1 := "nginx.service"
|
serviceName1 := "nginx.service"
|
||||||
serviceName2 := "apache.service"
|
serviceName2 := "apache.service"
|
||||||
|
|
||||||
id1 := getSystemdServiceId(systemId1, serviceName1)
|
id1 := makeStableHashId(systemId1, serviceName1)
|
||||||
id2 := getSystemdServiceId(systemId2, serviceName1)
|
id2 := makeStableHashId(systemId2, serviceName1)
|
||||||
id3 := getSystemdServiceId(systemId1, serviceName2)
|
id3 := makeStableHashId(systemId1, serviceName2)
|
||||||
id4 := getSystemdServiceId(systemId2, serviceName2)
|
id4 := makeStableHashId(systemId2, serviceName2)
|
||||||
|
|
||||||
// All IDs should be different
|
// All IDs should be different
|
||||||
assert.NotEqual(t, id1, id2)
|
assert.NotEqual(t, id1, id2)
|
||||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
id := getSystemdServiceId(tc.systemId, tc.serviceName)
|
id := makeStableHashId(tc.systemId, tc.serviceName)
|
||||||
// FNV-32 produces 8 hex characters
|
// 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)
|
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) {
|
t.Run("hexadecimal output", func(t *testing.T) {
|
||||||
id := getSystemdServiceId("test-system", "test-service")
|
id := makeStableHashId("test-system", "test-service")
|
||||||
assert.NotEmpty(t, id)
|
assert.NotEmpty(t, id)
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
// Should only contain hexadecimal characters
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"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/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -155,7 +156,7 @@ func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
// 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() {
|
if !ws.IsConnected() {
|
||||||
return nil, gws.ErrConnClosed
|
return nil, gws.ErrConnClosed
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var result map[string]any
|
var result map[string]smart.SmartData
|
||||||
handler := ResponseHandler(&smartDataHandler{result: &result})
|
handler := ResponseHandler(&smartDataHandler{result: &result})
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||||
return nil, err
|
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
|
// smartDataHandler parses SMART data map from AgentResponse
|
||||||
type smartDataHandler struct {
|
type smartDataHandler struct {
|
||||||
BaseHandler
|
BaseHandler
|
||||||
result *map[string]any
|
result *map[string]smart.SmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
if agentResponse.SmartData == nil {
|
if agentResponse.SmartData == nil {
|
||||||
return errors.New("no SMART data in response")
|
return errors.New("no SMART data in response")
|
||||||
}
|
}
|
||||||
// convert to map[string]any for transport convenience in hub layer
|
*h.result = agentResponse.SmartData
|
||||||
out := make(map[string]any, len(agentResponse.SmartData))
|
|
||||||
for k, v := range agentResponse.SmartData {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
*h.result = out
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1243,6 +1243,201 @@ func init() {
|
|||||||
"type": "base",
|
"type": "base",
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
"viewRule": "@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"
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
HardDriveIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home", t`All Systems`]}
|
keywords={["home"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 size-4" />
|
<ServerIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Dashboard</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
@@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(getPagePath($router, "smart"))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardDriveIcon className="me-2 size-4" />
|
||||||
|
<span>S.M.A.R.T.</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Page</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
|
|||||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
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 { Separator } from "../ui/separator"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
@@ -36,7 +36,7 @@ const syntaxTheme = "github-dark-dimmed"
|
|||||||
|
|
||||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||||
const loadTime = Date.now()
|
const loadTime = Date.now()
|
||||||
const [data, setData] = useState<ContainerRecord[]>([])
|
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
`sort-c-${systemId ? 1 : 0}`,
|
`sort-c-${systemId ? 1 : 0}`,
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
@@ -67,7 +67,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
newItems.push(item)
|
newItems.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const item of curItems) {
|
for (const item of curItems ?? []) {
|
||||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||||
newItems.push(item)
|
newItems.push(item)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: data ?? [],
|
||||||
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
@@ -174,7 +174,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -184,10 +184,12 @@ const AllContainersTable = memo(function AllContainersTable({
|
|||||||
table,
|
table,
|
||||||
rows,
|
rows,
|
||||||
colLength,
|
colLength,
|
||||||
|
data,
|
||||||
}: {
|
}: {
|
||||||
table: TableType<ContainerRecord>
|
table: TableType<ContainerRecord>
|
||||||
rows: Row<ContainerRecord>[]
|
rows: Row<ContainerRecord>[]
|
||||||
colLength: number
|
colLength: number
|
||||||
|
data: ContainerRecord[] | undefined
|
||||||
}) {
|
}) {
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -231,7 +233,11 @@ const AllContainersTable = memo(function AllContainersTable({
|
|||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
<Trans>No results.</Trans>
|
{data ? (
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
) : (
|
||||||
|
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
import {
|
import {
|
||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
|
HardDriveIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -29,6 +30,7 @@ import { LangToggle } from "./lang-toggle"
|
|||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
@@ -55,6 +57,13 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "smart")}
|
||||||
|
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
aria-label="S.M.A.R.T."
|
||||||
|
>
|
||||||
|
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
|||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
containers: "/containers",
|
||||||
|
smart: "/smart",
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
const currentUserSettings = useStore($userSettings)
|
const currentUserSettings = useStore($userSettings)
|
||||||
const layoutWidth = currentUserSettings.layoutWidth ?? 1480
|
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
20
internal/site/src/components/routes/smart.tsx
Normal file
20
internal/site/src/components/routes/smart.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ActiveAlerts />
|
||||||
|
<SmartTable />
|
||||||
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
|||||||
--table-header: hsl(225, 6%, 97%);
|
--table-header: hsl(225, 6%, 97%);
|
||||||
--chart-saturation: 65%;
|
--chart-saturation: 65%;
|
||||||
--chart-lightness: 50%;
|
--chart-lightness: 50%;
|
||||||
--container: 1480px;
|
--container: 1500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -117,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts"
|
|||||||
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
||||||
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
||||||
const Containers = lazy(() => import("@/components/routes/containers.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 SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ const App = memo(() => {
|
|||||||
return <SystemDetail id={page.params.id} />
|
return <SystemDetail id={page.params.id} />
|
||||||
} else if (page.route === "containers") {
|
} else if (page.route === "containers") {
|
||||||
return <Containers />
|
return <Containers />
|
||||||
|
} else if (page.route === "smart") {
|
||||||
|
return <Smart />
|
||||||
} else if (page.route === "settings") {
|
} else if (page.route === "settings") {
|
||||||
return <Settings />
|
return <Settings />
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ const Layout = () => {
|
|||||||
<LoginPage />
|
<LoginPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<div style={{"--container": `${userSettings.layoutWidth ?? 1480}px`} as React.CSSProperties}>
|
<div style={{ "--container": `${userSettings.layoutWidth ?? 1500}px` } as React.CSSProperties}>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
internal/site/src/types.d.ts
vendored
17
internal/site/src/types.d.ts
vendored
@@ -377,6 +377,23 @@ export interface SmartAttribute {
|
|||||||
wf?: string
|
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 {
|
export interface SystemdRecord extends RecordModel {
|
||||||
system: string
|
system: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user