mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-01 04:54:42 +00:00
Update ProxMenux 1.2.1.4-beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
1caca89b574241c9d754b9ac3bb11987c5eccc5f182d01a5c62e61623b62fda7
|
fba0f824699660d18f77bc8558370acd725921cc34737508605c83ced3c947a4
|
||||||
|
|||||||
@@ -140,19 +140,30 @@ export function About() {
|
|||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
v{APP_VERSION}
|
v{APP_VERSION}
|
||||||
</span>
|
</span>
|
||||||
{/* Changelog goes to the web — the in-app modal version
|
{/* Beta versions surface their pre-release notes on the
|
||||||
duplicated content and lacked a close affordance on
|
GitHub Releases page (where each beta is tagged + signed);
|
||||||
some viewports, forcing a page refresh. The web
|
stable versions point at the canonical web changelog
|
||||||
changelog is canonical and auto-syncs with releases. */}
|
which only carries shipped releases. Detection: the
|
||||||
<a
|
APP_VERSION string carries a "-beta" / "-rc" /
|
||||||
href="https://proxmenux.com/changelog"
|
"-alpha" suffix for any non-stable build. */}
|
||||||
target="_blank"
|
{(() => {
|
||||||
rel="noopener noreferrer"
|
const isPrerelease = /-(beta|rc|alpha)/i.test(APP_VERSION)
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
|
const href = isPrerelease
|
||||||
>
|
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||||
Changelog
|
: "https://proxmenux.com/en/changelog"
|
||||||
<ExternalLink className="h-3 w-3" />
|
const label = isPrerelease ? "Release notes" : "Changelog"
|
||||||
</a>
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[v0] Auth skip error:", err)
|
console.error("Auth skip error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -203,7 +203,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[v0] Auth setup error:", err)
|
console.error("Auth setup error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export default function Hardware() {
|
|||||||
if (hardwareData?.storage_devices) {
|
if (hardwareData?.storage_devices) {
|
||||||
hardwareData.storage_devices.forEach((device) => {
|
hardwareData.storage_devices.forEach((device) => {
|
||||||
if (device.name.startsWith("nvme")) {
|
if (device.name.startsWith("nvme")) {
|
||||||
console.log(`[v0] NVMe device ${device.name}:`, {
|
console.log(`NVMe device ${device.name}:`, {
|
||||||
pcie_gen: device.pcie_gen,
|
pcie_gen: device.pcie_gen,
|
||||||
pcie_width: device.pcie_width,
|
pcie_width: device.pcie_width,
|
||||||
pcie_max_gen: device.pcie_max_gen,
|
pcie_max_gen: device.pcie_max_gen,
|
||||||
@@ -452,7 +452,7 @@ export default function Hardware() {
|
|||||||
setDetailsLoading(false)
|
setDetailsLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name !== "AbortError") {
|
if (error instanceof Error && error.name !== "AbortError") {
|
||||||
console.error("[v0] Error fetching GPU realtime data:", error)
|
console.error("Error fetching GPU realtime data:", error)
|
||||||
}
|
}
|
||||||
setRealtimeGPUData({ has_monitoring_tool: false })
|
setRealtimeGPUData({ has_monitoring_tool: false })
|
||||||
setDetailsLoading(false)
|
setDetailsLoading(false)
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { getAuthToken } from "@/lib/api-config"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -357,8 +365,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
// `suppressionHours` overrides the category default for this dismiss:
|
||||||
e.stopPropagation()
|
// - undefined → backend uses the category's configured suppression
|
||||||
|
// - 24, 168 (7 days) → silence for that many hours
|
||||||
|
// - -1 → permanent dismiss; only revertible from
|
||||||
|
// Settings → Active Suppressions
|
||||||
|
const handleAcknowledge = async (
|
||||||
|
errorKey: string,
|
||||||
|
suppressionHours?: number,
|
||||||
|
) => {
|
||||||
setDismissingKey(errorKey)
|
setDismissingKey(errorKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -369,10 +384,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
headers["Authorization"] = `Bearer ${token}`
|
headers["Authorization"] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { error_key: errorKey }
|
||||||
|
if (suppressionHours !== undefined) {
|
||||||
|
body.suppression_hours = suppressionHours
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ error_key: errorKey }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseData = await response.json().catch(() => ({}))
|
const responseData = await response.json().catch(() => ({}))
|
||||||
@@ -390,7 +410,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
severity: responseData.result?.original_severity || 'WARNING',
|
severity: responseData.result?.original_severity || 'WARNING',
|
||||||
reason: 'Dismissed by user',
|
reason: 'Dismissed by user',
|
||||||
dismissed: true,
|
dismissed: true,
|
||||||
acknowledged_at: new Date().toISOString()
|
// Surface the chosen duration so the row shows the right badge
|
||||||
|
// (countdown vs. "Permanent") without waiting for the refetch.
|
||||||
|
permanent: suppressionHours === -1,
|
||||||
|
suppression_remaining_hours: suppressionHours === -1 ? -1 : undefined,
|
||||||
|
suppression_hours: suppressionHours,
|
||||||
|
acknowledged_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
setDismissedItems(prev => [...prev, dismissedItem])
|
setDismissedItems(prev => [...prev, dismissedItem])
|
||||||
}
|
}
|
||||||
@@ -511,32 +536,25 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
||||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
||||||
{checkData.dismissed && (
|
{checkData.dismissed && (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
checkData.permanent ? (
|
||||||
Dismissed
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
||||||
</Badge>
|
Permanent
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||||
|
Dismissed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
||||||
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
|
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
|
||||||
<Button
|
<DismissDropdown
|
||||||
size="sm"
|
onSelect={(hours) =>
|
||||||
variant="outline"
|
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||||
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
}
|
||||||
disabled={dismissingKey === (checkData.error_key || checkKey)}
|
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation()
|
|
||||||
handleAcknowledge(checkData.error_key || checkKey, e)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dismissingKey === (checkData.error_key || checkKey) ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X className="h-3 w-3 sm:mr-0.5" />
|
|
||||||
<span className="hidden sm:inline">Dismiss</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,25 +699,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
|
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
|
||||||
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
|
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
|
||||||
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
|
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
|
||||||
<Button
|
<DismissDropdown
|
||||||
size="sm"
|
onSelect={(hours) =>
|
||||||
variant="outline"
|
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||||
className="h-5 px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
}
|
||||||
disabled={dismissingKey === `category_${key}`}
|
busy={dismissingKey === `category_${key}_unknown`}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation()
|
|
||||||
handleAcknowledge(`category_${key}_unknown`, e)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dismissingKey === `category_${key}` ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X className="h-3 w-3 sm:mr-0.5" />
|
|
||||||
<span className="hidden sm:inline">Dismiss</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -840,3 +845,56 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small split button: the visible click opens a 3-option menu so the user
|
||||||
|
// chooses how long this specific alert stays silenced. ``-1`` is the
|
||||||
|
// permanent sentinel — backend stores it as `suppression_hours = -1` and
|
||||||
|
// the alert can only be brought back from Settings → Active Suppressions.
|
||||||
|
function DismissDropdown({
|
||||||
|
onSelect,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
onSelect: (suppressionHours: number) => void
|
||||||
|
busy: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3 sm:mr-0.5" />
|
||||||
|
<span className="hidden sm:inline">Dismiss</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Silence this alert for
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
||||||
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 24 hours
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
||||||
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 7 days
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onSelect(-1)}
|
||||||
|
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<BellOff className="h-3 w-3 mr-2" /> Permanently
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.1.3-beta</p>
|
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.1.4-beta</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
console.error("Failed to fetch traffic data for card:", error)
|
||||||
setTrafficData({ received: 0, sent: 0 })
|
setTrafficData({ received: 0, sent: 0 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function NetworkTrafficChart({
|
|||||||
setIsInitialLoad(false)
|
setIsInitialLoad(false)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[v0] Error fetching network metrics:", err)
|
console.error("Error fetching network metrics:", err)
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || "Error loading metrics")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
console.error("[v0] Invalid data format - data is not an array:", result)
|
console.error("Invalid data format - data is not an array:", result)
|
||||||
throw new Error("Invalid data format received from server")
|
throw new Error("Invalid data format received from server")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
console.warn("[v0] No data points received")
|
console.warn("No data points received")
|
||||||
setData([])
|
setData([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -159,9 +159,9 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
setData(transformedData)
|
setData(transformedData)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[v0] Error fetching node metrics:", err)
|
console.error("Error fetching node metrics:", err)
|
||||||
console.error("[v0] Error message:", err.message)
|
console.error("Error message:", err.message)
|
||||||
console.error("[v0] Error stack:", err.stack)
|
console.error("Error stack:", err.stack)
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || "Error loading metrics")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -1831,27 +1831,34 @@ export function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
{config.channels.apprise?.enabled && (
|
{config.channels.apprise?.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5 min-w-0">
|
||||||
<Label className="text-[11px] text-muted-foreground">Apprise URL</Label>
|
<Label className="text-[11px] text-muted-foreground">Apprise URL</Label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Input
|
<Input
|
||||||
type={showSecrets["apprise_url"] ? "text" : "password"}
|
type={showSecrets["apprise_url"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="tgram://bottoken/ChatID · ntfy://server/topic · discord://webhook_id/token · matrix://..."
|
placeholder="tgram://bottoken/ChatID"
|
||||||
value={config.channels.apprise?.url || ""}
|
value={config.channels.apprise?.url || ""}
|
||||||
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
||||||
onClick={() => setShowSecrets(s => ({ ...s, apprise_url: !s.apprise_url }))}
|
onClick={() => setShowSecrets(s => ({ ...s, apprise_url: !s.apprise_url }))}
|
||||||
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
|
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
|
||||||
>
|
>
|
||||||
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
{/* The examples row was overflowing on mobile because
|
||||||
|
every `<code>` token is atomic — the whole line
|
||||||
|
would scroll horizontally on narrow viewports.
|
||||||
|
`break-all` on the wrapper lets the layout break
|
||||||
|
mid-token if the viewport is really tight; on
|
||||||
|
wider screens the natural commas/spaces still
|
||||||
|
control wrapping. */}
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-relaxed break-all min-w-0">
|
||||||
A single URL that Apprise routes to the right service. Examples:
|
A single URL that Apprise routes to the right service. Examples:
|
||||||
<code className="text-foreground/80 mx-0.5">tgram://</code>,
|
<code className="text-foreground/80 mx-0.5">tgram://</code>,
|
||||||
<code className="text-foreground/80 mx-0.5">discord://</code>,
|
<code className="text-foreground/80 mx-0.5">discord://</code>,
|
||||||
@@ -1871,7 +1878,10 @@ export function NotificationSettings() {
|
|||||||
</a>.
|
</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end pt-1">
|
{renderChannelCategories("apprise")}
|
||||||
|
{renderQuietHours("apprise")}
|
||||||
|
{renderDailyDigest("apprise")}
|
||||||
|
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
onClick={() => handleTest("apprise")}
|
onClick={() => handleTest("apprise")}
|
||||||
|
|||||||
@@ -858,7 +858,7 @@ export function ProxmoxDashboard() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.1.3-beta</p>
|
<p className="font-medium mb-2">ProxMenux Monitor v1.2.1.4-beta</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://ko-fi.com/macrimi"
|
href="https://ko-fi.com/macrimi"
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server } from "lucide-react"
|
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell } from "lucide-react"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
|
||||||
const APP_VERSION = "1.2.1.3-beta" // Sync with AppImage/package.json
|
const APP_VERSION = "1.2.1.4-beta" // Sync with AppImage/package.json
|
||||||
|
|
||||||
interface ReleaseNote {
|
interface ReleaseNote {
|
||||||
date: string
|
date: string
|
||||||
@@ -18,6 +18,23 @@ interface ReleaseNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||||
|
"1.2.1.4-beta": {
|
||||||
|
date: "May 30, 2026",
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
"Per-error dismiss duration - The Dismiss button on each Health Monitor alert now opens a small dropdown with three options: 24 hours, 7 days, or Permanently. The 24h / 7d paths behave like the existing time-limited dismiss (the alert reappears after the window expires). Permanent dismisses persist with suppression_hours = -1 in the persistence DB, never re-emit, never re-notify, and are marked with a distinct amber Permanent badge in the Health Monitor so the operator knows the alert is intentionally silenced",
|
||||||
|
"Active Suppressions panel in Settings - New section inside Settings -> Health Monitor (below the per-category suppression durations) that lists every currently-dismissed alert, both time-limited (with countdown) and permanent. Each row carries the error_key, category, severity, when it was dismissed, and a Re-enable button that clears the acknowledgment so the alert can fire again on the next scan. The Re-enable button is gated by the Health Monitor Edit mode (same gating as the rest of the Health settings) — toggle Edit at the top of the page first, then the buttons become active. Permanent dismisses can only be reverted from here, time-limited ones can also be force-revived if you don't want to wait for the countdown",
|
||||||
|
"Apprise channel - per-event toggles, Quiet Hours and Daily Digest - The Apprise tab now exposes the same Notification Categories block, per-event sub-toggles, Quiet Hours and Daily Digest controls as Telegram / Gotify / Discord / Email. The backend already supported per-channel filtering for Apprise via the generic channel_overrides logic; the UI just wasn't surfacing it",
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"POST /api/health/acknowledge accepts an optional suppression_hours body field - positive integer for the dismiss duration in hours, -1 for permanent. Omitting the field preserves the previous behaviour (uses the category's configured default). New endpoint POST /api/health/un-acknowledge {error_key} reverses a dismiss (used by Settings -> Active Suppressions and by future automations)",
|
||||||
|
"Health Monitor dismissed annotation - When an alert is currently acknowledged with suppression_hours = -1, the dashboard payload now tags the check with permanent: true alongside dismissed: true so the UI can render the Permanent badge separately from the standard time-limited Dismissed badge",
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"Apprise URL section - Mobile overflow - On narrow viewports the Apprise URL row used to break the design: the placeholder packed four full example URLs into one line and the inline <code> examples in the description had no break-all rule, so the section pushed past the right edge of the viewport. The placeholder is now a single concise example (tgram://bottoken/ChatID), the URL input wrapper enforces min-w-0 / flex-1 / shrink-0 on its children, and the examples paragraph uses break-all min-w-0 so it wraps cleanly on any width",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"1.2.1.3-beta": {
|
"1.2.1.3-beta": {
|
||||||
date: "May 22, 2026",
|
date: "May 22, 2026",
|
||||||
changes: {
|
changes: {
|
||||||
@@ -146,36 +163,16 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
|||||||
|
|
||||||
const CURRENT_VERSION_FEATURES = [
|
const CURRENT_VERSION_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <RefreshCw className="h-5 w-5" />,
|
icon: <BellOff className="h-5 w-5" />,
|
||||||
text: "Post-install function update detection - The Monitor tracks installed ProxMenux optimizations and notifies when a newer version of any of them is available, with one-click apply",
|
text: "Per-error dismiss duration - The Dismiss button on each Health Monitor alert now opens a small dropdown so you choose 24 hours, 7 days, or Permanently for that specific alert. Permanent dismisses get a distinct Permanent badge and never re-notify",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Sliders className="h-5 w-5" />,
|
icon: <Sliders className="h-5 w-5" />,
|
||||||
text: "Health Monitor Thresholds - Per-category warning and critical levels for CPU, memory, temperature, storage and more, fully configurable from Settings",
|
text: "Active Suppressions section - New section inside Settings -> Health Monitor that lists every dismissed alert (time-limited and permanent) with a Re-enable button. Permanent dismisses can only be reverted from here. The Re-enable action is gated by Health Monitor Edit mode",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Cpu className="h-5 w-5" />,
|
icon: <Bell className="h-5 w-5" />,
|
||||||
text: "NVIDIA driver update notifications - Kernel-aware detection of new compatible driver versions, surfaced in the Hardware tab and as notifications when a newer build is published",
|
text: "Apprise channel parity - Apprise now exposes the same per-event toggles, Quiet Hours and Daily Digest controls as Telegram / Gotify / Discord / Email. Mobile overflow in the Apprise URL row is also fixed",
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Globe className="h-5 w-5" />,
|
|
||||||
text: "Secure Gateway update flow - One-click Tailscale update from Settings, with version indicators and notification when a new release is available",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Wrench className="h-5 w-5" />,
|
|
||||||
text: "Helper-Scripts menu - Richer context and useful information for each entry, so you know what every script does before running it",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Thermometer className="h-5 w-5" />,
|
|
||||||
text: "Improved disk temperature monitoring - Better readings, smarter caching across SMART probes and a redesigned history modal that opens at 24h by default",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Server className="h-5 w-5" />,
|
|
||||||
text: "VM and LXC modal expanded - Additional information consolidated into a single panel so you don't have to look it up across multiple tabs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Zap className="h-5 w-5" />,
|
|
||||||
text: "Faster page load and tighter security - Lighter network usage on the main tabs, plus stricter authentication checks across notification, scripts and terminal endpoints",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle } from "lucide-react"
|
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff } from "lucide-react"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
import { NotificationSettings } from "./notification-settings"
|
import { NotificationSettings } from "./notification-settings"
|
||||||
import { HealthThresholds } from "./health-thresholds"
|
import { HealthThresholds } from "./health-thresholds"
|
||||||
import { LxcUpdateDetection } from "./lxc-update-detection"
|
import { LxcUpdateDetection } from "./lxc-update-detection"
|
||||||
@@ -10,7 +12,6 @@ import { ScriptTerminalModal } from "./script-terminal-modal"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { Switch } from "./ui/switch"
|
import { Switch } from "./ui/switch"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { Badge } from "./ui/badge"
|
|
||||||
import { getNetworkUnit } from "../lib/format-network"
|
import { getNetworkUnit } from "../lib/format-network"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
|
||||||
@@ -188,6 +189,63 @@ const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
|||||||
security: Shield,
|
security: Shield,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acronyms that should stay uppercase in the normalized label.
|
||||||
|
const ERROR_KEY_ACRONYMS = new Set([
|
||||||
|
"CPU", "GPU", "IO", "RAM", "SSD", "HDD", "NIC", "API",
|
||||||
|
"URL", "SSH", "TLS", "SSL", "DNS", "DHCP", "NTP",
|
||||||
|
"NFS", "SMB", "CIFS", "ISCSI",
|
||||||
|
"PBS", "PVE", "LXC", "VM", "SMART", "ZFS", "LVM", "RAID",
|
||||||
|
"ID", "UUID", "MAC", "IP",
|
||||||
|
])
|
||||||
|
|
||||||
|
// Convert an internal error_key (e.g. `pve_storage_full_PBS-Cloud`)
|
||||||
|
// into a human-readable label (`PVE Storage Full: PBS-Cloud`).
|
||||||
|
// Tokens are split by `_`; trailing tokens that look like a resource
|
||||||
|
// identifier (contain a hyphen, uppercase letter or digit, or match
|
||||||
|
// a known device/interface pattern) are grouped after `:` so the
|
||||||
|
// title reads naturally. Known acronyms keep their uppercase form.
|
||||||
|
function normalizeErrorKey(key: string): string {
|
||||||
|
if (!key) return ""
|
||||||
|
|
||||||
|
const parts = key.split("_")
|
||||||
|
if (parts.length === 0) return key
|
||||||
|
|
||||||
|
const looksLikeResource = (s: string): boolean => {
|
||||||
|
if (!s) return false
|
||||||
|
if (s.includes("-")) return true
|
||||||
|
if (/[A-Z]/.test(s)) return true
|
||||||
|
if (/\d/.test(s)) return true
|
||||||
|
// Linux block/network device patterns
|
||||||
|
if (/^(sd[a-z]+\d*|nvme\d+n\d+|vmbr\d+|eth\d+|ens\d+|enp\d+|wlp\d+|tap\d+|veth\w+|vtnet\d+|vnet\d+)$/.test(s)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let cut = parts.length
|
||||||
|
for (let i = parts.length - 1; i >= 1; i--) {
|
||||||
|
if (looksLikeResource(parts[i])) {
|
||||||
|
cut = i
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const descParts = parts.slice(0, cut)
|
||||||
|
const resourceParts = parts.slice(cut)
|
||||||
|
|
||||||
|
const titleize = (w: string): string => {
|
||||||
|
if (!w) return w
|
||||||
|
const upper = w.toUpperCase()
|
||||||
|
if (ERROR_KEY_ACRONYMS.has(upper)) return upper
|
||||||
|
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = descParts.map(titleize).join(" ")
|
||||||
|
if (resourceParts.length === 0) return desc
|
||||||
|
return `${desc}: ${resourceParts.join("_")}`
|
||||||
|
}
|
||||||
|
|
||||||
interface ProxMenuxTool {
|
interface ProxMenuxTool {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
@@ -295,6 +353,23 @@ export function Settings() {
|
|||||||
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
||||||
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Active Suppressions panel — lists every error currently dismissed
|
||||||
|
// (time-limited or permanent) so the user can re-enable individual
|
||||||
|
// alerts. Mirrors what /api/health/full returns under `dismissed`.
|
||||||
|
type ActiveSuppression = {
|
||||||
|
error_key: string
|
||||||
|
category: string
|
||||||
|
severity?: string
|
||||||
|
reason?: string
|
||||||
|
acknowledged_at?: string
|
||||||
|
suppression_hours?: number
|
||||||
|
suppression_remaining_hours?: number
|
||||||
|
permanent?: boolean
|
||||||
|
}
|
||||||
|
const [activeSuppressions, setActiveSuppressions] = useState<ActiveSuppression[]>([])
|
||||||
|
const [loadingSuppressions, setLoadingSuppressions] = useState(true)
|
||||||
|
const [reEnablingKey, setReEnablingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
// Sprint 13 / issue #195: snippets storage selector. The bash helper
|
// Sprint 13 / issue #195: snippets storage selector. The bash helper
|
||||||
// resolves it on first GPU passthrough and saves to config.json; this
|
// resolves it on first GPU passthrough and saves to config.json; this
|
||||||
// card surfaces the same setting so the user can see/change it from
|
// card surfaces the same setting so the user can see/change it from
|
||||||
@@ -339,6 +414,7 @@ export function Settings() {
|
|||||||
getUnitsSettings()
|
getUnitsSettings()
|
||||||
loadHealthSettings()
|
loadHealthSettings()
|
||||||
loadRemoteStorages()
|
loadRemoteStorages()
|
||||||
|
loadActiveSuppressions()
|
||||||
loadNetworkInterfaces()
|
loadNetworkInterfaces()
|
||||||
loadSnippetsStorage()
|
loadSnippetsStorage()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -561,6 +637,41 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadActiveSuppressions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchApi("/api/health/dismissed")
|
||||||
|
if (data && Array.isArray(data.dismissed)) {
|
||||||
|
setActiveSuppressions(data.dismissed as ActiveSuppression[])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load active suppressions:", err)
|
||||||
|
} finally {
|
||||||
|
setLoadingSuppressions(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click "Re-enable" on a suppression → POST /api/health/un-acknowledge.
|
||||||
|
// Remove the row optimistically, then re-fetch the list silently to stay
|
||||||
|
// in sync with the server (which may have re-recorded the error if the
|
||||||
|
// condition is still active — that surfaces in the Health Monitor, not
|
||||||
|
// this panel).
|
||||||
|
const handleReEnable = async (errorKey: string) => {
|
||||||
|
if (!healthEditMode) return
|
||||||
|
setReEnablingKey(errorKey)
|
||||||
|
try {
|
||||||
|
await fetchApi("/api/health/un-acknowledge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ error_key: errorKey }),
|
||||||
|
})
|
||||||
|
setActiveSuppressions(prev => prev.filter(s => s.error_key !== errorKey))
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to re-enable alert:", err)
|
||||||
|
} finally {
|
||||||
|
setReEnablingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleStorageExclusionChange = async (storageName: string, storageType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
|
const handleStorageExclusionChange = async (storageName: string, storageType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
|
||||||
setSavingStorage(storageName)
|
setSavingStorage(storageName)
|
||||||
try {
|
try {
|
||||||
@@ -957,6 +1068,93 @@ export function Settings() {
|
|||||||
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
|
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active Suppressions subsection.
|
||||||
|
Lives inside the Health Monitor card (no separator).
|
||||||
|
Surfaces every currently-dismissed alert (time-limited
|
||||||
|
and permanent) with a Re-enable button gated by Edit
|
||||||
|
mode. Permanent dismisses chosen from the dashboard
|
||||||
|
"Dismiss → Permanently" dropdown can only be reverted
|
||||||
|
here, so this is the audit log + un-dismiss UI for
|
||||||
|
them. Time-limited dismisses (24h, 7d) are listed for
|
||||||
|
visibility and can also be force-revived from here. */}
|
||||||
|
<div className="pt-8">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<BellOff className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium">Active Suppressions</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||||
|
Alerts you have silenced from the Health Monitor. Permanent dismisses can only be
|
||||||
|
reverted here. Editing requires the Health Monitor <span className="font-mono text-xs">Edit</span> mode at the top of this card.
|
||||||
|
</p>
|
||||||
|
{loadingSuppressions ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin h-5 w-5 border-4 border-amber-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : activeSuppressions.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
No active suppressions. Dismissed alerts from the Health Monitor will appear here.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeSuppressions.map((s) => {
|
||||||
|
const remaining = s.suppression_remaining_hours
|
||||||
|
const remainingLabel = s.permanent
|
||||||
|
? "Permanent"
|
||||||
|
: remaining === undefined || remaining === null
|
||||||
|
? "Active"
|
||||||
|
: remaining >= 24
|
||||||
|
? `${Math.round(remaining / 24)}d remaining`
|
||||||
|
: `${Math.max(0, Math.round(remaining))}h remaining`
|
||||||
|
const dismissedAtLabel = s.acknowledged_at
|
||||||
|
? new Date(s.acknowledged_at).toLocaleString()
|
||||||
|
: ""
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.error_key}
|
||||||
|
className="flex items-start sm:items-center justify-between gap-3 px-3 py-2.5 rounded-md border border-border hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||||
|
{s.permanent ? (
|
||||||
|
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-amber-400 border-amber-400/40 mt-0.5 font-normal">
|
||||||
|
Permanent
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-blue-400 border-blue-400/30 mt-0.5 font-normal">
|
||||||
|
{remainingLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs sm:text-sm font-medium text-foreground truncate" title={s.error_key}>
|
||||||
|
{normalizeErrorKey(s.error_key)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||||
|
<span>category: <span className="font-medium text-foreground/80">{s.category || "—"}</span></span>
|
||||||
|
{s.severity && <span>severity: <span className="font-medium text-foreground/80">{s.severity}</span></span>}
|
||||||
|
{dismissedAtLabel && <span>dismissed: {dismissedAtLabel}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2.5 text-xs shrink-0 hover:bg-green-500/10 hover:border-green-500/50 bg-transparent"
|
||||||
|
disabled={!healthEditMode || reEnablingKey === s.error_key}
|
||||||
|
onClick={() => handleReEnable(s.error_key)}
|
||||||
|
title={!healthEditMode ? "Enable Health Monitor Edit mode to re-enable" : "Re-enable this alert"}
|
||||||
|
>
|
||||||
|
{reEnablingKey === s.error_key ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Re-enable"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const fetchStorageData = async (): Promise<StorageData | null> => {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[v0] Failed to fetch storage data from Flask server:", error)
|
console.error("Failed to fetch storage data from Flask server:", error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3624,7 +3624,7 @@ ${observationsHtml}
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="rpt-footer">
|
<div class="rpt-footer">
|
||||||
<div>Report generated by ProxMenux Monitor</div>
|
<div>Report generated by ProxMenux Monitor</div>
|
||||||
<div>ProxMenux Monitor v1.2.1.3-beta</div>
|
<div>ProxMenux Monitor v1.2.1.4-beta</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
setStats(result.stats)
|
setStats(result.stats)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[v0] Failed to fetch temperature history:", err)
|
console.error("Failed to fetch temperature history:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
console.error("[v0] TerminalPanel: WebSocket error:", error)
|
console.error("TerminalPanel: WebSocket error:", error)
|
||||||
setTerminals((prev) => prev.map((t) => {
|
setTerminals((prev) => prev.map((t) => {
|
||||||
if (t.id === terminal.id) {
|
if (t.id === terminal.id) {
|
||||||
if (t.pingInterval) {
|
if (t.pingInterval) {
|
||||||
@@ -924,6 +924,7 @@ const handleClose = () => {
|
|||||||
<div
|
<div
|
||||||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||||||
className="w-full h-full flex-1 bg-black overflow-hidden"
|
className="w-full h-full flex-1 bg-black overflow-hidden"
|
||||||
|
translate="no"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
@@ -956,6 +957,7 @@ const handleClose = () => {
|
|||||||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||||||
onClick={() => setActiveTerminalId(terminal.id)}
|
onClick={() => setActiveTerminalId(terminal.id)}
|
||||||
className="flex-1 w-full max-w-full bg-black overflow-hidden cursor-pointer"
|
className="flex-1 w-full max-w-full bg-black overflow-hidden cursor-pointer"
|
||||||
|
translate="no"
|
||||||
data-terminal-container
|
data-terminal-container
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ export function VirtualMachines() {
|
|||||||
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
|
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
|
console.log(`Could not fetch IP for LXC ${lxc.vmid}`)
|
||||||
configs[lxc.vmid] = "N/A"
|
configs[lxc.vmid] = "N/A"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -161,14 +161,14 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
|||||||
const contentType = response.headers.get("content-type")
|
const contentType = response.headers.get("content-type")
|
||||||
if (!contentType || !contentType.includes("application/json")) {
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
console.error("[v0] fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
console.error("fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
||||||
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await response.json()
|
return await response.json()
|
||||||
} catch (jsonError) {
|
} catch (jsonError) {
|
||||||
console.error("[v0] fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
console.error("fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.1.3-beta",
|
"version": "1.2.1.4-beta",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ def acknowledge_error():
|
|||||||
Acknowledge/dismiss an error manually.
|
Acknowledge/dismiss an error manually.
|
||||||
Returns details about the acknowledged error including original severity
|
Returns details about the acknowledged error including original severity
|
||||||
and suppression period info.
|
and suppression period info.
|
||||||
|
|
||||||
|
Body accepts an optional ``suppression_hours`` field — if omitted the
|
||||||
|
server uses the user-configured value for the error's category (current
|
||||||
|
behavior). When provided, the value overrides the category default for
|
||||||
|
this specific dismiss:
|
||||||
|
- positive integer N → silence for N hours
|
||||||
|
- ``-1`` → silence permanently (only revertible from
|
||||||
|
Settings → Active Suppressions)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -70,7 +78,17 @@ def acknowledge_error():
|
|||||||
return jsonify({'error': 'error_key is required'}), 400
|
return jsonify({'error': 'error_key is required'}), 400
|
||||||
|
|
||||||
error_key = data['error_key']
|
error_key = data['error_key']
|
||||||
result = health_persistence.acknowledge_error(error_key)
|
sup_override = None
|
||||||
|
if 'suppression_hours' in data and data['suppression_hours'] is not None:
|
||||||
|
try:
|
||||||
|
sup_override = int(data['suppression_hours'])
|
||||||
|
# Accept positive durations and the permanent sentinel (-1)
|
||||||
|
# only. Zero / other negatives would be nonsensical here.
|
||||||
|
if sup_override < -1 or sup_override == 0:
|
||||||
|
return jsonify({'error': 'suppression_hours must be a positive integer or -1 (permanent)'}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'suppression_hours must be an integer'}), 400
|
||||||
|
result = health_persistence.acknowledge_error(error_key, suppression_hours=sup_override)
|
||||||
|
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
# Invalidate cached health results so next fetch reflects the dismiss
|
# Invalidate cached health results so next fetch reflects the dismiss
|
||||||
@@ -130,6 +148,53 @@ def acknowledge_error():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@health_bp.route('/api/health/un-acknowledge', methods=['POST'])
|
||||||
|
def unacknowledge_error():
|
||||||
|
"""
|
||||||
|
Re-enable a previously dismissed error.
|
||||||
|
|
||||||
|
Used by Settings → Active Suppressions when the user explicitly removes
|
||||||
|
a suppression (time-limited or permanent). After this call the error
|
||||||
|
becomes eligible to re-emit and re-notify on the next health scan if
|
||||||
|
the underlying condition is still present.
|
||||||
|
|
||||||
|
Body: ``{"error_key": "<key>"}``
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'error_key' not in data:
|
||||||
|
return jsonify({'error': 'error_key is required'}), 400
|
||||||
|
error_key = data['error_key']
|
||||||
|
result = health_persistence.unacknowledge_error(error_key)
|
||||||
|
|
||||||
|
# Invalidate caches so the next health fetch reflects the new state
|
||||||
|
# (the alert may re-appear immediately if the condition still holds).
|
||||||
|
category = result.get('category', '')
|
||||||
|
cache_key_map = {
|
||||||
|
'logs': 'logs_analysis',
|
||||||
|
'pve_services': 'pve_services',
|
||||||
|
'updates': 'updates_check',
|
||||||
|
'security': 'security_check',
|
||||||
|
'temperature': 'cpu_check',
|
||||||
|
'network': 'network_check',
|
||||||
|
'disks': 'storage_check',
|
||||||
|
'vms': 'vms_check',
|
||||||
|
}
|
||||||
|
cache_key = cache_key_map.get(category)
|
||||||
|
if cache_key:
|
||||||
|
health_monitor.last_check_times.pop(cache_key, None)
|
||||||
|
health_monitor.cached_results.pop(cache_key, None)
|
||||||
|
for ck in ['_bg_overall', '_bg_detailed', 'overall_health']:
|
||||||
|
health_monitor.last_check_times.pop(ck, None)
|
||||||
|
health_monitor.cached_results.pop(ck, None)
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 404
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@health_bp.route('/api/health/active-errors', methods=['GET'])
|
@health_bp.route('/api/health/active-errors', methods=['GET'])
|
||||||
def get_active_errors():
|
def get_active_errors():
|
||||||
"""Get all active persistent errors"""
|
"""Get all active persistent errors"""
|
||||||
|
|||||||
@@ -265,18 +265,35 @@ def _apply_security_headers(response):
|
|||||||
# is banned in the 'proxmenux' fail2ban jail and blocks at app level.
|
# is banned in the 'proxmenux' fail2ban jail and blocks at app level.
|
||||||
import subprocess as _f2b_subprocess
|
import subprocess as _f2b_subprocess
|
||||||
import time as _f2b_time
|
import time as _f2b_time
|
||||||
|
import shutil as _f2b_shutil
|
||||||
|
|
||||||
# Cache banned IPs for 30 seconds to avoid calling fail2ban-client on every request
|
# Cache banned IPs for 30 seconds to avoid calling fail2ban-client on every request
|
||||||
_f2b_banned_cache = {"ips": set(), "ts": 0, "ttl": 30}
|
_f2b_banned_cache = {"ips": set(), "ts": 0, "ttl": 30}
|
||||||
|
# One-time check at module import — when Fail2Ban isn't installed we want
|
||||||
|
# the @app.before_request middleware to be a no-op. Without this guard
|
||||||
|
# every HTTP request to the Monitor went through _f2b_get_banned_ips() →
|
||||||
|
# execve fail2ban-client → ENOENT, and the negative result wasn't cached
|
||||||
|
# (only the success branch updated `ts`), so a missing binary triggered
|
||||||
|
# one failed execve per HTTP request. strace on a host without Fail2Ban
|
||||||
|
# captured 250+ failed execve attempts in 10 min from this single path.
|
||||||
|
# Fixed in v1.2.1.4 perf audit.
|
||||||
|
_F2B_BINARY = _f2b_shutil.which("fail2ban-client")
|
||||||
|
|
||||||
|
|
||||||
def _f2b_get_banned_ips():
|
def _f2b_get_banned_ips():
|
||||||
"""Get currently banned IPs from the proxmenux jail, with caching."""
|
"""Get currently banned IPs from the proxmenux jail, with caching."""
|
||||||
|
if _F2B_BINARY is None:
|
||||||
|
# Fail2Ban isn't installed on this host. Skip the subprocess
|
||||||
|
# entirely; the @app.before_request middleware will see an empty
|
||||||
|
# banned-IPs set and let every request through (which is the
|
||||||
|
# correct behaviour — there's no Fail2Ban to honour).
|
||||||
|
return _f2b_banned_cache["ips"]
|
||||||
now = _f2b_time.time()
|
now = _f2b_time.time()
|
||||||
if now - _f2b_banned_cache["ts"] < _f2b_banned_cache["ttl"]:
|
if now - _f2b_banned_cache["ts"] < _f2b_banned_cache["ttl"]:
|
||||||
return _f2b_banned_cache["ips"]
|
return _f2b_banned_cache["ips"]
|
||||||
try:
|
try:
|
||||||
result = _f2b_subprocess.run(
|
result = _f2b_subprocess.run(
|
||||||
["fail2ban-client", "status", "proxmenux"],
|
[_F2B_BINARY, "status", "proxmenux"],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
@@ -285,10 +302,13 @@ def _f2b_get_banned_ips():
|
|||||||
ip_str = line.split(":", 1)[1].strip()
|
ip_str = line.split(":", 1)[1].strip()
|
||||||
banned = set(ip.strip() for ip in ip_str.split() if ip.strip())
|
banned = set(ip.strip() for ip in ip_str.split() if ip.strip())
|
||||||
_f2b_banned_cache["ips"] = banned
|
_f2b_banned_cache["ips"] = banned
|
||||||
_f2b_banned_cache["ts"] = now
|
|
||||||
return banned
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Always update the timestamp — even on exception / non-zero rc /
|
||||||
|
# missing jail. Caches the negative result for the same TTL so a
|
||||||
|
# transient Fail2Ban outage doesn't trigger one subprocess call per
|
||||||
|
# HTTP request until it recovers.
|
||||||
|
_f2b_banned_cache["ts"] = now
|
||||||
return _f2b_banned_cache["ips"]
|
return _f2b_banned_cache["ips"]
|
||||||
|
|
||||||
# XFF / X-Real-IP are only honored when the operator opts in by setting
|
# XFF / X-Real-IP are only honored when the operator opts in by setting
|
||||||
@@ -711,6 +731,15 @@ def _temperature_collector_loop():
|
|||||||
RECORD_INTERVAL = 60
|
RECORD_INTERVAL = 60
|
||||||
TEMP_OFFSET = 40 # Record temp at :40 of each minute
|
TEMP_OFFSET = 40 # Record temp at :40 of each minute
|
||||||
LATENCY_OFFSET = 25 # Record latency at :25 of each minute
|
LATENCY_OFFSET = 25 # Record latency at :25 of each minute
|
||||||
|
# v1.2.1.4 perf audit: disk SMART polling used to fire on the exact
|
||||||
|
# same tick as CPU temp (offset :40). Keeping it on the same 60s
|
||||||
|
# cadence — operator wants per-minute disk temperature chart data —
|
||||||
|
# but shifted to offset :55 so the smartctl burst (one per disk)
|
||||||
|
# doesn't pile on top of the CPU temp read and the upcoming latency
|
||||||
|
# ping of the next cycle (:25 + 60). Net effect: load is now spread
|
||||||
|
# across :25 (latency), :40 (CPU temp), :55 (disk SMART burst)
|
||||||
|
# instead of stacking at :25 + :40.
|
||||||
|
DISK_TEMP_DELAY_AFTER_CPU = 15
|
||||||
CLEANUP_INTERVAL = 3600 # 60 minutes
|
CLEANUP_INTERVAL = 3600 # 60 minutes
|
||||||
CLEANUP_OFFSET = 120 # Cleanup at 2 min after the hour mark
|
CLEANUP_OFFSET = 120 # Cleanup at 2 min after the hour mark
|
||||||
|
|
||||||
@@ -729,15 +758,14 @@ def _temperature_collector_loop():
|
|||||||
_record_latency()
|
_record_latency()
|
||||||
last_latency = now
|
last_latency = now
|
||||||
|
|
||||||
# Temperature record (offset 40s - 15s after latency)
|
# CPU / sensors temperature record (offset 40s - 15s after latency)
|
||||||
_time.sleep(15)
|
_time.sleep(15)
|
||||||
_record_temperature()
|
_record_temperature()
|
||||||
# Sprint 14: piggy-back the per-disk temperature sampler on
|
# Sprint 14: per-disk SMART temperature sampler — kept on every
|
||||||
# the same minute tick. The sampler enumerates non-USB
|
# tick (operator-visible chart granularity) but offset further
|
||||||
# disks and writes a row each via smartctl; total cost is
|
# into the cycle so the smartctl subprocess burst (one per disk)
|
||||||
# well under a second on typical hosts. Wrapped in a
|
# doesn't collide with the cheap CPU/latency reads.
|
||||||
# try-block so a stuck smartctl call can't break the
|
_time.sleep(DISK_TEMP_DELAY_AFTER_CPU)
|
||||||
# CPU/latency pipeline.
|
|
||||||
try:
|
try:
|
||||||
import disk_temperature_history
|
import disk_temperature_history
|
||||||
disk_temperature_history.record_all_disk_temperatures()
|
disk_temperature_history.record_all_disk_temperatures()
|
||||||
@@ -10536,7 +10564,7 @@ def api_health():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
'version': '1.2.1.3-beta'
|
'version': '1.2.1.4-beta'
|
||||||
})
|
})
|
||||||
|
|
||||||
# ─── User-configurable health thresholds ─────────────────────────────────────
|
# ─── User-configurable health thresholds ─────────────────────────────────────
|
||||||
@@ -10697,18 +10725,60 @@ def api_health_thresholds_reset():
|
|||||||
@app.route('/api/health/acknowledge', methods=['POST'])
|
@app.route('/api/health/acknowledge', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_health_acknowledge():
|
def api_health_acknowledge():
|
||||||
"""Acknowledge/dismiss a health error by error_key."""
|
"""Acknowledge/dismiss a health error by error_key.
|
||||||
|
|
||||||
|
Optional ``suppression_hours`` body field overrides the category default
|
||||||
|
(positive integer for hours; ``-1`` for permanent dismiss).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
error_key = data.get('error_key', '')
|
error_key = data.get('error_key', '')
|
||||||
if not error_key:
|
if not error_key:
|
||||||
return jsonify({'error': 'error_key is required'}), 400
|
return jsonify({'error': 'error_key is required'}), 400
|
||||||
|
|
||||||
result = health_persistence.acknowledge_error(error_key)
|
sup_override = None
|
||||||
|
if 'suppression_hours' in data and data['suppression_hours'] is not None:
|
||||||
|
try:
|
||||||
|
sup_override = int(data['suppression_hours'])
|
||||||
|
if sup_override < -1 or sup_override == 0:
|
||||||
|
return jsonify({'error': 'suppression_hours must be a positive integer or -1 (permanent)'}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'suppression_hours must be an integer'}), 400
|
||||||
|
|
||||||
|
result = health_persistence.acknowledge_error(error_key, suppression_hours=sup_override)
|
||||||
return jsonify({'success': True, 'result': result})
|
return jsonify({'success': True, 'result': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/health/un-acknowledge', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_health_unacknowledge():
|
||||||
|
"""Reverse a previous dismiss — re-enables the alert so it can fire again.
|
||||||
|
|
||||||
|
Used by the Settings → Active Suppressions panel.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
error_key = data.get('error_key', '')
|
||||||
|
if not error_key:
|
||||||
|
return jsonify({'error': 'error_key is required'}), 400
|
||||||
|
|
||||||
|
result = health_persistence.unacknowledge_error(error_key)
|
||||||
|
# Invalidate caches so the next health fetch reflects the new state.
|
||||||
|
for ck in ['_bg_overall', '_bg_detailed', 'overall_health',
|
||||||
|
'storage_check', 'vms_check', 'logs_analysis',
|
||||||
|
'pve_services', 'updates_check', 'security_check',
|
||||||
|
'cpu_check', 'network_check']:
|
||||||
|
health_monitor.last_check_times.pop(ck, None)
|
||||||
|
health_monitor.cached_results.pop(ck, None)
|
||||||
|
|
||||||
|
status = 200 if result.get('success') else 404
|
||||||
|
return jsonify(result), status
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/prometheus', methods=['GET'])
|
@app.route('/api/prometheus', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_prometheus():
|
def api_prometheus():
|
||||||
@@ -10979,7 +11049,7 @@ def api_info():
|
|||||||
"""Root endpoint with API information"""
|
"""Root endpoint with API information"""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'name': 'ProxMenux Monitor API',
|
'name': 'ProxMenux Monitor API',
|
||||||
'version': '1.2.1.3-beta',
|
'version': '1.2.1.4-beta',
|
||||||
'endpoints': [
|
'endpoints': [
|
||||||
'/api/system',
|
'/api/system',
|
||||||
'/api/system-info',
|
'/api/system-info',
|
||||||
@@ -11728,7 +11798,7 @@ if __name__ == '__main__':
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
MONITOR_VERSION = '1.2.1.3-beta'
|
MONITOR_VERSION = '1.2.1.4-beta'
|
||||||
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
|
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||||
if db_path.exists():
|
if db_path.exists():
|
||||||
conn = sqlite3.connect(str(db_path), timeout=10)
|
conn = sqlite3.connect(str(db_path), timeout=10)
|
||||||
|
|||||||
@@ -793,7 +793,10 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def _annotate_dismissed(check_dict):
|
def _annotate_dismissed(check_dict):
|
||||||
"""Mutate check_dict in place to add `dismissed=True` if
|
"""Mutate check_dict in place to add `dismissed=True` if
|
||||||
its error_key is currently acknowledged in the DB.
|
its error_key is currently acknowledged in the DB. When the
|
||||||
|
dismiss was permanent (suppression_hours == -1) also tags
|
||||||
|
``permanent=True`` so the UI can render a "🔒 Permanent"
|
||||||
|
badge distinct from the time-limited countdown.
|
||||||
Returns True when the check should NOT contribute to the
|
Returns True when the check should NOT contribute to the
|
||||||
aggregate status."""
|
aggregate status."""
|
||||||
if not isinstance(check_dict, dict):
|
if not isinstance(check_dict, dict):
|
||||||
@@ -804,6 +807,8 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
if health_persistence.is_error_acknowledged(ek):
|
if health_persistence.is_error_acknowledged(ek):
|
||||||
check_dict['dismissed'] = True
|
check_dict['dismissed'] = True
|
||||||
|
if health_persistence.is_error_permanently_acknowledged(ek):
|
||||||
|
check_dict['permanent'] = True
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -794,17 +794,26 @@ class HealthPersistence:
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def acknowledge_error(self, error_key: str) -> Dict[str, Any]:
|
def acknowledge_error(self, error_key: str, suppression_hours: Optional[int] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Manually acknowledge an error (dismiss).
|
Manually acknowledge an error (dismiss).
|
||||||
- Looks up the category's configured suppression duration from user settings
|
|
||||||
- Stores suppression_hours on the error record (snapshot at dismiss time)
|
Args:
|
||||||
|
error_key: the unique key of the error to dismiss.
|
||||||
|
suppression_hours: optional override for the dismiss duration.
|
||||||
|
- ``None`` (default): use the category's configured value (current behavior).
|
||||||
|
- positive integer: silence for that many hours.
|
||||||
|
- ``-1``: silence permanently — the user must re-enable from
|
||||||
|
Settings → Active Suppressions to bring the alert back.
|
||||||
|
|
||||||
|
- Stores ``suppression_hours`` on the error record (snapshot at dismiss time).
|
||||||
- Marks as acknowledged so it won't re-appear during the suppression period
|
- Marks as acknowledged so it won't re-appear during the suppression period
|
||||||
|
(or ever, when ``suppression_hours == -1``).
|
||||||
"""
|
"""
|
||||||
with self._db_lock:
|
with self._db_lock:
|
||||||
return self._acknowledge_error_impl(error_key)
|
return self._acknowledge_error_impl(error_key, suppression_hours_override=suppression_hours)
|
||||||
|
|
||||||
def _acknowledge_error_impl(self, error_key):
|
def _acknowledge_error_impl(self, error_key, suppression_hours_override: Optional[int] = None):
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
category = ''
|
category = ''
|
||||||
@@ -852,6 +861,11 @@ class HealthPersistence:
|
|||||||
sup_hours = int(stored)
|
sup_hours = int(stored)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
# Caller-supplied override (e.g. per-error "permanent" dismiss
|
||||||
|
# picked by the user from the Health Monitor popover) trumps
|
||||||
|
# the category default. ``-1`` means silence permanently.
|
||||||
|
if suppression_hours_override is not None:
|
||||||
|
sup_hours = suppression_hours_override
|
||||||
|
|
||||||
# Insert as acknowledged but NOT resolved - error remains active
|
# Insert as acknowledged but NOT resolved - error remains active
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@@ -892,6 +906,11 @@ class HealthPersistence:
|
|||||||
sup_hours = int(stored)
|
sup_hours = int(stored)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
# Per-error override (e.g. user selected "Permanent" / "7 days"
|
||||||
|
# in the dismiss popover) takes precedence over the category
|
||||||
|
# default.
|
||||||
|
if suppression_hours_override is not None:
|
||||||
|
sup_hours = suppression_hours_override
|
||||||
|
|
||||||
# Mark as acknowledged but DO NOT set resolved_at
|
# Mark as acknowledged but DO NOT set resolved_at
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@@ -947,6 +966,61 @@ class HealthPersistence:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def unacknowledge_error(self, error_key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Reverse a previous dismiss (acknowledged → not acknowledged).
|
||||||
|
|
||||||
|
Used by the Settings → Active Suppressions panel: the user explicitly
|
||||||
|
re-enables an alert they had silenced (time-limited or permanent).
|
||||||
|
After this call the error becomes eligible to re-emit on the next
|
||||||
|
scan if the underlying condition is still present.
|
||||||
|
"""
|
||||||
|
with self._db_lock:
|
||||||
|
conn = self._get_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT category, severity, acknowledged FROM errors WHERE error_key = ?',
|
||||||
|
(error_key,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {'success': False, 'error': 'not_found', 'error_key': error_key}
|
||||||
|
category = row[0] or ''
|
||||||
|
severity = row[1] or 'WARNING'
|
||||||
|
was_acknowledged = bool(row[2])
|
||||||
|
if not was_acknowledged:
|
||||||
|
# Nothing to do — keep the call idempotent.
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'error_key': error_key,
|
||||||
|
'category': category,
|
||||||
|
'changed': False,
|
||||||
|
}
|
||||||
|
# Clear acknowledgment + stored suppression. The next health
|
||||||
|
# scan will decide whether to re-record the error based on the
|
||||||
|
# actual condition.
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE errors
|
||||||
|
SET acknowledged = 0, acknowledged_at = NULL, suppression_hours = NULL,
|
||||||
|
last_seen = ?
|
||||||
|
WHERE error_key = ?
|
||||||
|
''', (now, error_key))
|
||||||
|
self._record_event(cursor, 'unacknowledged', error_key, {
|
||||||
|
'category': category,
|
||||||
|
'severity': severity,
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'error_key': error_key,
|
||||||
|
'category': category,
|
||||||
|
'changed': True,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def is_error_acknowledged(self, error_key: str) -> bool:
|
def is_error_acknowledged(self, error_key: str) -> bool:
|
||||||
"""Check if an error_key has been acknowledged and is still within suppression window.
|
"""Check if an error_key has been acknowledged and is still within suppression window.
|
||||||
|
|
||||||
@@ -983,6 +1057,24 @@ class HealthPersistence:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_error_permanently_acknowledged(self, error_key: str) -> bool:
|
||||||
|
"""True only when the error is currently dismissed with
|
||||||
|
``suppression_hours == -1``. Used by the health monitor to surface a
|
||||||
|
"🔒 Permanent" badge in the UI vs. the regular time-limited dismiss."""
|
||||||
|
try:
|
||||||
|
with self._db_connection(row_factory=True) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT acknowledged, suppression_hours FROM errors WHERE error_key = ?',
|
||||||
|
(error_key,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or not row['acknowledged']:
|
||||||
|
return False
|
||||||
|
return (row['suppression_hours'] or 0) == -1
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Get all active (unresolved AND not acknowledged) errors, optionally filtered by category.
|
"""Get all active (unresolved AND not acknowledged) errors, optionally filtered by category.
|
||||||
|
|
||||||
|
|||||||
@@ -337,20 +337,36 @@ def _list_running_lxcs() -> list[dict[str, str]]:
|
|||||||
if not vmid:
|
if not vmid:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# v1.2.1.4 perf audit: previously this called `lxc-info -n <vmid> -p`
|
||||||
|
# for every running CT on every scan tick. With N CTs that's N
|
||||||
|
# subprocesses per cycle (lxc-info forks + execs + parses its own
|
||||||
|
# config to give us a single number we can read directly). The CT's
|
||||||
|
# init PID is the first child of the supervising lxc-start process
|
||||||
|
# we just identified — readable from /proc with zero subprocess
|
||||||
|
# cost.
|
||||||
pid = ''
|
pid = ''
|
||||||
try:
|
try:
|
||||||
p2 = subprocess.run(
|
with open(f'/proc/{entry.name}/task/{entry.name}/children', 'r') as f:
|
||||||
['lxc-info', '-n', vmid, '-p'],
|
children = f.read().split()
|
||||||
capture_output=True, text=True, timeout=2,
|
if children:
|
||||||
)
|
pid = children[0]
|
||||||
if p2.returncode == 0:
|
except (OSError, IOError):
|
||||||
for ln in p2.stdout.splitlines():
|
# Fallback to lxc-info only if the /proc read failed — keeps
|
||||||
# lxc-info output: "PID: 12345"
|
# behaviour identical for any edge case where the children
|
||||||
if ln.strip().lower().startswith('pid:'):
|
# file is unreadable (race with CT stop, kernel without
|
||||||
pid = ln.split(':', 1)[1].strip()
|
# CONFIG_PROC_CHILDREN, etc.).
|
||||||
break
|
try:
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
p2 = subprocess.run(
|
||||||
pass
|
['lxc-info', '-n', vmid, '-p'],
|
||||||
|
capture_output=True, text=True, timeout=2,
|
||||||
|
)
|
||||||
|
if p2.returncode == 0:
|
||||||
|
for ln in p2.stdout.splitlines():
|
||||||
|
if ln.strip().lower().startswith('pid:'):
|
||||||
|
pid = ln.split(':', 1)[1].strip()
|
||||||
|
break
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
out.append({'vmid': vmid, 'name': _read_lxc_name(vmid), 'pid': pid})
|
out.append({'vmid': vmid, 'name': _read_lxc_name(vmid), 'pid': pid})
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.1.3
|
1.2.1.4
|
||||||
|
|||||||
Reference in New Issue
Block a user