mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
Compare commits
7 Commits
sys-online
...
ssr-system
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605e3f7e9d | ||
|
|
fadfc5d81d | ||
|
|
fc39ff1e4d | ||
|
|
82ccfc66e0 | ||
|
|
890bad1c39 | ||
|
|
9c458885f1 | ||
|
|
d2aed0dc72 |
@@ -7,7 +7,7 @@ SKIP_WEB ?= false
|
||||
# Set executable extension based on target OS
|
||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||
|
||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
clean:
|
||||
@@ -53,6 +53,10 @@ build-agent: tidy build-dotnet-conditional
|
||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||
|
||||
build-hub-dev: tidy
|
||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||
|
||||
build: build-agent build-hub
|
||||
|
||||
generate-locales:
|
||||
@@ -73,9 +77,9 @@ dev-hub: export ENV=dev
|
||||
dev-hub:
|
||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||
@if command -v entr >/dev/null 2>&1; then \
|
||||
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
|
||||
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||
else \
|
||||
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
||||
cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||
fi
|
||||
|
||||
dev-agent:
|
||||
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/shirou/gopsutil/v4 v4.25.6
|
||||
github.com/spf13/cast v1.9.2
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.7
|
||||
github.com/stretchr/testify v1.11.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||
@@ -49,7 +50,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
|
||||
@@ -8,13 +8,10 @@ import (
|
||||
"beszel/internal/hub/systems"
|
||||
"beszel/internal/records"
|
||||
"beszel/internal/users"
|
||||
"beszel/site"
|
||||
"crypto/ed25519"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -22,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -115,6 +113,8 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
||||
// set URL if BASE_URL env is set
|
||||
if h.appURL != "" {
|
||||
settings.Meta.AppURL = h.appURL
|
||||
} else {
|
||||
h.appURL = settings.Meta.AppURL
|
||||
}
|
||||
if err := e.App.Save(settings); err != nil {
|
||||
return err
|
||||
@@ -164,55 +164,6 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startServer sets up the server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// TODO: exclude dev server from production binary
|
||||
switch h.IsDev() {
|
||||
case true:
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:5173",
|
||||
})
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
proxy.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
default:
|
||||
// parse app url
|
||||
parsedURL, err := url.Parse(h.appURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
|
||||
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
// get CSP configuration
|
||||
csp, cspExists := GetEnv("CSP")
|
||||
// add route
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
// serve static assets if path is in staticPaths
|
||||
for i := range staticPaths {
|
||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||
return serveStatic(e)
|
||||
}
|
||||
}
|
||||
if cspExists {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
return e.HTML(http.StatusOK, indexContent)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerCronJobs sets up scheduled tasks
|
||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||
// delete old system_stats and alerts_history records once every hour
|
||||
@@ -349,3 +300,30 @@ func (h *Hub) MakeLink(parts ...string) string {
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
Name string `json:"name"`
|
||||
Id string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Port uint16 `json:"port"`
|
||||
Host string `json:"host"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
func (h *Hub) getUserSystemsFromRequest(req *http.Request) ([]SystemInfo, error) {
|
||||
systems := []SystemInfo{}
|
||||
token, err := req.Cookie("beszauth")
|
||||
if err != nil {
|
||||
return systems, err
|
||||
}
|
||||
if token.Value != "" {
|
||||
user, err := h.FindAuthRecordByToken(token.Value)
|
||||
if err != nil {
|
||||
return systems, err
|
||||
}
|
||||
h.DB().NewQuery("SELECT s.id, s.info, s.status, s.name, s.port, s.host FROM systems s JOIN json_each(s.users) AS je WHERE je.value = {:user_id}").Bind(dbx.Params{
|
||||
"user_id": user.Id,
|
||||
}).All(&systems)
|
||||
}
|
||||
return systems, err
|
||||
}
|
||||
|
||||
101
beszel/internal/hub/server_development.go
Normal file
101
beszel/internal/hub/server_development.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build development
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// responseModifier wraps an http.RoundTripper to modify HTML responses
|
||||
type responseModifier struct {
|
||||
transport http.RoundTripper
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper interface with response modification
|
||||
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := rm.transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// Only modify HTML responses
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Read the response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Modify the HTML content here
|
||||
modifiedBody := rm.modifyHTML(string(body), req)
|
||||
|
||||
// Create a new response with the modified body
|
||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// modifyHTML applies modifications to HTML content
|
||||
func (rm *responseModifier) modifyHTML(html string, req *http.Request) string {
|
||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
html = strings.ReplaceAll(html, "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
slog.Info("modifying HTML", "appURL", rm.hub.appURL)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||
|
||||
systems, err := rm.hub.getUserSystemsFromRequest(req)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
systemsJson, err := json.Marshal(systems)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 1)
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
slog.Info("starting server", "appURL", h.appURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:5173",
|
||||
})
|
||||
|
||||
// Set up custom transport with response modification
|
||||
proxy.Transport = &responseModifier{
|
||||
transport: http.DefaultTransport,
|
||||
hub: h,
|
||||
}
|
||||
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
proxy.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
62
beszel/internal/hub/server_production.go
Normal file
62
beszel/internal/hub/server_production.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build !development
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/site"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// startServer sets up the production server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// parse app url
|
||||
parsedURL, err := url.Parse(h.appURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
// get CSP configuration
|
||||
csp, cspExists := GetEnv("CSP")
|
||||
// add route
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
// serve static assets if path is in staticPaths
|
||||
for i := range staticPaths {
|
||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||
return serveStatic(e)
|
||||
}
|
||||
}
|
||||
if cspExists {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
systems, err := h.getUserSystemsFromRequest(e.Request)
|
||||
if err != nil {
|
||||
slog.Error("error getting user systems", "error", err)
|
||||
}
|
||||
systemsJson, err := json.Marshal(systems)
|
||||
if err != nil {
|
||||
slog.Error("error marshalling user systems", "error", err)
|
||||
}
|
||||
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 1)
|
||||
return e.HTML(http.StatusOK, html)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
globalThis.BESZEL = {
|
||||
BASE_PATH: "%BASE_URL%",
|
||||
HUB_VERSION: "{{V}}",
|
||||
HUB_URL: "{{HUB_URL}}"
|
||||
HUB_URL: "{{HUB_URL}}",
|
||||
SYSTEMS: '{SYSTEMS}'
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { $publicKey } from "@/lib/stores"
|
||||
import { cn, generateToken, tokenMap, useLocalStorage } from "@/lib/utils"
|
||||
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
||||
import { pb, isReadOnlyUser } from "@/lib/api"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||
@@ -77,7 +77,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
||||
const port = useRef<HTMLInputElement>(null)
|
||||
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
||||
const isUnixSocket = hostValue.startsWith("/")
|
||||
const [tab, setTab] = useLocalStorage("as-tab", "docker")
|
||||
const [tab, setTab] = useBrowserStorage("as-tab", "docker")
|
||||
const [token, setToken] = useState(system?.token ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||
import { cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
import { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||
import { memo, useMemo } from "react"
|
||||
import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
|
||||
import { cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||
import { ChartData } from "@/types"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { ChartType, Unit } from "@/lib/enums"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function ContainerChart({
|
||||
dataKey,
|
||||
chartData,
|
||||
chartType,
|
||||
chartConfig,
|
||||
unit = "%",
|
||||
}: {
|
||||
dataKey: string
|
||||
chartData: ChartData
|
||||
chartType: ChartType
|
||||
chartConfig: ChartConfig
|
||||
unit?: string
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
@@ -28,40 +31,6 @@ export default memo(function ContainerChart({
|
||||
|
||||
const isNetChart = chartType === ChartType.Network
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const config = {} as Record<string, { label: string; color: string }>
|
||||
const totalUsage = new Map<string, number>()
|
||||
|
||||
// calculate total usage of each container
|
||||
for (const stats of containerData) {
|
||||
for (const key in stats) {
|
||||
if (!key || key === "created") continue
|
||||
|
||||
const currentTotal = totalUsage.get(key) ?? 0
|
||||
const increment = isNetChart
|
||||
? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
||||
: // @ts-ignore
|
||||
stats[key]?.[dataKey] ?? 0
|
||||
|
||||
totalUsage.set(key, currentTotal + increment)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort keys and generate colors based on usage
|
||||
const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
|
||||
|
||||
const length = sortedEntries.length
|
||||
sortedEntries.forEach(([key], i) => {
|
||||
const hue = ((i * 360) / length) % 360
|
||||
config[key] = {
|
||||
label: key,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
})
|
||||
|
||||
return config satisfies ChartConfig
|
||||
}, [chartData])
|
||||
|
||||
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
||||
const obj = {} as {
|
||||
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
|
||||
@@ -119,7 +88,14 @@ export default memo(function ContainerChart({
|
||||
return obj
|
||||
}, [])
|
||||
|
||||
const filterLower = filter?.toLowerCase()
|
||||
// Filter with set lookup
|
||||
const filteredKeys = useMemo(() => {
|
||||
if (!filter) {
|
||||
return new Set<string>()
|
||||
}
|
||||
const filterLower = filter.toLowerCase()
|
||||
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower)))
|
||||
}, [chartConfig, filter])
|
||||
|
||||
// console.log('rendered at', new Date())
|
||||
|
||||
@@ -162,9 +138,9 @@ export default memo(function ContainerChart({
|
||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filterLower && !key.toLowerCase().includes(filterLower)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
const filtered = filteredKeys.has(key)
|
||||
const fillOpacity = filtered ? 0.05 : 0.4
|
||||
const strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo } from "react"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function DiskChart({
|
||||
dataKey,
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
107
beszel/site/src/components/charts/hooks.ts
Normal file
107
beszel/site/src/components/charts/hooks.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { ChartConfig } from "@/components/ui/chart"
|
||||
import { ChartData } from "@/types"
|
||||
|
||||
/** Chart configurations for CPU, memory, and network usage charts */
|
||||
export interface ContainerChartConfigs {
|
||||
cpu: ChartConfig
|
||||
memory: ChartConfig
|
||||
network: ChartConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates chart configurations for container metrics visualization
|
||||
* @param containerData - Array of container statistics data points
|
||||
* @returns Chart configurations for CPU, memory, and network metrics
|
||||
*/
|
||||
export function useContainerChartConfigs(containerData: ChartData["containerData"]): ContainerChartConfigs {
|
||||
return useMemo(() => {
|
||||
const configs = {
|
||||
cpu: {} as ChartConfig,
|
||||
memory: {} as ChartConfig,
|
||||
network: {} as ChartConfig,
|
||||
}
|
||||
|
||||
// Aggregate usage metrics for each container
|
||||
const totalUsage = {
|
||||
cpu: new Map<string, number>(),
|
||||
memory: new Map<string, number>(),
|
||||
network: new Map<string, number>(),
|
||||
}
|
||||
|
||||
// Process each data point to calculate totals
|
||||
for (let i = 0; i < containerData.length; i++) {
|
||||
const stats = containerData[i]
|
||||
const containerNames = Object.keys(stats)
|
||||
|
||||
for (let j = 0; j < containerNames.length; j++) {
|
||||
const containerName = containerNames[j]
|
||||
// Skip metadata field
|
||||
if (containerName === "created") {
|
||||
continue
|
||||
}
|
||||
|
||||
const containerStats = stats[containerName]
|
||||
if (!containerStats) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate metrics for CPU, memory, and network
|
||||
const currentCpu = totalUsage.cpu.get(containerName) ?? 0
|
||||
const currentMemory = totalUsage.memory.get(containerName) ?? 0
|
||||
const currentNetwork = totalUsage.network.get(containerName) ?? 0
|
||||
|
||||
totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
|
||||
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0))
|
||||
totalUsage.network.set(containerName, currentNetwork + (containerStats.nr ?? 0) + (containerStats.ns ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate chart configurations for each metric type
|
||||
Object.entries(totalUsage).forEach(([chartType, usageMap]) => {
|
||||
const sortedContainers = Array.from(usageMap.entries()).sort(([, a], [, b]) => b - a)
|
||||
const chartConfig = {} as Record<string, { label: string; color: string }>
|
||||
const count = sortedContainers.length
|
||||
|
||||
// Generate colors for each container
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [containerName] = sortedContainers[i]
|
||||
const hue = ((i * 360) / count) % 360
|
||||
chartConfig[containerName] = {
|
||||
label: containerName,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
}
|
||||
|
||||
configs[chartType as keyof typeof configs] = chartConfig
|
||||
})
|
||||
|
||||
return configs
|
||||
}, [containerData])
|
||||
}
|
||||
|
||||
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||
export function useYAxisWidth() {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||
let maxChars = 0
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
function updateYAxisWidth(str: string) {
|
||||
if (str.length > maxChars) {
|
||||
maxChars = str.length
|
||||
const div = document.createElement("div")
|
||||
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
|
||||
div.innerHTML = str
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
const width = div.offsetWidth + 24
|
||||
if (width > yAxisWidth) {
|
||||
setYAxisWidth(div.offsetWidth + 24)
|
||||
}
|
||||
document.body.removeChild(div)
|
||||
})
|
||||
}
|
||||
return str
|
||||
}
|
||||
return { yAxisWidth, updateYAxisWidth }
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||
import { ChartData, SystemStats } from "@/types"
|
||||
import { memo } from "react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { memo } from "react"
|
||||
import { ChartData } from "@/types"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
@@ -2,11 +2,12 @@ import { t } from "@lingui/core/macro"
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo } from "react"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
@@ -8,19 +8,12 @@ import {
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
useYAxisWidth,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedFloat,
|
||||
chartMargin,
|
||||
formatTemperature,
|
||||
decimalString,
|
||||
} from "@/lib/utils"
|
||||
import { cn, formatShortDate, toFixedFloat, chartMargin, formatTemperature, decimalString } from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
|
||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||
const filter = useStore($temperatureFilter)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Suspense, memo, useEffect, useMemo } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||
import { $alerts, $systems } from "@/lib/stores"
|
||||
import { $alerts, $allSystemsById } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { GithubIcon } from "lucide-react"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { getSystemNameFromId } from "@/lib/utils"
|
||||
import { pb, updateRecordList, updateSystemList } from "@/lib/api"
|
||||
import { AlertRecord, SystemRecord } from "@/types"
|
||||
import { AlertRecord } from "@/types"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { $router, Link } from "../router"
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
||||
@@ -14,8 +12,6 @@ import { getPagePath } from "@nanostores/router"
|
||||
import { alertInfo } from "@/lib/alerts"
|
||||
import SystemsTable from "@/components/systems-table/systems-table"
|
||||
|
||||
// const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||
|
||||
export default memo(function () {
|
||||
const { t } = useLingui()
|
||||
|
||||
@@ -23,19 +19,6 @@ export default memo(function () {
|
||||
document.title = t`Dashboard` + " / Beszel"
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
// make sure we have the latest list of systems
|
||||
updateSystemList()
|
||||
|
||||
// subscribe to real time updates for systems / alerts
|
||||
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
||||
updateRecordList(e, $systems)
|
||||
})
|
||||
return () => {
|
||||
pb.collection("systems").unsubscribe("*")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
@@ -69,6 +52,7 @@ export default memo(function () {
|
||||
|
||||
const ActiveAlerts = () => {
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($allSystemsById)
|
||||
|
||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||
const activeAlerts: AlertRecord[] = []
|
||||
@@ -112,7 +96,7 @@ const ActiveAlerts = () => {
|
||||
>
|
||||
<info.icon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{alert.name === "Status" ? (
|
||||
@@ -125,7 +109,7 @@ const ActiveAlerts = () => {
|
||||
)}
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
|
||||
href={getPagePath($router, "system", { name: systems[alert.system]?.name })}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-label="View system"
|
||||
></Link>
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
$direction,
|
||||
$maxValues,
|
||||
$temperatureFilter,
|
||||
$allSystemsByName,
|
||||
} from "@/lib/stores"
|
||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import { useContainerChartConfigs } from "@/components/charts/hooks"
|
||||
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums"
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
listen,
|
||||
parseSemVer,
|
||||
toFixedFloat,
|
||||
useLocalStorage,
|
||||
useBrowserStorage,
|
||||
} from "@/lib/utils"
|
||||
import { getPbTimestamp, pb } from "@/lib/api"
|
||||
import { Separator } from "../ui/separator"
|
||||
@@ -49,6 +51,7 @@ import SwapChart from "@/components/charts/swap-chart"
|
||||
import TemperatureChart from "@/components/charts/temperature-chart"
|
||||
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
||||
import LoadAverageChart from "@/components/charts/load-average-chart"
|
||||
import { subscribeKeys } from "nanostores"
|
||||
|
||||
const cache = new Map<string, any>()
|
||||
|
||||
@@ -117,13 +120,13 @@ function dockerOrPodman(str: string, system: SystemRecord) {
|
||||
return str
|
||||
}
|
||||
|
||||
export default function SystemDetail({ name }: { name: string }) {
|
||||
export default memo(function SystemDetail({ name }: { name: string }) {
|
||||
const direction = useStore($direction)
|
||||
const { t } = useLingui()
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
const maxValues = useStore($maxValues)
|
||||
const [grid, setGrid] = useLocalStorage("grid", true)
|
||||
const [grid, setGrid] = useBrowserStorage("grid", true)
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||
@@ -149,36 +152,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
}
|
||||
}, [name])
|
||||
|
||||
// function resetCharts() {
|
||||
// setSystemStats([])
|
||||
// setContainerData([])
|
||||
// }
|
||||
|
||||
// useEffect(resetCharts, [chartTime])
|
||||
|
||||
// find matching system
|
||||
// find matching system and update when it changes
|
||||
useEffect(() => {
|
||||
if (system.id && system.name === name) {
|
||||
return
|
||||
}
|
||||
const matchingSystem = systems.find((s) => s.name === name) as SystemRecord
|
||||
if (matchingSystem) {
|
||||
setSystem(matchingSystem)
|
||||
}
|
||||
}, [name, system, systems])
|
||||
|
||||
// update system when new data is available
|
||||
useEffect(() => {
|
||||
if (!system.id) {
|
||||
return
|
||||
}
|
||||
pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
|
||||
setSystem(e.record)
|
||||
return subscribeKeys($allSystemsByName, [name], (newSystems) => {
|
||||
const sys = newSystems[name]
|
||||
sys?.id && setSystem(sys)
|
||||
})
|
||||
return () => {
|
||||
pb.collection("systems").unsubscribe(system.id)
|
||||
}
|
||||
}, [system.id])
|
||||
}, [name])
|
||||
|
||||
const chartData: ChartData = useMemo(() => {
|
||||
const lastCreated = Math.max(
|
||||
@@ -195,6 +175,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
}
|
||||
}, [systemStats, containerData, direction])
|
||||
|
||||
// Share chart config computation for all container charts
|
||||
const containerChartConfigs = useContainerChartConfigs(containerData)
|
||||
|
||||
// get stats
|
||||
useEffect(() => {
|
||||
if (!system.id || !chartTime) {
|
||||
@@ -503,7 +486,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={t`Average CPU utilization of containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
|
||||
<ContainerChart
|
||||
chartData={chartData}
|
||||
dataKey="c"
|
||||
chartType={ChartType.CPU}
|
||||
chartConfig={containerChartConfigs.cpu}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
@@ -525,7 +513,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
|
||||
<ContainerChart
|
||||
chartData={chartData}
|
||||
dataKey="m"
|
||||
chartType={ChartType.Memory}
|
||||
chartConfig={containerChartConfigs.memory}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
@@ -627,8 +620,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
|
||||
<ContainerChart
|
||||
chartData={chartData}
|
||||
chartType={ChartType.Network}
|
||||
dataKey="n"
|
||||
chartConfig={containerChartConfigs.network}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
@@ -835,7 +832,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||
const containerFilter = useStore(store)
|
||||
|
||||
@@ -36,9 +36,9 @@ import {
|
||||
FilterIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { $pausedSystems, $downSystems, $upSystems, $systems } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { cn, runOnce, useLocalStorage } from "@/lib/utils"
|
||||
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||
import { $router, Link } from "../router"
|
||||
import { useLingui, Trans } from "@lingui/react/macro"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||
@@ -50,23 +50,25 @@ import { SystemStatus } from "@/lib/enums"
|
||||
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | "up" | "down" | "paused"
|
||||
type StatusFilter = "all" | SystemRecord["status"]
|
||||
|
||||
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
|
||||
|
||||
export default function SystemsTable() {
|
||||
const data = useStore($systems)
|
||||
const downSystems = $downSystems.get()
|
||||
const upSystems = $upSystems.get()
|
||||
const pausedSystems = $pausedSystems.get()
|
||||
const { i18n, t } = useLingui()
|
||||
const [filter, setFilter] = useState<string>()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>("sortMode",[{ id: "system", desc: false }])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
||||
const [viewMode, setViewMode] = useLocalStorage<ViewMode>(
|
||||
"viewMode",
|
||||
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||
window.innerWidth < 1024 && data.length < 200 ? "grid" : "table"
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
"sortMode",
|
||||
[{ id: "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
|
||||
|
||||
const locale = i18n.locale
|
||||
|
||||
@@ -75,16 +77,20 @@ export default function SystemsTable() {
|
||||
if (statusFilter === "all") {
|
||||
return data
|
||||
}
|
||||
return data.filter((system) => system.status === statusFilter)
|
||||
if (statusFilter === SystemStatus.Up) {
|
||||
return Object.values(upSystems) ?? []
|
||||
}
|
||||
if (statusFilter === SystemStatus.Down) {
|
||||
return Object.values(downSystems) ?? []
|
||||
}
|
||||
return Object.values(pausedSystems) ?? []
|
||||
}, [data, statusFilter])
|
||||
|
||||
const runningRecords = useMemo(() => {
|
||||
return data.filter((record) => record.status === "up").length
|
||||
}, [data])
|
||||
|
||||
const totalRecords = useMemo(() => {
|
||||
return data.length
|
||||
}, [data])
|
||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||
"viewMode",
|
||||
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== undefined) {
|
||||
@@ -109,7 +115,6 @@ export default function SystemsTable() {
|
||||
columnVisibility,
|
||||
},
|
||||
defaultColumn: {
|
||||
// sortDescFirst: true,
|
||||
invertSorting: true,
|
||||
sortUndefined: "last",
|
||||
minSize: 0,
|
||||
@@ -121,18 +126,22 @@ export default function SystemsTable() {
|
||||
const rows = table.getRowModel().rows
|
||||
const columns = table.getAllColumns()
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const [upSystemsLength, downSystemsLength, pausedSystemsLength] = useMemo(() => {
|
||||
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
|
||||
}, [upSystems, downSystems, pausedSystems])
|
||||
|
||||
// TODO: hiding temp then gpu messes up table headers
|
||||
const CardHead = useMemo(() => {
|
||||
return (
|
||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||
<div className="grid md:flex gap-5 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2.5">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Systems</Trans>
|
||||
</CardTitle>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a system to view information - {runningRecords} / {totalRecords}</Trans>
|
||||
<p className={"ml-2 text-" + (runningRecords === totalRecords ? "emerald" : "red") + "-600"}>Online</p>
|
||||
<Trans>Click on a system to view more information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -184,13 +193,13 @@ export default function SystemsTable() {
|
||||
<Trans>All Systems</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Up</Trans>
|
||||
<Trans>Up ({upSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Down</Trans>
|
||||
<Trans>Down ({downSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Paused</Trans>
|
||||
<Trans>Paused ({pausedSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
@@ -261,7 +270,16 @@ export default function SystemsTable() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
)
|
||||
}, [visibleColumns.length, sorting, viewMode, locale, statusFilter, runningRecords, totalRecords])
|
||||
}, [
|
||||
visibleColumns.length,
|
||||
sorting,
|
||||
viewMode,
|
||||
locale,
|
||||
statusFilter,
|
||||
upSystemsLength,
|
||||
downSystemsLength,
|
||||
pausedSystemsLength,
|
||||
])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||
import { $alerts, $longestSystemNameLen, $systems, $userSettings } from "./stores"
|
||||
import { ChartTimes, UserSettings } from "@/types"
|
||||
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { chartTimeData } from "./utils"
|
||||
import { WritableAtom } from "nanostores"
|
||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
||||
import PocketBase from "pocketbase"
|
||||
import { basePath } from "@/components/router"
|
||||
|
||||
@@ -14,7 +12,14 @@ export const pb = new PocketBase(basePath)
|
||||
export const isAdmin = () => pb.authStore.record?.role === "admin"
|
||||
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
|
||||
|
||||
const verifyAuth = () => {
|
||||
export const updateCookieToken = () => {
|
||||
console.log("setting token", pb.authStore.token)
|
||||
document.cookie = `beszauth=${pb.authStore.token}; path=/; expires=${new Date(
|
||||
Date.now() + 7 * 24 * 60 * 60 * 1000
|
||||
).toString()}`
|
||||
}
|
||||
|
||||
export const verifyAuth = () => {
|
||||
pb.collection("users")
|
||||
.authRefresh()
|
||||
.catch(() => {
|
||||
@@ -29,7 +34,7 @@ const verifyAuth = () => {
|
||||
|
||||
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||
export async function logOut() {
|
||||
$systems.set([])
|
||||
$allSystemsByName.set({})
|
||||
$alerts.set({})
|
||||
$userSettings.set({} as UserSettings)
|
||||
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
||||
@@ -54,74 +59,6 @@ export async function updateUserSettings() {
|
||||
console.error("create settings", e)
|
||||
}
|
||||
}
|
||||
/** Update systems / alerts list when records change */
|
||||
export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
|
||||
const curRecords = $store.get()
|
||||
const newRecords = []
|
||||
if (e.action === "delete") {
|
||||
for (const server of curRecords) {
|
||||
if (server.id !== e.record.id) {
|
||||
newRecords.push(server)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let found = 0
|
||||
for (const server of curRecords) {
|
||||
if (server.id === e.record.id) {
|
||||
found = newRecords.push(e.record)
|
||||
} else {
|
||||
newRecords.push(server)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newRecords.push(e.record)
|
||||
}
|
||||
}
|
||||
$store.set(newRecords)
|
||||
}
|
||||
/** Fetches updated system list from database */
|
||||
export const updateSystemList = (() => {
|
||||
let isFetchingSystems = false
|
||||
return async () => {
|
||||
if (isFetchingSystems) {
|
||||
return
|
||||
}
|
||||
isFetchingSystems = true
|
||||
try {
|
||||
let records = await pb
|
||||
.collection<SystemRecord>("systems")
|
||||
.getFullList({ sort: "+name", fields: "id,name,host,port,info,status" })
|
||||
|
||||
if (records.length) {
|
||||
// records = [
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ...records,
|
||||
// ]
|
||||
// we need to loop once to get the longest name
|
||||
let longestName = $longestSystemNameLen.get()
|
||||
for (const { name } of records) {
|
||||
const nameLen = Math.min(20, name.length)
|
||||
if (nameLen > longestName) {
|
||||
$longestSystemNameLen.set(nameLen)
|
||||
longestName = nameLen
|
||||
}
|
||||
}
|
||||
$systems.set(records)
|
||||
} else {
|
||||
verifyAuth()
|
||||
}
|
||||
} finally {
|
||||
isFetchingSystems = false
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
|
||||
d ||= chartTimeData[timeString].getOffset(new Date())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { atom, map } from "nanostores"
|
||||
import { atom, computed, map, ReadableAtom } from "nanostores"
|
||||
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||
import { Unit } from "./enums"
|
||||
import { pb } from "./api"
|
||||
@@ -6,8 +6,18 @@ import { pb } from "./api"
|
||||
/** Store if user is authenticated */
|
||||
export const $authenticated = atom(pb.authStore.isValid)
|
||||
|
||||
/** List of system records */
|
||||
export const $systems = atom<SystemRecord[]>([])
|
||||
/** Map of system records by name */
|
||||
export const $allSystemsByName = map<Record<string, SystemRecord>>({})
|
||||
/** Map of system records by id */
|
||||
export const $allSystemsById = map<Record<string, SystemRecord>>({})
|
||||
/** Map of up systems by id */
|
||||
export const $upSystems = map<Record<string, SystemRecord>>({})
|
||||
/** Map of down systems by id */
|
||||
export const $downSystems = map<Record<string, SystemRecord>>({})
|
||||
/** Map of paused systems by id */
|
||||
export const $pausedSystems = map<Record<string, SystemRecord>>({})
|
||||
/** List of all system records */
|
||||
export const $systems: ReadableAtom<SystemRecord[]> = computed($allSystemsByName, Object.values)
|
||||
|
||||
/** Map of alert records by system id and alert name */
|
||||
export const $alerts = map<AlertMap>({})
|
||||
|
||||
167
beszel/site/src/lib/systemsManager.ts
Normal file
167
beszel/site/src/lib/systemsManager.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { SystemRecord } from "@/types"
|
||||
import { PreinitializedMapStore } from "nanostores"
|
||||
import { pb, verifyAuth } from "@/lib/api"
|
||||
import {
|
||||
$allSystemsByName,
|
||||
$upSystems,
|
||||
$downSystems,
|
||||
$pausedSystems,
|
||||
$allSystemsById,
|
||||
$longestSystemNameLen,
|
||||
} from "@/lib/stores"
|
||||
import { updateFavicon, FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED } from "@/lib/utils"
|
||||
import { SystemStatus } from "./enums"
|
||||
|
||||
const COLLECTION = pb.collection<SystemRecord>("systems")
|
||||
const FIELDS_DEFAULT = "id,name,host,port,info,status"
|
||||
|
||||
/** Maximum system name length for display purposes */
|
||||
const MAX_SYSTEM_NAME_LENGTH = 20
|
||||
|
||||
let initialized = false
|
||||
let unsub: (() => void) | undefined | void
|
||||
|
||||
/** Initialize the systems manager and set up listeners */
|
||||
export function init() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
|
||||
// sync system stores on change
|
||||
$allSystemsByName.listen((newSystems, oldSystems, changedKey) => {
|
||||
const oldSystem = oldSystems[changedKey]
|
||||
const newSystem = newSystems[changedKey]
|
||||
|
||||
// if system is undefined (deleted), remove it from the stores
|
||||
if (oldSystem && !newSystem?.id) {
|
||||
removeFromStore(oldSystem, $upSystems)
|
||||
removeFromStore(oldSystem, $downSystems)
|
||||
removeFromStore(oldSystem, $pausedSystems)
|
||||
removeFromStore(oldSystem, $allSystemsById)
|
||||
}
|
||||
|
||||
if (!newSystem) {
|
||||
onSystemsChanged(newSystems, undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const newStatus = newSystem.status
|
||||
if (newStatus === SystemStatus.Up) {
|
||||
$upSystems.setKey(newSystem.id, newSystem)
|
||||
removeFromStore(newSystem, $downSystems)
|
||||
removeFromStore(newSystem, $pausedSystems)
|
||||
} else if (newStatus === SystemStatus.Down) {
|
||||
$downSystems.setKey(newSystem.id, newSystem)
|
||||
removeFromStore(newSystem, $upSystems)
|
||||
removeFromStore(newSystem, $pausedSystems)
|
||||
} else if (newStatus === SystemStatus.Paused) {
|
||||
$pausedSystems.setKey(newSystem.id, newSystem)
|
||||
removeFromStore(newSystem, $upSystems)
|
||||
removeFromStore(newSystem, $downSystems)
|
||||
}
|
||||
|
||||
// run things that need to be done when systems change
|
||||
onSystemsChanged(newSystems, newSystem)
|
||||
})
|
||||
}
|
||||
|
||||
/** Update the longest system name length and favicon based on system status */
|
||||
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
|
||||
const upSystemsStore = $upSystems.get()
|
||||
const downSystemsStore = $downSystems.get()
|
||||
const upSystems = Object.values(upSystemsStore)
|
||||
const downSystems = Object.values(downSystemsStore)
|
||||
|
||||
// Update longest system name length
|
||||
const longestName = $longestSystemNameLen.get()
|
||||
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, changedSystem?.name.length || 0)
|
||||
if (nameLen > longestName) {
|
||||
$longestSystemNameLen.set(nameLen)
|
||||
}
|
||||
|
||||
// Update favicon based on system status
|
||||
if (downSystems.length > 0) {
|
||||
updateFavicon(FAVICON_RED)
|
||||
} else if (upSystems.length > 0) {
|
||||
updateFavicon(FAVICON_GREEN)
|
||||
} else {
|
||||
updateFavicon(FAVICON_DEFAULT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch systems from collection */
|
||||
async function fetchSystems(): Promise<SystemRecord[]> {
|
||||
try {
|
||||
return await COLLECTION.getFullList({ sort: "+name", fields: FIELDS_DEFAULT })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch systems:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Store management functions
|
||||
/** Add system to both name and ID stores */
|
||||
export function add(system: SystemRecord) {
|
||||
$allSystemsByName.setKey(system.name, system)
|
||||
$allSystemsById.setKey(system.id, system)
|
||||
}
|
||||
|
||||
/** Remove system from stores */
|
||||
export function remove(system: SystemRecord) {
|
||||
removeFromStore(system, $allSystemsByName)
|
||||
removeFromStore(system, $allSystemsById)
|
||||
removeFromStore(system, $upSystems)
|
||||
removeFromStore(system, $downSystems)
|
||||
removeFromStore(system, $pausedSystems)
|
||||
}
|
||||
|
||||
/** Remove system from specific store */
|
||||
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
|
||||
const key = store === $allSystemsByName ? system.name : system.id
|
||||
store.setKey(key, undefined as any)
|
||||
}
|
||||
|
||||
/** Action functions for subscription */
|
||||
const actionFns: Record<string, (system: SystemRecord) => void> = {
|
||||
create: add,
|
||||
update: add,
|
||||
delete: remove,
|
||||
}
|
||||
|
||||
/** Subscribe to real-time system updates from the collection */
|
||||
export async function subscribe() {
|
||||
try {
|
||||
unsub = await COLLECTION.subscribe("*", ({ action, record }) => actionFns[action]?.(record), {
|
||||
fields: FIELDS_DEFAULT,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to systems collection:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh all systems with latest data from the hub */
|
||||
export async function refresh(records: SystemRecord[] = []) {
|
||||
if (records.length) {
|
||||
for (const record of records) {
|
||||
add(record)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const records = await fetchSystems()
|
||||
if (!records.length) {
|
||||
// No systems found, verify authentication
|
||||
verifyAuth()
|
||||
return
|
||||
}
|
||||
for (const record of records) {
|
||||
add(record)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh systems:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribe from real-time system updates */
|
||||
export const unsubscribe = () => (unsub = unsub?.())
|
||||
@@ -2,13 +2,17 @@ import { t } from "@lingui/core/macro"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { $copyContent, $systems, $userSettings } from "./stores"
|
||||
import { $copyContent, $userSettings } from "./stores"
|
||||
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
||||
import { timeDay, timeHour } from "d3-time"
|
||||
import { useEffect, useState } from "react"
|
||||
import { MeterState, Unit } from "./enums"
|
||||
import { prependBasePath } from "@/components/router"
|
||||
|
||||
export const FAVICON_DEFAULT = "favicon.svg"
|
||||
export const FAVICON_GREEN = "favicon-green.svg"
|
||||
export const FAVICON_RED = "favicon-red.svg"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -104,32 +108,6 @@ export const chartTimeData: ChartTimeData = {
|
||||
},
|
||||
}
|
||||
|
||||
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||
export function useYAxisWidth() {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||
let maxChars = 0
|
||||
let timeout: Timer
|
||||
function updateYAxisWidth(str: string) {
|
||||
if (str.length > maxChars) {
|
||||
maxChars = str.length
|
||||
const div = document.createElement("div")
|
||||
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
|
||||
div.innerHTML = str
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
const width = div.offsetWidth + 24
|
||||
if (width > yAxisWidth) {
|
||||
setYAxisWidth(div.offsetWidth + 24)
|
||||
}
|
||||
document.body.removeChild(div)
|
||||
})
|
||||
}
|
||||
return str
|
||||
}
|
||||
return { yAxisWidth, updateYAxisWidth }
|
||||
}
|
||||
|
||||
/** Format number to x decimal places, without trailing zeros */
|
||||
export function toFixedFloat(num: number, digits: number) {
|
||||
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
|
||||
@@ -152,20 +130,20 @@ export function decimalString(num: number, digits = 2) {
|
||||
return formatter.format(num)
|
||||
}
|
||||
|
||||
/** Get value from local storage */
|
||||
function getStorageValue(key: string, defaultValue: any) {
|
||||
const saved = localStorage?.getItem(key)
|
||||
/** Get value from local or session storage */
|
||||
function getStorageValue(key: string, defaultValue: any, storageInterface: Storage = localStorage) {
|
||||
const saved = storageInterface?.getItem(key)
|
||||
return saved ? JSON.parse(saved) : defaultValue
|
||||
}
|
||||
|
||||
/** Hook to sync value in local storage */
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
/** Hook to sync value in local or session storage */
|
||||
export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterface: Storage = localStorage) {
|
||||
key = `besz-${key}`
|
||||
const [value, setValue] = useState(() => {
|
||||
return getStorageValue(key, defaultValue)
|
||||
return getStorageValue(key, defaultValue, storageInterface)
|
||||
})
|
||||
useEffect(() => {
|
||||
localStorage?.setItem(key, JSON.stringify(value))
|
||||
storageInterface?.setItem(key, JSON.stringify(value))
|
||||
}, [key, value])
|
||||
|
||||
return [value, setValue]
|
||||
@@ -344,28 +322,20 @@ export function debounce<T extends (...args: any[]) => any>(func: T, wait: numbe
|
||||
}
|
||||
}
|
||||
|
||||
/* returns the name of a system from its id */
|
||||
export const getSystemNameFromId = (() => {
|
||||
const cache = new Map<string, string>()
|
||||
return (systemId: string): string => {
|
||||
if (cache.has(systemId)) {
|
||||
return cache.get(systemId)!
|
||||
}
|
||||
const sysName = $systems.get().find((s) => s.id === systemId)?.name ?? ""
|
||||
cache.set(systemId, sysName)
|
||||
return sysName
|
||||
}
|
||||
})()
|
||||
|
||||
// Cache for runOnce
|
||||
const runOnceCache = new WeakMap<Function, { done: boolean; result: unknown }>()
|
||||
/** Run a function only once */
|
||||
export function runOnce<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
|
||||
let done = false
|
||||
let result: any
|
||||
return (...args: any) => {
|
||||
if (!done) {
|
||||
result = fn(...args)
|
||||
done = true
|
||||
export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
||||
return ((...args: Parameters<T>) => {
|
||||
let state = runOnceCache.get(fn)
|
||||
if (!state) {
|
||||
state = { done: false, result: undefined }
|
||||
runOnceCache.set(fn, state)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (!state.done) {
|
||||
state.result = fn(...args)
|
||||
state.done = true
|
||||
}
|
||||
return state.result
|
||||
}) as T
|
||||
}
|
||||
|
||||
@@ -4,17 +4,16 @@ import { Suspense, lazy, memo, useEffect } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { ThemeProvider } from "./components/theme-provider.tsx"
|
||||
import { DirectionProvider } from "@radix-ui/react-direction"
|
||||
import { $authenticated, $systems, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
||||
import { pb, updateSystemList, updateUserSettings } from "./lib/api.ts"
|
||||
import { $authenticated, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
||||
import { pb, updateUserSettings, updateCookieToken } from "./lib/api.ts"
|
||||
import * as systemsManager from "./lib/systemsManager.ts"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { Toaster } from "./components/ui/toaster.tsx"
|
||||
import { $router } from "./components/router.tsx"
|
||||
import { updateFavicon } from "@/lib/utils"
|
||||
import Navbar from "./components/navbar.tsx"
|
||||
import { I18nProvider } from "@lingui/react"
|
||||
import { i18n } from "@lingui/core"
|
||||
import { getLocale, dynamicActivate } from "./lib/i18n"
|
||||
import { SystemStatus } from "./lib/enums"
|
||||
import { alertManager } from "./lib/alerts"
|
||||
import Settings from "./components/routes/settings/layout.tsx"
|
||||
|
||||
@@ -25,52 +24,45 @@ const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.
|
||||
|
||||
const App = memo(() => {
|
||||
const page = useStore($router)
|
||||
const authenticated = useStore($authenticated)
|
||||
const systems = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
// change auth store on auth change
|
||||
updateCookieToken()
|
||||
pb.authStore.onChange(() => {
|
||||
$authenticated.set(pb.authStore.isValid)
|
||||
updateCookieToken()
|
||||
})
|
||||
// get version / public key
|
||||
pb.send("/api/beszel/getkey", {}).then((data) => {
|
||||
$publicKey.set(data.key)
|
||||
})
|
||||
// get servers / alerts / settings
|
||||
// get user settings
|
||||
updateUserSettings()
|
||||
const startingSystems = globalThis.BESZEL.SYSTEMS
|
||||
for (const system of startingSystems) {
|
||||
// if (typeof system.info === "string") {
|
||||
system.info = JSON.parse(system.info as unknown as string)
|
||||
// }
|
||||
}
|
||||
// need to get system list before alerts
|
||||
updateSystemList()
|
||||
// get alerts
|
||||
systemsManager.init()
|
||||
systemsManager
|
||||
// get current systems list
|
||||
.refresh(startingSystems)
|
||||
// subscribe to new system updates
|
||||
.then(systemsManager.subscribe)
|
||||
// get current alerts
|
||||
.then(alertManager.refresh)
|
||||
// subscribe to new alert updates
|
||||
.then(alertManager.subscribe)
|
||||
|
||||
return () => {
|
||||
updateFavicon("favicon.svg")
|
||||
// updateFavicon("favicon.svg")
|
||||
alertManager.unsubscribe()
|
||||
systemsManager.unsubscribe()
|
||||
globalThis.BESZEL.SYSTEMS = []
|
||||
}
|
||||
}, [])
|
||||
|
||||
// update favicon
|
||||
useEffect(() => {
|
||||
if (!systems.length || !authenticated) {
|
||||
updateFavicon("favicon.svg")
|
||||
} else {
|
||||
let up = false
|
||||
for (const system of systems) {
|
||||
if (system.status === SystemStatus.Down) {
|
||||
updateFavicon("favicon-red.svg")
|
||||
return
|
||||
}
|
||||
if (system.status === SystemStatus.Up) {
|
||||
up = true
|
||||
}
|
||||
}
|
||||
updateFavicon(up ? "favicon-green.svg" : "favicon.svg")
|
||||
}
|
||||
}, [systems])
|
||||
|
||||
if (!page) {
|
||||
return <h1 className="text-3xl text-center my-14">404</h1>
|
||||
} else if (page.route === "home") {
|
||||
|
||||
2
beszel/site/src/types.d.ts
vendored
2
beszel/site/src/types.d.ts
vendored
@@ -7,6 +7,8 @@ declare global {
|
||||
BASE_PATH: string
|
||||
HUB_VERSION: string
|
||||
HUB_URL: string
|
||||
/** initial list of systems */
|
||||
SYSTEMS: SystemRecord[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react-swc"
|
||||
import { lingui } from "@lingui/vite-plugin"
|
||||
import { version } from "./package.json"
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
@@ -13,13 +12,6 @@ export default defineConfig({
|
||||
}),
|
||||
lingui(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "replace version in index.html during dev",
|
||||
apply: "serve",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace("{{V}}", version).replace("{{HUB_URL}}", "")
|
||||
},
|
||||
},
|
||||
],
|
||||
esbuild: {
|
||||
legalComments: "external",
|
||||
|
||||
Reference in New Issue
Block a user