Compare commits

...

6 Commits

13 changed files with 113 additions and 16 deletions

View File

@@ -631,9 +631,17 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
}
}
// getRootMountPoint returns the appropriate root mount point for the system
// getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string {
if runtime.GOOS == "windows" {
if sd := os.Getenv("SystemDrive"); sd != "" {
return sd
}
return "C:"
}
// 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent)

View File

@@ -8,6 +8,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
</ItemGroup>
</Project>

View File

@@ -26,6 +26,7 @@ type SensorConfig struct {
isBlacklist bool
hasWildcards bool
skipCollection bool
firstRun bool
}
func (a *Agent) newSensorConfig() *SensorConfig {
@@ -52,6 +53,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal
context: context.Background(),
primarySensor: primarySensor,
skipCollection: skipCollection,
firstRun: true,
sensors: make(map[string]struct{}),
}
@@ -167,6 +169,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
err error
}
// Use a longer timeout on the first run to allow for initialization
// (e.g. Windows LHM subprocess startup)
timeout := temperatureFetchTimeout
if a.sensorConfig.firstRun {
a.sensorConfig.firstRun = false
timeout = 10 * time.Second
}
resultCh := make(chan result, 1)
go func() {
temps, err := a.getTempsWithPanicRecovery(getTemps)
@@ -176,7 +186,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
select {
case res := <-resultCh:
return res.temps, res.err
case <-time.After(temperatureFetchTimeout):
case <-time.After(timeout):
return nil, errTemperatureFetchTimeout
}
}

View File

@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
return true
}
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
// This is called when a system is paused to prevent delayed alerts from firing.
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
am.pendingAlerts.Range(func(key, value any) bool {
info := value.(*alertInfo)
if info.alertData.SystemID == systemID {
am.cancelPendingAlert(key.(string))
}
return true
})
}
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
func (am *AlertManager) processPendingAlert(alertID string) {
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)

View File

@@ -941,3 +941,68 @@ func TestStatusAlertClearedBeforeSend(t *testing.T) {
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
})
}
func TestCancelPendingStatusAlertsClearsAllAlertsForSystem(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system1 := core.NewRecord(systemCollection)
system1.Set("name", "system-1")
system1.Set("status", "up")
system1.Set("host", "127.0.0.1")
system1.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system1))
system2 := core.NewRecord(systemCollection)
system2.Set("name", "system-2")
system2.Set("status", "up")
system2.Set("host", "127.0.0.2")
system2.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system2))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert1 := core.NewRecord(alertCollection)
alert1.Set("user", user.Id)
alert1.Set("system", system1.Id)
alert1.Set("name", "Status")
alert1.Set("triggered", false)
alert1.Set("min", 5)
require.NoError(t, hub.Save(alert1))
alert2 := core.NewRecord(alertCollection)
alert2.Set("user", user.Id)
alert2.Set("system", system2.Id)
alert2.Set("name", "Status")
alert2.Set("triggered", false)
alert2.Set("min", 5)
require.NoError(t, hub.Save(alert2))
am := alerts.NewTestAlertManagerWithoutWorker(hub)
initialEmailCount := hub.TestMailer.TotalSend()
// Both systems go down
require.NoError(t, am.HandleStatusAlerts("down", system1))
require.NoError(t, am.HandleStatusAlerts("down", system2))
assert.Equal(t, 2, am.GetPendingAlertsCount(), "both systems should have pending alerts")
// System 1 is paused — cancel its pending alerts
am.CancelPendingStatusAlerts(system1.Id)
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only system2 alert should remain pending after pausing system1")
// Expire and process remaining alerts — only system2 should fire
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "only the non-paused system's alert should be processed")
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "only system2 should send a down notification")
}

View File

@@ -54,6 +54,7 @@ type hubLike interface {
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
CancelPendingStatusAlerts(systemID string)
}
// NewSystemManager creates a new SystemManager instance with the provided hub.
@@ -189,6 +190,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
system.closeSSHConnection()
}
_ = deactivateAlerts(e.App, e.Record.Id)
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
return e.Next()
case pending:
// Resume monitoring, preferring existing WebSocket connection

View File

@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
className={cn("size-[1.2em] pointer-events-none", {
"fill-primary": hasSystemAlert,
})}
/>

View File

@@ -63,7 +63,7 @@ export default function Navbar() {
className="p-2 ps-0 me-3 group"
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
>
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
<Logo className="h-[1.2rem] md:h-5 fill-foreground" />
</Link>
<Button
variant="outline"

View File

@@ -460,14 +460,14 @@ const SystemCard = memo(
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center gap-2 w-full overflow-hidden">
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
<div className="flex items-center gap-1 w-full overflow-hidden">
<h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<IndicatorDot system={system} />
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
</div>
</CardTitle>
</h3>
{table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 shrink-0 relative z-10">
<AlertButton system={system} />

View File

@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} />
<div className={cn("grid gap-2 text-start", className)} {...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"

View File

@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"

View File

@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} />
<div className={cn("grid gap-1.5 text-start", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"

View File

@@ -177,6 +177,10 @@
}
}
@utility text-card-title {
@apply text-[1.4rem] sm:text-2xl;
}
.recharts-tooltip-wrapper {
z-index: 51;
@apply tabular-nums;