mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
refactoring (no functionality changes)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -25,13 +26,16 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
|
builder := strings.Builder{}
|
||||||
fmt.Println("\nCommands:")
|
builder.WriteString("Usage: ")
|
||||||
fmt.Println(" health Check if the agent is running")
|
builder.WriteString(os.Args[0])
|
||||||
fmt.Println(" help Display this help message")
|
builder.WriteString(" [command] [flags]\n")
|
||||||
fmt.Println(" update Update to the latest version")
|
builder.WriteString("\nCommands:\n")
|
||||||
fmt.Println(" version Display the version")
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
fmt.Println("\nFlags:")
|
builder.WriteString(" help Display this help message\n")
|
||||||
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
|
builder.WriteString("\nFlags:\n")
|
||||||
|
fmt.Print(builder.String())
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +115,12 @@ func main() {
|
|||||||
serverConfig.Addr = addr
|
serverConfig.Addr = addr
|
||||||
serverConfig.Network = agent.GetNetwork(addr)
|
serverConfig.Network = agent.GetNetwork(addr)
|
||||||
|
|
||||||
agent, err := agent.NewAgent()
|
a, err := agent.NewAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to create agent: ", err)
|
log.Fatal("Failed to create agent: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := agent.Start(serverConfig); err != nil {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start server: ", err)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cachedData, ok := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(sessionID)
|
||||||
if ok {
|
if isCached {
|
||||||
slog.Debug("Cached stats", "session", sessionID)
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*cachedData = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", cachedData)
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
cachedData.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", cachedData.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Docker stats", "err", err)
|
slog.Debug("Containers", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
cachedData.Stats.ExtraFs[name] = stats
|
data.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(sessionID, cachedData)
|
a.cache.Set(sessionID, data)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
@@ -293,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: user.Id,
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,29 +43,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
|||||||
// Initialize user settings with defaults if not set
|
// Initialize user settings with defaults if not set
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
record := e.Record
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults (zero values can be ignored)
|
||||||
settings := UserSettings{
|
settings := UserSettings{
|
||||||
ChartTime: "1h",
|
ChartTime: "1h",
|
||||||
NotificationEmails: []string{},
|
|
||||||
NotificationWebhooks: []string{},
|
|
||||||
}
|
}
|
||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
|
||||||
// get user email from auth record
|
// get user email from auth record
|
||||||
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
var user struct {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
Email string `db:"email"`
|
||||||
if user := record.ExpandedOne("user"); user != nil {
|
|
||||||
settings.NotificationEmails = []string{user.GetString("email")}
|
|
||||||
} else {
|
|
||||||
log.Println("Failed to get user email from auth record")
|
|
||||||
}
|
}
|
||||||
} else {
|
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
|
||||||
log.Println("failed to expand user relation", "errs", errs)
|
"id": record.GetString("user"),
|
||||||
|
}).One(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("failed to get user email", "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
settings.NotificationEmails = []string{user.Email}
|
||||||
|
if len(settings.NotificationWebhooks) == 0 {
|
||||||
|
settings.NotificationWebhooks = []string{""}
|
||||||
}
|
}
|
||||||
// if len(settings.NotificationWebhooks) == 0 {
|
|
||||||
// settings.NotificationWebhooks = []string{""}
|
|
||||||
// }
|
|
||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
@@ -72,18 +72,18 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
|
||||||
// alertsSignature changes only when alerts for this system change
|
/* key to prevent re-rendering */
|
||||||
let alertsSignature = ""
|
const alertsSignature: string[] = []
|
||||||
|
|
||||||
const systemAlerts = alerts.filter((alert) => {
|
const systemAlerts = alerts.filter((alert) => {
|
||||||
if (alert.system === system.id) {
|
if (alert.system === system.id) {
|
||||||
alertsSignature += alert.name + alert.min + alert.value
|
alertsSignature.push(alert.name, alert.min, alert.value)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}) as AlertRecord[]
|
}) as AlertRecord[]
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// console.log("render modal", system.name, alertsSignature)
|
|
||||||
const data = Object.keys(alertInfo).map((name) => {
|
const data = Object.keys(alertInfo).map((name) => {
|
||||||
const alert = alertInfo[name as keyof typeof alertInfo]
|
const alert = alertInfo[name as keyof typeof alertInfo]
|
||||||
return {
|
return {
|
||||||
@@ -149,5 +149,5 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}, [alertsSignature, overwriteExisting])
|
}, [alertsSignature.join(""), overwriteExisting])
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,9 @@ export const Home = memo(() => {
|
|||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
let alertsKey = ""
|
/* key to prevent re-rendering of active alerts */
|
||||||
|
const alertsKey: string[] = []
|
||||||
|
|
||||||
const activeAlerts = useMemo(() => {
|
const activeAlerts = useMemo(() => {
|
||||||
const activeAlerts = alerts.filter((alert) => {
|
const activeAlerts = alerts.filter((alert) => {
|
||||||
const active = alert.triggered && alert.name in alertInfo
|
const active = alert.triggered && alert.name in alertInfo
|
||||||
@@ -26,7 +28,7 @@ export const Home = memo(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
||||||
alertsKey += alert.id
|
alertsKey.push(alert.id)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return activeAlerts
|
return activeAlerts
|
||||||
@@ -81,7 +83,7 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey]
|
[alertsKey.join("")]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -0,0 +1,420 @@
|
|||||||
|
import { SystemRecord } from "@/types"
|
||||||
|
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||||
|
import { ClassValue } from "clsx"
|
||||||
|
import {
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CpuIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
MemoryStickIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
WifiIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
copyToClipboard,
|
||||||
|
decimalString,
|
||||||
|
formatBytes,
|
||||||
|
formatTemperature,
|
||||||
|
isReadOnlyUser,
|
||||||
|
parseSemVer,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $userSettings, pb } from "@/lib/stores"
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useMemo, useRef, useState } from "react"
|
||||||
|
import { memo } from "react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { Dialog } from "../ui/dialog"
|
||||||
|
import { SystemDialog } from "../add-system"
|
||||||
|
import { AlertDialog } from "../ui/alert-dialog"
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../ui/alert-dialog"
|
||||||
|
import { buttonVariants } from "../ui/button"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param viewMode - "table" or "grid"
|
||||||
|
* @returns - Column definitions for the systems table
|
||||||
|
*/
|
||||||
|
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
|
const statusTranslations = {
|
||||||
|
up: () => t`Up`.toLowerCase(),
|
||||||
|
down: () => t`Down`.toLowerCase(),
|
||||||
|
paused: () => t`Paused`.toLowerCase(),
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
size: 200,
|
||||||
|
minSize: 0,
|
||||||
|
accessorKey: "name",
|
||||||
|
id: "system",
|
||||||
|
name: () => t`System`,
|
||||||
|
filterFn: (row, _, filterVal) => {
|
||||||
|
const filterLower = filterVal.toLowerCase()
|
||||||
|
const { name, status } = row.original
|
||||||
|
// Check if the filter matches the name or status for this row
|
||||||
|
if (
|
||||||
|
name.toLowerCase().includes(filterLower) ||
|
||||||
|
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
enableHiding: false,
|
||||||
|
invertSorting: false,
|
||||||
|
Icon: ServerIcon,
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
{info.getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.cpu,
|
||||||
|
id: "cpu",
|
||||||
|
name: () => t`CPU`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: CpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorKey: "info.mp",
|
||||||
|
accessorFn: ({ info }) => info.mp,
|
||||||
|
id: "memory",
|
||||||
|
name: () => t`Memory`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: MemoryStickIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dp,
|
||||||
|
id: "disk",
|
||||||
|
name: () => t`Disk`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: HardDriveIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.g,
|
||||||
|
id: "gpu",
|
||||||
|
name: () => "GPU",
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: GpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadAverage",
|
||||||
|
accessorFn: ({ info }) => {
|
||||||
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
|
// TODO: remove this in future release in favor of la array
|
||||||
|
if (!sum) {
|
||||||
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
|
size: 0,
|
||||||
|
Icon: HourglassIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const { info: sysInfo, status } = info.row.original
|
||||||
|
// agent version
|
||||||
|
const { minor, patch } = parseSemVer(sysInfo.v)
|
||||||
|
let loadAverages = sysInfo.la
|
||||||
|
|
||||||
|
// use legacy load averages if agent version is less than 12.1.0
|
||||||
|
if (!loadAverages || (minor === 12 && patch < 1)) {
|
||||||
|
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...loadAverages)
|
||||||
|
if (max === 0 && (status === "paused" || minor < 12)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotColor() {
|
||||||
|
const normalized = max / (sysInfo.t ?? 1)
|
||||||
|
if (status !== "up") return "bg-primary/30"
|
||||||
|
if (normalized < 0.7) return "bg-green-500"
|
||||||
|
if (normalized < 1) return "bg-yellow-500"
|
||||||
|
return "bg-red-600"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||||
|
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
|
||||||
|
{loadAverages?.map((la, i) => (
|
||||||
|
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
|
id: "net",
|
||||||
|
name: () => t`Net`,
|
||||||
|
size: 0,
|
||||||
|
Icon: EthernetIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const sys = info.row.original
|
||||||
|
if (sys.status === "paused") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dt,
|
||||||
|
id: "temp",
|
||||||
|
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
||||||
|
size: 50,
|
||||||
|
hideSort: true,
|
||||||
|
Icon: ThermometerIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const val = info.getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
|
return (
|
||||||
|
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.v,
|
||||||
|
id: "agent",
|
||||||
|
name: () => t`Agent`,
|
||||||
|
// invertSorting: true,
|
||||||
|
size: 50,
|
||||||
|
Icon: WifiIcon,
|
||||||
|
hideSort: true,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const version = info.getValue() as string
|
||||||
|
if (!version) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const system = info.row.original
|
||||||
|
return (
|
||||||
|
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
||||||
|
<IndicatorDot
|
||||||
|
system={system}
|
||||||
|
className={
|
||||||
|
(system.status !== "up" && "bg-primary/30") ||
|
||||||
|
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
||||||
|
"bg-yellow-500"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
// @ts-ignore
|
||||||
|
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||||
|
size: 50,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end items-center gap-1 -ms-3">
|
||||||
|
<AlertButton system={row.original} />
|
||||||
|
<ActionsButton system={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as ColumnDef<SystemRecord>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||||
|
const { column } = context
|
||||||
|
// @ts-ignore
|
||||||
|
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 px-3 flex"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="me-2 size-4" />}
|
||||||
|
{name()}
|
||||||
|
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const val = Number(info.getValue()) || 0
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
|
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
|
(info.row.original.status !== "up" && "bg-primary/30") ||
|
||||||
|
(val < 65 && "bg-green-500") ||
|
||||||
|
(val < 90 && "bg-yellow-500") ||
|
||||||
|
"bg-red-600"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
|
className ||= {
|
||||||
|
"bg-green-500": system.status === "up",
|
||||||
|
"bg-red-500": system.status === "down",
|
||||||
|
"bg-primary/40": system.status === "paused",
|
||||||
|
"bg-yellow-500": system.status === "pending",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
|
// style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
let editOpened = useRef(false)
|
||||||
|
const { t } = useLingui()
|
||||||
|
const { id, status, host, name } = system
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
editOpened.current = true
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(id, {
|
||||||
|
status: status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy name</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* edit dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
||||||
|
</Dialog>
|
||||||
|
{/* deletion dialog */}
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {name} from the
|
||||||
|
database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [id, status, host, name, t, deleteOpen, editOpen])
|
||||||
|
})
|
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CellContext,
|
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
@@ -9,14 +8,13 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
HeaderContext,
|
|
||||||
Row,
|
Row,
|
||||||
Table as TableType,
|
Table as TableType,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -29,105 +27,30 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
MoreHorizontalIcon,
|
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
MemoryStickIcon,
|
|
||||||
CopyIcon,
|
|
||||||
PauseCircleIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
WifiIcon,
|
|
||||||
HardDriveIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CpuIcon,
|
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
PenBoxIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import { $systems, $userSettings, pb } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import {
|
import { cn, useLocalStorage } from "@/lib/utils"
|
||||||
cn,
|
|
||||||
copyToClipboard,
|
|
||||||
isReadOnlyUser,
|
|
||||||
useLocalStorage,
|
|
||||||
formatTemperature,
|
|
||||||
decimalString,
|
|
||||||
formatBytes,
|
|
||||||
parseSemVer,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import AlertsButton from "../alerts/alert-button"
|
|
||||||
import { $router, Link, navigate } from "../router"
|
import { $router, Link, navigate } from "../router"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { ClassValue } from "clsx"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { SystemDialog } from "../add-system"
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
import { Dialog } from "../ui/dialog"
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const val = Number(info.getValue()) || 0
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
|
||||||
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
|
||||||
(info.row.original.status !== "up" && "bg-primary/30") ||
|
|
||||||
(val < 65 && "bg-green-500") ||
|
|
||||||
(val < 90 && "bg-yellow-500") ||
|
|
||||||
"bg-red-600"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transform: `scalex(${val / 100})`,
|
|
||||||
}}
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
|
||||||
const { column } = context
|
|
||||||
// @ts-ignore
|
|
||||||
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-9 px-3 flex"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="me-2 size-4" />}
|
|
||||||
{name()}
|
|
||||||
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SystemsTable() {
|
export default function SystemsTable() {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
@@ -145,212 +68,7 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columnDefs = useMemo(() => {
|
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [])
|
||||||
const statusTranslations = {
|
|
||||||
up: () => t`Up`.toLowerCase(),
|
|
||||||
down: () => t`Down`.toLowerCase(),
|
|
||||||
paused: () => t`Paused`.toLowerCase(),
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
size: 200,
|
|
||||||
minSize: 0,
|
|
||||||
accessorKey: "name",
|
|
||||||
id: "system",
|
|
||||||
name: () => t`System`,
|
|
||||||
filterFn: (row, _, filterVal) => {
|
|
||||||
const filterLower = filterVal.toLowerCase()
|
|
||||||
const { name, status } = row.original
|
|
||||||
// Check if the filter matches the name or status for this row
|
|
||||||
if (
|
|
||||||
name.toLowerCase().includes(filterLower) ||
|
|
||||||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
enableHiding: false,
|
|
||||||
invertSorting: false,
|
|
||||||
Icon: ServerIcon,
|
|
||||||
cell: (info) => (
|
|
||||||
<span className="flex gap-2 items-center md:ps-1 md:pe-5">
|
|
||||||
<IndicatorDot system={info.row.original} />
|
|
||||||
<span className="font-medium text-sm text-nowrap">
|
|
||||||
{info.getValue() as string}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.cpu,
|
|
||||||
id: "cpu",
|
|
||||||
name: () => t`CPU`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: CpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// accessorKey: "info.mp",
|
|
||||||
accessorFn: ({ info }) => info.mp,
|
|
||||||
id: "memory",
|
|
||||||
name: () => t`Memory`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: MemoryStickIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.dp,
|
|
||||||
id: "disk",
|
|
||||||
name: () => t`Disk`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: HardDriveIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.g,
|
|
||||||
id: "gpu",
|
|
||||||
name: () => "GPU",
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: GpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loadAverage",
|
|
||||||
accessorFn: ({ info }) => {
|
|
||||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
|
||||||
// TODO: remove this in future release in favor of la array
|
|
||||||
if (!sum) {
|
|
||||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
},
|
|
||||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
|
||||||
size: 0,
|
|
||||||
Icon: HourglassIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const { info: sysInfo, status } = info.row.original
|
|
||||||
// agent version
|
|
||||||
const { minor, patch } = parseSemVer(sysInfo.v)
|
|
||||||
let loadAverages = sysInfo.la
|
|
||||||
|
|
||||||
// use legacy load averages if agent version is less than 12.1.0
|
|
||||||
if (!loadAverages || (minor === 12 && patch < 1)) {
|
|
||||||
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max(...loadAverages)
|
|
||||||
if (max === 0 && (status === "paused" || minor < 12)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDotColor() {
|
|
||||||
const normalized = max / (sysInfo.t ?? 1)
|
|
||||||
if (status !== "up") return "bg-primary/30"
|
|
||||||
if (normalized < 0.7) return "bg-green-500"
|
|
||||||
if (normalized < 1) return "bg-yellow-500"
|
|
||||||
return "bg-red-600"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
|
||||||
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
|
|
||||||
{loadAverages?.map((la, i) => (
|
|
||||||
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
|
||||||
id: "net",
|
|
||||||
name: () => t`Net`,
|
|
||||||
size: 0,
|
|
||||||
Icon: EthernetIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const sys = info.row.original
|
|
||||||
if (sys.status === "paused") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
|
||||||
return (
|
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.dt,
|
|
||||||
id: "temp",
|
|
||||||
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
|
||||||
size: 50,
|
|
||||||
hideSort: true,
|
|
||||||
Icon: ThermometerIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
|
||||||
return (
|
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.v,
|
|
||||||
id: "agent",
|
|
||||||
name: () => t`Agent`,
|
|
||||||
// invertSorting: true,
|
|
||||||
size: 50,
|
|
||||||
Icon: WifiIcon,
|
|
||||||
hideSort: true,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const version = info.getValue() as string
|
|
||||||
if (!version) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const system = info.row.original
|
|
||||||
return (
|
|
||||||
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
|
||||||
<IndicatorDot
|
|
||||||
system={system}
|
|
||||||
className={
|
|
||||||
(system.status !== "up" && "bg-primary/30") ||
|
|
||||||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
|
||||||
"bg-yellow-500"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
// @ts-ignore
|
|
||||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
|
||||||
size: 50,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end items-center gap-1 -ms-3">
|
|
||||||
<AlertsButton system={row.original} />
|
|
||||||
<ActionsButton system={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] as ColumnDef<SystemRecord>[]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -628,7 +346,7 @@ const SystemCard = memo(
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
<AlertsButton system={system} />
|
<AlertButton system={system} />
|
||||||
<ActionsButton system={system} />
|
<ActionsButton system={system} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -663,120 +381,3 @@ const SystemCard = memo(
|
|||||||
}, [system, colLength, t])
|
}, [system, colLength, t])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
let editOpened = useRef(false)
|
|
||||||
const { t } = useLingui()
|
|
||||||
const { id, status, host, name } = system
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{!isReadOnlyUser() && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
editOpened.current = true
|
|
||||||
setEditOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(isReadOnlyUser() && "hidden")}
|
|
||||||
onClick={() => {
|
|
||||||
pb.collection("systems").update(id, {
|
|
||||||
status: status === "paused" ? "pending" : "paused",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "paused" ? (
|
|
||||||
<>
|
|
||||||
<PlayCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Resume</Trans>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PauseCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Pause</Trans>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
|
|
||||||
<CopyIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Copy name</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
|
||||||
<CopyIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Copy host</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* edit dialog */}
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
||||||
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
|
||||||
</Dialog>
|
|
||||||
{/* deletion dialog */}
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<Trans>
|
|
||||||
This action cannot be undone. This will permanently delete all current records for {name} from the
|
|
||||||
database.
|
|
||||||
</Trans>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={() => pb.collection("systems").delete(id)}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [id, status, host, name, t, deleteOpen, editOpen])
|
|
||||||
})
|
|
||||||
|
|
||||||
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
|
||||||
className ||= {
|
|
||||||
"bg-green-500": system.status === "up",
|
|
||||||
"bg-red-500": system.status === "down",
|
|
||||||
"bg-primary/40": system.status === "paused",
|
|
||||||
"bg-yellow-500": system.status === "pending",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
|
||||||
// style={{ marginBottom: "-1px" }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user