mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-04 23:06:23 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
aa78161adc4e3c39ab9c20bdf94ca646745894e3ad09ea188e30997233d92f87 ProxMenux-1.2.2.2-beta.AppImage
|
5dd5ce284a6d83250dec808b5d07fe14cd0b0ac71769a113f6f355e57c0a9882 ProxMenux-1.2.2.2-beta.AppImage
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
|||||||
import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery, Usb, BrainCircuit, AlertCircle } from "lucide-react"
|
import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery, Usb, BrainCircuit, AlertCircle } from "lucide-react"
|
||||||
import { Download } from "lucide-react"
|
import { Download } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { getDiskType } from "../lib/disk-type"
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
@@ -2460,25 +2461,10 @@ return (
|
|||||||
)
|
)
|
||||||
.map((device, index) => {
|
.map((device, index) => {
|
||||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
||||||
let diskType = "HDD"
|
// Classifier lives in lib/disk-type.ts — same rules
|
||||||
|
// the Storage page uses, so a drive can't show up as
|
||||||
// Check if it's NVMe
|
// HDD here while showing as SSD there.
|
||||||
if (diskName.startsWith("nvme")) {
|
const diskType = getDiskType(diskName, rotationRate)
|
||||||
diskType = "NVMe"
|
|
||||||
}
|
|
||||||
// Check rotation rate for SSD vs HDD
|
|
||||||
else if (rotationRate !== undefined && rotationRate !== null) {
|
|
||||||
// Handle both number and string formats
|
|
||||||
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
|
|
||||||
if (rateNum === 0 || isNaN(rateNum)) {
|
|
||||||
diskType = "SSD"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If rotation_rate is "Solid State Device" string
|
|
||||||
else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
|
|
||||||
diskType = "SSD"
|
|
||||||
}
|
|
||||||
|
|
||||||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||||||
NVMe: {
|
NVMe: {
|
||||||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||||
@@ -2600,38 +2586,22 @@ return (
|
|||||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Type</span>
|
<span className="text-sm font-medium text-muted-foreground">Type</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate)
|
||||||
let diskType = "HDD"
|
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||||||
|
NVMe: {
|
||||||
if (diskName.startsWith("nvme")) {
|
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||||
diskType = "NVMe"
|
label: "NVMe SSD",
|
||||||
} else if (rotationRate !== undefined && rotationRate !== null) {
|
},
|
||||||
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
|
SSD: {
|
||||||
if (rateNum === 0 || isNaN(rateNum)) {
|
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||||||
diskType = "SSD"
|
label: "SSD",
|
||||||
}
|
},
|
||||||
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
|
HDD: {
|
||||||
diskType = "SSD"
|
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||||
}
|
label: "HDD",
|
||||||
|
},
|
||||||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
|
||||||
NVMe: {
|
|
||||||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
|
||||||
label: "NVMe SSD",
|
|
||||||
},
|
|
||||||
SSD: {
|
|
||||||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
|
||||||
label: "SSD",
|
|
||||||
},
|
|
||||||
HDD: {
|
|
||||||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
|
||||||
label: "HDD",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return badgeStyles[diskType]
|
|
||||||
}
|
}
|
||||||
|
const diskBadge = badgeStyles[diskType]
|
||||||
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
|
|
||||||
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
|
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage, formatBytes } from "../lib/utils"
|
||||||
|
|
||||||
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
|
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
|
||||||
|
|
||||||
@@ -316,18 +316,6 @@ const methodBadgeCls = (m: string | undefined): string => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatStorage() in lib/utils.ts expects GIGABYTES as input — it's
|
|
||||||
// the storage layer's native unit. For raw log file sizes coming from
|
|
||||||
// os.path.getsize() we need a byte-aware formatter; passing N bytes to
|
|
||||||
// formatStorage() rendered "442 GB" for a 442-byte log. Tiny inline.
|
|
||||||
const formatBytes = (n: number): string => {
|
|
||||||
if (!Number.isFinite(n) || n < 0) return "—"
|
|
||||||
if (n < 1024) return `${n} B`
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
||||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
||||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatRunAt = (iso: string | null) => {
|
const formatRunAt = (iso: string | null) => {
|
||||||
if (!iso) return null
|
if (!iso) return null
|
||||||
try {
|
try {
|
||||||
@@ -509,6 +497,26 @@ export function HostBackup() {
|
|||||||
<Lock className="h-3.5 w-3.5" />
|
<Lock className="h-3.5 w-3.5" />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{/* Surface the profile mode at row level so the
|
||||||
|
operator can tell at a glance whether the
|
||||||
|
job ships the default path set or a custom
|
||||||
|
list — without having to open the detail
|
||||||
|
modal. Same wording the modal uses. */}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] uppercase tracking-wide ${
|
||||||
|
j.profile_mode === "custom"
|
||||||
|
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
||||||
|
: "border-border text-muted-foreground bg-background/40"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
j.profile_mode === "custom"
|
||||||
|
? "Custom path list — only the paths the operator picked"
|
||||||
|
: "Default path list — ProxMenux's recommended host config set"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{j.profile_mode === "custom" ? "custom" : "default"}
|
||||||
|
</Badge>
|
||||||
{!j.enabled && !j.manual && (
|
{!j.enabled && !j.manual && (
|
||||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
||||||
disabled
|
disabled
|
||||||
@@ -656,7 +664,7 @@ export function HostBackup() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-[11px] text-muted-foreground mb-3">
|
<p className="text-[11px] text-muted-foreground mb-3">
|
||||||
All backups visible from this host — local <code className="font-mono">.tar.zst</code> files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS snapshots from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS snapshots are extracted on-demand only when you request them.
|
All backups visible from this host — local <code className="font-mono">.tar.zst</code> files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them.
|
||||||
</p>
|
</p>
|
||||||
{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
|
{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
|
||||||
<div className="text-[11px] text-amber-500 mb-3 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/5 space-y-1">
|
<div className="text-[11px] text-amber-500 mb-3 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/5 space-y-1">
|
||||||
@@ -677,7 +685,7 @@ export function HostBackup() {
|
|||||||
</div>
|
</div>
|
||||||
) : unifiedArchives.length === 0 ? (
|
) : unifiedArchives.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="text-sm text-muted-foreground py-4">
|
||||||
No backups found yet. Use <span className="font-medium">Run manual backup</span> above, configure a scheduled job, or check that the configured PBS / Borg destinations have snapshots.
|
No backups found yet. Use <span className="font-medium">Run manual backup</span> above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -7205,14 +7213,15 @@ function RollbackPlanView({ plan }: { plan: RollbackPlan }) {
|
|||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
)
|
)
|
||||||
// Row uses flex-wrap + min-w-0 + gap so labels and pills flow to
|
// Each pill is a direct flex child of the row (no inner wrapper)
|
||||||
// a new line on narrow screens instead of pushing the card wider.
|
// so flex-wrap can break a long list of IDs mid-row on mobile.
|
||||||
|
// The previous nested `<div className="flex flex-wrap">` wouldn't
|
||||||
|
// wrap because its width was capped by the parent's flex layout,
|
||||||
|
// and the whole block would overflow the modal instead.
|
||||||
const Row = ({ label, items }: { label: string; items: (string | number)[] }) => (
|
const Row = ({ label, items }: { label: string; items: (string | number)[] }) => (
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 min-w-0">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1.5">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
<div className="flex flex-wrap gap-1.5 min-w-0">
|
{items.map((id) => <Pill key={id}>{id}</Pill>)}
|
||||||
{items.map((id) => <Pill key={id}>{id}</Pill>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { Progress } from "@/components/ui/progress"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { formatStorage as sharedFormatStorage } from "../lib/utils"
|
||||||
import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal"
|
import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal"
|
||||||
import { DiskTemperatureCard } from "./disk-temperature-card"
|
import { DiskTemperatureCard } from "./disk-temperature-card"
|
||||||
|
import { getDiskType as resolveDiskType } from "../lib/disk-type"
|
||||||
import {
|
import {
|
||||||
useDiskTempThresholds,
|
useDiskTempThresholds,
|
||||||
loadDiskTempThresholds,
|
loadDiskTempThresholds,
|
||||||
@@ -146,17 +148,9 @@ interface RemoteMountsData {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatStorage = (sizeInGB: number): string => {
|
// Re-exported under the local name so the rest of this large file
|
||||||
if (sizeInGB < 1) {
|
// stays untouched. Single source of truth lives in lib/utils.ts.
|
||||||
// Less than 1 GB, show in MB
|
const formatStorage = sharedFormatStorage
|
||||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
|
||||||
} else if (sizeInGB > 999) {
|
|
||||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
|
||||||
} else {
|
|
||||||
// Between 1 and 999 GB, show in GB
|
|
||||||
return `${sizeInGB.toFixed(2)} GB`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate the short ATA/SCSI error codes that appear inside `{ ... }`
|
// Translate the short ATA/SCSI error codes that appear inside `{ ... }`
|
||||||
// in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line
|
// in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line
|
||||||
@@ -624,21 +618,12 @@ export function StorageOverview() {
|
|||||||
return `${rpm.toLocaleString()} RPM`
|
return `${rpm.toLocaleString()} RPM`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDiskType = (diskName: string, rotationRate: number | undefined): string => {
|
// Thin wrapper over the shared classifier so the rest of the file
|
||||||
if (diskName.startsWith("nvme")) {
|
// doesn't need to be touched. The actual rules live in
|
||||||
return "NVMe"
|
// lib/disk-type.ts (single source of truth across Storage page,
|
||||||
}
|
// Hardware page, and any future consumer).
|
||||||
// rotation_rate = -1 means HDD but RPM is unknown (detected via kernel rotational flag)
|
const getDiskType = (diskName: string, rotationRate: number | undefined): string =>
|
||||||
// rotation_rate = 0 or undefined means SSD
|
resolveDiskType(diskName, rotationRate)
|
||||||
// rotation_rate > 0 means HDD with known RPM
|
|
||||||
if (rotationRate === -1) {
|
|
||||||
return "HDD"
|
|
||||||
}
|
|
||||||
if (!rotationRate || rotationRate === 0) {
|
|
||||||
return "SSD"
|
|
||||||
}
|
|
||||||
return "HDD"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => {
|
const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => {
|
||||||
const diskType = getDiskType(diskName, rotationRate)
|
const diskType = getDiskType(diskName, rotationRate)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Shared classifier for physical-disk type. Lives here because the
|
||||||
|
// Storage page and the Hardware page used to ship their own copies
|
||||||
|
// and silently drifted — old SSDs (e.g. OCZ-SOLID2) that don't expose
|
||||||
|
// a SMART rotation rate fell through Hardware's HDD-as-default branch
|
||||||
|
// and got mislabelled, while the Storage page got it right.
|
||||||
|
//
|
||||||
|
// Backend convention for `rotation_rate`:
|
||||||
|
// undefined / null / 0 → SSD (no platters reported)
|
||||||
|
// -1 → HDD detected via /sys rotational flag,
|
||||||
|
// but the drive doesn't expose RPM
|
||||||
|
// > 0 → HDD with known RPM
|
||||||
|
// string "Solid State" → SSD (smartctl wording on a few vendors)
|
||||||
|
|
||||||
|
export type DiskType = "NVMe" | "SSD" | "HDD"
|
||||||
|
|
||||||
|
export function getDiskType(
|
||||||
|
diskName: string,
|
||||||
|
rotationRate: number | string | null | undefined,
|
||||||
|
): DiskType {
|
||||||
|
if (diskName.startsWith("nvme")) return "NVMe"
|
||||||
|
if (rotationRate === -1) return "HDD"
|
||||||
|
if (typeof rotationRate === "string") {
|
||||||
|
if (rotationRate.includes("Solid State")) return "SSD"
|
||||||
|
const parsed = Number.parseInt(rotationRate, 10)
|
||||||
|
if (Number.isNaN(parsed) || parsed === 0) return "SSD"
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
|
if (rotationRate == null || rotationRate === 0) return "SSD"
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
@@ -19,3 +19,20 @@ export function formatStorage(sizeInGB: number): string {
|
|||||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Byte-aware formatter. Scales B → KB → MB → GB → TB. Use when the
|
||||||
|
// raw value comes in bytes (log file sizes from os.path.getsize(),
|
||||||
|
// PBS / Borg datastore capacity reported by the backend in bytes).
|
||||||
|
// The Backups page used to ship its own copy that capped at GB, so a
|
||||||
|
// 7 TB datastore showed up as "7311.55 GB" — extracted here so it
|
||||||
|
// can't drift again.
|
||||||
|
export function formatBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "—"
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
if (n < 1024 * 1024 * 1024 * 1024) {
|
||||||
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`
|
||||||
|
}
|
||||||
|
|||||||
@@ -597,22 +597,50 @@ class JournalWatcher:
|
|||||||
if self._running:
|
if self._running:
|
||||||
time.sleep(5) # Wait before restart
|
time.sleep(5) # Wait before restart
|
||||||
|
|
||||||
|
# Discard a saved cursor older than this many seconds — resuming
|
||||||
|
# from a stale cursor replays every event journald has produced
|
||||||
|
# since the file was last touched, which surfaces as duplicate
|
||||||
|
# notifications. 15 min comfortably covers graceful shutdowns,
|
||||||
|
# cron-restart loops and brief outages while keeping a multi-day
|
||||||
|
# downtime from spamming the operator. Reported on RimegraVE
|
||||||
|
# (.1.10): the service was last stopped 11 days before a restart;
|
||||||
|
# on respawn the JournalWatcher fired 16 backup_start notifs in
|
||||||
|
# 40 min for vzdump runs that PVE had logged hours-to-days
|
||||||
|
# earlier. source=journal in notification_history proved the
|
||||||
|
# event chain came from this replay path.
|
||||||
|
_CURSOR_STALE_AFTER_SEC = 15 * 60
|
||||||
|
|
||||||
def _run_journalctl(self):
|
def _run_journalctl(self):
|
||||||
"""Run journalctl -f and process output line by line."""
|
"""Run journalctl -f and process output line by line.
|
||||||
# Persist the cursor across watcher restarts so we don't lose events
|
|
||||||
# in the 5s gap between subprocess crash and respawn. journalctl
|
The cursor file lets us pick up after a short watcher crash
|
||||||
# writes the file with the latest seen cursor and on next start
|
without dropping events, but we drop it before invoking
|
||||||
# resumes from there. Falls back to -n 0 (start from now) only on
|
journalctl if it's older than _CURSOR_STALE_AFTER_SEC.
|
||||||
# the very first run when the cursor file doesn't exist yet.
|
That forces journalctl back to `-n 0` (start from now)
|
||||||
|
instead of replaying days of history as if it were live.
|
||||||
|
"""
|
||||||
cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt'
|
cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt'
|
||||||
try:
|
try:
|
||||||
Path(cursor_file).parent.mkdir(parents=True, exist_ok=True)
|
Path(cursor_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
cursor_path = Path(cursor_file)
|
||||||
|
if cursor_path.exists():
|
||||||
|
try:
|
||||||
|
age = time.time() - cursor_path.stat().st_mtime
|
||||||
|
if age > self._CURSOR_STALE_AFTER_SEC:
|
||||||
|
cursor_path.unlink(missing_ok=True)
|
||||||
|
print(
|
||||||
|
f"[JournalWatcher] Cursor stale "
|
||||||
|
f"({int(age)}s old) — restarting from now to "
|
||||||
|
f"avoid replaying historical events"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
cmd = ['journalctl', '-f', '-o', 'json', '--no-pager',
|
cmd = ['journalctl', '-f', '-o', 'json', '--no-pager',
|
||||||
f'--cursor-file={cursor_file}']
|
f'--cursor-file={cursor_file}']
|
||||||
if not Path(cursor_file).exists():
|
if not cursor_path.exists():
|
||||||
cmd.extend(['-n', '0']) # First run: don't replay history
|
cmd.extend(['-n', '0']) # First run (or stale): don't replay history
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ journalctl -u proxmenux-monitor -n 50
|
|||||||
|
|
||||||
## 🔧 Dependencies
|
## 🔧 Dependencies
|
||||||
|
|
||||||
The following dependencies are installed automatically during setup:
|
The following Debian packages are installed automatically during setup:
|
||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -146,12 +146,9 @@ The following dependencies are installed automatically during setup:
|
|||||||
| `curl` | Downloads and connectivity checks |
|
| `curl` | Downloads and connectivity checks |
|
||||||
| `jq` | JSON processing |
|
| `jq` | JSON processing |
|
||||||
| `git` | Repository cloning and updates |
|
| `git` | Repository cloning and updates |
|
||||||
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
|
| `python3` + `python3-pip` | ProxMenux Monitor (Flask web dashboard) |
|
||||||
| `googletrans` | Google Translate library *(Translation version only)* |
|
|
||||||
|
|
||||||
> **🛡️ Security Note / VirusTotal False Positive**
|
UI translations ship as pre-built JSON files per language (English, Spanish, French, German, Italian, Portuguese). There is no runtime translation dependency.
|
||||||
>
|
|
||||||
> If you scan the raw installation URL on VirusTotal, you might see a 1/95 detection by heuristic engines like *Chong Lua Dao*. This is a **known false positive**. Because this script uses the standard `curl | bash` installation pattern and downloads legitimate binaries (like `jq` from its official GitHub release), overly aggressive scanners flag the *behavior*. The script is 100% open source and safe to review. You can read more about this in [Issue #162](https://github.com/MacRimi/ProxMenux/issues/162).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user